1package palette
2
3import (
4 "testing"
5 "time"
6
7 "github.com/bnema/vev/internal/protocol"
8 "github.com/bnema/vev/internal/usecase/command"
9 "github.com/stretchr/testify/require"
10)
11
12func cmd(code, name, desc string) command.Command {
13 return command.Command{Code: code, Name: name, Desc: desc}
14}
15
16func codes(matches []Match) []string {
17 out := make([]string, len(matches))
18 for i, match := range matches {
19 cmd, ok := match.Result.Command()
20 if !ok {
21 continue
22 }
23 out[i] = cmd.Code
24 }
25 return out
26}
27
28func TestFuzzyOrdersVisibleFieldScoringTiers(t *testing.T) {
29 matches := Fuzzy(CommandResults([]command.Command{
30 cmd("ZQQ", "unused", "alpha beta"),
31 cmd("XAB", "unused", "subsequence code"),
32 cmd("ABX", "unused", "prefix code"),
33 cmd("AB", "unused", "exact code"),
34 }), "ab")
35
36 require.Equal(t, []string{"AB", "ABX", "XAB", "ZQQ"}, codes(matches))
37 require.Empty(t, matches[3].Positions, "description matches do not expose code highlight positions")
38}
39
40func TestFuzzyDoesNotMatchCommandNames(t *testing.T) {
41 matches := Fuzzy(CommandResults([]command.Command{
42 cmd("CPY", "Create Alpha Beta", "Enter copy mode"),
43 cmd("ZQQ", "unused", "Alpha Beta tools"),
44 }), "ab")
45
46 require.Equal(t, []string{"ZQQ"}, codes(matches))
47}
48
49func TestFuzzyDescriptionMatchingIsCaseInsensitiveWithoutCodeHighlights(t *testing.T) {
50 matches := Fuzzy(CommandResults([]command.Command{cmd("CPY", "unused", "Enter COPY mode")}), "copy")
51
52 require.Len(t, matches, 1)
53 require.Empty(t, matches[0].Positions)
54}
55
56func TestFuzzyTieBreaksBySpanFirstThenCode(t *testing.T) {
57 matches := Fuzzy(CommandResults([]command.Command{
58 cmd("ZAXQB", "", ""), // span 3, first 1
59 cmd("BAXQ", "", ""), // span 3, first 1, code sorts first
60 cmd("PXAQ", "", ""), // span 2, first 2
61 cmd("BAQ", "", ""), // span 2, first 0
62 }), "aq")
63
64 require.Equal(t, []string{"BAQ", "PXAQ", "BAXQ", "ZAXQB"}, codes(matches))
65}
66
67func TestFuzzyIsCaseInsensitiveAndHighlightsCodeRunePositions(t *testing.T) {
68 matches := Fuzzy(CommandResults([]command.Command{cmd("CpY", "Copy mode", "Enter copy mode")}), "cy")
69
70 require.Len(t, matches, 1)
71 require.Equal(t, []int{0, 2}, matches[0].Positions)
72}
73
74func TestFuzzyEmptyQueryPreservesRegistryOrder(t *testing.T) {
75 commands := []command.Command{cmd("B", "", ""), cmd("A", "", "")}
76 matches := Fuzzy(CommandResults(commands), "")
77
78 require.Equal(t, []string{"B", "A"}, codes(matches))
79}
80
81func TestFuzzyRanksExactSessionNameAheadOfPrefix(t *testing.T) {
82 tests := []struct {
83 name string
84 results []Result
85 wantFirst string
86 offset int
87 }{
88 {
89 name: "active session",
90 results: []Result{
91 NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"),
92 NewActiveSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"),
93 },
94 wantFirst: "Switch to session vev@arch",
95 offset: 18,
96 },
97 {
98 name: "stopped session",
99 results: []Result{
100 NewStoppedSessionResultWithDisplayOrigin(testExactTarget("vev-vt", 1), time.Time{}, "arch"),
101 NewStoppedSessionResultWithDisplayOrigin(testExactTarget("vev", 2), time.Time{}, "arch"),
102 },
103 wantFirst: "Resume session vev@arch",
104 offset: 15,
105 },
106 {
107 name: "recent route",
108 results: []Result{
109 NewRecentRouteResult("vev-vt", "vev-vt@arch", protocol.RouteNavigationAction{Key: 1}),
110 NewRecentRouteResult("vev", "vev@arch", protocol.RouteNavigationAction{Key: 2}),
111 },
112 wantFirst: "Switch to session vev@arch",
113 offset: 18,
114 },
115 }
116
117 for _, tt := range tests {
118 t.Run(tt.name, func(t *testing.T) {
119 matches := Fuzzy(tt.results, "vev")
120
121 require.Len(t, matches, 2)
122 require.Equal(t, tt.wantFirst, matches[0].Result.DisplayText())
123 require.Equal(t, []int{tt.offset, tt.offset + 1, tt.offset + 2}, matches[0].Positions)
124 })
125 }
126}
127
128func TestFuzzyOrdersMixedResults(t *testing.T) {
129 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
130 tests := []struct {
131 name string
132 results []Result
133 query string
134 wantText []string
135 wantKinds []ResultKind
136 wantPositions [][]int
137 wantActiveTargets map[int]protocol.ExactSessionTarget
138 }{
139 {
140 name: "command shortcode precedes exact session names and broader matches",
141 results: []Result{
142 NewStoppedSessionResult(testExactTarget("work", 1), created),
143 NewCommandResult(cmd("WORK", "", "Create a workspace")),
144 NewActiveSessionResult(testExactTarget("work", 2), created),
145 NewCommandResult(cmd("WQORRK", "", "")),
146 NewCommandResult(cmd("ZZZ", "", "work tools")),
147 },
148 query: "work",
149 wantText: []string{"WORK", "Switch to session work", "Resume session work", "WQORRK", "ZZZ"},
150 wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindStoppedSession, ResultKindCommand, ResultKindCommand},
151 wantPositions: [][]int{{0, 1, 2, 3}, {18, 19, 20, 21}, {15, 16, 17, 18}, {0, 2, 3, 5}, nil},
152 },
153 {
154 name: "equivalent sessions sort by normalized text",
155 results: []Result{
156 NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}),
157 NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}),
158 },
159 query: "a",
160 wantText: []string{"Resume session aAlpha", "Resume session aBravo"},
161 wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},
162 wantPositions: [][]int{{15}, {15}},
163 },
164 {
165 name: "semantic session prefixes sort by kind then normalized label",
166 results: []Result{
167 NewStoppedSessionResult(testExactTarget("aBravo", 1), time.Time{}),
168 NewStoppedSessionResult(testExactTarget("aAlpha", 2), time.Time{}),
169 NewActiveSessionResult(testExactTarget("aZulu", 3), time.Time{}),
170 NewActiveSessionResult(testExactTarget("aEcho", 4), time.Time{}),
171 NewActiveSessionResult(testExactTarget("aEcho", 5), time.Time{}),
172 NewCommandResult(cmd("AX", "", "")),
173 },
174 query: "a",
175 wantText: []string{"AX", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu", "Resume session aAlpha", "Resume session aBravo"},
176 wantKinds: []ResultKind{ResultKindCommand, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindStoppedSession, ResultKindStoppedSession},
177 wantPositions: [][]int{{0}, {18}, {18}, {18}, {15}, {15}},
178 wantActiveTargets: map[int]protocol.ExactSessionTarget{
179 1: testExactTarget("aEcho", 4), 2: testExactTarget("aEcho", 5), 3: testExactTarget("aZulu", 3),
180 },
181 },
182 }
183
184 for _, tt := range tests {
185 t.Run(tt.name, func(t *testing.T) {
186 matches := Fuzzy(tt.results, tt.query)
187 require.Equal(t, tt.wantText, matchSearchText(matches))
188 require.Equal(t, tt.wantKinds, matchKinds(matches))
189 positions := make([][]int, len(matches))
190 for i := range matches {
191 positions[i] = matches[i].Positions
192 }
193 require.Equal(t, tt.wantPositions, positions)
194 for index, wantTarget := range tt.wantActiveTargets {
195 gotTarget, ok := matches[index].Result.SessionTarget()
196 require.True(t, ok)
197 require.Equal(t, wantTarget, gotTarget)
198 }
199 })
200 }
201}
202
203func matchSearchText(matches []Match) []string {
204 out := make([]string, len(matches))
205 for i, match := range matches {
206 out[i] = match.Result.SearchText()
207 }
208 return out
209}
210
211func matchKinds(matches []Match) []ResultKind {
212 out := make([]ResultKind, len(matches))
213 for i, match := range matches {
214 out[i] = match.Result.Kind()
215 }
216 return out
217}