1package renderer
2
3import (
4 "bytes"
5 "strings"
6 "testing"
7)
8
9// markFrame writes uniform printable content so each row is distinguishable.
10func markFrame(f *Frame) {
11 for y := range f.Height {
12 for x := range f.Width {
13 f.Set(x, y, Cell{Rune: rune('A' + (y*f.Width+x)%26), Style: DefaultStyle()})
14 }
15 }
16}
17
18// outputContains checks that the output contains all the given substrings.
19func outputContains(t *testing.T, data []byte, subs ...string) {
20 t.Helper()
21 s := string(data)
22 for _, sub := range subs {
23 if !strings.Contains(s, sub) {
24 t.Errorf("output should contain %q", sub)
25 }
26 }
27}
28
29func outputEndsWith(t *testing.T, data []byte, suffix string) {
30 t.Helper()
31 s := string(data)
32 if !strings.HasSuffix(s, suffix) {
33 t.Errorf("output should end with %q, got %q", suffix, s)
34 }
35}
36
37// ---------------------------------------------------------------------------
38// First draw
39// ---------------------------------------------------------------------------
40
41func TestFirstDraw(t *testing.T) {
42 tests := []struct {
43 name string
44 damage []Damage
45 minCSICount int
46 }{
47 {
48 name: "explicit text damage",
49 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
50 minCSICount: 3,
51 },
52 {
53 name: "full redraw damage",
54 damage: []Damage{FullRedraw()},
55 },
56 }
57
58 for _, tt := range tests {
59 t.Run(tt.name, func(t *testing.T) {
60 r := New(Capabilities{})
61 frame := NewFrame(5, 3)
62 markFrame(&frame)
63
64 out, err := r.Draw(frame, tt.damage)
65 if err != nil {
66 t.Fatal(err)
67 }
68 if len(out) == 0 {
69 t.Fatal("expected non-empty output on first draw")
70 }
71 outputContains(t, out, "\x1b[0m")
72 if tt.minCSICount > 0 {
73 // Should have cursor-position sequences for all three rows.
74 if c := strings.Count(string(out), "\x1b["); c < tt.minCSICount {
75 t.Fatalf("expected at least %d CSI sequences, got %d", tt.minCSICount, c)
76 }
77 }
78 })
79 }
80}
81
82// ---------------------------------------------------------------------------
83// No-op
84// ---------------------------------------------------------------------------
85
86func TestNoOp(t *testing.T) {
87 tests := []struct {
88 name string
89 width int
90 height int
91 firstDamage []Damage
92 secondDamage []Damage
93 }{
94 {
95 name: "nil damage on unchanged frame",
96 width: 5,
97 height: 3,
98 firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 3}},
99 secondDamage: nil,
100 },
101 {
102 name: "empty damage slice on unchanged frame",
103 width: 3,
104 height: 2,
105 firstDamage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}},
106 secondDamage: []Damage{},
107 },
108 }
109
110 for _, tt := range tests {
111 t.Run(tt.name, func(t *testing.T) {
112 r := New(Capabilities{})
113 frame := NewFrame(tt.width, tt.height)
114 markFrame(&frame)
115
116 // First draw – populate shadow.
117 out1, err := r.Draw(frame, tt.firstDamage)
118 if err != nil {
119 t.Fatal(err)
120 }
121 if len(out1) == 0 {
122 t.Fatal("first draw must produce output")
123 }
124
125 // Second draw with no changes.
126 out2, err := r.Draw(frame, tt.secondDamage)
127 if err != nil {
128 t.Fatal(err)
129 }
130 if len(out2) != 0 {
131 t.Fatalf("expected no-op (empty output), got %q", string(out2))
132 }
133 })
134 }
135}
136
137// ---------------------------------------------------------------------------
138// Style reset discipline
139// ---------------------------------------------------------------------------
140
141func TestStyleResetDiscipline(t *testing.T) {
142 tests := []struct {
143 name string
144 run func(t *testing.T)
145 }{
146 {
147 name: "single styled cell among default cells",
148 run: func(t *testing.T) {
149 r := New(Capabilities{})
150 frame := NewFrame(4, 2)
151
152 // One styled cell, rest default.
153 frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}})
154
155 out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
156 if err != nil {
157 t.Fatal(err)
158 }
159 // Must end with style reset.
160 outputEndsWith(t, out, "\x1b[0m")
161 },
162 },
163 {
164 name: "after scroll damage",
165 run: func(t *testing.T) {
166 r := New(Capabilities{})
167 frame := NewFrame(4, 3)
168 markFrame(&frame)
169
170 // Populate shadow.
171 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
172 if err != nil {
173 t.Fatal(err)
174 }
175
176 // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.
177 frame2 := NewFrame(4, 3)
178 copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2
179 for i := 8; i < 12; i++ {
180 frame2.Cells[i] = BlankCell() // bottom row blank
181 }
182 frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
183
184 damage := []Damage{
185 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
186 {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
187 }
188
189 out, err := r.Draw(frame2, damage)
190 if err != nil {
191 t.Fatal(err)
192 }
193 // The output must end with a style reset (either from emitScrollUp or writeDamage).
194 outputEndsWith(t, out, "\x1b[0m")
195 },
196 },
197 }
198
199 for _, tt := range tests {
200 t.Run(tt.name, tt.run)
201 }
202}
203
204func TestStyleEqualUsesActiveColorMode(t *testing.T) {
205 tests := []struct {
206 name string
207 left Style
208 right Style
209 wantEqual bool
210 }{
211 {
212 name: "ignores inactive indexed foreground",
213 left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
214 right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
215 wantEqual: true,
216 },
217 {
218 name: "compares active RGB values",
219 left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
220 right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 13, G: 34, B: 56}, Background: -1},
221 wantEqual: false,
222 },
223 }
224
225 for _, tt := range tests {
226 t.Run(tt.name, func(t *testing.T) {
227 if got := tt.left.Equal(tt.right); got != tt.wantEqual {
228 t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual)
229 }
230 })
231 }
232}
233
234func TestRendererEmitsSGR(t *testing.T) {
235 tests := []struct {
236 name string
237 width int
238 height int
239 setup func(frame Frame)
240 damage []Damage
241 wantAll []string
242 }{
243 {
244 name: "truecolor foreground and background",
245 width: 1,
246 height: 1,
247 setup: func(frame Frame) {
248 frame.Set(0, 0, Cell{
249 Rune: 'X',
250 Style: Style{
251 Foreground: -1,
252 Background: -1,
253 HasForegroundRGB: true,
254 ForegroundRGB: RGB{R: 12, G: 34, B: 56},
255 HasBackgroundRGB: true,
256 BackgroundRGB: RGB{R: 200, G: 100, B: 50},
257 },
258 })
259 },
260 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
261 wantAll: []string{"\x1b[0;38;2;12;34;56;48;2;200;100;50m", "X"},
262 },
263 {
264 name: "style changes across adjacent color modes",
265 width: 3,
266 height: 1,
267 setup: func(frame Frame) {
268 frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}})
269 frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}})
270 frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()})
271 },
272 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}},
273 wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"},
274 },
275 {
276 name: "bold italic inverse",
277 width: 1,
278 height: 1,
279 setup: func(frame Frame) {
280 frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Italic: true, Inverse: true, Foreground: -1, Background: -1}})
281 },
282 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
283 wantAll: []string{"\x1b[0;1;3;7m", "X"},
284 },
285 {
286 name: "extended SGR attributes and underline color",
287 width: 1,
288 height: 1,
289 setup: func(frame Frame) {
290 frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: -1, Background: -1, Attrs: AttrDim | AttrUnderline | AttrBlink | AttrStrikethrough, UnderlineStyle: UnderlineCurly, HasUnderlineColor: true, UnderlineColor: 9}})
291 },
292 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
293 wantAll: []string{"\x1b[0;2;4:3;5;9;58;5;9m", "X"},
294 },
295 }
296
297 for _, tt := range tests {
298 t.Run(tt.name, func(t *testing.T) {
299 r := New(Capabilities{})
300 frame := NewFrame(tt.width, tt.height)
301 tt.setup(frame)
302
303 out, err := r.Draw(frame, tt.damage)
304 if err != nil {
305 t.Fatal(err)
306 }
307
308 outputContains(t, out, tt.wantAll...)
309 })
310 }
311}
312
313// ---------------------------------------------------------------------------
314// Scroll fast path
315// ---------------------------------------------------------------------------
316
317func TestScrollFastPath(t *testing.T) {
318 tests := []struct {
319 name string
320 }{
321 {name: "scroll up by one row exposes new bottom row via scroll region"},
322 }
323
324 for _, tt := range tests {
325 t.Run(tt.name, func(t *testing.T) {
326 r := New(Capabilities{})
327 frame := NewFrame(5, 4)
328 markFrame(&frame)
329
330 // Populate shadow.
331 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}})
332 if err != nil {
333 t.Fatal(err)
334 }
335
336 // Build a new frame that has been scrolled up by 1 (like the VT model does).
337 scrolled := NewFrame(5, 4)
338 copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3
339 for i := 15; i < 20; i++ {
340 scrolled.Cells[i] = BlankCell() // row 3 blanked
341 }
342 scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})
343 scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})
344 scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()})
345
346 damage := []Damage{
347 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
348 {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
349 }
350
351 out, err := r.Draw(scrolled, damage)
352 if err != nil {
353 t.Fatal(err)
354 }
355 if len(out) == 0 {
356 t.Fatal("expected non-empty output for scroll")
357 }
358 // Should contain scroll-region sequences.
359 outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4
360 outputContains(t, out, "\x1b[r") // restore scroll region
361 // Should contain the new text on the exposed row.
362 outputContains(t, out, "N")
363 outputContains(t, out, "e")
364 outputContains(t, out, "w")
365 })
366 }
367}
368
369// ---------------------------------------------------------------------------
370// Synchronized output wrapper
371// ---------------------------------------------------------------------------
372
373func TestSynchronizedOutput(t *testing.T) {
374 tests := []struct {
375 name string
376 run func(t *testing.T)
377 }{
378 {
379 name: "wraps output in sync CSI",
380 run: func(t *testing.T) {
381 r := New(Capabilities{SynchronizedOutput: true})
382 frame := NewFrame(3, 2)
383 markFrame(&frame)
384
385 out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
386 if err != nil {
387 t.Fatal(err)
388 }
389 if !strings.HasPrefix(string(out), SyncStartCSI) {
390 t.Errorf("expected output to start with sync start CSI")
391 }
392 if !strings.HasSuffix(string(out), SyncEndCSI) {
393 t.Errorf("expected output to end with sync end CSI, got %q", string(out))
394 }
395 },
396 },
397 {
398 name: "no-op returns empty output, not wrapped empty output",
399 run: func(t *testing.T) {
400 r := New(Capabilities{SynchronizedOutput: true})
401 frame := NewFrame(3, 2)
402 markFrame(&frame)
403
404 // First draw.
405 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
406 if err != nil {
407 t.Fatal(err)
408 }
409
410 // No-op – must return nil, not wrapped empty output.
411 out, err := r.Draw(frame, nil)
412 if err != nil {
413 t.Fatal(err)
414 }
415 if len(out) != 0 {
416 t.Fatalf("expected empty output for no-op, got %q", string(out))
417 }
418 },
419 },
420 }
421
422 for _, tt := range tests {
423 t.Run(tt.name, tt.run)
424 }
425}
426
427// ---------------------------------------------------------------------------
428// WrapSynchronized standalone function
429// ---------------------------------------------------------------------------
430
431func TestWrapSynchronized(t *testing.T) {
432 content := []byte("\x1b[0mhello")
433
434 tests := []struct {
435 name string
436 content []byte
437 enabled bool
438 check func(t *testing.T, got []byte)
439 }{
440 {
441 name: "enabled wraps with sync start/end and preserves content",
442 content: content,
443 enabled: true,
444 check: func(t *testing.T, got []byte) {
445 if !strings.HasPrefix(string(got), SyncStartCSI) {
446 t.Errorf("missing sync start")
447 }
448 if !strings.HasSuffix(string(got), SyncEndCSI) {
449 t.Errorf("missing sync end")
450 }
451 if !strings.Contains(string(got), "hello") {
452 t.Errorf("missing original content")
453 }
454 },
455 },
456 {
457 name: "disabled returns content unchanged",
458 content: content,
459 enabled: false,
460 check: func(t *testing.T, got []byte) {
461 if string(got) != string(content) {
462 t.Errorf("disabled wrapping should return content unchanged")
463 }
464 },
465 },
466 {
467 name: "empty input returns empty",
468 content: nil,
469 enabled: true,
470 check: func(t *testing.T, got []byte) {
471 if len(got) != 0 {
472 t.Errorf("empty input should return empty")
473 }
474 },
475 },
476 }
477
478 for _, tt := range tests {
479 t.Run(tt.name, func(t *testing.T) {
480 got := WrapSynchronized(tt.content, tt.enabled)
481 tt.check(t, got)
482 })
483 }
484}
485
486// ---------------------------------------------------------------------------
487// Partial damage draw
488// ---------------------------------------------------------------------------
489
490func TestPartialDamage(t *testing.T) {
491 tests := []struct {
492 name string
493 }{
494 {name: "single changed cell repositions cursor and resets style"},
495 }
496
497 for _, tt := range tests {
498 t.Run(tt.name, func(t *testing.T) {
499 r := New(Capabilities{})
500 frame := NewFrame(4, 2)
501 markFrame(&frame)
502
503 // Populate shadow.
504 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
505 if err != nil {
506 t.Fatal(err)
507 }
508
509 // Change one cell.
510 frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
511 damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}}
512
513 out, err := r.Draw(frame, damage)
514 if err != nil {
515 t.Fatal(err)
516 }
517 if len(out) == 0 {
518 t.Fatal("expected non-empty output for partial damage")
519 }
520 // Should position cursor at (2,1) — 1-indexed (3;2).
521 outputContains(t, out, "\x1b[2;3H")
522 outputContains(t, out, "Z")
523 outputEndsWith(t, out, "\x1b[0m")
524 })
525 }
526}
527
528// ---------------------------------------------------------------------------
529// Reset
530// ---------------------------------------------------------------------------
531
532func TestReset(t *testing.T) {
533 tests := []struct {
534 name string
535 }{
536 {name: "clears shadow state and forces a full draw next time"},
537 }
538
539 for _, tt := range tests {
540 t.Run(tt.name, func(t *testing.T) {
541 r := New(Capabilities{})
542 frame := NewFrame(3, 2)
543 markFrame(&frame)
544
545 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
546 if err != nil {
547 t.Fatal(err)
548 }
549
550 r.Reset()
551
552 if r.width != 0 || r.height != 0 || r.shadow != nil {
553 t.Fatal("Reset should clear width/height/shadow")
554 }
555
556 // After reset, the next draw should be a full draw.
557 out, err := r.Draw(frame, nil)
558 if err != nil {
559 t.Fatal(err)
560 }
561 if len(out) == 0 {
562 t.Fatal("expected non-empty output after reset")
563 }
564 })
565 }
566}
567
568// ---------------------------------------------------------------------------
569// Damage helper
570// ---------------------------------------------------------------------------
571
572func TestFullRedraw(t *testing.T) {
573 tests := []struct {
574 name string
575 run func(t *testing.T)
576 }{
577 {
578 name: "known same-size full redraw diffs against shadow",
579 run: func(t *testing.T) {
580 r := New(Capabilities{})
581 frame := NewFrame(3, 2)
582 markFrame(&frame)
583
584 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
585 t.Fatal(err)
586 }
587
588 changed := frame
589 changed.Set(0, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
590 out, err := r.Draw(changed, []Damage{FullRedraw()})
591 if err != nil {
592 t.Fatal(err)
593 }
594 got := string(out)
595 if strings.Contains(got, "ABC") {
596 t.Fatalf("unchanged row was re-emitted on dimension-stable FullRedraw: %q", got)
597 }
598 if !strings.Contains(got, "ZEF") {
599 t.Fatalf("changed row was not emitted on dimension-stable FullRedraw: %q", got)
600 }
601 outputEndsWith(t, out, "\x1b[0m")
602 },
603 },
604 {
605 name: "unchanged known same-size full redraw is a no-op",
606 run: func(t *testing.T) {
607 r := New(Capabilities{})
608 frame := NewFrame(3, 2)
609 markFrame(&frame)
610
611 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
612 t.Fatal(err)
613 }
614 out, err := r.Draw(frame, []Damage{FullRedraw()})
615 if err != nil {
616 t.Fatal(err)
617 }
618 if len(out) != 0 {
619 t.Fatalf("unchanged dimension-stable FullRedraw emitted output: %q", string(out))
620 }
621 },
622 },
623 {
624 name: "reset full redraw emits complete frame",
625 run: func(t *testing.T) {
626 r := New(Capabilities{})
627 frame := NewFrame(3, 2)
628 markFrame(&frame)
629
630 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
631 t.Fatal(err)
632 }
633 r.Reset()
634
635 out, err := r.Draw(frame, []Damage{FullRedraw()})
636 if err != nil {
637 t.Fatal(err)
638 }
639 got := string(out)
640 if !strings.Contains(got, "ABC") || !strings.Contains(got, "DEF") {
641 t.Fatalf("reset FullRedraw did not emit complete frame: %q", got)
642 }
643 outputEndsWith(t, out, "\x1b[0m")
644 },
645 },
646 }
647
648 for _, tt := range tests {
649 t.Run(tt.name, tt.run)
650 }
651}
652
653// ---------------------------------------------------------------------------
654// Scroll with no remaining damage
655// ---------------------------------------------------------------------------
656
657func TestScrollOnlyDamage(t *testing.T) {
658 tests := []struct {
659 name string
660 }{
661 {name: "scroll damage plus new-row text damage from the VT model"},
662 }
663
664 for _, tt := range tests {
665 t.Run(tt.name, func(t *testing.T) {
666 r := New(Capabilities{})
667 frame := NewFrame(4, 3)
668 markFrame(&frame)
669
670 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
671 if err != nil {
672 t.Fatal(err)
673 }
674
675 // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
676 scrolled := NewFrame(4, 3)
677 copy(scrolled.Cells[0:8], frame.Cells[4:12])
678 for i := 8; i < 12; i++ {
679 scrolled.Cells[i] = BlankCell()
680 }
681 scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
682
683 damage := []Damage{
684 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
685 {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
686 }
687
688 out, err := r.Draw(scrolled, damage)
689 if err != nil {
690 t.Fatal(err)
691 }
692 if len(out) == 0 {
693 t.Fatal("expected output for scroll")
694 }
695 // Should include scroll region
696 outputContains(t, out, "\x1b[1;3r")
697 outputContains(t, out, "\x1b[r")
698 outputEndsWith(t, out, "\x1b[0m")
699 })
700 }
701}
702
703// ---------------------------------------------------------------------------
704// writeCursor output format
705// ---------------------------------------------------------------------------
706
707func TestWriteCursor(t *testing.T) {
708 tests := []struct {
709 name string
710 y, x int
711 want string
712 }{
713 {name: "origin", y: 0, x: 0, want: "\x1b[1;1H"},
714 {name: "offset", y: 2, x: 5, want: "\x1b[3;6H"},
715 }
716
717 for _, tt := range tests {
718 t.Run(tt.name, func(t *testing.T) {
719 var buf bytes.Buffer
720 writeCursor(&buf, tt.y, tt.x)
721 if buf.String() != tt.want {
722 t.Errorf("unexpected cursor positioning: %q", buf.String())
723 }
724 })
725 }
726}
727
728// ---------------------------------------------------------------------------
729// isSafeScroll edge cases
730// ---------------------------------------------------------------------------
731
732func TestIsSafeScroll(t *testing.T) {
733 frame := NewFrame(10, 8)
734
735 tests := []struct {
736 name string
737 d Damage
738 want bool
739 }{
740 {"full width, partial height", Damage{X: 0, Y: 1, Width: 10, Height: 5, Count: 2}, true},
741 {"not full width", Damage{X: 1, Y: 1, Width: 8, Height: 5, Count: 2}, false},
742 {"count zero", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 0}, false},
743 {"count equals height", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 8}, false},
744 {"extends past frame", Damage{X: 0, Y: 6, Width: 10, Height: 6, Count: 2}, false},
745 {"negative Y", Damage{X: 0, Y: -1, Width: 10, Height: 5, Count: 1}, false},
746 {"height zero", Damage{X: 0, Y: 0, Width: 10, Height: 0, Count: 1}, false},
747 }
748
749 for _, tt := range tests {
750 t.Run(tt.name, func(t *testing.T) {
751 got := isSafeScroll(frame, tt.d)
752 if got != tt.want {
753 t.Errorf("isSafeScroll(%+v) = %v, want %v", tt.d, got, tt.want)
754 }
755 })
756 }
757}
758
759// ---------------------------------------------------------------------------
760// BlankCell / Cell.Equal
761// ---------------------------------------------------------------------------
762
763func TestCellEqual(t *testing.T) {
764 tests := []struct {
765 name string
766 a Cell
767 b Cell
768 want bool
769 }{
770 {
771 name: "blank cells should be equal",
772 a: BlankCell(),
773 b: BlankCell(),
774 want: true,
775 },
776 {
777 name: "different runes should not be equal",
778 a: Cell{Rune: 'A', Style: DefaultStyle()},
779 b: BlankCell(),
780 want: false,
781 },
782 {
783 name: "different styles should not be equal",
784 a: Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}},
785 b: BlankCell(),
786 want: false,
787 },
788 {
789 name: "continuation flag participates in equality",
790 a: Cell{Continuation: true},
791 b: Cell{},
792 want: false,
793 },
794 {
795 name: "two continuation cells are equal",
796 a: Cell{Continuation: true},
797 b: Cell{Continuation: true},
798 want: true,
799 },
800 }
801
802 for _, tt := range tests {
803 t.Run(tt.name, func(t *testing.T) {
804 if got := tt.a.Equal(tt.b); got != tt.want {
805 t.Errorf("Equal() = %v, want %v", got, tt.want)
806 }
807 })
808 }
809}
810
811// ---------------------------------------------------------------------------
812// Frame helpers
813// ---------------------------------------------------------------------------
814
815func TestFrameValidate(t *testing.T) {
816 tests := []struct {
817 name string
818 frame Frame
819 wantErr bool
820 }{
821 {name: "valid frame", frame: NewFrame(5, 3), wantErr: false},
822 {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true},
823 {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true},
824 }
825
826 for _, tt := range tests {
827 t.Run(tt.name, func(t *testing.T) {
828 err := tt.frame.Validate()
829 if tt.wantErr && err == nil {
830 t.Fatal("expected error, got nil")
831 }
832 if !tt.wantErr && err != nil {
833 t.Fatalf("unexpected error: %v", err)
834 }
835 })
836 }
837}
838
839func TestFrameAtSet(t *testing.T) {
840 tests := []struct {
841 name string
842 }{
843 {name: "At/Set round-trip and unaffected cells stay blank"},
844 }
845
846 for _, tt := range tests {
847 t.Run(tt.name, func(t *testing.T) {
848 f := NewFrame(3, 2)
849 cell := Cell{Rune: 'X', Style: Style{Bold: true}}
850 f.Set(1, 1, cell)
851
852 got := f.At(1, 1)
853 if got.Rune != 'X' || !got.Style.Bold {
854 t.Errorf("At/Set round-trip failed: got %+v", got)
855 }
856
857 // Unaffected cells are blank.
858 got = f.At(0, 0)
859 if got != BlankCell() {
860 t.Errorf("unexpected cell at (0,0): %+v", got)
861 }
862 })
863 }
864}
865
866// ---------------------------------------------------------------------------
867// Damage helpers
868// ---------------------------------------------------------------------------
869
870func TestSameDamage(t *testing.T) {
871 tests := []struct {
872 name string
873 a Damage
874 b Damage
875 want bool
876 }{
877 {
878 name: "identical damages should be equal",
879 a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
880 b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
881 want: true,
882 },
883 {
884 name: "different counts should not be equal",
885 a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
886 b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0},
887 want: false,
888 },
889 }
890
891 for _, tt := range tests {
892 t.Run(tt.name, func(t *testing.T) {
893 if got := sameDamage(tt.a, tt.b); got != tt.want {
894 t.Errorf("sameDamage() = %v, want %v", got, tt.want)
895 }
896 })
897 }
898}
899
900func TestDamageCoversCell(t *testing.T) {
901 damage := []Damage{
902 {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2},
903 }
904
905 tests := []struct {
906 name string
907 x, y int
908 want bool
909 }{
910 {name: "should cover (3,3)", x: 3, y: 3, want: true},
911 {name: "should cover (6,4)", x: 6, y: 4, want: true},
912 {name: "should not cover (1,3)", x: 1, y: 3, want: false},
913 {name: "should not cover (3,5)", x: 3, y: 5, want: false},
914 {name: "should not cover (7,3)", x: 7, y: 3, want: false},
915 }
916
917 for _, tt := range tests {
918 t.Run(tt.name, func(t *testing.T) {
919 if got := damageCoversCell(damage, tt.x, tt.y); got != tt.want {
920 t.Errorf("damageCoversCell(%d,%d) = %v, want %v", tt.x, tt.y, got, tt.want)
921 }
922 })
923 }
924}
925
926// ---------------------------------------------------------------------------
927// needsFull
928// ---------------------------------------------------------------------------
929
930func TestNeedsFull(t *testing.T) {
931 tests := []struct {
932 name string
933 damage []Damage
934 want bool
935 }{
936 {name: "nil damage should not need full", damage: nil, want: false},
937 {name: "empty damage should not need full", damage: []Damage{}, want: false},
938 {name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false},
939 {name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true},
940 }
941
942 for _, tt := range tests {
943 t.Run(tt.name, func(t *testing.T) {
944 if got := needsFull(tt.damage); got != tt.want {
945 t.Errorf("needsFull() = %v, want %v", got, tt.want)
946 }
947 })
948 }
949}
950
951func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
952 tests := []struct {
953 name string
954 run func(t *testing.T)
955 }{
956 {
957 name: "falls back to full redraw when scroll fast path is unsafe",
958 run: func(t *testing.T) {
959 r := New(Capabilities{})
960 frame := NewFrame(4, 3)
961 markFrame(&frame)
962 if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil {
963 t.Fatal(err)
964 }
965
966 changed := NewFrame(4, 3)
967 markFrame(&changed)
968 changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship
969 damage := []Damage{
970 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
971 {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
972 }
973 out, err := r.Draw(changed, damage)
974 if err != nil {
975 t.Fatal(err)
976 }
977 if rows := strings.Count(string(out), "H"); rows < changed.Height {
978 t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))
979 }
980 for i := range changed.Cells {
981 if !r.shadow[i].Equal(changed.Cells[i]) {
982 t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])
983 }
984 }
985 },
986 },
987 {
988 name: "out-of-bounds damage rectangles are clamped without panicking",
989 run: func(t *testing.T) {
990 r := New(Capabilities{})
991 frame := NewFrame(3, 2)
992 markFrame(&frame)
993 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
994 t.Fatal(err)
995 }
996 frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()})
997 defer func() {
998 if r := recover(); r != nil {
999 t.Fatalf("Draw panicked for out-of-bounds damage: %v", r)
1000 }
1001 }()
1002 if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil {
1003 t.Fatal(err)
1004 }
1005 },
1006 },
1007 }
1008
1009 for _, tt := range tests {
1010 t.Run(tt.name, tt.run)
1011 }
1012}
1013
1014// ---------------------------------------------------------------------------
1015// Wide (double-width) character rendering
1016// ---------------------------------------------------------------------------
1017
1018func TestWideCharRendering(t *testing.T) {
1019 // A wide left cell {Rune: r} followed by a continuation cell
1020 // {Continuation: true}. The renderer must emit the rune once and emit
1021 // nothing at all for the continuation cell (the terminal advances two
1022 // columns by itself). It must never emit a space for a continuation.
1023 tests := []struct {
1024 name string
1025 build func() Frame
1026 want string
1027 }{
1028 {
1029 name: "single wide char followed by ascii",
1030 build: func() Frame {
1031 f := NewFrame(4, 1)
1032 f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1033 f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1034 f.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
1035 return f
1036 },
1037 // row 0: cursor home, 你, (skip continuation), A, trailing space, reset
1038 want: "\x1b[1;1H你A \x1b[0m",
1039 },
1040 {
1041 name: "two adjacent wide chars then ascii",
1042 build: func() Frame {
1043 f := NewFrame(6, 1)
1044 f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1045 f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1046 f.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
1047 f.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
1048 f.Set(4, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1049 return f
1050 },
1051 want: "\x1b[1;1H你好X \x1b[0m",
1052 },
1053 }
1054
1055 for _, tt := range tests {
1056 t.Run(tt.name, func(t *testing.T) {
1057 r := New(Capabilities{})
1058 frame := tt.build()
1059 out, err := r.Draw(frame, []Damage{FullRedraw()})
1060 if err != nil {
1061 t.Fatal(err)
1062 }
1063 if string(out) != tt.want {
1064 t.Errorf("output = %q, want %q", string(out), tt.want)
1065 }
1066 // No continuation must ever be rendered as a space: the visible
1067 // glyph count is exactly the number of non-continuation cells.
1068 // A second draw with no damage must be a no-op, proving the shadow
1069 // matches the frame (continuation cells included).
1070 out2, err := r.Draw(frame, nil)
1071 if err != nil {
1072 t.Fatal(err)
1073 }
1074 if len(out2) != 0 {
1075 t.Errorf("second draw not a no-op: %q (shadow inconsistent)", string(out2))
1076 }
1077 })
1078 }
1079}
1080
1081func TestWideCharDamageEmission(t *testing.T) {
1082 // Populate the shadow with a blank frame, then place a wide char and draw
1083 // with a text-damage rect covering the pair. The continuation cell must
1084 // not produce a space.
1085 r := New(Capabilities{})
1086 blank := NewFrame(4, 1)
1087 if _, err := r.Draw(blank, []Damage{FullRedraw()}); err != nil {
1088 t.Fatal(err)
1089 }
1090
1091 frame := NewFrame(4, 1)
1092 frame.Set(0, 0, Cell{Rune: '好', Style: DefaultStyle()})
1093 frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1094
1095 out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1}})
1096 if err != nil {
1097 t.Fatal(err)
1098 }
1099 got := string(out)
1100 if !strings.Contains(got, "好") {
1101 t.Errorf("output %q missing wide rune", got)
1102 }
1103 if strings.Contains(got, "好 ") {
1104 t.Errorf("output %q emitted a space for the continuation cell", got)
1105 }
1106}