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
239// padRow blank-fills a terminal row before adding text.
240func padRow(s string, w int) []renderer.Cell {
241 cells := make([]renderer.Cell, w)
242 for i := range cells {
243 cells[i] = renderer.BlankCell()
244 }
245 for i, r := range []rune(s) {
246 if i >= w {
247 break
248 }
249 cells[i] = renderer.Cell{Rune: r}
250 }
251 return cells
252}
253
254func TestDocumentContentEnd(t *testing.T) {
255 tests := []struct {
256 name string
257 cells []renderer.Cell
258 bounds []vt.LineBound
259 want int
260 }{
261 {"hard short", padRow("ab", 8), []vt.LineBound{{End: 2}}, 2},
262 {"inner spaces", padRow("a b", 8), []vt.LineBound{{End: 3}}, 3},
263 {"blank", padRow("", 8), []vt.LineBound{{}}, 0},
264 {"soft trailing spaces", padRow("abc ", 8), []vt.LineBound{{End: 8, Soft: true}}, 8},
265 {"soft partial bound", padRow("abc", 8), []vt.LineBound{{End: 5, Soft: true}}, 5},
266 {"wide glyph", []renderer.Cell{{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell(), renderer.BlankCell(), renderer.BlankCell()}, []vt.LineBound{{End: 3}}, 3},
267 {"nil bounds", padRow("ab", 8), nil, 2},
268 }
269 for _, tt := range tests {
270 t.Run(tt.name, func(t *testing.T) {
271 doc := NewDocument(NewSnapshotFromLines([][]renderer.Cell{tt.cells}, tt.bounds, 8, 1), "")
272 require.Equal(t, tt.want, doc.contentEnd(0))
273 })
274 }
275}
276
277func TestDocumentExtractTrimsPaddingAndJoinsWrappedRows(t *testing.T) {
278
279 tests := []struct {
280 name string
281 rows [][]renderer.Cell
282 bounds []vt.LineBound
283 ranges []CellRange
284 want string
285 }{
286 {
287 name: "hard row drops its padding",
288 rows: [][]renderer.Cell{padRow("ab", 8)},
289 bounds: []vt.LineBound{{End: 2}},
290 ranges: []CellRange{{Row: 0, Start: 0, End: 7}},
291 want: "ab",
292 },
293 {
294 name: "soft row joins without a newline",
295 rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
296 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
297 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
298 want: "abcdefghij",
299 },
300 {
301 name: "soft row keeps a real trailing space",
302 rows: [][]renderer.Cell{padRow("abc ", 8), padRow("de", 8)},
303 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
304 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
305 want: "abc de",
306 },
307 {
308 name: "three consecutive soft rows",
309 rows: [][]renderer.Cell{padRow("aaaa", 4), padRow("bbbb", 4), padRow("cccc", 4), padRow("dd", 4)},
310 bounds: []vt.LineBound{
311 {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 4, Soft: true}, {End: 2},
312 },
313 ranges: []CellRange{
314 {Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3},
315 {Row: 2, Start: 0, End: 3}, {Row: 3, Start: 0, End: 3},
316 },
317 want: "aaaabbbbccccdd",
318 },
319 {
320 name: "hard rows keep their newline",
321 rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd", 8)},
322 bounds: []vt.LineBound{{End: 2}, {End: 2}},
323 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
324 want: "ab\ncd",
325 },
326 {
327 name: "sparse ranges do not join across a row gap",
328 rows: [][]renderer.Cell{padRow("ab", 8), padRow("ignored", 8), padRow("cd", 8)},
329 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 7}, {End: 2}},
330 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
331 want: "ab\ncd",
332 },
333 {
334 name: "invalid trailing range does not prevent final emitted row trimming",
335 rows: [][]renderer.Cell{padRow("ab", 8)},
336 bounds: []vt.LineBound{{End: 8, Soft: true}},
337 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 99, Start: 0, End: 7}},
338 want: "ab",
339 },
340 {
341 name: "blank row survives as an empty line",
342 rows: [][]renderer.Cell{padRow("ab", 8), padRow("", 8), padRow("cd", 8)},
343 bounds: []vt.LineBound{{End: 2}, {End: 0}, {End: 2}},
344 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}, {Row: 2, Start: 0, End: 7}},
345 want: "ab\n\ncd",
346 },
347 {
348 name: "the last row is trimmed even when soft",
349 rows: [][]renderer.Cell{padRow("ab", 8), padRow("cd ", 8)},
350 bounds: []vt.LineBound{{End: 2}, {End: 8, Soft: true}},
351 ranges: []CellRange{{Row: 0, Start: 0, End: 7}, {Row: 1, Start: 0, End: 7}},
352 want: "ab\ncd",
353 },
354 {
355 name: "wide glyph pushed off a soft row's edge",
356 // "a界" occupies columns 0..2; the wide rune did not fit at column 3,
357 // so End stops at 3 and column 3 is abandoned padding.
358 rows: [][]renderer.Cell{
359 {{Rune: 'a'}, {Rune: '界'}, {Continuation: true}, renderer.BlankCell()},
360 padRow("b", 4),
361 },
362 bounds: []vt.LineBound{{End: 3, Soft: true}, {End: 1}},
363 ranges: []CellRange{{Row: 0, Start: 0, End: 3}, {Row: 1, Start: 0, End: 3}},
364 want: "a界b",
365 },
366 {
367 name: "selection stopping mid soft row",
368 rows: [][]renderer.Cell{padRow("abcdefgh", 8), padRow("ij", 8)},
369 bounds: []vt.LineBound{{End: 8, Soft: true}, {End: 2}},
370 ranges: []CellRange{{Row: 0, Start: 2, End: 4}},
371 want: "cde",
372 },
373 }
374
375 for _, tc := range tests {
376 t.Run(tc.name, func(t *testing.T) {
377 doc := NewDocument(NewSnapshotFromLines(tc.rows, tc.bounds, 8, 0), "")
378 if got := doc.Extract(tc.ranges); got != tc.want {
379 t.Errorf("Extract() = %q, want %q", got, tc.want)
380 }
381 })
382 }
383}
384
385var benchmarkDocumentWordPos Pos
386
387func BenchmarkDocumentWordMovementLocal(b *testing.B) {
388 lines := make([]string, 32_768)
389 lines[0] = "alpha/beta"
390 for i := 1; i < len(lines); i++ {
391 lines[i] = "unrelated"
392 }
393 doc := documentFromStrings(lines, "/")
394 moves := []func(Pos) (Pos, bool){
395 doc.NextWordStart,
396 doc.PreviousWordStart,
397 doc.NextWordEnd,
398 }
399
400 b.ReportAllocs()
401 b.ResetTimer()
402 for i := 0; i < b.N; i++ {
403 for _, move := range moves {
404 pos, ok := move(Pos{Row: 0, Col: 2})
405 if !ok {
406 b.Fatal("local word movement failed")
407 }
408 benchmarkDocumentWordPos = pos
409 }
410 }
411}
412
413func TestDocumentAccessorsExposeImmutableSnapshot(t *testing.T) {
414 rows := [][]renderer.Cell{documentCells("one")}
415 doc := NewDocument(NewSnapshotFromRows(rows, 9, 2), "_")
416 rows[0][0].Rune = 'x'
417
418 require.Equal(t, 1, doc.Len())
419 require.Equal(t, 9, doc.Width())
420 require.Equal(t, 2, doc.Height())
421 require.Equal(t, "one", doc.LineText(0))
422 require.Equal(t, doc.Snapshot().Row(0), doc.Row(0))
423 require.Nil(t, doc.Row(1))
424}