package picker import ( "strings" "testing" renderer "github.com/bnema/vev-vt" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/protocol" "github.com/bnema/vev/internal/usecase/ui" "github.com/stretchr/testify/require" ) // line builds one selectable session line for the shared fixtures. func line(key, label string, actions protocol.PickerLineActions) protocol.PickerLine { return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label, Focusable: true, Actions: actions} } func navLine(key, label string) protocol.PickerLine { return line(key, label, protocol.PickerCanNavigate) } func section(label string) protocol.PickerLine { return protocol.PickerLine{Kind: protocol.PickerLineSection, Label: label, Dim: true} } func tabLine(key, label string, actions protocol.PickerLineActions) protocol.PickerLine { return protocol.PickerLine{Key: key, Kind: protocol.PickerLineTab, Label: label, Focusable: true, Actions: actions} } // inspectionLine builds one row the source keeps reachable without authorising // any action on it. func inspectionLine(key, label string) protocol.PickerLine { return protocol.PickerLine{Key: key, Kind: protocol.PickerLineHost, Label: label, Dim: true, Focusable: true} } func TestNewOrdersLinesAndSelectsTheCursorKey(t *testing.T) { m := New([]protocol.PickerLine{ section("LOCAL"), navLine("a/one", "one"), tabLine("a/one#t2", "logs", protocol.PickerCanNavigate), tabLine("a/one#t1", "shell", protocol.PickerCanNavigate), }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "a/one#t1", Index: 3}}) selected, ok := m.Selected() require.True(t, ok) require.Equal(t, "a/one#t1", selected.Key, "the published cursor key wins over the index hint") } func TestNewSelectsTheNearestFocusableRowForAnUnknownCursor(t *testing.T) { m := New([]protocol.PickerLine{ section("LOCAL"), navLine("a/one", "one"), tabLine("a/one#t1", "shell", protocol.PickerCanNavigate), }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "gone", Index: 2}}) selected, ok := m.Selected() require.True(t, ok) require.Equal(t, "a/one#t1", selected.Key) } func TestUpDownSkipsSectionsAndUnselectableRows(t *testing.T) { m := New([]protocol.PickerLine{ navLine("a/one", "one"), // A host status row is focusable but carries no action. inspectionLine("b/host", "example.test"), navLine("c/three", "three"), }, Config{Intent: protocol.PickerIntentNavigation}) m.SelectNearestRow(0) m.Down() selected, ok := m.Selected() require.False(t, ok, "a row without actions is never selectable") m.Down() selected, ok = m.Selected() require.True(t, ok) require.Equal(t, "c/three", selected.Key) m.Up() cursor, ok := m.Cursor() require.True(t, ok, "the cursor parks on the host row without activating it") require.Equal(t, "b/host", cursor.Key) m.Up() selected, ok = m.Selected() require.True(t, ok) require.Equal(t, "a/one", selected.Key) } func TestSortGroupedPullsNamedLinesAheadOfEphemeralOnes(t *testing.T) { lines := []protocol.PickerLine{ {Key: "e/eph", Kind: protocol.PickerLineSession, Label: "eph", Ephemeral: true, Focusable: true, Actions: protocol.PickerCanNavigate}, navLine("a/named", "named"), {Key: "e/eph2", Kind: protocol.PickerLineSession, Label: "eph2", Ephemeral: true, Focusable: true, Actions: protocol.PickerCanNavigate}, } recent := New(lines, Config{Intent: protocol.PickerIntentNavigation}) first, ok := recent.Cursor() require.True(t, ok) require.Equal(t, "e/eph", first.Key, "recency keeps the published order") grouped := New(lines, Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) first, ok = grouped.Cursor() require.True(t, ok) require.Equal(t, "a/named", first.Key, "grouped moves named sessions ahead") } func TestSetSortKeepsTheSelectedKey(t *testing.T) { m := New([]protocol.PickerLine{ {Key: "e/eph", Kind: protocol.PickerLineSession, Label: "eph", Ephemeral: true, Focusable: true, Actions: protocol.PickerCanNavigate}, navLine("a/named", "named"), navLine("b/other", "other"), }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "b/other", Index: 2}}) m.SetSort(SortGrouped) selected, ok := m.Selected() require.True(t, ok) require.Equal(t, "b/other", selected.Key) require.Equal(t, SortGrouped, m.SortMode()) m.SetSort(SortRecent) selected, ok = m.Selected() require.True(t, ok) require.Equal(t, "b/other", selected.Key) } func TestReplaceLinesKeepsSearchAndCursor(t *testing.T) { m := New([]protocol.PickerLine{ navLine("a/one", "one"), navLine("b/two", "two"), }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "b/two", Index: 1}}) m.EnterSearch() m.InsertSearch('t') m.ReplaceLines([]protocol.PickerLine{ navLine("a/one", "one"), navLine("b/two", "two"), navLine("c/three", "three"), }, protocol.PickerCursor{}) require.True(t, m.SearchActive()) require.Equal(t, "t", m.Query()) selected, ok := m.Selected() require.True(t, ok) require.Equal(t, "b/two", selected.Key, "the cursor survives a background refresh by key") } func TestSelectedHonoursSearchVisibility(t *testing.T) { m := New([]protocol.PickerLine{ navLine("a/one", "one"), navLine("b/two", "two"), }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "a/one"}}) m.EnterSearch() m.InsertSearch('z') _, ok := m.Selected() require.False(t, ok, "a cursor hidden by the search is never a commit target") require.Equal(t, -1, m.SelectedIndex(), "a query without matches leaves no visible cursor") } func TestRowsTheSourceSkippedAreNeverCursorDestinations(t *testing.T) { m := New([]protocol.PickerLine{ // A session header the source did not authorise: rendered, skipped. {Key: "a/one", Kind: protocol.PickerLineSession, Label: "one"}, tabLine("a/one#t1", "shell", protocol.PickerCanNavigate), inspectionLine("b/host", "example.test"), }, Config{Intent: protocol.PickerIntentNavigation, Cursor: protocol.PickerCursor{Key: "a/one", Index: 0}}) require.Equal(t, "a/one#t1", mustSelectedKey(t, m), "an unauthorised header is not a destination") m.Up() require.Equal(t, "a/one#t1", mustSelectedKey(t, m), "navigation never rests on the header") m.Down() require.Equal(t, "b/host", cursorKey(t, m), "an inspection row stays reachable") _, ok := m.Selected() require.False(t, ok, "an inspection row never commits") m.Down() require.Equal(t, "b/host", cursorKey(t, m), "the last eligible row holds the cursor") } func TestSearchWithoutMatchesClearsTheCursorAndMovementFollows(t *testing.T) { m := New([]protocol.PickerLine{ navLine("a/one", "one"), navLine("b/two", "two"), navLine("c/three", "three"), }, Config{Intent: protocol.PickerIntentNavigation}) m.EnterSearch() m.InsertSearch('t') require.Equal(t, 2, m.MatchCount()) m.Down() require.Equal(t, "c/three", mustSelectedKey(t, m), "search movement only visits matching rows") m.InsertSearch('z') require.Equal(t, 0, m.MatchCount()) require.Equal(t, -1, m.SelectedIndex()) m.Down() require.Equal(t, -1, m.SelectedIndex(), "movement cannot resurrect a hidden cursor") m.BackspaceSearch() require.Equal(t, 2, m.MatchCount()) require.Equal(t, "b/two", mustSelectedKey(t, m), "editing the query re-places the cursor on a shown row") m.ClearSearch() require.Equal(t, 3, m.MatchCount(), "an empty query shows every eligible row again") require.Equal(t, "b/two", mustSelectedKey(t, m)) } // mustSelectedKey reports the committed key, failing when the cursor holds no // committable row. func mustSelectedKey(t *testing.T, m *Model) string { t.Helper() selected, ok := m.Selected() require.True(t, ok, "the cursor must hold a committable row") return selected.Key } // cursorKey reports the raw cursor row key independently of committability. func cursorKey(t *testing.T, m *Model) string { t.Helper() line, ok := m.Cursor() require.True(t, ok) return line.Key } func TestSelectNearestRowSnapsForwardThenBackward(t *testing.T) { m := New([]protocol.PickerLine{ navLine("a/one", "one"), section("LOCAL"), navLine("b/two", "two"), }, Config{Intent: protocol.PickerIntentNavigation}) m.SelectNearestRow(1) selected, ok := m.Selected() require.True(t, ok) require.Equal(t, "b/two", selected.Key) m.SelectNearestRow(40) selected, ok = m.Selected() require.True(t, ok) require.Equal(t, "b/two", selected.Key) m.SelectNearestRow(-3) selected, ok = m.Selected() require.True(t, ok) require.Equal(t, "a/one", selected.Key) } func TestCloneIsIndependent(t *testing.T) { m := New([]protocol.PickerLine{navLine("a/one", "one"), navLine("b/two", "two")}, Config{Intent: protocol.PickerIntentNavigation}) clone := m.Clone() clone.Down() original, ok := m.Selected() require.True(t, ok) cloneSelected, ok := clone.Selected() require.True(t, ok) require.Equal(t, "a/one", original.Key) require.Equal(t, "b/two", cloneSelected.Key) } func TestIntentIsReported(t *testing.T) { m := New([]protocol.PickerLine{tabLine("a/one#t1", "shell", protocol.PickerCanMove)}, Config{Intent: protocol.PickerIntentMovePane}) require.Equal(t, protocol.PickerIntentMovePane, m.Intent()) require.Equal(t, " Sessions · recent ", SortRecent.Title()) require.Equal(t, " Sessions · grouped ", SortGrouped.Title()) } func TestChooseLayoutResponsiveBoundaries(t *testing.T) { tests := []struct { name string size domain.Size mode LayoutMode list domain.Rect preview domain.Rect }{ {name: "too small", size: domain.Size{Cols: 0, Rows: 0}, mode: LayoutListOnly}, {name: "list only", size: domain.Size{Cols: 40, Rows: 10}, mode: LayoutListOnly, list: domain.Rect{Width: 40, Height: 10}}, {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}}, {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}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { layout := ChooseLayout(tt.size) require.Equal(t, tt.mode, layout.Mode) require.Equal(t, tt.list, layout.List) require.Equal(t, tt.preview, layout.Preview) }) } } func TestChooseGeometryReservesTheStatusRow(t *testing.T) { geometry := ChooseGeometry(domain.Size{Cols: 80, Rows: 24}) require.Equal(t, domain.Rect{Width: 80, Height: 23}, geometry.Content) require.Equal(t, domain.Rect{Y: 23, Width: 80, Height: 1}, geometry.Status) } func TestRenderDrawsProblemDotsOnly(t *testing.T) { m := New([]protocol.PickerLine{ {Kind: protocol.PickerLineSection, Label: "host-a", Status: protocol.PickerLineStatusDown}, { Key: "a/broken", Kind: protocol.PickerLineSession, Label: "broken", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusError, Detail: "2", }, { Key: "a/live", Kind: protocol.PickerLineSession, Label: "live", Focusable: true, Actions: protocol.PickerCanNavigate, Status: protocol.PickerLineStatusUp, Detail: "2", }, { Key: "b/old", Kind: protocol.PickerLineSession, Label: "old", Stopped: true, Status: protocol.PickerLineStatusStopped, Detail: "stopped", Focusable: true, Actions: protocol.PickerCanNavigate, }, }, Config{Intent: protocol.PickerIntentNavigation}) frame := m.Render(domain.Size{Cols: 60, Rows: 8}, Preview{}) require.Equal(t, 60, frame.Width) require.Equal(t, 8, frame.Height) for row, want := range map[int]int{0: 167, 1: 167} { text := []rune(strings.TrimRight(rowText(frame.Row(row)), " ")) require.Equal(t, '●', text[len(text)-1], "row %d has a problem dot", row) dot := frame.At(len(text)-1, row).Style require.Equal(t, want, dot.Foreground, "row %d dot color", row) require.False(t, dot.Inverse, "row %d dot is never inverted, even when selected", row) } require.True(t, frame.At(0, 1).Style.Inverse, "the broken session is the selected row") for _, row := range []int{2, 3} { require.NotContains(t, rowText(frame.Row(row)), "●", "healthy and stopped rows carry no dot") } } func TestRenderShowsNoDaemonBadgeAndCreateHint(t *testing.T) { m := New([]protocol.PickerLine{{ Key: "h/remote", Kind: protocol.PickerLineHost, Label: "remote", Detail: "0 sessions", Status: protocol.PickerLineStatusNoDaemon, StatusDetail: "no daemon — Enter to create a session", Dim: true, Focusable: true, Actions: protocol.PickerCanNavigate, }}, Config{Intent: protocol.PickerIntentNavigation}) frame := m.Render(domain.Size{Cols: 80, Rows: 8}, Preview{}) require.Contains(t, rowText(frame.Row(0)), "●") require.Contains(t, rowText(frame.Row(7)), "no daemon") require.Contains(t, rowText(frame.Row(7)), "Enter to create a session") require.NotContains(t, rowText(frame.Row(7)), "Enter unavailable") } func TestRenderBlitsThePreviewIntoThePreviewRect(t *testing.T) { m := New([]protocol.PickerLine{navLine("a/one", "one")}, Config{Intent: protocol.PickerIntentNavigation}) preview := Preview{Rows: [][]renderer.Cell{{{Rune: 'X'}}}, Width: 1, Height: 1} frame := m.Render(domain.Size{Cols: 120, Rows: 12}, preview) geometry := ChooseGeometry(domain.Size{Cols: 120, Rows: 12}) x := geometry.Preview.X y := geometry.Preview.Y + geometry.Preview.Height - 1 require.Equal(t, 'X', frame.Cell(x, y).Rune) } func TestRenderAttentionMarkerFollowsTheTabName(t *testing.T) { m := New([]protocol.PickerLine{ tabLine("a/one#t1", "shell", protocol.PickerCanNavigate), {Key: "a/one#t2", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate}, }, Config{Intent: protocol.PickerIntentNavigation}) frame := m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{}) require.Contains(t, rowText(frame.Row(1)), "build") require.NotEqual(t, rowText(frame.Row(0)), rowText(frame.Row(1))) } // TestRenderAttentionBellOnHeadersAndTabs pins Plan 003 C1: a session header // and a tab row both draw the bell right after their name when they ring, and // neither draws it otherwise. func TestRenderAttentionBellOnHeadersAndTabs(t *testing.T) { bell := string(ui.AttentionGlyph) tests := []struct { name string line protocol.PickerLine want bool }{ {name: "ringing header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached", Attention: true}, want: true}, {name: "quiet header", line: protocol.PickerLine{Key: "s1", Kind: protocol.PickerLineSession, Label: "work", Detail: "attached"}}, {name: "ringing tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Attention: true, Focusable: true, Actions: protocol.PickerCanNavigate}, want: true}, {name: "quiet tab", line: protocol.PickerLine{Key: "t1", Kind: protocol.PickerLineTab, Label: "build", Focusable: true, Actions: protocol.PickerCanNavigate}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := New([]protocol.PickerLine{tt.line}, Config{Intent: protocol.PickerIntentNavigation}) text := rowText(m.Render(domain.Size{Cols: 60, Rows: 6}, Preview{}).Row(0)) require.Equal(t, tt.want, strings.Contains(text, tt.line.Label+" "+bell), "row %q", text) }) } } func TestSearchMatchesLabelsAndDetails(t *testing.T) { m := New([]protocol.PickerLine{ navLine("a/one", "one"), navLine("b/two", "two"), {Key: "c/three", Kind: protocol.PickerLineTab, Label: "three", Detail: " (vim)", Focusable: true, Actions: protocol.PickerCanNavigate}, }, Config{Intent: protocol.PickerIntentNavigation}) m.EnterSearch() m.InsertSearch('v') m.InsertSearch('i') m.InsertSearch('m') require.Equal(t, 1, m.MatchCount()) selected, ok := m.Selected() require.True(t, ok) require.Equal(t, "c/three", selected.Key) m.BackspaceSearch() require.Equal(t, "vi", m.Query()) m.ClearSearch() require.Equal(t, "", m.Query()) require.True(t, m.SearchActive(), "clearing the query keeps the search editor open") m.ExitSearch() require.False(t, m.SearchActive()) } // TestSearchSessionNameReachesItsTabRows pins that a session with tabs, whose // header is not itself a destination, is found by its name: the query matches // the tab rows that are its destinations, never another session's tabs. func TestSearchSessionNameReachesItsTabRows(t *testing.T) { tab := func(key, label string) protocol.PickerLine { return protocol.PickerLine{Key: key, Kind: protocol.PickerLineTab, Label: label, Focusable: true, Actions: protocol.PickerCanNavigate} } session := func(key, label string) protocol.PickerLine { return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label} } lines := []protocol.PickerLine{ session("a", "picka1234"), tab("a/1", "1"), session("b", "pickb1234"), tab("b/1", "1"), tab("b/2", "logs"), } tests := []struct { name string query string wantMatches int wantKey string }{ {name: "exact session name", query: "pickb1234", wantMatches: 2, wantKey: "b/1"}, {name: "session name prefix", query: "picka", wantMatches: 1, wantKey: "a/1"}, {name: "tab label still matches", query: "logs", wantMatches: 1, wantKey: "b/2"}, {name: "no session of that name", query: "pickc", wantMatches: 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := New(lines, Config{Intent: protocol.PickerIntentNavigation}) m.EnterSearch() for _, r := range tt.query { m.InsertSearch(r) } require.Equal(t, tt.wantMatches, m.MatchCount()) selected, ok := m.Selected() if tt.wantKey == "" { require.False(t, ok) return } require.True(t, ok) require.Equal(t, tt.wantKey, selected.Key) }) } } func rowText(row []renderer.Cell) string { out := make([]rune, 0, len(row)) for _, cell := range row { out = append(out, cell.Rune) } return string(out) } func TestReplaceLinesSelectsThePreviousRowWhenTheCursorRowLeaves(t *testing.T) { all := []protocol.PickerLine{navLine("a", "a"), navLine("b", "b"), navLine("c", "c"), navLine("d", "d")} tests := []struct { name string cursor string next []protocol.PickerLine want string }{ {name: "middle row killed", cursor: "c", next: []protocol.PickerLine{all[0], all[1], all[3]}, want: "b"}, {name: "last row killed", cursor: "d", next: all[:3], want: "c"}, {name: "first row killed falls to the nearest", cursor: "a", next: all[1:], want: "b"}, {name: "unchanged row keeps the cursor", cursor: "c", next: all, want: "c"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := New(all, Config{Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}}) m.ReplaceLines(tt.next, protocol.PickerCursor{Index: -1}) selected, ok := m.Selected() require.True(t, ok) require.Equal(t, tt.want, selected.Key) }) } } func TestTreePrefixesBranchSessionsAndTabs(t *testing.T) { m := New([]protocol.PickerLine{ section("local"), navLine("a", "alpha"), tabLine("a1", "one", protocol.PickerCanNavigate), tabLine("a2", "two", protocol.PickerCanNavigate), navLine("b", "beta"), tabLine("b1", "one", protocol.PickerCanNavigate), section("remote"), navLine("c", "gamma"), }, Config{}) got := make([]string, 0, len(m.rows)) for _, r := range m.rows { got = append(got, r.tree+r.line.Label) } require.Equal(t, []string{ "local", "├─ alpha", "│ ├─ one", "│ └─ two", "└─ beta", " └─ one", "remote", "└─ gamma", }, got) frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{}) cell := frame.At(0, 1) require.Equal(t, '├', cell.Rune) require.NotZero(t, cell.Style.Attrs&renderer.AttrDim, "the selected row keeps its dimmed tree") cell = frame.At(0, 4) // "└─ beta", not selected require.Equal(t, '└', cell.Rune) // A fixed grey, not SGR dim: some terminals barely dim box drawing. require.Equal(t, 244, cell.Style.Foreground, "tree lines use a readable grey") require.False(t, cell.Style.HasForegroundRGB) require.Zero(t, cell.Style.Attrs&renderer.AttrDim) } func TestTreeStyleKeepsRowDimming(t *testing.T) { tests := []struct { name string lines []protocol.PickerLine query string }{ {name: "dim line", lines: func() []protocol.PickerLine { dim := navLine("b", "beta") dim.Dim = true return []protocol.PickerLine{section("local"), navLine("a", "alpha"), dim} }()}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := New(tt.lines, Config{}) if tt.query != "" { m.EnterSearch() for _, r := range tt.query { m.InsertSearch(r) } } frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{}) cell := frame.At(0, 2) // "└─ beta", not selected require.Equal(t, '└', cell.Rune) require.Equal(t, 244, cell.Style.Foreground) require.NotZero(t, cell.Style.Attrs&renderer.AttrDim, "a dimmed row keeps its tree dimmed") }) } } func TestDetailLeadingSpaceIsTrimmed(t *testing.T) { tab := tabLine("a1", "1", protocol.PickerCanNavigate) tab.Detail = " (fish)" m := New([]protocol.PickerLine{section("local"), navLine("a", "alpha"), tab}, Config{}) frame := m.Render(domain.Size{Cols: 40, Rows: 30}, Preview{}) row := "" for x := range 14 { row += string(frame.At(x, 2).Rune) } require.Equal(t, " └─ 1 (fish)", row, "one gap, not two") } func TestReplaceLinesPreviousRowRespectsSearch(t *testing.T) { m := New([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "alps")}, Config{Cursor: protocol.PickerCursor{Key: "c", Index: -1}}) m.EnterSearch() for _, r := range "al" { m.InsertSearch(r) } m.Down() // alpha -> alps: beta is hidden by the query selected, ok := m.Selected() require.True(t, ok) require.Equal(t, "c", selected.Key) m.ReplaceLines([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta")}, protocol.PickerCursor{Index: -1}) selected, ok = m.Selected() require.True(t, ok) require.Equal(t, "a", selected.Key, "the hidden row above is skipped for the nearest matching one") } func TestReplaceLinesPreviousRowWhenTheCursorRowStopsMatching(t *testing.T) { m := New([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "alps"), navLine("d", "altitude")}, Config{Cursor: protocol.PickerCursor{Key: "c", Index: -1}}) m.EnterSearch() for _, r := range "al" { m.InsertSearch(r) } m.Down() // alpha -> alps require.Equal(t, "c", mustSelectedKey(t, m)) // c is still published but no longer matches; the source hints the last row. m.ReplaceLines([]protocol.PickerLine{navLine("a", "alpha"), navLine("b", "beta"), navLine("c", "cliff"), navLine("d", "altitude")}, protocol.PickerCursor{Index: 3}) require.Equal(t, "a", mustSelectedKey(t, m), "the nearest matching row above wins over the source hint") } // drawnRows reports the tree prefix and label of every drawn row. func drawnRows(m *Model) []string { got := make([]string, 0, len(m.view)) for _, i := range m.view { got = append(got, m.rows[i].tree+m.rows[i].line.Label) } return got } func foldFixture() []protocol.PickerLine { session := func(key, label string) protocol.PickerLine { return protocol.PickerLine{Key: key, Kind: protocol.PickerLineSession, Label: label} } return []protocol.PickerLine{ section("LOCAL"), session("w", "work"), tabLine("w1", "shell", protocol.PickerCanNavigate), tabLine("w2", "logs", protocol.PickerCanNavigate), session("s", "scratch"), tabLine("s1", "apply-migrations", protocol.PickerCanNavigate), section("devbox"), session("a", "api"), tabLine("a1", "server", protocol.PickerCanNavigate), tabLine("a2", "db", protocol.PickerCanNavigate), } } func TestSectionFolding(t *testing.T) { tests := []struct { name string keys func(m *Model) wantRows []string wantCursor string }{ { name: "left from a tab climbs to its section", keys: func(m *Model) { m.Left() }, wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "\x00section:0:LOCAL", }, { name: "left on a section collapses it", keys: func(m *Model) { m.Left(); m.Left() }, wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "\x00section:0:LOCAL", }, { name: "down from a collapsed section skips its rows", keys: func(m *Model) { m.Left(); m.Left(); m.Down() }, wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "\x00section:0:devbox", }, { name: "right expands it again", keys: func(m *Model) { m.Left(); m.Left(); m.Right() }, wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "\x00section:0:LOCAL", }, { name: "toggle collapses and expands", keys: func(m *Model) { m.Left(); m.ToggleSection() }, wantRows: []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "\x00section:0:LOCAL", }, { name: "right off a section does nothing", keys: func(m *Model) { m.Right() }, wantRows: []string{"LOCAL", "├─ work", "│ ├─ shell", "│ └─ logs", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "w1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) require.Equal(t, "w1", cursorKey(t, m), "a section header never takes the initial cursor") tt.keys(m) require.Equal(t, tt.wantRows, drawnRows(m)) key, _ := m.cursorKey() require.Equal(t, tt.wantCursor, key) }) } } func TestSectionFoldSurvivesRepublication(t *testing.T) { m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) m.Left() m.Left() m.ReplaceLines(foldFixture(), protocol.PickerCursor{Index: -1}) require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m)) key, _ := m.cursorKey() require.Equal(t, "\x00section:0:LOCAL", key) _, ok := m.Selected() require.False(t, ok, "a section header is never committable") frame := m.Render(domain.Size{Cols: 40, Rows: 10}, Preview{}) require.True(t, strings.HasPrefix(rowText(frame.Row(0)), "▸ LOCAL (2)"), "collapsed header %q", rowText(frame.Row(0))) require.True(t, strings.HasPrefix(rowText(frame.Row(1)), "▾ devbox"), "expanded header %q", rowText(frame.Row(1))) } func TestSearchShowsOnlyMatchesInTreeOrder(t *testing.T) { tests := []struct { name string query string wantRows []string wantCursor string }{ {name: "matches keep their ancestors", query: "ap", wantRows: []string{"LOCAL", "└─ scratch", " └─ apply-migrations", "devbox", "└─ api", " ├─ server", " └─ db"}, wantCursor: "s1"}, {name: "one tab match", query: "serv", wantRows: []string{"devbox", "└─ api", " └─ server"}, wantCursor: "a1"}, {name: "no match draws nothing", query: "zzz", wantRows: []string{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) m.Down() m.Down() // somewhere in the middle: typing still starts on the first match // A collapsed section never hides a match. m.Left() m.Left() m.EnterSearch() for _, r := range tt.query { m.InsertSearch(r) } require.Equal(t, tt.wantRows, drawnRows(m)) if tt.wantCursor == "" { require.Equal(t, -1, m.SelectedIndex()) return } require.Equal(t, tt.wantCursor, mustSelectedKey(t, m)) }) } } func TestExitSearchRestoresFolds(t *testing.T) { m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) m.Left() m.Left() m.EnterSearch() m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL m.ExitSearch() require.Equal(t, []string{"LOCAL", "devbox", "└─ api", " ├─ server", " └─ db"}, drawnRows(m)) key, _ := m.cursorKey() require.Equal(t, "\x00section:0:LOCAL", key, "a match a fold hides leaves the cursor on its section") } func TestRemovedRowNeverHandsTheCursorToASection(t *testing.T) { tests := []struct { name string cursor string drop string want string }{ {name: "first tab under LOCAL", cursor: "w1", drop: "w1", want: "w2"}, {name: "first tab under a remote section", cursor: "a1", drop: "a1", want: "s1"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped, Cursor: protocol.PickerCursor{Key: tt.cursor, Index: -1}}) next := make([]protocol.PickerLine, 0) for _, line := range foldFixture() { if line.Key != tt.drop { next = append(next, line) } } m.ReplaceLines(next, protocol.PickerCursor{Index: -1}) require.Equal(t, tt.want, mustSelectedKey(t, m)) }) } } func TestSameLabelSectionsFoldApart(t *testing.T) { m := New([]protocol.PickerLine{ section("remote"), navLine("a", "alpha"), section("remote"), navLine("b", "beta"), }, Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) m.Left() m.Left() require.Equal(t, []string{"remote", "remote", "└─ beta"}, drawnRows(m)) } func TestClearSearchKeepsTheCursorOnADrawnRow(t *testing.T) { m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) m.Left() m.Left() m.EnterSearch() m.InsertSearch('s') // first match: work/shell, inside the collapsed LOCAL m.ClearSearch() require.False(t, m.hidden[m.SelectedIndex()], "the cursor rests on a drawn row") } func TestSectionFooterHint(t *testing.T) { tests := []struct { name string search bool want string }{ {name: "normal mode offers h/l", want: "Enter/h/l fold"}, {name: "search mode types h/l", search: true, want: "Enter fold"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m := New(foldFixture(), Config{Intent: protocol.PickerIntentNavigation, Sort: SortGrouped}) m.Left() if tt.search { m.EnterSearch() } frame := m.Render(domain.Size{Cols: 80, Rows: 12}, Preview{}) status := rowText(frame.Row(11)) require.Contains(t, status, tt.want) if tt.search { require.NotContains(t, status, "h/l") } }) } }