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 registryFirst := command.Registry()[0]
400
401 tests := []struct {
402 name string
403 commands []command.Command
404 registry bool
405 query string
406 down int
407 want string
408 wantChange bool
409 rendered string
410 }{
411 {name: "nil model", want: "", wantChange: false},
412 {name: "empty model", commands: nil, want: "", wantChange: false},
413 {name: "registry first from empty query", registry: true, want: registryFirst.Code, wantChange: true, rendered: registryFirst.Code},
414 {name: "prefix match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cp", want: "CPY", wantChange: true},
415 {name: "fuzzy match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "cy", want: "CPY", wantChange: true},
416 {name: "description match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "enter", want: "CPY", wantChange: true},
417 {name: "navigated selection", commands: []command.Command{cmd("AAA", "", ""), cmd("BBB", "", "")}, down: 1, want: "BBB", wantChange: true},
418 {name: "static command replaces query", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "copy", want: "CPY", wantChange: true},
419 {name: "argument command appends required space", commands: []command.Command{jrs}, query: "jrs", want: "JRS ", wantChange: true},
420 {name: "partial token preserves unicode whitespace argument bytes", commands: []command.Command{jrs}, query: "jr\u2003 \tα β", want: "JRS α β", wantChange: true},
421 {name: "exact code and argument is unchanged", commands: []command.Command{jrs}, query: "JRS\u2003 α β", want: "JRS\u2003 α β", wantChange: false},
422 {name: "no match", commands: []command.Command{cmd("CPY", "Copy", "Enter copy mode")}, query: "zzz", want: "zzz", wantChange: false},
423 {name: "effective override code", commands: []command.Command{{Slug: "new-tab", Code: "NT", Desc: "Create tab"}}, query: "n", want: "NT", wantChange: true},
424 }
425
426 for _, tt := range tests {
427 t.Run(tt.name, func(t *testing.T) {
428 var m *Model
429 if tt.name != "nil model" {
430 if tt.registry {
431 m = NewRegistry()
432 } else {
433 m = New(CommandResults(tt.commands))
434 }
435 for _, r := range tt.query {
436 m.Insert(r)
437 }
438 for range tt.down {
439 m.Down()
440 }
441 }
442
443 changed := m.CompleteSelected()
444 require.Equal(t, tt.wantChange, changed)
445 if m == nil {
446 require.Equal(t, tt.want, "")
447 return
448 }
449 require.Equal(t, tt.want, m.Query())
450 if tt.rendered != "" {
451 frame := m.Render(domain.Size{Cols: 32, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
452 require.Equal(t, "> "+tt.rendered, frameRow(frame, 0)[:len([]rune(tt.rendered))+2])
453 }
454 })
455 }
456}
457
458func TestModelUsesDefensiveTypedResultsAndKeepsSessionsCommandInert(t *testing.T) {
459 created := time.Date(2026, time.March, 1, 0, 0, 0, 0, time.UTC)
460 results := []Result{
461 NewCommandResult(cmd("JRS", "Jump", "Jump to recent session")),
462 NewActiveSessionResult("work", created, "work-id"),
463 NewStoppedSessionResult("work", created),
464 }
465 m := New(results)
466 results[1] = NewActiveSessionResult("changed", created, "changed-id")
467
468 for _, r := range "switch" {
469 m.Insert(r)
470 }
471 matches := m.Matches()
472 require.Len(t, matches, 1)
473 require.Equal(t, ResultKindActiveSession, matches[0].Result.Kind())
474 matches[0].Result = NewCommandResult(cmd("BAD", "", ""))
475 require.Equal(t, ResultKindActiveSession, m.Matches()[0].Result.Kind())
476
477 selected, ok := m.Selected()
478 require.True(t, ok)
479 require.Equal(t, ResultKindActiveSession, selected.Kind())
480 id, active := selected.SessionID()
481 require.True(t, active)
482 require.Equal(t, domain.SessionID("work-id"), id)
483 require.False(t, m.CompleteSelected(), "sessions never participate in tab completion")
484 require.Equal(t, "switch", m.Query())
485 _, argument := m.ArgumentCommand()
486 require.False(t, argument)
487
488 frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
489 require.Equal(t, "Switch to session work ", frameRow(frame, 1))
490}
491
492func TestRenderStoppedSessionHighlightsNameAfterResumePrefix(t *testing.T) {
493 m := New([]Result{NewStoppedSessionResult("work", time.Unix(0, 1))})
494 m.Insert('w')
495 m.Insert('k')
496
497 frame := m.Render(domain.Size{Cols: 28, Rows: 2}, RenderOptions{Styles: DefaultRenderStyles()})
498
499 require.Equal(t, "Resume session work ", frameRow(frame, 1))
500 require.False(t, frame.At(14, 1).Style.Bold, "resume prefix is not highlighted")
501 require.True(t, frame.At(15, 1).Style.Bold, "first matched session rune is highlighted")
502 require.True(t, frame.At(18, 1).Style.Bold, "last matched session rune is highlighted")
503}
504
505func TestRenderFeedbackUsesSelectedSessionRowWithoutAddingResult(t *testing.T) {
506 m := New([]Result{NewActiveSessionResult("work", time.Unix(0, 1), "work")})
507 frame := m.Render(domain.Size{Cols: 64, Rows: 3}, RenderOptions{Styles: DefaultRenderStyles(), Feedback: "requested session is unavailable"})
508 require.Len(t, m.Matches(), 1)
509 require.Contains(t, frameRow(frame, 1), "requested session is unavailable")
510}
511
512func TestRenderGuidanceReplacesOnlyExactContextualRow(t *testing.T) {
513 jrs := cmd("JRS", "Jump", "Jump to recent session")
514 jrs.Arguments = command.ArgumentsRequired
515 jrs.ContextHint = command.ContextHintRecentSessions
516 m := New(CommandResults([]command.Command{jrs}))
517 for _, r := range "JRS 1" {
518 m.Insert(r)
519 }
520
521 frame := m.Render(domain.Size{Cols: 28, Rows: 4}, RenderOptions{Styles: DefaultRenderStyles(), Guidance: "jump to recent session 1"})
522 if got := frameRow(frame, 1); got != "JRS jump to recent session 1" {
523 t.Fatalf("command row = %q, want contextual guidance without a feedback row", got)
524 }
525}
526
527func TestRenderStylesFillBaseRowsAndSelection(t *testing.T) {
528 m := New(CommandResults([]command.Command{cmd("ABC", "Alpha", "first")}))
529 base := renderer.Style{Foreground: 1, Background: 2}
530 row := renderer.Style{Foreground: 3, Background: 4}
531 selection := renderer.Style{Foreground: 5, Background: 6}
532 frame := m.Render(domain.Size{Cols: 24, Rows: 4}, RenderOptions{Styles: RenderStyles{Base: base, Row: row, Selection: selection, Description: row}})
533
534 require.True(t, frame.At(23, 3).Style.Equal(base), "unused interior keeps modal base")
535 require.True(t, frame.At(23, 0).Style.Equal(base), "input row keeps base surface")
536 require.True(t, frame.At(23, 1).Style.Equal(selection), "selected result owns active surface")
537}