1package vt
2
3import (
4 "testing"
5
6 renderer "github.com/bnema/vev-vt/core"
7 "github.com/stretchr/testify/require"
8)
9
10func TestWritePrintableAndUTF8(t *testing.T) {
11 tests := []struct {
12 name string
13 run func(t *testing.T)
14 }{
15 {
16 name: "printable ASCII advances cursor and records damage",
17 run: func(t *testing.T) {
18 s := NewScreen(5, 3)
19 s.Write([]byte("Hi"))
20
21 assertCell(t, s, 0, 0, 'H')
22 assertCell(t, s, 1, 0, 'i')
23 if s.Col != 2 || s.Row != 0 {
24 t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
25 }
26
27 // Should have damage.
28 d := s.Damage()
29 if len(d) == 0 {
30 t.Fatal("expected damage after writing")
31 }
32 },
33 },
34 {
35 name: "UTF-8 multi-byte runes occupy one cell each",
36 run: func(t *testing.T) {
37 s := NewScreen(10, 3)
38 s.Write([]byte("aäéø©"))
39
40 assertCell(t, s, 0, 0, 'a')
41 assertCell(t, s, 1, 0, 'ä')
42 assertCell(t, s, 2, 0, 'é')
43 assertCell(t, s, 3, 0, 'ø')
44 assertCell(t, s, 4, 0, '©')
45 },
46 },
47 {
48 name: "printable beyond width wraps to next line",
49 run: func(t *testing.T) {
50 s := NewScreen(4, 2)
51 s.Write([]byte("ABCDE"))
52
53 assertCell(t, s, 0, 0, 'A')
54 assertCell(t, s, 1, 0, 'B')
55 assertCell(t, s, 2, 0, 'C')
56 assertCell(t, s, 3, 0, 'D')
57 // E wrapped to next line.
58 assertCell(t, s, 0, 1, 'E')
59 },
60 },
61 {
62 name: "printable beyond screen height scrolls",
63 run: func(t *testing.T) {
64 s := NewScreen(3, 2)
65 // Fill both lines.
66 s.Write([]byte("ABC"))
67 s.Write([]byte("DEF"))
68 // Row is now 1 (bottom). One more char should scroll.
69 s.Write([]byte("G"))
70
71 // After scroll: old row 0 = "DEF", new row 1 = "G ".
72 assertCell(t, s, 0, 0, 'D')
73 assertCell(t, s, 1, 0, 'E')
74 assertCell(t, s, 2, 0, 'F')
75 assertCell(t, s, 0, 1, 'G')
76 assertCell(t, s, 1, 1, ' ')
77
78 // Check scroll damage.
79 d := s.Damage()
80 if !hasDamageKind(d, renderer.DamageScrollUp) {
81 t.Error("expected scroll damage")
82 }
83 },
84 },
85 }
86
87 for _, tt := range tests {
88 t.Run(tt.name, tt.run)
89 }
90}
91
92func TestCursorControlChars(t *testing.T) {
93 tests := []struct {
94 name string
95 run func(t *testing.T)
96 }{
97 {
98 name: "CR moves column back to 0",
99 run: func(t *testing.T) {
100 s := NewScreen(10, 3)
101 s.Write([]byte("Hello"))
102 // CR moves column back to 0.
103 s.Write([]byte("\rWorld"))
104
105 assertCell(t, s, 0, 0, 'W')
106 assertCell(t, s, 1, 0, 'o')
107 assertCell(t, s, 2, 0, 'r')
108 assertCell(t, s, 3, 0, 'l')
109 assertCell(t, s, 4, 0, 'd')
110 if s.Col != 5 {
111 t.Errorf("col after CR + World = %d, want 5", s.Col)
112 }
113 },
114 },
115 {
116 name: "LF with CR moves to next line column 0",
117 run: func(t *testing.T) {
118 s := NewScreen(5, 3)
119 s.Write([]byte("A\r\nB\r\nC"))
120
121 assertCell(t, s, 0, 0, 'A')
122 assertCell(t, s, 0, 1, 'B')
123 assertCell(t, s, 0, 2, 'C')
124 if s.Row != 2 || s.Col != 1 {
125 t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col)
126 }
127 },
128 },
129 {
130 name: "LF alone does not reset column",
131 run: func(t *testing.T) {
132 s := NewScreen(5, 3)
133 s.Write([]byte("AB\nC"))
134
135 assertCell(t, s, 0, 0, 'A')
136 assertCell(t, s, 1, 0, 'B')
137 assertCell(t, s, 0, 1, ' ')
138 assertCell(t, s, 2, 1, 'C')
139 if s.Row != 1 || s.Col != 3 {
140 t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col)
141 }
142 },
143 },
144 {
145 name: "LF at pending wrap boundary advances exactly one line",
146 run: func(t *testing.T) {
147 s := NewScreen(5, 4)
148 s.Write([]byte("ABCDE\nZ"))
149
150 assertCell(t, s, 4, 0, 'E')
151 assertCell(t, s, 4, 1, 'Z')
152 if s.Row != 1 || s.Col != 5 {
153 t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col)
154 }
155 },
156 },
157 {
158 name: "LF scrolls when both lines are full",
159 run: func(t *testing.T) {
160 s := NewScreen(5, 2)
161 // Fill both lines then CRLF to scroll and return to column 0.
162 s.Write([]byte("AAAAA"))
163 s.Write([]byte("BBBBB"))
164 s.Write([]byte("\r\nCCCCC"))
165
166 assertCell(t, s, 0, 0, 'B')
167 assertCell(t, s, 4, 1, 'C')
168 },
169 },
170 {
171 name: "BS moves cursor back without touching the cell",
172 run: func(t *testing.T) {
173 s := NewScreen(10, 3)
174 s.Write([]byte("Hello\b"))
175
176 if s.Col != 4 {
177 t.Errorf("col after BS = %d, want 4", s.Col)
178 }
179 assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved
180 },
181 },
182 {
183 name: "BS at column zero is a no-op",
184 run: func(t *testing.T) {
185 s := NewScreen(10, 3)
186 s.Write([]byte("\b")) // BS at col 0 should be no-op
187
188 if s.Col != 0 {
189 t.Errorf("col = %d, want 0", s.Col)
190 }
191 },
192 },
193 {
194 name: "tab moves cursor without changing cells or damage",
195 run: func(t *testing.T) {
196 s := NewScreen(10, 2)
197 for y := range s.Frame.Height {
198 for x := range s.Frame.Width {
199 s.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.Frame.Width + x)})
200 }
201 }
202 before := s.Frame.Clone()
203 s.Row, s.Col = 1, 6
204 s.ClearDamage()
205
206 s.Write([]byte("\t"))
207 if s.Row != 1 || s.Col != 8 {
208 t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col)
209 }
210 assertFramesEqual(t, s.Frame, before)
211 if got := s.Damage(); len(got) != 0 {
212 t.Errorf("tab damage = %+v, want none", got)
213 }
214
215 // A tab whose next stop lies past the edge clamps at the last
216 // column; it must not wrap or scroll the bottom row.
217 s.Write([]byte("\t"))
218 if s.Row != 1 || s.Col != 9 {
219 t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col)
220 }
221 assertFramesEqual(t, s.Frame, before)
222 if got := s.Damage(); len(got) != 0 {
223 t.Errorf("clamped tab damage = %+v, want none", got)
224 }
225 },
226 },
227 }
228
229 for _, tt := range tests {
230 t.Run(tt.name, tt.run)
231 }
232}
233
234func TestInvalidInputIgnored(t *testing.T) {
235 tests := []struct {
236 name string
237 input []byte
238 }{
239 {
240 name: "invalid UTF-8 continuation byte without start byte",
241 input: []byte{0x80, 0x81},
242 },
243 {
244 name: "unhandled control characters below 0x20",
245 input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f},
246 },
247 }
248
249 for _, tt := range tests {
250 t.Run(tt.name, func(t *testing.T) {
251 s := NewScreen(10, 3)
252 s.Write(tt.input)
253 // Should not panic, and no cells should be modified.
254 for y := range 3 {
255 for x := range 10 {
256 if c := cellAt(s, x, y); c.Rune != ' ' {
257 t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
258 }
259 }
260 }
261 })
262 }
263}
264
265func TestWriteAtEdgeNoPanic(t *testing.T) {
266 tests := []struct {
267 name string
268 }{
269 {name: "writing past a 1x1 screen scrolls instead of panicking"},
270 }
271
272 for _, tt := range tests {
273 t.Run(tt.name, func(t *testing.T) {
274 s := NewScreen(1, 1)
275 // Fill the only cell then try to write more (should scroll).
276 s.Write([]byte("ABC"))
277 // Should not panic.
278 _ = cellAt(s, 0, 0)
279 })
280 }
281}
282
283func TestStylePersists(t *testing.T) {
284 tests := []struct {
285 name string
286 }{
287 {name: "bold persists across separate Write calls"},
288 }
289
290 for _, tt := range tests {
291 t.Run(tt.name, func(t *testing.T) {
292 s := NewScreen(10, 3)
293 s.Write([]byte("\x1b[1mAB"))
294 s.Write([]byte("CD"))
295
296 c1 := cellAt(s, 0, 0)
297 c2 := cellAt(s, 2, 0)
298 if !c1.Style.Bold || !c2.Style.Bold {
299 t.Error("bold should persist across Write calls")
300 }
301 })
302 }
303}
304
305func TestWideAndZeroWidthRunes(t *testing.T) {
306 tests := []struct {
307 name string
308 run func(t *testing.T)
309 }{
310 {
311 name: "CJK writes wide left cell plus continuation",
312 run: func(t *testing.T) {
313 s := NewScreen(10, 3)
314 // Two CJK characters, each width 2: left cell holds the rune,
315 // right cell is a continuation marker.
316 s.Write([]byte("你好"))
317
318 assertCell(t, s, 0, 0, '你')
319 assertContinuation(t, s, 1, 0)
320 assertCell(t, s, 2, 0, '好')
321 assertContinuation(t, s, 3, 0)
322 if s.Col != 4 || s.Row != 0 {
323 t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
324 }
325 },
326 },
327 {
328 name: "CJK mixed with ASCII",
329 run: func(t *testing.T) {
330 s := NewScreen(10, 3)
331 s.Write([]byte("A你B"))
332
333 assertCell(t, s, 0, 0, 'A')
334 assertCell(t, s, 1, 0, '你')
335 assertContinuation(t, s, 2, 0)
336 assertCell(t, s, 3, 0, 'B')
337 if s.Col != 4 {
338 t.Errorf("cursor at col=%d, want 4", s.Col)
339 }
340 },
341 },
342 {
343 name: "emoji writes wide pair",
344 run: func(t *testing.T) {
345 s := NewScreen(10, 3)
346 s.Write([]byte("\U0001f600")) // 😀 grinning face
347
348 assertCell(t, s, 0, 0, '\U0001f600')
349 assertContinuation(t, s, 1, 0)
350 if s.Col != 2 {
351 t.Errorf("cursor at col=%d, want 2", s.Col)
352 }
353 },
354 },
355 {
356 name: "combining mark is skipped without advancing cursor",
357 run: func(t *testing.T) {
358 s := NewScreen(10, 3)
359 // 'A' followed by combining acute accent U+0301
360 s.Write([]byte("A\xcc\x81"))
361
362 assertCell(t, s, 0, 0, 'A')
363 // Combining mark should be skipped — no cell written, no cursor advance.
364 if s.Col != 1 {
365 t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col)
366 }
367 },
368 },
369 {
370 name: "zero-width characters are skipped without advancing cursor",
371 run: func(t *testing.T) {
372 s := NewScreen(10, 3)
373 // Zero-width space U+200B
374 s.Write([]byte("A\xe2\x80\x8bB"))
375
376 assertCell(t, s, 0, 0, 'A')
377 assertCell(t, s, 1, 0, 'B')
378 if s.Col != 2 {
379 t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col)
380 }
381 },
382 },
383 {
384 name: "CJK surrounded by ASCII on both sides",
385 run: func(t *testing.T) {
386 s := NewScreen(10, 3)
387 s.Write([]byte("a你b好c"))
388
389 assertCell(t, s, 0, 0, 'a')
390 assertCell(t, s, 1, 0, '你')
391 assertContinuation(t, s, 2, 0)
392 assertCell(t, s, 3, 0, 'b')
393 assertCell(t, s, 4, 0, '好')
394 assertContinuation(t, s, 5, 0)
395 assertCell(t, s, 6, 0, 'c')
396 if s.Col != 7 {
397 t.Errorf("cursor at col=%d, want 7", s.Col)
398 }
399 },
400 },
401 {
402 name: "wide char at last column wraps to next line, abandoned cell cleared",
403 run: func(t *testing.T) {
404 s := NewScreen(3, 2)
405 // Fill the first two columns; cursor lands on the last column.
406 s.Write([]byte("AB")) // cells [A B _], Col=2
407 // A wide char at the last column cannot straddle the edge: it
408 // wraps to the next line, clearing the abandoned last cell.
409 s.Write([]byte("你"))
410
411 assertCell(t, s, 0, 0, 'A')
412 assertCell(t, s, 1, 0, 'B')
413 assertBlank(t, s, 2, 0) // abandoned last cell cleared
414 assertCell(t, s, 0, 1, '你')
415 assertContinuation(t, s, 1, 1)
416 if s.Col != 2 || s.Row != 1 {
417 t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
418 }
419 },
420 },
421 {
422 name: "CJK damage width is 2",
423 run: func(t *testing.T) {
424 s := NewScreen(10, 3)
425 s.ClearDamage()
426 s.Write([]byte("你"))
427
428 d := s.Damage()
429 if len(d) == 0 {
430 t.Fatal("expected damage")
431 }
432 if d[0].Width != 2 {
433 t.Errorf("damage width = %d, want 2", d[0].Width)
434 }
435 },
436 },
437 {
438 name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
439 run: func(t *testing.T) {
440 s := NewScreen(10, 3)
441 // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute.
442 s.Write([]byte("a你\U0001f600b\xcc\x81"))
443
444 assertCell(t, s, 0, 0, 'a')
445 assertCell(t, s, 1, 0, '你')
446 assertContinuation(t, s, 2, 0)
447 assertCell(t, s, 3, 0, '\U0001f600')
448 assertContinuation(t, s, 4, 0)
449 assertCell(t, s, 5, 0, 'b')
450 // Combining mark skipped, no cell at col 6.
451 if s.Col != 6 || s.Row != 0 {
452 t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
453 }
454 },
455 },
456 {
457 name: "overwrite left half of wide pair with narrow clears both",
458 run: func(t *testing.T) {
459 s := NewScreen(10, 3)
460 s.Write([]byte("你")) // (0)=你 (1)=cont
461 s.Write([]byte("\x1b[1;1H")) // cursor home
462 s.Write([]byte("X")) // overwrite the wide left half
463
464 assertCell(t, s, 0, 0, 'X')
465 assertBlank(t, s, 1, 0) // orphaned continuation cleared
466 },
467 },
468 {
469 name: "overwrite right half (continuation) with narrow clears both",
470 run: func(t *testing.T) {
471 s := NewScreen(10, 3)
472 s.Write([]byte("你")) // (0)=你 (1)=cont
473 s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation)
474 s.Write([]byte("X")) // overwrite the continuation
475
476 assertBlank(t, s, 0, 0) // orphaned wide left cleared
477 assertCell(t, s, 1, 0, 'X')
478 },
479 },
480 {
481 name: "overwrite half of wide pair with a new wide char",
482 run: func(t *testing.T) {
483 s := NewScreen(10, 3)
484 s.Write([]byte("你好")) // (0)你 (1)cont (2)好 (3)cont
485 s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你)
486 s.Write([]byte("学")) // wide write at cols 1,2
487
488 assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared
489 assertCell(t, s, 1, 0, '学')
490 assertContinuation(t, s, 2, 0)
491 assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared
492 },
493 },
494 {
495 name: "erase to end of line covering a continuation clears its wide left half",
496 run: func(t *testing.T) {
497 s := NewScreen(10, 3)
498 s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
499 s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation)
500 s.Write([]byte("\x1b[K")) // erase from col 2 to end of line
501
502 assertCell(t, s, 0, 0, 'A')
503 assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared
504 assertBlank(t, s, 2, 0)
505 assertBlank(t, s, 3, 0)
506 },
507 },
508 {
509 name: "erase to start of line covering a wide left clears its continuation",
510 run: func(t *testing.T) {
511 s := NewScreen(10, 3)
512 s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
513 s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left)
514 s.Write([]byte("\x1b[1K")) // erase from start to col 1 inclusive
515
516 assertBlank(t, s, 0, 0)
517 assertBlank(t, s, 1, 0)
518 assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared
519 assertCell(t, s, 3, 0, 'B')
520 },
521 },
522 {
523 name: "wrap abandoning a continuation cell clears its pair and extends damage",
524 run: func(t *testing.T) {
525 s := NewScreen(4, 2)
526 s.Write([]byte("AB你")) // A(0) B(1) 你(2) cont(3)
527 s.Write([]byte("\x1b[1;4H")) // cursor to col 3: the continuation cell
528 s.ClearDamage()
529 // A wide rune at the last column wraps; the abandoned last cell
530 // is a continuation, so its wide left half (col 2) must be
531 // cleared too and the damage must span both columns.
532 s.Write([]byte("好"))
533
534 assertCell(t, s, 0, 0, 'A')
535 assertCell(t, s, 1, 0, 'B')
536 assertBlank(t, s, 2, 0) // orphaned wide left cleared
537 assertBlank(t, s, 3, 0) // abandoned continuation cleared
538 assertCell(t, s, 0, 1, '好')
539 assertContinuation(t, s, 1, 1)
540 if s.Col != 2 || s.Row != 1 {
541 t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
542 }
543
544 d := s.Damage()
545 if len(d) == 0 {
546 t.Fatal("expected damage")
547 }
548 // First damage item covers the cleared pair on row 0.
549 if d[0].X != 2 || d[0].Y != 0 || d[0].Width != 2 {
550 t.Errorf("wrap damage = {X:%d Y:%d W:%d}, want {X:2 Y:0 W:2}", d[0].X, d[0].Y, d[0].Width)
551 }
552 },
553 },
554 {
555 name: "insert chars at a continuation splits the pair and repairs orphans",
556 run: func(t *testing.T) {
557 s := NewScreen(6, 2)
558 s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
559 s.Write([]byte("\x1b[1;4H")) // cursor to col 3: continuation of 好
560 s.ClearDamage()
561 s.Write([]byte("\x1b[1@")) // ICH 1: shift right from col 3
562
563 // The shift splits 好/cont: 好 stays at col 2, its continuation
564 // moves to col 4. Both orphans must be repaired to blanks.
565 assertCell(t, s, 0, 0, '你')
566 assertContinuation(t, s, 1, 0)
567 assertBlank(t, s, 2, 0) // 好 orphaned by the split → cleared
568 assertBlank(t, s, 3, 0) // inserted blank
569 assertBlank(t, s, 4, 0) // shifted continuation orphaned → cleared
570 assertBlank(t, s, 5, 0)
571
572 d := s.Damage()
573 if len(d) == 0 {
574 t.Fatal("expected damage")
575 }
576 // Damage must extend one column left to cover the repaired 好.
577 if d[0].X != 2 || d[0].Width != 4 {
578 t.Errorf("ICH damage = {X:%d W:%d}, want {X:2 W:4}", d[0].X, d[0].Width)
579 }
580 },
581 },
582 {
583 name: "insert chars shifting a wide pair off the right edge repairs the left half",
584 run: func(t *testing.T) {
585 s := NewScreen(4, 2)
586 s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
587 s.Write([]byte("\x1b[1;1H")) // cursor home
588 s.Write([]byte("\x1b[1@")) // ICH 1: shift the row right by 1
589
590 // 好 lands on the last column with its continuation pushed off
591 // the edge; the orphaned wide left must be blanked.
592 assertBlank(t, s, 0, 0)
593 assertCell(t, s, 1, 0, '你')
594 assertContinuation(t, s, 2, 0)
595 assertBlank(t, s, 3, 0) // 好 lost its continuation → cleared
596 },
597 },
598 {
599 name: "delete char at the left half of a wide pair repairs the orphan",
600 run: func(t *testing.T) {
601 s := NewScreen(6, 2)
602 s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
603 s.Write([]byte("\x1b[1;1H")) // cursor to col 0: wide left of 你
604 s.ClearDamage()
605 s.Write([]byte("\x1b[1P")) // DCH 1
606
607 // 你's continuation shifts to col 0 with no left half → repaired.
608 assertBlank(t, s, 0, 0)
609 assertCell(t, s, 1, 0, '好')
610 assertContinuation(t, s, 2, 0)
611 assertCell(t, s, 3, 0, 'A')
612 assertCell(t, s, 4, 0, 'B')
613 assertBlank(t, s, 5, 0)
614
615 d := s.Damage()
616 if len(d) == 0 {
617 t.Fatal("expected damage")
618 }
619 if d[0].X != 0 || d[0].Width != 6 {
620 t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
621 }
622 },
623 },
624 {
625 name: "delete char at a continuation repairs the wide left and extends damage",
626 run: func(t *testing.T) {
627 s := NewScreen(6, 2)
628 s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
629 s.Write([]byte("\x1b[1;2H")) // cursor to col 1: continuation of 你
630 s.ClearDamage()
631 s.Write([]byte("\x1b[1P")) // DCH 1: delete the continuation
632
633 // 你 at col 0 loses its continuation → repaired to blank.
634 assertBlank(t, s, 0, 0)
635 assertCell(t, s, 1, 0, '好')
636 assertContinuation(t, s, 2, 0)
637 assertCell(t, s, 3, 0, 'A')
638 assertCell(t, s, 4, 0, 'B')
639 assertBlank(t, s, 5, 0)
640
641 d := s.Damage()
642 if len(d) == 0 {
643 t.Fatal("expected damage")
644 }
645 // Damage must extend one column left (to col 0) to cover the
646 // repaired wide left half.
647 if d[0].X != 0 || d[0].Width != 6 {
648 t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
649 }
650 },
651 },
652 {
653 name: "IRM inserts narrow printable cells and reset restores overwrite",
654 run: func(t *testing.T) {
655 s := NewScreen(8, 1)
656 s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
657
658 if got := lineText(s, 0); got != "abXcd " {
659 t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
660 }
661 s.Write([]byte("\x1b[4l\x1b[1;3HY"))
662 if got := lineText(s, 0); got != "abYcd " {
663 t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
664 }
665 },
666 },
667 {
668 name: "IRM inserts wide printable cells and repairs evicted wide head",
669 run: func(t *testing.T) {
670 s := NewScreen(6, 1)
671 s.Write([]byte("ab界cd"))
672 s.Write([]byte("\x1b[1;3H\x1b[4h好"))
673
674 assertCell(t, s, 0, 0, 'a')
675 assertCell(t, s, 1, 0, 'b')
676 assertCell(t, s, 2, 0, '好')
677 assertContinuation(t, s, 3, 0)
678 assertCell(t, s, 4, 0, '界')
679 assertContinuation(t, s, 5, 0)
680 assertNoOrphanWideCells(t, s)
681 },
682 },
683 {
684 name: "IRM wide insert at right edge repairs orphan",
685 run: func(t *testing.T) {
686 s := NewScreen(5, 1)
687 s.Write([]byte("abc界"))
688 s.Write([]byte("\x1b[1;2H\x1b[4h好"))
689
690 assertCell(t, s, 0, 0, 'a')
691 assertCell(t, s, 1, 0, '好')
692 assertContinuation(t, s, 2, 0)
693 assertCell(t, s, 3, 0, 'b')
694 assertCell(t, s, 4, 0, 'c')
695 assertNoOrphanWideCells(t, s)
696 },
697 },
698 {
699 name: "wide char on a width-1 screen becomes a narrow replacement rune",
700 run: func(t *testing.T) {
701 s := NewScreen(1, 2)
702 // A wide rune cannot fit on a 1-column screen. Store a narrow
703 // replacement instead so renderer width matches the cell layout.
704 s.Write([]byte("你"))
705
706 assertCell(t, s, 0, 0, '\uFFFD')
707 if got := renderer.RuneWidth(cellAt(s, 0, 0).Rune); got != 1 {
708 t.Errorf("stored rune width = %d, want 1", got)
709 }
710 if cellAt(s, 0, 0).Continuation {
711 t.Error("width-1 replacement must not be a continuation")
712 }
713 assertNoOrphanWideCells(t, s)
714 if s.Col != 1 {
715 t.Errorf("cursor at col=%d, want 1", s.Col)
716 }
717 },
718 },
719 {
720 name: "wide pair survives a scroll",
721 run: func(t *testing.T) {
722 s := NewScreen(4, 2)
723 s.Write([]byte("你\r\n好")) // row0: 你, row1: 好
724 s.Write([]byte("\r\n")) // bottom row → scroll up
725
726 // After scrolling up, row1's 好 moves to row0 intact.
727 assertCell(t, s, 0, 0, '好')
728 assertContinuation(t, s, 1, 0)
729 },
730 },
731 {
732 name: "combining mark alone writes no cell and produces no damage",
733 run: func(t *testing.T) {
734 s := NewScreen(10, 3)
735 s.ClearDamage()
736 s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char)
737
738 // No cells should be modified.
739 for y := range 3 {
740 for x := range 10 {
741 if c := cellAt(s, x, y); c.Rune != ' ' {
742 t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
743 }
744 }
745 }
746 if s.Col != 0 || s.Row != 0 {
747 t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
748 }
749 d := s.Damage()
750 if len(d) != 0 {
751 t.Errorf("expected no damage for combining mark alone, got %+v", d)
752 }
753 },
754 },
755 }
756
757 for _, tt := range tests {
758 t.Run(tt.name, tt.run)
759 }
760}
761
762func TestRuneWidth(t *testing.T) {
763 tests := []struct {
764 name string
765 r rune
766 width int
767 }{
768 {"space", ' ', 1},
769 {"lowercase ascii letter", 'a', 1},
770 {"uppercase ascii letter", 'A', 1},
771 {"ascii digit", '1', 1},
772 {"soft hyphen", 0x00AD, 0},
773 {"combining grapheme joiner", 0x034F, 0},
774 {"combining acute accent", 0x0301, 0},
775 {"combining grave accent", 0x0300, 0},
776 {"zero-width space", 0x200B, 0},
777 {"zero-width non-joiner", 0x200C, 0},
778 {"zero-width joiner", 0x200D, 0},
779 {"BOM / zero-width no-break space", 0xFEFF, 0},
780 {"CJK Unified Ideograph (一)", 0x4E00, 2},
781 {"CJK (二)", 0x4E8C, 2},
782 {"CJK end of BMP", 0x9FFF, 2},
783 {"CJK Extension A", 0x3400, 2},
784 {"Hangul Syllable (가)", 0xAC00, 2},
785 {"Hangul Syllable end", 0xD7AF, 2},
786 {"CJK Ideographic Space", 0x3000, 2},
787 {"Fullwidth Exclamation Mark", 0xFF01, 2},
788 {"Fullwidth Cent Sign", 0xFFE0, 2},
789 {"grinning face emoji", 0x1F600, 2},
790 {"rocket emoji", 0x1F680, 2},
791 {"hiragana a", 0x3042, 2},
792 {"katakana a", 0x30A2, 2},
793 {"CJK Extension B", 0x20000, 2},
794 {"supplemental symbols (robot)", 0x1F916, 2},
795 {"symbols extended-A (sari)", 0x1FA71, 2},
796 {"ideographic full stop", 0x3002, 2},
797 {"NUL", '\x00', 0},
798 {"SOH", '\x01', 0},
799 {"ESC", '\x1b', 0},
800 {"DEL", 0x7F, 0},
801 }
802
803 for _, tt := range tests {
804 t.Run(tt.name, func(t *testing.T) {
805 got := renderer.RuneWidth(tt.r)
806 if got != tt.width {
807 t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
808 }
809 })
810 }
811}
812
813func TestC1Controls(t *testing.T) {
814 tests := []struct {
815 name string
816 run func(t *testing.T)
817 }{
818 {
819 name: "C1 controls are dropped without moving the cursor",
820 run: func(t *testing.T) {
821 s := NewScreen(10, 3)
822 s.Write([]byte{0xC2, 0x80}) // U+0080 PAD
823 s.Write([]byte{0xC2, 0x9F}) // U+009F APC
824
825 // No cells should be modified.
826 for y := range 3 {
827 for x := range 10 {
828 if c := cellAt(s, x, y); c.Rune != ' ' {
829 t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune)
830 }
831 }
832 }
833 // Cursor should not have moved.
834 if s.Col != 0 || s.Row != 0 {
835 t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
836 }
837 },
838 },
839 {
840 name: "the full C1 control range U+0080-U+009F is dropped",
841 run: func(t *testing.T) {
842 s := NewScreen(10, 3)
843 // Send all C1 control runes U+0080 through U+009F.
844 for r := 0x80; r <= 0x9F; r++ {
845 s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)})
846 }
847
848 // No cells should be modified.
849 for y := range 3 {
850 for x := range 10 {
851 if c := cellAt(s, x, y); c.Rune != ' ' {
852 t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune)
853 }
854 }
855 }
856 if s.Col != 0 || s.Row != 0 {
857 t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
858 }
859 },
860 },
861 {
862 name: "printable characters still work after C1 controls",
863 run: func(t *testing.T) {
864 s := NewScreen(10, 3)
865 // Printable characters should still work after C1 controls.
866 s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
867 s.Write([]byte("AB"))
868 s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
869 s.Write([]byte("CD"))
870
871 assertCell(t, s, 0, 0, 'A')
872 assertCell(t, s, 1, 0, 'B')
873 assertCell(t, s, 2, 0, 'C')
874 assertCell(t, s, 3, 0, 'D')
875 if s.Col != 4 {
876 t.Errorf("cursor at col=%d, want 4", s.Col)
877 }
878 },
879 },
880 {
881 name: "C1 controls mixed with CR preserve CR behavior",
882 run: func(t *testing.T) {
883 s := NewScreen(10, 3)
884 // C1 controls mixed with CR/LF should preserve CR/LF behavior.
885 s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
886 s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A
887 s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
888
889 assertCell(t, s, 0, 0, 'B')
890 },
891 },
892 {
893 name: "C1 controls mixed with LF preserve LF behavior",
894 run: func(t *testing.T) {
895 s := NewScreen(10, 3)
896 s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
897 s.Write([]byte("A\nB"))
898
899 assertCell(t, s, 0, 0, 'A')
900 assertCell(t, s, 1, 1, 'B')
901 if s.Row != 1 || s.Col != 2 {
902 t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col)
903 }
904 },
905 },
906 {
907 name: "C1 controls produce no damage",
908 run: func(t *testing.T) {
909 s := NewScreen(10, 3)
910 s.ClearDamage()
911 // C1 controls should produce no damage.
912 s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F})
913
914 d := s.Damage()
915 if len(d) != 0 {
916 t.Errorf("expected no damage from C1 controls, got %+v", d)
917 }
918 },
919 },
920 }
921
922 for _, tt := range tests {
923 t.Run(tt.name, tt.run)
924 }
925}
926
927func TestScreenLineBoundsDescribesTheLiveGrid(t *testing.T) {
928 tests := []struct {
929 name string
930 width int
931 height int
932 write string
933 want []LineBound
934 // checkOwned exercises the caller-owned-copy guarantee: it only makes
935 // sense to check once per LineBounds() call, so it lives on the case
936 // whose write actually produces a soft-wrapped row 0 to corrupt.
937 checkOwned bool
938 }{
939 {
940 name: "wraps after column width",
941 width: 4,
942 height: 3,
943 write: "abcdef", // wraps after column 4
944 want: []LineBound{
945 {End: 4, Soft: true}, // row 0: filled the row and wrapped
946 {End: 2, Soft: false}, // row 1: "ef", no wrap
947 {End: 0, Soft: false}, // row 2: untouched
948 },
949 checkOwned: true,
950 },
951 }
952
953 for _, tt := range tests {
954 t.Run(tt.name, func(t *testing.T) {
955 s := NewScreen(tt.width, tt.height)
956 s.Write([]byte(tt.write))
957
958 bounds := s.LineBounds()
959 require.Equal(t, tt.want, bounds)
960
961 if tt.checkOwned {
962 // The result is owned by the caller.
963 bounds[0] = LineBound{}
964 require.True(t, s.LineBounds()[0].Soft, "mutating the result changed the screen's own boundaries")
965 }
966 })
967 }
968}