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 }
@@ -41,37 +41,44 @@ func outputEndsWith(t *testing.T, data []byte, suffix string) {
41 // ---------------------------------------------------------------------------41 // ---------------------------------------------------------------------------
42 42
43 func TestFirstDraw(t *testing.T) {43 func TestFirstDraw(t *testing.T) {
44- r := New(Capabilities{})44+ tests := []struct {
45- frame := NewFrame(5, 3)45+ name string
46- markFrame(&frame)46+ damage []Damage
47-47+ minCSICount int
48- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}})48+ }{
49- if err != nil {49+ {
50- t.Fatal(err)50+ name: "explicit text damage",
51- }51+ damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
52- if len(out) == 0 {52+ minCSICount: 3,
53- t.Fatal("expected non-empty output on first draw")53+ },
54- }54+ {
55- outputContains(t, out, "\x1b[0m")55+ name: "full redraw damage",
56- // Should have cursor-position sequences for all three rows.56+ damage: []Damage{FullRedraw()},
57- if c := strings.Count(string(out), "\x1b["); c < 3 {57+ },
58- t.Fatalf("expected at least 3 CSI sequences, got %d", c)
59 }58 }
60-}
61 59
62-func TestFirstDrawFullRedraw(t *testing.T) {60+ for _, tt := range tests {
63- r := New(Capabilities{})61+ t.Run(tt.name, func(t *testing.T) {
64- frame := NewFrame(5, 3)62+ r := New(Capabilities{})
65- markFrame(&frame)63+ frame := NewFrame(5, 3)
64+ markFrame(&frame)
66 65
67- out, err := r.Draw(frame, []Damage{FullRedraw()})66+ out, err := r.Draw(frame, tt.damage)
68- if err != nil {67+ if err != nil {
69- t.Fatal(err)68+ t.Fatal(err)
70- }69+ }
71- if len(out) == 0 {70+ if len(out) == 0 {
72- t.Fatal("expected non-empty output on full redraw")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+ })
73 }81 }
74- outputContains(t, out, "\x1b[0m")
75 }82 }
76 83
77 // ---------------------------------------------------------------------------84 // ---------------------------------------------------------------------------
@@ -79,47 +86,53 @@ func TestFirstDrawFullRedraw(t *testing.T) {
79 // ---------------------------------------------------------------------------86 // ---------------------------------------------------------------------------
80 87
81 func TestNoOp(t *testing.T) {88 func TestNoOp(t *testing.T) {
82- r := New(Capabilities{})89+ tests := []struct {
83- frame := NewFrame(5, 3)90+ name string
84- markFrame(&frame)91+ width int
85-92+ height int
86- // First draw – populate shadow.93+ firstDamage []Damage
87- out1, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}})94+ secondDamage []Damage
88- if err != nil {95+ }{
89- t.Fatal(err)96+ {
90- }97+ name: "nil damage on unchanged frame",
91- if len(out1) == 0 {98+ width: 5,
92- t.Fatal("first draw must produce output")99+ height: 3,
93- }100+ firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
94-101+ secondDamage: nil,
95- // Second draw with no changes.102+ },
96- out2, err := r.Draw(frame, nil)103+ {
97- if err != nil {104+ name: "empty damage slice on unchanged frame",
98- t.Fatal(err)105+ width: 3,
99- }106+ height: 2,
100- if len(out2) != 0 {107+ firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}},
101- t.Fatalf("expected no-op (empty output), got %q", string(out2))108+ secondDamage: []Damage{},
109+ },
102 }110 }
103-}
104 111
105-func TestNoOpWithEmptyDamage(t *testing.T) {112+ for _, tt := range tests {
106- r := New(Capabilities{})113+ t.Run(tt.name, func(t *testing.T) {
107- frame := NewFrame(3, 2)114+ r := New(Capabilities{})
108- markFrame(&frame)115+ frame := NewFrame(tt.width, tt.height)
109-116+ markFrame(&frame)
110- // Populate shadow.117+
111- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})118+ // First draw – populate shadow.
112- if err != nil {119+ out1, err := r.Draw(frame, tt.firstDamage)
113- t.Fatal(err)120+ if err != nil {
114- }121+ t.Fatal(err)
122+ }
123+ if len(out1) == 0 {
124+ t.Fatal("first draw must produce output")
125+ }
115 126
116- // Same frame, empty damage slice.127+ // Second draw with no changes.
117- out, err := r.Draw(frame, []Damage{})128+ out2, err := r.Draw(frame, tt.secondDamage)
118- if err != nil {129+ if err != nil {
119- t.Fatal(err)130+ t.Fatal(err)
120- }131+ }
121- if len(out) != 0 {132+ if len(out2) != 0 {
122- t.Fatalf("expected empty output for no changes, got %q", string(out))133+ t.Fatalf("expected no-op (empty output), got %q", string(out2))
134+ }
135+ })
123 }136 }
124 }137 }
125 138
@@ -127,103 +140,156 @@ func TestNoOpWithEmptyDamage(t *testing.T) {
127 // Style reset discipline140 // Style reset discipline
128 // ---------------------------------------------------------------------------141 // ---------------------------------------------------------------------------
129 142
130-func TestStyleReset(t *testing.T) {143+func TestStyleResetDiscipline(t *testing.T) {
131- r := New(Capabilities{})144+ tests := []struct {
132- frame := NewFrame(4, 2)145+ name string
133-146+ run func(t *testing.T)
134- // One styled cell, rest default.147+ }{
135- frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}})148+ {
136-149+ name: "single styled cell among default cells",
137- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})150+ run: func(t *testing.T) {
138- if err != nil {151+ r := New(Capabilities{})
139- t.Fatal(err)152+ frame := NewFrame(4, 2)
140- }153+
141- // Must end with style reset.154+ // One styled cell, rest default.
142- outputEndsWith(t, out, "\x1b[0m")155+ frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}})
143-}156+
144-157+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
145-func TestStyleEqualUsesActiveColorMode(t *testing.T) {158+ if err != nil {
146- left := Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}159+ t.Fatal(err)
147- right := Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1}160+ }
148- if !left.Equal(right) {161+ // Must end with style reset.
149- t.Fatal("RGB foreground equality should ignore inactive indexed foreground")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+ },
150 }199 }
151 200
152- right.ForegroundRGB.R = 13201+ for _, tt := range tests {
153- if left.Equal(right) {202+ t.Run(tt.name, tt.run)
154- t.Fatal("RGB foreground equality should compare active RGB values")
155 }203 }
156 }204 }
157 205
158-func TestRendererEmitsTruecolorSGR(t *testing.T) {206+func TestStyleEqualUsesActiveColorMode(t *testing.T) {
159- r := New(Capabilities{})207+ tests := []struct {
160- frame := NewFrame(1, 1)208+ name string
161- frame.Set(0, 0, Cell{209+ left Style
162- Rune: 'X',210+ right Style
163- Style: Style{211+ wantEqual bool
164- Foreground: -1,212+ }{
165- Background: -1,213+ {
166- HasForegroundRGB: true,214+ name: "ignores inactive indexed foreground",
167- ForegroundRGB: RGB{R: 12, G: 34, B: 56},215+ left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
168- HasBackgroundRGB: true,216+ right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
169- BackgroundRGB: RGB{R: 200, G: 100, B: 50},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,
170 },224 },
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)
176 }225 }
177 226
178- outputContains(t, out, "\x1b[0;38;2;12;34;56;48;2;200;100;50m")227+ for _, tt := range tests {
179- outputContains(t, out, "X")228+ t.Run(tt.name, func(t *testing.T) {
180-}229+ if got := tt.left.Equal(tt.right); got != tt.wantEqual {
181-230+ t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual)
182-func TestRendererEmitsStyleChangesForAdjacentColorModes(t *testing.T) {231+ }
183- r := New(Capabilities{})232+ })
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)
192 }233 }
193-
194- outputContains(t, out, "\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD")
195 }234 }
196 235
197-func TestStyleResetAfterScroll(t *testing.T) {236+func TestRendererEmitsSGR(t *testing.T) {
198- r := New(Capabilities{})237+ tests := []struct {
199- frame := NewFrame(4, 3)238+ name string
200- markFrame(&frame)239+ width int
201-240+ height int
202- // Populate shadow.241+ setup func(frame Frame)
203- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})242+ damage []Damage
204- if err != nil {243+ wantAll []string
205- t.Fatal(err)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+ },
206 }277 }
207 278
208- // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.279+ for _, tt := range tests {
209- frame2 := NewFrame(4, 3)280+ t.Run(tt.name, func(t *testing.T) {
210- copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2281+ r := New(Capabilities{})
211- for i := 8; i < 12; i++ {282+ frame := NewFrame(tt.width, tt.height)
212- frame2.Cells[i] = BlankCell() // bottom row blank283+ tt.setup(frame)
213- }
214- frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
215 284
216- damage := []Damage{285+ out, err := r.Draw(frame, tt.damage)
217- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},286+ if err != nil {
218- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},287+ t.Fatal(err)
219- }288+ }
220 289
221- out, err := r.Draw(frame2, damage)290+ outputContains(t, out, tt.wantAll...)
222- if err != nil {291+ })
223- t.Fatal(err)
224 }292 }
225- // The output must end with a style reset (either from emitScrollUp or writeDamage).
226- outputEndsWith(t, out, "\x1b[0m")
227 }293 }
228 294
229 // ---------------------------------------------------------------------------295 // ---------------------------------------------------------------------------
@@ -231,45 +297,55 @@ func TestStyleResetAfterScroll(t *testing.T) {
231 // ---------------------------------------------------------------------------297 // ---------------------------------------------------------------------------
232 298
233 func TestScrollFastPath(t *testing.T) {299 func TestScrollFastPath(t *testing.T) {
234- r := New(Capabilities{})300+ tests := []struct {
235- frame := NewFrame(5, 4)301+ name string
236- markFrame(&frame)302+ }{
237-303+ {name: "scroll up by one row exposes new bottom row via scroll region"},
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)
242 }304 }
243 305
244- // Build a new frame that has been scrolled up by 1 (like the VT model does).306+ for _, tt := range tests {
245- scrolled := NewFrame(5, 4)307+ t.Run(tt.name, func(t *testing.T) {
246- copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3308+ r := New(Capabilities{})
247- for i := 15; i < 20; i++ {309+ frame := NewFrame(5, 4)
248- scrolled.Cells[i] = BlankCell() // row 3 blanked310+ markFrame(&frame)
249- }311+
250- scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})312+ // Populate shadow.
251- scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})313+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}})
252- scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()})314+ if err != nil {
315+ t.Fatal(err)
316+ }
253 317
254- damage := []Damage{318+ // Build a new frame that has been scrolled up by 1 (like the VT model does).
255- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},319+ scrolled := NewFrame(5, 4)
256- {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},320+ copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3
257- }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()})
258 327
259- out, err := r.Draw(scrolled, damage)328+ damage := []Damage{
260- if err != nil {329+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
261- t.Fatal(err)330+ {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
262- }331+ }
263- if len(out) == 0 {332+
264- t.Fatal("expected non-empty output for scroll")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+ })
265 }348 }
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")
273 }349 }
274 350
275 // ---------------------------------------------------------------------------351 // ---------------------------------------------------------------------------
@@ -277,40 +353,56 @@ func TestScrollFastPath(t *testing.T) {
277 // ---------------------------------------------------------------------------353 // ---------------------------------------------------------------------------
278 354
279 func TestSynchronizedOutput(t *testing.T) {355 func TestSynchronizedOutput(t *testing.T) {
280- r := New(Capabilities{SynchronizedOutput: true})356+ tests := []struct {
281- frame := NewFrame(3, 2)357+ name string
282- markFrame(&frame)358+ run func(t *testing.T)
283-359+ }{
284- out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})360+ {
285- if err != nil {361+ name: "wraps output in sync CSI",
286- t.Fatal(err)362+ run: func(t *testing.T) {
287- }363+ r := New(Capabilities{SynchronizedOutput: true})
288- if !strings.HasPrefix(string(out), SyncStartCSI) {364+ frame := NewFrame(3, 2)
289- t.Errorf("expected output to start with sync start CSI")365+ markFrame(&frame)
290- }366+
291- if !strings.HasSuffix(string(out), SyncEndCSI) {367+ out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
292- t.Errorf("expected output to end with sync end CSI, got %q", string(out))368+ if err != nil {
293- }369+ t.Fatal(err)
294-}370+ }
295-371+ if !strings.HasPrefix(string(out), SyncStartCSI) {
296-func TestSynchronizedOutputNoOp(t *testing.T) {372+ t.Errorf("expected output to start with sync start CSI")
297- r := New(Capabilities{SynchronizedOutput: true})373+ }
298- frame := NewFrame(3, 2)374+ if !strings.HasSuffix(string(out), SyncEndCSI) {
299- markFrame(&frame)375+ t.Errorf("expected output to end with sync end CSI, got %q", string(out))
300-376+ }
301- // First draw.377+ },
302- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})378+ },
303- if err != nil {379+ {
304- t.Fatal(err)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+ },
305 }402 }
306 403
307- // No-op – must return nil, not wrapped empty output.404+ for _, tt := range tests {
308- out, err := r.Draw(frame, nil)405+ t.Run(tt.name, tt.run)
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))
314 }406 }
315 }407 }
316 408
@@ -320,27 +412,56 @@ func TestSynchronizedOutputNoOp(t *testing.T) {
320 412
321 func TestWrapSynchronized(t *testing.T) {413 func TestWrapSynchronized(t *testing.T) {
322 content := []byte("\x1b[0mhello")414 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- }
333 415
334- // disabled416+ tests := []struct {
335- plain := WrapSynchronized(content, false)417+ name string
336- if string(plain) != string(content) {418+ content []byte
337- t.Errorf("disabled wrapping should return content unchanged")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+ },
338 }458 }
339 459
340- // empty input460+ for _, tt := range tests {
341- empty := WrapSynchronized(nil, true)461+ t.Run(tt.name, func(t *testing.T) {
342- if len(empty) != 0 {462+ got := WrapSynchronized(tt.content, tt.enabled)
343- t.Errorf("empty input should return empty")463+ tt.check(t, got)
464+ })
344 }465 }
345 }466 }
346 467
@@ -349,110 +470,138 @@ func TestWrapSynchronized(t *testing.T) {
349 // ---------------------------------------------------------------------------470 // ---------------------------------------------------------------------------
350 471
351 func TestPartialDamage(t *testing.T) {472 func TestPartialDamage(t *testing.T) {
352- r := New(Capabilities{})473+ tests := []struct {
353- frame := NewFrame(4, 2)474+ name string
354- markFrame(&frame)475+ }{
355-476+ {name: "single changed cell repositions cursor and resets style"},
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)
360 }477 }
361 478
362- // Change one cell.479+ for _, tt := range tests {
363- frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()})480+ t.Run(tt.name, func(t *testing.T) {
364- damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}}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+ }
365 490
366- out, err := r.Draw(frame, damage)491+ // Change one cell.
367- if err != nil {492+ frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
368- t.Fatal(err)493+ damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}}
369- }494+
370- if len(out) == 0 {495+ out, err := r.Draw(frame, damage)
371- t.Fatal("expected non-empty output for partial 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+ })
372 }507 }
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")
377 }508 }
378 509
379 // ---------------------------------------------------------------------------510 // ---------------------------------------------------------------------------
380 // Scheduler coalescing511 // Scheduler coalescing
381 // ---------------------------------------------------------------------------512 // ---------------------------------------------------------------------------
382 513
383-func TestSchedulerCoalescing(t *testing.T) {514+func TestScheduler(t *testing.T) {
384- s := NewScheduler(30 * time.Millisecond)515+ tests := []struct {
385- ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)516+ name string
386- defer cancel()517+ run func(t *testing.T)
387-518+ }{
388- frames := s.Run(ctx)519+ {
389-520+ name: "coalesces bursts of requests into a single frame",
390- // Fire several requests before the timer fires.521+ run: func(t *testing.T) {
391- s.Request()522+ s := NewScheduler(30 * time.Millisecond)
392- s.Request()523+ ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
393- s.Request()524+ defer cancel()
394-525+
395- // Expect exactly one frame.526+ frames := s.Run(ctx)
396- select {527+
397- case _, ok := <-frames:528+ // Fire several requests before the timer fires.
398- if !ok {529+ s.Request()
399- t.Fatal("channel closed unexpectedly")530+ s.Request()
400- }531+ s.Request()
401- case <-ctx.Done():532+
402- t.Fatal("timeout waiting for frame")533+ // Expect exactly one frame.
403- }534+ select {
404-535+ case _, ok := <-frames:
405- // No second frame should arrive without another request.536+ if !ok {
406- select {537+ t.Fatal("channel closed unexpectedly")
407- case <-frames:538+ }
408- t.Fatal("unexpected second frame")539+ case <-ctx.Done():
409- case <-time.After(80 * time.Millisecond):540+ t.Fatal("timeout waiting for frame")
410- // good – coalesced.541+ }
411- }542+
412-}543+ // No second frame should arrive without another request.
413-544+ select {
414-func TestSchedulerMultipleFrames(t *testing.T) {545+ case <-frames:
415- s := NewScheduler(10 * time.Millisecond)546+ t.Fatal("unexpected second frame")
416- ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)547+ case <-time.After(80 * time.Millisecond):
417- defer cancel()548+ // good – coalesced.
418-549+ }
419- frames := s.Run(ctx)550+ },
420-551+ },
421- s.Request()552+ {
422- <-frames // consume first553+ name: "delivers a frame per request without spurious extras",
423-554+ run: func(t *testing.T) {
424- s.Request()555+ s := NewScheduler(10 * time.Millisecond)
425- <-frames // consume second556+ ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
426-557+ defer cancel()
427- // No pending request – should not deliver another frame.558+
428- select {559+ frames := s.Run(ctx)
429- case <-frames:560+
430- t.Fatal("unexpected frame without request")561+ s.Request()
431- case <-time.After(40 * time.Millisecond):562+ <-frames // consume first
432- // good563+
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+ },
433 }601 }
434-}
435-
436-func TestSchedulerContextCancellation(t *testing.T) {
437- s := NewScheduler(50 * time.Millisecond)
438- ctx, cancel := context.WithCancel(context.Background())
439 602
440- frames := s.Run(ctx)603+ for _, tt := range tests {
441- s.Request()604+ t.Run(tt.name, tt.run)
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.
456 }605 }
457 }606 }
458 607
@@ -461,28 +610,38 @@ func TestSchedulerContextCancellation(t *testing.T) {
461 // ---------------------------------------------------------------------------610 // ---------------------------------------------------------------------------
462 611
463 func TestReset(t *testing.T) {612 func TestReset(t *testing.T) {
464- r := New(Capabilities{})613+ tests := []struct {
465- frame := NewFrame(3, 2)614+ name string
466- markFrame(&frame)615+ }{
467-616+ {name: "clears shadow state and forces a full draw next time"},
468- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
469- if err != nil {
470- t.Fatal(err)
471 }617 }
472 618
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)
474 624
475- if r.width != 0 || r.height != 0 || r.shadow != nil {625+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
476- t.Fatal("Reset should clear width/height/shadow")626+ if err != nil {
477- }627+ t.Fatal(err)
628+ }
478 629
479- // After reset, the next draw should be a full draw.630+ r.Reset()
480- out, err := r.Draw(frame, nil)631+
481- if err != nil {632+ if r.width != 0 || r.height != 0 || r.shadow != nil {
482- t.Fatal(err)633+ t.Fatal("Reset should clear width/height/shadow")
483- }634+ }
484- if len(out) == 0 {635+
485- t.Fatal("expected non-empty output after reset")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+ })
486 }645 }
487 }646 }
488 647
@@ -491,25 +650,35 @@ func TestReset(t *testing.T) {
491 // ---------------------------------------------------------------------------650 // ---------------------------------------------------------------------------
492 651
493 func TestFullRedraw(t *testing.T) {652 func TestFullRedraw(t *testing.T) {
494- r := New(Capabilities{})653+ tests := []struct {
495- frame := NewFrame(3, 2)654+ name string
496- markFrame(&frame)655+ }{
497-656+ {name: "forces full output even with an unchanged frame"},
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)
502 }657 }
503 658
504- // FullRedraw forces full output even with same frame.659+ for _, tt := range tests {
505- out, err := r.Draw(frame, []Damage{FullRedraw()})660+ t.Run(tt.name, func(t *testing.T) {
506- if err != nil {661+ r := New(Capabilities{})
507- t.Fatal(err)662+ frame := NewFrame(3, 2)
508- }663+ markFrame(&frame)
509- if len(out) == 0 {664+
510- t.Fatal("expected output for FullRedraw")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+ })
511 }681 }
512- outputEndsWith(t, out, "\x1b[0m")
513 }682 }
514 683
515 // ---------------------------------------------------------------------------684 // ---------------------------------------------------------------------------
@@ -517,39 +686,49 @@ func TestFullRedraw(t *testing.T) {
517 // ---------------------------------------------------------------------------686 // ---------------------------------------------------------------------------
518 687
519 func TestScrollOnlyDamage(t *testing.T) {688 func TestScrollOnlyDamage(t *testing.T) {
520- r := New(Capabilities{})689+ tests := []struct {
521- frame := NewFrame(4, 3)690+ name string
522- markFrame(&frame)691+ }{
523-692+ {name: "scroll damage plus new-row text damage from the VT model"},
524- _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
525- if err != nil {
526- t.Fatal(err)
527 }693 }
528 694
529- // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.695+ for _, tt := range tests {
530- scrolled := NewFrame(4, 3)696+ t.Run(tt.name, func(t *testing.T) {
531- copy(scrolled.Cells[0:8], frame.Cells[4:12])697+ r := New(Capabilities{})
532- for i := 8; i < 12; i++ {698+ frame := NewFrame(4, 3)
533- scrolled.Cells[i] = BlankCell()699+ markFrame(&frame)
534- }
535- scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
536 700
537- damage := []Damage{701+ _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
538- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},702+ if err != nil {
539- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},703+ t.Fatal(err)
540- }704+ }
541 705
542- out, err := r.Draw(scrolled, damage)706+ // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
543- if err != nil {707+ scrolled := NewFrame(4, 3)
544- t.Fatal(err)708+ copy(scrolled.Cells[0:8], frame.Cells[4:12])
545- }709+ for i := 8; i < 12; i++ {
546- if len(out) == 0 {710+ scrolled.Cells[i] = BlankCell()
547- t.Fatal("expected output for scroll")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+ })
548 }731 }
549- // Should include scroll region
550- outputContains(t, out, "\x1b[1;3r")
551- outputContains(t, out, "\x1b[r")
552- outputEndsWith(t, out, "\x1b[0m")
553 }732 }
554 733
555 // ---------------------------------------------------------------------------734 // ---------------------------------------------------------------------------
@@ -557,15 +736,23 @@ func TestScrollOnlyDamage(t *testing.T) {
557 // ---------------------------------------------------------------------------736 // ---------------------------------------------------------------------------
558 737
559 func TestWriteCursor(t *testing.T) {738 func TestWriteCursor(t *testing.T) {
560- var buf bytes.Buffer739+ tests := []struct {
561- writeCursor(&buf, 0, 0)740+ name string
562- if buf.String() != "\x1b[1;1H" {741+ y, x int
563- t.Errorf("unexpected cursor positioning: %q", buf.String())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"},
564 }746 }
565- buf.Reset()747+
566- writeCursor(&buf, 2, 5)748+ for _, tt := range tests {
567- if buf.String() != "\x1b[3;6H" {749+ t.Run(tt.name, func(t *testing.T) {
568- t.Errorf("unexpected cursor positioning: %q", buf.String())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+ })
569 }756 }
570 }757 }
571 758
@@ -605,21 +792,38 @@ func TestIsSafeScroll(t *testing.T) {
605 // ---------------------------------------------------------------------------792 // ---------------------------------------------------------------------------
606 793
607 func TestCellEqual(t *testing.T) {794 func TestCellEqual(t *testing.T) {
608- a := BlankCell()795+ tests := []struct {
609- b := BlankCell()796+ name string
610- if !a.Equal(b) {797+ a Cell
611- t.Error("blank cells should be equal")798+ b Cell
612- }799+ want bool
613-800+ }{
614- a.Rune = 'A'801+ {
615- if a.Equal(b) {802+ name: "blank cells should be equal",
616- t.Error("different runes should not 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+ },
617 }819 }
618 820
619- a = BlankCell()821+ for _, tt := range tests {
620- a.Style.Bold = true822+ t.Run(tt.name, func(t *testing.T) {
621- if a.Equal(b) {823+ if got := tt.a.Equal(tt.b); got != tt.want {
622- t.Error("different styles should not be equal")824+ t.Errorf("Equal() = %v, want %v", got, tt.want)
825+ }
826+ })
623 }827 }
624 }828 }
625 829
@@ -628,39 +832,53 @@ func TestCellEqual(t *testing.T) {
628 // ---------------------------------------------------------------------------832 // ---------------------------------------------------------------------------
629 833
630 func TestFrameValidate(t *testing.T) {834 func TestFrameValidate(t *testing.T) {
631- // Valid frame.835+ tests := []struct {
632- f := NewFrame(5, 3)836+ name string
633- if err := f.Validate(); err != nil {837+ frame Frame
634- t.Fatalf("unexpected error: %v", err)838+ wantErr bool
635- }839+ }{
636-840+ {name: "valid frame", frame: NewFrame(5, 3), wantErr: false},
637- // Invalid dimensions.841+ {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true},
638- f2 := Frame{Width: 0, Height: 5}842+ {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true},
639- if err := f2.Validate(); err == nil {
640- t.Fatal("expected error for zero width")
641 }843 }
642 844
643- // Wrong cell count.845+ for _, tt := range tests {
644- f3 := Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}846+ t.Run(tt.name, func(t *testing.T) {
645- if err := f3.Validate(); err == nil {847+ err := tt.frame.Validate()
646- t.Fatal("expected error for wrong cell count")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+ })
647 }855 }
648 }856 }
649 857
650 func TestFrameAtSet(t *testing.T) {858 func TestFrameAtSet(t *testing.T) {
651- f := NewFrame(3, 2)859+ tests := []struct {
652- cell := Cell{Rune: 'X', Style: Style{Bold: true}}860+ name string
653- f.Set(1, 1, cell)861+ }{
654-862+ {name: "At/Set round-trip and unaffected cells stay blank"},
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)
658 }863 }
659 864
660- // Unaffected cells are blank.865+ for _, tt := range tests {
661- got = f.At(0, 0)866+ t.Run(tt.name, func(t *testing.T) {
662- if got != BlankCell() {867+ f := NewFrame(3, 2)
663- t.Errorf("unexpected cell at (0,0): %+v", got)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+ })
664 }882 }
665 }883 }
666 884
@@ -669,15 +887,32 @@ func TestFrameAtSet(t *testing.T) {
669 // ---------------------------------------------------------------------------887 // ---------------------------------------------------------------------------
670 888
671 func TestSameDamage(t *testing.T) {889 func TestSameDamage(t *testing.T) {
672- a := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}890+ tests := []struct {
673- b := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5}891+ name string
674- if !sameDamage(a, b) {892+ a Damage
675- t.Error("identical damages should be equal")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+ },
676 }908 }
677 909
678- c := Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0}910+ for _, tt := range tests {
679- if sameDamage(a, c) {911+ t.Run(tt.name, func(t *testing.T) {
680- t.Error("different counts should not be equal")912+ if got := sameDamage(tt.a, tt.b); got != tt.want {
913+ t.Errorf("sameDamage() = %v, want %v", got, tt.want)
914+ }
915+ })
681 }916 }
682 }917 }
683 918
@@ -686,20 +921,24 @@ func TestDamageCoversCell(t *testing.T) {
686 {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2},921 {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2},
687 }922 }
688 923
689- if !damageCoversCell(damage, 3, 3) {924+ tests := []struct {
690- t.Error("should cover (3,3)")925+ name string
691- }926+ x, y int
692- if !damageCoversCell(damage, 6, 4) {927+ want bool
693- t.Error("should cover (6,4)")928+ }{
694- }929+ {name: "should cover (3,3)", x: 3, y: 3, want: true},
695- if damageCoversCell(damage, 1, 3) {930+ {name: "should cover (6,4)", x: 6, y: 4, want: true},
696- t.Error("should not cover (1,3)")931+ {name: "should not cover (1,3)", x: 1, y: 3, want: false},
697- }932+ {name: "should not cover (3,5)", x: 3, y: 5, want: false},
698- if damageCoversCell(damage, 3, 5) {933+ {name: "should not cover (7,3)", x: 7, y: 3, want: false},
699- t.Error("should not cover (3,5)")
700 }934 }
701- if damageCoversCell(damage, 7, 3) {935+
702- t.Error("should not cover (7,3)")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+ })
703 }942 }
704 }943 }
705 944
@@ -708,63 +947,85 @@ func TestDamageCoversCell(t *testing.T) {
708 // ---------------------------------------------------------------------------947 // ---------------------------------------------------------------------------
709 948
710 func TestNeedsFull(t *testing.T) {949 func TestNeedsFull(t *testing.T) {
711- if needsFull(nil) {950+ tests := []struct {
712- t.Error("nil damage should not need full")951+ name string
713- }952+ damage []Damage
714- if needsFull([]Damage{}) {953+ want bool
715- t.Error("empty damage should not need full")954+ }{
716- }955+ {name: "nil damage should not need full", damage: nil, want: false},
717- if needsFull([]Damage{{Kind: DamageText}}) {956+ {name: "empty damage should not need full", damage: []Damage{}, want: false},
718- t.Error("text damage should not need full")957+ {name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false},
719- }958+ {name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true},
720- if !needsFull([]Damage{FullRedraw()}) {
721- t.Error("FullRedraw should need full")
722 }959 }
723-}
724 960
725-func TestScrollDamageFallbackFullRedrawWhenFastPathUnsafe(t *testing.T) {961+ for _, tt := range tests {
726- r := New(Capabilities{})962+ t.Run(tt.name, func(t *testing.T) {
727- frame := NewFrame(4, 3)963+ if got := needsFull(tt.damage); got != tt.want {
728- markFrame(&frame)964+ t.Errorf("needsFull() = %v, want %v", got, tt.want)
729- if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil {965+ }
730- t.Fatal(err)966+ })
731 }967 }
968+}
732 969
733- changed := NewFrame(4, 3)970+func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
734- markFrame(&changed)971+ tests := []struct {
735- changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship972+ name string
736- damage := []Damage{973+ run func(t *testing.T)
737- {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},974+ }{
738- {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},975+ {
739- }976+ name: "falls back to full redraw when scroll fast path is unsafe",
740- out, err := r.Draw(changed, damage)977+ run: func(t *testing.T) {
741- if err != nil {978+ r := New(Capabilities{})
742- t.Fatal(err)979+ frame := NewFrame(4, 3)
743- }980+ markFrame(&frame)
744- if rows := strings.Count(string(out), "H"); rows < changed.Height {981+ if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil {
745- t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))982+ t.Fatal(err)
746- }983+ }
747- for i := range changed.Cells {984+
748- if !r.shadow[i].Equal(changed.Cells[i]) {985+ changed := NewFrame(4, 3)
749- t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])986+ markFrame(&changed)
750- }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+ },
751 }1026 }
752-}
753 1027
754-func TestDamageRectanglesAreClamped(t *testing.T) {1028+ for _, tt := range tests {
755- r := New(Capabilities{})1029+ t.Run(tt.name, tt.run)
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)
769 }1030 }
770 }1031 }
M pkg/vt/screen_test.go +1594 -1271 large diff (2865 lines changed): collapsed

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