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} }()}, {name: "search miss", lines: []protocol.PickerLine{section("local"), navLine("a", "alpha"), navLine("b", "beta")}, query: "alp"}, } 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") }