filepicker_test.go

  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}