1package visualsearch
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/stretchr/testify/require"
8
9 renderer "github.com/bnema/vev-vt"
10 "github.com/bnema/vev/internal/domain"
11 scopy "github.com/bnema/vev/internal/usecase/copy"
12)
13
14var benchmarkModelSink *Model
15
16func testSnapshot(lines ...string) scopy.Snapshot {
17 rows := make([][]renderer.Cell, len(lines))
18 for i, line := range lines {
19 runes := []rune(line)
20 rows[i] = make([]renderer.Cell, len(runes))
21 for x, r := range runes {
22 rows[i][x] = renderer.Cell{Rune: r}
23 }
24 }
25 return scopy.NewSnapshotFromRows(rows, 40, 4)
26}
27
28func TestVisualSearchModelFiltersAndSelectsLineMatches(t *testing.T) {
29 m := New(testSnapshot("alpha", "beta alpha", "gamma"))
30
31 for _, r := range "alpha" {
32 m.Insert(r)
33 }
34
35 matches := m.Matches()
36 require.Len(t, matches, 2)
37 require.Equal(t, 0, matches[0].Row)
38 require.Equal(t, "alpha", matches[0].Text)
39 require.Equal(t, 1, matches[1].Row)
40 selected, ok := m.Selected()
41 require.True(t, ok)
42 require.Equal(t, matches[0], selected)
43
44 m.Down()
45 selected, ok = m.Selected()
46 require.True(t, ok)
47 require.Equal(t, matches[1], selected)
48}
49
50// Typing refines previous matches incrementally; every intermediate result
51// must equal a full search for the same query.
52func TestVisualSearchIncrementalMatchesEqualFullSearch(t *testing.T) {
53 snapshot := testSnapshot("Snapshot ssh", "snap SNAPshot snapsh", "界snap 界snapshot", "none", " snaps ")
54 doc := scopy.NewDocument(snapshot, "")
55 steps := []struct {
56 name string
57 edit func(*Model)
58 }{
59 {name: "type", edit: func(m *Model) {
60 for _, r := range "snapsh" {
61 m.Insert(r)
62 }
63 }},
64 {name: "backspace then retype", edit: func(m *Model) { m.Backspace(); m.Backspace(); m.Insert('S') }},
65 {name: "leading space", edit: func(m *Model) {
66 for range 10 {
67 m.Backspace()
68 }
69 for _, r := range " snap" {
70 m.Insert(r)
71 }
72 }},
73 }
74 m := New(snapshot)
75 for _, step := range steps {
76 t.Run(step.name, func(t *testing.T) {
77 step.edit(m)
78 require.Equal(t, scopy.FindMatches(doc, m.Query()), m.Matches(), "query %q", m.Query())
79 require.NotEmpty(t, m.Matches(), "fixture must keep the query matching")
80 })
81 }
82 clone := m.Clone()
83 clone.Insert('s')
84 require.Equal(t, scopy.FindMatches(doc, clone.Query()), clone.Matches())
85}
86
87func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
88 rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}}
89 m := New(scopy.NewSnapshotFromRows(rows, 40, 1))
90
91 rows[0][0].Rune = 'z'
92 for _, r := range "alpha" {
93 m.Insert(r)
94 }
95
96 matches := m.Matches()
97 require.Len(t, matches, 1)
98 require.Equal(t, "alpha", matches[0].Text)
99}
100
101func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) {
102 m := New(testSnapshot("alpha", "beta alpha"))
103 for _, r := range "alpha" {
104 m.Insert(r)
105 }
106 clone := m.Clone()
107
108 require.Equal(t, m.snapshot.Row(0), clone.snapshot.Row(0))
109 clone.Down()
110 clone.Insert('z')
111
112 require.Equal(t, "alpha", m.Query())
113 require.Equal(t, 0, m.SelectedIndex())
114 require.Len(t, m.Matches(), 2)
115 require.Equal(t, "alphaz", clone.Query())
116 require.Equal(t, -1, clone.SelectedIndex())
117 require.Len(t, clone.Matches(), 0)
118}
119
120func TestVisualSearchRenderDistinguishesMultipleMatchesOnSameLine(t *testing.T) {
121 m := New(testSnapshot("alpha alpha"))
122 for _, r := range "alpha" {
123 m.Insert(r)
124 }
125
126 frame := m.Render(domain.Size{Cols: 40, Rows: 4})
127 text := rowText(frame.Row(1)) + "\n" + rowText(frame.Row(2))
128
129 require.Contains(t, text, "1:1")
130 require.Contains(t, text, "1:7")
131}
132
133func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) {
134 m := New(testSnapshot("alpha", "beta alpha", "gamma"))
135 for _, r := range "alpha" {
136 m.Insert(r)
137 }
138
139 frame := m.Render(domain.Size{Cols: 40, Rows: 4})
140
141 text := rowText(frame.Row(0)) + "\n" + rowText(frame.Row(1)) + "\n" + rowText(frame.Row(2))
142 require.Contains(t, text, "/alpha")
143 require.Contains(t, text, "1:1 alpha")
144 require.Contains(t, text, "2:6 beta alpha")
145}
146
147func TestVisualSearchCloneAllocationIsWidthIndependent(t *testing.T) {
148 if testing.Short() {
149 t.Skip("skipping benchmark-driven allocation gate in short mode")
150 }
151 narrow := benchmarkVisualSearchCloneBytes(16)
152 wide := benchmarkVisualSearchCloneBytes(512)
153 assertWidthIndependentCloneAllocations(t, narrow, wide)
154}
155
156// The comparison holds row and match counts equal. Model.Clone must copy only
157// mutable UI/match state, so a document-row clone is observable as width-scaled
158// B/op without relying on a Go-version-specific absolute allocation budget.
159func benchmarkVisualSearchCloneBytes(width int) int64 {
160 const rows = 256
161
162 line := strings.Repeat("x", width-7) + "needle"
163 lines := make([]string, rows)
164 for i := range lines {
165 lines[i] = line
166 }
167 model := New(testSnapshot(lines...))
168 for _, r := range "needle" {
169 model.Insert(r)
170 }
171 if got := len(model.Matches()); got != rows {
172 panic("visual-search allocation gate requires one match per row")
173 }
174
175 result := testing.Benchmark(func(b *testing.B) {
176 b.ReportAllocs()
177 b.ResetTimer()
178 for b.Loop() {
179 benchmarkModelSink = model.Clone()
180 }
181 })
182 return result.AllocedBytesPerOp()
183}
184
185func assertWidthIndependentCloneAllocations(t *testing.T, narrow, wide int64) {
186 t.Helper()
187 const conservativeWidthTolerance = 2
188 if wide > narrow*conservativeWidthTolerance {
189 t.Fatalf("VisualSearch Model.Clone B/op scaled with row width: narrow=%d wide=%d (limit=%d)", narrow, wide, narrow*conservativeWidthTolerance)
190 }
191}
192
193func BenchmarkVisualSearchClone10KRows(b *testing.B) {
194 const historyRows = 10_000
195
196 lines := make([]string, historyRows)
197 for i := range lines {
198 lines[i] = "immutable history row"
199 }
200 model := New(testSnapshot(lines...))
201 for _, r := range "history" {
202 model.Insert(r)
203 }
204 if got := len(model.Matches()); got != historyRows {
205 b.Fatalf("representative query matches = %d, want %d", got, historyRows)
206 }
207
208 b.ReportAllocs()
209 b.ResetTimer()
210 for b.Loop() {
211 benchmarkModelSink = model.Clone()
212 }
213}
214
215func rowText(row []renderer.Cell) string {
216 var b strings.Builder
217 for _, c := range row {
218 if c.Rune == 0 {
219 b.WriteRune(' ')
220 continue
221 }
222 b.WriteRune(c.Rune)
223 }
224 return b.String()
225}
226
227func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) {
228 base := renderer.Style{Foreground: 1, Background: 2}
229 selection := renderer.Style{Foreground: 3, Background: 4}
230 for _, tt := range []struct {
231 name string
232 x, y int
233 want renderer.Style
234 why string
235 }{
236 {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"},
237 {name: "input caret", x: 2, y: 0, want: base, why: "caret cell keeps base surface under the real cursor"},
238 } {
239 t.Run(tt.name, func(t *testing.T) {
240 m := New(testSnapshot("alpha"))
241 m.Insert('a')
242 frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection})
243 require.True(t, frame.At(tt.x, tt.y).Style.Equal(tt.want), tt.why)
244 })
245 }
246}