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}