1package screens
2
3import (
4 "errors"
5 "sync/atomic"
6 "testing"
7
8 tea "charm.land/bubbletea/v2"
9
10 "git.secluded.site/keld/internal/restic"
11 "git.secluded.site/keld/internal/ui"
12)
13
14// testNodes returns LsNode entries matching testFS.
15func testNodes() []restic.LsNode {
16 return []restic.LsNode{
17 {Name: "dir1", Type: "dir", Path: "/dir1", Mode: 0o755},
18 {Name: "file1.txt", Type: "file", Path: "/dir1/file1.txt", Size: 5, Mode: 0o644},
19 {Name: "file2.txt", Type: "file", Path: "/dir1/file2.txt", Size: 5, Mode: 0o644},
20 {Name: "dir2", Type: "dir", Path: "/dir2", Mode: 0o755},
21 {Name: "file3.txt", Type: "file", Path: "/dir2/file3.txt", Size: 4, Mode: 0o644},
22 {Name: "root.txt", Type: "file", Path: "/root.txt", Size: 4, Mode: 0o644},
23 }
24}
25
26// simulateFileLoad sends a filesLoadedMsg directly to the screen.
27func simulateFileLoad(fp *FilePicker, nodes []restic.LsNode, err error) *FilePicker {
28 gen := atomic.LoadInt64(&fp.gen)
29 msg := filesLoadedMsg{gen: gen, nodes: nodes, err: err}
30 screen, cmd := fp.Update(msg)
31 fp = screen.(*FilePicker)
32 fp, _ = drain(fp, cmd)
33 return fp
34}
35
36func initFilePicker(fp *FilePicker) *FilePicker {
37 fp.Init()
38 screen, _ := fp.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
39 return screen.(*FilePicker)
40}
41
42func TestFilePickerTitle(t *testing.T) {
43 t.Parallel()
44
45 fp := NewFilePicker(nil, func() string { return "abc123" }, testStyles())
46 if got := fp.Title(); got != "Select files to restore" {
47 t.Errorf("Title() = %q, want %q", got, "Select files to restore")
48 }
49}
50
51func TestFilePickerSelectionEmpty(t *testing.T) {
52 t.Parallel()
53
54 fp := NewFilePicker(nil, func() string { return "abc123" }, testStyles())
55 if got := fp.Selection(); got != "" {
56 t.Errorf("Selection() before interaction = %q, want empty", got)
57 }
58}
59
60func TestFilePickerKeyBindings(t *testing.T) {
61 t.Parallel()
62
63 fp := NewFilePicker(nil, func() string { return "abc123" }, testStyles())
64 if len(fp.KeyBindings()) == 0 {
65 t.Fatal("KeyBindings() returned no bindings")
66 }
67}
68
69func TestFilePickerLoadsAndPresents(t *testing.T) {
70 t.Parallel()
71
72 nodes := testNodes()
73 loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
74
75 fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
76 fp = initFilePicker(fp)
77 fp = simulateFileLoad(fp, nodes, nil)
78
79 // Should be in picking phase. The picker should show files.
80 if fp.phase != phaseFilePicking {
81 t.Fatalf("phase = %d, want %d (phaseFilePicking)", fp.phase, phaseFilePicking)
82 }
83
84 view := fp.View()
85 if view == "" {
86 t.Error("View() should not be empty in picking phase")
87 }
88}
89
90func TestFilePickerConfirmAllFiles(t *testing.T) {
91 t.Parallel()
92
93 nodes := testNodes()
94 loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
95
96 fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
97 fp = initFilePicker(fp)
98 fp = simulateFileLoad(fp, nodes, nil)
99
100 // Press enter to confirm with all files selected (default).
101 screen, cmd := fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
102 fp = screen.(*FilePicker)
103
104 if cmd == nil {
105 t.Fatal("expected DoneCmd after confirming all files")
106 }
107 if _, ok := cmd().(ui.DoneMsg); !ok {
108 t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
109 }
110
111 // All files selected → no includes.
112 if got := fp.Includes(); got != nil {
113 t.Errorf("Includes() = %v, want nil for full restore", got)
114 }
115
116 if got := fp.Selection(); got != "all files" {
117 t.Errorf("Selection() = %q, want %q", got, "all files")
118 }
119}
120
121func TestFilePickerErrorAutoAdvances(t *testing.T) {
122 t.Parallel()
123
124 loader := func(_ string) ([]restic.LsNode, error) {
125 return nil, errors.New("connection failed")
126 }
127
128 fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
129 fp = initFilePicker(fp)
130
131 // Simulate error result.
132 gen := atomic.LoadInt64(&fp.gen)
133 msg := filesLoadedMsg{gen: gen, nodes: nil, err: errors.New("connection failed")}
134 screen, _ := fp.Update(msg)
135 fp = screen.(*FilePicker)
136
137 // Should show a notice phase, not immediately DoneCmd.
138 if fp.phase != phaseFileNotice {
139 t.Fatalf("phase = %d, want %d (phaseFileNotice)", fp.phase, phaseFileNotice)
140 }
141 if fp.notice == "" {
142 t.Error("notice should be set on error")
143 }
144
145 // Press any key to acknowledge and advance.
146 screen, cmd := fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
147 fp = screen.(*FilePicker)
148
149 if cmd == nil {
150 t.Fatal("expected DoneCmd after acknowledging notice")
151 }
152 if _, ok := cmd().(ui.DoneMsg); !ok {
153 t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
154 }
155
156 // No includes — full restore.
157 if got := fp.Includes(); got != nil {
158 t.Errorf("Includes() = %v, want nil after error", got)
159 }
160}
161
162func TestFilePickerEmptyNodesAutoAdvances(t *testing.T) {
163 t.Parallel()
164
165 loader := func(_ string) ([]restic.LsNode, error) {
166 return []restic.LsNode{}, nil
167 }
168
169 fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
170 fp = initFilePicker(fp)
171
172 gen := atomic.LoadInt64(&fp.gen)
173 msg := filesLoadedMsg{gen: gen, nodes: []restic.LsNode{}, err: nil}
174 screen, _ := fp.Update(msg)
175 fp = screen.(*FilePicker)
176
177 // Should show notice phase.
178 if fp.phase != phaseFileNotice {
179 t.Fatalf("phase = %d, want %d (phaseFileNotice)", fp.phase, phaseFileNotice)
180 }
181
182 // Press key to advance.
183 screen, cmd := fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
184 _ = screen.(*FilePicker)
185
186 if cmd == nil {
187 t.Fatal("expected DoneCmd after acknowledging empty notice")
188 }
189 if _, ok := cmd().(ui.DoneMsg); !ok {
190 t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
191 }
192}
193
194func TestFilePickerNilLoaderAutoAdvances(t *testing.T) {
195 t.Parallel()
196
197 fp := NewFilePicker(nil, func() string { return "abc123" }, testStyles())
198 cmd := fp.Init()
199
200 // With nil loader, should immediately return DoneCmd.
201 if cmd == nil {
202 t.Fatal("expected DoneCmd from Init with nil loader")
203 }
204 if _, ok := cmd().(ui.DoneMsg); !ok {
205 t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
206 }
207}
208
209func TestFilePickerEscAtRootReturnsBack(t *testing.T) {
210 t.Parallel()
211
212 nodes := testNodes()
213 loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
214
215 fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
216 fp = initFilePicker(fp)
217 fp = simulateFileLoad(fp, nodes, nil)
218
219 // At root directory, Esc should return BackCmd.
220 _, cmd := fp.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
221
222 if cmd == nil {
223 t.Fatal("expected BackCmd on Esc at root")
224 }
225 if _, ok := cmd().(ui.BackMsg); !ok {
226 t.Errorf("cmd produced %T, want ui.BackMsg", cmd())
227 }
228}
229
230func TestFilePickerEscDuringLoadingReturnsBack(t *testing.T) {
231 t.Parallel()
232
233 loader := func(_ string) ([]restic.LsNode, error) { return nil, nil }
234
235 fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
236 fp = initFilePicker(fp)
237
238 _, cmd := fp.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
239
240 if cmd == nil {
241 t.Fatal("expected BackCmd on Esc during loading")
242 }
243 if _, ok := cmd().(ui.BackMsg); !ok {
244 t.Errorf("cmd produced %T, want ui.BackMsg", cmd())
245 }
246}
247
248func TestFilePickerStaleLoadIgnored(t *testing.T) {
249 t.Parallel()
250
251 nodes := testNodes()
252 loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
253
254 fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
255 fp = initFilePicker(fp)
256
257 staleGen := atomic.LoadInt64(&fp.gen)
258 fp.Init() // bumps generation
259
260 // Deliver stale result.
261 msg := filesLoadedMsg{gen: staleGen, nodes: nodes, err: nil}
262 screen, _ := fp.Update(msg)
263 fp = screen.(*FilePicker)
264
265 // Should still be loading.
266 if fp.phase != phaseFileLoading {
267 t.Fatalf("phase = %d, want %d (phaseFileLoading) after stale result", fp.phase, phaseFileLoading)
268 }
269}
270
271func TestFilePickerIncludesWithPartialSelection(t *testing.T) {
272 t.Parallel()
273
274 nodes := testNodes()
275 loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
276
277 fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
278 fp = initFilePicker(fp)
279 fp = simulateFileLoad(fp, nodes, nil)
280
281 // The picker should have files loaded. Toggle the first item
282 // (should be dir1, sorted dirs first) via space.
283 screen, cmd := fp.Update(tea.KeyPressMsg{Code: ' ', Text: " "})
284 fp = screen.(*FilePicker)
285 fp, _ = drain(fp, cmd)
286
287 // Confirm with enter.
288 screen, cmd = fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
289 fp = screen.(*FilePicker)
290
291 if cmd == nil {
292 t.Fatal("expected DoneCmd after partial selection confirm")
293 }
294
295 // Should have includes (partial selection).
296 includes := fp.Includes()
297 if includes == nil {
298 t.Error("Includes() should not be nil for partial selection")
299 }
300
301 // All includes should have leading "/".
302 for _, inc := range includes {
303 if inc[0] != '/' {
304 t.Errorf("include %q should start with /", inc)
305 }
306 }
307}
308
309func TestFilePickerSelectionShowsCount(t *testing.T) {
310 t.Parallel()
311
312 nodes := testNodes()
313 loader := func(_ string) ([]restic.LsNode, error) { return nodes, nil }
314
315 fp := NewFilePicker(loader, func() string { return "abc123" }, testStyles())
316 fp = initFilePicker(fp)
317 fp = simulateFileLoad(fp, nodes, nil)
318
319 // Toggle first item and confirm.
320 screen, cmd := fp.Update(tea.KeyPressMsg{Code: ' ', Text: " "})
321 fp = screen.(*FilePicker)
322 fp, _ = drain(fp, cmd)
323
324 screen, _ = fp.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
325 fp = screen.(*FilePicker)
326
327 sel := fp.Selection()
328 if sel == "" {
329 t.Error("Selection() should not be empty after partial selection")
330 }
331 if sel == "all files" {
332 t.Error("Selection() should not be 'all files' for partial selection")
333 }
334}
335
336func TestFilePickerSnapshotIDFromClosure(t *testing.T) {
337 t.Parallel()
338
339 var receivedID string
340 loader := func(snapshotID string) ([]restic.LsNode, error) {
341 receivedID = snapshotID
342 return testNodes(), nil
343 }
344
345 fp := NewFilePicker(loader, func() string { return "my-snapshot-id" }, testStyles())
346 fp = initFilePicker(fp)
347
348 // The loader should have received the snapshot ID from the closure.
349 // Since we're simulating, we need to actually call the loader.
350 // In the real flow, Init() creates a Cmd that calls the loader.
351 // Let's verify the snapshotID func is called properly by
352 // checking the stored value.
353 if fp.snapshotID != "my-snapshot-id" {
354 t.Errorf("snapshotID = %q, want %q", fp.snapshotID, "my-snapshot-id")
355 }
356
357 _ = receivedID // used by the loader
358}