Add picker key-handling tests

Amolith created

Change summary

internal/picker/filepicker.go      |   2 
internal/picker/filepicker_test.go | 347 ++++++++++++++++++++++++++++++++
2 files changed, 348 insertions(+), 1 deletion(-)

Detailed changes

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")),
 	}
 }
 

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