1package copy
2
3import (
4 "bytes"
5 "encoding/base64"
6 "strings"
7 "testing"
8
9 vt "github.com/bnema/vev-vt"
10 renderer "github.com/bnema/vev-vt/ansi"
11 "github.com/bnema/vev/internal/domain"
12 "github.com/stretchr/testify/require"
13)
14
15func row(text string) []renderer.Cell {
16 cells := make([]renderer.Cell, 0, len([]rune(text)))
17 for _, r := range text {
18 cells = append(cells, renderer.Cell{Rune: r})
19 }
20 return cells
21}
22func modeFor(lines []string, height int) *Mode {
23 rows := make([][]renderer.Cell, len(lines))
24 for i := range lines {
25 rows[i] = row(lines[i])
26 }
27 return NewMode(NewDocument(NewSnapshotFromRows(rows, 16, height), domain.DefaultWordSeparators))
28}
29
30func TestNewSnapshotFromRowsPreservesWideRows(t *testing.T) {
31 wide := make([]renderer.Cell, 321)
32 for i := range wide {
33 wide[i] = renderer.Cell{Rune: rune('a' + i%26)}
34 }
35 rows := [][]renderer.Cell{wide, row("tail")}
36
37 snapshot := NewSnapshotFromRows(rows, len(wide), len(rows))
38
39 require.Equal(t, len(rows), snapshot.history.Len())
40 for i, want := range rows {
41 require.Equal(t, want, snapshot.history.Row(i))
42 }
43}
44
45func TestSnapshotCarriesImmutableRowIDsAndDocumentsResolveBookmarks(t *testing.T) {
46 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
47 require.NoError(t, history.AppendWithID(row("hist"), vt.LineBound{End: 4}, vt.RowID(11)))
48 require.NoError(t, history.AppendWithID(row("tail"), vt.LineBound{End: 4}, vt.RowID(12)))
49 screen := renderer.NewFrame(4, 2)
50 screen.WriteRow(0, 0, row("live"))
51 screen.WriteRow(1, 0, row("more"))
52 rowIDs := []vt.RowID{21, 22}
53
54 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 4}}, rowIDs)
55 rowIDs[0] = 99
56 doc := NewDocument(snapshot, "")
57
58 require.Equal(t, []vt.RowID{11, 12, 21, 22}, snapshot.RowIDs())
59 ids := snapshot.RowIDs()
60 ids[0] = 100
61 require.Equal(t, vt.RowID(11), snapshot.RowID(0))
62 require.Equal(t, vt.RowID(21), doc.RowID(2))
63 require.Equal(t, 2, doc.FindRowID(vt.RowID(21)))
64 require.Equal(t, 3, doc.FindRowID(vt.RowID(22)))
65 require.Equal(t, -1, doc.FindRowID(vt.RowID(99)))
66}
67
68func TestSnapshotBoundDispatchesLikeRow(t *testing.T) {
69 row := func(s string) []renderer.Cell {
70 cells := make([]renderer.Cell, 0, len(s))
71 for _, r := range s {
72 cells = append(cells, renderer.Cell{Rune: r})
73 }
74 return cells
75 }
76
77 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
78 if err := history.Append(row("hist"), vt.LineBound{End: 4, Soft: true}); err != nil {
79 t.Fatalf("append: %v", err)
80 }
81 screen := renderer.NewFrame(4, 2)
82 screen.WriteRow(0, 0, row("live"))
83
84 snap := NewSnapshot(history, screen, []vt.LineBound{{End: 4}, {End: 0, Soft: true}}, nil)
85
86 tests := []struct {
87 name string
88 i int
89 want vt.LineBound
90 }{
91 {name: "history row", i: 0, want: vt.LineBound{End: 4, Soft: true}},
92 {name: "first screen row", i: 1, want: vt.LineBound{End: 4}},
93 {name: "second screen row", i: 2, want: vt.LineBound{End: 0, Soft: true}},
94 {name: "past the end", i: 3, want: vt.LineBound{}},
95 {name: "negative", i: -1, want: vt.LineBound{}},
96 }
97 for _, tc := range tests {
98 t.Run(tc.name, func(t *testing.T) {
99 if got := snap.Bound(tc.i); got != tc.want {
100 t.Errorf("Bound(%d) = %+v, want %+v", tc.i, got, tc.want)
101 }
102 })
103 }
104}
105
106func TestCopyModeKeyboardSelection(t *testing.T) {
107 m := modeFor([]string{"alpha", "xy", "bravo", "charlie"}, 2)
108 require.Equal(t, Pos{Row: 3}, m.Cursor())
109 m.Top()
110 m.ToggleLineSelection()
111 m.Down()
112 require.Equal(t, Line, m.Selection().Granularity)
113 require.Equal(t, "alpha\nxy", m.SelectedText())
114 m.Right()
115 require.Equal(t, Character, m.Selection().Granularity)
116 require.Equal(t, Pos{Row: 0}, m.Selection().Anchor)
117 require.Equal(t, Pos{Row: 1, Col: 1}, m.Selection().Active)
118 require.True(t, m.Left())
119 require.Equal(t, Pos{Row: 1}, m.Cursor())
120 require.True(t, m.Right())
121 require.True(t, m.Up())
122 require.Equal(t, Pos{Row: 0, Col: 1}, m.Cursor())
123 require.True(t, m.Down())
124 require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
125 require.True(t, m.WordNext())
126 require.Equal(t, Pos{Row: 2}, m.Cursor())
127 require.True(t, m.WordEnd())
128 require.Equal(t, Pos{Row: 2, Col: 4}, m.Cursor())
129 require.True(t, m.WordBackward())
130 require.Equal(t, Pos{Row: 2}, m.Cursor())
131}
132
133func TestCopyModePreferredColumnAndPartialSpaces(t *testing.T) {
134 m := modeFor([]string{"abcdef", "xy", "abcdef"}, 2)
135 require.True(t, m.SetPosition(Pos{Row: 0, Col: 5}))
136 m.Down()
137 require.Equal(t, Pos{Row: 1, Col: 1}, m.Cursor())
138 m.Down()
139 require.Equal(t, Pos{Row: 2, Col: 5}, m.Cursor())
140 m.SetPosition(Pos{Row: 0, Col: 1})
141 require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 1}))
142 require.True(t, m.ExtendCharacterSelection(Pos{Row: 0, Col: 4}))
143 require.Equal(t, "bcde", m.SelectedText())
144}
145
146func TestCopyModeRenderSelectionInvertsSearchAndWideGlyph(t *testing.T) {
147 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}}
148 m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 3, 1), ""))
149 require.True(t, m.SetSearchMatches("界", []SearchMatch{{Row: 0, Start: 0, End: 2}}, 0))
150 require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 0}))
151 f := m.Render(renderer.DefaultStyle(), renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}})
152 require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
153 require.True(t, f.At(1, 0).Style.HasBackgroundRGB)
154 require.True(t, f.At(0, 0).Style.Inverse)
155 require.True(t, f.At(1, 0).Style.Inverse)
156}
157
158func TestCopyModeSearchNavigationExtendsActiveSelection(t *testing.T) {
159 matches := []SearchMatch{{Row: 1, Start: 1, End: 2}, {Row: 2, Start: 2, End: 3}}
160 m := modeFor([]string{"zero", "one", "two"}, 3)
161
162 m.SetPosition(Pos{Row: 0})
163 m.ToggleLineSelection()
164 require.True(t, m.SetSearchMatches("match", matches, 0))
165 require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 1, Col: 1}, Granularity: Line, Enabled: true}, m.Selection())
166
167 require.True(t, m.Right())
168 require.Equal(t, Character, m.Selection().Granularity)
169 require.True(t, m.NextSearchMatch(1))
170 require.Equal(t, Selection{Anchor: Pos{Row: 0}, Active: Pos{Row: 2, Col: 2}, Granularity: Character, Enabled: true}, m.Selection())
171}
172
173func TestCopyModeSearchExtendsSelectionWhenMatchIsAlreadyAtCursor(t *testing.T) {
174 m := modeFor([]string{"zero", "one"}, 2)
175 require.True(t, m.StartCharacterSelection(Pos{Row: 0}))
176 require.True(t, m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}))
177 require.True(t, m.SetPosition(Pos{Row: 0}))
178
179 require.True(t, m.SetSearchMatches("zero", []SearchMatch{{Row: 0, Start: 0, End: 4}}, 0))
180 require.Equal(t, Pos{Row: 0}, m.Selection().Active)
181}
182
183func TestCopyModeSearchNilMode(t *testing.T) {
184 var mode *Mode
185 require.False(t, mode.Search("anything"))
186}
187
188func TestCopyModeRenderBoundsCursorToRenderedRow(t *testing.T) {
189 m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("alpha")}, 2, 1), ""))
190 require.True(t, m.SetPosition(Pos{Row: 0, Col: 4}), "cursor remains valid in the document despite a narrow render frame")
191
192 selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
193 require.NotPanics(t, func() { _ = m.Render(renderer.DefaultStyle(), selection) })
194 frame := m.Render(renderer.DefaultStyle(), selection)
195 require.True(t, frame.At(1, 0).Style.HasBackgroundRGB, "a valid off-frame cursor is rendered at the visible edge")
196}
197
198func TestCopyModeRenderKeepsPassiveCursorOutsidePartialSameRowSelection(t *testing.T) {
199 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'b'}}
200 m := NewMode(NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 4, 1), ""))
201 require.True(t, m.StartCharacterSelection(Pos{Row: 0, Col: 2}))
202 require.True(t, m.SetPosition(Pos{Row: 0, Col: 1})) // Normalize the wide-glyph continuation to its head.
203
204 selection := renderer.Style{HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 1}}
205 f := m.Render(renderer.DefaultStyle(), selection)
206 require.True(t, f.At(0, 0).Style.HasBackgroundRGB)
207 require.True(t, f.At(2, 0).Style.Inverse)
208 require.False(t, f.At(2, 0).Style.HasBackgroundRGB)
209}
210
211func TestFindMatchesUsesExclusiveDisplayCellOffsets(t *testing.T) {
212 cells := []renderer.Cell{{Rune: '界'}, {Continuation: true}, {Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}
213 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{cells}, 7, 1), "")
214 require.Equal(t, []SearchMatch{{Row: 0, Start: 2, End: 7, Text: "界alpha"}}, FindMatches(doc, "alpha"))
215}
216
217func TestFindMatchesRepeatedUnicodeDisplayCells(t *testing.T) {
218 cells := []renderer.Cell{
219 {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
220 {Rune: 'ä'}, {Rune: '界'}, {Continuation: true},
221 {Rune: 'Ä'}, {Rune: '界'}, {Continuation: true},
222 }
223 doc := NewDocument(NewSnapshotFromRows([][]renderer.Cell{row("aaaa"), cells}, 9, 2), "")
224
225 for _, tt := range []struct {
226 name, query string
227 want []SearchMatch
228 }{
229 {
230 name: "repeated non-overlapping",
231 query: "aa",
232 want: []SearchMatch{
233 {Row: 0, Start: 0, End: 2, Text: "aaaa"},
234 {Row: 0, Start: 2, End: 4, Text: "aaaa"},
235 },
236 },
237 {
238 name: "unicode display cells",
239 query: "Ä界",
240 want: []SearchMatch{
241 {Row: 1, Start: 0, End: 3, Text: "Ä界ä界Ä界"},
242 {Row: 1, Start: 3, End: 6, Text: "Ä界ä界Ä界"},
243 {Row: 1, Start: 6, End: 9, Text: "Ä界ä界Ä界"},
244 },
245 },
246 } {
247 t.Run(tt.name, func(t *testing.T) {
248 require.Equal(t, tt.want, FindMatches(doc, tt.query))
249 })
250 }
251}
252
253func TestFindMatchesUsesSealedScrollbackWithoutGlobalCopy(t *testing.T) {
254 const rows = 10_000
255 history := vt.NewHistory(vt.HistoryConfig{MaxRows: rows + 1, ChunkRows: 256})
256 unmatched, target := row("unmatched"), row("target")
257 for range rows {
258 require.NoError(t, history.Append(unmatched, vt.LineBound{End: len(unmatched)}))
259 }
260 require.NoError(t, history.Append(target, vt.LineBound{End: len(target)}))
261 snapshot := NewSnapshot(history, renderer.NewFrame(16, 1), nil, nil)
262 view := history.SealAndView()
263 require.Same(t, view.Chunk(0), snapshot.history.Chunk(0))
264
265 doc := NewDocument(snapshot, "")
266 require.Equal(t, []SearchMatch{{Row: rows, Start: 0, End: 6, Text: "target"}}, FindMatches(doc, "target"))
267}
268
269func TestCopyModeRenderSelectionAllocationsBoundedToViewport(t *testing.T) {
270 const (
271 rows = 10_000
272 height = 4
273 maxAllocs = 20
274 )
275 lines := make([][]renderer.Cell, rows)
276 for i := range lines {
277 lines[i] = row("selection")
278 }
279 m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
280 require.True(t, m.StartCharacterSelection(Pos{}))
281 require.True(t, m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}))
282
283 allocs := testing.AllocsPerRun(10, func() {
284 _ = m.Render()
285 })
286 require.LessOrEqual(t, allocs, float64(maxAllocs), "rendering a %d-line selection must only allocate for its %d-line viewport", rows, height)
287}
288
289func BenchmarkCopyModeRenderLargeSelection(b *testing.B) {
290 const (
291 rows = 10_000
292 height = 4
293 )
294 lines := make([][]renderer.Cell, rows)
295 for i := range lines {
296 lines[i] = row("selection")
297 }
298 m := NewMode(NewDocument(NewSnapshotFromRows(lines, 16, height), ""))
299 if !m.StartCharacterSelection(Pos{}) || !m.ExtendCharacterSelection(Pos{Row: rows - 1, Col: 8}) {
300 b.Fatal("could not create large selection")
301 }
302
303 b.ReportAllocs()
304 b.ResetTimer()
305 for range b.N {
306 _ = m.Render()
307 }
308}
309
310func TestCopyModeOSC52SelectionMatrix(t *testing.T) {
311 cases := []struct {
312 name string
313 selectFn func(*Mode)
314 want string
315 }{
316 {"line", func(m *Mode) { m.SetPosition(Pos{}); m.ToggleLineSelection(); m.Down() }, "alpha\nbravo"},
317 {"horizontal", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 1}); m.ExtendCharacterSelection(Pos{Col: 3}) }, "lph"},
318 {"multiline", func(m *Mode) { m.StartCharacterSelection(Pos{Col: 2}); m.ExtendCharacterSelection(Pos{Row: 1, Col: 1}) }, "pha\nbr"},
319 {"reverse", func(m *Mode) { m.StartCharacterSelection(Pos{Row: 1, Col: 1}); m.ExtendCharacterSelection(Pos{Col: 2}) }, "pha\nbr"},
320 {"word", func(m *Mode) { m.SelectWordAt(Pos{Row: 1, Col: 2}) }, "bravo"},
321 }
322 for _, tc := range cases {
323 t.Run(tc.name, func(t *testing.T) {
324 m := modeFor([]string{"alpha", "bravo"}, 2)
325 tc.selectFn(m)
326 got := OSC52(m.SelectedText())
327 require.Len(t, got, 1)
328 want := []byte("\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(tc.want)) + "\x07")
329 require.True(t, bytes.Equal(want, got[0]))
330 })
331 }
332}
333
334func TestOSC52LimitsAndBase64(t *testing.T) {
335 require.Nil(t, OSC52(strings.Repeat("x", OSC52MaxPayloadBytes+1)))
336 encoded := base64.StdEncoding.EncodeToString([]byte("hello"))
337 require.Equal(t, []byte("\x1b]52;c;"+encoded+"\x07"), OSC52FromBase64(encoded))
338}
339
340func TestCopyModeRenderStylesStatusFiller(t *testing.T) {
341 m := modeFor([]string{"alpha"}, 5)
342 status := renderer.Style{Foreground: 1, Background: 2}
343 selection := renderer.Style{Foreground: 3, Background: 4}
344 frame := m.Render(status, selection)
345
346 require.True(t, frame.At(4, frame.Height-1).Style.Equal(status), "status filler keeps cached status surface")
347}
348
349func TestSelectedTextJoinsAcrossTheHistoryScreenBoundary(t *testing.T) {
350 row := func(s string, w int) []renderer.Cell {
351 cells := make([]renderer.Cell, w)
352 for i := range cells {
353 cells[i] = renderer.BlankCell()
354 }
355 for i, r := range []rune(s) {
356 if i >= w {
357 break
358 }
359 cells[i] = renderer.Cell{Rune: r}
360 }
361 return cells
362 }
363
364 const width = 4
365 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8})
366 // The last history row wrapped into what is now the first live screen row.
367 if err := history.Append(row("abcd", width), vt.LineBound{End: width, Soft: true}); err != nil {
368 t.Fatalf("append: %v", err)
369 }
370 screen := renderer.NewFrame(width, 2)
371 screen.WriteRow(0, 0, row("ef", width))
372 screen.WriteRow(1, 0, row("gh", width))
373
374 snapshot := NewSnapshot(history, screen, []vt.LineBound{{End: 2}, {End: 2}}, nil)
375 doc := NewDocument(snapshot, "")
376
377 ranges := []CellRange{
378 {Row: 0, Start: 0, End: width - 1},
379 {Row: 1, Start: 0, End: width - 1},
380 {Row: 2, Start: 0, End: width - 1},
381 }
382 const want = "abcdef\ngh"
383 if got := doc.Extract(ranges); got != want {
384 t.Errorf("Extract() = %q, want %q", got, want)
385 }
386}
387
388// TestSelectedTextJoinsRowsWrappedByTheVT drives the whole chain with no
389// hand-written bounds: a real vt.Screen wraps one logical line itself, its
390// LineBound travels through eviction into history or stays on the live grid,
391// and Extract must give the line back exactly as the application printed it.
392func TestSelectedTextJoinsRowsWrappedByTheVT(t *testing.T) {
393 const (
394 width = 8
395 height = 4
396 line = "abcdefghij"
397 )
398 cases := []struct {
399 name string
400 // input is written verbatim to the VT; the wrap and every scroll it
401 // implies are the emulator's own decisions.
402 input string
403 // wantHistoryLen pins where the history/screen seam falls, so a change
404 // in scroll behavior fails loudly instead of silently retargeting the
405 // case onto rows the seam no longer separates.
406 wantHistoryLen int
407 // wrapRow is the first snapshot row of the wrapped logical line.
408 wrapRow int
409 }{
410 {
411 name: "wrapped line straddles the history and screen seam",
412 input: line + "\r\nx\r\ny\r\n",
413 wantHistoryLen: 1,
414 wrapRow: 0,
415 },
416 {
417 name: "wrapped line sits entirely in sealed history",
418 input: line + "\r\nx\r\ny\r\nz\r\n",
419 wantHistoryLen: 2,
420 wrapRow: 0,
421 },
422 {
423 name: "wrapped line sits entirely on the live screen",
424 input: "1\r\n2\r\n3\r\n4\r\n5\r\n\x1b[2;1H" + line,
425 wantHistoryLen: 2,
426 wrapRow: 3,
427 },
428 {
429 // The common case: the pane is full, so the cursor is on the last
430 // row and the wrap scrolls the screen as it happens. This is what a
431 // long line printed at a shell prompt actually does.
432 name: "wrapped line wraps out of the bottom row of a full pane",
433 input: "1\r\n2\r\n3\r\n4\r\n5\r\n" + line,
434 wantHistoryLen: 3,
435 wrapRow: 5,
436 },
437 }
438 for _, tc := range cases {
439 t.Run(tc.name, func(t *testing.T) {
440 screen := vt.NewScreenWithHistory(width, height, vt.HistoryConfig{MaxRows: 64})
441 screen.Write([]byte(tc.input))
442
443 snapshot := NewSnapshot(screen.History(), screen, screen.LineBounds(), nil)
444 require.Equal(t, tc.wantHistoryLen, snapshot.history.Len(), "history/screen seam moved")
445 doc := NewDocument(snapshot, domain.DefaultWordSeparators)
446
447 got := doc.Extract([]CellRange{
448 {Row: tc.wrapRow, Start: 0, End: width - 1},
449 {Row: tc.wrapRow + 1, Start: 0, End: width - 1},
450 })
451 require.Equal(t, line, got, "the two physical rows must rejoin without a newline or grid padding")
452 })
453 }
454}