filepicker_test.go

  1package picker
  2
  3import (
  4	"io/fs"
  5	"testing"
  6
  7	tea "charm.land/bubbletea/v2"
  8
  9	"git.secluded.site/keld/internal/restic"
 10)
 11
 12// testNodes returns a small set of LsNodes for building a test FS.
 13// Tree structure:
 14//
 15//	music/
 16//	  album-01/
 17//	    01-track.flac
 18//	    02-track.flac
 19//	  album-02/
 20//	    01-song.flac
 21//	docs/
 22//	  notes.txt
 23//	readme.txt
 24func testNodes() []restic.LsNode {
 25	return []restic.LsNode{
 26		{Name: "music", Type: "dir", Path: "/music"},
 27		{Name: "album-01", Type: "dir", Path: "/music/album-01"},
 28		{Name: "01-track.flac", Type: "file", Path: "/music/album-01/01-track.flac", Size: 1000},
 29		{Name: "02-track.flac", Type: "file", Path: "/music/album-01/02-track.flac", Size: 2000},
 30		{Name: "album-02", Type: "dir", Path: "/music/album-02"},
 31		{Name: "01-song.flac", Type: "file", Path: "/music/album-02/01-song.flac", Size: 3000},
 32		{Name: "docs", Type: "dir", Path: "/docs"},
 33		{Name: "notes.txt", Type: "file", Path: "/docs/notes.txt", Size: 500},
 34		{Name: "readme.txt", Type: "file", Path: "/readme.txt", Size: 100},
 35	}
 36}
 37
 38// initPicker creates a picker Model from test nodes, sends a
 39// WindowSizeMsg to set the height, and processes the initial readDir
 40// command so the file list is populated and ready for key events.
 41func initPicker(t *testing.T) Model {
 42	t.Helper()
 43
 44	sfs := restic.NewSnapshotFS(testNodes())
 45	m := New(sfs)
 46
 47	// Process Init to get the readDir command.
 48	cmd := m.Init()
 49	if cmd == nil {
 50		t.Fatal("Init returned nil cmd")
 51	}
 52	msg := cmd()
 53	m, _ = m.Update(msg)
 54
 55	// Set a reasonable height so the picker can display entries.
 56	m, _ = m.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
 57
 58	return m
 59}
 60
 61// Key construction helpers. key.Matches compares KeyPressMsg.String()
 62// against the binding's key list, so we need String() to return the
 63// right value for each key.
 64
 65func keyPress(code rune, text string) tea.KeyPressMsg {
 66	return tea.KeyPressMsg{Code: code, Text: text}
 67}
 68
 69func keyEnter() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
 70func keySpace() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeySpace, Text: " "} }
 71func keyCtrlC() tea.KeyPressMsg { return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl} }
 72func keyL() tea.KeyPressMsg     { return keyPress('l', "l") }
 73func keyH() tea.KeyPressMsg     { return keyPress('h', "h") }
 74func keyJ() tea.KeyPressMsg     { return keyPress('j', "j") }
 75func keyK() tea.KeyPressMsg     { return keyPress('k', "k") }
 76func keyRight() tea.KeyPressMsg { return tea.KeyPressMsg{Code: tea.KeyRight} }
 77
 78func TestFilePickerSpaceTogglesFile(t *testing.T) {
 79	t.Parallel()
 80	m := initPicker(t)
 81
 82	// Root directory listing is sorted: dirs first, then files.
 83	// Expected order: docs/, music/, readme.txt
 84	// Navigate to readme.txt (index 2).
 85	m, _ = m.Update(keyJ()) // 0→1
 86	m, _ = m.Update(keyJ()) // 1→2
 87
 88	if m.HighlightedPath() != "readme.txt" {
 89		t.Fatalf("expected cursor on readme.txt, got %q", m.HighlightedPath())
 90	}
 91
 92	// Space should select it.
 93	m, _ = m.Update(keySpace())
 94	if m.Selection.State("readme.txt") != CheckAll {
 95		t.Errorf("after space: got %v, want CheckAll", m.Selection.State("readme.txt"))
 96	}
 97
 98	// Space again should deselect it.
 99	m, _ = m.Update(keySpace())
100	if m.Selection.State("readme.txt") != CheckNone {
101		t.Errorf("after second space: got %v, want CheckNone", m.Selection.State("readme.txt"))
102	}
103}
104
105func TestFilePickerSpaceTogglesDirectory(t *testing.T) {
106	t.Parallel()
107	m := initPicker(t)
108
109	// First entry should be docs/ (dirs sorted before files).
110	if m.HighlightedPath() != "docs" {
111		t.Fatalf("expected cursor on docs, got %q", m.HighlightedPath())
112	}
113
114	// Space should select the entire docs subtree.
115	m, _ = m.Update(keySpace())
116	if m.Selection.State("docs") != CheckAll {
117		t.Errorf("after space: got %v, want CheckAll", m.Selection.State("docs"))
118	}
119	if m.Selection.State("docs/notes.txt") != CheckAll {
120		t.Error("docs/notes.txt should be selected after toggling docs/")
121	}
122
123	// Space again deselects.
124	m, _ = m.Update(keySpace())
125	if m.Selection.State("docs") != CheckNone {
126		t.Errorf("after second space: got %v, want CheckNone", m.Selection.State("docs"))
127	}
128}
129
130func TestFilePickerOpenDescendsIntoDirectory(t *testing.T) {
131	t.Parallel()
132	m := initPicker(t)
133
134	// Cursor is on docs/ (first entry).
135	if m.CurrentDirectory != "." {
136		t.Fatalf("expected root directory, got %q", m.CurrentDirectory)
137	}
138
139	// Press l to open docs/.
140	var cmd tea.Cmd
141	m, cmd = m.Update(keyL())
142	if m.CurrentDirectory != "docs" {
143		t.Fatalf("expected CurrentDirectory=docs, got %q", m.CurrentDirectory)
144	}
145
146	// Process the readDir command to load the directory contents.
147	if cmd == nil {
148		t.Fatal("open should return a readDir command")
149	}
150	msg := cmd()
151	m, _ = m.Update(msg)
152
153	// docs/ contains only notes.txt.
154	if len(m.files) != 1 || m.files[0].Name() != "notes.txt" {
155		t.Errorf("expected [notes.txt], got %v", fileNames(m.files))
156	}
157
158	// Also test with the right arrow key.
159	m2 := initPicker(t)
160	m2, _ = m2.Update(keyRight())
161	if m2.CurrentDirectory != "docs" {
162		t.Fatalf("right arrow: expected CurrentDirectory=docs, got %q", m2.CurrentDirectory)
163	}
164}
165
166func TestFilePickerOpenOnFileIsNoOp(t *testing.T) {
167	t.Parallel()
168	m := initPicker(t)
169
170	// Navigate to readme.txt (index 2).
171	m, _ = m.Update(keyJ()) // 0→1
172	m, _ = m.Update(keyJ()) // 1→2
173
174	if m.HighlightedPath() != "readme.txt" {
175		t.Fatalf("expected cursor on readme.txt, got %q", m.HighlightedPath())
176	}
177
178	origDir := m.CurrentDirectory
179	m, cmd := m.Update(keyL())
180	if m.CurrentDirectory != origDir {
181		t.Errorf("open on file changed directory to %q", m.CurrentDirectory)
182	}
183	if cmd != nil {
184		t.Error("open on file should not return a command")
185	}
186}
187
188func TestFilePickerBackReturnsToParent(t *testing.T) {
189	t.Parallel()
190	m := initPicker(t)
191
192	// Navigate to music/ (index 1) and open it.
193	m, _ = m.Update(keyJ()) // 0→1 (music/)
194	if m.HighlightedPath() != "music" {
195		t.Fatalf("expected cursor on music, got %q", m.HighlightedPath())
196	}
197
198	var cmd tea.Cmd
199	m, cmd = m.Update(keyL()) // open music/
200	if cmd != nil {
201		m, _ = m.Update(cmd())
202	}
203	if m.CurrentDirectory != "music" {
204		t.Fatalf("expected CurrentDirectory=music, got %q", m.CurrentDirectory)
205	}
206
207	// Press h to go back.
208	m, cmd = m.Update(keyH())
209	if m.CurrentDirectory != "." {
210		t.Fatalf("expected CurrentDirectory=., got %q", m.CurrentDirectory)
211	}
212	if cmd != nil {
213		m, _ = m.Update(cmd())
214	}
215
216	// Cursor should be restored to index 1 (where music/ was).
217	if m.HighlightedPath() != "music" {
218		t.Errorf("expected cursor restored to music, got %q", m.HighlightedPath())
219	}
220}
221
222func TestFilePickerEnterConfirms(t *testing.T) {
223	t.Parallel()
224	m := initPicker(t)
225
226	// Cursor is on docs/ (a directory). Enter should confirm, not descend.
227	origDir := m.CurrentDirectory
228	m, cmd := m.Update(keyEnter())
229
230	if !m.Confirmed {
231		t.Error("Enter should set Confirmed = true")
232	}
233	if m.CurrentDirectory != origDir {
234		t.Errorf("Enter should not change directory, got %q", m.CurrentDirectory)
235	}
236
237	// The command should be tea.Quit.
238	if cmd == nil {
239		t.Fatal("Enter should return a command")
240	}
241	msg := cmd()
242	if _, ok := msg.(tea.QuitMsg); !ok {
243		t.Errorf("expected tea.QuitMsg, got %T", msg)
244	}
245}
246
247func TestFilePickerNestedToggleUsesFullPath(t *testing.T) {
248	t.Parallel()
249	m := initPicker(t)
250
251	// Navigate to music/ (index 1) and open it.
252	m, _ = m.Update(keyJ())
253	var cmd tea.Cmd
254	m, cmd = m.Update(keyL()) // open music/
255	if cmd != nil {
256		m, _ = m.Update(cmd())
257	}
258
259	// Inside music/: album-01/, album-02/ (dirs sorted first).
260	// Open album-01/.
261	m, cmd = m.Update(keyL())
262	if cmd != nil {
263		m, _ = m.Update(cmd())
264	}
265	if m.CurrentDirectory != "music/album-01" {
266		t.Fatalf("expected CurrentDirectory=music/album-01, got %q", m.CurrentDirectory)
267	}
268
269	// Files: 01-track.flac, 02-track.flac. Toggle the first one.
270	if m.HighlightedPath() != "music/album-01/01-track.flac" {
271		t.Fatalf("expected cursor on music/album-01/01-track.flac, got %q", m.HighlightedPath())
272	}
273
274	m, _ = m.Update(keySpace())
275	if m.Selection.State("music/album-01/01-track.flac") != CheckAll {
276		t.Error("space should toggle the full FS-relative path")
277	}
278	// Parent should be partial.
279	if m.Selection.State("music/album-01") != CheckPartial {
280		t.Errorf("album-01: got %v, want CheckPartial", m.Selection.State("music/album-01"))
281	}
282}
283
284func TestFilePickerCursorBounds(t *testing.T) {
285	t.Parallel()
286	m := initPicker(t)
287
288	// Root has 3 entries: docs/, music/, readme.txt (indices 0, 1, 2).
289
290	// Pressing up at the top should clamp at 0.
291	m, _ = m.Update(keyK())
292	if m.selected != 0 {
293		t.Errorf("up at top: expected selected=0, got %d", m.selected)
294	}
295
296	// Move to the bottom.
297	m, _ = m.Update(keyJ()) // 0→1
298	m, _ = m.Update(keyJ()) // 1→2
299	if m.selected != 2 {
300		t.Fatalf("expected selected=2, got %d", m.selected)
301	}
302
303	// Pressing down at the bottom should clamp at 2.
304	m, _ = m.Update(keyJ())
305	if m.selected != 2 {
306		t.Errorf("down at bottom: expected selected=2, got %d", m.selected)
307	}
308}
309
310func TestFilePickerBackAtRoot(t *testing.T) {
311	t.Parallel()
312	m := initPicker(t)
313
314	origDir := m.CurrentDirectory
315	origSelected := m.selected
316	origFiles := len(m.files)
317
318	// Press back at root — should be a no-op (no crash, stays at root).
319	m, cmd := m.Update(keyH())
320
321	if m.CurrentDirectory != origDir {
322		t.Errorf("back at root changed directory to %q", m.CurrentDirectory)
323	}
324	if m.selected != origSelected {
325		t.Errorf("back at root changed selected from %d to %d", origSelected, m.selected)
326	}
327
328	// Process the readDir command if returned (it re-reads root).
329	if cmd != nil {
330		msg := cmd()
331		m, _ = m.Update(msg)
332	}
333
334	// Should still have the same entries.
335	if len(m.files) != origFiles {
336		t.Errorf("back at root changed file count from %d to %d", origFiles, len(m.files))
337	}
338}
339
340func TestFilePickerCtrlCQuits(t *testing.T) {
341	t.Parallel()
342	m := initPicker(t)
343
344	// Toggle something so we can verify it doesn't count as confirmed.
345	m, _ = m.Update(keySpace())
346
347	m, cmd := m.Update(keyCtrlC())
348
349	if m.Confirmed {
350		t.Error("ctrl+c should not set Confirmed")
351	}
352	if cmd == nil {
353		t.Fatal("ctrl+c should return a command")
354	}
355	msg := cmd()
356	if _, ok := msg.(tea.QuitMsg); !ok {
357		t.Errorf("expected tea.QuitMsg, got %T", msg)
358	}
359}
360
361// fileNames returns the names of directory entries for error messages.
362func fileNames(entries []fs.DirEntry) []string {
363	names := make([]string, len(entries))
364	for i, e := range entries {
365		names[i] = e.Name()
366	}
367	return names
368}