1package palette
2
3import (
4 "fmt"
5 "testing"
6 "time"
7
8 renderer "github.com/bnema/vev-vt"
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/usecase/command"
11 themeui "github.com/bnema/vev/internal/usecase/theme"
12 "github.com/stretchr/testify/require"
13)
14
15func TestModelInsertBackspaceAndSelectionClamp(t *testing.T) {
16 m := New(CommandResults([]command.Command{
17 cmd("ABC", "Alpha", "first"),
18 cmd("DEF", "Delta", "second"),
19 cmd("AXY", "Other", "third"),
20 }))
21
22 require.Equal(t, "", m.Query())
23 selected, ok := m.Selected()
24 require.True(t, ok)
25 require.Equal(t, "ABC", selectedCommandCode(t, selected))
26
27 m.Down()
28 m.Down()
29 selected, ok = m.Selected()
30 require.True(t, ok)
31 require.Equal(t, "AXY", selectedCommandCode(t, selected))
32
33 m.Insert('d')
34 require.Equal(t, "d", m.Query())
35 selected, ok = m.Selected()
36 require.True(t, ok)
37 require.Equal(t, "DEF", selectedCommandCode(t, selected), "selection clamps to only match after query changes")
38
39 m.Up()
40 selected, ok = m.Selected()
41 require.True(t, ok)
42 require.Equal(t, "DEF", selectedCommandCode(t, selected), "up at first match clamps")
43
44 m.Backspace()
45 require.Equal(t, "", m.Query())
46 require.Len(t, m.Matches(), 3)
47}
48
49func TestModelNoMatchesClearsSelection(t *testing.T) {
50 m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
51 m.Insert('z')
52
53 _, ok := m.Selected()
54 require.False(t, ok)
55 require.Empty(t, m.Matches())
56}
57
58func TestModelMatchesDeepCopiesPositions(t *testing.T) {
59 m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
60 m.Insert('a')
61
62 matches := m.Matches()
63 require.Len(t, matches, 1)
64 require.Equal(t, []int{0}, matches[0].Positions)
65
66 matches[0].Positions[0] = 2
67
68 fresh := m.Matches()
69 require.Equal(t, []int{0}, fresh[0].Positions)
70}
71
72func TestModelExactArgumentMatchMovesExistingFuzzyMatchToFront(t *testing.T) {
73 zzz := cmd("ZZZ", "", "ZZZ 1")
74 zzz.Arguments = command.ArgumentsRequired
75 commands := []command.Command{
76 cmd("AAA", "", "ZZZ 1"),
77 zzz,
78 }
79 want := Fuzzy(CommandResults(commands), "ZZZ 1")[1]
80 m := New(CommandResults(commands))
81
82 for _, r := range "ZZZ 1" {
83 m.Insert(r)
84 }
85
86 matches := m.Matches()
87 require.Len(t, matches, 2)
88 require.Equal(t, "ZZZ", matchCommandCode(t, matches[0]))
89 require.Equal(t, want, matches[0], "the existing fuzzy match must retain its metadata")
90 selected, ok := m.Selected()
91 require.True(t, ok)
92 require.Equal(t, "ZZZ", selectedCommandCode(t, selected))
93}
94
95func TestModelExactArgumentMatchKeepsAbsentAndFirstBehavior(t *testing.T) {
96 for _, tt := range []struct {
97 name string
98 commands func(zzz command.Command) []command.Command
99 want Match
100 wantLen int
101 }{
102 {
103 name: "absent fuzzy match is prepended",
104 commands: func(zzz command.Command) []command.Command {
105 return []command.Command{cmd("AAA", "", "ZZZ 1"), zzz}
106 },
107 want: newMatch(NewCommandResult(command.Command{
108 Code: "ZZZ",
109 Desc: "unmatched",
110 Arguments: command.ArgumentsRequired,
111 }), 0),
112 wantLen: 2,
113 },
114 {
115 name: "first fuzzy match is unchanged",
116 commands: func(zzz command.Command) []command.Command {
117 zzz.Desc = "ZZZ 1"
118 return []command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}
119 },
120 want: func() Match {
121 zzz := cmd("ZZZ", "", "ZZZ 1")
122 zzz.Arguments = command.ArgumentsRequired
123 return Fuzzy(CommandResults([]command.Command{zzz, cmd("ZZZZ", "", "ZZZ 1")}), "ZZZ 1")[0]
124 }(),
125 wantLen: 2,
126 },
127 } {
128 t.Run(tt.name, func(t *testing.T) {
129 zzz := cmd("ZZZ", "", "unmatched")
130 zzz.Arguments = command.ArgumentsRequired
131 m := New(CommandResults(tt.commands(zzz)))
132 for _, r := range "ZZZ 1" {
133 m.Insert(r)
134 }
135
136 matches := m.Matches()
137 require.Len(t, matches, tt.wantLen)
138 require.Equal(t, tt.want, matches[0])
139 })
140 }
141}
142
143func TestRenderDrawsOnlyCodeAndDescriptionWithStyles(t *testing.T) {
144 m := New(CommandResults([]command.Command{
145 cmd("CPY", "Copy", "Enter copy mode"),
146 cmd("CNT", "New", "Create tab"),
147 }))
148 m.Insert('c')
149 m.Insert('y')
150 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles()})
151
152 require.Equal(t, '>', frame.At(0, 0).Rune)
153 require.Equal(t, ' ', frame.At(1, 0).Rune)
154 require.Equal(t, 'c', frame.At(2, 0).Rune)
155 require.Equal(t, 'y', frame.At(3, 0).Rune)
156 require.True(t, frame.At(4, 0).Style.Inverse, "caret is reverse-video after query")
157
158 require.Equal(t, "CPY Enter copy mode ", frameRow(frame, 1))
159 require.NotContains(t, frameRow(frame, 1), "Copy")
160 require.NotContains(t, frameRow(frame, 1), "—")
161 require.True(t, frame.At(0, 1).Style.Inverse, "selected line is inverse")
162 require.True(t, frame.At(5, 1).Style.Inverse, "selected line padding/text remains inverse")
163 require.True(t, frame.At(0, 1).Style.Bold, "command code C is bold")
164 require.True(t, frame.At(1, 1).Style.Bold, "command code P is bold")
165 require.True(t, frame.At(2, 1).Style.Bold, "command code Y is bold")
166 require.True(t, frame.At(4, 1).Style.Italic, "description is italic")
167}
168
169func TestRenderSafelyClipsVisibleFieldsAtNarrowWidths(t *testing.T) {
170 m := New(CommandResults([]command.Command{cmd("CPY", "Copy", "Enter copy mode")}))
171 want := []rune("CPY Enter copy mode")
172
173 for _, cols := range []int{0, 1, 3, 4, 7} {
174 t.Run(fmt.Sprintf("cols_%d", cols), func(t *testing.T) {
175 frame := m.Render(domain.Size{Cols: cols, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
176 require.Equal(t, cols, frame.Width)
177 if cols > 0 {
178 require.Equal(t, want[:min(cols, len(want))], []rune(frameRow(frame, 1)))
179 }
180 })
181 }
182}
183
184func selectedCommandCode(t *testing.T, result Result) string {
185 t.Helper()
186 cmd, ok := result.Command()
187 require.True(t, ok, "selected result is a command")
188 return cmd.Code
189}
190
191func matchCommandCode(t *testing.T, match Match) string {
192 t.Helper()
193 return selectedCommandCode(t, match.Result)
194}
195
196func frameRow(frame renderer.Frame, y int) string {
197 row := make([]rune, frame.Width)
198 for x := range frame.Width {
199 row[x] = frame.At(x, y).Rune
200 }
201 return string(row)
202}
203
204func TestRenderUsesConfiguredStyles(t *testing.T) {
205 tests := []struct {
206 name string
207 styles func() RenderStyles
208 x, y int
209 unfiltered bool
210 assert func(t *testing.T, style renderer.Style)
211 }{
212 {
213 name: "selection style for fuzzy highlights",
214 styles: func() RenderStyles {
215 accent := renderer.DefaultStyle()
216 accent.HasBackgroundRGB = true
217 accent.BackgroundRGB = renderer.RGB{R: 1, G: 2, B: 3}
218 styles := DefaultRenderStyles()
219 styles.Selection = accent
220 return styles
221 },
222 x: 0,
223 y: 1,
224 assert: func(t *testing.T, style renderer.Style) {
225 require.True(t, style.Bold)
226 require.False(t, style.Inverse)
227 require.True(t, style.HasBackgroundRGB)
228 require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, style.BackgroundRGB)
229 },
230 },
231 {
232 name: "muted italic description style",
233 styles: func() RenderStyles {
234 muted := renderer.DefaultStyle()
235 muted.Italic = true
236 muted.HasForegroundRGB = true
237 muted.ForegroundRGB = renderer.RGB{R: 10, G: 20, B: 30}
238 styles := DefaultRenderStyles()
239 styles.Description = muted
240 return styles
241 },
242 x: 4,
243 y: 2,
244 unfiltered: true,
245 assert: func(t *testing.T, style renderer.Style) {
246 require.True(t, style.Italic)
247 require.True(t, style.HasForegroundRGB)
248 require.Equal(t, -1, style.Foreground)
249 require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, style.ForegroundRGB)
250 },
251 },
252 {
253 name: "description italic is configurable",
254 styles: func() RenderStyles {
255 styles := DefaultRenderStyles()
256 styles.Description.Italic = false
257 styles.SelectionDescription.Italic = false
258 return styles
259 },
260 x: 4,
261 y: 1,
262 assert: func(t *testing.T, style renderer.Style) {
263 require.False(t, style.Italic)
264 },
265 },
266 }
267
268 for _, tt := range tests {
269 t.Run(tt.name, func(t *testing.T) {
270 m := New(CommandResults([]command.Command{
271 cmd("CPY", "Copy", "Enter copy mode"),
272 cmd("CNT", "New", "Create tab"),
273 }))
274 if !tt.unfiltered {
275 m.Insert('c')
276 m.Insert('y')
277 }
278 frame := m.Render(domain.Size{Cols: 28, Rows: 3}, RenderOptions{Styles: tt.styles()})
279
280 tt.assert(t, frame.At(tt.x, tt.y).Style)
281 })
282 }
283}
284
285func TestPaletteDescriptionUsesSelectedRowForeground(t *testing.T) {
286 selection := renderer.DefaultStyle()
287 selection.HasForegroundRGB = true
288 selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
289 selection.HasBackgroundRGB = true
290 selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
291 description := renderer.DefaultStyle()
292 description.Italic = true
293 description.HasForegroundRGB = true
294 description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
295 selectionDescription := selection
296 selectionDescription.Italic = true
297 selectionDescription.ForegroundRGB = renderer.RGB{R: 220, G: 221, B: 222}
298
299 model := New(CommandResults([]command.Command{
300 cmd("ONE", "One", "first description"),
301 cmd("TWO", "Two", "second description"),
302 }))
303 frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
304 Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
305 Selection: selection, Description: description, SelectionDescription: selectionDescription,
306 }})
307
308 selected := frame.At(4, 1).Style
309 require.True(t, selected.Equal(selectionDescription))
310
311 inactive := frame.At(4, 2).Style
312 require.Equal(t, description.ForegroundRGB, inactive.ForegroundRGB)
313 require.True(t, inactive.Italic)
314}
315
316func TestPaletteDescriptionWithoutSelectionStylePreservesDescriptionForeground(t *testing.T) {
317 selection := renderer.DefaultStyle()
318 selection.HasForegroundRGB = true
319 selection.ForegroundRGB = renderer.RGB{R: 240, G: 241, B: 242}
320 selection.HasBackgroundRGB = true
321 selection.BackgroundRGB = renderer.RGB{R: 20, G: 21, B: 22}
322 description := renderer.DefaultStyle()
323 description.Italic = true
324 description.HasForegroundRGB = true
325 description.ForegroundRGB = renderer.RGB{R: 100, G: 101, B: 102}
326
327 model := New(CommandResults([]command.Command{cmd("ONE", "One", "first description")}))
328 frame := model.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: RenderStyles{
329 Base: renderer.DefaultStyle(), Row: renderer.DefaultStyle(),
330 Selection: selection, Description: description,
331 }})
332
333 selected := frame.At(4, 1).Style
334 require.Equal(t, description.ForegroundRGB, selected.ForegroundRGB)
335 require.Equal(t, selection.BackgroundRGB, selected.BackgroundRGB)
336 require.True(t, selected.Italic)
337}
338
339func TestPaletteDescriptionKeepsInactiveRowSurfaceAcrossFallbacks(t *testing.T) {
340 paletteColors := [16]renderer.RGB{}
341 paletteColors[2] = renderer.RGB{R: 10, G: 230, B: 120}
342 paletteColors[10] = paletteColors[2]
343 accentTheme := themeui.Theme{
344 Foreground: renderer.RGB{R: 230, G: 230, B: 230}, Background: renderer.RGB{R: 8, G: 9, B: 10},
345 HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true,
346 Palette: paletteColors, PaletteKnown: 1<<2 | 1<<10,
347 }
348 indexedTheme := accentTheme
349 indexedTheme.TrueColor = false
350 paletteOffTheme := accentTheme
351 paletteOffTheme.UsePalette = false
352 neutralTheme := paletteOffTheme
353 neutralTheme.PaletteKnown = 0
354
355 tests := []struct {
356 name string
357 theme themeui.Theme
358 policy domain.ThemeAccent
359 }{
360 {name: "truecolor accent", theme: accentTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
361 {name: "indexed only", theme: indexedTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}},
362 {name: "palette off", theme: paletteOffTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
363 {name: "forced dark", theme: themeui.BuiltinDark, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
364 {name: "forced light", theme: themeui.BuiltinLight, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
365 {name: "neutral fallback", theme: neutralTheme, policy: domain.ThemeAccent{Mode: domain.ThemeAccentAuto}},
366 }
367
368 for _, tt := range tests {
369 t.Run(tt.name, func(t *testing.T) {
370 styles := themeui.Resolve(tt.theme, tt.policy).Styles
371 model := New(CommandResults([]command.Command{
372 cmd("ONE", "One", "first description"),
373 cmd("TWO", "Two", "second description"),
374 }))
375 model.Down()
376 frame := model.Render(domain.Size{Cols: 32, Rows: 3}, RenderOptions{Styles: RenderStyles{
377 Base: styles.PickerBase, Row: styles.SurfaceInactive,
378 Selection: styles.PickerSelection, Description: styles.PickerDescription,
379 SelectionDescription: styles.PickerSelectionMuted,
380 }})
381 inactive := frame.At(4, 1).Style
382 require.Equal(t, styles.SurfaceInactive.Background, inactive.Background)
383 require.Equal(t, styles.SurfaceInactive.HasBackgroundRGB, inactive.HasBackgroundRGB)
384 require.Equal(t, styles.SurfaceInactive.BackgroundRGB, inactive.BackgroundRGB)
385 if tt.name == "indexed only" {
386 require.Equal(t, 2, inactive.Foreground)
387 require.False(t, inactive.HasBackgroundRGB)
388 }
389
390 selected := frame.At(4, 2).Style
391 require.True(t, selected.Equal(styles.PickerSelectionMuted))
392 })
393 }
394}
395
396func TestModelCompleteSelected(t *testing.T) {
397 jrs := cmd("JRS", "Jump", "Jump to recent session")
398 jrs.Arguments = command.ArgumentsRequired
399 optional := cmd("CNS", "New session", "Create named session")
400 optional.Arguments = command.ArgumentsOptional
401 registryFirst := command.Registry()[0]
402
403 tests := []struct {
404 name string
405 commands []command.Command
406 registry bool
407 query string
408 down int
409 want string
410 wantChange bool
411 rendered string
412 }{
413 {name: "nil model", want: "", wantChange: false},
414 {name: "empty model", commands: nil, want: "", wantChange: false},
415 {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
416 {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
417 {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
418 {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
419 {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
420 {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
421 {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
422 {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true},
423 {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false},
424 {name: "leading whitespace optional argument does not duplicate token", commands: []command.Command{optional}, query: "\u2003CNS name", want: "\u2003CNS name", wantChange: false},
425 {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
426 {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
427 }
428
429 for _, tt := range tests {
430 t.Run(tt.name, func(t *testing.T) {
431 var m *Model
432 if tt.name != "nil model" {
433 if tt.registry {
434 m = NewRegistry()
435 } else {
436 m = New(CommandResults(tt.commands))
437 }
438 for _, r := range tt.query {
439 m.Insert(r)
440 }
441 for range tt.down {
442 m.Down()
443 }
444 }
445
446 changed := m.CompleteSelected()
447 require.Equal(t, tt.wantChange, changed)
448 if m == nil {
449 require.Equal(t, tt.want, "")
450 return
451 }
452 require.Equal(t, tt.want, m.Query())
453 if tt.rendered != "" {
454 frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
455 require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
456 }
457 })
458 }
459}
460
461func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
462 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
463 results := []Result{
464 NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
465 NewActiveSessionResult("work", created, "work-id"),
466 NewStoppedSessionResult("work", created),
467 }
468 m := New(results)
469 results[1] = NewActiveSessionResult("changed", created, "changed-id")
470
471 for _, r := range "switch" {
472 m.Insert(r)
473 }
474 matches := m.Matches()
475 require.Len(t, matches, 1)
476 require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
477 matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
478 require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
479
480 selected, ok := m.Selected()
481 require.True(t, ok)
482 require.Equal(t, ResultKindActiveSession, selected.Kind())
483 id, active := selected.SessionID()
484 require.True(t, active)
485 require.Equal(t, domain.SessionID("work-id"), id)
486 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
487 require.Equal(t, "switch", m.Query())
488 _, argument := m.ArgumentCommand()
489 require.False(t, argument)
490
491 frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
492 require.Equal(t, "Switch to session work ", frameRow(frame, 1))
493}
494
495func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
496 m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})
497 m.Insert('w')
498 m.Insert('k')
499
500 frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
501
502 require.Equal(t, "Resume session work ", frameRow(frame, 1))
503 require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
504 require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
505 require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
506}
507
508func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
509 m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
510 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
511 require.Len(t, m.Matches(), 1)
512 require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
513}
514
515func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
516 jrs := cmd("JRS", "Jump", "Jump to recent session")
517 jrs.Arguments = command.ArgumentsRequired
518 jrs.ContextHint = command.ContextHintRecentSessions
519 m := New(CommandResults([]command.Command{jrs}))
520 for _, r := range "JRS 1" {
521 m.Insert(r)
522 }
523
524 frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"})
525 if got := frameRow(frame, 1); got != "JRS jump to recent session 1" {
526 t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
527 }
528}
529
530func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
531 m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
532 base := renderer.Style{Foreground: 1, Background: 2}
533 row := renderer.Style{Foreground: 3, Background: 4}
534 selection := renderer.Style{Foreground: 5, Background: 6}
535 frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
536
537 require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
538 require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
539 require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
540}