v0.2.0 80c61e3

fuzzy_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close