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
50func TestVisualSearchNewSnapshotOwnsSourceRows(t *testing.T) {
51 rows := [][]renderer.Cell{{{Rune: 'a'}, {Rune: 'l'}, {Rune: 'p'}, {Rune: 'h'}, {Rune: 'a'}}}
52 m := New(scopy.NewSnapshotFromRows(rows, 40, 1))
53
54 rows[0][0].Rune = 'z'
55 for _, r := range "alpha" {
56 m.Insert(r)
57 }
58
59 matches := m.Matches()
60 require.Len(t, matches, 1)
61 require.Equal(t, "alpha", matches[0].Text)
62}
63
64func TestVisualSearchCloneRetainsDocumentAndKeepsUIStateIndependent(t *testing.T) {
65 m := New(testSnapshot("alpha", "beta alpha"))
66 for _, r := range "alpha" {
67 m.Insert(r)
68 }
69 clone := m.Clone()
70
71 require.Equal(t, m.snapshot.Row(0), clone.snapshot.Row(0))
72 clone.Down()
73 clone.Insert('z')
74
75 require.Equal(t, "alpha", m.Query())
76 require.Equal(t, 0, m.SelectedIndex())
77 require.Len(t, m.Matches(), 2)
78 require.Equal(t, "alphaz", clone.Query())
79 require.Equal(t, -1, clone.SelectedIndex())
80 require.Len(t, clone.Matches(), 0)
81}
82
83func TestVisualSearchRenderDistinguishesMultipleMatchesOnSameLine(t *testing.T) {
84 m := New(testSnapshot("alpha alpha"))
85 for _, r := range "alpha" {
86 m.Insert(r)
87 }
88
89 frame := m.Render(domain.Size{Cols: 40, Rows: 4})
90 text := rowText(frame.Row(1)) + "\n" + rowText(frame.Row(2))
91
92 require.Contains(t, text, "1:1")
93 require.Contains(t, text, "1:7")
94}
95
96func TestVisualSearchRenderShowsInputAndLineResults(t *testing.T) {
97 m := New(testSnapshot("alpha", "beta alpha", "gamma"))
98 for _, r := range "alpha" {
99 m.Insert(r)
100 }
101
102 frame := m.Render(domain.Size{Cols: 40, Rows: 4})
103
104 text := rowText(frame.Row(0)) + "\n" + rowText(frame.Row(1)) + "\n" + rowText(frame.Row(2))
105 require.Contains(t, text, "/alpha")
106 require.Contains(t, text, "1:1 alpha")
107 require.Contains(t, text, "2:6 beta alpha")
108}
109
110func TestVisualSearchCloneAllocationIsWidthIndependent(t *testing.T) {
111 if testing.Short() {
112 t.Skip("skipping benchmark-driven allocation gate in short mode")
113 }
114 narrow := benchmarkVisualSearchCloneBytes(16)
115 wide := benchmarkVisualSearchCloneBytes(512)
116 assertWidthIndependentCloneAllocations(t, narrow, wide)
117}
118
119// The comparison holds row and match counts equal. Model.Clone must copy only
120// mutable UI/match state, so a document-row clone is observable as width-scaled
121// B/op without relying on a Go-version-specific absolute allocation budget.
122func benchmarkVisualSearchCloneBytes(width int) int64 {
123 const rows = 256
124
125 line := strings.Repeat("x", width-7) + "needle"
126 lines := make([]string, rows)
127 for i := range lines {
128 lines[i] = line
129 }
130 model := New(testSnapshot(lines...))
131 for _, r := range "needle" {
132 model.Insert(r)
133 }
134 if got := len(model.Matches()); got != rows {
135 panic("visual-search allocation gate requires one match per row")
136 }
137
138 result := testing.Benchmark(func(b *testing.B) {
139 b.ReportAllocs()
140 b.ResetTimer()
141 for b.Loop() {
142 benchmarkModelSink = model.Clone()
143 }
144 })
145 return result.AllocedBytesPerOp()
146}
147
148func assertWidthIndependentCloneAllocations(t *testing.T, narrow, wide int64) {
149 t.Helper()
150 const conservativeWidthTolerance = 2
151 if wide > narrow*conservativeWidthTolerance {
152 t.Fatalf("VisualSearch Model.Clone B/op scaled with row width: narrow=%d wide=%d (limit=%d)", narrow, wide, narrow*conservativeWidthTolerance)
153 }
154}
155
156func BenchmarkVisualSearchClone10KRows(b *testing.B) {
157 const historyRows = 10_000
158
159 lines := make([]string, historyRows)
160 for i := range lines {
161 lines[i] = "immutable history row"
162 }
163 model := New(testSnapshot(lines...))
164 for _, r := range "history" {
165 model.Insert(r)
166 }
167 if got := len(model.Matches()); got != historyRows {
168 b.Fatalf("representative query matches = %d, want %d", got, historyRows)
169 }
170
171 b.ReportAllocs()
172 b.ResetTimer()
173 for b.Loop() {
174 benchmarkModelSink = model.Clone()
175 }
176}
177
178func rowText(row []renderer.Cell) string {
179 var b strings.Builder
180 for _, c := range row {
181 if c.Rune == 0 {
182 b.WriteRune(' ')
183 continue
184 }
185 b.WriteRune(c.Rune)
186 }
187 return b.String()
188}
189
190func TestVisualSearchRenderStyledUsesSemanticRoles(t *testing.T) {
191 base := renderer.Style{Foreground: 1, Background: 2}
192 selection := renderer.Style{Foreground: 3, Background: 4}
193 for _, tt := range []struct {
194 name string
195 x, y int
196 want renderer.Style
197 why string
198 }{
199 {name: "blank filler", x: 19, y: 3, want: base, why: "blank filler keeps base surface"},
200 {name: "input caret", x: 2, y: 0, want: selection, why: "input caret keeps selection surface"},
201 } {
202 t.Run(tt.name, func(t *testing.T) {
203 m := New(testSnapshot("alpha"))
204 m.Insert('a')
205 frame := m.RenderStyled(domain.Size{Cols: 20, Rows: 4}, RenderStyles{Base: base, Selection: selection})
206 require.True(t, frame.At(tt.x, tt.y).Style.Equal(tt.want), tt.why)
207 })
208 }
209}