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}