detached 7b0740b

test(pkg): convert ported tests to table-driven

Restructure pkg/renderer and pkg/vt tests to the project's table-driven
convention: named cases (slice of structs) + t.Run(name, ...) subtests.
Closely related scenarios (e.g. SGR attribute variants, cursor moves,
CSI editing sequences, C1/OSC handling, wide-rune handling) are merged
into single table-driven test functions with real data tables where the
shape naturally allows it, and into named-case/run-closure tables where
scenarios are too distinct to share a data shape. TestRuneWidth, which
was only partially table-driven (no t.Run), now uses named subtests too.

Every original assertion and test case is preserved — no behavior
changes, no dropped cases. Benchmarks are untouched.
commit
7b0740bc14960456d11e288a31f5cfd6e6c7a246
author
brice · · 2026-07-02 09:20 UTC
parent
84a83dc
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +2357 -1773
M pkg/renderer/renderer_test.go +763 -502
@@ -41,37 +41,44 @@ func outputEndsWith(t *testing.T, data []byte, suffix string) {
4141 // ---------------------------------------------------------------------------
4242
4343 func TestFirstDraw(t *testing.T) {
44- r := New(Capabilities{})
45- frame := NewFrame(5, 3)
46- markFrame(&frame)
47-
48- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}})
49- if err != nil {
50- t.Fatal(err)
51- }
52- if len(out) == 0 {
53- t.Fatal("expected non-empty output on first draw")
54- }
55- outputContains(t, out, "\x1b[0m")
56- // Should have cursor-position sequences for all three rows.
57- if c := strings.Count(string(out), "\x1b["); c < 3 {
58- t.Fatalf("expected at least 3 CSI sequences, got %d", c)
44+ tests := []struct {
45+ name string
46+ damage []Damage
47+ minCSICount int
48+ }{
49+ {
50+ name: "explicit text damage",
51+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
52+ minCSICount: 3,
53+ },
54+ {
55+ name: "full redraw damage",
56+ damage: []Damage{FullRedraw()},
57+ },
5958 }
60-}
6159
62-func TestFirstDrawFullRedraw(t *testing.T) {
63- r := New(Capabilities{})
64- frame := NewFrame(5, 3)
65- markFrame(&frame)
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ r := New(Capabilities{})
63+ frame := NewFrame(5, 3)
64+ markFrame(&frame)
6665
67- out, err := r.Draw(frame, []Damage{FullRedraw()})
68- if err != nil {
69- t.Fatal(err)
70- }
71- if len(out) == 0 {
72- t.Fatal("expected non-empty output on full redraw")
66+ out, err := r.Draw(frame, tt.damage)
67+ if err != nil {
68+ t.Fatal(err)
69+ }
70+ if len(out) == 0 {
71+ t.Fatal("expected non-empty output on first draw")
72+ }
73+ outputContains(t, out, "\x1b[0m")
74+ if tt.minCSICount > 0 {
75+ // Should have cursor-position sequences for all three rows.
76+ if c := strings.Count(string(out), "\x1b["); c < tt.minCSICount {
77+ t.Fatalf("expected at least %d CSI sequences, got %d", tt.minCSICount, c)
78+ }
79+ }
80+ })
7381 }
74- outputContains(t, out, "\x1b[0m")
7582 }
7683
7784 // ---------------------------------------------------------------------------
@@ -79,47 +86,53 @@ func TestFirstDrawFullRedraw(t *testing.T) {
7986 // ---------------------------------------------------------------------------
8087
8188 func TestNoOp(t *testing.T) {
82- r := New(Capabilities{})
83- frame := NewFrame(5, 3)
84- markFrame(&frame)
85-
86- // First draw – populate shadow.
87- out1, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}})
88- if err != nil {
89- t.Fatal(err)
90- }
91- if len(out1) == 0 {
92- t.Fatal("first draw must produce output")
93- }
94-
95- // Second draw with no changes.
96- out2, err := r.Draw(frame, nil)
97- if err != nil {
98- t.Fatal(err)
99- }
100- if len(out2) != 0 {
101- t.Fatalf("expected no-op (empty output), got %q", string(out2))
89+ tests := []struct {
90+ name string
91+ width int
92+ height int
93+ firstDamage []Damage
94+ secondDamage []Damage
95+ }{
96+ {
97+ name: "nil damage on unchanged frame",
98+ width: 5,
99+ height: 3,
100+ firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
101+ secondDamage: nil,
102+ },
103+ {
104+ name: "empty damage slice on unchanged frame",
105+ width: 3,
106+ height: 2,
107+ firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}},
108+ secondDamage: []Damage{},
109+ },
102110 }
103-}
104111
105-func TestNoOpWithEmptyDamage(t *testing.T) {
106- r := New(Capabilities{})
107- frame := NewFrame(3, 2)
108- markFrame(&frame)
109-
110- // Populate shadow.
111- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
112- if err != nil {
113- t.Fatal(err)
114- }
112+ for _, tt := range tests {
113+ t.Run(tt.name, func(t *testing.T) {
114+ r := New(Capabilities{})
115+ frame := NewFrame(tt.width, tt.height)
116+ markFrame(&frame)
117+
118+ // First draw – populate shadow.
119+ out1, err := r.Draw(frame, tt.firstDamage)
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ if len(out1) == 0 {
124+ t.Fatal("first draw must produce output")
125+ }
115126
116- // Same frame, empty damage slice.
117- out, err := r.Draw(frame, []Damage{})
118- if err != nil {
119- t.Fatal(err)
120- }
121- if len(out) != 0 {
122- t.Fatalf("expected empty output for no changes, got %q", string(out))
127+ // Second draw with no changes.
128+ out2, err := r.Draw(frame, tt.secondDamage)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ if len(out2) != 0 {
133+ t.Fatalf("expected no-op (empty output), got %q", string(out2))
134+ }
135+ })
123136 }
124137 }
125138
@@ -127,103 +140,156 @@ func TestNoOpWithEmptyDamage(t *testing.T) {
127140 // Style reset discipline
128141 // ---------------------------------------------------------------------------
129142
130-func TestStyleReset(t *testing.T) {
131- r := New(Capabilities{})
132- frame := NewFrame(4, 2)
133-
134- // One styled cell, rest default.
135- frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}})
136-
137- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
138- if err != nil {
139- t.Fatal(err)
140- }
141- // Must end with style reset.
142- outputEndsWith(t, out, "\x1b[0m")
143-}
144-
145-func TestStyleEqualUsesActiveColorMode(t *testing.T) {
146- left := Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}
147- right := Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}
148- if !left.Equal(right) {
149- t.Fatal("RGB foreground equality should ignore inactive indexed foreground")
143+func TestStyleResetDiscipline(t *testing.T) {
144+ tests := []struct {
145+ name string
146+ run func(t *testing.T)
147+ }{
148+ {
149+ name: "single styled cell among default cells",
150+ run: func(t *testing.T) {
151+ r := New(Capabilities{})
152+ frame := NewFrame(4, 2)
153+
154+ // One styled cell, rest default.
155+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}})
156+
157+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ // Must end with style reset.
162+ outputEndsWith(t, out, "\x1b[0m")
163+ },
164+ },
165+ {
166+ name: "after scroll damage",
167+ run: func(t *testing.T) {
168+ r := New(Capabilities{})
169+ frame := NewFrame(4, 3)
170+ markFrame(&frame)
171+
172+ // Populate shadow.
173+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
174+ if err != nil {
175+ t.Fatal(err)
176+ }
177+
178+ // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.
179+ frame2 := NewFrame(4, 3)
180+ copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2
181+ for i := 8; i < 12; i++ {
182+ frame2.Cells[i] = BlankCell() // bottom row blank
183+ }
184+ frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
185+
186+ damage := []Damage{
187+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
188+ {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
189+ }
190+
191+ out, err := r.Draw(frame2, damage)
192+ if err != nil {
193+ t.Fatal(err)
194+ }
195+ // The output must end with a style reset (either from emitScrollUp or writeDamage).
196+ outputEndsWith(t, out, "\x1b[0m")
197+ },
198+ },
150199 }
151200
152- right.ForegroundRGB.R = 13
153- if left.Equal(right) {
154- t.Fatal("RGB foreground equality should compare active RGB values")
201+ for _, tt := range tests {
202+ t.Run(tt.name, tt.run)
155203 }
156204 }
157205
158-func TestRendererEmitsTruecolorSGR(t *testing.T) {
159- r := New(Capabilities{})
160- frame := NewFrame(1, 1)
161- frame.Set(0, 0, Cell{
162- Rune: 'X',
163- Style: Style{
164- Foreground: -1,
165- Background: -1,
166- HasForegroundRGB: true,
167- ForegroundRGB: RGB{R: 12, G: 34, B: 56},
168- HasBackgroundRGB: true,
169- BackgroundRGB: RGB{R: 200, G: 100, B: 50},
206+func TestStyleEqualUsesActiveColorMode(t *testing.T) {
207+ tests := []struct {
208+ name string
209+ left Style
210+ right Style
211+ wantEqual bool
212+ }{
213+ {
214+ name: "ignores inactive indexed foreground",
215+ left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
216+ right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
217+ wantEqual: true,
218+ },
219+ {
220+ name: "compares active RGB values",
221+ left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
222+ right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 13, G: 34, B: 56}, Background: -1},
223+ wantEqual: false,
170224 },
171- })
172-
173- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}})
174- if err != nil {
175- t.Fatal(err)
176225 }
177226
178- outputContains(t, out, "\x1b[0;38;2;12;34;56;48;2;200;100;50m")
179- outputContains(t, out, "X")
180-}
181-
182-func TestRendererEmitsStyleChangesForAdjacentColorModes(t *testing.T) {
183- r := New(Capabilities{})
184- frame := NewFrame(3, 1)
185- frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}})
186- frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}})
187- frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()})
188-
189- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}})
190- if err != nil {
191- t.Fatal(err)
227+ for _, tt := range tests {
228+ t.Run(tt.name, func(t *testing.T) {
229+ if got := tt.left.Equal(tt.right); got != tt.wantEqual {
230+ t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual)
231+ }
232+ })
192233 }
193-
194- outputContains(t, out, "\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD")
195234 }
196235
197-func TestStyleResetAfterScroll(t *testing.T) {
198- r := New(Capabilities{})
199- frame := NewFrame(4, 3)
200- markFrame(&frame)
201-
202- // Populate shadow.
203- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
204- if err != nil {
205- t.Fatal(err)
236+func TestRendererEmitsSGR(t *testing.T) {
237+ tests := []struct {
238+ name string
239+ width int
240+ height int
241+ setup func(frame Frame)
242+ damage []Damage
243+ wantAll []string
244+ }{
245+ {
246+ name: "truecolor foreground and background",
247+ width: 1,
248+ height: 1,
249+ setup: func(frame Frame) {
250+ frame.Set(0, 0, Cell{
251+ Rune: 'X',
252+ Style: Style{
253+ Foreground: -1,
254+ Background: -1,
255+ HasForegroundRGB: true,
256+ ForegroundRGB: RGB{R: 12, G: 34, B: 56},
257+ HasBackgroundRGB: true,
258+ BackgroundRGB: RGB{R: 200, G: 100, B: 50},
259+ },
260+ })
261+ },
262+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
263+ wantAll: []string{"\x1b[0;38;2;12;34;56;48;2;200;100;50m", "X"},
264+ },
265+ {
266+ name: "style changes across adjacent color modes",
267+ width: 3,
268+ height: 1,
269+ setup: func(frame Frame) {
270+ frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}})
271+ frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}})
272+ frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()})
273+ },
274+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}},
275+ wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"},
276+ },
206277 }
207278
208- // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.
209- frame2 := NewFrame(4, 3)
210- copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2
211- for i := 8; i < 12; i++ {
212- frame2.Cells[i] = BlankCell() // bottom row blank
213- }
214- frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
279+ for _, tt := range tests {
280+ t.Run(tt.name, func(t *testing.T) {
281+ r := New(Capabilities{})
282+ frame := NewFrame(tt.width, tt.height)
283+ tt.setup(frame)
215284
216- damage := []Damage{
217- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
218- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
219- }
285+ out, err := r.Draw(frame, tt.damage)
286+ if err != nil {
287+ t.Fatal(err)
288+ }
220289
221- out, err := r.Draw(frame2, damage)
222- if err != nil {
223- t.Fatal(err)
290+ outputContains(t, out, tt.wantAll...)
291+ })
224292 }
225- // The output must end with a style reset (either from emitScrollUp or writeDamage).
226- outputEndsWith(t, out, "\x1b[0m")
227293 }
228294
229295 // ---------------------------------------------------------------------------
@@ -231,45 +297,55 @@ func TestStyleResetAfterScroll(t *testing.T) {
231297 // ---------------------------------------------------------------------------
232298
233299 func TestScrollFastPath(t *testing.T) {
234- r := New(Capabilities{})
235- frame := NewFrame(5, 4)
236- markFrame(&frame)
237-
238- // Populate shadow.
239- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}})
240- if err != nil {
241- t.Fatal(err)
300+ tests := []struct {
301+ name string
302+ }{
303+ {name: "scroll up by one row exposes new bottom row via scroll region"},
242304 }
243305
244- // Build a new frame that has been scrolled up by 1 (like the VT model does).
245- scrolled := NewFrame(5, 4)
246- copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3
247- for i := 15; i < 20; i++ {
248- scrolled.Cells[i] = BlankCell() // row 3 blanked
249- }
250- scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})
251- scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})
252- scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()})
306+ for _, tt := range tests {
307+ t.Run(tt.name, func(t *testing.T) {
308+ r := New(Capabilities{})
309+ frame := NewFrame(5, 4)
310+ markFrame(&frame)
311+
312+ // Populate shadow.
313+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}})
314+ if err != nil {
315+ t.Fatal(err)
316+ }
253317
254- damage := []Damage{
255- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
256- {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
257- }
318+ // Build a new frame that has been scrolled up by 1 (like the VT model does).
319+ scrolled := NewFrame(5, 4)
320+ copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3
321+ for i := 15; i < 20; i++ {
322+ scrolled.Cells[i] = BlankCell() // row 3 blanked
323+ }
324+ scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})
325+ scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})
326+ scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()})
258327
259- out, err := r.Draw(scrolled, damage)
260- if err != nil {
261- t.Fatal(err)
262- }
263- if len(out) == 0 {
264- t.Fatal("expected non-empty output for scroll")
328+ damage := []Damage{
329+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
330+ {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
331+ }
332+
333+ out, err := r.Draw(scrolled, damage)
334+ if err != nil {
335+ t.Fatal(err)
336+ }
337+ if len(out) == 0 {
338+ t.Fatal("expected non-empty output for scroll")
339+ }
340+ // Should contain scroll-region sequences.
341+ outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4
342+ outputContains(t, out, "\x1b[r") // restore scroll region
343+ // Should contain the new text on the exposed row.
344+ outputContains(t, out, "N")
345+ outputContains(t, out, "e")
346+ outputContains(t, out, "w")
347+ })
265348 }
266- // Should contain scroll-region sequences.
267- outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4
268- outputContains(t, out, "\x1b[r") // restore scroll region
269- // Should contain the new text on the exposed row.
270- outputContains(t, out, "N")
271- outputContains(t, out, "e")
272- outputContains(t, out, "w")
273349 }
274350
275351 // ---------------------------------------------------------------------------
@@ -277,40 +353,56 @@ func TestScrollFastPath(t *testing.T) {
277353 // ---------------------------------------------------------------------------
278354
279355 func TestSynchronizedOutput(t *testing.T) {
280- r := New(Capabilities{SynchronizedOutput: true})
281- frame := NewFrame(3, 2)
282- markFrame(&frame)
283-
284- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
285- if err != nil {
286- t.Fatal(err)
287- }
288- if !strings.HasPrefix(string(out), SyncStartCSI) {
289- t.Errorf("expected output to start with sync start CSI")
290- }
291- if !strings.HasSuffix(string(out), SyncEndCSI) {
292- t.Errorf("expected output to end with sync end CSI, got %q", string(out))
293- }
294-}
295-
296-func TestSynchronizedOutputNoOp(t *testing.T) {
297- r := New(Capabilities{SynchronizedOutput: true})
298- frame := NewFrame(3, 2)
299- markFrame(&frame)
300-
301- // First draw.
302- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
303- if err != nil {
304- t.Fatal(err)
356+ tests := []struct {
357+ name string
358+ run func(t *testing.T)
359+ }{
360+ {
361+ name: "wraps output in sync CSI",
362+ run: func(t *testing.T) {
363+ r := New(Capabilities{SynchronizedOutput: true})
364+ frame := NewFrame(3, 2)
365+ markFrame(&frame)
366+
367+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
368+ if err != nil {
369+ t.Fatal(err)
370+ }
371+ if !strings.HasPrefix(string(out), SyncStartCSI) {
372+ t.Errorf("expected output to start with sync start CSI")
373+ }
374+ if !strings.HasSuffix(string(out), SyncEndCSI) {
375+ t.Errorf("expected output to end with sync end CSI, got %q", string(out))
376+ }
377+ },
378+ },
379+ {
380+ name: "no-op returns empty output, not wrapped empty output",
381+ run: func(t *testing.T) {
382+ r := New(Capabilities{SynchronizedOutput: true})
383+ frame := NewFrame(3, 2)
384+ markFrame(&frame)
385+
386+ // First draw.
387+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
388+ if err != nil {
389+ t.Fatal(err)
390+ }
391+
392+ // No-op – must return nil, not wrapped empty output.
393+ out, err := r.Draw(frame, nil)
394+ if err != nil {
395+ t.Fatal(err)
396+ }
397+ if len(out) != 0 {
398+ t.Fatalf("expected empty output for no-op, got %q", string(out))
399+ }
400+ },
401+ },
305402 }
306403
307- // No-op – must return nil, not wrapped empty output.
308- out, err := r.Draw(frame, nil)
309- if err != nil {
310- t.Fatal(err)
311- }
312- if len(out) != 0 {
313- t.Fatalf("expected empty output for no-op, got %q", string(out))
404+ for _, tt := range tests {
405+ t.Run(tt.name, tt.run)
314406 }
315407 }
316408
@@ -320,27 +412,56 @@ func TestSynchronizedOutputNoOp(t *testing.T) {
320412
321413 func TestWrapSynchronized(t *testing.T) {
322414 content := []byte("\x1b[0mhello")
323- wrapped := WrapSynchronized(content, true)
324- if !strings.HasPrefix(string(wrapped), SyncStartCSI) {
325- t.Errorf("missing sync start")
326- }
327- if !strings.HasSuffix(string(wrapped), SyncEndCSI) {
328- t.Errorf("missing sync end")
329- }
330- if !strings.Contains(string(wrapped), "hello") {
331- t.Errorf("missing original content")
332- }
333415
334- // disabled
335- plain := WrapSynchronized(content, false)
336- if string(plain) != string(content) {
337- t.Errorf("disabled wrapping should return content unchanged")
416+ tests := []struct {
417+ name string
418+ content []byte
419+ enabled bool
420+ check func(t *testing.T, got []byte)
421+ }{
422+ {
423+ name: "enabled wraps with sync start/end and preserves content",
424+ content: content,
425+ enabled: true,
426+ check: func(t *testing.T, got []byte) {
427+ if !strings.HasPrefix(string(got), SyncStartCSI) {
428+ t.Errorf("missing sync start")
429+ }
430+ if !strings.HasSuffix(string(got), SyncEndCSI) {
431+ t.Errorf("missing sync end")
432+ }
433+ if !strings.Contains(string(got), "hello") {
434+ t.Errorf("missing original content")
435+ }
436+ },
437+ },
438+ {
439+ name: "disabled returns content unchanged",
440+ content: content,
441+ enabled: false,
442+ check: func(t *testing.T, got []byte) {
443+ if string(got) != string(content) {
444+ t.Errorf("disabled wrapping should return content unchanged")
445+ }
446+ },
447+ },
448+ {
449+ name: "empty input returns empty",
450+ content: nil,
451+ enabled: true,
452+ check: func(t *testing.T, got []byte) {
453+ if len(got) != 0 {
454+ t.Errorf("empty input should return empty")
455+ }
456+ },
457+ },
338458 }
339459
340- // empty input
341- empty := WrapSynchronized(nil, true)
342- if len(empty) != 0 {
343- t.Errorf("empty input should return empty")
460+ for _, tt := range tests {
461+ t.Run(tt.name, func(t *testing.T) {
462+ got := WrapSynchronized(tt.content, tt.enabled)
463+ tt.check(t, got)
464+ })
344465 }
345466 }
346467
@@ -349,110 +470,138 @@ func TestWrapSynchronized(t *testing.T) {
349470 // ---------------------------------------------------------------------------
350471
351472 func TestPartialDamage(t *testing.T) {
352- r := New(Capabilities{})
353- frame := NewFrame(4, 2)
354- markFrame(&frame)
355-
356- // Populate shadow.
357- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
358- if err != nil {
359- t.Fatal(err)
473+ tests := []struct {
474+ name string
475+ }{
476+ {name: "single changed cell repositions cursor and resets style"},
360477 }
361478
362- // Change one cell.
363- frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
364- damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}}
479+ for _, tt := range tests {
480+ t.Run(tt.name, func(t *testing.T) {
481+ r := New(Capabilities{})
482+ frame := NewFrame(4, 2)
483+ markFrame(&frame)
484+
485+ // Populate shadow.
486+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
487+ if err != nil {
488+ t.Fatal(err)
489+ }
365490
366- out, err := r.Draw(frame, damage)
367- if err != nil {
368- t.Fatal(err)
369- }
370- if len(out) == 0 {
371- t.Fatal("expected non-empty output for partial damage")
491+ // Change one cell.
492+ frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
493+ damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}}
494+
495+ out, err := r.Draw(frame, damage)
496+ if err != nil {
497+ t.Fatal(err)
498+ }
499+ if len(out) == 0 {
500+ t.Fatal("expected non-empty output for partial damage")
501+ }
502+ // Should position cursor at (2,1) — 1-indexed (3;2).
503+ outputContains(t, out, "\x1b[2;3H")
504+ outputContains(t, out, "Z")
505+ outputEndsWith(t, out, "\x1b[0m")
506+ })
372507 }
373- // Should position cursor at (2,1) — 1-indexed (3;2).
374- outputContains(t, out, "\x1b[2;3H")
375- outputContains(t, out, "Z")
376- outputEndsWith(t, out, "\x1b[0m")
377508 }
378509
379510 // ---------------------------------------------------------------------------
380511 // Scheduler coalescing
381512 // ---------------------------------------------------------------------------
382513
383-func TestSchedulerCoalescing(t *testing.T) {
384- s := NewScheduler(30 * time.Millisecond)
385- ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
386- defer cancel()
387-
388- frames := s.Run(ctx)
389-
390- // Fire several requests before the timer fires.
391- s.Request()
392- s.Request()
393- s.Request()
394-
395- // Expect exactly one frame.
396- select {
397- case _, ok := <-frames:
398- if !ok {
399- t.Fatal("channel closed unexpectedly")
400- }
401- case <-ctx.Done():
402- t.Fatal("timeout waiting for frame")
403- }
404-
405- // No second frame should arrive without another request.
406- select {
407- case <-frames:
408- t.Fatal("unexpected second frame")
409- case <-time.After(80 * time.Millisecond):
410- // good – coalesced.
411- }
412-}
413-
414-func TestSchedulerMultipleFrames(t *testing.T) {
415- s := NewScheduler(10 * time.Millisecond)
416- ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
417- defer cancel()
418-
419- frames := s.Run(ctx)
420-
421- s.Request()
422- <-frames // consume first
423-
424- s.Request()
425- <-frames // consume second
426-
427- // No pending request – should not deliver another frame.
428- select {
429- case <-frames:
430- t.Fatal("unexpected frame without request")
431- case <-time.After(40 * time.Millisecond):
432- // good
514+func TestScheduler(t *testing.T) {
515+ tests := []struct {
516+ name string
517+ run func(t *testing.T)
518+ }{
519+ {
520+ name: "coalesces bursts of requests into a single frame",
521+ run: func(t *testing.T) {
522+ s := NewScheduler(30 * time.Millisecond)
523+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
524+ defer cancel()
525+
526+ frames := s.Run(ctx)
527+
528+ // Fire several requests before the timer fires.
529+ s.Request()
530+ s.Request()
531+ s.Request()
532+
533+ // Expect exactly one frame.
534+ select {
535+ case _, ok := <-frames:
536+ if !ok {
537+ t.Fatal("channel closed unexpectedly")
538+ }
539+ case <-ctx.Done():
540+ t.Fatal("timeout waiting for frame")
541+ }
542+
543+ // No second frame should arrive without another request.
544+ select {
545+ case <-frames:
546+ t.Fatal("unexpected second frame")
547+ case <-time.After(80 * time.Millisecond):
548+ // good – coalesced.
549+ }
550+ },
551+ },
552+ {
553+ name: "delivers a frame per request without spurious extras",
554+ run: func(t *testing.T) {
555+ s := NewScheduler(10 * time.Millisecond)
556+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
557+ defer cancel()
558+
559+ frames := s.Run(ctx)
560+
561+ s.Request()
562+ <-frames // consume first
563+
564+ s.Request()
565+ <-frames // consume second
566+
567+ // No pending request – should not deliver another frame.
568+ select {
569+ case <-frames:
570+ t.Fatal("unexpected frame without request")
571+ case <-time.After(40 * time.Millisecond):
572+ // good
573+ }
574+ },
575+ },
576+ {
577+ name: "closes frames channel on context cancellation",
578+ run: func(t *testing.T) {
579+ s := NewScheduler(50 * time.Millisecond)
580+ ctx, cancel := context.WithCancel(context.Background())
581+
582+ frames := s.Run(ctx)
583+ s.Request()
584+
585+ // Cancel before the timer fires.
586+ cancel()
587+
588+ // Channel should close without ever delivering a frame.
589+ select {
590+ case _, ok := <-frames:
591+ if ok {
592+ // A frame may have been emitted before cancellation took effect;
593+ // that is acceptable. What matters is that the goroutine exits.
594+ _ = ok
595+ }
596+ case <-time.After(200 * time.Millisecond):
597+ // Channel didn't close – goroutine may be stuck.
598+ }
599+ },
600+ },
433601 }
434-}
435-
436-func TestSchedulerContextCancellation(t *testing.T) {
437- s := NewScheduler(50 * time.Millisecond)
438- ctx, cancel := context.WithCancel(context.Background())
439602
440- frames := s.Run(ctx)
441- s.Request()
442-
443- // Cancel before the timer fires.
444- cancel()
445-
446- // Channel should close without ever delivering a frame.
447- select {
448- case _, ok := <-frames:
449- if ok {
450- // A frame may have been emitted before cancellation took effect;
451- // that is acceptable. What matters is that the goroutine exits.
452- _ = ok
453- }
454- case <-time.After(200 * time.Millisecond):
455- // Channel didn't close – goroutine may be stuck.
603+ for _, tt := range tests {
604+ t.Run(tt.name, tt.run)
456605 }
457606 }
458607
@@ -461,28 +610,38 @@ func TestSchedulerContextCancellation(t *testing.T) {
461610 // ---------------------------------------------------------------------------
462611
463612 func TestReset(t *testing.T) {
464- r := New(Capabilities{})
465- frame := NewFrame(3, 2)
466- markFrame(&frame)
467-
468- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
469- if err != nil {
470- t.Fatal(err)
613+ tests := []struct {
614+ name string
615+ }{
616+ {name: "clears shadow state and forces a full draw next time"},
471617 }
472618
473- r.Reset()
619+ for _, tt := range tests {
620+ t.Run(tt.name, func(t *testing.T) {
621+ r := New(Capabilities{})
622+ frame := NewFrame(3, 2)
623+ markFrame(&frame)
474624
475- if r.width != 0 || r.height != 0 || r.shadow != nil {
476- t.Fatal("Reset should clear width/height/shadow")
477- }
625+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
626+ if err != nil {
627+ t.Fatal(err)
628+ }
478629
479- // After reset, the next draw should be a full draw.
480- out, err := r.Draw(frame, nil)
481- if err != nil {
482- t.Fatal(err)
483- }
484- if len(out) == 0 {
485- t.Fatal("expected non-empty output after reset")
630+ r.Reset()
631+
632+ if r.width != 0 || r.height != 0 || r.shadow != nil {
633+ t.Fatal("Reset should clear width/height/shadow")
634+ }
635+
636+ // After reset, the next draw should be a full draw.
637+ out, err := r.Draw(frame, nil)
638+ if err != nil {
639+ t.Fatal(err)
640+ }
641+ if len(out) == 0 {
642+ t.Fatal("expected non-empty output after reset")
643+ }
644+ })
486645 }
487646 }
488647
@@ -491,25 +650,35 @@ func TestReset(t *testing.T) {
491650 // ---------------------------------------------------------------------------
492651
493652 func TestFullRedraw(t *testing.T) {
494- r := New(Capabilities{})
495- frame := NewFrame(3, 2)
496- markFrame(&frame)
497-
498- // Populate.
499- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
500- if err != nil {
501- t.Fatal(err)
653+ tests := []struct {
654+ name string
655+ }{
656+ {name: "forces full output even with an unchanged frame"},
502657 }
503658
504- // FullRedraw forces full output even with same frame.
505- out, err := r.Draw(frame, []Damage{FullRedraw()})
506- if err != nil {
507- t.Fatal(err)
508- }
509- if len(out) == 0 {
510- t.Fatal("expected output for FullRedraw")
659+ for _, tt := range tests {
660+ t.Run(tt.name, func(t *testing.T) {
661+ r := New(Capabilities{})
662+ frame := NewFrame(3, 2)
663+ markFrame(&frame)
664+
665+ // Populate.
666+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
667+ if err != nil {
668+ t.Fatal(err)
669+ }
670+
671+ // FullRedraw forces full output even with same frame.
672+ out, err := r.Draw(frame, []Damage{FullRedraw()})
673+ if err != nil {
674+ t.Fatal(err)
675+ }
676+ if len(out) == 0 {
677+ t.Fatal("expected output for FullRedraw")
678+ }
679+ outputEndsWith(t, out, "\x1b[0m")
680+ })
511681 }
512- outputEndsWith(t, out, "\x1b[0m")
513682 }
514683
515684 // ---------------------------------------------------------------------------
@@ -517,39 +686,49 @@ func TestFullRedraw(t *testing.T) {
517686 // ---------------------------------------------------------------------------
518687
519688 func TestScrollOnlyDamage(t *testing.T) {
520- r := New(Capabilities{})
521- frame := NewFrame(4, 3)
522- markFrame(&frame)
523-
524- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
525- if err != nil {
526- t.Fatal(err)
689+ tests := []struct {
690+ name string
691+ }{
692+ {name: "scroll damage plus new-row text damage from the VT model"},
527693 }
528694
529- // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
530- scrolled := NewFrame(4, 3)
531- copy(scrolled.Cells[0:8], frame.Cells[4:12])
532- for i := 8; i < 12; i++ {
533- scrolled.Cells[i] = BlankCell()
534- }
535- scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
695+ for _, tt := range tests {
696+ t.Run(tt.name, func(t *testing.T) {
697+ r := New(Capabilities{})
698+ frame := NewFrame(4, 3)
699+ markFrame(&frame)
536700
537- damage := []Damage{
538- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
539- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
540- }
701+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
702+ if err != nil {
703+ t.Fatal(err)
704+ }
541705
542- out, err := r.Draw(scrolled, damage)
543- if err != nil {
544- t.Fatal(err)
545- }
546- if len(out) == 0 {
547- t.Fatal("expected output for scroll")
706+ // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
707+ scrolled := NewFrame(4, 3)
708+ copy(scrolled.Cells[0:8], frame.Cells[4:12])
709+ for i := 8; i < 12; i++ {
710+ scrolled.Cells[i] = BlankCell()
711+ }
712+ scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
713+
714+ damage := []Damage{
715+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
716+ {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
717+ }
718+
719+ out, err := r.Draw(scrolled, damage)
720+ if err != nil {
721+ t.Fatal(err)
722+ }
723+ if len(out) == 0 {
724+ t.Fatal("expected output for scroll")
725+ }
726+ // Should include scroll region
727+ outputContains(t, out, "\x1b[1;3r")
728+ outputContains(t, out, "\x1b[r")
729+ outputEndsWith(t, out, "\x1b[0m")
730+ })
548731 }
549- // Should include scroll region
550- outputContains(t, out, "\x1b[1;3r")
551- outputContains(t, out, "\x1b[r")
552- outputEndsWith(t, out, "\x1b[0m")
553732 }
554733
555734 // ---------------------------------------------------------------------------
@@ -557,15 +736,23 @@ func TestScrollOnlyDamage(t *testing.T) {
557736 // ---------------------------------------------------------------------------
558737
559738 func TestWriteCursor(t *testing.T) {
560- var buf bytes.Buffer
561- writeCursor(&buf, 0, 0)
562- if buf.String() != "\x1b[1;1H" {
563- t.Errorf("unexpected cursor positioning: %q", buf.String())
739+ tests := []struct {
740+ name string
741+ y, x int
742+ want string
743+ }{
744+ {name: "origin", y: 0, x: 0, want: "\x1b[1;1H"},
745+ {name: "offset", y: 2, x: 5, want: "\x1b[3;6H"},
564746 }
565- buf.Reset()
566- writeCursor(&buf, 2, 5)
567- if buf.String() != "\x1b[3;6H" {
568- t.Errorf("unexpected cursor positioning: %q", buf.String())
747+
748+ for _, tt := range tests {
749+ t.Run(tt.name, func(t *testing.T) {
750+ var buf bytes.Buffer
751+ writeCursor(&buf, tt.y, tt.x)
752+ if buf.String() != tt.want {
753+ t.Errorf("unexpected cursor positioning: %q", buf.String())
754+ }
755+ })
569756 }
570757 }
571758
@@ -605,21 +792,38 @@ func TestIsSafeScroll(t *testing.T) {
605792 // ---------------------------------------------------------------------------
606793
607794 func TestCellEqual(t *testing.T) {
608- a := BlankCell()
609- b := BlankCell()
610- if !a.Equal(b) {
611- t.Error("blank cells should be equal")
612- }
613-
614- a.Rune = 'A'
615- if a.Equal(b) {
616- t.Error("different runes should not be equal")
795+ tests := []struct {
796+ name string
797+ a Cell
798+ b Cell
799+ want bool
800+ }{
801+ {
802+ name: "blank cells should be equal",
803+ a: BlankCell(),
804+ b: BlankCell(),
805+ want: true,
806+ },
807+ {
808+ name: "different runes should not be equal",
809+ a: Cell{Rune: 'A', Style: DefaultStyle()},
810+ b: BlankCell(),
811+ want: false,
812+ },
813+ {
814+ name: "different styles should not be equal",
815+ a: Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}},
816+ b: BlankCell(),
817+ want: false,
818+ },
617819 }
618820
619- a = BlankCell()
620- a.Style.Bold = true
621- if a.Equal(b) {
622- t.Error("different styles should not be equal")
821+ for _, tt := range tests {
822+ t.Run(tt.name, func(t *testing.T) {
823+ if got := tt.a.Equal(tt.b); got != tt.want {
824+ t.Errorf("Equal() = %v, want %v", got, tt.want)
825+ }
826+ })
623827 }
624828 }
625829
@@ -628,39 +832,53 @@ func TestCellEqual(t *testing.T) {
628832 // ---------------------------------------------------------------------------
629833
630834 func TestFrameValidate(t *testing.T) {
631- // Valid frame.
632- f := NewFrame(5, 3)
633- if err := f.Validate(); err != nil {
634- t.Fatalf("unexpected error: %v", err)
635- }
636-
637- // Invalid dimensions.
638- f2 := Frame{Width: 0, Height: 5}
639- if err := f2.Validate(); err == nil {
640- t.Fatal("expected error for zero width")
835+ tests := []struct {
836+ name string
837+ frame Frame
838+ wantErr bool
839+ }{
840+ {name: "valid frame", frame: NewFrame(5, 3), wantErr: false},
841+ {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true},
842+ {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true},
641843 }
642844
643- // Wrong cell count.
644- f3 := Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}
645- if err := f3.Validate(); err == nil {
646- t.Fatal("expected error for wrong cell count")
845+ for _, tt := range tests {
846+ t.Run(tt.name, func(t *testing.T) {
847+ err := tt.frame.Validate()
848+ if tt.wantErr && err == nil {
849+ t.Fatal("expected error, got nil")
850+ }
851+ if !tt.wantErr && err != nil {
852+ t.Fatalf("unexpected error: %v", err)
853+ }
854+ })
647855 }
648856 }
649857
650858 func TestFrameAtSet(t *testing.T) {
651- f := NewFrame(3, 2)
652- cell := Cell{Rune: 'X', Style: Style{Bold: true}}
653- f.Set(1, 1, cell)
654-
655- got := f.At(1, 1)
656- if got.Rune != 'X' || !got.Style.Bold {
657- t.Errorf("At/Set round-trip failed: got %+v", got)
859+ tests := []struct {
860+ name string
861+ }{
862+ {name: "At/Set round-trip and unaffected cells stay blank"},
658863 }
659864
660- // Unaffected cells are blank.
661- got = f.At(0, 0)
662- if got != BlankCell() {
663- t.Errorf("unexpected cell at (0,0): %+v", got)
865+ for _, tt := range tests {
866+ t.Run(tt.name, func(t *testing.T) {
867+ f := NewFrame(3, 2)
868+ cell := Cell{Rune: 'X', Style: Style{Bold: true}}
869+ f.Set(1, 1, cell)
870+
871+ got := f.At(1, 1)
872+ if got.Rune != 'X' || !got.Style.Bold {
873+ t.Errorf("At/Set round-trip failed: got %+v", got)
874+ }
875+
876+ // Unaffected cells are blank.
877+ got = f.At(0, 0)
878+ if got != BlankCell() {
879+ t.Errorf("unexpected cell at (0,0): %+v", got)
880+ }
881+ })
664882 }
665883 }
666884
@@ -669,15 +887,32 @@ func TestFrameAtSet(t *testing.T) {
669887 // ---------------------------------------------------------------------------
670888
671889 func TestSameDamage(t *testing.T) {
672- a := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}
673- b := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}
674- if !sameDamage(a, b) {
675- t.Error("identical damages should be equal")
890+ tests := []struct {
891+ name string
892+ a Damage
893+ b Damage
894+ want bool
895+ }{
896+ {
897+ name: "identical damages should be equal",
898+ a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
899+ b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
900+ want: true,
901+ },
902+ {
903+ name: "different counts should not be equal",
904+ a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
905+ b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0},
906+ want: false,
907+ },
676908 }
677909
678- c := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0}
679- if sameDamage(a, c) {
680- t.Error("different counts should not be equal")
910+ for _, tt := range tests {
911+ t.Run(tt.name, func(t *testing.T) {
912+ if got := sameDamage(tt.a, tt.b); got != tt.want {
913+ t.Errorf("sameDamage() = %v, want %v", got, tt.want)
914+ }
915+ })
681916 }
682917 }
683918
@@ -686,20 +921,24 @@ func TestDamageCoversCell(t *testing.T) {
686921 {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2},
687922 }
688923
689- if !damageCoversCell(damage, 3, 3) {
690- t.Error("should cover (3,3)")
691- }
692- if !damageCoversCell(damage, 6, 4) {
693- t.Error("should cover (6,4)")
694- }
695- if damageCoversCell(damage, 1, 3) {
696- t.Error("should not cover (1,3)")
697- }
698- if damageCoversCell(damage, 3, 5) {
699- t.Error("should not cover (3,5)")
924+ tests := []struct {
925+ name string
926+ x, y int
927+ want bool
928+ }{
929+ {name: "should cover (3,3)", x: 3, y: 3, want: true},
930+ {name: "should cover (6,4)", x: 6, y: 4, want: true},
931+ {name: "should not cover (1,3)", x: 1, y: 3, want: false},
932+ {name: "should not cover (3,5)", x: 3, y: 5, want: false},
933+ {name: "should not cover (7,3)", x: 7, y: 3, want: false},
700934 }
701- if damageCoversCell(damage, 7, 3) {
702- t.Error("should not cover (7,3)")
935+
936+ for _, tt := range tests {
937+ t.Run(tt.name, func(t *testing.T) {
938+ if got := damageCoversCell(damage, tt.x, tt.y); got != tt.want {
939+ t.Errorf("damageCoversCell(%d,%d) = %v, want %v", tt.x, tt.y, got, tt.want)
940+ }
941+ })
703942 }
704943 }
705944
@@ -708,63 +947,85 @@ func TestDamageCoversCell(t *testing.T) {
708947 // ---------------------------------------------------------------------------
709948
710949 func TestNeedsFull(t *testing.T) {
711- if needsFull(nil) {
712- t.Error("nil damage should not need full")
713- }
714- if needsFull([]Damage{}) {
715- t.Error("empty damage should not need full")
716- }
717- if needsFull([]Damage{{Kind: DamageText}}) {
718- t.Error("text damage should not need full")
719- }
720- if !needsFull([]Damage{FullRedraw()}) {
721- t.Error("FullRedraw should need full")
950+ tests := []struct {
951+ name string
952+ damage []Damage
953+ want bool
954+ }{
955+ {name: "nil damage should not need full", damage: nil, want: false},
956+ {name: "empty damage should not need full", damage: []Damage{}, want: false},
957+ {name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false},
958+ {name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true},
722959 }
723-}
724960
725-func TestScrollDamageFallbackFullRedrawWhenFastPathUnsafe(t *testing.T) {
726- r := New(Capabilities{})
727- frame := NewFrame(4, 3)
728- markFrame(&frame)
729- if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil {
730- t.Fatal(err)
961+ for _, tt := range tests {
962+ t.Run(tt.name, func(t *testing.T) {
963+ if got := needsFull(tt.damage); got != tt.want {
964+ t.Errorf("needsFull() = %v, want %v", got, tt.want)
965+ }
966+ })
731967 }
968+}
732969
733- changed := NewFrame(4, 3)
734- markFrame(&changed)
735- changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship
736- damage := []Damage{
737- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
738- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
739- }
740- out, err := r.Draw(changed, damage)
741- if err != nil {
742- t.Fatal(err)
743- }
744- if rows := strings.Count(string(out), "H"); rows < changed.Height {
745- t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))
746- }
747- for i := range changed.Cells {
748- if !r.shadow[i].Equal(changed.Cells[i]) {
749- t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])
750- }
970+func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
971+ tests := []struct {
972+ name string
973+ run func(t *testing.T)
974+ }{
975+ {
976+ name: "falls back to full redraw when scroll fast path is unsafe",
977+ run: func(t *testing.T) {
978+ r := New(Capabilities{})
979+ frame := NewFrame(4, 3)
980+ markFrame(&frame)
981+ if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil {
982+ t.Fatal(err)
983+ }
984+
985+ changed := NewFrame(4, 3)
986+ markFrame(&changed)
987+ changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship
988+ damage := []Damage{
989+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
990+ {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
991+ }
992+ out, err := r.Draw(changed, damage)
993+ if err != nil {
994+ t.Fatal(err)
995+ }
996+ if rows := strings.Count(string(out), "H"); rows < changed.Height {
997+ t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))
998+ }
999+ for i := range changed.Cells {
1000+ if !r.shadow[i].Equal(changed.Cells[i]) {
1001+ t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])
1002+ }
1003+ }
1004+ },
1005+ },
1006+ {
1007+ name: "out-of-bounds damage rectangles are clamped without panicking",
1008+ run: func(t *testing.T) {
1009+ r := New(Capabilities{})
1010+ frame := NewFrame(3, 2)
1011+ markFrame(&frame)
1012+ if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
1013+ t.Fatal(err)
1014+ }
1015+ frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()})
1016+ defer func() {
1017+ if r := recover(); r != nil {
1018+ t.Fatalf("Draw panicked for out-of-bounds damage: %v", r)
1019+ }
1020+ }()
1021+ if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil {
1022+ t.Fatal(err)
1023+ }
1024+ },
1025+ },
7511026 }
752-}
7531027
754-func TestDamageRectanglesAreClamped(t *testing.T) {
755- r := New(Capabilities{})
756- frame := NewFrame(3, 2)
757- markFrame(&frame)
758- if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
759- t.Fatal(err)
760- }
761- frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()})
762- defer func() {
763- if r := recover(); r != nil {
764- t.Fatalf("Draw panicked for out-of-bounds damage: %v", r)
765- }
766- }()
767- if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil {
768- t.Fatal(err)
1028+ for _, tt := range tests {
1029+ t.Run(tt.name, tt.run)
7691030 }
7701031 }
M pkg/vt/screen_test.go +1594 -1271
@@ -52,302 +52,440 @@ func lineText(s *Screen, y int) string {
5252 // Printable ASCII / UTF-8
5353 // ---------------------------------------------------------------------------
5454
55-func TestWritePrintable(t *testing.T) {
56- s := NewScreen(5, 3)
57- s.Write([]byte("Hi"))
58-
59- assertCell(t, s, 0, 0, 'H')
60- assertCell(t, s, 1, 0, 'i')
61- if s.Col != 2 || s.Row != 0 {
62- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
63- }
64-
65- // Should have damage.
66- d := s.Damage()
67- if len(d) == 0 {
68- t.Fatal("expected damage after writing")
69- }
70-}
71-
72-func TestWriteUTF8(t *testing.T) {
73- s := NewScreen(10, 3)
74- s.Write([]byte("aäéø©"))
75-
76- assertCell(t, s, 0, 0, 'a')
77- assertCell(t, s, 1, 0, 'ä')
78- assertCell(t, s, 2, 0, 'é')
79- assertCell(t, s, 3, 0, 'ø')
80- assertCell(t, s, 4, 0, '©')
81-}
82-
83-func TestWritePrintableBeyondWidthWraps(t *testing.T) {
84- s := NewScreen(4, 2)
85- s.Write([]byte("ABCDE"))
86-
87- assertCell(t, s, 0, 0, 'A')
88- assertCell(t, s, 1, 0, 'B')
89- assertCell(t, s, 2, 0, 'C')
90- assertCell(t, s, 3, 0, 'D')
91- // E wrapped to next line.
92- assertCell(t, s, 0, 1, 'E')
93-}
94-
95-func TestWritePrintableBeyondScreenScrolls(t *testing.T) {
96- s := NewScreen(3, 2)
97- // Fill both lines.
98- s.Write([]byte("ABC"))
99- s.Write([]byte("DEF"))
100- // Row is now 1 (bottom). One more char should scroll.
101- s.Write([]byte("G"))
102-
103- // After scroll: old row 0 = "DEF", new row 1 = "G ".
104- assertCell(t, s, 0, 0, 'D')
105- assertCell(t, s, 1, 0, 'E')
106- assertCell(t, s, 2, 0, 'F')
107- assertCell(t, s, 0, 1, 'G')
108- assertCell(t, s, 1, 1, ' ')
109-
110- // Check scroll damage.
111- d := s.Damage()
112- if !hasDamageKind(d, renderer.DamageScrollUp) {
113- t.Error("expected scroll damage")
114- }
115-}
116-
117-// ---------------------------------------------------------------------------
118-// Carriage Return
119-// ---------------------------------------------------------------------------
120-
121-func TestCR(t *testing.T) {
122- s := NewScreen(10, 3)
123- s.Write([]byte("Hello"))
124- // CR moves column back to 0.
125- s.Write([]byte("\rWorld"))
126-
127- assertCell(t, s, 0, 0, 'W')
128- assertCell(t, s, 1, 0, 'o')
129- assertCell(t, s, 2, 0, 'r')
130- assertCell(t, s, 3, 0, 'l')
131- assertCell(t, s, 4, 0, 'd')
132- if s.Col != 5 {
133- t.Errorf("col after CR + World = %d, want 5", s.Col)
134- }
135-}
136-
137-// ---------------------------------------------------------------------------
138-// Line Feed
139-// ---------------------------------------------------------------------------
140-
141-func TestLF(t *testing.T) {
142- s := NewScreen(5, 3)
143- s.Write([]byte("A\r\nB\r\nC"))
144-
145- assertCell(t, s, 0, 0, 'A')
146- assertCell(t, s, 0, 1, 'B')
147- assertCell(t, s, 0, 2, 'C')
148- if s.Row != 2 || s.Col != 1 {
149- t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col)
150- }
151-}
152-
153-func TestLFDoesNotResetColumn(t *testing.T) {
154- s := NewScreen(5, 3)
155- s.Write([]byte("AB\nC"))
156-
157- assertCell(t, s, 0, 0, 'A')
158- assertCell(t, s, 1, 0, 'B')
159- assertCell(t, s, 0, 1, ' ')
160- assertCell(t, s, 2, 1, 'C')
161- if s.Row != 1 || s.Col != 3 {
162- t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col)
55+func TestWritePrintableAndUTF8(t *testing.T) {
56+ tests := []struct {
57+ name string
58+ run func(t *testing.T)
59+ }{
60+ {
61+ name: "printable ASCII advances cursor and records damage",
62+ run: func(t *testing.T) {
63+ s := NewScreen(5, 3)
64+ s.Write([]byte("Hi"))
65+
66+ assertCell(t, s, 0, 0, 'H')
67+ assertCell(t, s, 1, 0, 'i')
68+ if s.Col != 2 || s.Row != 0 {
69+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
70+ }
71+
72+ // Should have damage.
73+ d := s.Damage()
74+ if len(d) == 0 {
75+ t.Fatal("expected damage after writing")
76+ }
77+ },
78+ },
79+ {
80+ name: "UTF-8 multi-byte runes occupy one cell each",
81+ run: func(t *testing.T) {
82+ s := NewScreen(10, 3)
83+ s.Write([]byte("aäéø©"))
84+
85+ assertCell(t, s, 0, 0, 'a')
86+ assertCell(t, s, 1, 0, 'ä')
87+ assertCell(t, s, 2, 0, 'é')
88+ assertCell(t, s, 3, 0, 'ø')
89+ assertCell(t, s, 4, 0, '©')
90+ },
91+ },
92+ {
93+ name: "printable beyond width wraps to next line",
94+ run: func(t *testing.T) {
95+ s := NewScreen(4, 2)
96+ s.Write([]byte("ABCDE"))
97+
98+ assertCell(t, s, 0, 0, 'A')
99+ assertCell(t, s, 1, 0, 'B')
100+ assertCell(t, s, 2, 0, 'C')
101+ assertCell(t, s, 3, 0, 'D')
102+ // E wrapped to next line.
103+ assertCell(t, s, 0, 1, 'E')
104+ },
105+ },
106+ {
107+ name: "printable beyond screen height scrolls",
108+ run: func(t *testing.T) {
109+ s := NewScreen(3, 2)
110+ // Fill both lines.
111+ s.Write([]byte("ABC"))
112+ s.Write([]byte("DEF"))
113+ // Row is now 1 (bottom). One more char should scroll.
114+ s.Write([]byte("G"))
115+
116+ // After scroll: old row 0 = "DEF", new row 1 = "G ".
117+ assertCell(t, s, 0, 0, 'D')
118+ assertCell(t, s, 1, 0, 'E')
119+ assertCell(t, s, 2, 0, 'F')
120+ assertCell(t, s, 0, 1, 'G')
121+ assertCell(t, s, 1, 1, ' ')
122+
123+ // Check scroll damage.
124+ d := s.Damage()
125+ if !hasDamageKind(d, renderer.DamageScrollUp) {
126+ t.Error("expected scroll damage")
127+ }
128+ },
129+ },
163130 }
164-}
165131
166-func TestLFAtPendingWrapBoundaryAdvancesOneLine(t *testing.T) {
167- s := NewScreen(5, 4)
168- s.Write([]byte("ABCDE\nZ"))
169-
170- assertCell(t, s, 4, 0, 'E')
171- assertCell(t, s, 4, 1, 'Z')
172- if s.Row != 1 || s.Col != 5 {
173- t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col)
132+ for _, tt := range tests {
133+ t.Run(tt.name, tt.run)
174134 }
175135 }
176136
177-func TestLFScroll(t *testing.T) {
178- s := NewScreen(5, 2)
179- // Fill both lines then CRLF to scroll and return to column 0.
180- s.Write([]byte("AAAAA"))
181- s.Write([]byte("BBBBB"))
182- s.Write([]byte("\r\nCCCCC"))
183-
184- assertCell(t, s, 0, 0, 'B')
185- assertCell(t, s, 4, 1, 'C')
186-}
187-
188137 // ---------------------------------------------------------------------------
189-// Backspace
138+// Carriage Return / Line Feed / Backspace / Tab
190139 // ---------------------------------------------------------------------------
191140
192-func TestBS(t *testing.T) {
193- s := NewScreen(10, 3)
194- s.Write([]byte("Hello\b"))
195-
196- if s.Col != 4 {
197- t.Errorf("col after BS = %d, want 4", s.Col)
198- }
199- assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved
200-}
201-
202-func TestBSAtColumnZero(t *testing.T) {
203- s := NewScreen(10, 3)
204- s.Write([]byte("\b")) // BS at col 0 should be no-op
205-
206- if s.Col != 0 {
207- t.Errorf("col = %d, want 0", s.Col)
141+func TestCursorControlChars(t *testing.T) {
142+ tests := []struct {
143+ name string
144+ run func(t *testing.T)
145+ }{
146+ {
147+ name: "CR moves column back to 0",
148+ run: func(t *testing.T) {
149+ s := NewScreen(10, 3)
150+ s.Write([]byte("Hello"))
151+ // CR moves column back to 0.
152+ s.Write([]byte("\rWorld"))
153+
154+ assertCell(t, s, 0, 0, 'W')
155+ assertCell(t, s, 1, 0, 'o')
156+ assertCell(t, s, 2, 0, 'r')
157+ assertCell(t, s, 3, 0, 'l')
158+ assertCell(t, s, 4, 0, 'd')
159+ if s.Col != 5 {
160+ t.Errorf("col after CR + World = %d, want 5", s.Col)
161+ }
162+ },
163+ },
164+ {
165+ name: "LF with CR moves to next line column 0",
166+ run: func(t *testing.T) {
167+ s := NewScreen(5, 3)
168+ s.Write([]byte("A\r\nB\r\nC"))
169+
170+ assertCell(t, s, 0, 0, 'A')
171+ assertCell(t, s, 0, 1, 'B')
172+ assertCell(t, s, 0, 2, 'C')
173+ if s.Row != 2 || s.Col != 1 {
174+ t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col)
175+ }
176+ },
177+ },
178+ {
179+ name: "LF alone does not reset column",
180+ run: func(t *testing.T) {
181+ s := NewScreen(5, 3)
182+ s.Write([]byte("AB\nC"))
183+
184+ assertCell(t, s, 0, 0, 'A')
185+ assertCell(t, s, 1, 0, 'B')
186+ assertCell(t, s, 0, 1, ' ')
187+ assertCell(t, s, 2, 1, 'C')
188+ if s.Row != 1 || s.Col != 3 {
189+ t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col)
190+ }
191+ },
192+ },
193+ {
194+ name: "LF at pending wrap boundary advances exactly one line",
195+ run: func(t *testing.T) {
196+ s := NewScreen(5, 4)
197+ s.Write([]byte("ABCDE\nZ"))
198+
199+ assertCell(t, s, 4, 0, 'E')
200+ assertCell(t, s, 4, 1, 'Z')
201+ if s.Row != 1 || s.Col != 5 {
202+ t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col)
203+ }
204+ },
205+ },
206+ {
207+ name: "LF scrolls when both lines are full",
208+ run: func(t *testing.T) {
209+ s := NewScreen(5, 2)
210+ // Fill both lines then CRLF to scroll and return to column 0.
211+ s.Write([]byte("AAAAA"))
212+ s.Write([]byte("BBBBB"))
213+ s.Write([]byte("\r\nCCCCC"))
214+
215+ assertCell(t, s, 0, 0, 'B')
216+ assertCell(t, s, 4, 1, 'C')
217+ },
218+ },
219+ {
220+ name: "BS moves cursor back without touching the cell",
221+ run: func(t *testing.T) {
222+ s := NewScreen(10, 3)
223+ s.Write([]byte("Hello\b"))
224+
225+ if s.Col != 4 {
226+ t.Errorf("col after BS = %d, want 4", s.Col)
227+ }
228+ assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved
229+ },
230+ },
231+ {
232+ name: "BS at column zero is a no-op",
233+ run: func(t *testing.T) {
234+ s := NewScreen(10, 3)
235+ s.Write([]byte("\b")) // BS at col 0 should be no-op
236+
237+ if s.Col != 0 {
238+ t.Errorf("col = %d, want 0", s.Col)
239+ }
240+ },
241+ },
242+ {
243+ name: "tab advances to next 8-column boundary",
244+ run: func(t *testing.T) {
245+ s := NewScreen(20, 3)
246+ s.Write([]byte("A\tB"))
247+
248+ assertCell(t, s, 0, 0, 'A')
249+ // Tab advances to next 8-column boundary (col 8).
250+ for i := 1; i < 8; i++ {
251+ assertCell(t, s, i, 0, ' ')
252+ }
253+ assertCell(t, s, 8, 0, 'B')
254+ },
255+ },
208256 }
209-}
210-
211-// ---------------------------------------------------------------------------
212-// Tab
213-// ---------------------------------------------------------------------------
214257
215-func TestTab(t *testing.T) {
216- s := NewScreen(20, 3)
217- s.Write([]byte("A\tB"))
218-
219- assertCell(t, s, 0, 0, 'A')
220- // Tab advances to next 8-column boundary (col 8).
221- for i := 1; i < 8; i++ {
222- assertCell(t, s, i, 0, ' ')
258+ for _, tt := range tests {
259+ t.Run(tt.name, tt.run)
223260 }
224- assertCell(t, s, 8, 0, 'B')
225261 }
226262
227263 // ---------------------------------------------------------------------------
228264 // CSI SGR
229265 // ---------------------------------------------------------------------------
230266
231-func TestSGRDefault(t *testing.T) {
232- s := NewScreen(10, 3)
233- s.Write([]byte("\x1b[0m"))
234-
235- if s.Style != (renderer.DefaultStyle()) {
236- t.Errorf("style after SGR 0 = %+v, want default", s.Style)
237- }
238-}
239-
240-func TestSGRBold(t *testing.T) {
241- s := NewScreen(10, 3)
242- s.Write([]byte("\x1b[1m"))
243-
244- if !s.Style.Bold {
245- t.Error("expected bold")
246- }
247-}
248-
249-func TestSGRInverse(t *testing.T) {
250- s := NewScreen(10, 3)
251- s.Write([]byte("\x1b[7m"))
252-
253- if !s.Style.Inverse {
254- t.Error("expected inverse")
255- }
256-}
257-
258-func TestSGRForeground(t *testing.T) {
259- s := NewScreen(10, 3)
260- s.Write([]byte("\x1b[31m")) // red
261-
262- if s.Style.Foreground != 1 {
263- t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground)
264- }
265-}
266-
267-func TestSGRBackground(t *testing.T) {
268- s := NewScreen(10, 3)
269- s.Write([]byte("\x1b[44m")) // blue background
270-
271- if s.Style.Background != 4 {
272- t.Errorf("background = %d, want 4 (blue)", s.Style.Background)
273- }
274-}
275-
276-func TestSGR256Color(t *testing.T) {
277- s := NewScreen(10, 3)
278- s.Write([]byte("\x1b[38;5;82m")) // 256-color foreground
279-
280- if s.Style.Foreground != 82 {
281- t.Errorf("foreground = %d, want 82", s.Style.Foreground)
282- }
283-}
284-
285-func TestSGR256Background(t *testing.T) {
286- s := NewScreen(10, 3)
287- s.Write([]byte("\x1b[48;5;200m")) // 256-color background
288-
289- if s.Style.Background != 200 {
290- t.Errorf("background = %d, want 200", s.Style.Background)
291- }
292-}
293-
294-func TestSGRBrightForeground(t *testing.T) {
295- s := NewScreen(10, 3)
296- s.Write([]byte("\x1b[91m")) // bright red
297-
298- if s.Style.Foreground != 9 {
299- t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground)
300- }
301-}
302-
303-func TestSGRBrightBackground(t *testing.T) {
304- s := NewScreen(10, 3)
305- s.Write([]byte("\x1b[107m")) // bright white background
306-
307- if s.Style.Background != 15 {
308- t.Errorf("background = %d, want 15 (bright white)", s.Style.Background)
309- }
310-}
311-
312-func TestSGRTruecolorForeground(t *testing.T) {
313- s := NewScreen(10, 3)
314- s.Write([]byte("\x1b[38;2;12;34;56mX"))
315-
316- want := renderer.RGB{R: 12, G: 34, B: 56}
317- if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
318- t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
319- }
320- if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
321- t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
267+func TestSGR(t *testing.T) {
268+ tests := []struct {
269+ name string
270+ seq string
271+ check func(t *testing.T, s *Screen)
272+ }{
273+ {
274+ name: "SGR 0 resets to default style",
275+ seq: "\x1b[0m",
276+ check: func(t *testing.T, s *Screen) {
277+ if s.Style != (renderer.DefaultStyle()) {
278+ t.Errorf("style after SGR 0 = %+v, want default", s.Style)
279+ }
280+ },
281+ },
282+ {
283+ name: "SGR 1 sets bold",
284+ seq: "\x1b[1m",
285+ check: func(t *testing.T, s *Screen) {
286+ if !s.Style.Bold {
287+ t.Error("expected bold")
288+ }
289+ },
290+ },
291+ {
292+ name: "SGR 7 sets inverse",
293+ seq: "\x1b[7m",
294+ check: func(t *testing.T, s *Screen) {
295+ if !s.Style.Inverse {
296+ t.Error("expected inverse")
297+ }
298+ },
299+ },
300+ {
301+ name: "SGR 31 sets red foreground",
302+ seq: "\x1b[31m",
303+ check: func(t *testing.T, s *Screen) {
304+ if s.Style.Foreground != 1 {
305+ t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground)
306+ }
307+ },
308+ },
309+ {
310+ name: "SGR 44 sets blue background",
311+ seq: "\x1b[44m",
312+ check: func(t *testing.T, s *Screen) {
313+ if s.Style.Background != 4 {
314+ t.Errorf("background = %d, want 4 (blue)", s.Style.Background)
315+ }
316+ },
317+ },
318+ {
319+ name: "SGR 38;5 sets 256-color foreground",
320+ seq: "\x1b[38;5;82m",
321+ check: func(t *testing.T, s *Screen) {
322+ if s.Style.Foreground != 82 {
323+ t.Errorf("foreground = %d, want 82", s.Style.Foreground)
324+ }
325+ },
326+ },
327+ {
328+ name: "SGR 48;5 sets 256-color background",
329+ seq: "\x1b[48;5;200m",
330+ check: func(t *testing.T, s *Screen) {
331+ if s.Style.Background != 200 {
332+ t.Errorf("background = %d, want 200", s.Style.Background)
333+ }
334+ },
335+ },
336+ {
337+ name: "SGR 91 sets bright red foreground",
338+ seq: "\x1b[91m",
339+ check: func(t *testing.T, s *Screen) {
340+ if s.Style.Foreground != 9 {
341+ t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground)
342+ }
343+ },
344+ },
345+ {
346+ name: "SGR 107 sets bright white background",
347+ seq: "\x1b[107m",
348+ check: func(t *testing.T, s *Screen) {
349+ if s.Style.Background != 15 {
350+ t.Errorf("background = %d, want 15 (bright white)", s.Style.Background)
351+ }
352+ },
353+ },
354+ {
355+ name: "SGR 22 resets bold",
356+ seq: "\x1b[1mHello\x1b[22mWorld",
357+ check: func(t *testing.T, s *Screen) {
358+ if s.Style.Bold {
359+ t.Error("bold should be reset after 22")
360+ }
361+ },
362+ },
363+ {
364+ name: "SGR 27 resets inverse",
365+ seq: "\x1b[7m\x1b[27m",
366+ check: func(t *testing.T, s *Screen) {
367+ if s.Style.Inverse {
368+ t.Error("inverse should be reset after 27")
369+ }
370+ },
371+ },
372+ {
373+ name: "SGR 39 resets foreground to default",
374+ seq: "\x1b[31m\x1b[39m",
375+ check: func(t *testing.T, s *Screen) {
376+ if s.Style.Foreground != -1 {
377+ t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground)
378+ }
379+ },
380+ },
381+ {
382+ name: "SGR 49 resets background to default",
383+ seq: "\x1b[44m\x1b[49m",
384+ check: func(t *testing.T, s *Screen) {
385+ if s.Style.Background != -1 {
386+ t.Errorf("background should be default after 49, got %d", s.Style.Background)
387+ }
388+ },
389+ },
390+ {
391+ name: "SGR style is applied to written cells",
392+ seq: "\x1b[1;31mX",
393+ check: func(t *testing.T, s *Screen) {
394+ c := cellAt(s, 0, 0)
395+ if !c.Style.Bold {
396+ t.Error("cell should be bold")
397+ }
398+ if c.Style.Foreground != 1 {
399+ t.Errorf("cell foreground = %d, want 1", c.Style.Foreground)
400+ }
401+ },
402+ },
403+ {
404+ name: "multiple sequential SGR sequences accumulate",
405+ seq: "\x1b[31m\x1b[1m\x1b[44mX",
406+ check: func(t *testing.T, s *Screen) {
407+ c := cellAt(s, 0, 0)
408+ if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 {
409+ t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style)
410+ }
411+ },
412+ },
413+ {
414+ name: "empty SGR params reset style",
415+ seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset
416+ check: func(t *testing.T, s *Screen) {
417+ if s.Style != (renderer.DefaultStyle()) {
418+ t.Errorf("style after empty SGR = %+v, want default", s.Style)
419+ }
420+ },
421+ },
322422 }
323-}
324-
325-func TestSGRTruecolorBackground(t *testing.T) {
326- s := NewScreen(10, 3)
327- s.Write([]byte("\x1b[48;2;200;100;50m"))
328423
329- want := renderer.RGB{R: 200, G: 100, B: 50}
330- if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
331- t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
424+ for _, tt := range tests {
425+ t.Run(tt.name, func(t *testing.T) {
426+ s := NewScreen(10, 3)
427+ s.Write([]byte(tt.seq))
428+ tt.check(t, s)
429+ })
332430 }
333431 }
334432
335-func TestSGRDefaultForegroundClearsTruecolor(t *testing.T) {
336- s := NewScreen(10, 3)
337- s.Write([]byte("\x1b[38;2;12;34;56m\x1b[39m"))
338-
339- if s.Style.HasForegroundRGB || s.Style.Foreground != -1 {
340- t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground)
433+func TestSGRTruecolor(t *testing.T) {
434+ tests := []struct {
435+ name string
436+ seq string
437+ check func(t *testing.T, s *Screen)
438+ }{
439+ {
440+ name: "SGR 38;2 sets truecolor foreground on style and cell",
441+ seq: "\x1b[38;2;12;34;56mX",
442+ check: func(t *testing.T, s *Screen) {
443+ want := renderer.RGB{R: 12, G: 34, B: 56}
444+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
445+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
446+ }
447+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
448+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
449+ }
450+ },
451+ },
452+ {
453+ name: "SGR 48;2 sets truecolor background",
454+ seq: "\x1b[48;2;200;100;50m",
455+ check: func(t *testing.T, s *Screen) {
456+ want := renderer.RGB{R: 200, G: 100, B: 50}
457+ if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
458+ t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
459+ }
460+ },
461+ },
462+ {
463+ name: "SGR 39 after truecolor clears truecolor foreground",
464+ seq: "\x1b[38;2;12;34;56m\x1b[39m",
465+ check: func(t *testing.T, s *Screen) {
466+ if s.Style.HasForegroundRGB || s.Style.Foreground != -1 {
467+ t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground)
468+ }
469+ },
470+ },
471+ {
472+ name: "truecolor components are clamped to 0-255",
473+ seq: "\x1b[38;2;-1;300;42m",
474+ check: func(t *testing.T, s *Screen) {
475+ want := renderer.RGB{R: 0, G: 255, B: 42}
476+ if s.Style.ForegroundRGB != want {
477+ t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want)
478+ }
479+ },
480+ },
341481 }
342-}
343-
344-func TestSGRTruecolorClampsComponents(t *testing.T) {
345- s := NewScreen(10, 3)
346- s.Write([]byte("\x1b[38;2;-1;300;42m"))
347482
348- want := renderer.RGB{R: 0, G: 255, B: 42}
349- if s.Style.ForegroundRGB != want {
350- t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want)
483+ for _, tt := range tests {
484+ t.Run(tt.name, func(t *testing.T) {
485+ s := NewScreen(10, 3)
486+ s.Write([]byte(tt.seq))
487+ tt.check(t, s)
488+ })
351489 }
352490 }
353491
@@ -384,317 +522,390 @@ func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) {
384522 }
385523 }
386524
387-func TestSGRResetBold(t *testing.T) {
388- s := NewScreen(10, 3)
389- s.Write([]byte("\x1b[1mHello\x1b[22mWorld"))
390-
391- if s.Style.Bold {
392- t.Error("bold should be reset after 22")
393- }
394-}
395-
396-func TestSGRResetInverse(t *testing.T) {
397- s := NewScreen(10, 3)
398- s.Write([]byte("\x1b[7m\x1b[27m"))
399-
400- if s.Style.Inverse {
401- t.Error("inverse should be reset after 27")
402- }
403-}
404-
405-func TestSGRDefaultForeground(t *testing.T) {
406- s := NewScreen(10, 3)
407- s.Write([]byte("\x1b[31m\x1b[39m"))
408-
409- if s.Style.Foreground != -1 {
410- t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground)
411- }
412-}
413-
414-func TestSGRDefaultBackground(t *testing.T) {
415- s := NewScreen(10, 3)
416- s.Write([]byte("\x1b[44m\x1b[49m"))
525+// ---------------------------------------------------------------------------
526+// Clear screen / clear line / erase modes
527+// ---------------------------------------------------------------------------
417528
418- if s.Style.Background != -1 {
419- t.Errorf("background should be default after 49, got %d", s.Style.Background)
529+func TestClearAndErase(t *testing.T) {
530+ tests := []struct {
531+ name string
532+ run func(t *testing.T)
533+ }{
534+ {
535+ name: "CSI 2J clears the whole screen",
536+ run: func(t *testing.T) {
537+ s := NewScreen(5, 3)
538+ s.Write([]byte("HelloWorldABCDE"))
539+ // Clear damage from writing.
540+ s.ClearDamage()
541+
542+ s.Write([]byte("\x1b[2J"))
543+
544+ // All cells should be blank.
545+ for y := 0; y < 3; y++ {
546+ for x := 0; x < 5; x++ {
547+ if c := cellAt(s, x, y); c.Rune != ' ' {
548+ t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune)
549+ }
550+ }
551+ }
552+
553+ d := s.Damage()
554+ if !hasDamageKind(d, renderer.DamageClear) {
555+ t.Error("expected DamageClear after CSI 2 J")
556+ }
557+ },
558+ },
559+ {
560+ name: "CSI K clears from cursor to end of line",
561+ run: func(t *testing.T) {
562+ s := NewScreen(10, 3)
563+ s.Write([]byte("HelloWorld"))
564+ s.ClearDamage()
565+
566+ // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end.
567+ s.Write([]byte("\x1b[1;6H"))
568+ s.Write([]byte("\x1b[K"))
569+
570+ for x := 5; x < 10; x++ {
571+ if c := cellAt(s, x, 0); c.Rune != ' ' {
572+ t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune)
573+ }
574+ }
575+ // First 5 chars should remain.
576+ assertCell(t, s, 0, 0, 'H')
577+
578+ d := s.Damage()
579+ if !hasDamageKind(d, renderer.DamageClear) {
580+ t.Error("expected DamageClear after CSI K")
581+ }
582+ },
583+ },
584+ {
585+ name: "erase display modes 0/1/2 (implicit, 1, 2/3)",
586+ run: func(t *testing.T) {
587+ s := NewScreen(5, 3)
588+ s.Write([]byte("abcdefghijklmno"))
589+ s.Write([]byte("\x1b[2;3H"))
590+ s.ClearDamage()
591+ s.Write([]byte("\x1b[J"))
592+ assertCell(t, s, 0, 0, 'a')
593+ assertCell(t, s, 1, 1, 'g')
594+ if c := cellAt(s, 2, 1); c.Rune != ' ' {
595+ t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune)
596+ }
597+
598+ s = NewScreen(5, 3)
599+ s.Write([]byte("abcdefghijklmno"))
600+ s.Write([]byte("\x1b[2;3H"))
601+ s.Write([]byte("\x1b[1J"))
602+ if c := cellAt(s, 0, 0); c.Rune != ' ' {
603+ t.Fatalf("start-to-cursor clear left %q at start", c.Rune)
604+ }
605+ assertCell(t, s, 3, 1, 'i')
606+ },
607+ },
608+ {
609+ name: "erase line modes 0/1/2",
610+ run: func(t *testing.T) {
611+ s := NewScreen(5, 2)
612+ s.Write([]byte("abcde"))
613+ s.Write([]byte("\x1b[1;3H"))
614+ s.Write([]byte("\x1b[1K"))
615+ if c := cellAt(s, 0, 0); c.Rune != ' ' {
616+ t.Fatalf("line start clear left %q", c.Rune)
617+ }
618+ assertCell(t, s, 3, 0, 'd')
619+
620+ s = NewScreen(5, 2)
621+ s.Write([]byte("abcde"))
622+ s.Write([]byte("\x1b[1;3H"))
623+ s.Write([]byte("\x1b[2K"))
624+ for x := 0; x < 5; x++ {
625+ if c := cellAt(s, x, 0); c.Rune != ' ' {
626+ t.Fatalf("line clear left %q at %d", c.Rune, x)
627+ }
628+ }
629+ },
630+ },
420631 }
421-}
422632
423-func TestSGRAppliesToCells(t *testing.T) {
424- s := NewScreen(10, 3)
425- s.Write([]byte("\x1b[1;31mX"))
426-
427- c := cellAt(s, 0, 0)
428- if !c.Style.Bold {
429- t.Error("cell should be bold")
430- }
431- if c.Style.Foreground != 1 {
432- t.Errorf("cell foreground = %d, want 1", c.Style.Foreground)
633+ for _, tt := range tests {
634+ t.Run(tt.name, tt.run)
433635 }
434636 }
435637
436638 // ---------------------------------------------------------------------------
437-// Clear screen
639+// Cursor move
438640 // ---------------------------------------------------------------------------
439641
440-func TestClearScreen(t *testing.T) {
441- s := NewScreen(5, 3)
442- s.Write([]byte("HelloWorldABCDE"))
443- // Clear damage from writing.
444- s.ClearDamage()
642+func TestCursorMove(t *testing.T) {
643+ tests := []struct {
644+ name string
645+ seq string
646+ initRow int
647+ initCol int
648+ wantRow int
649+ wantCol int
650+ }{
651+ {name: "CSI H moves to explicit row/col", seq: "\x1b[3;4H", wantRow: 2, wantCol: 3},
652+ {name: "CSI H clamps to bottom-right when out of bounds", seq: "\x1b[100;200H", wantRow: 4, wantCol: 9},
653+ {name: "CSI f moves to explicit row/col", seq: "\x1b[2;8f", wantRow: 1, wantCol: 7},
654+ {name: "CSI C moves forward after CR", seq: "\r\x1b[3C", wantRow: 0, wantCol: 3},
655+ {name: "CSI H with no params moves to (1,1)", seq: "\x1b[H", wantRow: 0, wantCol: 0},
656+ {name: "CSI ;5H uses default row with explicit column", seq: "\x1b[;5H", initRow: 3, initCol: 4, wantRow: 0, wantCol: 4},
657+ }
445658
446- s.Write([]byte("\x1b[2J"))
659+ for _, tt := range tests {
660+ t.Run(tt.name, func(t *testing.T) {
661+ s := NewScreen(10, 5)
662+ s.Row, s.Col = tt.initRow, tt.initCol
663+ s.Write([]byte(tt.seq))
447664
448- // All cells should be blank.
449- for y := 0; y < 3; y++ {
450- for x := 0; x < 5; x++ {
451- if c := cellAt(s, x, y); c.Rune != ' ' {
452- t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune)
665+ if s.Row != tt.wantRow || s.Col != tt.wantCol {
666+ t.Errorf("cursor at row=%d col=%d, want row=%d col=%d", s.Row, s.Col, tt.wantRow, tt.wantCol)
453667 }
454- }
455- }
456-
457- d := s.Damage()
458- if !hasDamageKind(d, renderer.DamageClear) {
459- t.Error("expected DamageClear after CSI 2 J")
668+ })
460669 }
461670 }
462671
463672 // ---------------------------------------------------------------------------
464-// Clear line
673+// ESC sequences
465674 // ---------------------------------------------------------------------------
466675
467-func TestClearLine(t *testing.T) {
468- s := NewScreen(10, 3)
469- s.Write([]byte("HelloWorld"))
470- s.ClearDamage()
471-
472- // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end.
473- s.Write([]byte("\x1b[1;6H"))
474- s.Write([]byte("\x1b[K"))
475-
476- for x := 5; x < 10; x++ {
477- if c := cellAt(s, x, 0); c.Rune != ' ' {
478- t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune)
479- }
676+func TestESCSequences(t *testing.T) {
677+ tests := []struct {
678+ name string
679+ run func(t *testing.T)
680+ }{
681+ {
682+ name: "ESC = (DECKPAM) is ignored, following text is printable",
683+ run: func(t *testing.T) {
684+ s := NewScreen(10, 2)
685+ s.Write([]byte("\x1b=abc"))
686+
687+ assertCell(t, s, 0, 0, 'a')
688+ assertCell(t, s, 1, 0, 'b')
689+ assertCell(t, s, 2, 0, 'c')
690+ if s.Col != 3 || s.Row != 0 {
691+ t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col)
692+ }
693+ },
694+ },
695+ {
696+ name: "ESC 7 / ESC 8 save and restore the cursor",
697+ run: func(t *testing.T) {
698+ s := NewScreen(10, 3)
699+ s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X"))
700+
701+ assertCell(t, s, 0, 0, 'a')
702+ assertCell(t, s, 1, 0, 'b')
703+ assertCell(t, s, 2, 0, 'c')
704+ assertCell(t, s, 3, 0, 'X')
705+ assertCell(t, s, 4, 1, 'Z')
706+ assertCell(t, s, 5, 1, 'Z')
707+ if s.Row != 0 || s.Col != 4 {
708+ t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
709+ }
710+ },
711+ },
712+ {
713+ name: "ESC D (index), ESC E (next line), ESC M (reverse index)",
714+ run: func(t *testing.T) {
715+ s := NewScreen(5, 3)
716+ s.Write([]byte("A\x1bDB\x1bEC"))
717+
718+ assertCell(t, s, 0, 0, 'A')
719+ assertCell(t, s, 1, 1, 'B')
720+ assertCell(t, s, 0, 2, 'C')
721+
722+ s = NewScreen(5, 3)
723+ s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop"))
724+ assertCell(t, s, 0, 0, 't')
725+ assertCell(t, s, 0, 1, 'm')
726+ },
727+ },
480728 }
481- // First 5 chars should remain.
482- assertCell(t, s, 0, 0, 'H')
483729
484- d := s.Damage()
485- if !hasDamageKind(d, renderer.DamageClear) {
486- t.Error("expected DamageClear after CSI K")
730+ for _, tt := range tests {
731+ t.Run(tt.name, tt.run)
487732 }
488733 }
489734
490735 // ---------------------------------------------------------------------------
491-// Cursor move
736+// CSI editing sequences (cursor moves, ICH/DCH, IL/DL, scroll region, alt screen)
492737 // ---------------------------------------------------------------------------
493738
494-func TestCursorMoveH(t *testing.T) {
495- s := NewScreen(10, 5)
496- s.Write([]byte("\x1b[3;4H"))
497-
498- if s.Row != 2 || s.Col != 3 {
499- t.Errorf("cursor at row=%d col=%d, want row=2 col=3", s.Row, s.Col)
500- }
501-}
502-
503-func TestCursorMoveOutsideBounds(t *testing.T) {
504- s := NewScreen(10, 5)
505- s.Write([]byte("\x1b[100;200H"))
506-
507- // Should clamp to bottom-right.
508- if s.Row != 4 || s.Col != 9 {
509- t.Errorf("cursor at row=%d col=%d, want row=4 col=9", s.Row, s.Col)
510- }
511-}
512-
513-func TestCursorMoveF(t *testing.T) {
514- s := NewScreen(10, 5)
515- s.Write([]byte("\x1b[2;8f"))
516-
517- if s.Row != 1 || s.Col != 7 {
518- t.Errorf("cursor at row=%d col=%d, want row=1 col=7", s.Row, s.Col)
519- }
520-}
521-
522-func TestCursorMoveForwardC(t *testing.T) {
523- s := NewScreen(10, 5)
524- s.Write([]byte("\r\x1b[3C"))
525-
526- if s.Row != 0 || s.Col != 3 {
527- t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col)
528- }
529-}
530-
531-func TestESC_DECKPAMIgnored(t *testing.T) {
532- s := NewScreen(10, 2)
533- s.Write([]byte("\x1b=abc"))
534-
535- assertCell(t, s, 0, 0, 'a')
536- assertCell(t, s, 1, 0, 'b')
537- assertCell(t, s, 2, 0, 'c')
538- if s.Col != 3 || s.Row != 0 {
539- t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col)
540- }
541-}
542-
543-func TestESC_SaveRestoreCursor(t *testing.T) {
544- s := NewScreen(10, 3)
545- s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X"))
546-
547- assertCell(t, s, 0, 0, 'a')
548- assertCell(t, s, 1, 0, 'b')
549- assertCell(t, s, 2, 0, 'c')
550- assertCell(t, s, 3, 0, 'X')
551- assertCell(t, s, 4, 1, 'Z')
552- assertCell(t, s, 5, 1, 'Z')
553- if s.Row != 0 || s.Col != 4 {
554- t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
555- }
556-}
557-
558-func TestESC_IndexNextLineAndReverseIndex(t *testing.T) {
559- s := NewScreen(5, 3)
560- s.Write([]byte("A\x1bDB\x1bEC"))
561-
562- assertCell(t, s, 0, 0, 'A')
563- assertCell(t, s, 1, 1, 'B')
564- assertCell(t, s, 0, 2, 'C')
565-
566- s = NewScreen(5, 3)
567- s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop"))
568- assertCell(t, s, 0, 0, 't')
569- assertCell(t, s, 0, 1, 'm')
570-}
571-
572-func TestCSI_CursorDirectionalMoves(t *testing.T) {
573- s := NewScreen(10, 4)
574- s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>"))
575-
576- assertCell(t, s, 4, 2, 'X')
577- assertCell(t, s, 3, 2, '<')
578- assertCell(t, s, 4, 1, '^')
579- assertCell(t, s, 5, 2, 'v')
580- assertCell(t, s, 9, 2, '>')
581-}
582-
583-func TestCSI_InsertDeleteCharacters(t *testing.T) {
584- s := NewScreen(8, 2)
585- s.Write([]byte("abcdef"))
586- s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
587- if got := lineText(s, 0); got != "abXYcdef" {
588- t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef")
589- }
590-
591- s.Write([]byte("\x1b[1;4H\x1b[3P"))
592- if got := lineText(s, 0); got != "abXef " {
593- t.Fatalf("line after DCH = %q, want %q", got, "abXef ")
594- }
595-}
596-
597-func TestCSI_InsertDeleteLines(t *testing.T) {
598- s := NewScreen(5, 4)
599- s.Write([]byte("11111222223333344444"))
600- s.Write([]byte("\x1b[2;1H\x1b[L"))
601- if got := lineText(s, 1); got != " " {
602- t.Fatalf("inserted line = %q, want blank", got)
603- }
604- if got := lineText(s, 2); got != "22222" {
605- t.Fatalf("shifted line = %q, want 22222", got)
606- }
607-
608- s.Write([]byte("\x1b[2;1H\x1b[M"))
609- if got := lineText(s, 1); got != "22222" {
610- t.Fatalf("line after delete = %q, want 22222", got)
611- }
612-}
613-
614-func TestCSI_ScrollRegion(t *testing.T) {
615- s := NewScreen(5, 4)
616- s.Write([]byte("11111222223333344444"))
617- s.Write([]byte("\x1b[2;3r\x1b[3;1H\n"))
618-
619- if got := lineText(s, 0); got != "11111" {
620- t.Fatalf("row outside region changed: %q", got)
621- }
622- if got := lineText(s, 1); got != "33333" {
623- t.Fatalf("region did not scroll up, row 1 = %q", got)
624- }
625- if got := lineText(s, 2); got != " " {
626- t.Fatalf("region bottom not blanked, row 2 = %q", got)
627- }
628- if got := lineText(s, 3); got != "44444" {
629- t.Fatalf("row below region changed: %q", got)
739+func TestCSIEditingSequences(t *testing.T) {
740+ tests := []struct {
741+ name string
742+ run func(t *testing.T)
743+ }{
744+ {
745+ name: "directional cursor moves (A/B/C/D)",
746+ run: func(t *testing.T) {
747+ s := NewScreen(10, 4)
748+ s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>"))
749+
750+ assertCell(t, s, 4, 2, 'X')
751+ assertCell(t, s, 3, 2, '<')
752+ assertCell(t, s, 4, 1, '^')
753+ assertCell(t, s, 5, 2, 'v')
754+ assertCell(t, s, 9, 2, '>')
755+ },
756+ },
757+ {
758+ name: "insert and delete characters (@ and P)",
759+ run: func(t *testing.T) {
760+ s := NewScreen(8, 2)
761+ s.Write([]byte("abcdef"))
762+ s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
763+ if got := lineText(s, 0); got != "abXYcdef" {
764+ t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef")
765+ }
766+
767+ s.Write([]byte("\x1b[1;4H\x1b[3P"))
768+ if got := lineText(s, 0); got != "abXef " {
769+ t.Fatalf("line after DCH = %q, want %q", got, "abXef ")
770+ }
771+ },
772+ },
773+ {
774+ name: "insert and delete lines (L and M)",
775+ run: func(t *testing.T) {
776+ s := NewScreen(5, 4)
777+ s.Write([]byte("11111222223333344444"))
778+ s.Write([]byte("\x1b[2;1H\x1b[L"))
779+ if got := lineText(s, 1); got != " " {
780+ t.Fatalf("inserted line = %q, want blank", got)
781+ }
782+ if got := lineText(s, 2); got != "22222" {
783+ t.Fatalf("shifted line = %q, want 22222", got)
784+ }
785+
786+ s.Write([]byte("\x1b[2;1H\x1b[M"))
787+ if got := lineText(s, 1); got != "22222" {
788+ t.Fatalf("line after delete = %q, want 22222", got)
789+ }
790+ },
791+ },
792+ {
793+ name: "scroll region (CSI r) confines LF scrolling",
794+ run: func(t *testing.T) {
795+ s := NewScreen(5, 4)
796+ s.Write([]byte("11111222223333344444"))
797+ s.Write([]byte("\x1b[2;3r\x1b[3;1H\n"))
798+
799+ if got := lineText(s, 0); got != "11111" {
800+ t.Fatalf("row outside region changed: %q", got)
801+ }
802+ if got := lineText(s, 1); got != "33333" {
803+ t.Fatalf("region did not scroll up, row 1 = %q", got)
804+ }
805+ if got := lineText(s, 2); got != " " {
806+ t.Fatalf("region bottom not blanked, row 2 = %q", got)
807+ }
808+ if got := lineText(s, 3); got != "44444" {
809+ t.Fatalf("row below region changed: %q", got)
810+ }
811+ },
812+ },
813+ {
814+ name: "private alternate screen mode restores normal screen on exit",
815+ run: func(t *testing.T) {
816+ s := NewScreen(20, 5)
817+ s.Write([]byte("normal-line"))
818+ s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP"))
819+ s.Write([]byte("\x1b[?1049lafter"))
820+
821+ if got := lineText(s, 0); got != "normal-lineafter " {
822+ t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got)
823+ }
824+ if got := lineText(s, 2); got != " " {
825+ t.Fatalf("alternate content leaked into row 2: %q", got)
826+ }
827+ },
828+ },
630829 }
631-}
632-
633-func TestCSI_PrivateAlternateScreenRestoresNormalScreen(t *testing.T) {
634- s := NewScreen(20, 5)
635- s.Write([]byte("normal-line"))
636- s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP"))
637- s.Write([]byte("\x1b[?1049lafter"))
638830
639- if got := lineText(s, 0); got != "normal-lineafter " {
640- t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got)
641- }
642- if got := lineText(s, 2); got != " " {
643- t.Fatalf("alternate content leaked into row 2: %q", got)
831+ for _, tt := range tests {
832+ t.Run(tt.name, tt.run)
644833 }
645834 }
646835
647-func TestRendererStaysInSyncAfterVTEditDamage(t *testing.T) {
648- s := NewScreen(8, 2)
649- r := renderer.New(renderer.Capabilities{})
650- s.Write([]byte("abcdef"))
651- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
652- t.Fatal(err)
653- }
654- s.ClearDamage()
655-
656- s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
657- if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
658- t.Fatal(err)
659- }
660- s.ClearDamage()
661-
662- out, err := r.Draw(s.Frame, nil)
663- if err != nil {
664- t.Fatal(err)
665- }
666- if len(out) != 0 {
667- t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out))
668- }
669-}
836+// ---------------------------------------------------------------------------
837+// Integration scenarios
838+// ---------------------------------------------------------------------------
670839
671-func TestDCS_STTerminatedIgnored(t *testing.T) {
672- s := NewScreen(20, 2)
673- s.Write([]byte("\x1bP+q696e646e\x1b\\prompt"))
674-
675- assertCell(t, s, 0, 0, 'p')
676- assertCell(t, s, 1, 0, 'r')
677- assertCell(t, s, 2, 0, 'o')
678- assertCell(t, s, 3, 0, 'm')
679- assertCell(t, s, 4, 0, 'p')
680- assertCell(t, s, 5, 0, 't')
681- if s.Col != 6 || s.Row != 0 {
682- t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col)
840+func TestIntegrationScenarios(t *testing.T) {
841+ tests := []struct {
842+ name string
843+ run func(t *testing.T)
844+ }{
845+ {
846+ name: "renderer stays in sync after VT edit damage",
847+ run: func(t *testing.T) {
848+ s := NewScreen(8, 2)
849+ r := renderer.New(renderer.Capabilities{})
850+ s.Write([]byte("abcdef"))
851+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
852+ t.Fatal(err)
853+ }
854+ s.ClearDamage()
855+
856+ s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
857+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
858+ t.Fatal(err)
859+ }
860+ s.ClearDamage()
861+
862+ out, err := r.Draw(s.Frame, nil)
863+ if err != nil {
864+ t.Fatal(err)
865+ }
866+ if len(out) != 0 {
867+ t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out))
868+ }
869+ },
870+ },
871+ {
872+ name: "DCS sequence terminated by ST is ignored",
873+ run: func(t *testing.T) {
874+ s := NewScreen(20, 2)
875+ s.Write([]byte("\x1bP+q696e646e\x1b\\prompt"))
876+
877+ assertCell(t, s, 0, 0, 'p')
878+ assertCell(t, s, 1, 0, 'r')
879+ assertCell(t, s, 2, 0, 'o')
880+ assertCell(t, s, 3, 0, 'm')
881+ assertCell(t, s, 4, 0, 'p')
882+ assertCell(t, s, 5, 0, 't')
883+ if s.Col != 6 || s.Row != 0 {
884+ t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col)
885+ }
886+ },
887+ },
888+ {
889+ name: "fish-like prompt redraw preserves typed characters",
890+ run: func(t *testing.T) {
891+ s := NewScreen(10, 2)
892+ s.Write([]byte("> "))
893+ s.Write([]byte("a\r\x1b[3C"))
894+ s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C"))
895+
896+ assertCell(t, s, 0, 0, '>')
897+ assertCell(t, s, 1, 0, ' ')
898+ assertCell(t, s, 2, 0, 'a')
899+ assertCell(t, s, 3, 0, 'b')
900+ if s.Row != 0 || s.Col != 4 {
901+ t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
902+ }
903+ },
904+ },
683905 }
684-}
685906
686-func TestFishLikePromptRedrawPreservesTypedCharacters(t *testing.T) {
687- s := NewScreen(10, 2)
688- s.Write([]byte("> "))
689- s.Write([]byte("a\r\x1b[3C"))
690- s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C"))
691-
692- assertCell(t, s, 0, 0, '>')
693- assertCell(t, s, 1, 0, ' ')
694- assertCell(t, s, 2, 0, 'a')
695- assertCell(t, s, 3, 0, 'b')
696- if s.Row != 0 || s.Col != 4 {
697- t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
907+ for _, tt := range tests {
908+ t.Run(tt.name, tt.run)
698909 }
699910 }
700911
@@ -702,54 +913,70 @@ func TestFishLikePromptRedrawPreservesTypedCharacters(t *testing.T) {
702913 // Scroll damage
703914 // ---------------------------------------------------------------------------
704915
705-func TestScrollDamageKind(t *testing.T) {
706- s := NewScreen(5, 2)
707- // Fill screen then cause a scroll.
708- s.Write([]byte("AAAAABBBBB")) // both rows filled
709- s.ClearDamage()
710-
711- s.Write([]byte("CCCCC")) // forces a scroll up
712-
713- d := s.Damage()
714- if len(d) == 0 {
715- t.Fatal("expected damage after scroll")
716- }
717-
718- if !hasDamageKind(d, renderer.DamageScrollUp) {
719- t.Errorf("expected DamageScrollUp in %v", damageKinds(d))
720- }
721- if !hasDamageKind(d, renderer.DamageText) {
722- t.Errorf("expected DamageText in %v", damageKinds(d))
916+func TestScrollDamage(t *testing.T) {
917+ tests := []struct {
918+ name string
919+ run func(t *testing.T)
920+ }{
921+ {
922+ name: "scroll produces both DamageScrollUp and DamageText",
923+ run: func(t *testing.T) {
924+ s := NewScreen(5, 2)
925+ // Fill screen then cause a scroll.
926+ s.Write([]byte("AAAAABBBBB")) // both rows filled
927+ s.ClearDamage()
928+
929+ s.Write([]byte("CCCCC")) // forces a scroll up
930+
931+ d := s.Damage()
932+ if len(d) == 0 {
933+ t.Fatal("expected damage after scroll")
934+ }
935+
936+ if !hasDamageKind(d, renderer.DamageScrollUp) {
937+ t.Errorf("expected DamageScrollUp in %v", damageKinds(d))
938+ }
939+ if !hasDamageKind(d, renderer.DamageText) {
940+ t.Errorf("expected DamageText in %v", damageKinds(d))
941+ }
942+ },
943+ },
944+ {
945+ name: "scroll damage coordinates match the full-width scrolled region",
946+ run: func(t *testing.T) {
947+ s := NewScreen(5, 3)
948+ s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows
949+ s.ClearDamage()
950+
951+ // Write another char to force scroll.
952+ s.Write([]byte("D"))
953+
954+ d := s.Damage()
955+ var scrollDamage *renderer.Damage
956+ for i, dd := range d {
957+ if dd.Kind == renderer.DamageScrollUp {
958+ scrollDamage = &d[i]
959+ break
960+ }
961+ }
962+ if scrollDamage == nil {
963+ t.Fatal("expected scroll damage")
964+ }
965+ if scrollDamage.X != 0 || scrollDamage.Y != 0 {
966+ t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y)
967+ }
968+ if scrollDamage.Width != 5 || scrollDamage.Height != 3 {
969+ t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height)
970+ }
971+ if scrollDamage.Count != 1 {
972+ t.Errorf("scroll count = %d, want 1", scrollDamage.Count)
973+ }
974+ },
975+ },
723976 }
724-}
725977
726-func TestScrollDamageCoordinates(t *testing.T) {
727- s := NewScreen(5, 3)
728- s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows
729- s.ClearDamage()
730-
731- // Write another char to force scroll.
732- s.Write([]byte("D"))
733-
734- d := s.Damage()
735- var scrollDamage *renderer.Damage
736- for i, dd := range d {
737- if dd.Kind == renderer.DamageScrollUp {
738- scrollDamage = &d[i]
739- break
740- }
741- }
742- if scrollDamage == nil {
743- t.Fatal("expected scroll damage")
744- }
745- if scrollDamage.X != 0 || scrollDamage.Y != 0 {
746- t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y)
747- }
748- if scrollDamage.Width != 5 || scrollDamage.Height != 3 {
749- t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height)
750- }
751- if scrollDamage.Count != 1 {
752- t.Errorf("scroll count = %d, want 1", scrollDamage.Count)
978+ for _, tt := range tests {
979+ t.Run(tt.name, tt.run)
753980 }
754981 }
755982
@@ -757,53 +984,71 @@ func TestScrollDamageCoordinates(t *testing.T) {
757984 // Damage coalescing
758985 // ---------------------------------------------------------------------------
759986
760-func TestDamageCoalescing(t *testing.T) {
761- s := NewScreen(20, 3)
762- s.Write([]byte("ABC")) // Three adjacent chars on one line
763-
764- d := s.Damage()
765- // Should be coalesced into a single DamageText.
766- if len(d) != 1 {
767- t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d)
768- }
769- if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 {
770- t.Errorf("unexpected coalesced damage: %+v", d[0])
771- }
772-}
773-
774-func TestDamageCoalescingNewlineBreaks(t *testing.T) {
775- s := NewScreen(10, 3)
776- s.Write([]byte("AB\nCD"))
777-
778- d := s.Damage()
779- // "AB" on line 0, "CD" on line 1 → two separate DamageText items.
780- textCount := 0
781- for _, dd := range d {
782- if dd.Kind == renderer.DamageText {
783- textCount++
784- }
785- }
786- if textCount < 2 {
787- t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount)
788- }
789-}
790-
791-func TestDamageCoalescingFullRedraw(t *testing.T) {
792- s := NewScreen(5, 3)
793- // NewScreen already has FullRedraw in damage.
794- d := s.Damage()
795- if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw {
796- t.Fatalf("expected single FullRedraw, got %+v", d)
987+func TestDamageCoalescingBehavior(t *testing.T) {
988+ tests := []struct {
989+ name string
990+ run func(t *testing.T)
991+ }{
992+ {
993+ name: "adjacent single-cell writes coalesce into one damage entry",
994+ run: func(t *testing.T) {
995+ s := NewScreen(20, 3)
996+ s.Write([]byte("ABC")) // Three adjacent chars on one line
997+
998+ d := s.Damage()
999+ // Should be coalesced into a single DamageText.
1000+ if len(d) != 1 {
1001+ t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d)
1002+ }
1003+ if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 {
1004+ t.Errorf("unexpected coalesced damage: %+v", d[0])
1005+ }
1006+ },
1007+ },
1008+ {
1009+ name: "newline breaks coalescing across lines",
1010+ run: func(t *testing.T) {
1011+ s := NewScreen(10, 3)
1012+ s.Write([]byte("AB\nCD"))
1013+
1014+ d := s.Damage()
1015+ // "AB" on line 0, "CD" on line 1 → two separate DamageText items.
1016+ textCount := 0
1017+ for _, dd := range d {
1018+ if dd.Kind == renderer.DamageText {
1019+ textCount++
1020+ }
1021+ }
1022+ if textCount < 2 {
1023+ t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount)
1024+ }
1025+ },
1026+ },
1027+ {
1028+ name: "writing replaces the initial FullRedraw with text damage",
1029+ run: func(t *testing.T) {
1030+ s := NewScreen(5, 3)
1031+ // NewScreen already has FullRedraw in damage.
1032+ d := s.Damage()
1033+ if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw {
1034+ t.Fatalf("expected single FullRedraw, got %+v", d)
1035+ }
1036+
1037+ // Writing should replace it with text damage.
1038+ s.Write([]byte("X"))
1039+ d = s.Damage()
1040+ if len(d) == 0 {
1041+ t.Fatal("expected damage after write")
1042+ }
1043+ if d[0].Kind == renderer.DamageFullRedraw {
1044+ t.Error("FullRedraw should have been replaced by text damage")
1045+ }
1046+ },
1047+ },
7971048 }
7981049
799- // Writing should replace it with text damage.
800- s.Write([]byte("X"))
801- d = s.Damage()
802- if len(d) == 0 {
803- t.Fatal("expected damage after write")
804- }
805- if d[0].Kind == renderer.DamageFullRedraw {
806- t.Error("FullRedraw should have been replaced by text damage")
1050+ for _, tt := range tests {
1051+ t.Run(tt.name, tt.run)
8071052 }
8081053 }
8091054
@@ -811,40 +1056,56 @@ func TestDamageCoalescingFullRedraw(t *testing.T) {
8111056 // Resize
8121057 // ---------------------------------------------------------------------------
8131058
814-func TestResizeLarger(t *testing.T) {
815- s := NewScreen(5, 3)
816- s.Write([]byte("Hello"))
817- s.Resize(10, 5)
818-
819- if s.Frame.Width != 10 || s.Frame.Height != 5 {
820- t.Errorf("frame size = %dx%d, want 10x5", s.Frame.Width, s.Frame.Height)
821- }
822- // Should be blank (new frame).
823- for x := 0; x < 10; x++ {
824- assertCell(t, s, x, 0, ' ')
825- }
826- // Cursor reset.
827- if s.Row != 0 || s.Col != 0 {
828- t.Errorf("cursor at (%d,%d), want (0,0)", s.Row, s.Col)
829- }
830- // Should have FullRedraw.
831- d := s.Damage()
832- if !hasDamageKind(d, renderer.DamageFullRedraw) {
833- t.Errorf("expected FullRedraw after resize, got %v", damageKinds(d))
1059+func TestResize(t *testing.T) {
1060+ tests := []struct {
1061+ name string
1062+ run func(t *testing.T)
1063+ }{
1064+ {
1065+ name: "resizing larger clears the frame and forces FullRedraw",
1066+ run: func(t *testing.T) {
1067+ s := NewScreen(5, 3)
1068+ s.Write([]byte("Hello"))
1069+ s.Resize(10, 5)
1070+
1071+ if s.Frame.Width != 10 || s.Frame.Height != 5 {
1072+ t.Errorf("frame size = %dx%d, want 10x5", s.Frame.Width, s.Frame.Height)
1073+ }
1074+ // Should be blank (new frame).
1075+ for x := 0; x < 10; x++ {
1076+ assertCell(t, s, x, 0, ' ')
1077+ }
1078+ // Cursor reset.
1079+ if s.Row != 0 || s.Col != 0 {
1080+ t.Errorf("cursor at (%d,%d), want (0,0)", s.Row, s.Col)
1081+ }
1082+ // Should have FullRedraw.
1083+ d := s.Damage()
1084+ if !hasDamageKind(d, renderer.DamageFullRedraw) {
1085+ t.Errorf("expected FullRedraw after resize, got %v", damageKinds(d))
1086+ }
1087+ },
1088+ },
1089+ {
1090+ name: "resizing to the same size preserves content and skips FullRedraw",
1091+ run: func(t *testing.T) {
1092+ s := NewScreen(5, 3)
1093+ s.Write([]byte("Hello"))
1094+
1095+ s.Resize(5, 3) // same size
1096+
1097+ assertCell(t, s, 0, 0, 'H') // content preserved
1098+ // No FullRedraw.
1099+ d := s.Damage()
1100+ if len(d) != 0 && d[0].Kind == renderer.DamageFullRedraw {
1101+ t.Error("same-size resize should not produce FullRedraw")
1102+ }
1103+ },
1104+ },
8341105 }
835-}
8361106
837-func TestResizeSameSize(t *testing.T) {
838- s := NewScreen(5, 3)
839- s.Write([]byte("Hello"))
840-
841- s.Resize(5, 3) // same size
842-
843- assertCell(t, s, 0, 0, 'H') // content preserved
844- // No FullRedraw.
845- d := s.Damage()
846- if len(d) != 0 && d[0].Kind == renderer.DamageFullRedraw {
847- t.Error("same-size resize should not produce FullRedraw")
1107+ for _, tt := range tests {
1108+ t.Run(tt.name, tt.run)
8481109 }
8491110 }
8501111
@@ -853,338 +1114,371 @@ func TestResizeSameSize(t *testing.T) {
8531114 // ---------------------------------------------------------------------------
8541115
8551116 func TestClearDamage(t *testing.T) {
856- s := NewScreen(5, 3)
857- s.Write([]byte("Hello"))
858- s.ClearDamage()
859- d := s.Damage()
860- if len(d) != 0 {
861- t.Fatalf("expected empty damage after ClearDamage, got %+v", d)
862- }
863-}
864-
865-// ---------------------------------------------------------------------------
866-// Non-printable / invalid bytes
867-// ---------------------------------------------------------------------------
868-
869-func TestInvalidBytes(t *testing.T) {
870- s := NewScreen(5, 3)
871- // Invalid UTF-8 continuation byte without a start byte.
872- s.Write([]byte{0x80, 0x81})
873- // Should not panic, and no cells should be modified.
874- for y := 0; y < 3; y++ {
875- for x := 0; x < 5; x++ {
876- if c := cellAt(s, x, y); c.Rune != ' ' {
877- t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
878- }
879- }
1117+ tests := []struct {
1118+ name string
1119+ }{
1120+ {name: "ClearDamage empties the damage list"},
8801121 }
881-}
8821122
883-func TestControlChars(t *testing.T) {
884- s := NewScreen(10, 3)
885- // Control characters below 0x20 that are not handled (CR/LF/BS/TAB) should be ignored.
886- ctrl := []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f}
887- s.Write(ctrl)
888- // No cells should be modified.
889- for y := 0; y < 3; y++ {
890- for x := 0; x < 10; x++ {
891- if c := cellAt(s, x, y); c.Rune != ' ' {
892- t.Errorf("cell(%d,%d) = %q, want space after control chars", x, y, c.Rune)
1123+ for _, tt := range tests {
1124+ t.Run(tt.name, func(t *testing.T) {
1125+ s := NewScreen(5, 3)
1126+ s.Write([]byte("Hello"))
1127+ s.ClearDamage()
1128+ d := s.Damage()
1129+ if len(d) != 0 {
1130+ t.Fatalf("expected empty damage after ClearDamage, got %+v", d)
8931131 }
894- }
1132+ })
8951133 }
8961134 }
8971135
8981136 // ---------------------------------------------------------------------------
899-// Multiple sequential CSI sequences
1137+// Non-printable / invalid bytes
9001138 // ---------------------------------------------------------------------------
9011139
902-func TestMultipleSGR(t *testing.T) {
903- s := NewScreen(10, 3)
904- s.Write([]byte("\x1b[31m\x1b[1m\x1b[44mX"))
905-
906- c := cellAt(s, 0, 0)
907- if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 {
908- t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style)
1140+func TestInvalidInputIgnored(t *testing.T) {
1141+ tests := []struct {
1142+ name string
1143+ input []byte
1144+ }{
1145+ {
1146+ name: "invalid UTF-8 continuation byte without start byte",
1147+ input: []byte{0x80, 0x81},
1148+ },
1149+ {
1150+ name: "unhandled control characters below 0x20",
1151+ input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f},
1152+ },
9091153 }
910-}
911-
912-func TestSGREmptyParamsReset(t *testing.T) {
913- s := NewScreen(10, 3)
914- s.Write([]byte("\x1b[31m\x1b[1m\x1b[m")) // empty params → reset
9151154
916- if s.Style != (renderer.DefaultStyle()) {
917- t.Errorf("style after empty SGR = %+v, want default", s.Style)
1155+ for _, tt := range tests {
1156+ t.Run(tt.name, func(t *testing.T) {
1157+ s := NewScreen(10, 3)
1158+ s.Write(tt.input)
1159+ // Should not panic, and no cells should be modified.
1160+ for y := 0; y < 3; y++ {
1161+ for x := 0; x < 10; x++ {
1162+ if c := cellAt(s, x, y); c.Rune != ' ' {
1163+ t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
1164+ }
1165+ }
1166+ }
1167+ })
9181168 }
9191169 }
9201170
9211171 // ---------------------------------------------------------------------------
922-// Cursor move with zero / missing params
1172+// No overflow on edge writes
9231173 // ---------------------------------------------------------------------------
9241174
925-func TestCursorMoveZeroParams(t *testing.T) {
926- s := NewScreen(10, 5)
927- s.Write([]byte("\x1b[H")) // no params → (1,1)
928-
929- if s.Row != 0 || s.Col != 0 {
930- t.Errorf("cursor at (%d,%d), want (0,0)", s.Row, s.Col)
1175+func TestWriteAtEdgeNoPanic(t *testing.T) {
1176+ tests := []struct {
1177+ name string
1178+ }{
1179+ {name: "writing past a 1x1 screen scrolls instead of panicking"},
9311180 }
932-}
933-
934-func TestCursorMovePartialParams(t *testing.T) {
935- s := NewScreen(10, 5)
936- s.Row, s.Col = 3, 4
937- s.Write([]byte("\x1b[;5H")) // row=1 (default), col=5
9381181
939- if s.Row != 0 || s.Col != 4 {
940- t.Errorf("cursor at (%d,%d), want (0,4)", s.Row, s.Col)
1182+ for _, tt := range tests {
1183+ t.Run(tt.name, func(t *testing.T) {
1184+ s := NewScreen(1, 1)
1185+ // Fill the only cell then try to write more (should scroll).
1186+ s.Write([]byte("ABC"))
1187+ // Should not panic.
1188+ _ = cellAt(s, 0, 0)
1189+ })
9411190 }
9421191 }
9431192
944-// ---------------------------------------------------------------------------
945-// No overflow on edge writes
946-// ---------------------------------------------------------------------------
947-
948-func TestWriteAtEdgeNoPanic(t *testing.T) {
949- s := NewScreen(1, 1)
950- // Fill the only cell then try to write more (should scroll).
951- s.Write([]byte("ABC"))
952- // Should not panic.
953- _ = cellAt(s, 0, 0)
954-}
955-
9561193 // ---------------------------------------------------------------------------
9571194 // Style persistence across writes
9581195 // ---------------------------------------------------------------------------
9591196
9601197 func TestStylePersists(t *testing.T) {
961- s := NewScreen(10, 3)
962- s.Write([]byte("\x1b[1mAB"))
963- s.Write([]byte("CD"))
964-
965- c1 := cellAt(s, 0, 0)
966- c2 := cellAt(s, 2, 0)
967- if !c1.Style.Bold || !c2.Style.Bold {
968- t.Error("bold should persist across Write calls")
969- }
970-}
971-
972-// ---------------------------------------------------------------------------
973-// Rune width (CJK, emoji, combining marks, zero-width)
974-// ---------------------------------------------------------------------------
975-
976-func TestCJKWideChars(t *testing.T) {
977- s := NewScreen(10, 3)
978- // Write two CJK characters that are width 2 in a real terminal.
979- // They should be rendered as '?' placeholders, advancing cursor by 1 each.
980- s.Write([]byte("\xe4\xbd\xa0\xe5\xa5\xbd")) // 你好
981-
982- assertCell(t, s, 0, 0, '?')
983- assertCell(t, s, 1, 0, '?')
984- if s.Col != 2 || s.Row != 0 {
985- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
986- }
987-}
988-
989-func TestCJKMixedWithASCII(t *testing.T) {
990- s := NewScreen(10, 3)
991- s.Write([]byte("A\xe4\xbd\xa0B")) // A你B
992-
993- assertCell(t, s, 0, 0, 'A')
994- assertCell(t, s, 1, 0, '?')
995- assertCell(t, s, 2, 0, 'B')
996- if s.Col != 3 {
997- t.Errorf("cursor at col=%d, want 3", s.Col)
1198+ tests := []struct {
1199+ name string
1200+ }{
1201+ {name: "bold persists across separate Write calls"},
9981202 }
999-}
10001203
1001-func TestEmojiRenderedAsPlaceholder(t *testing.T) {
1002- s := NewScreen(10, 3)
1003- em := []byte("\U0001f600") // 😀 grinning face
1004- s.Write(em)
1204+ for _, tt := range tests {
1205+ t.Run(tt.name, func(t *testing.T) {
1206+ s := NewScreen(10, 3)
1207+ s.Write([]byte("\x1b[1mAB"))
1208+ s.Write([]byte("CD"))
10051209
1006- // Wide emoji is rendered as '?' placeholder.
1007- assertCell(t, s, 0, 0, '?')
1008- if s.Col != 1 {
1009- t.Errorf("cursor at col=%d, want 1", s.Col)
1210+ c1 := cellAt(s, 0, 0)
1211+ c2 := cellAt(s, 2, 0)
1212+ if !c1.Style.Bold || !c2.Style.Bold {
1213+ t.Error("bold should persist across Write calls")
1214+ }
1215+ })
10101216 }
10111217 }
10121218
1013-func TestCombiningMarkSkipped(t *testing.T) {
1014- s := NewScreen(10, 3)
1015- // 'A' followed by combining acute accent U+0301
1016- s.Write([]byte("A\xcc\x81"))
1219+// ---------------------------------------------------------------------------
1220+// Rune width (CJK, emoji, combining marks, zero-width)
1221+// ---------------------------------------------------------------------------
10171222
1018- assertCell(t, s, 0, 0, 'A')
1019- // Combining mark should be skipped — no cell written, no cursor advance.
1020- if s.Col != 1 {
1021- t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col)
1223+func TestWideAndZeroWidthRunes(t *testing.T) {
1224+ tests := []struct {
1225+ name string
1226+ run func(t *testing.T)
1227+ }{
1228+ {
1229+ name: "CJK wide characters render as single-cell placeholders",
1230+ run: func(t *testing.T) {
1231+ s := NewScreen(10, 3)
1232+ // Write two CJK characters that are width 2 in a real terminal.
1233+ // They should be rendered as '?' placeholders, advancing cursor by 1 each.
1234+ s.Write([]byte("\xe4\xbd\xa0\xe5\xa5\xbd")) // 你好
1235+
1236+ assertCell(t, s, 0, 0, '?')
1237+ assertCell(t, s, 1, 0, '?')
1238+ if s.Col != 2 || s.Row != 0 {
1239+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
1240+ }
1241+ },
1242+ },
1243+ {
1244+ name: "CJK mixed with ASCII",
1245+ run: func(t *testing.T) {
1246+ s := NewScreen(10, 3)
1247+ s.Write([]byte("A\xe4\xbd\xa0B")) // A你B
1248+
1249+ assertCell(t, s, 0, 0, 'A')
1250+ assertCell(t, s, 1, 0, '?')
1251+ assertCell(t, s, 2, 0, 'B')
1252+ if s.Col != 3 {
1253+ t.Errorf("cursor at col=%d, want 3", s.Col)
1254+ }
1255+ },
1256+ },
1257+ {
1258+ name: "emoji rendered as placeholder",
1259+ run: func(t *testing.T) {
1260+ s := NewScreen(10, 3)
1261+ em := []byte("\U0001f600") // 😀 grinning face
1262+ s.Write(em)
1263+
1264+ // Wide emoji is rendered as '?' placeholder.
1265+ assertCell(t, s, 0, 0, '?')
1266+ if s.Col != 1 {
1267+ t.Errorf("cursor at col=%d, want 1", s.Col)
1268+ }
1269+ },
1270+ },
1271+ {
1272+ name: "combining mark is skipped without advancing cursor",
1273+ run: func(t *testing.T) {
1274+ s := NewScreen(10, 3)
1275+ // 'A' followed by combining acute accent U+0301
1276+ s.Write([]byte("A\xcc\x81"))
1277+
1278+ assertCell(t, s, 0, 0, 'A')
1279+ // Combining mark should be skipped — no cell written, no cursor advance.
1280+ if s.Col != 1 {
1281+ t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col)
1282+ }
1283+ },
1284+ },
1285+ {
1286+ name: "zero-width characters are skipped without advancing cursor",
1287+ run: func(t *testing.T) {
1288+ s := NewScreen(10, 3)
1289+ // Zero-width space U+200B
1290+ s.Write([]byte("A\xe2\x80\x8bB"))
1291+
1292+ assertCell(t, s, 0, 0, 'A')
1293+ assertCell(t, s, 1, 0, 'B')
1294+ if s.Col != 2 {
1295+ t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col)
1296+ }
1297+ },
1298+ },
1299+ {
1300+ name: "CJK surrounded by ASCII on both sides",
1301+ run: func(t *testing.T) {
1302+ s := NewScreen(10, 3)
1303+ s.Write([]byte("a\xe4\xbd\xa0b\xe5\xa5\xbdc")) // a你b好c
1304+
1305+ assertCell(t, s, 0, 0, 'a')
1306+ assertCell(t, s, 1, 0, '?')
1307+ assertCell(t, s, 2, 0, 'b')
1308+ assertCell(t, s, 3, 0, '?')
1309+ assertCell(t, s, 4, 0, 'c')
1310+ if s.Col != 5 {
1311+ t.Errorf("cursor at col=%d, want 5", s.Col)
1312+ }
1313+ },
1314+ },
1315+ {
1316+ name: "CJK at the edge of the line wraps via scroll on a 1-row screen",
1317+ run: func(t *testing.T) {
1318+ s := NewScreen(2, 1)
1319+ // Write two ASCII chars to fill the line.
1320+ s.Write([]byte("AB")) // Cells = [A B], Col=2
1321+ // CJK at col 2 (edge) triggers newline → scroll on 1-row screen.
1322+ // scrollUp blanks the only row, then '?' is written at col 0.
1323+ s.Write([]byte("\xe4\xbd\xa0")) // 你
1324+
1325+ assertCell(t, s, 0, 0, '?') // CJK placeholder after scroll+blank
1326+ assertCell(t, s, 1, 0, ' ') // untouched
1327+ if s.Col != 1 || s.Row != 0 {
1328+ t.Errorf("cursor at col=%d row=%d, want col=1 row=0", s.Col, s.Row)
1329+ }
1330+ },
1331+ },
1332+ {
1333+ name: "CJK damage width is normalized to 1",
1334+ run: func(t *testing.T) {
1335+ s := NewScreen(10, 3)
1336+ s.ClearDamage()
1337+ s.Write([]byte("\xe4\xbd\xa0")) // 你
1338+
1339+ d := s.Damage()
1340+ if len(d) == 0 {
1341+ t.Fatal("expected damage")
1342+ }
1343+ // After normalization, damage width should be 1, not 2.
1344+ if d[0].Width != 1 {
1345+ t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width)
1346+ }
1347+ },
1348+ },
1349+ {
1350+ name: "emoji damage width is normalized to 1",
1351+ run: func(t *testing.T) {
1352+ s := NewScreen(10, 3)
1353+ s.ClearDamage()
1354+ s.Write([]byte("\U0001f600")) // 😀
1355+
1356+ d := s.Damage()
1357+ if len(d) == 0 {
1358+ t.Fatal("expected damage")
1359+ }
1360+ if d[0].Width != 1 {
1361+ t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width)
1362+ }
1363+ },
1364+ },
1365+ {
1366+ name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
1367+ run: func(t *testing.T) {
1368+ s := NewScreen(10, 3)
1369+ // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute '◌́'
1370+ s.Write([]byte("a\xe4\xbd\xa0\U0001f600b\xcc\x81"))
1371+
1372+ assertCell(t, s, 0, 0, 'a') // 'a'
1373+ assertCell(t, s, 1, 0, '?') // 你 normalized
1374+ assertCell(t, s, 2, 0, '?') // 😀 normalized
1375+ assertCell(t, s, 3, 0, 'b') // 'b'
1376+ // Combining mark skipped, no cell at col 4.
1377+ // Cursor at col 4 (advanced by a, CJK, emoji, b).
1378+ if s.Col != 4 || s.Row != 0 {
1379+ t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
1380+ }
1381+ },
1382+ },
1383+ {
1384+ name: "combining mark alone writes no cell and produces no damage",
1385+ run: func(t *testing.T) {
1386+ s := NewScreen(10, 3)
1387+ s.ClearDamage()
1388+ s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char)
1389+
1390+ // No cells should be modified.
1391+ for y := 0; y < 3; y++ {
1392+ for x := 0; x < 10; x++ {
1393+ if c := cellAt(s, x, y); c.Rune != ' ' {
1394+ t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
1395+ }
1396+ }
1397+ }
1398+ if s.Col != 0 || s.Row != 0 {
1399+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
1400+ }
1401+ d := s.Damage()
1402+ if len(d) != 0 {
1403+ t.Errorf("expected no damage for combining mark alone, got %+v", d)
1404+ }
1405+ },
1406+ },
10221407 }
1023-}
1024-
1025-func TestZeroWidthCharsSkipped(t *testing.T) {
1026- s := NewScreen(10, 3)
1027- // Zero-width space U+200B
1028- s.Write([]byte("A\xe2\x80\x8bB"))
10291408
1030- assertCell(t, s, 0, 0, 'A')
1031- assertCell(t, s, 1, 0, 'B')
1032- if s.Col != 2 {
1033- t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col)
1409+ for _, tt := range tests {
1410+ t.Run(tt.name, tt.run)
10341411 }
10351412 }
10361413
10371414 func TestRuneWidth(t *testing.T) {
10381415 tests := []struct {
1416+ name string
10391417 r rune
10401418 width int
10411419 }{
1042- {' ', 1},
1043- {'a', 1},
1044- {'A', 1},
1045- {'1', 1},
1046- {0x00AD, 0}, // soft hyphen
1047- {0x034F, 0}, // combining grapheme joiner
1048- {0x0301, 0}, // combining acute accent
1049- {0x0300, 0}, // combining grave accent
1050- {0x200B, 0}, // zero-width space
1051- {0x200C, 0}, // zero-width non-joiner
1052- {0x200D, 0}, // zero-width joiner
1053- {0xFEFF, 0}, // BOM / zero-width no-break space
1054- {0x4E00, 2}, // CJK Unified Ideograph (一)
1055- {0x4E8C, 2}, // CJK (二)
1056- {0x9FFF, 2}, // CJK end of BMP
1057- {0x3400, 2}, // CJK Extension A
1058- {0xAC00, 2}, // Hangul Syllable (가)
1059- {0xD7AF, 2}, // Hangul Syllable end
1060- {0x3000, 2}, // CJK Ideographic Space
1061- {0xFF01, 2}, // Fullwidth Exclamation Mark
1062- {0xFFE0, 2}, // Fullwidth Cent Sign
1063- {0x1F600, 2}, // 😀
1064- {0x1F680, 2}, // 🚀
1065- {'\x00', 0},
1066- {'\x01', 0},
1067- {'\x1b', 0},
1068- {0x7F, 0},
1420+ {"space", ' ', 1},
1421+ {"lowercase ascii letter", 'a', 1},
1422+ {"uppercase ascii letter", 'A', 1},
1423+ {"ascii digit", '1', 1},
1424+ {"soft hyphen", 0x00AD, 0},
1425+ {"combining grapheme joiner", 0x034F, 0},
1426+ {"combining acute accent", 0x0301, 0},
1427+ {"combining grave accent", 0x0300, 0},
1428+ {"zero-width space", 0x200B, 0},
1429+ {"zero-width non-joiner", 0x200C, 0},
1430+ {"zero-width joiner", 0x200D, 0},
1431+ {"BOM / zero-width no-break space", 0xFEFF, 0},
1432+ {"CJK Unified Ideograph (一)", 0x4E00, 2},
1433+ {"CJK (二)", 0x4E8C, 2},
1434+ {"CJK end of BMP", 0x9FFF, 2},
1435+ {"CJK Extension A", 0x3400, 2},
1436+ {"Hangul Syllable (가)", 0xAC00, 2},
1437+ {"Hangul Syllable end", 0xD7AF, 2},
1438+ {"CJK Ideographic Space", 0x3000, 2},
1439+ {"Fullwidth Exclamation Mark", 0xFF01, 2},
1440+ {"Fullwidth Cent Sign", 0xFFE0, 2},
1441+ {"grinning face emoji", 0x1F600, 2},
1442+ {"rocket emoji", 0x1F680, 2},
1443+ {"NUL", '\x00', 0},
1444+ {"SOH", '\x01', 0},
1445+ {"ESC", '\x1b', 0},
1446+ {"DEL", 0x7F, 0},
10691447 }
10701448
10711449 for _, tt := range tests {
1072- got := runeWidth(tt.r)
1073- if got != tt.width {
1074- t.Errorf("runeWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
1075- }
1450+ t.Run(tt.name, func(t *testing.T) {
1451+ got := runeWidth(tt.r)
1452+ if got != tt.width {
1453+ t.Errorf("runeWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
1454+ }
1455+ })
10761456 }
10771457 }
10781458
10791459 func TestDamageReturnsReference(t *testing.T) {
1080- // Damage() should return a reference to internal slice (no copy).
1081- s := NewScreen(10, 3)
1082- s.Write([]byte("X"))
1083- d1 := s.Damage()
1084- // Internal slice should be the same (or at least reference the same backing).
1085- if len(d1) == 0 {
1086- t.Fatal("expected damage")
1087- }
1088- // Write more to trigger record.
1089- s.Write([]byte("Y"))
1090- d2 := s.Damage()
1091- _ = d2
1092- // Both d1 and d2 reference the internal slice; we just don't crash.
1093-}
1094-
1095-func TestCJKSurroundedByASCII(t *testing.T) {
1096- s := NewScreen(10, 3)
1097- s.Write([]byte("a\xe4\xbd\xa0b\xe5\xa5\xbdc")) // a你b好c
1098-
1099- assertCell(t, s, 0, 0, 'a')
1100- assertCell(t, s, 1, 0, '?')
1101- assertCell(t, s, 2, 0, 'b')
1102- assertCell(t, s, 3, 0, '?')
1103- assertCell(t, s, 4, 0, 'c')
1104- if s.Col != 5 {
1105- t.Errorf("cursor at col=%d, want 5", s.Col)
1106- }
1107-}
1108-
1109-func TestCJKAtEdgeWraps(t *testing.T) {
1110- s := NewScreen(2, 1)
1111- // Write two ASCII chars to fill the line.
1112- s.Write([]byte("AB")) // Cells = [A B], Col=2
1113- // CJK at col 2 (edge) triggers newline → scroll on 1-row screen.
1114- // scrollUp blanks the only row, then '?' is written at col 0.
1115- s.Write([]byte("\xe4\xbd\xa0")) // 你
1116-
1117- assertCell(t, s, 0, 0, '?') // CJK placeholder after scroll+blank
1118- assertCell(t, s, 1, 0, ' ') // untouched
1119- if s.Col != 1 || s.Row != 0 {
1120- t.Errorf("cursor at col=%d row=%d, want col=1 row=0", s.Col, s.Row)
1121- }
1122-}
1123-
1124-func TestCJKDamageWidthNormalized(t *testing.T) {
1125- s := NewScreen(10, 3)
1126- s.ClearDamage()
1127- s.Write([]byte("\xe4\xbd\xa0")) // 你
1128-
1129- d := s.Damage()
1130- if len(d) == 0 {
1131- t.Fatal("expected damage")
1132- }
1133- // After normalization, damage width should be 1, not 2.
1134- if d[0].Width != 1 {
1135- t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width)
1136- }
1137-}
1138-
1139-func TestEmojiDamageWidthNormalized(t *testing.T) {
1140- s := NewScreen(10, 3)
1141- s.ClearDamage()
1142- s.Write([]byte("\U0001f600")) // 😀
1143-
1144- d := s.Damage()
1145- if len(d) == 0 {
1146- t.Fatal("expected damage")
1147- }
1148- if d[0].Width != 1 {
1149- t.Errorf("damage width = %d, want 1 (normalized)", d[0].Width)
1150- }
1151-}
1152-
1153-func TestMixedCursorAlignment(t *testing.T) {
1154- s := NewScreen(10, 3)
1155- // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute '◌́'
1156- s.Write([]byte("a\xe4\xbd\xa0\U0001f600b\xcc\x81"))
1157-
1158- assertCell(t, s, 0, 0, 'a') // 'a'
1159- assertCell(t, s, 1, 0, '?') // 你 normalized
1160- assertCell(t, s, 2, 0, '?') // 😀 normalized
1161- assertCell(t, s, 3, 0, 'b') // 'b'
1162- // Combining mark skipped, no cell at col 4.
1163- // Cursor at col 4 (advanced by a, CJK, emoji, b).
1164- if s.Col != 4 || s.Row != 0 {
1165- t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
1460+ tests := []struct {
1461+ name string
1462+ }{
1463+ {name: "Damage() returns a reference to the internal slice"},
11661464 }
1167-}
11681465
1169-func TestCombiningMarkNoCellWritten(t *testing.T) {
1170- s := NewScreen(10, 3)
1171- s.ClearDamage()
1172- s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char)
1173-
1174- // No cells should be modified.
1175- for y := 0; y < 3; y++ {
1176- for x := 0; x < 10; x++ {
1177- if c := cellAt(s, x, y); c.Rune != ' ' {
1178- t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
1466+ for _, tt := range tests {
1467+ t.Run(tt.name, func(t *testing.T) {
1468+ // Damage() should return a reference to internal slice (no copy).
1469+ s := NewScreen(10, 3)
1470+ s.Write([]byte("X"))
1471+ d1 := s.Damage()
1472+ // Internal slice should be the same (or at least reference the same backing).
1473+ if len(d1) == 0 {
1474+ t.Fatal("expected damage")
11791475 }
1180- }
1181- }
1182- if s.Col != 0 || s.Row != 0 {
1183- t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
1184- }
1185- d := s.Damage()
1186- if len(d) != 0 {
1187- t.Errorf("expected no damage for combining mark alone, got %+v", d)
1476+ // Write more to trigger record.
1477+ s.Write([]byte("Y"))
1478+ d2 := s.Damage()
1479+ _ = d2
1480+ // Both d1 and d2 reference the internal slice; we just don't crash.
1481+ })
11881482 }
11891483 }
11901484
@@ -1192,124 +1486,148 @@ func TestCombiningMarkNoCellWritten(t *testing.T) {
11921486 // CSI S / SU — Scroll Up
11931487 // ---------------------------------------------------------------------------
11941488
1195-func TestCSI_SCrollUpDefault(t *testing.T) {
1196- s := NewScreen(5, 4)
1197- s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
1198- s.ClearDamage()
1199-
1200- // CSI S with no param → scroll up by 1.
1201- s.Write([]byte("\x1b[S"))
1202-
1203- // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank.
1204- assertCell(t, s, 0, 0, 'B')
1205- assertCell(t, s, 0, 1, 'C')
1206- assertCell(t, s, 0, 2, 'D')
1207- assertCell(t, s, 0, 3, ' ')
1208-
1209- // Cursor should NOT have moved.
1210- if s.Col != 5 || s.Row != 3 {
1211- t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
1212- }
1213-
1214- d := s.Damage()
1215- if !hasDamageKind(d, renderer.DamageScrollUp) {
1216- t.Errorf("expected DamageScrollUp, got %v", damageKinds(d))
1217- }
1218-}
1219-
1220-func TestCSI_SCrollUpWithCount(t *testing.T) {
1221- s := NewScreen(5, 4)
1222- s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
1223- s.ClearDamage()
1224-
1225- // CSI 2 S → scroll up by 2.
1226- s.Write([]byte("\x1b[2S"))
1227-
1228- // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank.
1229- assertCell(t, s, 0, 0, 'C')
1230- assertCell(t, s, 0, 1, 'D')
1231- assertCell(t, s, 0, 2, ' ')
1232- assertCell(t, s, 0, 3, ' ')
1233-
1234- // Cursor preserved.
1235- if s.Col != 5 || s.Row != 3 {
1236- t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
1237- }
1238-}
1239-
1240-func TestCSI_SCrollUpClamp(t *testing.T) {
1241- s := NewScreen(5, 3)
1242- s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
1243- s.ClearDamage()
1244-
1245- // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank).
1246- s.Write([]byte("\x1b[100S"))
1247-
1248- // All rows should be blank.
1249- for y := 0; y < 3; y++ {
1250- for x := 0; x < 5; x++ {
1251- assertCell(t, s, x, y, ' ')
1252- }
1253- }
1254-}
1255-
1256-func TestCSI_SCrollUpCountZero(t *testing.T) {
1257- s := NewScreen(5, 3)
1258- s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
1259- s.ClearDamage()
1260-
1261- // CSI 0 S → default to 1 (parameter 0 means default in VT spec).
1262- s.Write([]byte("\x1b[0S"))
1263-
1264- // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank.
1265- assertCell(t, s, 0, 0, 'B')
1266- assertCell(t, s, 0, 1, 'C')
1267- assertCell(t, s, 0, 2, ' ')
1268-}
1269-
1270-func TestCSI_SCrollUpDamageCount(t *testing.T) {
1271- s := NewScreen(5, 4)
1272- s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
1273- s.ClearDamage()
1274-
1275- s.Write([]byte("\x1b[3S")) // scroll up by 3
1276-
1277- d := s.Damage()
1278- var scrollDamage *renderer.Damage
1279- for i, dd := range d {
1280- if dd.Kind == renderer.DamageScrollUp {
1281- scrollDamage = &d[i]
1282- break
1283- }
1284- }
1285- if scrollDamage == nil {
1286- t.Fatal("expected DamageScrollUp")
1287- }
1288- if scrollDamage.Count != 3 {
1289- t.Errorf("scroll count = %d, want 3", scrollDamage.Count)
1290- }
1291- if scrollDamage.Width != 5 || scrollDamage.Height != 4 {
1292- t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height)
1293- }
1294-
1295- // Should also have text damage for the blanked rows (bottom 3 rows).
1296- if !hasDamageKind(d, renderer.DamageText) {
1297- t.Error("expected DamageText for blanked rows")
1489+func TestCSIScrollUp(t *testing.T) {
1490+ tests := []struct {
1491+ name string
1492+ run func(t *testing.T)
1493+ }{
1494+ {
1495+ name: "CSI S with no param scrolls up by 1 and preserves cursor",
1496+ run: func(t *testing.T) {
1497+ s := NewScreen(5, 4)
1498+ s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
1499+ s.ClearDamage()
1500+
1501+ // CSI S with no param → scroll up by 1.
1502+ s.Write([]byte("\x1b[S"))
1503+
1504+ // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank.
1505+ assertCell(t, s, 0, 0, 'B')
1506+ assertCell(t, s, 0, 1, 'C')
1507+ assertCell(t, s, 0, 2, 'D')
1508+ assertCell(t, s, 0, 3, ' ')
1509+
1510+ // Cursor should NOT have moved.
1511+ if s.Col != 5 || s.Row != 3 {
1512+ t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
1513+ }
1514+
1515+ d := s.Damage()
1516+ if !hasDamageKind(d, renderer.DamageScrollUp) {
1517+ t.Errorf("expected DamageScrollUp, got %v", damageKinds(d))
1518+ }
1519+ },
1520+ },
1521+ {
1522+ name: "CSI 2S scrolls up by an explicit count",
1523+ run: func(t *testing.T) {
1524+ s := NewScreen(5, 4)
1525+ s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
1526+ s.ClearDamage()
1527+
1528+ // CSI 2 S → scroll up by 2.
1529+ s.Write([]byte("\x1b[2S"))
1530+
1531+ // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank.
1532+ assertCell(t, s, 0, 0, 'C')
1533+ assertCell(t, s, 0, 1, 'D')
1534+ assertCell(t, s, 0, 2, ' ')
1535+ assertCell(t, s, 0, 3, ' ')
1536+
1537+ // Cursor preserved.
1538+ if s.Col != 5 || s.Row != 3 {
1539+ t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
1540+ }
1541+ },
1542+ },
1543+ {
1544+ name: "CSI 100S on a 3-row screen clamps to the full screen",
1545+ run: func(t *testing.T) {
1546+ s := NewScreen(5, 3)
1547+ s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
1548+ s.ClearDamage()
1549+
1550+ // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank).
1551+ s.Write([]byte("\x1b[100S"))
1552+
1553+ // All rows should be blank.
1554+ for y := 0; y < 3; y++ {
1555+ for x := 0; x < 5; x++ {
1556+ assertCell(t, s, x, y, ' ')
1557+ }
1558+ }
1559+ },
1560+ },
1561+ {
1562+ name: "CSI 0S defaults to a scroll count of 1",
1563+ run: func(t *testing.T) {
1564+ s := NewScreen(5, 3)
1565+ s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
1566+ s.ClearDamage()
1567+
1568+ // CSI 0 S → default to 1 (parameter 0 means default in VT spec).
1569+ s.Write([]byte("\x1b[0S"))
1570+
1571+ // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank.
1572+ assertCell(t, s, 0, 0, 'B')
1573+ assertCell(t, s, 0, 1, 'C')
1574+ assertCell(t, s, 0, 2, ' ')
1575+ },
1576+ },
1577+ {
1578+ name: "CSI 3S reports scroll count 3 and blanked-row text damage",
1579+ run: func(t *testing.T) {
1580+ s := NewScreen(5, 4)
1581+ s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
1582+ s.ClearDamage()
1583+
1584+ s.Write([]byte("\x1b[3S")) // scroll up by 3
1585+
1586+ d := s.Damage()
1587+ var scrollDamage *renderer.Damage
1588+ for i, dd := range d {
1589+ if dd.Kind == renderer.DamageScrollUp {
1590+ scrollDamage = &d[i]
1591+ break
1592+ }
1593+ }
1594+ if scrollDamage == nil {
1595+ t.Fatal("expected DamageScrollUp")
1596+ }
1597+ if scrollDamage.Count != 3 {
1598+ t.Errorf("scroll count = %d, want 3", scrollDamage.Count)
1599+ }
1600+ if scrollDamage.Width != 5 || scrollDamage.Height != 4 {
1601+ t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height)
1602+ }
1603+
1604+ // Should also have text damage for the blanked rows (bottom 3 rows).
1605+ if !hasDamageKind(d, renderer.DamageText) {
1606+ t.Error("expected DamageText for blanked rows")
1607+ }
1608+ },
1609+ },
1610+ {
1611+ name: "cursor position is preserved across a scroll-up",
1612+ run: func(t *testing.T) {
1613+ s := NewScreen(10, 5)
1614+ s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows
1615+ s.ClearDamage()
1616+
1617+ // Move cursor to middle of row 2.
1618+ s.Write([]byte("\x1b[3;3H")) // row=3, col=3
1619+ s.Write([]byte("\x1b[2S")) // scroll up by 2
1620+
1621+ // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2).
1622+ if s.Row != 2 || s.Col != 2 {
1623+ t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col)
1624+ }
1625+ },
1626+ },
12981627 }
1299-}
13001628
1301-func TestCSI_SCursorPreserved(t *testing.T) {
1302- s := NewScreen(10, 5)
1303- s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows
1304- s.ClearDamage()
1305-
1306- // Move cursor to middle of row 2.
1307- s.Write([]byte("\x1b[3;3H")) // row=3, col=3
1308- s.Write([]byte("\x1b[2S")) // scroll up by 2
1309-
1310- // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2).
1311- if s.Row != 2 || s.Col != 2 {
1312- t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col)
1629+ for _, tt := range tests {
1630+ t.Run(tt.name, tt.run)
13131631 }
13141632 }
13151633
@@ -1317,85 +1635,109 @@ func TestCSI_SCursorPreserved(t *testing.T) {
13171635 // OSC sequences
13181636 // ---------------------------------------------------------------------------
13191637
1320-func TestOSC_BELTerminatedIgnored(t *testing.T) {
1321- s := NewScreen(20, 2)
1322- s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ "))
1323-
1324- assertCell(t, s, 0, 0, 'f')
1325- assertCell(t, s, 1, 0, 'i')
1326- assertCell(t, s, 2, 0, 's')
1327- assertCell(t, s, 3, 0, 'h')
1328- assertCell(t, s, 4, 0, '$')
1329- assertCell(t, s, 5, 0, ' ')
1330- if s.Col != 6 || s.Row != 0 {
1331- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
1332- }
1333-}
1334-
1335-func TestOSC_STTerminatedIgnored(t *testing.T) {
1336- s := NewScreen(20, 2)
1337- s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> "))
1338-
1339- assertCell(t, s, 0, 0, '>')
1340- assertCell(t, s, 1, 0, ' ')
1341- if s.Col != 2 || s.Row != 0 {
1342- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
1343- }
1344-}
1345-
1346-func TestOSC_MultipleSequencesIgnored(t *testing.T) {
1347- s := NewScreen(20, 2)
1348- s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt"))
1349-
1350- assertCell(t, s, 0, 0, 'p')
1351- assertCell(t, s, 1, 0, 'r')
1352- assertCell(t, s, 2, 0, 'o')
1353- assertCell(t, s, 3, 0, 'm')
1354- assertCell(t, s, 4, 0, 'p')
1355- assertCell(t, s, 5, 0, 't')
1356- if s.Col != 6 || s.Row != 0 {
1357- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
1358- }
1359-}
1360-
1361-func TestOSC_BELTerminatedSplitAcrossWritesIgnored(t *testing.T) {
1362- s := NewScreen(20, 2)
1363- s.Write([]byte("\x1b]0;~/Projects/ymux"))
1364- s.Write([]byte(" - fish\x07fish$ "))
1365-
1366- assertCell(t, s, 0, 0, 'f')
1367- assertCell(t, s, 1, 0, 'i')
1368- assertCell(t, s, 2, 0, 's')
1369- assertCell(t, s, 3, 0, 'h')
1370- assertCell(t, s, 4, 0, '$')
1371- assertCell(t, s, 5, 0, ' ')
1372- if s.Col != 6 || s.Row != 0 {
1373- t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
1374- }
1375-}
1376-
1377-func TestOSC_STTerminatedSplitAcrossWritesIgnored(t *testing.T) {
1378- s := NewScreen(20, 2)
1379- s.Write([]byte("\x1b]133;A;click_events=1\x1b"))
1380- s.Write([]byte("\\> "))
1381-
1382- assertCell(t, s, 0, 0, '>')
1383- assertCell(t, s, 1, 0, ' ')
1384- if s.Col != 2 || s.Row != 0 {
1385- t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
1638+func TestOSCSequences(t *testing.T) {
1639+ tests := []struct {
1640+ name string
1641+ run func(t *testing.T)
1642+ }{
1643+ {
1644+ name: "BEL-terminated OSC is ignored",
1645+ run: func(t *testing.T) {
1646+ s := NewScreen(20, 2)
1647+ s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ "))
1648+
1649+ assertCell(t, s, 0, 0, 'f')
1650+ assertCell(t, s, 1, 0, 'i')
1651+ assertCell(t, s, 2, 0, 's')
1652+ assertCell(t, s, 3, 0, 'h')
1653+ assertCell(t, s, 4, 0, '$')
1654+ assertCell(t, s, 5, 0, ' ')
1655+ if s.Col != 6 || s.Row != 0 {
1656+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
1657+ }
1658+ },
1659+ },
1660+ {
1661+ name: "ST-terminated OSC is ignored",
1662+ run: func(t *testing.T) {
1663+ s := NewScreen(20, 2)
1664+ s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> "))
1665+
1666+ assertCell(t, s, 0, 0, '>')
1667+ assertCell(t, s, 1, 0, ' ')
1668+ if s.Col != 2 || s.Row != 0 {
1669+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
1670+ }
1671+ },
1672+ },
1673+ {
1674+ name: "multiple OSC sequences in a row are all ignored",
1675+ run: func(t *testing.T) {
1676+ s := NewScreen(20, 2)
1677+ s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt"))
1678+
1679+ assertCell(t, s, 0, 0, 'p')
1680+ assertCell(t, s, 1, 0, 'r')
1681+ assertCell(t, s, 2, 0, 'o')
1682+ assertCell(t, s, 3, 0, 'm')
1683+ assertCell(t, s, 4, 0, 'p')
1684+ assertCell(t, s, 5, 0, 't')
1685+ if s.Col != 6 || s.Row != 0 {
1686+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
1687+ }
1688+ },
1689+ },
1690+ {
1691+ name: "BEL-terminated OSC split across writes is ignored",
1692+ run: func(t *testing.T) {
1693+ s := NewScreen(20, 2)
1694+ s.Write([]byte("\x1b]0;~/Projects/ymux"))
1695+ s.Write([]byte(" - fish\x07fish$ "))
1696+
1697+ assertCell(t, s, 0, 0, 'f')
1698+ assertCell(t, s, 1, 0, 'i')
1699+ assertCell(t, s, 2, 0, 's')
1700+ assertCell(t, s, 3, 0, 'h')
1701+ assertCell(t, s, 4, 0, '$')
1702+ assertCell(t, s, 5, 0, ' ')
1703+ if s.Col != 6 || s.Row != 0 {
1704+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
1705+ }
1706+ },
1707+ },
1708+ {
1709+ name: "ST-terminated OSC split across writes is ignored",
1710+ run: func(t *testing.T) {
1711+ s := NewScreen(20, 2)
1712+ s.Write([]byte("\x1b]133;A;click_events=1\x1b"))
1713+ s.Write([]byte("\\> "))
1714+
1715+ assertCell(t, s, 0, 0, '>')
1716+ assertCell(t, s, 1, 0, ' ')
1717+ if s.Col != 2 || s.Row != 0 {
1718+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
1719+ }
1720+ },
1721+ },
1722+ {
1723+ name: "unterminated OSC sequence is bounded and dropped",
1724+ run: func(t *testing.T) {
1725+ s := NewScreen(20, 2)
1726+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
1727+ s.Write([]byte("\x1b]0;" + payload))
1728+ s.Write([]byte("OK"))
1729+
1730+ assertCell(t, s, 0, 0, 'O')
1731+ assertCell(t, s, 1, 0, 'K')
1732+ if len(s.escapeBuf) != 0 {
1733+ t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf))
1734+ }
1735+ },
1736+ },
13861737 }
1387-}
1388-
1389-func TestOSC_UnterminatedSequenceIsBoundedAndDropped(t *testing.T) {
1390- s := NewScreen(20, 2)
1391- payload := strings.Repeat("x", maxEscapeBufferLen+1)
1392- s.Write([]byte("\x1b]0;" + payload))
1393- s.Write([]byte("OK"))
13941738
1395- assertCell(t, s, 0, 0, 'O')
1396- assertCell(t, s, 1, 0, 'K')
1397- if len(s.escapeBuf) != 0 {
1398- t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf))
1739+ for _, tt := range tests {
1740+ t.Run(tt.name, tt.run)
13991741 }
14001742 }
14011743
@@ -1403,135 +1745,116 @@ func TestOSC_UnterminatedSequenceIsBoundedAndDropped(t *testing.T) {
14031745 // C1 Unicode controls (U+0080-U+009F)
14041746 // ---------------------------------------------------------------------------
14051747
1406-func TestC1ControlsDropped(t *testing.T) {
1407- s := NewScreen(10, 3)
1408- s.Write([]byte{0xC2, 0x80}) // U+0080 PAD
1409- s.Write([]byte{0xC2, 0x9F}) // U+009F APC
1410-
1411- // No cells should be modified.
1412- for y := 0; y < 3; y++ {
1413- for x := 0; x < 10; x++ {
1414- if c := cellAt(s, x, y); c.Rune != ' ' {
1415- t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune)
1416- }
1417- }
1418- }
1419- // Cursor should not have moved.
1420- if s.Col != 0 || s.Row != 0 {
1421- t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
1422- }
1423-}
1424-
1425-func TestC1ControlsRange(t *testing.T) {
1426- s := NewScreen(10, 3)
1427- // Send all C1 control runes U+0080 through U+009F.
1428- for r := 0x80; r <= 0x9F; r++ {
1429- s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)})
1430- }
1431-
1432- // No cells should be modified.
1433- for y := 0; y < 3; y++ {
1434- for x := 0; x < 10; x++ {
1435- if c := cellAt(s, x, y); c.Rune != ' ' {
1436- t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune)
1437- }
1438- }
1439- }
1440- if s.Col != 0 || s.Row != 0 {
1441- t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
1442- }
1443-}
1444-
1445-func TestC1ControlsAfterPrintable(t *testing.T) {
1446- s := NewScreen(10, 3)
1447- // Printable characters should still work after C1 controls.
1448- s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
1449- s.Write([]byte("AB"))
1450- s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
1451- s.Write([]byte("CD"))
1452-
1453- assertCell(t, s, 0, 0, 'A')
1454- assertCell(t, s, 1, 0, 'B')
1455- assertCell(t, s, 2, 0, 'C')
1456- assertCell(t, s, 3, 0, 'D')
1457- if s.Col != 4 {
1458- t.Errorf("cursor at col=%d, want 4", s.Col)
1459- }
1460-}
1461-
1462-func TestC1ControlsPreserveCRLF(t *testing.T) {
1463- s := NewScreen(10, 3)
1464- // C1 controls mixed with CR/LF should preserve CR/LF behavior.
1465- s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
1466- s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A
1467- s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
1468-
1469- assertCell(t, s, 0, 0, 'B')
1470-}
1471-
1472-func TestC1ControlsPreserveLF(t *testing.T) {
1473- s := NewScreen(10, 3)
1474- s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
1475- s.Write([]byte("A\nB"))
1476-
1477- assertCell(t, s, 0, 0, 'A')
1478- assertCell(t, s, 1, 1, 'B')
1479- if s.Row != 1 || s.Col != 2 {
1480- t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col)
1481- }
1482-}
1483-
1484-func TestC1ControlsNoDamage(t *testing.T) {
1485- s := NewScreen(10, 3)
1486- s.ClearDamage()
1487- // C1 controls should produce no damage.
1488- s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F})
1489-
1490- d := s.Damage()
1491- if len(d) != 0 {
1492- t.Errorf("expected no damage from C1 controls, got %+v", d)
1493- }
1494-}
1495-
1496-func TestEraseDisplayModes(t *testing.T) {
1497- s := NewScreen(5, 3)
1498- s.Write([]byte("abcdefghijklmno"))
1499- s.Write([]byte("\x1b[2;3H"))
1500- s.ClearDamage()
1501- s.Write([]byte("\x1b[J"))
1502- assertCell(t, s, 0, 0, 'a')
1503- assertCell(t, s, 1, 1, 'g')
1504- if c := cellAt(s, 2, 1); c.Rune != ' ' {
1505- t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune)
1506- }
1507-
1508- s = NewScreen(5, 3)
1509- s.Write([]byte("abcdefghijklmno"))
1510- s.Write([]byte("\x1b[2;3H"))
1511- s.Write([]byte("\x1b[1J"))
1512- if c := cellAt(s, 0, 0); c.Rune != ' ' {
1513- t.Fatalf("start-to-cursor clear left %q at start", c.Rune)
1748+func TestC1Controls(t *testing.T) {
1749+ tests := []struct {
1750+ name string
1751+ run func(t *testing.T)
1752+ }{
1753+ {
1754+ name: "C1 controls are dropped without moving the cursor",
1755+ run: func(t *testing.T) {
1756+ s := NewScreen(10, 3)
1757+ s.Write([]byte{0xC2, 0x80}) // U+0080 PAD
1758+ s.Write([]byte{0xC2, 0x9F}) // U+009F APC
1759+
1760+ // No cells should be modified.
1761+ for y := 0; y < 3; y++ {
1762+ for x := 0; x < 10; x++ {
1763+ if c := cellAt(s, x, y); c.Rune != ' ' {
1764+ t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune)
1765+ }
1766+ }
1767+ }
1768+ // Cursor should not have moved.
1769+ if s.Col != 0 || s.Row != 0 {
1770+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
1771+ }
1772+ },
1773+ },
1774+ {
1775+ name: "the full C1 control range U+0080-U+009F is dropped",
1776+ run: func(t *testing.T) {
1777+ s := NewScreen(10, 3)
1778+ // Send all C1 control runes U+0080 through U+009F.
1779+ for r := 0x80; r <= 0x9F; r++ {
1780+ s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)})
1781+ }
1782+
1783+ // No cells should be modified.
1784+ for y := 0; y < 3; y++ {
1785+ for x := 0; x < 10; x++ {
1786+ if c := cellAt(s, x, y); c.Rune != ' ' {
1787+ t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune)
1788+ }
1789+ }
1790+ }
1791+ if s.Col != 0 || s.Row != 0 {
1792+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
1793+ }
1794+ },
1795+ },
1796+ {
1797+ name: "printable characters still work after C1 controls",
1798+ run: func(t *testing.T) {
1799+ s := NewScreen(10, 3)
1800+ // Printable characters should still work after C1 controls.
1801+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
1802+ s.Write([]byte("AB"))
1803+ s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
1804+ s.Write([]byte("CD"))
1805+
1806+ assertCell(t, s, 0, 0, 'A')
1807+ assertCell(t, s, 1, 0, 'B')
1808+ assertCell(t, s, 2, 0, 'C')
1809+ assertCell(t, s, 3, 0, 'D')
1810+ if s.Col != 4 {
1811+ t.Errorf("cursor at col=%d, want 4", s.Col)
1812+ }
1813+ },
1814+ },
1815+ {
1816+ name: "C1 controls mixed with CR preserve CR behavior",
1817+ run: func(t *testing.T) {
1818+ s := NewScreen(10, 3)
1819+ // C1 controls mixed with CR/LF should preserve CR/LF behavior.
1820+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
1821+ s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A
1822+ s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
1823+
1824+ assertCell(t, s, 0, 0, 'B')
1825+ },
1826+ },
1827+ {
1828+ name: "C1 controls mixed with LF preserve LF behavior",
1829+ run: func(t *testing.T) {
1830+ s := NewScreen(10, 3)
1831+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
1832+ s.Write([]byte("A\nB"))
1833+
1834+ assertCell(t, s, 0, 0, 'A')
1835+ assertCell(t, s, 1, 1, 'B')
1836+ if s.Row != 1 || s.Col != 2 {
1837+ t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col)
1838+ }
1839+ },
1840+ },
1841+ {
1842+ name: "C1 controls produce no damage",
1843+ run: func(t *testing.T) {
1844+ s := NewScreen(10, 3)
1845+ s.ClearDamage()
1846+ // C1 controls should produce no damage.
1847+ s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F})
1848+
1849+ d := s.Damage()
1850+ if len(d) != 0 {
1851+ t.Errorf("expected no damage from C1 controls, got %+v", d)
1852+ }
1853+ },
1854+ },
15141855 }
1515- assertCell(t, s, 3, 1, 'i')
1516-}
15171856
1518-func TestEraseLineModes(t *testing.T) {
1519- s := NewScreen(5, 2)
1520- s.Write([]byte("abcde"))
1521- s.Write([]byte("\x1b[1;3H"))
1522- s.Write([]byte("\x1b[1K"))
1523- if c := cellAt(s, 0, 0); c.Rune != ' ' {
1524- t.Fatalf("line start clear left %q", c.Rune)
1525- }
1526- assertCell(t, s, 3, 0, 'd')
1527-
1528- s = NewScreen(5, 2)
1529- s.Write([]byte("abcde"))
1530- s.Write([]byte("\x1b[1;3H"))
1531- s.Write([]byte("\x1b[2K"))
1532- for x := 0; x < 5; x++ {
1533- if c := cellAt(s, x, 0); c.Rune != ' ' {
1534- t.Fatalf("line clear left %q at %d", c.Rune, x)
1535- }
1857+ for _, tt := range tests {
1858+ t.Run(tt.name, tt.run)
15361859 }
15371860 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close