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