1package ansi
2
3import (
4 "bytes"
5 "strconv"
6 "strings"
7 "testing"
8)
9
10// ---------------------------------------------------------------------------
11// Cursor-motion minimization (drawState.moveTo)
12// ---------------------------------------------------------------------------
13
14func TestMoveToChoosesCheapestSequence(t *testing.T) {
15 tests := []struct {
16 name string
17 known bool
18 fromRow int
19 fromCol int
20 toRow int
21 toCol int
22 want string
23 }{
24 {name: "unknown position uses absolute CUP", known: false, toRow: 2, toCol: 3, want: "\x1b[3;4H"},
25 {name: "same position emits nothing", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 3, want: ""},
26 {name: "same row col0 uses CR", known: true, fromRow: 2, fromCol: 5, toRow: 2, toCol: 0, want: "\r"},
27 {name: "same row forward uses CUF", known: true, fromRow: 2, fromCol: 2, toRow: 2, toCol: 7, want: "\x1b[5C"},
28 {name: "same row backward uses CUB", known: true, fromRow: 2, fromCol: 7, toRow: 2, toCol: 2, want: "\x1b[5D"},
29 {name: "same row forward by one omits count", known: true, fromRow: 2, fromCol: 3, toRow: 2, toCol: 4, want: "\x1b[C"},
30 {name: "same col up uses CUU", known: true, fromRow: 5, fromCol: 3, toRow: 2, toCol: 3, want: "\x1b[3A"},
31 {name: "same col down uses CUD", known: true, fromRow: 2, fromCol: 3, toRow: 5, toCol: 3, want: "\x1b[3B"},
32 {name: "same col up by one omits count", known: true, fromRow: 5, fromCol: 3, toRow: 4, toCol: 3, want: "\x1b[A"},
33 {name: "diagonal move uses absolute CUP", known: true, fromRow: 2, fromCol: 2, toRow: 5, toCol: 7, want: "\x1b[6;8H"},
34 {name: "short forward beats absolute", known: true, fromRow: 2, fromCol: 0, toRow: 2, toCol: 1, want: "\x1b[C"},
35 }
36
37 for _, tt := range tests {
38 t.Run(tt.name, func(t *testing.T) {
39 st := newDrawStateForProfile(ColorProfileTrueColor)
40 if tt.known {
41 st.setCursor(tt.fromRow, tt.fromCol)
42 }
43 var buf bytes.Buffer
44 st.moveTo(&buf, tt.toRow, tt.toCol)
45 if buf.String() != tt.want {
46 t.Errorf("moveTo() = %q, want %q", buf.String(), tt.want)
47 }
48 if !st.curKnown || st.curRow != tt.toRow || st.curCol != tt.toCol {
49 t.Errorf("cursor not tracked to (%d,%d): got known=%v (%d,%d)", tt.toRow, tt.toCol, st.curKnown, st.curRow, st.curCol)
50 }
51 })
52 }
53}
54
55// ---------------------------------------------------------------------------
56// End-to-end golden byte output (exact bytes emitted by Draw)
57// ---------------------------------------------------------------------------
58
59func drawGolden(t *testing.T, r *Renderer, frame Frame, damage []Damage) string {
60 t.Helper()
61 out, err := r.Draw(frame, damage)
62 if err != nil {
63 t.Fatal(err)
64 }
65 return string(out)
66}
67
68func setRow(f Frame, y int, s string, st Style) {
69 col := 0
70 for _, ru := range s {
71 f.Set(col, y, Cell{Rune: ru, Style: st})
72 col++
73 }
74}
75
76func hasCUB(out string) bool {
77 for i := 0; i+2 < len(out); i++ {
78 if out[i] != '\x1b' || out[i+1] != '[' {
79 continue
80 }
81 j := i + 2
82 for j < len(out) && out[j] >= '0' && out[j] <= '9' {
83 j++
84 }
85 if j < len(out) && out[j] == 'D' {
86 return true
87 }
88 }
89 return false
90}
91
92func TestBackwardOverlappingDamageRendersFinalSpanOnce(t *testing.T) {
93 r := New(Capabilities{})
94 base := NewFrame(10, 1)
95 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
96 t.Fatal(err)
97 }
98
99 frame := NewFrame(10, 1)
100 setRow(frame, 0, "abcdefghi", DefaultStyle())
101 got := drawGolden(t, r, frame, []Damage{
102 {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
103 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
104 })
105 want := "\x1b[1;1Habcdefghi\x1b[0m"
106 if got != want {
107 t.Fatalf("output = %q, want %q", got, want)
108 }
109}
110
111func TestDamageOrderPermutationProducesCanonicalOutput(t *testing.T) {
112 tests := []struct {
113 name string
114 spans []Damage
115 }{
116 {
117 name: "overlapping spans",
118 spans: []Damage{
119 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
120 {Kind: DamageText, X: 4, Y: 0, Width: 5, Height: 1, Count: 1},
121 },
122 },
123 {
124 name: "adjacent spans",
125 spans: []Damage{
126 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1, Count: 1},
127 {Kind: DamageText, X: 5, Y: 0, Width: 4, Height: 1, Count: 1},
128 },
129 },
130 }
131
132 for _, tt := range tests {
133 t.Run(tt.name, func(t *testing.T) {
134 render := func(spans []Damage) string {
135 t.Helper()
136 r := New(Capabilities{})
137 if _, err := r.Draw(NewFrame(10, 1), []Damage{FullRedraw()}); err != nil {
138 t.Fatal(err)
139 }
140 frame := NewFrame(10, 1)
141 setRow(frame, 0, "abcdefghi", DefaultStyle())
142 return drawGolden(t, r, frame, spans)
143 }
144
145 forward := render(tt.spans)
146 reverse := render([]Damage{tt.spans[1], tt.spans[0]})
147 if forward != reverse {
148 t.Fatalf("forward output = %q, reverse output = %q", forward, reverse)
149 }
150 if hasCUB(forward) {
151 t.Fatalf("canonical output contains CUB: %q", forward)
152 }
153 want := "\x1b[1;1Habcdefghi\x1b[0m"
154 if forward != want {
155 t.Fatalf("output = %q, want canonical output %q", forward, want)
156 }
157 })
158 }
159}
160
161func TestNativeClearGolden(t *testing.T) {
162 tests := []struct {
163 name string
164 build func() Frame
165 want string
166 }{
167 {
168 name: "trailing blank span becomes EL",
169 build: func() Frame {
170 f := NewFrame(10, 1)
171 setRow(f, 0, "Hi", DefaultStyle())
172 return f
173 },
174 // content, then EL to clear the 8 trailing blanks, then reset
175 want: "\x1b[1;1HHi\x1b[K\x1b[0m",
176 },
177 {
178 name: "whole blank line cleared natively",
179 build: func() Frame {
180 f := NewFrame(10, 2)
181 setRow(f, 0, "AB", DefaultStyle())
182 // row 1 stays entirely blank
183 return f
184 },
185 // Row 0 leaves the cursor tracked at (0,2); the cheapest whole-line
186 // clear for row 1 is a one-row CUD in the same column plus 2K
187 // (3+4 bytes) rather than CUP to col 0 plus EL (6+3 bytes).
188 want: "\x1b[1;1HAB\x1b[K\x1b[B\x1b[2K\x1b[0m",
189 },
190 {
191 name: "short trailing blank span kept as spaces",
192 build: func() Frame {
193 f := NewFrame(5, 1)
194 setRow(f, 0, "ABC", DefaultStyle())
195 // two trailing blanks (< 3) → cheaper as spaces
196 return f
197 },
198 want: "\x1b[1;1HABC \x1b[0m",
199 },
200 }
201
202 for _, tt := range tests {
203 t.Run(tt.name, func(t *testing.T) {
204 r := New(Capabilities{})
205 frame := tt.build()
206 got := drawGolden(t, r, frame, []Damage{FullRedraw()})
207 if got != tt.want {
208 t.Fatalf("output = %q, want %q", got, tt.want)
209 }
210 // Shadow consistency: an identical redraw must be a no-op.
211 out2, err := r.Draw(frame, nil)
212 if err != nil {
213 t.Fatal(err)
214 }
215 if len(out2) != 0 {
216 t.Fatalf("second draw not a no-op: %q", string(out2))
217 }
218 })
219 }
220}
221
222func TestSGRPersistsAcrossDamageRects(t *testing.T) {
223 // Two same-styled rects on different rows: the SGR sequence must be emitted
224 // exactly once (the pen persists across rects for the whole Draw).
225 r := New(Capabilities{})
226 base := NewFrame(4, 3)
227 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
228 t.Fatal(err)
229 }
230
231 styled := Style{Bold: true, Foreground: 1, Background: -1}
232 frame := NewFrame(4, 3)
233 setRow(frame, 0, "XXXX", styled)
234 setRow(frame, 2, "YYYY", styled)
235 damage := []Damage{
236 {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
237 {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1, Count: 1},
238 }
239 got := drawGolden(t, r, frame, damage)
240 // Row0 reaches the right edge (no trailing blanks) so tracking invalidates;
241 // row2 is therefore positioned with an absolute CUP. The SGR appears once.
242 want := "\x1b[1;1H\x1b[0;1;38;5;1mXXXX\x1b[3;1HYYYY\x1b[0m"
243 if got != want {
244 t.Fatalf("output = %q, want %q", got, want)
245 }
246}
247
248func TestRightEdgeRunInvalidatesTracking(t *testing.T) {
249 // Adversarial: a run printed up to the terminal's right edge leaves the
250 // cursor in a terminal-dependent "wrap pending" state. The renderer must
251 // NOT assume the cursor wrapped to the next row; the following row must be
252 // positioned with an absolute CUP, never an implicit newline/relative move.
253 r := New(Capabilities{})
254 frame := NewFrame(5, 2)
255 setRow(frame, 0, "ABCDE", DefaultStyle())
256 setRow(frame, 1, "FGHIJ", DefaultStyle())
257 got := drawGolden(t, r, frame, []Damage{FullRedraw()})
258 want := "\x1b[1;1HABCDE\x1b[2;1HFGHIJ\x1b[0m"
259 if got != want {
260 t.Fatalf("output = %q, want %q", got, want)
261 }
262}
263
264func TestWideRunCursorTrackingAndEL(t *testing.T) {
265 t.Run("cursor advances two columns per wide rune", func(t *testing.T) {
266 // Rect A ends with a wide pair (not at the edge); rect B starts one
267 // column later. The gap between them must be crossed with the cursor
268 // position that reflects the terminal advancing two columns for the
269 // wide rune — otherwise the relative move would be wrong.
270 r := New(Capabilities{})
271 base := NewFrame(8, 1)
272 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
273 t.Fatal(err)
274 }
275 frame := NewFrame(8, 1)
276 frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
277 frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
278 frame.Set(2, 0, Cell{Rune: 'A', Style: DefaultStyle()})
279 frame.Set(3, 0, Cell{Rune: 'B', Style: DefaultStyle()})
280 // column 4 stays blank (undamaged gap)
281 frame.Set(5, 0, Cell{Rune: 'C', Style: DefaultStyle()})
282 frame.Set(6, 0, Cell{Rune: 'D', Style: DefaultStyle()})
283 frame.Set(7, 0, Cell{Rune: 'E', Style: DefaultStyle()})
284 damage := []Damage{
285 {Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 1, Count: 1},
286 {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},
287 }
288 got := drawGolden(t, r, frame, damage)
289 // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF.
290 want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m"
291 if got != want {
292 t.Fatalf("output = %q, want %q", got, want)
293 }
294 })
295
296 t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
297 // A damage rect that ends on a wide head whose continuation lies
298 // outside the rect makes the terminal advance one column further than
299 // the rect width accounts for. Tracking must be dropped: the next
300 // rect must be positioned with absolute CUP, not a relative move.
301 r := New(Capabilities{})
302 base := NewFrame(8, 1)
303 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
304 t.Fatal(err)
305 }
306 frame := NewFrame(8, 1)
307 frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
308 frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
309 frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
310 frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
311 damage := []Damage{
312 // Ends on the wide head at col 1; its continuation (col 2) is outside.
313 {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1},
314 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
315 }
316 got := drawGolden(t, r, frame, damage)
317 // After A你 the real cursor is at col 3, not col 2: a relative CUF
318 // would be off by one, so the second rect must use absolute CUP.
319 want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
320 if got != want {
321 t.Fatalf("output = %q, want %q", got, want)
322 }
323 })
324
325 t.Run("EL after a wide pair", func(t *testing.T) {
326 r := New(Capabilities{})
327 frame := NewFrame(7, 1)
328 frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
329 frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
330 frame.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
331 frame.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
332 // columns 4,5,6 blank → EL
333 got := drawGolden(t, r, frame, []Damage{FullRedraw()})
334 want := "\x1b[1;1H你好\x1b[K\x1b[0m"
335 if got != want {
336 t.Fatalf("output = %q, want %q", got, want)
337 }
338 out2, err := r.Draw(frame, nil)
339 if err != nil {
340 t.Fatal(err)
341 }
342 if len(out2) != 0 {
343 t.Fatalf("second draw not a no-op: %q", string(out2))
344 }
345 })
346}
347
348func TestScrollFastPathEmitsCanonicalTextDamage(t *testing.T) {
349 render := func(damage []Damage) string {
350 t.Helper()
351 r := New(Capabilities{})
352 base := NewFrame(10, 3)
353 setRow(base, 0, "aaaaaaaaaa", DefaultStyle())
354 setRow(base, 1, "bbbbbbbbbb", DefaultStyle())
355 setRow(base, 2, "cccccccccc", DefaultStyle())
356 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
357 t.Fatal(err)
358 }
359
360 frame := NewFrame(10, 3)
361 setRow(frame, 0, "bbbbbbbbbb", DefaultStyle())
362 setRow(frame, 1, "cccccccccc", DefaultStyle())
363 setRow(frame, 2, "abcdefghij", DefaultStyle())
364 out, err := r.Draw(frame, damage)
365 if err != nil {
366 t.Fatal(err)
367 }
368 return string(out)
369 }
370
371 scroll := Damage{Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}
372 forward := render([]Damage{
373 scroll,
374 {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
375 {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
376 })
377 reverse := render([]Damage{
378 {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
379 scroll,
380 {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
381 })
382 if forward != reverse {
383 t.Fatalf("scroll output depends on text damage order: forward %q, reverse %q", forward, reverse)
384 }
385 if strings.Contains(forward, "\r") || !strings.Contains(forward, "abcdefghij") {
386 t.Fatalf("scroll output did not emit one forward canonical span: %q", forward)
387 }
388}
389
390func TestCanonicalDamageMergesClearAndText(t *testing.T) {
391 r := New(Capabilities{})
392 base := NewFrame(10, 1)
393 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
394 t.Fatal(err)
395 }
396 frame := NewFrame(10, 1)
397 setRow(frame, 0, "abcdefghi", DefaultStyle())
398 out, err := r.Draw(frame, []Damage{
399 {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
400 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
401 })
402 if err != nil {
403 t.Fatal(err)
404 }
405 if got, want := string(out), "\x1b[1;1Habcdefghi\x1b[0m"; got != want {
406 t.Fatalf("output = %q, want %q", got, want)
407 }
408}
409
410func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
411 r := New(Capabilities{})
412 base := NewFrame(8, 1)
413 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
414 t.Fatal(err)
415 }
416 frame := NewFrame(8, 1)
417 frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
418 frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
419 frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
420 frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
421 out, err := r.Draw(frame, []Damage{
422 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1},
423 {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1},
424 })
425 if err != nil {
426 t.Fatal(err)
427 }
428 if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want {
429 t.Fatalf("output = %q, want %q", got, want)
430 }
431}
432
433func TestScrollPlannerFallbackRedrawsWithoutScrollEscape(t *testing.T) {
434 r := New(Capabilities{})
435 base := NewFrame(1, maxPlannedDamageSpans+1)
436 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
437 t.Fatal(err)
438 }
439
440 frame := base.Clone()
441 frame.ScrollUp(0, frame.Height-1, 1)
442 frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
443 damage := []Damage{
444 {Kind: DamageScrollUp, X: 0, Y: 0, Width: frame.Width, Height: frame.Height, Count: 1},
445 {Kind: DamageText, X: 0, Y: 0, Width: frame.Width, Height: frame.Height},
446 }
447 out, err := r.Draw(frame, damage)
448 if err != nil {
449 t.Fatal(err)
450 }
451 if scrollEscape := "\x1b[1;" + strconv.Itoa(frame.Height) + "r"; strings.Contains(string(out), scrollEscape) {
452 t.Fatalf("planner fallback emitted scroll escape %q before full redraw: %q", scrollEscape, string(out))
453 }
454 if !strings.Contains(string(out), "X") {
455 t.Fatalf("planner fallback did not redraw the frame: %q", string(out))
456 }
457 out, err = r.Draw(frame, nil)
458 if err != nil {
459 t.Fatal(err)
460 }
461 if len(out) != 0 {
462 t.Fatalf("planner fallback did not synchronize shadow; follow-up output = %q", string(out))
463 }
464}
465
466func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
467 r := New(Capabilities{})
468 frame := NewFrame(1, maxPlannedDamageSpans+1)
469 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
470 t.Fatal(err)
471 }
472 frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
473 out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}})
474 if err != nil {
475 t.Fatal(err)
476 }
477 if !strings.Contains(string(out), "X") {
478 t.Fatalf("fallback output did not redraw the frame: %q", string(out))
479 }
480 out, err = r.Draw(frame, nil)
481 if err != nil {
482 t.Fatal(err)
483 }
484 if len(out) != 0 {
485 t.Fatalf("fallback did not synchronize shadow; follow-up output = %q", string(out))
486 }
487}