1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
//! Issue #4 — damage (line + column span) + first-class scroll op.
//! Model: incremental bounds, ack-gated reset, recorded scroll op (ADR-0003).
use justerm_core::{Engine, TermDamage};
// ScrollOp is brought in per-test where needed.
/// Writing one glyph damages only its line, with a column span of just that cell.
#[test]
fn single_cell_change_damages_its_line_span() {
let mut term = Engine::new(10, 3);
term.reset_damage(); // clean baseline (the "last ack")
term.feed(b"x"); // one glyph at (0, 0)
match term.damage() {
TermDamage::Partial(lines) => {
assert_eq!(lines.len(), 1);
assert_eq!((lines[0].line, lines[0].left, lines[0].right), (0, 0, 0));
}
other => panic!("expected partial damage, got {other:?}"),
}
}
/// Erasing records damage over the cleared span.
#[test]
fn erase_damages_the_cleared_span() {
let mut term = Engine::new(10, 1);
term.feed(b"abcde");
term.reset_damage(); // baseline after the writes
term.feed(b"\x1b[1;3H\x1b[K"); // cursor to col 2, erase to end of line
match term.damage() {
TermDamage::Partial(lines) => {
assert_eq!(lines.len(), 1);
assert_eq!((lines[0].line, lines[0].left, lines[0].right), (0, 2, 9));
}
other => panic!("{other:?}"),
}
}
/// A pure cursor move puts the old *and* new cursor cells into the rendered
/// frame, even though no cell *content* changed — the consumer draws the caret
/// by cell-invert (beamterm has no cursor primitive), so without this the old
/// position keeps the inverted cell (a ghost) and the new one is never inverted.
/// Caret cells live in `frame()` (the render producer), not `damage()` (which
/// stays content-only for cadence); mirrors Alacritty's `last_cursor`. (#38)
#[test]
fn cursor_move_puts_old_and_new_cells_in_frame() {
let mut term = Engine::new(80, 24);
term.feed(b"abc"); // content on row 0; cursor now at (0, 3)
term.reset_damage(); // ack: consumer has seen the cursor at (0, 3)
term.feed(b"\x1b[10;20H"); // CUP to (9, 19); no glyph written
let covers = |spans: &[justerm_core::Span], line: u16, col: u16| {
spans
.iter()
.any(|s| s.line == line && s.left <= col && s.right >= col)
};
let f = term.frame();
assert!(
covers(&f.spans, 0, 3),
"old cursor cell (0,3) must be in the frame, got {:?}",
f.spans
);
assert!(
covers(&f.spans, 9, 19),
"new cursor cell (9,19) must be in the frame, got {:?}",
f.spans
);
// `damage()` stays content-only: a pure move records no content change.
assert!(
matches!(term.damage(), TermDamage::Partial(ref l) if l.is_empty()),
"damage() is content-only and must stay empty on a pure cursor move"
);
}
/// A scroll is a first-class op, not full-screen damage.
#[test]
fn scroll_emits_first_class_op_not_full_damage() {
let mut term = Engine::new(4, 2);
term.feed(b"a\r\nb"); // a → row 0, b → row 1
term.reset_damage();
term.feed(b"\r\nc"); // CR+LF at the bottom → scroll up; c on the new bottom
let op = term.scroll_delta().expect("expected a scroll op");
assert_eq!((op.top, op.bottom, op.count), (0, 1, 1)); // rows [0..=1] up by 1
assert!(!matches!(term.damage(), TermDamage::Full)); // not a full redraw
}
/// Switching to the alt screen replaces the whole screen → full damage.
#[test]
fn alt_screen_switch_is_full_damage() {
let mut term = Engine::new(4, 2);
term.feed(b"ab");
term.reset_damage();
term.feed(b"\x1b[?1049h"); // enter alt → entire screen swapped + cleared
assert!(matches!(term.damage(), TermDamage::Full));
}
/// Resize clears any pending scroll op — a stale op points at the old rows and
/// would be out of range against the resized screen.
#[test]
fn resize_clears_stale_scroll_op() {
let mut term = Engine::new(4, 5);
term.feed(b"a\r\nb\r\nc\r\nd\r\ne\r\nf"); // line-feeds at the bottom record a scroll op
assert!(term.scroll_delta().is_some());
term.resize(4, 3);
assert!(term.scroll_delta().is_none());
}
/// Reverse index at the top margin scrolls down → a negative-count scroll op.
#[test]
fn reverse_index_emits_down_scroll_op() {
let mut term = Engine::new(4, 2);
term.feed(b"\x1b[1;1H"); // cursor at the top margin
term.reset_damage();
term.feed(b"\x1bM"); // RI → scroll the region down
let op = term.scroll_delta().expect("expected a scroll op");
assert_eq!((op.top, op.bottom, op.count), (0, 1, -1));
}
/// The common path: print on the bottom row, then a line-feed scrolls it up.
/// Damage must follow the content to its new row (and the exposed bottom row is
/// new blank content), so the consumer redraws the right rows after the shift.
#[test]
fn write_then_scroll_realigns_damage_with_content() {
let mut term = Engine::new(4, 2);
term.feed(b"\x1b[2;1H"); // cursor to the bottom row
term.reset_damage();
term.feed(b"Z\n"); // write Z at the bottom, then LF scrolls it up
let lines = match term.damage() {
TermDamage::Partial(l) => l,
other => panic!("{other:?}"),
};
// Z ended up on row 0 after the scroll — its damage must point there.
assert!(
lines.iter().any(|d| d.line == 0),
"row 0 not damaged: {lines:?}"
);
// The newly exposed bottom row is new blank content → damaged too.
assert!(
lines.iter().any(|d| d.line == 1),
"row 1 not damaged: {lines:?}"
);
}
/// Several scrolls of the same region between acks accumulate into one op (flow
/// control: a slow consumer gets a single larger shift, never a pile-up).
#[test]
fn repeated_scroll_accumulates_count() {
let mut term = Engine::new(4, 3);
term.feed(b"a\r\nb\r\nc"); // fill 3 rows
term.reset_damage();
term.feed(b"\r\nd\r\ne"); // two line-feeds → two full-region scrolls
let op = term.scroll_delta().expect("scroll op");
assert_eq!((op.top, op.bottom, op.count), (0, 2, 2));
}
/// #661 — the *reported* count never exceeds the region's own height. Shifting a
/// region by more than its height moves every source row outside it, so the extra
/// magnitude carries nothing a consumer can act on — while it does overflow the
/// wire's `i16` and flip an up-scroll into a down-scroll. Both references that
/// state a quantity here clamp it the same way: alacritty
/// `term/mod.rs:773` (`cmp::min(lines, region height)`) and ghostty
/// `Terminal.zig:2703` (`@min(count, rem)`), SHAs pinned in theflow.md.
#[test]
fn reported_scroll_count_is_capped_at_the_region_height() {
let mut term = Engine::new(4, 3);
term.feed(b"a\r\nb\r\nc"); // fill 3 rows, cursor on the last one
term.reset_damage();
term.feed(&vec![b'\n'; 40_000]); // 39 998 full-region up-scrolls
let op = term.scroll_delta().expect("scroll op");
assert_eq!((op.top, op.bottom), (0, 2), "still one full-screen region");
assert_eq!(op.count, 3, "capped at the 3-row region's height");
}
/// The same cap downward — a reverse-index flood is the mirror, and the sign has
/// to survive it (the wire defect *was* a sign flip, so a cap that only guarded
/// the positive direction would look right in the test above and still ship the
/// bug).
#[test]
fn the_cap_applies_to_a_down_scroll_too() {
let mut term = Engine::new(4, 3);
term.feed(b"a\r\nb\r\nc");
term.reset_damage();
let mut down = b"\x1b[H".to_vec(); // home; RI at the top row scrolls down
down.extend(std::iter::repeat_n(*b"\x1bM", 40_000).flatten());
term.feed(&down);
let op = term.scroll_delta().expect("scroll op");
assert_eq!(op.count, -3, "capped at -height, not saturated to +height");
}
/// The cap is on what is *reported*, not on what is accumulated — so a region
/// that scrolls far up and then all the way back down reports the true small net,
/// not a saturated one. Clamping the accumulator instead would answer `-3` here:
/// it would have already forgotten 39 998 of the up-scrolls by the time the
/// down-scrolls arrived, and each subsequent one would walk the saturated value
/// down through zero.
#[test]
fn a_scroll_that_returns_to_its_start_reports_no_net_shift() {
let mut term = Engine::new(4, 3);
term.feed(b"a\r\nb\r\nc");
term.reset_damage();
term.feed(&vec![b'\n'; 40_000]); // +40 000 (the fill left the cursor on the last row)
let mut down = b"\x1b[H".to_vec();
down.extend(std::iter::repeat_n(*b"\x1bM", 40_000).flatten()); // -40 000
term.feed(&down);
let op = term.scroll_delta().expect("scroll op");
assert_eq!(op.count, 0, "the net is zero and small enough to say so");
}
/// Scrolls of *different* regions in one frame cannot be expressed as a single
/// op, so they degrade to full damage rather than silently dropping one.
#[test]
fn scrolls_of_different_regions_degrade_to_full() {
let mut term = Engine::new(4, 4);
term.feed(b"\x1b[4;1H"); // cursor to the bottom of the full screen
term.reset_damage();
term.feed(b"\n"); // scroll the full region [0..=3]
term.feed(b"\x1b[2;3r"); // DECSTBM region rows 2..3 → [1..=2] (homes cursor)
term.feed(b"\x1b[3;1H\n"); // cursor to the region bottom, scroll [1..=2]
assert!(matches!(term.damage(), TermDamage::Full));
}