1package ansi
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
137func TestPrepareNoOpDoesNotCloneFrame(t *testing.T) {
138 r := New(Capabilities{})
139 frame := NewFrame(3, 2)
140 markFrame(&frame)
141 if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}}); err != nil {
142 t.Fatal(err)
143 }
144
145 prepared, err := r.Prepare(frame, []Damage{{Kind: DamageText, X: frame.Width, Y: 0, Width: 1, Height: 1}}, false)
146 if err != nil {
147 t.Fatal(err)
148 }
149 if prepared.candidate.frame.Width != 0 || prepared.candidate.frame.Height != 0 || prepared.candidate.frame.Cells != nil {
150 t.Fatal("no-op prepared draw owns frame storage")
151 }
152 prepared.Commit()
153}
154
155// ---------------------------------------------------------------------------
156// Style reset discipline
157// ---------------------------------------------------------------------------
158
159func TestStyleResetDiscipline(t *testing.T) {
160 tests := []struct {
161 name string
162 run func(t *testing.T)
163 }{
164 {
165 name: "single styled cell among default cells",
166 run: func(t *testing.T) {
167 r := New(Capabilities{})
168 frame := NewFrame(4, 2)
169
170 // One styled cell, rest default.
171 frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Foreground: 2}})
172
173 out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
174 if err != nil {
175 t.Fatal(err)
176 }
177 // Must end with style reset.
178 outputEndsWith(t, out, "\x1b[0m")
179 },
180 },
181 {
182 name: "after scroll damage",
183 run: func(t *testing.T) {
184 r := New(Capabilities{})
185 frame := NewFrame(4, 3)
186 markFrame(&frame)
187
188 // Populate shadow.
189 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
190 if err != nil {
191 t.Fatal(err)
192 }
193
194 // Cause a scroll-up via VT model simulation: shift cells and emit scroll damage.
195 frame2 := NewFrame(4, 3)
196 copy(frame2.Cells[4:], frame.Cells[:8]) // row 0←row 1, row 1←row 2
197 for i := 8; i < 12; i++ {
198 frame2.Cells[i] = BlankCell() // bottom row blank
199 }
200 frame2.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) // new char on bottom row
201
202 damage := []Damage{
203 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
204 {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
205 }
206
207 out, err := r.Draw(frame2, damage)
208 if err != nil {
209 t.Fatal(err)
210 }
211 // The output must end with a style reset.
212 outputEndsWith(t, out, "\x1b[0m")
213 },
214 },
215 }
216
217 for _, tt := range tests {
218 t.Run(tt.name, tt.run)
219 }
220}
221
222func TestStyleEqualUsesActiveColorMode(t *testing.T) {
223 tests := []struct {
224 name string
225 left Style
226 right Style
227 wantEqual bool
228 }{
229 {
230 name: "ignores inactive indexed foreground",
231 left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
232 right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
233 wantEqual: true,
234 },
235 {
236 name: "compares active RGB values",
237 left: Style{Foreground: 1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 12, G: 34, B: 56}, Background: -1},
238 right: Style{Foreground: 2, HasForegroundRGB: true, ForegroundRGB: RGB{R: 13, G: 34, B: 56}, Background: -1},
239 wantEqual: false,
240 },
241 }
242
243 for _, tt := range tests {
244 t.Run(tt.name, func(t *testing.T) {
245 if got := tt.left.Equal(tt.right); got != tt.wantEqual {
246 t.Fatalf("Equal() = %v, want %v", got, tt.wantEqual)
247 }
248 })
249 }
250}
251
252func TestRendererEmitsSGR(t *testing.T) {
253 tests := []struct {
254 name string
255 width int
256 height int
257 setup func(frame Frame)
258 damage []Damage
259 wantAll []string
260 }{
261 {
262 name: "truecolor foreground and background",
263 width: 1,
264 height: 1,
265 setup: func(frame Frame) {
266 frame.Set(0, 0, Cell{
267 Rune: 'X',
268 Style: Style{
269 Foreground: -1,
270 Background: -1,
271 HasForegroundRGB: true,
272 ForegroundRGB: RGB{R: 12, G: 34, B: 56},
273 HasBackgroundRGB: true,
274 BackgroundRGB: RGB{R: 200, G: 100, B: 50},
275 },
276 })
277 },
278 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
279 wantAll: []string{"\x1b[0;38;2;12;34;56;48;2;200;100;50m", "X"},
280 },
281 {
282 name: "style changes across adjacent color modes",
283 width: 3,
284 height: 1,
285 setup: func(frame Frame) {
286 frame.Set(0, 0, Cell{Rune: 'R', Style: Style{Foreground: -1, Background: -1, HasForegroundRGB: true, ForegroundRGB: RGB{R: 1, G: 2, B: 3}}})
287 frame.Set(1, 0, Cell{Rune: 'I', Style: Style{Foreground: 82, Background: -1}})
288 frame.Set(2, 0, Cell{Rune: 'D', Style: DefaultStyle()})
289 },
290 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 1}},
291 wantAll: []string{"\x1b[0;38;2;1;2;3m", "R", "\x1b[0;38;5;82m", "I", "\x1b[0mD"},
292 },
293 {
294 name: "bold italic inverse",
295 width: 1,
296 height: 1,
297 setup: func(frame Frame) {
298 frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Bold: true, Italic: true, Inverse: true, Foreground: -1, Background: -1}})
299 },
300 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
301 wantAll: []string{"\x1b[0;1;3;7m", "X"},
302 },
303 {
304 name: "extended SGR attributes and underline color",
305 width: 1,
306 height: 1,
307 setup: func(frame Frame) {
308 frame.Set(0, 0, Cell{Rune: 'X', Style: Style{Foreground: -1, Background: -1, Attrs: AttrDim | AttrUnderline | AttrBlink | AttrStrikethrough, UnderlineStyle: UnderlineCurly, HasUnderlineColor: true, UnderlineColor: 9}})
309 },
310 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
311 wantAll: []string{"\x1b[0;2;4:3;5;9;58;5;9m", "X"},
312 },
313 }
314
315 for _, tt := range tests {
316 t.Run(tt.name, func(t *testing.T) {
317 r := New(Capabilities{})
318 frame := NewFrame(tt.width, tt.height)
319 tt.setup(frame)
320
321 out, err := r.Draw(frame, tt.damage)
322 if err != nil {
323 t.Fatal(err)
324 }
325
326 outputContains(t, out, tt.wantAll...)
327 })
328 }
329}
330
331// ---------------------------------------------------------------------------
332// Scroll fast path
333// ---------------------------------------------------------------------------
334
335func TestScrollFastPath(t *testing.T) {
336 tests := []struct {
337 name string
338 }{
339 {name: "scroll up by one row exposes new bottom row via scroll region"},
340 }
341
342 for _, tt := range tests {
343 t.Run(tt.name, func(t *testing.T) {
344 r := New(Capabilities{})
345 frame := NewFrame(5, 4)
346 markFrame(&frame)
347
348 // Populate shadow.
349 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 4}})
350 if err != nil {
351 t.Fatal(err)
352 }
353
354 // Build a new frame that has been scrolled up by 1 (like the VT model does).
355 scrolled := NewFrame(5, 4)
356 copy(scrolled.Cells[0:15], frame.Cells[5:20]) // rows 0,1,2 ← rows 1,2,3
357 for i := 15; i < 20; i++ {
358 scrolled.Cells[i] = BlankCell() // row 3 blanked
359 }
360 scrolled.Set(0, 3, Cell{Rune: 'N', Style: DefaultStyle()})
361 scrolled.Set(1, 3, Cell{Rune: 'e', Style: DefaultStyle()})
362 scrolled.Set(2, 3, Cell{Rune: 'w', Style: DefaultStyle()})
363
364 damage := []Damage{
365 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
366 {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
367 }
368
369 out, err := r.Draw(scrolled, damage)
370 if err != nil {
371 t.Fatal(err)
372 }
373 if len(out) == 0 {
374 t.Fatal("expected non-empty output for scroll")
375 }
376 // Should contain scroll-region sequences.
377 outputContains(t, out, "\x1b[1;4r") // scroll region rows 1-4
378 outputContains(t, out, "\x1b[r") // restore scroll region
379 // Should contain the new text on the exposed row.
380 outputContains(t, out, "N")
381 outputContains(t, out, "e")
382 outputContains(t, out, "w")
383 })
384 }
385}
386
387// ---------------------------------------------------------------------------
388// Synchronized output wrapper
389// ---------------------------------------------------------------------------
390
391func TestSynchronizedOutput(t *testing.T) {
392 tests := []struct {
393 name string
394 run func(t *testing.T)
395 }{
396 {
397 name: "wraps output in sync CSI",
398 run: func(t *testing.T) {
399 r := New(Capabilities{SynchronizedOutput: true})
400 frame := NewFrame(3, 2)
401 markFrame(&frame)
402
403 out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
404 if err != nil {
405 t.Fatal(err)
406 }
407 if !strings.HasPrefix(string(out), SyncStartCSI) {
408 t.Errorf("expected output to start with sync start CSI")
409 }
410 if !strings.HasSuffix(string(out), SyncEndCSI) {
411 t.Errorf("expected output to end with sync end CSI, got %q", string(out))
412 }
413 },
414 },
415 {
416 name: "no-op returns empty output, not wrapped empty output",
417 run: func(t *testing.T) {
418 r := New(Capabilities{SynchronizedOutput: true})
419 frame := NewFrame(3, 2)
420 markFrame(&frame)
421
422 // First draw.
423 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
424 if err != nil {
425 t.Fatal(err)
426 }
427
428 // No-op – must return nil, not wrapped empty output.
429 out, err := r.Draw(frame, nil)
430 if err != nil {
431 t.Fatal(err)
432 }
433 if len(out) != 0 {
434 t.Fatalf("expected empty output for no-op, got %q", string(out))
435 }
436 },
437 },
438 }
439
440 for _, tt := range tests {
441 t.Run(tt.name, tt.run)
442 }
443}
444
445// ---------------------------------------------------------------------------
446// WrapSynchronized standalone function
447// ---------------------------------------------------------------------------
448
449func TestWrapSynchronized(t *testing.T) {
450 content := []byte("\x1b[0mhello")
451
452 tests := []struct {
453 name string
454 content []byte
455 enabled bool
456 check func(t *testing.T, got []byte)
457 }{
458 {
459 name: "enabled wraps with sync start/end and preserves content",
460 content: content,
461 enabled: true,
462 check: func(t *testing.T, got []byte) {
463 if !strings.HasPrefix(string(got), SyncStartCSI) {
464 t.Errorf("missing sync start")
465 }
466 if !strings.HasSuffix(string(got), SyncEndCSI) {
467 t.Errorf("missing sync end")
468 }
469 if !strings.Contains(string(got), "hello") {
470 t.Errorf("missing original content")
471 }
472 },
473 },
474 {
475 name: "disabled returns content unchanged",
476 content: content,
477 enabled: false,
478 check: func(t *testing.T, got []byte) {
479 if string(got) != string(content) {
480 t.Errorf("disabled wrapping should return content unchanged")
481 }
482 },
483 },
484 {
485 name: "empty input returns empty",
486 content: nil,
487 enabled: true,
488 check: func(t *testing.T, got []byte) {
489 if len(got) != 0 {
490 t.Errorf("empty input should return empty")
491 }
492 },
493 },
494 }
495
496 for _, tt := range tests {
497 t.Run(tt.name, func(t *testing.T) {
498 got := WrapSynchronized(tt.content, tt.enabled)
499 tt.check(t, got)
500 })
501 }
502}
503
504// ---------------------------------------------------------------------------
505// Partial damage draw
506// ---------------------------------------------------------------------------
507
508func TestPartialDamage(t *testing.T) {
509 tests := []struct {
510 name string
511 }{
512 {name: "single changed cell repositions cursor and resets style"},
513 }
514
515 for _, tt := range tests {
516 t.Run(tt.name, func(t *testing.T) {
517 r := New(Capabilities{})
518 frame := NewFrame(4, 2)
519 markFrame(&frame)
520
521 // Populate shadow.
522 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}})
523 if err != nil {
524 t.Fatal(err)
525 }
526
527 // Change one cell.
528 frame.Set(2, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
529 damage := []Damage{{Kind: DamageText, X: 2, Y: 1, Width: 1, Height: 1}}
530
531 out, err := r.Draw(frame, damage)
532 if err != nil {
533 t.Fatal(err)
534 }
535 if len(out) == 0 {
536 t.Fatal("expected non-empty output for partial damage")
537 }
538 // Should position cursor at (2,1) — 1-indexed (3;2).
539 outputContains(t, out, "\x1b[2;3H")
540 outputContains(t, out, "Z")
541 outputEndsWith(t, out, "\x1b[0m")
542 })
543 }
544}
545
546// ---------------------------------------------------------------------------
547// Reset
548// ---------------------------------------------------------------------------
549
550func TestReset(t *testing.T) {
551 tests := []struct {
552 name string
553 }{
554 {name: "clears shadow state and forces a full draw next time"},
555 }
556
557 for _, tt := range tests {
558 t.Run(tt.name, func(t *testing.T) {
559 r := New(Capabilities{})
560 frame := NewFrame(3, 2)
561 markFrame(&frame)
562
563 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 3, Height: 2}})
564 if err != nil {
565 t.Fatal(err)
566 }
567
568 r.Reset()
569
570 if r.width != 0 || r.height != 0 || r.shadow != nil {
571 t.Fatal("Reset should clear width/height/shadow")
572 }
573
574 // After reset, the next draw should be a full draw.
575 out, err := r.Draw(frame, nil)
576 if err != nil {
577 t.Fatal(err)
578 }
579 if len(out) == 0 {
580 t.Fatal("expected non-empty output after reset")
581 }
582 })
583 }
584}
585
586// ---------------------------------------------------------------------------
587// Damage helper
588// ---------------------------------------------------------------------------
589
590func TestFullRedraw(t *testing.T) {
591 tests := []struct {
592 name string
593 run func(t *testing.T)
594 }{
595 {
596 name: "known same-size full redraw diffs against shadow",
597 run: func(t *testing.T) {
598 r := New(Capabilities{})
599 frame := NewFrame(3, 2)
600 markFrame(&frame)
601
602 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
603 t.Fatal(err)
604 }
605
606 changed := frame
607 changed.Set(0, 1, Cell{Rune: 'Z', Style: DefaultStyle()})
608 out, err := r.Draw(changed, []Damage{FullRedraw()})
609 if err != nil {
610 t.Fatal(err)
611 }
612 got := string(out)
613 if strings.Contains(got, "ABC") {
614 t.Fatalf("unchanged row was re-emitted on dimension-stable FullRedraw: %q", got)
615 }
616 if !strings.Contains(got, "ZEF") {
617 t.Fatalf("changed row was not emitted on dimension-stable FullRedraw: %q", got)
618 }
619 outputEndsWith(t, out, "\x1b[0m")
620 },
621 },
622 {
623 name: "unchanged known same-size full redraw is a no-op",
624 run: func(t *testing.T) {
625 r := New(Capabilities{})
626 frame := NewFrame(3, 2)
627 markFrame(&frame)
628
629 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
630 t.Fatal(err)
631 }
632 out, err := r.Draw(frame, []Damage{FullRedraw()})
633 if err != nil {
634 t.Fatal(err)
635 }
636 if len(out) != 0 {
637 t.Fatalf("unchanged dimension-stable FullRedraw emitted output: %q", string(out))
638 }
639 },
640 },
641 {
642 name: "reset full redraw emits complete frame",
643 run: func(t *testing.T) {
644 r := New(Capabilities{})
645 frame := NewFrame(3, 2)
646 markFrame(&frame)
647
648 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
649 t.Fatal(err)
650 }
651 r.Reset()
652
653 out, err := r.Draw(frame, []Damage{FullRedraw()})
654 if err != nil {
655 t.Fatal(err)
656 }
657 got := string(out)
658 if !strings.Contains(got, "ABC") || !strings.Contains(got, "DEF") {
659 t.Fatalf("reset FullRedraw did not emit complete frame: %q", got)
660 }
661 outputEndsWith(t, out, "\x1b[0m")
662 },
663 },
664 }
665
666 for _, tt := range tests {
667 t.Run(tt.name, tt.run)
668 }
669}
670
671// ---------------------------------------------------------------------------
672// Scroll with no remaining damage
673// ---------------------------------------------------------------------------
674
675func TestScrollOnlyDamage(t *testing.T) {
676 tests := []struct {
677 name string
678 }{
679 {name: "scroll damage plus new-row text damage from the VT model"},
680 }
681
682 for _, tt := range tests {
683 t.Run(tt.name, func(t *testing.T) {
684 r := New(Capabilities{})
685 frame := NewFrame(4, 3)
686 markFrame(&frame)
687
688 _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}})
689 if err != nil {
690 t.Fatal(err)
691 }
692
693 // Scroll up 1 but the VT model would also emit DamageText for the new bottom row.
694 scrolled := NewFrame(4, 3)
695 copy(scrolled.Cells[0:8], frame.Cells[4:12])
696 for i := 8; i < 12; i++ {
697 scrolled.Cells[i] = BlankCell()
698 }
699 scrolled.Set(0, 2, Cell{Rune: 'X', Style: DefaultStyle()})
700
701 damage := []Damage{
702 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
703 {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
704 }
705
706 out, err := r.Draw(scrolled, damage)
707 if err != nil {
708 t.Fatal(err)
709 }
710 if len(out) == 0 {
711 t.Fatal("expected output for scroll")
712 }
713 // Should include scroll region
714 outputContains(t, out, "\x1b[1;3r")
715 outputContains(t, out, "\x1b[r")
716 outputEndsWith(t, out, "\x1b[0m")
717 })
718 }
719}
720
721// ---------------------------------------------------------------------------
722// writeCursor output format
723// ---------------------------------------------------------------------------
724
725func TestWriteCursor(t *testing.T) {
726 tests := []struct {
727 name string
728 y, x int
729 want string
730 }{
731 {name: "origin", y: 0, x: 0, want: "\x1b[1;1H"},
732 {name: "offset", y: 2, x: 5, want: "\x1b[3;6H"},
733 }
734
735 for _, tt := range tests {
736 t.Run(tt.name, func(t *testing.T) {
737 var buf bytes.Buffer
738 writeCursor(&buf, tt.y, tt.x)
739 if buf.String() != tt.want {
740 t.Errorf("unexpected cursor positioning: %q", buf.String())
741 }
742 })
743 }
744}
745
746// ---------------------------------------------------------------------------
747// isSafeScroll edge cases
748// ---------------------------------------------------------------------------
749
750func TestIsSafeScroll(t *testing.T) {
751 frame := NewFrame(10, 8)
752
753 tests := []struct {
754 name string
755 d Damage
756 want bool
757 }{
758 {"full width, partial height", Damage{X: 0, Y: 1, Width: 10, Height: 5, Count: 2}, true},
759 {"not full width", Damage{X: 1, Y: 1, Width: 8, Height: 5, Count: 2}, false},
760 {"count zero", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 0}, false},
761 {"count equals height", Damage{X: 0, Y: 0, Width: 10, Height: 8, Count: 8}, false},
762 {"extends past frame", Damage{X: 0, Y: 6, Width: 10, Height: 6, Count: 2}, false},
763 {"negative Y", Damage{X: 0, Y: -1, Width: 10, Height: 5, Count: 1}, false},
764 {"height zero", Damage{X: 0, Y: 0, Width: 10, Height: 0, Count: 1}, false},
765 }
766
767 for _, tt := range tests {
768 t.Run(tt.name, func(t *testing.T) {
769 got := isSafeScroll(frame, tt.d)
770 if got != tt.want {
771 t.Errorf("isSafeScroll(%+v) = %v, want %v", tt.d, got, tt.want)
772 }
773 })
774 }
775}
776
777// ---------------------------------------------------------------------------
778// BlankCell / Cell.Equal
779// ---------------------------------------------------------------------------
780
781func TestCellEqual(t *testing.T) {
782 tests := []struct {
783 name string
784 a Cell
785 b Cell
786 want bool
787 }{
788 {
789 name: "blank cells should be equal",
790 a: BlankCell(),
791 b: BlankCell(),
792 want: true,
793 },
794 {
795 name: "different runes should not be equal",
796 a: Cell{Rune: 'A', Style: DefaultStyle()},
797 b: BlankCell(),
798 want: false,
799 },
800 {
801 name: "different styles should not be equal",
802 a: Cell{Rune: ' ', Style: Style{Bold: true, Foreground: -1, Background: -1}},
803 b: BlankCell(),
804 want: false,
805 },
806 {
807 name: "continuation flag participates in equality",
808 a: Cell{Continuation: true},
809 b: Cell{},
810 want: false,
811 },
812 {
813 name: "two continuation cells are equal",
814 a: Cell{Continuation: true},
815 b: Cell{Continuation: true},
816 want: true,
817 },
818 }
819
820 for _, tt := range tests {
821 t.Run(tt.name, func(t *testing.T) {
822 if got := tt.a.Equal(tt.b); got != tt.want {
823 t.Errorf("Equal() = %v, want %v", got, tt.want)
824 }
825 })
826 }
827}
828
829// ---------------------------------------------------------------------------
830// Frame helpers
831// ---------------------------------------------------------------------------
832
833func TestFrameValidate(t *testing.T) {
834 tests := []struct {
835 name string
836 frame Frame
837 wantErr bool
838 }{
839 {name: "valid frame", frame: NewFrame(5, 3), wantErr: false},
840 {name: "invalid dimensions (zero width)", frame: Frame{Width: 0, Height: 5}, wantErr: true},
841 {name: "wrong cell count", frame: Frame{Width: 2, Height: 2, Cells: make([]Cell, 3)}, wantErr: true},
842 }
843
844 for _, tt := range tests {
845 t.Run(tt.name, func(t *testing.T) {
846 err := tt.frame.Validate()
847 if tt.wantErr && err == nil {
848 t.Fatal("expected error, got nil")
849 }
850 if !tt.wantErr && err != nil {
851 t.Fatalf("unexpected error: %v", err)
852 }
853 })
854 }
855}
856
857func TestFrameAtSet(t *testing.T) {
858 tests := []struct {
859 name string
860 }{
861 {name: "At/Set round-trip and unaffected cells stay blank"},
862 }
863
864 for _, tt := range tests {
865 t.Run(tt.name, func(t *testing.T) {
866 f := NewFrame(3, 2)
867 cell := Cell{Rune: 'X', Style: Style{Bold: true}}
868 f.Set(1, 1, cell)
869
870 got := f.At(1, 1)
871 if got.Rune != 'X' || !got.Style.Bold {
872 t.Errorf("At/Set round-trip failed: got %+v", got)
873 }
874
875 // Unaffected cells are blank.
876 got = f.At(0, 0)
877 if got != BlankCell() {
878 t.Errorf("unexpected cell at (0,0): %+v", got)
879 }
880 })
881 }
882}
883
884// ---------------------------------------------------------------------------
885// Damage helpers
886// ---------------------------------------------------------------------------
887
888func TestSameDamage(t *testing.T) {
889 tests := []struct {
890 name string
891 a Damage
892 b Damage
893 want bool
894 }{
895 {
896 name: "identical damages should be equal",
897 a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
898 b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
899 want: true,
900 },
901 {
902 name: "different counts should not be equal",
903 a: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 5},
904 b: Damage{Kind: DamageText, X: 1, Y: 2, Width: 3, Height: 4, Count: 0},
905 want: false,
906 },
907 }
908
909 for _, tt := range tests {
910 t.Run(tt.name, func(t *testing.T) {
911 if got := sameDamage(tt.a, tt.b); got != tt.want {
912 t.Errorf("sameDamage() = %v, want %v", got, tt.want)
913 }
914 })
915 }
916}
917
918func TestDamageCoversCell(t *testing.T) {
919 damage := []Damage{
920 {Kind: DamageText, X: 2, Y: 3, Width: 5, Height: 2},
921 }
922
923 tests := []struct {
924 name string
925 x, y int
926 want bool
927 }{
928 {name: "should cover (3,3)", x: 3, y: 3, want: true},
929 {name: "should cover (6,4)", x: 6, y: 4, want: true},
930 {name: "should not cover (1,3)", x: 1, y: 3, want: false},
931 {name: "should not cover (3,5)", x: 3, y: 5, want: false},
932 {name: "should not cover (7,3)", x: 7, y: 3, want: false},
933 }
934
935 for _, tt := range tests {
936 t.Run(tt.name, func(t *testing.T) {
937 if got := damageCoversCell(damage, tt.x, tt.y); got != tt.want {
938 t.Errorf("damageCoversCell(%d,%d) = %v, want %v", tt.x, tt.y, got, tt.want)
939 }
940 })
941 }
942}
943
944// ---------------------------------------------------------------------------
945// needsFull
946// ---------------------------------------------------------------------------
947
948func TestNeedsFull(t *testing.T) {
949 tests := []struct {
950 name string
951 damage []Damage
952 want bool
953 }{
954 {name: "nil damage should not need full", damage: nil, want: false},
955 {name: "empty damage should not need full", damage: []Damage{}, want: false},
956 {name: "text damage should not need full", damage: []Damage{{Kind: DamageText}}, want: false},
957 {name: "FullRedraw should need full", damage: []Damage{FullRedraw()}, want: true},
958 }
959
960 for _, tt := range tests {
961 t.Run(tt.name, func(t *testing.T) {
962 if got := needsFull(tt.damage); got != tt.want {
963 t.Errorf("needsFull() = %v, want %v", got, tt.want)
964 }
965 })
966 }
967}
968
969func TestDrawFallbackAndClampingEdgeCases(t *testing.T) {
970 tests := []struct {
971 name string
972 run func(t *testing.T)
973 }{
974 {
975 name: "falls back to full redraw when scroll fast path is unsafe",
976 run: func(t *testing.T) {
977 r := New(Capabilities{})
978 frame := NewFrame(4, 3)
979 markFrame(&frame)
980 if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 3}}); err != nil {
981 t.Fatal(err)
982 }
983
984 changed := NewFrame(4, 3)
985 markFrame(&changed)
986 changed.Set(0, 0, Cell{Rune: 'x', Style: DefaultStyle()}) // invalidates scroll relationship
987 damage := []Damage{
988 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
989 {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
990 }
991 out, err := r.Draw(changed, damage)
992 if err != nil {
993 t.Fatal(err)
994 }
995 if rows := strings.Count(string(out), "H"); rows < changed.Height {
996 t.Fatalf("expected full redraw after unsafe scroll fallback, output %q", string(out))
997 }
998 for i := range changed.Cells {
999 if !r.shadow[i].Equal(changed.Cells[i]) {
1000 t.Fatalf("shadow[%d] = %+v, want %+v", i, r.shadow[i], changed.Cells[i])
1001 }
1002 }
1003 },
1004 },
1005 {
1006 name: "out-of-bounds damage rectangles are clamped without panicking",
1007 run: func(t *testing.T) {
1008 r := New(Capabilities{})
1009 frame := NewFrame(3, 2)
1010 markFrame(&frame)
1011 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
1012 t.Fatal(err)
1013 }
1014 frame.Set(0, 0, Cell{Rune: 'Z', Style: DefaultStyle()})
1015 defer func() {
1016 if r := recover(); r != nil {
1017 t.Fatalf("Draw panicked for out-of-bounds damage: %v", r)
1018 }
1019 }()
1020 if _, err := r.Draw(frame, []Damage{{Kind: DamageText, X: -2, Y: -1, Width: 4, Height: 3}}); err != nil {
1021 t.Fatal(err)
1022 }
1023 },
1024 },
1025 }
1026
1027 for _, tt := range tests {
1028 t.Run(tt.name, tt.run)
1029 }
1030}
1031
1032// ---------------------------------------------------------------------------
1033// Wide (double-width) character rendering
1034// ---------------------------------------------------------------------------
1035
1036func TestWideCharRendering(t *testing.T) {
1037 // A wide left cell {Rune: r} followed by a continuation cell
1038 // {Continuation: true}. The renderer must emit the rune once and emit
1039 // nothing at all for the continuation cell (the terminal advances two
1040 // columns by itself). It must never emit a space for a continuation.
1041 tests := []struct {
1042 name string
1043 build func() Frame
1044 want string
1045 }{
1046 {
1047 name: "single wide char followed by ascii",
1048 build: func() Frame {
1049 f := NewFrame(4, 1)
1050 f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1051 f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1052 f.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
1053 return f
1054 },
1055 // row 0: cursor home, 你, (skip continuation), A, trailing space, reset
1056 want: "\x1b[1;1H你A \x1b[0m",
1057 },
1058 {
1059 name: "two adjacent wide chars then ascii",
1060 build: func() Frame {
1061 f := NewFrame(6, 1)
1062 f.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
1063 f.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1064 f.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
1065 f.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
1066 f.Set(4, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1067 return f
1068 },
1069 want: "\x1b[1;1H你好X \x1b[0m",
1070 },
1071 }
1072
1073 for _, tt := range tests {
1074 t.Run(tt.name, func(t *testing.T) {
1075 r := New(Capabilities{})
1076 frame := tt.build()
1077 out, err := r.Draw(frame, []Damage{FullRedraw()})
1078 if err != nil {
1079 t.Fatal(err)
1080 }
1081 if string(out) != tt.want {
1082 t.Errorf("output = %q, want %q", string(out), tt.want)
1083 }
1084 // No continuation must ever be rendered as a space: the visible
1085 // glyph count is exactly the number of non-continuation cells.
1086 // A second draw with no damage must be a no-op, proving the shadow
1087 // matches the frame (continuation cells included).
1088 out2, err := r.Draw(frame, nil)
1089 if err != nil {
1090 t.Fatal(err)
1091 }
1092 if len(out2) != 0 {
1093 t.Errorf("second draw not a no-op: %q (shadow inconsistent)", string(out2))
1094 }
1095 })
1096 }
1097}
1098
1099func TestRendererPrepare(t *testing.T) {
1100 fullFrame := testFrame("ABC", "DEF")
1101 fullDamage := []Damage{FullRedraw()}
1102
1103 tests := []struct {
1104 name string
1105 run func(*testing.T)
1106 }{
1107 {name: "zero commit is safe", run: func(t *testing.T) {
1108 var prepared PreparedDraw
1109 prepared.Commit()
1110 }},
1111
1112 {name: "discard and reprepare returns identical owned bytes", run: func(t *testing.T) {
1113 r := New(Capabilities{})
1114 first, err := r.Prepare(fullFrame, fullDamage, false)
1115 if err != nil {
1116 t.Fatal(err)
1117 }
1118 want := append([]byte(nil), first.Bytes()...)
1119
1120 second, err := r.Prepare(fullFrame, fullDamage, false)
1121 if err != nil {
1122 t.Fatal(err)
1123 }
1124 if !bytes.Equal(second.Bytes(), want) {
1125 t.Fatalf("reprepared bytes = %q, want %q", second.Bytes(), want)
1126 }
1127
1128 other := testFrame("12345678", "abcdefgh", "ABCDEFGH")
1129 if _, err := r.Prepare(other, fullDamage, false); err != nil {
1130 t.Fatal(err)
1131 }
1132 if !bytes.Equal(first.Bytes(), want) {
1133 t.Fatalf("prepared bytes changed after pooled buffer reuse: got %q, want %q", first.Bytes(), want)
1134 }
1135 }},
1136
1137 {name: "commit then no-op", run: func(t *testing.T) {
1138 r := New(Capabilities{})
1139 prepared, err := r.Prepare(fullFrame, fullDamage, false)
1140 if err != nil {
1141 t.Fatal(err)
1142 }
1143 prepared.Commit()
1144
1145 next, err := r.Prepare(fullFrame, nil, false)
1146 if err != nil {
1147 t.Fatal(err)
1148 }
1149 if len(next.Bytes()) != 0 {
1150 t.Fatalf("unchanged frame emitted %q", next.Bytes())
1151 }
1152 }},
1153
1154 {name: "reset preparation is transactional", run: func(t *testing.T) {
1155 r := New(Capabilities{})
1156 initial, err := r.Prepare(fullFrame, fullDamage, false)
1157 if err != nil {
1158 t.Fatal(err)
1159 }
1160 initial.Commit()
1161
1162 resetFrame := testFrame("ABC", "XYZ")
1163 resetPrepared, err := r.Prepare(resetFrame, nil, true)
1164 if err != nil {
1165 t.Fatal(err)
1166 }
1167 want, err := New(Capabilities{}).Draw(resetFrame, nil)
1168 if err != nil {
1169 t.Fatal(err)
1170 }
1171 if !bytes.Equal(resetPrepared.Bytes(), want) {
1172 t.Fatalf("reset preparation = %q, want full draw %q", resetPrepared.Bytes(), want)
1173 }
1174
1175 unchanged, err := r.Prepare(fullFrame, nil, false)
1176 if err != nil {
1177 t.Fatal(err)
1178 }
1179 if len(unchanged.Bytes()) != 0 {
1180 t.Fatalf("discarded reset changed committed shadow: %q", unchanged.Bytes())
1181 }
1182
1183 resetPrepared, err = r.Prepare(resetFrame, nil, true)
1184 if err != nil {
1185 t.Fatal(err)
1186 }
1187 resetPrepared.Commit()
1188 afterCommit, err := r.Prepare(resetFrame, nil, false)
1189 if err != nil {
1190 t.Fatal(err)
1191 }
1192 if len(afterCommit.Bytes()) != 0 {
1193 t.Fatalf("committed reset was not retained: %q", afterCommit.Bytes())
1194 }
1195 }},
1196
1197 {name: "successful no-byte commit", run: func(t *testing.T) {
1198 r := New(Capabilities{})
1199 initial, err := r.Prepare(fullFrame, fullDamage, false)
1200 if err != nil {
1201 t.Fatal(err)
1202 }
1203 initial.Commit()
1204
1205 noOp, err := r.Prepare(fullFrame, nil, false)
1206 if err != nil {
1207 t.Fatal(err)
1208 }
1209 if len(noOp.Bytes()) != 0 {
1210 t.Fatalf("no-op emitted %q", noOp.Bytes())
1211 }
1212 noOp.Commit()
1213
1214 again, err := r.Prepare(fullFrame, nil, false)
1215 if err != nil {
1216 t.Fatal(err)
1217 }
1218 if len(again.Bytes()) != 0 {
1219 t.Fatalf("committed no-op changed renderer state: %q", again.Bytes())
1220 }
1221 }},
1222
1223 {name: "double commit is idempotent", run: func(t *testing.T) {
1224 base := testFrame("0000", "1111", "2222", "3333")
1225 scrolled := testFrame("1111", "2222", "3333", "new!")
1226 damage := []Damage{
1227 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1228 {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1229 }
1230 r := New(Capabilities{})
1231 if _, err := r.Draw(base, fullDamage); err != nil {
1232 t.Fatal(err)
1233 }
1234
1235 prepared, err := r.Prepare(scrolled, damage, false)
1236 if err != nil {
1237 t.Fatal(err)
1238 }
1239 prepared.Commit()
1240 prepared.Commit()
1241
1242 next, err := r.Prepare(scrolled, nil, false)
1243 if err != nil {
1244 t.Fatal(err)
1245 }
1246 if len(next.Bytes()) != 0 {
1247 t.Fatalf("double commit advanced state twice: %q", next.Bytes())
1248 }
1249 }},
1250
1251 {name: "copied prepared draw commits scroll once", run: func(t *testing.T) {
1252 base := testFrame("0000", "1111", "2222", "3333")
1253 scrolled := testFrame("1111", "2222", "3333", "new!")
1254 damage := []Damage{
1255 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 4, Count: 1},
1256 {Kind: DamageText, X: 0, Y: 3, Width: 4, Height: 1},
1257 }
1258 r := New(Capabilities{})
1259 if _, err := r.Draw(base, fullDamage); err != nil {
1260 t.Fatal(err)
1261 }
1262
1263 prepared, err := r.Prepare(scrolled, damage, false)
1264 if err != nil {
1265 t.Fatal(err)
1266 }
1267 copied := prepared
1268 prepared.Commit()
1269 copied.Commit()
1270
1271 next, err := r.Prepare(scrolled, nil, false)
1272 if err != nil {
1273 t.Fatal(err)
1274 }
1275 if len(next.Bytes()) != 0 {
1276 t.Fatalf("copied commit advanced state twice: %q", next.Bytes())
1277 }
1278 }},
1279
1280 {name: "commit uses immutable frame snapshot", run: func(t *testing.T) {
1281 r := New(Capabilities{})
1282 base := testFrame("ABC", "DEF")
1283 if _, err := r.Draw(base, fullDamage); err != nil {
1284 t.Fatal(err)
1285 }
1286
1287 changed := base.Clone()
1288 changed.Set(1, 1, Cell{Rune: 'X', Style: DefaultStyle()})
1289 want := changed.Clone()
1290 prepared, err := r.Prepare(changed, []Damage{{Kind: DamageText, X: 1, Y: 1, Width: 1, Height: 1}}, false)
1291 if err != nil {
1292 t.Fatal(err)
1293 }
1294 if !strings.Contains(string(prepared.Bytes()), "X") {
1295 t.Fatalf("prepared output = %q, want changed cell", prepared.Bytes())
1296 }
1297
1298 changed.Set(1, 1, Cell{Rune: 'Y', Style: DefaultStyle()})
1299 prepared.Commit()
1300
1301 next, err := r.Prepare(want, nil, false)
1302 if err != nil {
1303 t.Fatal(err)
1304 }
1305 if len(next.Bytes()) != 0 {
1306 t.Fatalf("snapshot did not preserve prepared frame: %q", next.Bytes())
1307 }
1308 }},
1309
1310 {name: "matches concrete ANSI fixtures", run: func(t *testing.T) {
1311 tests := []struct {
1312 name string
1313 prepare func(t *testing.T) PreparedDraw
1314 want string
1315 }{
1316 {
1317 name: "full draw",
1318 prepare: func(t *testing.T) PreparedDraw {
1319 prepared, err := New(Capabilities{}).Prepare(fullFrame, fullDamage, false)
1320 if err != nil {
1321 t.Fatal(err)
1322 }
1323 return prepared
1324 },
1325 want: "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m",
1326 },
1327 {
1328 name: "synchronized full draw",
1329 prepare: func(t *testing.T) PreparedDraw {
1330 prepared, err := New(Capabilities{SynchronizedOutput: true}).Prepare(fullFrame, fullDamage, false)
1331 if err != nil {
1332 t.Fatal(err)
1333 }
1334 return prepared
1335 },
1336 want: SyncStartCSI + "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m" + SyncEndCSI,
1337 },
1338 {
1339 name: "fragmented damage",
1340 prepare: func(t *testing.T) PreparedDraw {
1341 base := testFrame("abcdefgh", "ijklmnop", "qrstuvwx")
1342 frame := base.Clone()
1343 frame.Set(1, 0, Cell{Rune: 'X', Style: DefaultStyle()})
1344 frame.Set(5, 0, Cell{Rune: 'Y', Style: DefaultStyle()})
1345 frame.Set(2, 2, Cell{Rune: 'Z', Style: DefaultStyle()})
1346 damage := []Damage{
1347 {Kind: DamageText, X: 5, Y: 0, Width: 1, Height: 1},
1348 {Kind: DamageText, X: 1, Y: 0, Width: 1, Height: 1},
1349 {Kind: DamageText, X: 2, Y: 2, Width: 1, Height: 1},
1350 }
1351 r := New(Capabilities{})
1352 if _, err := r.Draw(base, fullDamage); err != nil {
1353 t.Fatal(err)
1354 }
1355 prepared, err := r.Prepare(frame, damage, false)
1356 if err != nil {
1357 t.Fatal(err)
1358 }
1359 return prepared
1360 },
1361 want: "\x1b[1;2HX\x1b[3CY\x1b[3;3HZ\x1b[0m",
1362 },
1363 {
1364 name: "scroll",
1365 prepare: func(t *testing.T) PreparedDraw {
1366 base := testFrame("00000", "11111", "22222", "33333")
1367 frame := testFrame("11111", "22222", "33333", "new!!")
1368 damage := []Damage{
1369 {Kind: DamageScrollUp, X: 0, Y: 0, Width: 5, Height: 4, Count: 1},
1370 {Kind: DamageText, X: 0, Y: 3, Width: 5, Height: 1},
1371 }
1372 r := New(Capabilities{})
1373 if _, err := r.Draw(base, fullDamage); err != nil {
1374 t.Fatal(err)
1375 }
1376 prepared, err := r.Prepare(frame, damage, false)
1377 if err != nil {
1378 t.Fatal(err)
1379 }
1380 return prepared
1381 },
1382 want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",
1383 },
1384 }
1385
1386 for _, tt := range tests {
1387 t.Run(tt.name, func(t *testing.T) {
1388 if got := string(tt.prepare(t).Bytes()); got != tt.want {
1389 t.Fatalf("prepared ANSI = %q, want %q", got, tt.want)
1390 }
1391 })
1392 }
1393 }},
1394 }
1395
1396 for _, tt := range tests {
1397 t.Run(tt.name, tt.run)
1398 }
1399}
1400
1401func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) {
1402 frame := NewFrame(2, 1)
1403 committed := Frame{Width: 2, Height: 1, Cells: make([]Cell, 2)}
1404
1405 if _, err := PlanDelta(frame, nil, committed, false); err == nil {
1406 t.Fatal("PlanDelta accepted an invalid committed frame")
1407 }
1408}
1409
1410func TestWideCharDamageEmission(t *testing.T) {
1411 // Populate the shadow with a blank frame, then place a wide char and draw
1412 // with a text-damage rect covering the pair. The continuation cell must
1413 // not produce a space.
1414 r := New(Capabilities{})
1415 blank := NewFrame(4, 1)
1416 if _, err := r.Draw(blank, []Damage{FullRedraw()}); err != nil {
1417 t.Fatal(err)
1418 }
1419
1420 frame := NewFrame(4, 1)
1421 frame.Set(0, 0, Cell{Rune: '好', Style: DefaultStyle()})
1422 frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
1423
1424 out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1}})
1425 if err != nil {
1426 t.Fatal(err)
1427 }
1428 got := string(out)
1429 if !strings.Contains(got, "好") {
1430 t.Errorf("output %q missing wide rune", got)
1431 }
1432 if strings.Contains(got, "好 ") {
1433 t.Errorf("output %q emitted a space for the continuation cell", got)
1434 }
1435}