snapshot_test.go

  1package screens
  2
  3import (
  4	"errors"
  5	"sync/atomic"
  6	"testing"
  7	"time"
  8
  9	tea "charm.land/bubbletea/v2"
 10
 11	"git.secluded.site/keld/internal/restic"
 12	"git.secluded.site/keld/internal/ui"
 13)
 14
 15func testSnapshots() []restic.Snapshot {
 16	return []restic.Snapshot{
 17		{
 18			ID:       "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
 19			ShortID:  "abcdef01",
 20			Time:     time.Date(2026, 3, 15, 10, 30, 0, 0, time.UTC),
 21			Hostname: "host1",
 22			Paths:    []string{"/home"},
 23			Tags:     []string{"daily"},
 24		},
 25		{
 26			ID:       "1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
 27			ShortID:  "12345678",
 28			Time:     time.Date(2026, 3, 14, 9, 0, 0, 0, time.UTC),
 29			Hostname: "host2",
 30			Paths:    []string{"/etc"},
 31		},
 32	}
 33}
 34
 35// simulateLoad sends a snapshotsLoadedMsg directly to the screen,
 36// bypassing tea.Batch. This is how tests deliver async results —
 37// in the real runtime, the Batch command produces this message.
 38func simulateLoad(s *Snapshot, snaps []restic.Snapshot, err error) *Snapshot {
 39	gen := atomic.LoadInt64(&s.gen)
 40	msg := snapshotsLoadedMsg{gen: gen, snapshots: snaps, err: err}
 41	screen, cmd := s.Update(msg)
 42	s = screen.(*Snapshot)
 43	// Drain any form init commands.
 44	s, _ = drain(s, cmd)
 45	return s
 46}
 47
 48// simulateStaleLoad sends a snapshotsLoadedMsg with an old generation,
 49// simulating a result from a prior Init that should be ignored.
 50func simulateStaleLoad(s *Snapshot, snaps []restic.Snapshot, err error, staleGen int64) *Snapshot {
 51	msg := snapshotsLoadedMsg{gen: staleGen, snapshots: snaps, err: err}
 52	screen, _ := s.Update(msg)
 53	return screen.(*Snapshot)
 54}
 55
 56func initSnapshot(s *Snapshot) *Snapshot {
 57	s.Init()
 58	screen, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
 59	return screen.(*Snapshot)
 60}
 61
 62func TestSnapshotTitle(t *testing.T) {
 63	t.Parallel()
 64
 65	s := NewSnapshot(nil, testStyles())
 66	if got := s.Title(); got != "Select a snapshot" {
 67		t.Errorf("Title() = %q, want %q", got, "Select a snapshot")
 68	}
 69}
 70
 71func TestSnapshotSelectionEmpty(t *testing.T) {
 72	t.Parallel()
 73
 74	s := NewSnapshot(nil, testStyles())
 75	if got := s.Selection(); got != "" {
 76		t.Errorf("Selection() before interaction = %q, want empty", got)
 77	}
 78}
 79
 80func TestSnapshotKeyBindings(t *testing.T) {
 81	t.Parallel()
 82
 83	s := NewSnapshot(nil, testStyles())
 84	if len(s.KeyBindings()) == 0 {
 85		t.Fatal("KeyBindings() returned no bindings")
 86	}
 87}
 88
 89func TestSnapshotLoadsAndPresentsSelect(t *testing.T) {
 90	t.Parallel()
 91
 92	snaps := testSnapshots()
 93	loader := func() ([]restic.Snapshot, error) { return snaps, nil }
 94
 95	s := NewSnapshot(loader, testStyles())
 96	s = initSnapshot(s)
 97	s = simulateLoad(s, snaps, nil)
 98
 99	// Should now be in the selecting phase. Press enter to pick first.
100	screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
101	s, cmd = drain(screen.(*Snapshot), cmd)
102
103	if cmd == nil {
104		t.Fatal("expected DoneCmd after selecting a snapshot")
105	}
106	if _, ok := cmd().(ui.DoneMsg); !ok {
107		t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
108	}
109
110	if got := s.Value(); got != "abcdef01" {
111		t.Errorf("Value() = %q, want %q", got, "abcdef01")
112	}
113}
114
115func TestSnapshotSelectionShowsShortID(t *testing.T) {
116	t.Parallel()
117
118	snaps := testSnapshots()
119	loader := func() ([]restic.Snapshot, error) { return snaps, nil }
120
121	s := NewSnapshot(loader, testStyles())
122	s = initSnapshot(s)
123	s = simulateLoad(s, snaps, nil)
124
125	// Select first snapshot.
126	screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
127	s, _ = drain(screen.(*Snapshot), cmd)
128
129	if got := s.Selection(); got != "abcdef01" {
130		t.Errorf("Selection() = %q, want %q", got, "abcdef01")
131	}
132}
133
134func TestSnapshotErrorFallsBackToManual(t *testing.T) {
135	t.Parallel()
136
137	netErr := errors.New("network error")
138	loader := func() ([]restic.Snapshot, error) { return nil, netErr }
139
140	s := NewSnapshot(loader, testStyles())
141	s = initSnapshot(s)
142	s = simulateLoad(s, nil, netErr)
143
144	// Should be in manual entry phase with error notice.
145	if s.notice == "" {
146		t.Error("notice should be set on error")
147	}
148
149	// Type a snapshot ID and submit.
150	for _, ch := range "latest" {
151		screen, _ := s.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)})
152		s = screen.(*Snapshot)
153	}
154
155	screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
156	s, cmd = drain(screen.(*Snapshot), cmd)
157
158	if cmd == nil {
159		t.Fatal("expected DoneCmd after manual entry")
160	}
161	if _, ok := cmd().(ui.DoneMsg); !ok {
162		t.Errorf("cmd produced %T, want ui.DoneMsg", cmd())
163	}
164
165	if got := s.Value(); got != "latest" {
166		t.Errorf("Value() = %q, want %q", got, "latest")
167	}
168}
169
170func TestSnapshotNoRepoFallsBackSilently(t *testing.T) {
171	t.Parallel()
172
173	loader := func() ([]restic.Snapshot, error) { return nil, restic.ErrNoRepo }
174
175	s := NewSnapshot(loader, testStyles())
176	s = initSnapshot(s)
177	s = simulateLoad(s, nil, restic.ErrNoRepo)
178
179	// Should be in manual entry phase with no error notice.
180	if s.notice != "" {
181		t.Errorf("notice = %q, want empty for ErrNoRepo (silent fallback)", s.notice)
182	}
183
184	// Type and submit.
185	for _, ch := range "abc123" {
186		screen, _ := s.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)})
187		s = screen.(*Snapshot)
188	}
189	screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
190	s, cmd = drain(screen.(*Snapshot), cmd)
191
192	if cmd == nil {
193		t.Fatal("expected DoneCmd after manual entry")
194	}
195	if got := s.Value(); got != "abc123" {
196		t.Errorf("Value() = %q, want %q", got, "abc123")
197	}
198}
199
200func TestSnapshotEmptyRepoFallsBackWithNotice(t *testing.T) {
201	t.Parallel()
202
203	loader := func() ([]restic.Snapshot, error) { return []restic.Snapshot{}, nil }
204
205	s := NewSnapshot(loader, testStyles())
206	s = initSnapshot(s)
207	s = simulateLoad(s, []restic.Snapshot{}, nil)
208
209	// Should show a notice about empty repo.
210	if s.notice == "" {
211		t.Error("notice should be set when repository has no snapshots")
212	}
213}
214
215func TestSnapshotNilLoaderGoesDirectToManual(t *testing.T) {
216	t.Parallel()
217
218	s := NewSnapshot(nil, testStyles())
219	cmd := s.Init()
220	screen, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 20})
221	s = screen.(*Snapshot)
222
223	// Drain any init commands from the manual form.
224	s, _ = drain(s, cmd)
225
226	// With no loader, should start in manual entry immediately.
227	for _, ch := range "latest" {
228		screen, _ := s.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)})
229		s = screen.(*Snapshot)
230	}
231	screen, cmd = s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
232	s, cmd = drain(screen.(*Snapshot), cmd)
233
234	if cmd == nil {
235		t.Fatal("expected DoneCmd after manual entry")
236	}
237	if got := s.Value(); got != "latest" {
238		t.Errorf("Value() = %q, want %q", got, "latest")
239	}
240}
241
242func TestSnapshotManualEntryFromSelect(t *testing.T) {
243	t.Parallel()
244
245	snaps := testSnapshots()
246	loader := func() ([]restic.Snapshot, error) { return snaps, nil }
247
248	s := NewSnapshot(loader, testStyles())
249	s = initSnapshot(s)
250	s = simulateLoad(s, snaps, nil)
251
252	// Navigate down to the "Enter ID manually…" option (last item,
253	// 2 snapshots + 1 manual = index 2, so press down twice).
254	screen, _ := s.Update(tea.KeyPressMsg{Code: tea.KeyDown})
255	s = screen.(*Snapshot)
256	screen, _ = s.Update(tea.KeyPressMsg{Code: tea.KeyDown})
257	s = screen.(*Snapshot)
258
259	// Select the manual entry option.
260	screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
261	s, _ = drain(screen.(*Snapshot), cmd)
262
263	// Should now be in manual entry phase. Type an ID.
264	for _, ch := range "abc123:subfolder" {
265		screen, _ := s.Update(tea.KeyPressMsg{Code: ch, Text: string(ch)})
266		s = screen.(*Snapshot)
267	}
268	screen, cmd = s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
269	s, cmd = drain(screen.(*Snapshot), cmd)
270
271	if cmd == nil {
272		t.Fatal("expected DoneCmd after manual entry from select")
273	}
274	if got := s.Value(); got != "abc123:subfolder" {
275		t.Errorf("Value() = %q, want %q", got, "abc123:subfolder")
276	}
277}
278
279func TestSnapshotEscDuringLoadingReturnsBack(t *testing.T) {
280	t.Parallel()
281
282	loader := func() ([]restic.Snapshot, error) { return nil, nil }
283
284	s := NewSnapshot(loader, testStyles())
285	s = initSnapshot(s)
286
287	// Press Esc during loading phase.
288	_, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
289
290	if cmd == nil {
291		t.Fatal("expected BackCmd on Esc during loading")
292	}
293	if _, ok := cmd().(ui.BackMsg); !ok {
294		t.Errorf("cmd produced %T, want ui.BackMsg", cmd())
295	}
296}
297
298func TestSnapshotEscDuringSelectReturnsBack(t *testing.T) {
299	t.Parallel()
300
301	snaps := testSnapshots()
302	loader := func() ([]restic.Snapshot, error) { return snaps, nil }
303
304	s := NewSnapshot(loader, testStyles())
305	s = initSnapshot(s)
306	s = simulateLoad(s, snaps, nil)
307
308	// Esc during selection should back out.
309	_, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
310
311	if cmd == nil {
312		t.Fatal("expected BackCmd on Esc during selection")
313	}
314	if _, ok := cmd().(ui.BackMsg); !ok {
315		t.Errorf("cmd produced %T, want ui.BackMsg", cmd())
316	}
317}
318
319func TestSnapshotStaleLoadIgnored(t *testing.T) {
320	t.Parallel()
321
322	snaps := testSnapshots()
323	loader := func() ([]restic.Snapshot, error) { return snaps, nil }
324
325	s := NewSnapshot(loader, testStyles())
326	s = initSnapshot(s)
327
328	// Record the current generation, then re-init to bump it.
329	staleGen := atomic.LoadInt64(&s.gen)
330	s.Init()
331
332	// Deliver a result with the stale generation. Should be ignored.
333	s = simulateStaleLoad(s, snaps, nil, staleGen)
334
335	// Should still be loading (stale result ignored).
336	if s.phase != phaseSnapshotLoading {
337		t.Fatalf("phase = %d, want %d (phaseSnapshotLoading) after stale result", s.phase, phaseSnapshotLoading)
338	}
339
340	// Deliver with the current generation.
341	s = simulateLoad(s, snaps, nil)
342
343	// Now should be in selecting phase.
344	screen, cmd := s.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
345	_, cmd = drain(screen.(*Snapshot), cmd)
346
347	if cmd == nil {
348		t.Fatal("expected DoneCmd after selecting from fresh load")
349	}
350}