1package picker
2
3import (
4 "strings"
5 "testing"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/protocol"
10 "github.com/bnema/vev/internal/usecase/ui"
11 "github.com/stretchr/testify/require"
12)
13
14// line builds one selectable session line for the shared fixtures.
15func line(key, label string, actions protocol.PickerLineActions) protocol.PickerLine {
16 return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label, Focusable: true, Actions: actions}
17}
18
19func navLine(key, label string) protocol.PickerLine {
20 return line(key, label, protocol.PickerCanNavigate)
21}
22
23func section(label string) protocol.PickerLine {
24 return protocol.PickerLine{Kind: protocol.PickerLineSection, Label: label, Dim: true}
25}
26
27func tabLine(key, label string, actions protocol.PickerLineActions) protocol.PickerLine {
28 return protocol.PickerLine{Key: key, Kind: protocol.PickerLineTab, Label: label, Focusable: true, Actions: actions}
29}
30
31// inspectionLine builds one row the source keeps reachable without authorising
32// any action on it.
33func inspectionLine(key, label string) protocol.PickerLine {
34 return protocol.PickerLine{Key: key, Kind: protocol.PickerLineHost, Label: label, Dim: true, Focusable: true}
35}
36
37func TestNewOrdersLinesAndSelectsTheCursorKey(t *testing.T) {
38 m := New([]protocol.PickerLine{
39 section("LOCAL"),
40 navLine("a/one", "one"),
41 tabLine("a/one#t2", "logs", protocol.PickerCanNavigate),
42 tabLine("a/one#t1", "shell", protocol.PickerCanNavigate),
43 }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "a/one#t1", Index: 3}})
44
45 selected, ok := m.Selected()
46 require.True(t, ok)
47 require.Equal(t, "a/one#t1", selected.Key, "the published cursor key wins over the index hint")
48}
49
50func TestNewSelectsTheNearestFocusableRowForAnUnknownCursor(t *testing.T) {
51 m := New([]protocol.PickerLine{
52 section("LOCAL"),
53 navLine("a/one", "one"),
54 tabLine("a/one#t1", "shell", protocol.PickerCanNavigate),
55 }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "gone", Index: 2}})
56
57 selected, ok := m.Selected()
58 require.True(t, ok)
59 require.Equal(t, "a/one#t1", selected.Key)
60}
61
62func TestUpDownSkipsSectionsAndUnselectableRows(t *testing.T) {
63 m := New([]protocol.PickerLine{
64 navLine("a/one", "one"),
65 // A host status row is focusable but carries no action.
66 inspectionLine("b/host", "example.test"),
67 navLine("c/three", "three"),
68 }, Config{Intent: protocol.PickerIntentNavigation})
69
70 m.SelectNearestRow(0)
71 m.Down()
72 selected, ok := m.Selected()
73 require.False(t, ok, "a row without actions is never selectable")
74 m.Down()
75 selected, ok = m.Selected()
76 require.True(t, ok)
77 require.Equal(t, "c/three", selected.Key)
78 m.Up()
79 cursor, ok := m.Cursor()
80 require.True(t, ok, "the cursor parks on the host row without activating it")
81 require.Equal(t, "b/host", cursor.Key)
82 m.Up()
83 selected, ok = m.Selected()
84 require.True(t, ok)
85 require.Equal(t, "a/one", selected.Key)
86}
87
88func TestSortGroupedPullsNamedLinesAheadOfEphemeralOnes(t *testing.T) {
89 lines := []protocol.PickerLine{
90 {Key: "e/eph", Kind: protocol.PickerLineSession, Label: "eph", Ephemeral: true, Focusable: true, Actions: protocol.PickerCanNavigate},
91 navLine("a/named", "named"),
92 {Key: "e/eph2", Kind: protocol.PickerLineSession, Label: "eph2", Ephemeral: true, Focusable: true, Actions: protocol.PickerCanNavigate},
93 }
94 recent := New(lines, Config{Intent: protocol.PickerIntentNavigation})
95 first, ok := recent.Cursor()
96 require.True(t, ok)
97 require.Equal(t, "e/eph", first.Key, "recency keeps the published order")
98
99 grouped := New(lines, Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
100 first, ok = grouped.Cursor()
101 require.True(t, ok)
102 require.Equal(t, "a/named", first.Key, "grouped moves named sessions ahead")
103}
104
105func TestSetSortKeepsTheSelectedKey(t *testing.T) {
106 m := New([]protocol.PickerLine{
107 {Key: "e/eph", Kind: protocol.PickerLineSession, Label: "eph", Ephemeral: true, Focusable: true, Actions: protocol.PickerCanNavigate},
108 navLine("a/named", "named"),
109 navLine("b/other", "other"),
110 }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "b/other", Index: 2}})
111
112 m.SetSort(SortGrouped)
113 selected, ok := m.Selected()
114 require.True(t, ok)
115 require.Equal(t, "b/other", selected.Key)
116 require.Equal(t, SortGrouped, m.SortMode())
117
118 m.SetSort(SortRecent)
119 selected, ok = m.Selected()
120 require.True(t, ok)
121 require.Equal(t, "b/other", selected.Key)
122}
123
124func TestReplaceLinesKeepsSearchAndCursor(t *testing.T) {
125 m := New([]protocol.PickerLine{
126 navLine("a/one", "one"),
127 navLine("b/two", "two"),
128 }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "b/two", Index: 1}})
129 m.EnterSearch()
130 m.InsertSearch('t')
131
132 m.ReplaceLines([]protocol.PickerLine{
133 navLine("a/one", "one"),
134 navLine("b/two", "two"),
135 navLine("c/three", "three"),
136 }, protocol.PickerCursor{})
137
138 require.True(t, m.SearchActive())
139 require.Equal(t, "t", m.Query())
140 selected, ok := m.Selected()
141 require.True(t, ok)
142 require.Equal(t, "b/two", selected.Key, "the cursor survives a background refresh by key")
143}
144
145func TestSelectedHonoursSearchVisibility(t *testing.T) {
146 m := New([]protocol.PickerLine{
147 navLine("a/one", "one"),
148 navLine("b/two", "two"),
149 }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "a/one"}})
150 m.EnterSearch()
151 m.InsertSearch('z')
152
153 _, ok := m.Selected()
154 require.False(t, ok, "a cursor hidden by the search is never a commit target")
155 require.Equal(t, -1, m.SelectedIndex(), "a query without matches leaves no visible cursor")
156}
157
158func TestRowsTheSourceSkippedAreNeverCursorDestinations(t *testing.T) {
159 m := New([]protocol.PickerLine{
160 // A session header the source did not authorise: rendered, skipped.
161 {Key: "a/one", Kind: protocol.PickerLineSession, Label: "one"},
162 tabLine("a/one#t1", "shell", protocol.PickerCanNavigate),
163 inspectionLine("b/host", "example.test"),
164 }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "a/one", Index: 0}})
165
166 require.Equal(t, "a/one#t1", mustSelectedKey(t, m), "an unauthorised header is not a destination")
167 m.Up()
168 require.Equal(t, "a/one#t1", mustSelectedKey(t, m), "navigation never rests on the header")
169 m.Down()
170 require.Equal(t, "b/host", cursorKey(t, m), "an inspection row stays reachable")
171 _, ok := m.Selected()
172 require.False(t, ok, "an inspection row never commits")
173 m.Down()
174 require.Equal(t, "b/host", cursorKey(t, m), "the last eligible row holds the cursor")
175}
176
177func TestSearchWithoutMatchesClearsTheCursorAndMovementFollows(t *testing.T) {
178 m := New([]protocol.PickerLine{
179 navLine("a/one", "one"),
180 navLine("b/two", "two"),
181 navLine("c/three", "three"),
182 }, Config{Intent: protocol.PickerIntentNavigation})
183 m.EnterSearch()
184 m.InsertSearch('t')
185 require.Equal(t, 2, m.MatchCount())
186 m.Down()
187 require.Equal(t, "c/three", mustSelectedKey(t, m), "search movement only visits matching rows")
188 m.InsertSearch('z')
189 require.Equal(t, 0, m.MatchCount())
190 require.Equal(t, -1, m.SelectedIndex())
191 m.Down()
192 require.Equal(t, -1, m.SelectedIndex(), "movement cannot resurrect a hidden cursor")
193 m.BackspaceSearch()
194 require.Equal(t, 2, m.MatchCount())
195 require.Equal(t, "b/two", mustSelectedKey(t, m), "editing the query re-places the cursor on a shown row")
196 m.ClearSearch()
197 require.Equal(t, 3, m.MatchCount(), "an empty query shows every eligible row again")
198 require.Equal(t, "b/two", mustSelectedKey(t, m))
199}
200
201// mustSelectedKey reports the committed key, failing when the cursor holds no
202// committable row.
203func mustSelectedKey(t *testing.T, m *Model) string {
204 t.Helper()
205 selected, ok := m.Selected()
206 require.True(t, ok, "the cursor must hold a committable row")
207 return selected.Key
208}
209
210// cursorKey reports the raw cursor row key independently of committability.
211func cursorKey(t *testing.T, m *Model) string {
212 t.Helper()
213 line, ok := m.Cursor()
214 require.True(t, ok)
215 return line.Key
216}
217
218func TestSelectNearestRowSnapsForwardThenBackward(t *testing.T) {
219 m := New([]protocol.PickerLine{
220 navLine("a/one", "one"),
221 section("LOCAL"),
222 navLine("b/two", "two"),
223 }, Config{Intent: protocol.PickerIntentNavigation})
224
225 m.SelectNearestRow(1)
226 selected, ok := m.Selected()
227 require.True(t, ok)
228 require.Equal(t, "b/two", selected.Key)
229 m.SelectNearestRow(40)
230 selected, ok = m.Selected()
231 require.True(t, ok)
232 require.Equal(t, "b/two", selected.Key)
233 m.SelectNearestRow(-3)
234 selected, ok = m.Selected()
235 require.True(t, ok)
236 require.Equal(t, "a/one", selected.Key)
237}
238
239func TestCloneIsIndependent(t *testing.T) {
240 m := New([]protocol.PickerLine{navLine("a/one", "one"), navLine("b/two", "two")}, Config{Intent: protocol.PickerIntentNavigation})
241 clone := m.Clone()
242 clone.Down()
243 original, ok := m.Selected()
244 require.True(t, ok)
245 cloneSelected, ok := clone.Selected()
246 require.True(t, ok)
247 require.Equal(t, "a/one", original.Key)
248 require.Equal(t, "b/two", cloneSelected.Key)
249}
250
251func TestIntentIsReported(t *testing.T) {
252 m := New([]protocol.PickerLine{tabLine("a/one#t1", "shell", protocol.PickerCanMove)}, Config{Intent: protocol.PickerIntentMovePane})
253 require.Equal(t, protocol.PickerIntentMovePane, m.Intent())
254 require.Equal(t, " Sessions · recent ", SortRecent.Title())
255 require.Equal(t, " Sessions · grouped ", SortGrouped.Title())
256}
257
258func TestChooseLayoutResponsiveBoundaries(t *testing.T) {
259 tests := []struct {
260 name string
261 size domain.Size
262 mode LayoutMode
263 list domain.Rect
264 preview domain.Rect
265 }{
266 {name: "too small", size: domain.Size{Cols: 0, Rows: 0}, mode: LayoutListOnly},
267 {name: "list only", size: domain.Size{Cols: 40, Rows: 10}, mode: LayoutListOnly, list: domain.Rect{Width: 40, Height: 10}},
268 {name: "stacked", size: domain.Size{Cols: 40, Rows: 20}, mode: LayoutStacked, list: domain.Rect{Width: 40, Height: 8}, preview: domain.Rect{Y: 9, Width: 40, Height: 11}},
269 {name: "horizontal", size: domain.Size{Cols: 120, Rows: 20}, mode: LayoutHorizontal, list: domain.Rect{Width: 44, Height: 20}, preview: domain.Rect{X: 45, Width: 75, Height: 20}},
270 }
271 for _, tt := range tests {
272 t.Run(tt.name, func(t *testing.T) {
273 layout := ChooseLayout(tt.size)
274 require.Equal(t, tt.mode, layout.Mode)
275 require.Equal(t, tt.list, layout.List)
276 require.Equal(t, tt.preview, layout.Preview)
277 })
278 }
279}
280
281func TestChooseGeometryReservesTheStatusRow(t *testing.T) {
282 geometry := ChooseGeometry(domain.Size{Cols: 80, Rows: 24})
283 require.Equal(t, domain.Rect{Width: 80, Height: 23}, geometry.Content)
284 require.Equal(t, domain.Rect{Y: 23, Width: 80, Height: 1}, geometry.Status)
285}
286
287func TestRenderDrawsProblemDotsOnly(t *testing.T) {
288 m := New([]protocol.PickerLine{
289 {Kind: protocol.PickerLineSection, Label: "host-a", Status: protocol.PickerLineStatusDown},
290 {
291 Key: "a/broken", Kind: protocol.PickerLineSession, Label: "broken", Focusable: true, Actions: protocol.PickerCanNavigate,
292 Status: protocol.PickerLineStatusError, Detail: "2",
293 },
294 {
295 Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate,
296 Status: protocol.PickerLineStatusUp, Detail: "2",
297 },
298 {
299 Key: "b/old", Kind: protocol.PickerLineSession, Label: "old", Stopped: true,
300 Status: protocol.PickerLineStatusStopped, Detail: "stopped", Focusable: true, Actions: protocol.PickerCanNavigate,
301 },
302 }, Config{Intent: protocol.PickerIntentNavigation})
303
304 frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{})
305 require.Equal(t, 60, frame.Width)
306 require.Equal(t, 8, frame.Height)
307 for row, want := range map[int]int{0: 167, 1: 167} {
308 text := []rune(strings.TrimRight(rowText(frame.Row(row)), " "))
309 require.Equal(t, '●', text[len(text)-1], "row %d has a problem dot", row)
310 dot := frame.At(len(text)-1, row).Style
311 require.Equal(t, want, dot.Foreground, "row %d dot color", row)
312 require.False(t, dot.Inverse, "row %d dot is never inverted, even when selected", row)
313 }
314 require.True(t, frame.At(0, 1).Style.Inverse, "the broken session is the selected row")
315 for _, row := range []int{2, 3} {
316 require.NotContains(t, rowText(frame.Row(row)), "●", "healthy and stopped rows carry no dot")
317 }
318}
319
320func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) {
321 m := New([]protocol.PickerLine{{
322 Key: "h/remote", Kind: protocol.PickerLineHost, Label: "remote", Detail: "0 sessions",
323 Status: protocol.PickerLineStatusNoDaemon, StatusDetail: "no daemon — Enter to create a session",
324 Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate,
325 }}, Config{Intent: protocol.PickerIntentNavigation})
326 frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{})
327 require.Contains(t, rowText(frame.Row(0)), "●")
328 require.Contains(t, rowText(frame.Row(7)), "no daemon")
329 require.Contains(t, rowText(frame.Row(7)), "Enter to create a session")
330 require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable")
331}
332
333func TestRenderBlitsThePreviewIntoThePreviewRect(t *testing.T) {
334 m := New([]protocol.PickerLine{navLine("a/one", "one")}, Config{Intent: protocol.PickerIntentNavigation})
335 preview := Preview{Rows: [][]renderer.Cell{{{Rune: 'X'}}}, Width: 1, Height: 1}
336 frame := m.Render(domain.Size{Cols: 120, Rows: 12}, preview)
337 geometry := ChooseGeometry(domain.Size{Cols: 120, Rows: 12})
338 x := geometry.Preview.X
339 y := geometry.Preview.Y + geometry.Preview.Height - 1
340 require.Equal(t, 'X', frame.Cell(x, y).Rune)
341}
342
343func TestRenderAttentionMarkerFollowsTheTabName(t *testing.T) {
344 m := New([]protocol.PickerLine{
345 tabLine("a/one#t1", "shell", protocol.PickerCanNavigate),
346 {Key: "a/one#t2", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate},
347 }, Config{Intent: protocol.PickerIntentNavigation})
348 frame := m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{})
349 require.Contains(t, rowText(frame.Row(1)), "build")
350 require.NotEqual(t, rowText(frame.Row(0)), rowText(frame.Row(1)))
351}
352
353// TestRenderAttentionBellOnHeadersAndTabs pins Plan 003 C1: a session header
354// and a tab row both draw the bell right after their name when they ring, and
355// neither draws it otherwise.
356func TestRenderAttentionBellOnHeadersAndTabs(t *testing.T) {
357 bell := string(ui.AttentionGlyph)
358 tests := []struct {
359 name string
360 line protocol.PickerLine
361 want bool
362 }{
363 {name: "ringing header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached", Attention: true}, want: true},
364 {name: "quiet header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached"}},
365 {name: "ringing tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate}, want: true},
366 {name: "quiet tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Focusable: true, Actions: protocol.PickerCanNavigate}},
367 }
368 for _, tt := range tests {
369 t.Run(tt.name, func(t *testing.T) {
370 m := New([]protocol.PickerLine{tt.line}, Config{Intent: protocol.PickerIntentNavigation})
371 text := rowText(m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{}).Row(0))
372 require.Equal(t, tt.want, strings.Contains(text, tt.line.Label+" "+bell), "row %q", text)
373 })
374 }
375}
376
377func TestSearchMatchesLabelsAndDetails(t *testing.T) {
378 m := New([]protocol.PickerLine{
379 navLine("a/one", "one"),
380 navLine("b/two", "two"),
381 {Key: "c/three", Kind: protocol.PickerLineTab, Label: "three", Detail: " (vim)", Focusable: true, Actions: protocol.PickerCanNavigate},
382 }, Config{Intent: protocol.PickerIntentNavigation})
383 m.EnterSearch()
384 m.InsertSearch('v')
385 m.InsertSearch('i')
386 m.InsertSearch('m')
387
388 require.Equal(t, 1, m.MatchCount())
389 selected, ok := m.Selected()
390 require.True(t, ok)
391 require.Equal(t, "c/three", selected.Key)
392 m.BackspaceSearch()
393 require.Equal(t, "vi", m.Query())
394 m.ClearSearch()
395 require.Equal(t, "", m.Query())
396 require.True(t, m.SearchActive(), "clearing the query keeps the search editor open")
397 m.ExitSearch()
398 require.False(t, m.SearchActive())
399}
400
401// TestSearchSessionNameReachesItsTabRows pins that a session with tabs, whose
402// header is not itself a destination, is found by its name: the query matches
403// the tab rows that are its destinations, never another session's tabs.
404func TestSearchSessionNameReachesItsTabRows(t *testing.T) {
405 tab := func(key, label string) protocol.PickerLine {
406 return protocol.PickerLine{Key: key, Kind: protocol.PickerLineTab, Label: label, Focusable: true, Actions: protocol.PickerCanNavigate}
407 }
408 session := func(key, label string) protocol.PickerLine {
409 return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label}
410 }
411 lines := []protocol.PickerLine{
412 session("a", "picka1234"), tab("a/1", "1"),
413 session("b", "pickb1234"), tab("b/1", "1"), tab("b/2", "logs"),
414 }
415 tests := []struct {
416 name string
417 query string
418 wantMatches int
419 wantKey string
420 }{
421 {name: "exact session name", query: "pickb1234", wantMatches: 2, wantKey: "b/1"},
422 {name: "session name prefix", query: "picka", wantMatches: 1, wantKey: "a/1"},
423 {name: "tab label still matches", query: "logs", wantMatches: 1, wantKey: "b/2"},
424 {name: "no session of that name", query: "pickc", wantMatches: 0},
425 }
426 for _, tt := range tests {
427 t.Run(tt.name, func(t *testing.T) {
428 m := New(lines, Config{Intent: protocol.PickerIntentNavigation})
429 m.EnterSearch()
430 for _, r := range tt.query {
431 m.InsertSearch(r)
432 }
433 require.Equal(t, tt.wantMatches, m.MatchCount())
434 selected, ok := m.Selected()
435 if tt.wantKey == "" {
436 require.False(t, ok)
437 return
438 }
439 require.True(t, ok)
440 require.Equal(t, tt.wantKey, selected.Key)
441 })
442 }
443}
444
445func rowText(row []renderer.Cell) string {
446 out := make([]rune, 0, len(row))
447 for _, cell := range row {
448 out = append(out, cell.Rune)
449 }
450 return string(out)
451}
452
453func TestReplaceLinesSelectsThePreviousRowWhenTheCursorRowLeaves(t *testing.T) {
454 all := []protocol.PickerLine{navLine("a", "a"), navLine("b", "b"), navLine("c", "c"), navLine("d", "d")}
455 tests := []struct {
456 name string
457 cursor string
458 next []protocol.PickerLine
459 want string
460 }{
461 {name: "middle row killed", cursor: "c", next: []protocol.PickerLine{all[0], all[1], all[3]}, want: "b"},
462 {name: "last row killed", cursor: "d", next: all[:3], want: "c"},
463 {name: "first row killed falls to the nearest", cursor: "a", next: all[1:], want: "b"},
464 {name: "unchanged row keeps the cursor", cursor: "c", next: all, want: "c"},
465 }
466 for _, tt := range tests {
467 t.Run(tt.name, func(t *testing.T) {
468 m := New(all, Config{Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}})
469 m.ReplaceLines(tt.next, protocol.PickerCursor{Index: -1})
470 selected, ok := m.Selected()
471 require.True(t, ok)
472 require.Equal(t, tt.want, selected.Key)
473 })
474 }
475}
476
477func TestTreePrefixesBranchSessionsAndTabs(t *testing.T) {
478 m := New([]protocol.PickerLine{
479 section("local"),
480 navLine("a", "alpha"), tabLine("a1", "one", protocol.PickerCanNavigate), tabLine("a2", "two", protocol.PickerCanNavigate),
481 navLine("b", "beta"), tabLine("b1", "one", protocol.PickerCanNavigate),
482 section("remote"),
483 navLine("c", "gamma"),
484 }, Config{})
485 got := make([]string, 0, len(m.rows))
486 for _, r := range m.rows {
487 got = append(got, r.tree+r.line.Label)
488 }
489 require.Equal(t, []string{
490 "local",
491 "├─ alpha", "│ ├─ one", "│ └─ two",
492 "└─ beta", " └─ one",
493 "remote",
494 "└─ gamma",
495 }, got)
496
497 frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
498 cell := frame.At(0, 1)
499 require.Equal(t, '├', cell.Rune)
500 require.NotZero(t, cell.Style.Attrs&renderer.AttrDim, "the selected row keeps its dimmed tree")
501
502 cell = frame.At(0, 4) // "└─ beta", not selected
503 require.Equal(t, '└', cell.Rune)
504 // A fixed grey, not SGR dim: some terminals barely dim box drawing.
505 require.Equal(t, 244, cell.Style.Foreground, "tree lines use a readable grey")
506 require.False(t, cell.Style.HasForegroundRGB)
507 require.Zero(t, cell.Style.Attrs&renderer.AttrDim)
508}
509
510func TestTreeStyleKeepsRowDimming(t *testing.T) {
511 tests := []struct {
512 name string
513 lines []protocol.PickerLine
514 query string
515 }{
516 {name: "dim line", lines: func() []protocol.PickerLine {
517 dim := navLine("b", "beta")
518 dim.Dim = true
519 return []protocol.PickerLine{section("local"), navLine("a", "alpha"), dim}
520 }()},
521 {name: "search miss", lines: []protocol.PickerLine{section("local"), navLine("a", "alpha"), navLine("b", "beta")}, query: "alp"},
522 }
523 for _, tt := range tests {
524 t.Run(tt.name, func(t *testing.T) {
525 m := New(tt.lines, Config{})
526 if tt.query != "" {
527 m.EnterSearch()
528 for _, r := range tt.query {
529 m.InsertSearch(r)
530 }
531 }
532 frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
533 cell := frame.At(0, 2) // "└─ beta", not selected
534 require.Equal(t, '└', cell.Rune)
535 require.Equal(t, 244, cell.Style.Foreground)
536 require.NotZero(t, cell.Style.Attrs&renderer.AttrDim, "a dimmed row keeps its tree dimmed")
537 })
538 }
539}
540
541func TestDetailLeadingSpaceIsTrimmed(t *testing.T) {
542 tab := tabLine("a1", "1", protocol.PickerCanNavigate)
543 tab.Detail = " (fish)"
544 m := New([]protocol.PickerLine{section("local"), navLine("a", "alpha"), tab}, Config{})
545 frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
546 row := ""
547 for x := range 14 {
548 row += string(frame.At(x, 2).Rune)
549 }
550 require.Equal(t, " └─ 1 (fish)", row, "one gap, not two")
551}
552
553func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) {
554 m := New([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "alps")}, Config{Cursor: protocol.PickerCursor{Key: "c", Index: -1}})
555 m.EnterSearch()
556 for _, r := range "al" {
557 m.InsertSearch(r)
558 }
559 m.Down() // alpha -> alps: beta is hidden by the query
560 selected, ok := m.Selected()
561 require.True(t, ok)
562 require.Equal(t, "c", selected.Key)
563
564 m.ReplaceLines([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta")}, protocol.PickerCursor{Index: -1})
565
566 selected, ok = m.Selected()
567 require.True(t, ok)
568 require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one")
569}