From 4ee76b2fd48b44b044a230afef3ee322a7d10cc0 Mon Sep 17 00:00:00 2001 From: Amolith Date: Thu, 26 Mar 2026 22:00:29 -0600 Subject: [PATCH] Add picker key-handling tests --- internal/picker/filepicker.go | 2 +- internal/picker/filepicker_test.go | 347 +++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 internal/picker/filepicker_test.go diff --git a/internal/picker/filepicker.go b/internal/picker/filepicker.go index 289a179e8d8bc1095b42b69d0cd0fd1986b1c4ba..e00d96ab5d65e5df3a02a61c37a73314672a8eec 100644 --- a/internal/picker/filepicker.go +++ b/internal/picker/filepicker.go @@ -100,7 +100,7 @@ func DefaultKeyMap() KeyMap { Back: key.NewBinding(key.WithKeys("h", "backspace", "left", "esc"), key.WithHelp("h", "back")), Open: key.NewBinding(key.WithKeys("l", "right", "enter"), key.WithHelp("l", "open")), Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), - Toggle: key.NewBinding(key.WithKeys(" "), key.WithHelp("space", "toggle")), + Toggle: key.NewBinding(key.WithKeys("space"), key.WithHelp("space", "toggle")), } } diff --git a/internal/picker/filepicker_test.go b/internal/picker/filepicker_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e32cb6366e18d5f82b70c6680d631a458fc3a75d --- /dev/null +++ b/internal/picker/filepicker_test.go @@ -0,0 +1,347 @@ +package picker + +import ( + "io/fs" + "testing" + + tea "charm.land/bubbletea/v2" + + "git.secluded.site/keld/internal/restic" +) + +// testNodes returns a small set of LsNodes for building a test FS. +// Tree structure: +// +// music/ +// album-01/ +// 01-track.flac +// 02-track.flac +// album-02/ +// 01-song.flac +// docs/ +// notes.txt +// readme.txt +func testNodes() []restic.LsNode { + return []restic.LsNode{ + {Name: "music", Type: "dir", Path: "/music"}, + {Name: "album-01", Type: "dir", Path: "/music/album-01"}, + {Name: "01-track.flac", Type: "file", Path: "/music/album-01/01-track.flac", Size: 1000}, + {Name: "02-track.flac", Type: "file", Path: "/music/album-01/02-track.flac", Size: 2000}, + {Name: "album-02", Type: "dir", Path: "/music/album-02"}, + {Name: "01-song.flac", Type: "file", Path: "/music/album-02/01-song.flac", Size: 3000}, + {Name: "docs", Type: "dir", Path: "/docs"}, + {Name: "notes.txt", Type: "file", Path: "/docs/notes.txt", Size: 500}, + {Name: "readme.txt", Type: "file", Path: "/readme.txt", Size: 100}, + } +} + +// initPicker creates a picker Model from test nodes, sends a +// WindowSizeMsg to set the height, and processes the initial readDir +// command so the file list is populated and ready for key events. +func initPicker(t *testing.T) Model { + t.Helper() + + sfs := restic.NewSnapshotFS(testNodes()) + m := New(sfs) + m.Selection = NewSelection(sfs) + + // Process Init to get the readDir command. + cmd := m.Init() + if cmd == nil { + t.Fatal("Init returned nil cmd") + } + msg := cmd() + m, _ = m.Update(msg) + + // Set a reasonable height so the picker can display entries. + m, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 30}) + + return m +} + +// Key construction helpers. key.Matches compares KeyPressMsg.String() +// against the binding's key list, so we need String() to return the +// right value for each key. + +func keyPress(code rune, text string) tea.KeyPressMsg { + return tea.KeyPressMsg{Code: code, Text: text} +} + +func keyEnter() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} } +func keySpace() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} } +func keyL() tea.KeyPressMsg { return keyPress('l', "l") } +func keyH() tea.KeyPressMsg { return keyPress('h', "h") } +func keyJ() tea.KeyPressMsg { return keyPress('j', "j") } +func keyK() tea.KeyPressMsg { return keyPress('k', "k") } +func keyRight() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyRight} } + +func TestFilePickerSpaceTogglesFile(t *testing.T) { + t.Parallel() + m := initPicker(t) + + // Root directory listing is sorted: dirs first, then files. + // Expected order: docs/, music/, readme.txt + // Navigate to readme.txt (index 2). + m, _ = m.Update(keyJ()) // 0→1 + m, _ = m.Update(keyJ()) // 1→2 + + if m.HighlightedPath() != "readme.txt" { + t.Fatalf("expected cursor on readme.txt, got %q", m.HighlightedPath()) + } + + // Space should select it. + m, _ = m.Update(keySpace()) + if m.Selection.State("readme.txt") != CheckAll { + t.Errorf("after space: got %v, want CheckAll", m.Selection.State("readme.txt")) + } + + // Space again should deselect it. + m, _ = m.Update(keySpace()) + if m.Selection.State("readme.txt") != CheckNone { + t.Errorf("after second space: got %v, want CheckNone", m.Selection.State("readme.txt")) + } +} + +func TestFilePickerSpaceTogglesDirectory(t *testing.T) { + t.Parallel() + m := initPicker(t) + + // First entry should be docs/ (dirs sorted before files). + if m.HighlightedPath() != "docs" { + t.Fatalf("expected cursor on docs, got %q", m.HighlightedPath()) + } + + // Space should select the entire docs subtree. + m, _ = m.Update(keySpace()) + if m.Selection.State("docs") != CheckAll { + t.Errorf("after space: got %v, want CheckAll", m.Selection.State("docs")) + } + if m.Selection.State("docs/notes.txt") != CheckAll { + t.Error("docs/notes.txt should be selected after toggling docs/") + } + + // Space again deselects. + m, _ = m.Update(keySpace()) + if m.Selection.State("docs") != CheckNone { + t.Errorf("after second space: got %v, want CheckNone", m.Selection.State("docs")) + } +} + +func TestFilePickerOpenDescendsIntoDirectory(t *testing.T) { + t.Parallel() + m := initPicker(t) + + // Cursor is on docs/ (first entry). + if m.CurrentDirectory != "." { + t.Fatalf("expected root directory, got %q", m.CurrentDirectory) + } + + // Press l to open docs/. + var cmd tea.Cmd + m, cmd = m.Update(keyL()) + if m.CurrentDirectory != "docs" { + t.Fatalf("expected CurrentDirectory=docs, got %q", m.CurrentDirectory) + } + + // Process the readDir command to load the directory contents. + if cmd == nil { + t.Fatal("open should return a readDir command") + } + msg := cmd() + m, _ = m.Update(msg) + + // docs/ contains only notes.txt. + if len(m.files) != 1 || m.files[0].Name() != "notes.txt" { + t.Errorf("expected [notes.txt], got %v", fileNames(m.files)) + } + + // Also test with the right arrow key. + m2 := initPicker(t) + m2, _ = m2.Update(keyRight()) + if m2.CurrentDirectory != "docs" { + t.Fatalf("right arrow: expected CurrentDirectory=docs, got %q", m2.CurrentDirectory) + } +} + +func TestFilePickerOpenOnFileIsNoOp(t *testing.T) { + t.Parallel() + m := initPicker(t) + + // Navigate to readme.txt (index 2). + m, _ = m.Update(keyJ()) // 0→1 + m, _ = m.Update(keyJ()) // 1→2 + + if m.HighlightedPath() != "readme.txt" { + t.Fatalf("expected cursor on readme.txt, got %q", m.HighlightedPath()) + } + + origDir := m.CurrentDirectory + m, cmd := m.Update(keyL()) + if m.CurrentDirectory != origDir { + t.Errorf("open on file changed directory to %q", m.CurrentDirectory) + } + if cmd != nil { + t.Error("open on file should not return a command") + } +} + +func TestFilePickerBackReturnsToParent(t *testing.T) { + t.Parallel() + m := initPicker(t) + + // Navigate to music/ (index 1) and open it. + m, _ = m.Update(keyJ()) // 0→1 (music/) + if m.HighlightedPath() != "music" { + t.Fatalf("expected cursor on music, got %q", m.HighlightedPath()) + } + + var cmd tea.Cmd + m, cmd = m.Update(keyL()) // open music/ + if cmd != nil { + m, _ = m.Update(cmd()) + } + if m.CurrentDirectory != "music" { + t.Fatalf("expected CurrentDirectory=music, got %q", m.CurrentDirectory) + } + + // Press h to go back. + m, cmd = m.Update(keyH()) + if m.CurrentDirectory != "." { + t.Fatalf("expected CurrentDirectory=., got %q", m.CurrentDirectory) + } + if cmd != nil { + m, _ = m.Update(cmd()) + } + + // Cursor should be restored to index 1 (where music/ was). + if m.HighlightedPath() != "music" { + t.Errorf("expected cursor restored to music, got %q", m.HighlightedPath()) + } +} + +func TestFilePickerEnterConfirms(t *testing.T) { + t.Parallel() + m := initPicker(t) + + // Cursor is on docs/ (a directory). Enter should confirm, not descend. + origDir := m.CurrentDirectory + m, cmd := m.Update(keyEnter()) + + if !m.Confirmed { + t.Error("Enter should set Confirmed = true") + } + if m.CurrentDirectory != origDir { + t.Errorf("Enter should not change directory, got %q", m.CurrentDirectory) + } + + // The command should be tea.Quit. + if cmd == nil { + t.Fatal("Enter should return a command") + } + msg := cmd() + if _, ok := msg.(tea.QuitMsg); !ok { + t.Errorf("expected tea.QuitMsg, got %T", msg) + } +} + +func TestFilePickerNestedToggleUsesFullPath(t *testing.T) { + t.Parallel() + m := initPicker(t) + + // Navigate to music/ (index 1) and open it. + m, _ = m.Update(keyJ()) + var cmd tea.Cmd + m, cmd = m.Update(keyL()) // open music/ + if cmd != nil { + m, _ = m.Update(cmd()) + } + + // Inside music/: album-01/, album-02/ (dirs sorted first). + // Open album-01/. + m, cmd = m.Update(keyL()) + if cmd != nil { + m, _ = m.Update(cmd()) + } + if m.CurrentDirectory != "music/album-01" { + t.Fatalf("expected CurrentDirectory=music/album-01, got %q", m.CurrentDirectory) + } + + // Files: 01-track.flac, 02-track.flac. Toggle the first one. + if m.HighlightedPath() != "music/album-01/01-track.flac" { + t.Fatalf("expected cursor on music/album-01/01-track.flac, got %q", m.HighlightedPath()) + } + + m, _ = m.Update(keySpace()) + if m.Selection.State("music/album-01/01-track.flac") != CheckAll { + t.Error("space should toggle the full FS-relative path") + } + // Parent should be partial. + if m.Selection.State("music/album-01") != CheckPartial { + t.Errorf("album-01: got %v, want CheckPartial", m.Selection.State("music/album-01")) + } +} + +func TestFilePickerCursorBounds(t *testing.T) { + t.Parallel() + m := initPicker(t) + + // Root has 3 entries: docs/, music/, readme.txt (indices 0, 1, 2). + + // Pressing up at the top should clamp at 0. + m, _ = m.Update(keyK()) + if m.selected != 0 { + t.Errorf("up at top: expected selected=0, got %d", m.selected) + } + + // Move to the bottom. + m, _ = m.Update(keyJ()) // 0→1 + m, _ = m.Update(keyJ()) // 1→2 + if m.selected != 2 { + t.Fatalf("expected selected=2, got %d", m.selected) + } + + // Pressing down at the bottom should clamp at 2. + m, _ = m.Update(keyJ()) + if m.selected != 2 { + t.Errorf("down at bottom: expected selected=2, got %d", m.selected) + } +} + +func TestFilePickerBackAtRoot(t *testing.T) { + t.Parallel() + m := initPicker(t) + + origDir := m.CurrentDirectory + origSelected := m.selected + origFiles := len(m.files) + + // Press back at root — should be a no-op (no crash, stays at root). + m, cmd := m.Update(keyH()) + + if m.CurrentDirectory != origDir { + t.Errorf("back at root changed directory to %q", m.CurrentDirectory) + } + if m.selected != origSelected { + t.Errorf("back at root changed selected from %d to %d", origSelected, m.selected) + } + + // Process the readDir command if returned (it re-reads root). + if cmd != nil { + msg := cmd() + m, _ = m.Update(msg) + } + + // Should still have the same entries. + if len(m.files) != origFiles { + t.Errorf("back at root changed file count from %d to %d", origFiles, len(m.files)) + } +} + +// fileNames returns the names of directory entries for error messages. +func fileNames(entries []fs.DirEntry) []string { + names := make([]string, len(entries)) + for i, e := range entries { + names[i] = e.Name() + } + return names +}