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