1package vt
2
3import (
4 "testing"
5
6 "github.com/bnema/vev/pkg/renderer"
7 "github.com/stretchr/testify/require"
8)
9
10func TestParseCSIInts(t *testing.T) {
11 t.Parallel()
12
13 tests := []struct {
14 name string
15 params string
16 want []int
17 }{
18 {name: "empty", params: "", want: nil},
19 {name: "single", params: "5", want: []int{5}},
20 {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}},
21 {name: "leading empty token", params: ";5", want: []int{0, 5}},
22 {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
23 {name: "trailing empty token", params: "5;", want: []int{5, 0}},
24 {name: "question prefix", params: "?25", want: []int{25}},
25 {name: "greater prefix", params: ">0;1", want: []int{0, 1}},
26 {name: "prefix order", params: "?>7", want: []int{7}},
27 {name: "junk token", params: "12x;4", want: []int{0, 4}},
28 }
29
30 for _, tt := range tests {
31 t.Run(tt.name, func(t *testing.T) {
32 require.Equal(t, tt.want, parseCSIInts(tt.params))
33 })
34 }
35}
36
37func TestParseSGRInts(t *testing.T) {
38 t.Parallel()
39
40 tests := []struct {
41 name string
42 params string
43 want []int
44 }{
45 {name: "empty", params: "", want: nil},
46 {name: "single", params: "1", want: []int{1}},
47 {name: "multiple", params: "1;31", want: []int{1, 31}},
48 {name: "leading empty token", params: ";5", want: []int{0, 5}},
49 {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
50 {name: "trailing empty token", params: "5;", want: []int{5, 0}},
51 {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}},
52 {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}},
53 {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}},
54 {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}},
55 {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}},
56 {name: "non color colon group", params: "1:2:3", want: []int{1}},
57 {name: "junk token", params: "abc;31", want: []int{0, 31}},
58 }
59
60 for _, tt := range tests {
61 t.Run(tt.name, func(t *testing.T) {
62 require.Equal(t, tt.want, parseSGRInts(tt.params))
63 })
64 }
65}
66
67func TestSGR(t *testing.T) {
68 tests := []struct {
69 name string
70 seq string
71 check func(t *testing.T, s *Screen)
72 }{
73 {
74 name: "SGR 0 resets to default style",
75 seq: "\x1b[0m",
76 check: func(t *testing.T, s *Screen) {
77 if s.Style != (renderer.DefaultStyle()) {
78 t.Errorf("style after SGR 0 = %+v, want default", s.Style)
79 }
80 },
81 },
82 {
83 name: "SGR 1 sets bold",
84 seq: "\x1b[1m",
85 check: func(t *testing.T, s *Screen) {
86 if !s.Style.Bold {
87 t.Error("expected bold")
88 }
89 },
90 },
91 {
92 name: "SGR 7 sets inverse",
93 seq: "\x1b[7m",
94 check: func(t *testing.T, s *Screen) {
95 if !s.Style.Inverse {
96 t.Error("expected inverse")
97 }
98 },
99 },
100 {
101 name: "SGR 31 sets red foreground",
102 seq: "\x1b[31m",
103 check: func(t *testing.T, s *Screen) {
104 if s.Style.Foreground != 1 {
105 t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground)
106 }
107 },
108 },
109 {
110 name: "SGR 44 sets blue background",
111 seq: "\x1b[44m",
112 check: func(t *testing.T, s *Screen) {
113 if s.Style.Background != 4 {
114 t.Errorf("background = %d, want 4 (blue)", s.Style.Background)
115 }
116 },
117 },
118 {
119 name: "SGR 38;5 sets 256-color foreground",
120 seq: "\x1b[38;5;82m",
121 check: func(t *testing.T, s *Screen) {
122 if s.Style.Foreground != 82 {
123 t.Errorf("foreground = %d, want 82", s.Style.Foreground)
124 }
125 },
126 },
127 {
128 name: "SGR 48;5 sets 256-color background",
129 seq: "\x1b[48;5;200m",
130 check: func(t *testing.T, s *Screen) {
131 if s.Style.Background != 200 {
132 t.Errorf("background = %d, want 200", s.Style.Background)
133 }
134 },
135 },
136 {
137 name: "SGR 91 sets bright red foreground",
138 seq: "\x1b[91m",
139 check: func(t *testing.T, s *Screen) {
140 if s.Style.Foreground != 9 {
141 t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground)
142 }
143 },
144 },
145 {
146 name: "SGR 107 sets bright white background",
147 seq: "\x1b[107m",
148 check: func(t *testing.T, s *Screen) {
149 if s.Style.Background != 15 {
150 t.Errorf("background = %d, want 15 (bright white)", s.Style.Background)
151 }
152 },
153 },
154 {
155 name: "SGR 22 resets bold",
156 seq: "\x1b[1mHello\x1b[22mWorld",
157 check: func(t *testing.T, s *Screen) {
158 if s.Style.Bold {
159 t.Error("bold should be reset after 22")
160 }
161 },
162 },
163 {
164 name: "SGR 27 resets inverse",
165 seq: "\x1b[7m\x1b[27m",
166 check: func(t *testing.T, s *Screen) {
167 if s.Style.Inverse {
168 t.Error("inverse should be reset after 27")
169 }
170 },
171 },
172 {
173 name: "SGR 39 resets foreground to default",
174 seq: "\x1b[31m\x1b[39m",
175 check: func(t *testing.T, s *Screen) {
176 if s.Style.Foreground != -1 {
177 t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground)
178 }
179 },
180 },
181 {
182 name: "SGR 49 resets background to default",
183 seq: "\x1b[44m\x1b[49m",
184 check: func(t *testing.T, s *Screen) {
185 if s.Style.Background != -1 {
186 t.Errorf("background should be default after 49, got %d", s.Style.Background)
187 }
188 },
189 },
190 {
191 name: "SGR style is applied to written cells",
192 seq: "\x1b[1;31mX",
193 check: func(t *testing.T, s *Screen) {
194 c := cellAt(s, 0, 0)
195 if !c.Style.Bold {
196 t.Error("cell should be bold")
197 }
198 if c.Style.Foreground != 1 {
199 t.Errorf("cell foreground = %d, want 1", c.Style.Foreground)
200 }
201 },
202 },
203 {
204 name: "multiple sequential SGR sequences accumulate",
205 seq: "\x1b[31m\x1b[1m\x1b[44mX",
206 check: func(t *testing.T, s *Screen) {
207 c := cellAt(s, 0, 0)
208 if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 {
209 t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style)
210 }
211 },
212 },
213 {
214 name: "SGR 3 sets italic on style and cell, SGR 23 clears it",
215 seq: "\x1b[3mX\x1b[23mY",
216 check: func(t *testing.T, s *Screen) {
217 if !cellAt(s, 0, 0).Style.Italic {
218 t.Errorf("first cell italic = false, want true")
219 }
220 if cellAt(s, 1, 0).Style.Italic {
221 t.Errorf("second cell italic = true, want false after SGR 23")
222 }
223 if s.Style.Italic {
224 t.Errorf("current style italic = true, want false after SGR 23")
225 }
226 },
227 },
228 {
229 name: "SGR preserves dim underline blink and strikethrough with resets",
230 seq: "\x1b[2;4;5;9mA\x1b[22;24;25;29mB",
231 check: func(t *testing.T, s *Screen) {
232 first := cellAt(s, 0, 0).Style
233 if first.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough {
234 t.Errorf("first cell attrs = %b, want dim underline blink strikethrough", first.Attrs)
235 }
236 second := cellAt(s, 1, 0).Style
237 if second.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 {
238 t.Errorf("second cell attrs = %b, want resets cleared", second.Attrs)
239 }
240 },
241 },
242 {
243 name: "SGR 22 resets both bold and dim",
244 seq: "\x1b[1;2m\x1b[22m",
245 check: func(t *testing.T, s *Screen) {
246 if s.Style.Bold || s.Style.Attrs&renderer.AttrDim != 0 {
247 t.Errorf("style after SGR 22 = bold:%v attrs:%b, want neither bold nor dim", s.Style.Bold, s.Style.Attrs)
248 }
249 },
250 },
251 {
252 name: "SGR underline style and color are preserved and reset",
253 seq: "\x1b[4:3;58:2::255:0:0mA\x1b[59mB",
254 check: func(t *testing.T, s *Screen) {
255 first := cellAt(s, 0, 0).Style
256 if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
257 t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
258 }
259 want := renderer.RGB{R: 255, G: 0, B: 0}
260 if !first.HasUnderlineColorRGB || first.UnderlineColorRGB != want {
261 t.Errorf("first underline color = rgb:%v %+v, want %+v", first.HasUnderlineColorRGB, first.UnderlineColorRGB, want)
262 }
263 second := cellAt(s, 1, 0).Style
264 if second.HasUnderlineColorRGB || second.HasUnderlineColor {
265 t.Errorf("second underline color flags = indexed:%v rgb:%v, want reset", second.HasUnderlineColor, second.HasUnderlineColorRGB)
266 }
267 },
268 },
269 {
270 name: "SGR 4 colon zero clears underline",
271 seq: "\x1b[4:3mA\x1b[4:0mB",
272 check: func(t *testing.T, s *Screen) {
273 first := cellAt(s, 0, 0).Style
274 if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
275 t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
276 }
277 second := cellAt(s, 1, 0).Style
278 if second.Attrs&renderer.AttrUnderline != 0 || second.UnderlineStyle != renderer.UnderlineNone {
279 t.Errorf("second underline = attrs:%b style:%d, want cleared", second.Attrs, second.UnderlineStyle)
280 }
281 },
282 },
283 {
284 name: "empty SGR params reset style",
285 seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset
286 check: func(t *testing.T, s *Screen) {
287 if s.Style != (renderer.DefaultStyle()) {
288 t.Errorf("style after empty SGR = %+v, want default", s.Style)
289 }
290 },
291 },
292 }
293
294 for _, tt := range tests {
295 t.Run(tt.name, func(t *testing.T) {
296 s := NewScreen(10, 3)
297 s.Write([]byte(tt.seq))
298 tt.check(t, s)
299 })
300 }
301}
302
303func TestSGRTruecolor(t *testing.T) {
304 tests := []struct {
305 name string
306 seq string
307 check func(t *testing.T, s *Screen)
308 }{
309 {
310 name: "SGR 38;2 sets truecolor foreground on style and cell",
311 seq: "\x1b[38;2;12;34;56mX",
312 check: func(t *testing.T, s *Screen) {
313 want := renderer.RGB{R: 12, G: 34, B: 56}
314 if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
315 t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
316 }
317 if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
318 t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
319 }
320 },
321 },
322 {
323 name: "SGR 38:2 sets truecolor foreground",
324 seq: "\x1b[38:2:12:34:56mX",
325 check: func(t *testing.T, s *Screen) {
326 want := renderer.RGB{R: 12, G: 34, B: 56}
327 if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
328 t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
329 }
330 if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
331 t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
332 }
333 },
334 },
335 {
336 name: "SGR 38:2 empty color space sets truecolor foreground",
337 seq: "\x1b[38:2::12:34:56mX",
338 check: func(t *testing.T, s *Screen) {
339 want := renderer.RGB{R: 12, G: 34, B: 56}
340 if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
341 t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
342 }
343 if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
344 t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
345 }
346 },
347 },
348 {
349 name: "SGR 38:2 color space id sets truecolor foreground",
350 seq: "\x1b[38:2:1:12:34:56mX",
351 check: func(t *testing.T, s *Screen) {
352 want := renderer.RGB{R: 12, G: 34, B: 56}
353 if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
354 t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
355 }
356 if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
357 t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
358 }
359 },
360 },
361 {
362 name: "mixed semicolon and colon SGR groups",
363 seq: "\x1b[1;38:2:10:20:30;7mX",
364 check: func(t *testing.T, s *Screen) {
365 want := renderer.RGB{R: 10, G: 20, B: 30}
366 if !s.Style.Bold || !s.Style.Inverse {
367 t.Errorf("bold/inverse = %v/%v, want true/true", s.Style.Bold, s.Style.Inverse)
368 }
369 if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
370 t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
371 }
372 },
373 },
374 {
375 name: "truncated colon truecolor group does not consume next parameter",
376 seq: "\x1b[38:2:1:2;31m",
377 check: func(t *testing.T, s *Screen) {
378 if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
379 t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
380 }
381 },
382 },
383 {
384 name: "SGR 48;2 sets truecolor background",
385 seq: "\x1b[48;2;200;100;50m",
386 check: func(t *testing.T, s *Screen) {
387 want := renderer.RGB{R: 200, G: 100, B: 50}
388 if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
389 t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
390 }
391 },
392 },
393 {
394 name: "SGR 48:2 sets truecolor background",
395 seq: "\x1b[48:2:200:100:50m",
396 check: func(t *testing.T, s *Screen) {
397 want := renderer.RGB{R: 200, G: 100, B: 50}
398 if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
399 t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
400 }
401 },
402 },
403 {
404 name: "SGR 38:5 sets 256-color foreground",
405 seq: "\x1b[38:5:82m",
406 check: func(t *testing.T, s *Screen) {
407 if s.Style.Foreground != 82 || s.Style.HasForegroundRGB {
408 t.Errorf("foreground = %d rgb:%v, want 82 false", s.Style.Foreground, s.Style.HasForegroundRGB)
409 }
410 },
411 },
412 {
413 name: "SGR 48:5 sets 256-color background",
414 seq: "\x1b[48:5:200m",
415 check: func(t *testing.T, s *Screen) {
416 if s.Style.Background != 200 || s.Style.HasBackgroundRGB {
417 t.Errorf("background = %d rgb:%v, want 200 false", s.Style.Background, s.Style.HasBackgroundRGB)
418 }
419 },
420 },
421 {
422 name: "truncated colon foreground index group does not consume next parameter",
423 seq: "\x1b[38:5;31m",
424 check: func(t *testing.T, s *Screen) {
425 if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
426 t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
427 }
428 },
429 },
430 {
431 name: "truncated colon background index group does not consume next parameter",
432 seq: "\x1b[48:5;44m",
433 check: func(t *testing.T, s *Screen) {
434 if s.Style.Background != 4 || s.Style.HasBackgroundRGB {
435 t.Errorf("background = %d rgb:%v, want 4 false", s.Style.Background, s.Style.HasBackgroundRGB)
436 }
437 },
438 },
439 {
440 name: "SGR 39 after truecolor clears truecolor foreground",
441 seq: "\x1b[38;2;12;34;56m\x1b[39m",
442 check: func(t *testing.T, s *Screen) {
443 if s.Style.HasForegroundRGB || s.Style.Foreground != -1 {
444 t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground)
445 }
446 },
447 },
448 {
449 name: "truecolor components are clamped to 0-255",
450 seq: "\x1b[38;2;-1;300;42m",
451 check: func(t *testing.T, s *Screen) {
452 want := renderer.RGB{R: 0, G: 255, B: 42}
453 if s.Style.ForegroundRGB != want {
454 t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want)
455 }
456 },
457 },
458 }
459
460 for _, tt := range tests {
461 t.Run(tt.name, func(t *testing.T) {
462 s := NewScreen(10, 3)
463 s.Write([]byte(tt.seq))
464 tt.check(t, s)
465 })
466 }
467}
468
469func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) {
470 tests := []struct {
471 name string
472 seq string
473 foreground int
474 background int
475 hasForeground bool
476 hasBackground bool
477 }{
478 {name: "foreground rgb to bright", seq: "\x1b[38;2;1;2;3m\x1b[91m", foreground: 9, background: -1},
479 {name: "foreground rgb to indexed", seq: "\x1b[38;2;1;2;3m\x1b[38;5;82m", foreground: 82, background: -1},
480 {name: "foreground indexed to rgb", seq: "\x1b[38;5;82m\x1b[38;2;4;5;6m", foreground: -1, background: -1, hasForeground: true},
481 {name: "background rgb to bright", seq: "\x1b[48;2;1;2;3m\x1b[107m", foreground: -1, background: 15},
482 {name: "background rgb to indexed", seq: "\x1b[48;2;1;2;3m\x1b[48;5;200m", foreground: -1, background: 200},
483 {name: "background rgb to default", seq: "\x1b[48;2;1;2;3m\x1b[49m", foreground: -1, background: -1},
484 {name: "background indexed to rgb", seq: "\x1b[48;5;200m\x1b[48;2;4;5;6m", foreground: -1, background: -1, hasBackground: true},
485 }
486
487 for _, tt := range tests {
488 t.Run(tt.name, func(t *testing.T) {
489 s := NewScreen(10, 3)
490 s.Write([]byte(tt.seq))
491
492 if s.Style.Foreground != tt.foreground || s.Style.HasForegroundRGB != tt.hasForeground {
493 t.Errorf("foreground = %d rgb:%v, want %d rgb:%v", s.Style.Foreground, s.Style.HasForegroundRGB, tt.foreground, tt.hasForeground)
494 }
495 if s.Style.Background != tt.background || s.Style.HasBackgroundRGB != tt.hasBackground {
496 t.Errorf("background = %d rgb:%v, want %d rgb:%v", s.Style.Background, s.Style.HasBackgroundRGB, tt.background, tt.hasBackground)
497 }
498 })
499 }
500}
501
502func TestClearAndErase(t *testing.T) {
503 tests := []struct {
504 name string
505 run func(t *testing.T)
506 }{
507 {
508 name: "CSI 2J clears the whole screen",
509 run: func(t *testing.T) {
510 s := NewScreen(5, 3)
511 s.Write([]byte("HelloWorldABCDE"))
512 // Clear damage from writing.
513 s.ClearDamage()
514
515 s.Write([]byte("\x1b[2J"))
516
517 // All cells should be blank.
518 for y := range 3 {
519 for x := range 5 {
520 if c := cellAt(s, x, y); c.Rune != ' ' {
521 t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune)
522 }
523 }
524 }
525
526 d := s.Damage()
527 if !hasDamageKind(d, renderer.DamageClear) {
528 t.Error("expected DamageClear after CSI 2 J")
529 }
530 },
531 },
532 {
533 name: "CSI K clears from cursor to end of line",
534 run: func(t *testing.T) {
535 s := NewScreen(10, 3)
536 s.Write([]byte("HelloWorld"))
537 s.ClearDamage()
538
539 // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end.
540 s.Write([]byte("\x1b[1;6H"))
541 s.Write([]byte("\x1b[K"))
542
543 for x := 5; x < 10; x++ {
544 if c := cellAt(s, x, 0); c.Rune != ' ' {
545 t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune)
546 }
547 }
548 // First 5 chars should remain.
549 assertCell(t, s, 0, 0, 'H')
550
551 d := s.Damage()
552 if !hasDamageKind(d, renderer.DamageClear) {
553 t.Error("expected DamageClear after CSI K")
554 }
555 },
556 },
557 {
558 name: "erase display modes 0/1/2 (implicit, 1, 2/3)",
559 run: func(t *testing.T) {
560 s := NewScreen(5, 3)
561 s.Write([]byte("abcdefghijklmno"))
562 s.Write([]byte("\x1b[2;3H"))
563 s.ClearDamage()
564 s.Write([]byte("\x1b[J"))
565 assertCell(t, s, 0, 0, 'a')
566 assertCell(t, s, 1, 1, 'g')
567 if c := cellAt(s, 2, 1); c.Rune != ' ' {
568 t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune)
569 }
570
571 s = NewScreen(5, 3)
572 s.Write([]byte("abcdefghijklmno"))
573 s.Write([]byte("\x1b[2;3H"))
574 s.Write([]byte("\x1b[1J"))
575 if c := cellAt(s, 0, 0); c.Rune != ' ' {
576 t.Fatalf("start-to-cursor clear left %q at start", c.Rune)
577 }
578 assertCell(t, s, 3, 1, 'i')
579 },
580 },
581 {
582 name: "erase line modes 0/1/2",
583 run: func(t *testing.T) {
584 s := NewScreen(5, 2)
585 s.Write([]byte("abcde"))
586 s.Write([]byte("\x1b[1;3H"))
587 s.Write([]byte("\x1b[1K"))
588 if c := cellAt(s, 0, 0); c.Rune != ' ' {
589 t.Fatalf("line start clear left %q", c.Rune)
590 }
591 assertCell(t, s, 3, 0, 'd')
592
593 s = NewScreen(5, 2)
594 s.Write([]byte("abcde"))
595 s.Write([]byte("\x1b[1;3H"))
596 s.Write([]byte("\x1b[2K"))
597 for x := range 5 {
598 if c := cellAt(s, x, 0); c.Rune != ' ' {
599 t.Fatalf("line clear left %q at %d", c.Rune, x)
600 }
601 }
602 },
603 },
604 }
605
606 for _, tt := range tests {
607 t.Run(tt.name, tt.run)
608 }
609}
610
611func TestBackgroundColorErase(t *testing.T) {
612 tests := []struct {
613 name string
614 width int
615 height int
616 seq string
617 startX int
618 wantStyle renderer.Style
619 }{
620 {
621 name: "indexed background for EL",
622 width: 8,
623 height: 1,
624 seq: "\x1b[1;31;48;5;236mX\x1b[K",
625 startX: 1,
626 wantStyle: renderer.Style{Foreground: -1, Background: 236},
627 },
628 {
629 name: "truecolor background for full ED",
630 width: 3,
631 height: 2,
632 seq: "abcdef\x1b[1;31;48;2;12;34;56m\x1b[2J",
633 wantStyle: renderer.Style{
634 Foreground: -1,
635 Background: -1,
636 HasBackgroundRGB: true,
637 BackgroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
638 },
639 },
640 {
641 name: "default background after SGR 49",
642 width: 2,
643 height: 1,
644 seq: "\x1b[48;2;12;34;56m\x1b[49m\x1b[K",
645 wantStyle: renderer.DefaultStyle(),
646 },
647 {
648 name: "default background after SGR 0",
649 width: 2,
650 height: 1,
651 seq: "\x1b[48;2;12;34;56m\x1b[0m\x1b[K",
652 wantStyle: renderer.DefaultStyle(),
653 },
654 }
655
656 for _, tt := range tests {
657 t.Run(tt.name, func(t *testing.T) {
658 s := NewScreen(tt.width, tt.height)
659 s.Write([]byte(tt.seq))
660
661 for y := range tt.height {
662 for x := tt.startX; x < tt.width; x++ {
663 cell := cellAt(s, x, y)
664 if cell.Rune != ' ' || cell.Style != tt.wantStyle {
665 t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle)
666 }
667 }
668 }
669 })
670 }
671}
672
673func TestCursorMove(t *testing.T) {
674 tests := []struct {
675 name string
676 seq string
677 initRow int
678 initCol int
679 wantRow int
680 wantCol int
681 }{
682 {name: "CSI H moves to explicit row/col", seq: "\x1b[3;4H", wantRow: 2, wantCol: 3},
683 {name: "CSI H clamps to bottom-right when out of bounds", seq: "\x1b[100;200H", wantRow: 4, wantCol: 9},
684 {name: "CSI f moves to explicit row/col", seq: "\x1b[2;8f", wantRow: 1, wantCol: 7},
685 {name: "CSI C moves forward after CR", seq: "\r\x1b[3C", wantRow: 0, wantCol: 3},
686 {name: "CSI H with no params moves to (1,1)", seq: "\x1b[H", wantRow: 0, wantCol: 0},
687 {name: "CSI ;5H uses default row with explicit column", seq: "\x1b[;5H", initRow: 3, initCol: 4, wantRow: 0, wantCol: 4},
688 }
689
690 for _, tt := range tests {
691 t.Run(tt.name, func(t *testing.T) {
692 s := NewScreen(10, 5)
693 s.Row, s.Col = tt.initRow, tt.initCol
694 s.Write([]byte(tt.seq))
695
696 if s.Row != tt.wantRow || s.Col != tt.wantCol {
697 t.Errorf("cursor at row=%d col=%d, want row=%d col=%d", s.Row, s.Col, tt.wantRow, tt.wantCol)
698 }
699 })
700 }
701}
702
703func TestCSIEditingSequences(t *testing.T) {
704 tests := []struct {
705 name string
706 run func(t *testing.T)
707 }{
708 {
709 name: "directional cursor moves (A/B/C/D)",
710 run: func(t *testing.T) {
711 s := NewScreen(10, 4)
712 s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>"))
713
714 assertCell(t, s, 4, 2, 'X')
715 assertCell(t, s, 3, 2, '<')
716 assertCell(t, s, 4, 1, '^')
717 assertCell(t, s, 5, 2, 'v')
718 assertCell(t, s, 9, 2, '>')
719 },
720 },
721 {
722 name: "insert and delete characters (@ and P)",
723 run: func(t *testing.T) {
724 s := NewScreen(8, 2)
725 s.Write([]byte("abcdef"))
726 s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
727 if got := lineText(s, 0); got != "abXYcdef" {
728 t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef")
729 }
730
731 s.Write([]byte("\x1b[1;4H\x1b[3P"))
732 if got := lineText(s, 0); got != "abXef " {
733 t.Fatalf("line after DCH = %q, want %q", got, "abXef ")
734 }
735 },
736 },
737 {
738 name: "insert and delete lines (L and M)",
739 run: func(t *testing.T) {
740 s := NewScreen(5, 4)
741 s.Write([]byte("11111222223333344444"))
742 s.Write([]byte("\x1b[2;1H\x1b[L"))
743 if got := lineText(s, 1); got != " " {
744 t.Fatalf("inserted line = %q, want blank", got)
745 }
746 if got := lineText(s, 2); got != "22222" {
747 t.Fatalf("shifted line = %q, want 22222", got)
748 }
749
750 s.Write([]byte("\x1b[2;1H\x1b[M"))
751 if got := lineText(s, 1); got != "22222" {
752 t.Fatalf("line after delete = %q, want 22222", got)
753 }
754 },
755 },
756 {
757 name: "scroll region (CSI r) confines LF scrolling",
758 run: func(t *testing.T) {
759 s := NewScreen(5, 4)
760 s.Write([]byte("11111222223333344444"))
761 s.Write([]byte("\x1b[2;3r\x1b[3;1H\n"))
762
763 if got := lineText(s, 0); got != "11111" {
764 t.Fatalf("row outside region changed: %q", got)
765 }
766 if got := lineText(s, 1); got != "33333" {
767 t.Fatalf("region did not scroll up, row 1 = %q", got)
768 }
769 if got := lineText(s, 2); got != " " {
770 t.Fatalf("region bottom not blanked, row 2 = %q", got)
771 }
772 if got := lineText(s, 3); got != "44444" {
773 t.Fatalf("row below region changed: %q", got)
774 }
775 },
776 },
777 {
778 name: "DECOM homes and addresses relative to scroll region",
779 run: func(t *testing.T) {
780 s := NewScreen(6, 5)
781 s.Write([]byte("\x1b[2;4r\x1b[?6h"))
782 if s.Row != 1 || s.Col != 0 {
783 t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col)
784 }
785
786 s.Write([]byte("\x1b[2;3H"))
787 if s.Row != 2 || s.Col != 2 {
788 t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col)
789 }
790 s.Write([]byte("\x1b[99;1H"))
791 if s.Row != 3 || s.Col != 0 {
792 t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col)
793 }
794 },
795 },
796 {
797 name: "DECOM already set homes DECSTBM to scroll region",
798 run: func(t *testing.T) {
799 s := NewScreen(6, 5)
800 s.Write([]byte("\x1b[?6h\x1b[2;4r"))
801 if s.Row != 1 || s.Col != 0 {
802 t.Fatalf("DECSTBM cursor with DECOM set = (%d,%d), want (1,0)", s.Row, s.Col)
803 }
804
805 s.Write([]byte("\x1b[2;3H"))
806 if s.Row != 2 || s.Col != 2 {
807 t.Fatalf("DECOM addressed cursor after DECSTBM = (%d,%d), want (2,2)", s.Row, s.Col)
808 }
809 s.Write([]byte("\x1b[99;1H"))
810 if s.Row != 3 || s.Col != 0 {
811 t.Fatalf("DECOM clamped cursor after DECSTBM = (%d,%d), want (3,0)", s.Row, s.Col)
812 }
813 },
814 },
815 {
816 name: "DECOM reset homes and restores full-frame addressing",
817 run: func(t *testing.T) {
818 s := NewScreen(6, 5)
819 s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l"))
820 if s.Row != 0 || s.Col != 0 {
821 t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col)
822 }
823
824 s.Write([]byte("\x1b[5;6H"))
825 if s.Row != 4 || s.Col != 5 {
826 t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col)
827 }
828 },
829 },
830 {
831 name: "private alternate screen mode restores normal screen on exit",
832 run: func(t *testing.T) {
833 s := NewScreen(20, 5)
834 s.Write([]byte("normal-line"))
835 s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP"))
836 s.Write([]byte("\x1b[?1049lafter"))
837
838 if got := lineText(s, 0); got != "normal-lineafter " {
839 t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got)
840 }
841 if got := lineText(s, 2); got != " " {
842 t.Fatalf("alternate content leaked into row 2: %q", got)
843 }
844 },
845 },
846 }
847
848 for _, tt := range tests {
849 t.Run(tt.name, tt.run)
850 }
851}
852
853func TestCSIScrollUp(t *testing.T) {
854 tests := []struct {
855 name string
856 run func(t *testing.T)
857 }{
858 {
859 name: "CSI S with no param scrolls up by 1 and preserves cursor",
860 run: func(t *testing.T) {
861 s := NewScreen(5, 4)
862 s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
863 s.ClearDamage()
864
865 // CSI S with no param → scroll up by 1.
866 s.Write([]byte("\x1b[S"))
867
868 // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank.
869 assertCell(t, s, 0, 0, 'B')
870 assertCell(t, s, 0, 1, 'C')
871 assertCell(t, s, 0, 2, 'D')
872 assertCell(t, s, 0, 3, ' ')
873
874 // Cursor should NOT have moved.
875 if s.Col != 5 || s.Row != 3 {
876 t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
877 }
878
879 d := s.Damage()
880 if !hasDamageKind(d, renderer.DamageScrollUp) {
881 t.Errorf("expected DamageScrollUp, got %v", damageKinds(d))
882 }
883 },
884 },
885 {
886 name: "CSI 2S scrolls up by an explicit count",
887 run: func(t *testing.T) {
888 s := NewScreen(5, 4)
889 s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
890 s.ClearDamage()
891
892 // CSI 2 S → scroll up by 2.
893 s.Write([]byte("\x1b[2S"))
894
895 // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank.
896 assertCell(t, s, 0, 0, 'C')
897 assertCell(t, s, 0, 1, 'D')
898 assertCell(t, s, 0, 2, ' ')
899 assertCell(t, s, 0, 3, ' ')
900
901 // Cursor preserved.
902 if s.Col != 5 || s.Row != 3 {
903 t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
904 }
905 },
906 },
907 {
908 name: "CSI 100S on a 3-row screen clamps to the full screen",
909 run: func(t *testing.T) {
910 s := NewScreen(5, 3)
911 s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
912 s.ClearDamage()
913
914 // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank).
915 s.Write([]byte("\x1b[100S"))
916
917 // All rows should be blank.
918 for y := range 3 {
919 for x := range 5 {
920 assertCell(t, s, x, y, ' ')
921 }
922 }
923 },
924 },
925 {
926 name: "CSI 0S defaults to a scroll count of 1",
927 run: func(t *testing.T) {
928 s := NewScreen(5, 3)
929 s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
930 s.ClearDamage()
931
932 // CSI 0 S → default to 1 (parameter 0 means default in VT spec).
933 s.Write([]byte("\x1b[0S"))
934
935 // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank.
936 assertCell(t, s, 0, 0, 'B')
937 assertCell(t, s, 0, 1, 'C')
938 assertCell(t, s, 0, 2, ' ')
939 },
940 },
941 {
942 name: "CSI 3S reports scroll count 3 and blanked-row text damage",
943 run: func(t *testing.T) {
944 s := NewScreen(5, 4)
945 s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
946 s.ClearDamage()
947
948 s.Write([]byte("\x1b[3S")) // scroll up by 3
949
950 d := s.Damage()
951 var scrollDamage *renderer.Damage
952 for i, dd := range d {
953 if dd.Kind == renderer.DamageScrollUp {
954 scrollDamage = &d[i]
955 break
956 }
957 }
958 if scrollDamage == nil {
959 t.Fatal("expected DamageScrollUp")
960 }
961 if scrollDamage.Count != 3 {
962 t.Errorf("scroll count = %d, want 3", scrollDamage.Count)
963 }
964 if scrollDamage.Width != 5 || scrollDamage.Height != 4 {
965 t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height)
966 }
967
968 // Should also have text damage for the blanked rows (bottom 3 rows).
969 if !hasDamageKind(d, renderer.DamageText) {
970 t.Error("expected DamageText for blanked rows")
971 }
972 },
973 },
974 {
975 name: "cursor position is preserved across a scroll-up",
976 run: func(t *testing.T) {
977 s := NewScreen(10, 5)
978 s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows
979 s.ClearDamage()
980
981 // Move cursor to middle of row 2.
982 s.Write([]byte("\x1b[3;3H")) // row=3, col=3
983 s.Write([]byte("\x1b[2S")) // scroll up by 2
984
985 // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2).
986 if s.Row != 2 || s.Col != 2 {
987 t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col)
988 }
989 },
990 },
991 }
992
993 for _, tt := range tests {
994 t.Run(tt.name, tt.run)
995 }
996}