@@ -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
+}