1package palette
2
3import (
4 "testing"
5 "time"
6
7 "github.com/bnema/vev/internal/domain"
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 TestFuzzyOrdersMixedResults(t *testing.T) {
82 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
83 tests := []struct {
84 name string
85 results []Result
86 query string
87 wantText []string
88 wantKinds []ResultKind
89 wantPositions [][]int
90 wantActiveIDs map[int]domain.SessionID
91 }{
92 {
93 name: "command shortcode precedes sessions and description matches",
94 results: []Result{
95 NewStoppedSessionResult("work", created),
96 NewCommandResult(cmd("WORK", "", "Create a workspace")),
97 NewActiveSessionResult("work", created, domain.SessionID("work-id")),
98 NewCommandResult(cmd("WQORRK", "", "")),
99 NewCommandResult(cmd("ZZZ", "", "work tools")),
100 },
101 query: "work",
102 wantText: []string{"WORK", "Resume session work", "WQORRK", "Switch to session work", "ZZZ"},
103 wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindCommand, ResultKindActiveSession, ResultKindCommand},
104 wantPositions: [][]int{{0, 1, 2, 3}, {15, 16, 17, 18}, {0, 2, 3, 5}, {1, 8, 20, 21}, nil},
105 },
106 {
107 name: "equivalent sessions sort by normalized text",
108 results: []Result{
109 NewStoppedSessionResult("aBravo", time.Time{}),
110 NewStoppedSessionResult("aAlpha", time.Time{}),
111 },
112 query: "a",
113 wantText: []string{"Resume session aAlpha", "Resume session aBravo"},
114 wantKinds: []ResultKind{ResultKindStoppedSession, ResultKindStoppedSession},
115 wantPositions: [][]int{{15}, {15}},
116 },
117 {
118 name: "display prefix positions sort before later active names",
119 results: []Result{
120 NewStoppedSessionResult("aBravo", time.Time{}),
121 NewStoppedSessionResult("aAlpha", time.Time{}),
122 NewActiveSessionResult("aZulu", time.Time{}, "z"),
123 NewActiveSessionResult("aEcho", time.Time{}, "e"),
124 NewActiveSessionResult("aEcho", time.Time{}, "e2"),
125 NewCommandResult(cmd("AX", "", "")),
126 },
127 query: "a",
128 wantText: []string{"AX", "Resume session aAlpha", "Resume session aBravo", "Switch to session aEcho", "Switch to session aEcho", "Switch to session aZulu"},
129 wantKinds: []ResultKind{ResultKindCommand, ResultKindStoppedSession, ResultKindStoppedSession, ResultKindActiveSession, ResultKindActiveSession, ResultKindActiveSession},
130 wantPositions: [][]int{{0}, {15}, {15}, {18}, {18}, {18}},
131 wantActiveIDs: map[int]domain.SessionID{3: "e", 4: "e2", 5: "z"},
132 },
133 }
134
135 for _, tt := range tests {
136 t.Run(tt.name, func(t *testing.T) {
137 matches := Fuzzy(tt.results, tt.query)
138 require.Equal(t, tt.wantText, matchSearchText(matches))
139 require.Equal(t, tt.wantKinds, matchKinds(matches))
140 positions := make([][]int, len(matches))
141 for i := range matches {
142 positions[i] = matches[i].Positions
143 }
144 require.Equal(t, tt.wantPositions, positions)
145 for index, wantID := range tt.wantActiveIDs {
146 gotID, ok := matches[index].Result.SessionID()
147 require.True(t, ok)
148 require.Equal(t, wantID, gotID)
149 }
150 })
151 }
152}
153
154func matchSearchText(matches []Match) []string {
155 out := make([]string, len(matches))
156 for i, match := range matches {
157 out[i] = match.Result.SearchText()
158 }
159 return out
160}
161
162func matchKinds(matches []Match) []ResultKind {
163 out := make([]ResultKind, len(matches))
164 for i, match := range matches {
165 out[i] = match.Result.Kind()
166 }
167 return out
168}