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 is widened to the whole pair", func(t *testing.T) {
297 // A damage rect that ends on a wide head whose continuation lies
298 // outside the rect is widened to cover the pair, so the terminal
299 // advance matches the span and tracking stays exact.
300 r := New(Capabilities{})
301 base := NewFrame(8, 1)
302 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
303 t.Fatal(err)
304 }
305 frame := NewFrame(8, 1)
306 frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
307 frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
308 frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
309 frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
310 damage := []Damage{
311 // Ends on the wide head at col 1; its continuation (col 2) is outside.
312 {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1, Count: 1},
313 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
314 }
315 got := drawGolden(t, r, frame, damage)
316 // After A你 the cursor is known at col 3, so one CUF reaches col 4.
317 want := "\x1b[1;1HA你\x1b[CB\x1b[0m"
318 if got != want {
319 t.Fatalf("output = %q, want %q", got, want)
320 }
321 })
322
323 t.Run("EL after a wide pair", func(t *testing.T) {
324 r := New(Capabilities{})
325 frame := NewFrame(7, 1)
326 frame.Set(0, 0, Cell{Rune: '你', Style: DefaultStyle()})
327 frame.Set(1, 0, Cell{Continuation: true, Style: DefaultStyle()})
328 frame.Set(2, 0, Cell{Rune: '好', Style: DefaultStyle()})
329 frame.Set(3, 0, Cell{Continuation: true, Style: DefaultStyle()})
330 // columns 4,5,6 blank → EL
331 got := drawGolden(t, r, frame, []Damage{FullRedraw()})
332 want := "\x1b[1;1H你好\x1b[K\x1b[0m"
333 if got != want {
334 t.Fatalf("output = %q, want %q", got, want)
335 }
336 out2, err := r.Draw(frame, nil)
337 if err != nil {
338 t.Fatal(err)
339 }
340 if len(out2) != 0 {
341 t.Fatalf("second draw not a no-op: %q", string(out2))
342 }
343 })
344}
345
346func TestScrollFastPathEmitsCanonicalTextDamage(t *testing.T) {
347 render := func(damage []Damage) string {
348 t.Helper()
349 r := New(Capabilities{})
350 base := NewFrame(10, 3)
351 setRow(base, 0, "aaaaaaaaaa", DefaultStyle())
352 setRow(base, 1, "bbbbbbbbbb", DefaultStyle())
353 setRow(base, 2, "cccccccccc", DefaultStyle())
354 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
355 t.Fatal(err)
356 }
357
358 frame := NewFrame(10, 3)
359 setRow(frame, 0, "bbbbbbbbbb", DefaultStyle())
360 setRow(frame, 1, "cccccccccc", DefaultStyle())
361 setRow(frame, 2, "abcdefghij", DefaultStyle())
362 out, err := r.Draw(frame, damage)
363 if err != nil {
364 t.Fatal(err)
365 }
366 return string(out)
367 }
368
369 scroll := Damage{Kind: DamageScrollUp, X: 0, Y: 0, Width: 10, Height: 3, Count: 1}
370 forward := render([]Damage{
371 scroll,
372 {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
373 {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
374 })
375 reverse := render([]Damage{
376 {Kind: DamageText, X: 4, Y: 2, Width: 6, Height: 1},
377 scroll,
378 {Kind: DamageText, X: 0, Y: 2, Width: 5, Height: 1},
379 })
380 if forward != reverse {
381 t.Fatalf("scroll output depends on text damage order: forward %q, reverse %q", forward, reverse)
382 }
383 if strings.Contains(forward, "\r") || !strings.Contains(forward, "abcdefghij") {
384 t.Fatalf("scroll output did not emit one forward canonical span: %q", forward)
385 }
386}
387
388func TestCanonicalDamageMergesClearAndText(t *testing.T) {
389 r := New(Capabilities{})
390 base := NewFrame(10, 1)
391 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
392 t.Fatal(err)
393 }
394 frame := NewFrame(10, 1)
395 setRow(frame, 0, "abcdefghi", DefaultStyle())
396 out, err := r.Draw(frame, []Damage{
397 {Kind: DamageClear, X: 5, Y: 0, Width: 4, Height: 1},
398 {Kind: DamageText, X: 0, Y: 0, Width: 5, Height: 1},
399 })
400 if err != nil {
401 t.Fatal(err)
402 }
403 if got, want := string(out), "\x1b[1;1Habcdefghi\x1b[0m"; got != want {
404 t.Fatalf("output = %q, want %q", got, want)
405 }
406}
407
408func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
409 r := New(Capabilities{})
410 base := NewFrame(8, 1)
411 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
412 t.Fatal(err)
413 }
414 frame := NewFrame(8, 1)
415 frame.Set(0, 0, Cell{Rune: 'A', Style: DefaultStyle()})
416 frame.Set(1, 0, Cell{Rune: '你', Style: DefaultStyle()})
417 frame.Set(2, 0, Cell{Continuation: true, Style: DefaultStyle()})
418 frame.Set(4, 0, Cell{Rune: 'B', Style: DefaultStyle()})
419 out, err := r.Draw(frame, []Damage{
420 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1},
421 {Kind: DamageText, X: 0, Y: 0, Width: 2, Height: 1},
422 })
423 if err != nil {
424 t.Fatal(err)
425 }
426 if got, want := string(out), "\x1b[1;1HA你\x1b[CB\x1b[0m"; got != want {
427 t.Fatalf("output = %q, want %q", got, want)
428 }
429}
430
431func TestScrollPlannerFallbackRedrawsWithoutScrollEscape(t *testing.T) {
432 r := New(Capabilities{})
433 base := NewFrame(1, maxPlannedDamageSpans+1)
434 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
435 t.Fatal(err)
436 }
437
438 frame := base.Clone()
439 frame.ScrollUp(0, frame.Height-1, 1)
440 frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
441 damage := []Damage{
442 {Kind: DamageScrollUp, X: 0, Y: 0, Width: frame.Width, Height: frame.Height, Count: 1},
443 {Kind: DamageText, X: 0, Y: 0, Width: frame.Width, Height: frame.Height},
444 }
445 out, err := r.Draw(frame, damage)
446 if err != nil {
447 t.Fatal(err)
448 }
449 if scrollEscape := "\x1b[1;" + strconv.Itoa(frame.Height) + "r"; strings.Contains(string(out), scrollEscape) {
450 t.Fatalf("planner fallback emitted scroll escape %q before full redraw: %q", scrollEscape, string(out))
451 }
452 if !strings.Contains(string(out), "X") {
453 t.Fatalf("planner fallback did not redraw the frame: %q", string(out))
454 }
455 out, err = r.Draw(frame, nil)
456 if err != nil {
457 t.Fatal(err)
458 }
459 if len(out) != 0 {
460 t.Fatalf("planner fallback did not synchronize shadow; follow-up output = %q", string(out))
461 }
462}
463
464func TestDamagePlannerFallbackRedrawsAndSynchronizesShadow(t *testing.T) {
465 r := New(Capabilities{})
466 frame := NewFrame(1, maxPlannedDamageSpans+1)
467 if _, err := r.Draw(frame, []Damage{FullRedraw()}); err != nil {
468 t.Fatal(err)
469 }
470 frame.Set(0, frame.Height-1, Cell{Rune: 'X', Style: DefaultStyle()})
471 out, err := r.Draw(frame, []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: frame.Height}})
472 if err != nil {
473 t.Fatal(err)
474 }
475 if !strings.Contains(string(out), "X") {
476 t.Fatalf("fallback output did not redraw the frame: %q", string(out))
477 }
478 out, err = r.Draw(frame, nil)
479 if err != nil {
480 t.Fatal(err)
481 }
482 if len(out) != 0 {
483 t.Fatalf("fallback did not synchronize shadow; follow-up output = %q", string(out))
484 }
485}