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 }
522 for _, tt := range tests {
523 t.Run(tt.name, func(t *testing.T) {
524 m := New(tt.lines, Config{})
525 if tt.query != "" {
526 m.EnterSearch()
527 for _, r := range tt.query {
528 m.InsertSearch(r)
529 }
530 }
531 frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
532 cell := frame.At(0, 2) // "└─ beta", not selected
533 require.Equal(t, '└', cell.Rune)
534 require.Equal(t, 244, cell.Style.Foreground)
535 require.NotZero(t, cell.Style.Attrs&renderer.AttrDim, "a dimmed row keeps its tree dimmed")
536 })
537 }
538}
539
540func TestDetailLeadingSpaceIsTrimmed(t *testing.T) {
541 tab := tabLine("a1", "1", protocol.PickerCanNavigate)
542 tab.Detail = " (fish)"
543 m := New([]protocol.PickerLine{section("local"), navLine("a", "alpha"), tab}, Config{})
544 frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{})
545 row := ""
546 for x := range 14 {
547 row += string(frame.At(x, 2).Rune)
548 }
549 require.Equal(t, " └─ 1 (fish)", row, "one gap, not two")
550}
551
552func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) {
553 m := New([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "alps")}, Config{Cursor: protocol.PickerCursor{Key: "c", Index: -1}})
554 m.EnterSearch()
555 for _, r := range "al" {
556 m.InsertSearch(r)
557 }
558 m.Down() // alpha -> alps: beta is hidden by the query
559 selected, ok := m.Selected()
560 require.True(t, ok)
561 require.Equal(t, "c", selected.Key)
562
563 m.ReplaceLines([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta")}, protocol.PickerCursor{Index: -1})
564
565 selected, ok = m.Selected()
566 require.True(t, ok)
567 require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one")
568}
569
570func TestReplaceLinesPreviousRowWhenTheCursorRowStopsMatching(t *testing.T) {
571 m := New([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "alps"), navLine("d", "altitude")}, Config{Cursor: protocol.PickerCursor{Key: "c", Index: -1}})
572 m.EnterSearch()
573 for _, r := range "al" {
574 m.InsertSearch(r)
575 }
576 m.Down() // alpha -> alps
577 require.Equal(t, "c", mustSelectedKey(t, m))
578
579 // c is still published but no longer matches; the source hints the last row.
580 m.ReplaceLines([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "cliff"), navLine("d", "altitude")}, protocol.PickerCursor{Index: 3})
581
582 require.Equal(t, "a", mustSelectedKey(t, m), "the nearest matching row above wins over the source hint")
583}
584
585// drawnRows reports the tree prefix and label of every drawn row.
586func drawnRows(m *Model) []string {
587 got := make([]string, 0, len(m.view))
588 for _, i := range m.view {
589 got = append(got, m.rows[i].tree+m.rows[i].line.Label)
590 }
591 return got
592}
593
594func foldFixture() []protocol.PickerLine {
595 session := func(key, label string) protocol.PickerLine {
596 return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label}
597 }
598 return []protocol.PickerLine{
599 section("LOCAL"),
600 session("w", "work"), tabLine("w1", "shell", protocol.PickerCanNavigate), tabLine("w2", "logs", protocol.PickerCanNavigate),
601 session("s", "scratch"), tabLine("s1", "apply-migrations", protocol.PickerCanNavigate),
602 section("devbox"),
603 session("a", "api"), tabLine("a1", "server", protocol.PickerCanNavigate), tabLine("a2", "db", protocol.PickerCanNavigate),
604 }
605}
606
607func TestSectionFolding(t *testing.T) {
608 tests := []struct {
609 name string
610 keys func(m *Model)
611 wantRows []string
612 wantCursor string
613 }{
614 {
615 name: "left from a tab climbs to its section",
616 keys: func(m *Model) { m.Left() },
617 wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
618 wantCursor: "\x00section:0:LOCAL",
619 },
620 {
621 name: "left on a section collapses it",
622 keys: func(m *Model) { m.Left(); m.Left() },
623 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
624 wantCursor: "\x00section:0:LOCAL",
625 },
626 {
627 name: "down from a collapsed section skips its rows",
628 keys: func(m *Model) { m.Left(); m.Left(); m.Down() },
629 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
630 wantCursor: "\x00section:0:devbox",
631 },
632 {
633 name: "right expands it again",
634 keys: func(m *Model) { m.Left(); m.Left(); m.Right() },
635 wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
636 wantCursor: "\x00section:0:LOCAL",
637 },
638 {
639 name: "toggle collapses and expands",
640 keys: func(m *Model) { m.Left(); m.ToggleSection() },
641 wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"},
642 wantCursor: "\x00section:0:LOCAL",
643 },
644 {
645 name: "right off a section does nothing",
646 keys: func(m *Model) { m.Right() },
647 wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"},
648 wantCursor: "w1",
649 },
650 }
651 for _, tt := range tests {
652 t.Run(tt.name, func(t *testing.T) {
653 m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
654 require.Equal(t, "w1", cursorKey(t, m), "a section header never takes the initial cursor")
655 tt.keys(m)
656 require.Equal(t, tt.wantRows, drawnRows(m))
657 key, _ := m.cursorKey()
658 require.Equal(t, tt.wantCursor, key)
659 })
660 }
661}
662
663func TestSectionFoldSurvivesRepublication(t *testing.T) {
664 m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
665 m.Left()
666 m.Left()
667 m.ReplaceLines(foldFixture(), protocol.PickerCursor{Index: -1})
668 require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
669 key, _ := m.cursorKey()
670 require.Equal(t, "\x00section:0:LOCAL", key)
671 _, ok := m.Selected()
672 require.False(t, ok, "a section header is never committable")
673
674 frame := m.Render(domain.Size{Cols: 40, Rows: 10}, Preview{})
675 require.True(t, strings.HasPrefix(rowText(frame.Row(0)), "▸ LOCAL (2)"), "collapsed header %q", rowText(frame.Row(0)))
676 require.True(t, strings.HasPrefix(rowText(frame.Row(1)), "▾ devbox"), "expanded header %q", rowText(frame.Row(1)))
677}
678
679func TestSearchShowsOnlyMatchesInTreeOrder(t *testing.T) {
680 tests := []struct {
681 name string
682 query string
683 wantRows []string
684 wantCursor string
685 }{
686 {name: "matches keep their ancestors", query: "ap", wantRows: []string{"LOCAL", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "s1"},
687 {name: "one tab match", query: "serv", wantRows: []string{"devbox", "└─ api", " └─ server"}, wantCursor: "a1"},
688 {name: "no match draws nothing", query: "zzz", wantRows: []string{}},
689 }
690 for _, tt := range tests {
691 t.Run(tt.name, func(t *testing.T) {
692 m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
693 m.Down()
694 m.Down() // somewhere in the middle: typing still starts on the first match
695 // A collapsed section never hides a match.
696 m.Left()
697 m.Left()
698 m.EnterSearch()
699 for _, r := range tt.query {
700 m.InsertSearch(r)
701 }
702 require.Equal(t, tt.wantRows, drawnRows(m))
703 if tt.wantCursor == "" {
704 require.Equal(t, -1, m.SelectedIndex())
705 return
706 }
707 require.Equal(t, tt.wantCursor, mustSelectedKey(t, m))
708 })
709 }
710}
711
712func TestExitSearchRestoresFolds(t *testing.T) {
713 m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
714 m.Left()
715 m.Left()
716 m.EnterSearch()
717 m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL
718 m.ExitSearch()
719 require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m))
720 key, _ := m.cursorKey()
721 require.Equal(t, "\x00section:0:LOCAL", key, "a match a fold hides leaves the cursor on its section")
722}
723
724func TestRemovedRowNeverHandsTheCursorToASection(t *testing.T) {
725 tests := []struct {
726 name string
727 cursor string
728 drop string
729 want string
730 }{
731 {name: "first tab under LOCAL", cursor: "w1", drop: "w1", want: "w2"},
732 {name: "first tab under a remote section", cursor: "a1", drop: "a1", want: "s1"},
733 }
734 for _, tt := range tests {
735 t.Run(tt.name, func(t *testing.T) {
736 m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped, Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}})
737 next := make([]protocol.PickerLine, 0)
738 for _, line := range foldFixture() {
739 if line.Key != tt.drop {
740 next = append(next, line)
741 }
742 }
743 m.ReplaceLines(next, protocol.PickerCursor{Index: -1})
744 require.Equal(t, tt.want, mustSelectedKey(t, m))
745 })
746 }
747}
748
749func TestSameLabelSectionsFoldApart(t *testing.T) {
750 m := New([]protocol.PickerLine{
751 section("remote"), navLine("a", "alpha"),
752 section("remote"), navLine("b", "beta"),
753 }, Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
754 m.Left()
755 m.Left()
756 require.Equal(t, []string{"remote", "remote", "└─ beta"}, drawnRows(m))
757}
758
759func TestClearSearchKeepsTheCursorOnADrawnRow(t *testing.T) {
760 m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
761 m.Left()
762 m.Left()
763 m.EnterSearch()
764 m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL
765 m.ClearSearch()
766 require.False(t, m.hidden[m.SelectedIndex()], "the cursor rests on a drawn row")
767}
768
769func TestSectionFooterHint(t *testing.T) {
770 tests := []struct {
771 name string
772 search bool
773 want string
774 }{
775 {name: "normal mode offers h/l", want: "Enter/h/l fold"},
776 {name: "search mode types h/l", search: true, want: "Enter fold"},
777 }
778 for _, tt := range tests {
779 t.Run(tt.name, func(t *testing.T) {
780 m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped})
781 m.Left()
782 if tt.search {
783 m.EnterSearch()
784 }
785 frame := m.Render(domain.Size{Cols: 80, Rows: 12}, Preview{})
786 status := rowText(frame.Row(11))
787 require.Contains(t, status, tt.want)
788 if tt.search {
789 require.NotContains(t, status, "h/l")
790 }
791 })
792 }
793}