1package vt
2
3import (
4 "math/rand"
5 "strings"
6 "testing"
7
8 renderer "github.com/bnema/vev-vt/core"
9)
10
11func TestResize(t *testing.T) {
12 setRow := func(s *Screen, y int, text string) {
13 for x, r := range []rune(text) {
14 if x >= s.Frame.Width {
15 break
16 }
17 s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
18 }
19 }
20 rowString := func(s *Screen, y int) string {
21 runes := make([]rune, s.Frame.Width)
22 for x := range s.Frame.Width {
23 runes[x] = s.Frame.At(x, y).Rune
24 }
25 return string(runes)
26 }
27
28 tests := []struct {
29 name string
30 run func(t *testing.T)
31 }{
32 {
33 name: "same size preserves content and skips FullRedraw",
34 run: func(t *testing.T) {
35 s := NewScreen(5, 3)
36 s.ClearDamage()
37 setRow(s, 0, "Hello")
38
39 s.Resize(5, 3)
40
41 if got := rowString(s, 0); got != "Hello" {
42 t.Fatalf("row 0 = %q, want Hello", got)
43 }
44 if d := s.Damage(); len(d) != 0 {
45 t.Fatalf("same-size resize damage = %v, want none", damageKinds(d))
46 }
47 },
48 },
49 {
50 name: "grow preserves visible content and cursor",
51 run: func(t *testing.T) {
52 s := NewScreen(5, 2)
53 setRow(s, 0, "Hello")
54 setRow(s, 1, "World")
55 s.Row, s.Col = 1, 4
56
57 s.Resize(8, 4)
58
59 if got := rowString(s, 0); got != "Hello " {
60 t.Fatalf("row 0 = %q", got)
61 }
62 if got := rowString(s, 1); got != "World " {
63 t.Fatalf("row 1 = %q", got)
64 }
65 if s.Row != 1 || s.Col != 4 {
66 t.Fatalf("cursor = (%d,%d), want (1,4)", s.Row, s.Col)
67 }
68 if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
69 t.Fatalf("resize damage = %v, want FullRedraw", damageKinds(s.Damage()))
70 }
71 },
72 },
73 {
74 name: "height shrink evicts top lines and follows cursor",
75 run: func(t *testing.T) {
76 s := NewScreen(4, 5)
77 for y, text := range []string{"0000", "1111", "2222", "3333", "4444"} {
78 setRow(s, y, text)
79 }
80 s.Row, s.Col = 4, 2
81 var evicted []string
82 s.OnLineEvicted = func(row []renderer.Cell) {
83 runes := make([]rune, len(row))
84 for i, c := range row {
85 runes[i] = c.Rune
86 }
87 evicted = append(evicted, string(runes))
88 }
89
90 s.Resize(4, 3)
91
92 if got, want := evicted, []string{"0000", "1111"}; strings.Join(got, ",") != strings.Join(want, ",") {
93 t.Fatalf("evicted = %v, want %v", got, want)
94 }
95 if got := rowString(s, 0); got != "2222" {
96 t.Fatalf("row 0 = %q", got)
97 }
98 if s.Row != 2 || s.Col != 2 {
99 t.Fatalf("cursor = (%d,%d), want (2,2)", s.Row, s.Col)
100 }
101 if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
102 t.Fatalf("shrink damage = %v, want FullRedraw", damageKinds(s.Damage()))
103 }
104 },
105 },
106 {
107 name: "soft wrapped lines reflow on shrink and growth",
108 run: func(t *testing.T) {
109 s := NewScreen(5, 3)
110 s.Write([]byte("abcdefgh"))
111
112 s.Resize(3, 3)
113 if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.Join(got, ",") != "abc,def,gh " {
114 t.Fatalf("shrink rows = %q", got)
115 }
116
117 s.Resize(5, 3)
118 if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != "abcde,fgh " {
119 t.Fatalf("growth rows = %q", got)
120 }
121 },
122 },
123 {
124 name: "erase severs a soft wrap boundary",
125 run: func(t *testing.T) {
126 s := NewScreen(4, 2)
127 s.Write([]byte("abcdef"))
128 s.Write([]byte("\x1b[1;1H\x1b[2K"))
129
130 // Erasing through the right edge ends the logical line: the
131 // blank head must not pull "ef" up a row on reflow.
132 s.Resize(3, 2)
133 if got := []string{rowString(s, 0), rowString(s, 1)}; strings.Join(got, ",") != " ,ef " {
134 t.Fatalf("erased soft line reflow = %q", got)
135 }
136 },
137 },
138 {
139 name: "in-place repaint does not merge with the row below",
140 run: func(t *testing.T) {
141 // Status UIs (docker buildx, shell prompt redraws) repaint a
142 // previously soft-wrapped row with CUP + text + EL and no
143 // newline. The stale soft link must not glue the repainted row
144 // to the unrelated row below: reflow would merge them, and the
145 // next shrink would truncate the merged line, losing cells.
146 s := NewScreen(40, 8)
147 s.Write([]byte(strings.Repeat("A", 40) + "BBBB\r\n"))
148 s.Write([]byte("tail-line\r\n"))
149 s.Write([]byte("\x1b[1;1HSHORT\x1b[K"))
150 s.Write([]byte("\x1b[4;1H"))
151
152 s.Resize(60, 8)
153 if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" {
154 t.Fatalf("grow rows = %q", got)
155 }
156
157 s.Resize(40, 8)
158 if got := []string{rowString(s, 0), rowString(s, 1), rowString(s, 2)}; strings.TrimRight(got[0], " ") != "SHORT" || strings.TrimRight(got[1], " ") != "BBBB" || strings.TrimRight(got[2], " ") != "tail-line" {
159 t.Fatalf("round-trip rows = %q", got)
160 }
161 },
162 },
163 {
164 name: "wide edge padding does not become logical content",
165 run: func(t *testing.T) {
166 s := NewScreen(4, 3)
167 s.Write([]byte("abc界x"))
168
169 s.Resize(3, 3)
170 if got := rowString(s, 0); got != "abc" {
171 t.Fatalf("first reflow row = %q", got)
172 }
173 assertCell(t, s, 0, 1, '界')
174 if !cellAt(s, 1, 1).Continuation {
175 t.Fatal("wide continuation was not retained")
176 }
177 assertCell(t, s, 2, 1, 'x')
178 assertNoOrphanWideCells(t, s)
179 },
180 },
181 {
182 name: "width truncates and pads without reflow",
183 run: func(t *testing.T) {
184 s := NewScreen(6, 2)
185 setRow(s, 0, "abcdef")
186 setRow(s, 1, "ghijkl")
187
188 s.Resize(3, 2)
189 if got := rowString(s, 0); got != "abc" {
190 t.Fatalf("truncated row = %q", got)
191 }
192 s.Resize(5, 2)
193 if got := rowString(s, 1); got != "ghi " {
194 t.Fatalf("padded row = %q", got)
195 }
196 },
197 },
198 {
199 name: "width shrink repairs truncated wide head",
200 run: func(t *testing.T) {
201 s := NewScreen(4, 1)
202 s.Write([]byte("AB界"))
203
204 s.Resize(3, 1)
205
206 assertCell(t, s, 0, 0, 'A')
207 assertCell(t, s, 1, 0, 'B')
208 assertBlank(t, s, 2, 0)
209 assertNoOrphanWideCells(t, s)
210 },
211 },
212 {
213 name: "random wide resize repairs copied rows",
214 run: func(t *testing.T) {
215 rng := rand.New(rand.NewSource(1))
216 for range 200 {
217 s := NewScreen(8, 4)
218 s.Write([]byte("a界b好c語d"))
219 s.Row = rng.Intn(s.Frame.Height)
220
221 s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
222
223 assertNoOrphanWideCells(t, s)
224 }
225 },
226 },
227 {
228 name: "cursor and saved cursor clamp",
229 run: func(t *testing.T) {
230 s := NewScreen(6, 4)
231 s.Row, s.Col = 3, 5
232 s.saveCursor()
233
234 s.Resize(3, 2)
235
236 if s.Row != 1 || s.Col != 2 {
237 t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col)
238 }
239 s.Row, s.Col = 0, 0
240 s.restoreCursor()
241 if s.Row != 1 || s.Col != 2 {
242 t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col)
243 }
244 },
245 },
246 {
247 name: "alternate resize keeps saved normal content",
248 run: func(t *testing.T) {
249 s := NewScreen(5, 3)
250 setRow(s, 0, "shell")
251 s.Row = 0
252 s.Write([]byte("\x1b[?1049h"))
253 setRow(s, 0, "alt!!")
254
255 s.Resize(7, 4)
256 s.Write([]byte("\x1b[?1049l"))
257
258 if got := rowString(s, 0); got != "shell " {
259 t.Fatalf("normal row after alt resize = %q", got)
260 }
261 },
262 },
263 {
264 name: "alternate resize shifts active and saved cursors",
265 run: func(t *testing.T) {
266 s := NewScreen(5, 5)
267 s.Row, s.Col = 3, 4
268 s.saveCursor()
269 s.scrollTop, s.scrollBottom = 1, 4
270 s.Row = 4
271 s.Write([]byte("\x1b[?1049h"))
272 s.Row, s.Col = 3, 4
273 s.saveCursor()
274 s.Row = 4
275
276 // The expected rows account for the resize shift that keeps the
277 // cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor,
278 // not just width/height clamping.
279 s.Resize(3, 3)
280
281 if s.Row != 2 || s.Col != 2 {
282 t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col)
283 }
284 s.Row, s.Col = 0, 0
285 s.restoreCursor()
286 if s.Row != 1 || s.Col != 2 {
287 t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
288 }
289
290 s.Write([]byte("\x1b[?1049l"))
291 if s.scrollTop != 0 || s.scrollBottom != 2 {
292 t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom)
293 }
294 s.Row, s.Col = 0, 0
295 s.restoreCursor()
296 if s.Row != 1 || s.Col != 2 {
297 t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
298 }
299 },
300 },
301 {
302 name: "style mouse cursor and bracketed paste modes survive",
303 run: func(t *testing.T) {
304 s := NewScreen(5, 2)
305 s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
306
307 s.Resize(6, 3)
308
309 if !s.Style.Bold {
310 t.Fatal("bold style was reset")
311 }
312 if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
313 t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
314 }
315 if !s.BracketedPasteMode() {
316 t.Fatal("bracketed paste mode was reset")
317 }
318 if s.CursorVisible() {
319 t.Fatal("cursor visibility was reset")
320 }
321 if style, ok := s.CursorStyle(); style != 3 || !ok {
322 t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
323 }
324 s.Write([]byte("\x1b[?2004l"))
325 if s.BracketedPasteMode() {
326 t.Fatal("bracketed paste mode remained enabled after reset")
327 }
328 },
329 },
330 }
331
332 for _, tt := range tests {
333 t.Run(tt.name, tt.run)
334 }
335}