1package copy
2
3import (
4 "testing"
5
6 vt "github.com/bnema/vev-vt"
7 renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/stretchr/testify/require"
9)
10
11func documentCells(text string) []renderer.Cell {
12 cells := make([]renderer.Cell, 0, len([]rune(text)))
13 for _, r := range text {
14 cells = append(cells, renderer.Cell{Rune: r})
15 }
16 return cells
17}
18
19func documentFromStrings(lines []string, separators string) *Document {
20 rows := make([][]renderer.Cell, len(lines))
21 for i, line := range lines {
22 rows[i] = documentCells(line)
23 }
24 return NewDocument(NewSnapshotFromRows(rows, 32, 4), separators)
25}
26
27func TestDocumentNormalize(t *testing.T) {
28 wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
29 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}}, 5, 1), " -_@")
30
31 tests := []struct {
32 name string
33 in Pos
34 want Pos
35 ok bool
36 }{
37 {"glyph head", Pos{0, 1}, Pos{0, 1}, true},
38 {"wide continuation", Pos{0, 2}, Pos{0, 1}, true},
39 {"empty row stable position", Pos{1, 0}, Pos{1, 0}, true},
40 {"negative row", Pos{-1, 0}, Pos{}, false},
41 {"past row", Pos{2, 0}, Pos{}, false},
42 {"negative column", Pos{0, -1}, Pos{}, false},
43 {"past column", Pos{0, 5}, Pos{}, false},
44 {"empty row past column", Pos{1, 1}, Pos{}, false},
45 }
46 for _, tt := range tests {
47 t.Run(tt.name, func(t *testing.T) {
48 got, ok := doc.Normalize(tt.in)
49 require.Equal(t, tt.ok, ok)
50 if ok {
51 require.Equal(t, tt.want, got)
52 }
53 })
54 }
55}
56
57func TestDocumentGlyphNavigation(t *testing.T) {
58 wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
59 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide}, 5, 1), " -_@")
60
61 tests := []struct {
62 name string
63 fn func(Pos) (Pos, bool)
64 in Pos
65 want Pos
66 ok bool
67 }{
68 {"next narrow to wide", doc.NextGlyph, Pos{0, 0}, Pos{0, 1}, true},
69 {"next skips continuation", doc.NextGlyph, Pos{0, 1}, Pos{0, 3}, true},
70 {"next at end", doc.NextGlyph, Pos{0, 4}, Pos{}, false},
71 {"previous skips continuation", doc.PrevGlyph, Pos{0, 3}, Pos{0, 1}, true},
72 {"previous continuation normalizes", doc.PrevGlyph, Pos{0, 2}, Pos{0, 0}, true},
73 {"previous at start", doc.PrevGlyph, Pos{0, 0}, Pos{}, false},
74 }
75 for _, tt := range tests {
76 t.Run(tt.name, func(t *testing.T) {
77 got, ok := tt.fn(tt.in)
78 require.Equal(t, tt.ok, ok)
79 if ok {
80 require.Equal(t, tt.want, got)
81 }
82 })
83 }
84}
85
86func TestDocumentWordBounds(t *testing.T) {
87 doc := documentFromStrings([]string{"alpha/beta gamma", "été\u2003東京", ""}, "/")
88
89 tests := []struct {
90 name string
91 in Pos
92 start Pos
93 end Pos
94 ok bool
95 }{
96 {"configured separator", Pos{0, 5}, Pos{}, Pos{}, false},
97 {"second word", Pos{0, 8}, Pos{0, 6}, Pos{0, 9}, true},
98 {"unicode whitespace", Pos{1, 3}, Pos{}, Pos{}, false},
99 {"unicode word", Pos{1, 5}, Pos{1, 4}, Pos{1, 5}, true},
100 {"empty row", Pos{2, 0}, Pos{}, Pos{}, false},
101 {"out of range", Pos{4, 0}, Pos{}, Pos{}, false},
102 }
103 for _, tt := range tests {
104 t.Run(tt.name, func(t *testing.T) {
105 start, end, ok := doc.WordBounds(tt.in)
106 require.Equal(t, tt.ok, ok)
107 if ok {
108 require.Equal(t, tt.start, start)
109 require.Equal(t, tt.end, end)
110 }
111 })
112 }
113}
114
115func TestDocumentWordMovement(t *testing.T) {
116 doc := documentFromStrings([]string{"alpha/beta", " gamma", "delta"}, "/")
117
118 tests := []struct {
119 name string
120 fn func(Pos) (Pos, bool)
121 in Pos
122 want Pos
123 }{
124 {"next skips current word and separator", doc.NextWordStart, Pos{0, 2}, Pos{0, 6}},
125 {"next crosses rows", doc.NextWordStart, Pos{0, 7}, Pos{1, 1}},
126 {"previous returns current word start", doc.PreviousWordStart, Pos{1, 3}, Pos{1, 1}},
127 {"previous crosses rows", doc.PreviousWordStart, Pos{1, 1}, Pos{0, 6}},
128 {"next end returns current word end", doc.NextWordEnd, Pos{0, 2}, Pos{0, 4}},
129 {"next end skips separators", doc.NextWordEnd, Pos{0, 5}, Pos{0, 9}},
130 }
131 for _, tt := range tests {
132 t.Run(tt.name, func(t *testing.T) {
133 got, ok := tt.fn(tt.in)
134 require.True(t, ok)
135 require.Equal(t, tt.want, got)
136 })
137 }
138}
139
140func TestDocumentNextWordEndAdvancesPastCompletedWord(t *testing.T) {
141 doc := documentFromStrings([]string{"alpha beta"}, "/")
142
143 got, ok := doc.NextWordEnd(Pos{Row: 0, Col: 4})
144
145 require.True(t, ok)
146 require.Equal(t, Pos{Row: 0, Col: 9}, got)
147}
148
149func TestDocumentNextWordEndCrossesEmptyRow(t *testing.T) {
150 doc := documentFromStrings([]string{"alpha", "", "beta"}, "/")
151
152 got, ok := doc.NextWordEnd(Pos{Row: 0, Col: 4})
153
154 require.True(t, ok)
155 require.Equal(t, Pos{Row: 2, Col: 3}, got)
156}
157
158func TestDocumentWordMovementPreservesWideSeparatorsEmptyRowsAndEndProgression(t *testing.T) {
159 wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: '/'}, {Rune: 'b'}}
160 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{wide, {}, documentCells(" cd")}, 5, 3), "/")
161
162 tests := []struct {
163 name string
164 move func(Pos) (Pos, bool)
165 in Pos
166 want Pos
167 }{
168 {"next start skips wide word and separator", doc.NextWordStart, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}},
169 {"previous start crosses separator", doc.PreviousWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 0, Col: 0}},
170 {"end advances from wide word", doc.NextWordEnd, Pos{Row: 0, Col: 1}, Pos{Row: 0, Col: 4}},
171 {"next start crosses empty row", doc.NextWordStart, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 1}},
172 {"previous start crosses empty row", doc.PreviousWordStart, Pos{Row: 2, Col: 1}, Pos{Row: 0, Col: 4}},
173 {"end crosses empty row", doc.NextWordEnd, Pos{Row: 0, Col: 4}, Pos{Row: 2, Col: 2}},
174 }
175 for _, tt := range tests {
176 t.Run(tt.name, func(t *testing.T) {
177 got, ok := tt.move(tt.in)
178 require.True(t, ok)
179 require.Equal(t, tt.want, got)
180 })
181 }
182}
183
184func TestDocumentWordMovementDoesNotAllocateWholeDocument(t *testing.T) {
185 lines := make([]string, 8_192)
186 lines[0] = "alpha/beta"
187 for i := 1; i < len(lines); i++ {
188 lines[i] = "unrelated"
189 }
190 doc := documentFromStrings(lines, "/")
191
192 tests := []struct {
193 name string
194 move func(Pos) (Pos, bool)
195 in Pos
196 want Pos
197 }{
198 {"next start", doc.NextWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 6}},
199 {"previous start", doc.PreviousWordStart, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 0}},
200 {"next end", doc.NextWordEnd, Pos{Row: 0, Col: 2}, Pos{Row: 0, Col: 4}},
201 }
202 for _, tt := range tests {
203 t.Run(tt.name, func(t *testing.T) {
204 allocs := testing.AllocsPerRun(100, func() {
205 got, ok := tt.move(tt.in)
206 if !ok || got != tt.want {
207 t.Fatalf("move() = (%v, %t), want (%v, true)", got, ok, tt.want)
208 }
209 })
210 require.Zero(t, allocs, "local word movement must not materialize the document")
211 })
212 }
213}
214
215func TestDocumentExtractRanges(t *testing.T) {
216 wide := []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, {Rune: ' '}, {Rune: 'b'}}
217 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{documentCells("alpha "), documentCells("beta"), wide, {}}, 7, 4), "")
218
219 tests := []struct {
220 name string
221 ranges []CellRange
222 want string
223 }{
224 {"inclusive partial multiline drops each row's padding", []CellRange{{Row: 0, Start: 2, End: 6}, {Row: 1, Start: 0, End: 1}}, "pha\nbe"},
225 {"whole row drops trailing spaces", []CellRange{{Row: 0, Start: 0, End: 6}}, "alpha"},
226 {"wide glyph emitted once", []CellRange{{Row: 2, Start: 1, End: 2}}, "界"},
227 {"continuation endpoint normalizes to head", []CellRange{{Row: 2, Start: 2, End: 2}}, "界"},
228 {"empty row", []CellRange{{Row: 3, Start: 0, End: 0}}, ""},
229 {"out of range ignored", []CellRange{{Row: 8, Start: 0, End: 1}}, ""},
230 {"no ranges", nil, ""},
231 }
232 for _, tt := range tests {
233 t.Run(tt.name, func(t *testing.T) {
234 require.Equal(t, tt.want, doc.Extract(tt.ranges))
235 })
236 }
237}
238
239func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
240 // pad renders s into a width-w row, blank-filled like a real terminal grid.
241 pad := func(s string, w int) []renderer.Cell {
242 cells := make([]renderer.Cell, w)
243 for i := range cells {
244 cells[i] = renderer.BlankCell()
245 }
246 for i, r := range []rune(s) {
247 if i >= w {
248 break
249 }
250 cells[i] = renderer.Cell{Rune: r}
251 }
252 return cells
253 }
254
255 tests := []struct {
256 name string
257 rows [][]renderer.Cell
258 bounds []vt.LineBound
259 ranges []CellRange
260 want string
261 }{
262 {
263 name: "hard row drops its padding",
264 rows: [][]renderer.Cell{pad("ab", 8)},
265 bounds: []vt.LineBound{{End: 2}},
266 ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
267 want: "ab",
268 },
269 {
270 name: "soft row joins without a newline",
271 rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
272 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
273 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
274 want: "abcdefghij",
275 },
276 {
277 name: "soft row keeps a real trailing space",
278 rows: [][]renderer.Cell{pad("abc ", 8), pad("de", 8)},
279 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
280 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
281 want: "abc de",
282 },
283 {
284 name: "three consecutive soft rows",
285 rows: [][]renderer.Cell{pad("aaaa", 4), pad("bbbb", 4), pad("cccc", 4), pad("dd", 4)},
286 bounds: []vt.LineBound{
287 {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
288 },
289 ranges: []CellRange{
290 {Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3},
291 {Row: 2, Start: 0, End: 3}, {Row: 3, Start: 0, End: 3},
292 },
293 want: "aaaabbbbccccdd",
294 },
295 {
296 name: "hard rows keep their newline",
297 rows: [][]renderer.Cell{pad("ab", 8), pad("cd", 8)},
298 bounds: []vt.LineBound{{End: 2}, {End: 2}},
299 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
300 want: "ab\ncd",
301 },
302 {
303 name: "sparse ranges do not join across a row gap",
304 rows: [][]renderer.Cell{pad("ab", 8), pad("ignored", 8), pad("cd", 8)},
305 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
306 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
307 want: "ab\ncd",
308 },
309 {
310 name: "invalid trailing range does not prevent final emitted row trimming",
311 rows: [][]renderer.Cell{pad("ab", 8)},
312 bounds: []vt.LineBound{{End: 8, Soft: true}},
313 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
314 want: "ab",
315 },
316 {
317 name: "blank row survives as an empty line",
318 rows: [][]renderer.Cell{pad("ab", 8), pad("", 8), pad("cd", 8)},
319 bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
320 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
321 want: "ab\n\ncd",
322 },
323 {
324 name: "the last row is trimmed even when soft",
325 rows: [][]renderer.Cell{pad("ab", 8), pad("cd ", 8)},
326 bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
327 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
328 want: "ab\ncd",
329 },
330 {
331 name: "wide glyph pushed off a soft row's edge",
332 // "a界" occupies columns 0..2; the wide rune did not fit at column 3,
333 // so End stops at 3 and column 3 is abandoned padding.
334 rows: [][]renderer.Cell{
335 {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
336 pad("b", 4),
337 },
338 bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
339 ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
340 want: "a界b",
341 },
342 {
343 name: "selection stopping mid soft row",
344 rows: [][]renderer.Cell{pad("abcdefgh", 8), pad("ij", 8)},
345 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
346 ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
347 want: "cde",
348 },
349 }
350
351 for _, tc := range tests {
352 t.Run(tc.name, func(t *testing.T) {
353 doc := NewDocument(NewSnapshotFromLines(tc.rows, tc.bounds, 8, 0), "")
354 if got := doc.Extract(tc.ranges); got != tc.want {
355 t.Errorf("Extract() = %q, want %q", got, tc.want)
356 }
357 })
358 }
359}
360
361var benchmarkDocumentWordPos Pos
362
363func BenchmarkDocumentWordMovementLocal(b *testing.B) {
364 lines := make([]string, 32_768)
365 lines[0] = "alpha/beta"
366 for i := 1; i < len(lines); i++ {
367 lines[i] = "unrelated"
368 }
369 doc := documentFromStrings(lines, "/")
370 moves := []func(Pos) (Pos, bool){
371 doc.NextWordStart,
372 doc.PreviousWordStart,
373 doc.NextWordEnd,
374 }
375
376 b.ReportAllocs()
377 b.ResetTimer()
378 for i := 0; i < b.N; i++ {
379 for _, move := range moves {
380 pos, ok := move(Pos{Row: 0, Col: 2})
381 if !ok {
382 b.Fatal("local word movement failed")
383 }
384 benchmarkDocumentWordPos = pos
385 }
386 }
387}
388
389func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) {
390 rows := [][]renderer.Cell{documentCells("one")}
391 doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_")
392 rows[0][0].Rune = 'x'
393
394 require.Equal(t, 1, doc.Len())
395 require.Equal(t, 9, doc.Width())
396 require.Equal(t, 2, doc.Height())
397 require.Equal(t, "one", doc.LineText(0))
398 require.Equal(t, doc.Snapshot().Row(0), doc.Row(0))
399 require.Nil(t, doc.Row(1))
400}