1package ui
2
3import (
4 "strings"
5 "testing"
6
7 "charm.land/bubbles/v2/key"
8 tea "charm.land/bubbletea/v2"
9)
10
11// fakeScreen is a minimal Screen implementation for testing session
12// navigation without depending on real UI components. It returns
13// BackCmd on Esc to signal back-navigation, matching the expected
14// behaviour for screens that don't use Esc internally.
15type fakeScreen struct {
16 title string
17 selection string
18 initCalls int
19}
20
21func newFake(title string) *fakeScreen {
22 return &fakeScreen{title: title}
23}
24
25func (f *fakeScreen) Init() tea.Cmd { f.initCalls++; return nil }
26
27func (f *fakeScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
28 if kp, ok := msg.(tea.KeyPressMsg); ok {
29 if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
30 return f, BackCmd
31 }
32 }
33 return f, nil
34}
35
36func (f *fakeScreen) View() string { return f.title + " view" }
37func (f *fakeScreen) Title() string { return f.title }
38func (f *fakeScreen) KeyBindings() []key.Binding { return nil }
39func (f *fakeScreen) Selection() string { return f.selection }
40
41// complete marks the fake screen as having a selection for breadcrumb
42// display purposes.
43func (f *fakeScreen) complete(sel string) { f.selection = sel }
44
45// selectingScreen completes itself on any non-Esc key press by setting
46// its selection and returning DoneCmd to signal advancement.
47type selectingScreen struct {
48 fakeScreen
49}
50
51func newSelecting(title string) *selectingScreen {
52 return &selectingScreen{fakeScreen: fakeScreen{title: title}}
53}
54
55func (s *selectingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
56 if kp, ok := msg.(tea.KeyPressMsg); ok {
57 if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
58 return s, BackCmd
59 }
60 s.selection = s.title
61 return s, DoneCmd
62 }
63 return s, nil
64}
65
66// escConsumingScreen handles Esc internally (e.g. like a file picker)
67// and does not return BackCmd.
68type escConsumingScreen struct {
69 fakeScreen
70 escHandled bool
71}
72
73func newEscConsuming(title string) *escConsumingScreen {
74 return &escConsumingScreen{fakeScreen: fakeScreen{title: title}}
75}
76
77func (e *escConsumingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
78 if kp, ok := msg.(tea.KeyPressMsg); ok {
79 if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
80 e.escHandled = true
81 return e, nil // consume Esc, no BackMsg
82 }
83 }
84 return e, nil
85}
86
87// sizeCaptureScreen records the height from WindowSizeMsg for testing
88// that the session adjusts dimensions correctly.
89type sizeCaptureScreen struct {
90 fakeScreen
91 onSize func(int)
92}
93
94func (s *sizeCaptureScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
95 if wsm, ok := msg.(tea.WindowSizeMsg); ok {
96 if s.onSize != nil {
97 s.onSize(wsm.Height)
98 }
99 }
100 return s.fakeScreen.Update(msg)
101}
102
103func escMsg() tea.Msg {
104 return tea.KeyPressMsg{Code: tea.KeyEscape}
105}
106
107func ctrlCMsg() tea.Msg {
108 return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
109}
110
111func enterMsg() tea.Msg {
112 return tea.KeyPressMsg{Code: tea.KeyEnter}
113}
114
115// resizeMsg simulates a window resize — an unrelated background message.
116func resizeMsg() tea.Msg {
117 return tea.WindowSizeMsg{Width: 80, Height: 24}
118}
119
120func TestSessionInit(t *testing.T) {
121 t.Parallel()
122
123 a := newFake("first")
124 b := newFake("second")
125 s := New([]Screen{a, b})
126 s.Init()
127
128 if a.initCalls != 1 {
129 t.Errorf("first screen Init called %d times, want 1", a.initCalls)
130 }
131 if b.initCalls != 0 {
132 t.Errorf("second screen Init called %d times, want 0", b.initCalls)
133 }
134}
135
136func TestCtrlCExits(t *testing.T) {
137 t.Parallel()
138
139 s := New([]Screen{newFake("one")})
140 s.Init()
141
142 result, cmd := s.Update(ctrlCMsg())
143 sess := result.(Session)
144
145 if !sess.done {
146 t.Error("session should be done after Ctrl+C")
147 }
148 if cmd == nil {
149 t.Error("expected tea.Quit command")
150 }
151}
152
153func TestEscOnFirstScreenExits(t *testing.T) {
154 t.Parallel()
155
156 s := New([]Screen{newFake("one")})
157 s.Init()
158
159 // Esc is forwarded to the screen, which returns BackCmd.
160 result, _ := s.Update(escMsg())
161 // BackMsg triggers navigateBack.
162 result, cmd := result.(Session).Update(BackMsg{})
163 sess := result.(Session)
164
165 if !sess.done {
166 t.Error("Esc on first screen should exit")
167 }
168 if cmd == nil {
169 t.Error("expected tea.Quit command")
170 }
171}
172
173func TestEscNavigatesBack(t *testing.T) {
174 t.Parallel()
175
176 a := newFake("first")
177 b := newFake("second")
178 a.complete("picked")
179
180 s := New([]Screen{a, b})
181 s.cursor = 1
182
183 // Esc → screen returns BackCmd → session receives BackMsg.
184 result, _ := s.Update(escMsg())
185 result, _ = result.(Session).Update(BackMsg{})
186 sess := result.(Session)
187
188 if sess.cursor != 0 {
189 t.Errorf("cursor = %d, want 0 after Esc", sess.cursor)
190 }
191}
192
193func TestBackNavigationPreservesState(t *testing.T) {
194 t.Parallel()
195
196 a := newFake("first")
197 b := newFake("second")
198 a.complete("picked")
199
200 s := New([]Screen{a, b})
201 s.cursor = 1
202
203 result, _ := s.Update(escMsg())
204 result, _ = result.(Session).Update(BackMsg{})
205 sess := result.(Session)
206
207 screen := sess.screens[0].(*fakeScreen)
208 if screen.Selection() != "picked" {
209 t.Errorf("selection = %q, want %q after back navigation", screen.Selection(), "picked")
210 }
211}
212
213func TestBackNavDoesNotReAdvance(t *testing.T) {
214 t.Parallel()
215
216 a := newFake("first")
217 b := newFake("second")
218 a.complete("picked")
219
220 s := New([]Screen{a, b})
221 s.cursor = 1
222
223 // Navigate back to the first screen.
224 result, _ := s.Update(escMsg())
225 result, _ = result.(Session).Update(BackMsg{})
226 sess := result.(Session)
227
228 if sess.cursor != 0 {
229 t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor)
230 }
231
232 // Send an unrelated message (window resize). The session should
233 // NOT re-advance because advancement only happens via DoneMsg,
234 // not by polling Selection().
235 result, _ = sess.Update(resizeMsg())
236 sess = result.(Session)
237
238 if sess.cursor != 0 {
239 t.Errorf("cursor = %d after resize on completed screen, want 0 (should not re-advance)", sess.cursor)
240 }
241}
242
243func TestScreenAdvancesOnDoneMsg(t *testing.T) {
244 t.Parallel()
245
246 a := newSelecting("first")
247 b := newFake("second")
248
249 s := New([]Screen{a, b})
250 s.Init()
251
252 // Send a key to trigger selection on the first screen. The
253 // selectingScreen returns DoneCmd, which produces DoneMsg.
254 result, cmd := s.Update(enterMsg())
255 // The cmd contains DoneCmd; execute it to produce DoneMsg.
256 if cmd == nil {
257 t.Fatal("expected DoneCmd from selecting screen")
258 }
259 // Simulate the runtime delivering the DoneMsg.
260 result, _ = result.(Session).Update(DoneMsg{})
261 sess := result.(Session)
262
263 if sess.cursor != 1 {
264 t.Errorf("cursor = %d, want 1 after DoneMsg", sess.cursor)
265 }
266 if b.initCalls != 1 {
267 t.Errorf("second screen Init called %d times, want 1", b.initCalls)
268 }
269}
270
271func TestReAdvanceAfterBackAndNewSelection(t *testing.T) {
272 t.Parallel()
273
274 a := newSelecting("first")
275 b := newFake("second")
276
277 s := New([]Screen{a, b})
278 s.Init()
279
280 // Advance past the first screen.
281 result, _ := s.Update(enterMsg())
282 result, _ = result.(Session).Update(DoneMsg{})
283 sess := result.(Session)
284
285 if sess.cursor != 1 {
286 t.Fatalf("cursor = %d, want 1 after first advance", sess.cursor)
287 }
288
289 // Navigate back.
290 result, _ = sess.Update(escMsg())
291 result, _ = result.(Session).Update(BackMsg{})
292 sess = result.(Session)
293
294 if sess.cursor != 0 {
295 t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor)
296 }
297
298 // Make a new selection. This should advance again.
299 result, _ = sess.Update(enterMsg())
300 result, _ = result.(Session).Update(DoneMsg{})
301 sess = result.(Session)
302
303 if sess.cursor != 1 {
304 t.Errorf("cursor = %d, want 1 after re-selection", sess.cursor)
305 }
306}
307
308func TestEscConsumedByScreen(t *testing.T) {
309 t.Parallel()
310
311 a := newFake("first")
312 b := newEscConsuming("picker")
313 a.complete("restore")
314
315 s := New([]Screen{a, b})
316 s.cursor = 1
317
318 // Esc is forwarded to the screen, which handles it internally
319 // and does NOT return BackCmd.
320 result, _ := s.Update(escMsg())
321 sess := result.(Session)
322
323 // Should still be on the same screen.
324 if sess.cursor != 1 {
325 t.Errorf("cursor = %d, want 1 (screen consumed Esc)", sess.cursor)
326 }
327
328 screen := sess.screens[1].(*escConsumingScreen)
329 if !screen.escHandled {
330 t.Error("screen should have handled Esc internally")
331 }
332}
333
334func TestBreadcrumbEmpty(t *testing.T) {
335 t.Parallel()
336
337 s := New([]Screen{newFake("one")})
338 crumb := s.breadcrumb()
339
340 if crumb != "" {
341 t.Errorf("breadcrumb on first screen = %q, want empty", crumb)
342 }
343}
344
345func TestBreadcrumbShowsCompletedSelections(t *testing.T) {
346 t.Parallel()
347
348 a := newFake("cmd")
349 b := newFake("preset")
350 c := newFake("details")
351
352 a.complete("restore")
353 b.complete("home@cloud")
354
355 s := New([]Screen{a, b, c})
356 s.cursor = 2
357
358 crumb := s.breadcrumb()
359 if !strings.Contains(crumb, "restore") {
360 t.Errorf("breadcrumb missing 'restore': %q", crumb)
361 }
362 if !strings.Contains(crumb, "home@cloud") {
363 t.Errorf("breadcrumb missing 'home@cloud': %q", crumb)
364 }
365}
366
367func TestBreadcrumbUpdatesOnBack(t *testing.T) {
368 t.Parallel()
369
370 a := newFake("cmd")
371 b := newFake("preset")
372 c := newFake("details")
373
374 a.complete("restore")
375 b.complete("home@cloud")
376
377 s := New([]Screen{a, b, c})
378 s.cursor = 2
379
380 result, _ := s.Update(escMsg())
381 result, _ = result.(Session).Update(BackMsg{})
382 sess := result.(Session)
383
384 crumb := sess.breadcrumb()
385 if !strings.Contains(crumb, "restore") {
386 t.Errorf("breadcrumb after back missing 'restore': %q", crumb)
387 }
388 // On screen b now — b's selection should not be in the crumb
389 // because the breadcrumb only shows screens before the current one.
390 if strings.Contains(crumb, "home@cloud") {
391 t.Errorf("breadcrumb after back should not contain current screen's selection: %q", crumb)
392 }
393}
394
395func TestViewIncludesChrome(t *testing.T) {
396 t.Parallel()
397
398 a := newFake("first")
399 a.complete("restore")
400 b := newFake("Second Step")
401
402 s := New([]Screen{a, b})
403 s.cursor = 1
404 s.width = 80
405 s.height = 24
406
407 view := s.View()
408
409 if !strings.Contains(view.Content, "Second Step") {
410 t.Error("view should contain the screen title")
411 }
412 if !strings.Contains(view.Content, "restore") {
413 t.Error("view should contain the breadcrumb")
414 }
415 if !strings.Contains(view.Content, "esc") {
416 t.Error("view should contain the help bar with esc binding")
417 }
418}
419
420func TestViewEmptyScreens(t *testing.T) {
421 t.Parallel()
422
423 s := New([]Screen{})
424 view := s.View()
425 if view.Content != "" {
426 t.Errorf("view with no screens should be empty, got %q", view.Content)
427 }
428}
429
430func TestBackNavReInitsScreen(t *testing.T) {
431 t.Parallel()
432
433 a := newFake("first")
434 b := newFake("second")
435 a.complete("cmd")
436
437 s := New([]Screen{a, b})
438 s.cursor = 1
439
440 result, _ := s.Update(escMsg())
441 result.(Session).Update(BackMsg{})
442
443 // After navigating back, the first screen should be re-inited.
444 if a.initCalls != 1 {
445 t.Errorf("first screen Init called %d times after back nav, want 1", a.initCalls)
446 }
447}
448
449func TestDoneOnLastScreenCompletes(t *testing.T) {
450 t.Parallel()
451
452 a := newSelecting("only")
453 s := New([]Screen{a})
454 s.Init()
455
456 // Select on the only screen — DoneMsg should complete the session.
457 result, _ := s.Update(enterMsg())
458 result, cmd := result.(Session).Update(DoneMsg{})
459 sess := result.(Session)
460
461 if !sess.done {
462 t.Error("session should be done after DoneMsg on last screen")
463 }
464 if !sess.Completed() {
465 t.Error("session should be completed (not cancelled)")
466 }
467 if cmd == nil {
468 t.Error("expected tea.Quit command")
469 }
470}
471
472func TestCtrlCIsNotCompleted(t *testing.T) {
473 t.Parallel()
474
475 s := New([]Screen{newFake("one")})
476 s.Init()
477
478 result, _ := s.Update(ctrlCMsg())
479 sess := result.(Session)
480
481 if !sess.done {
482 t.Error("session should be done after Ctrl+C")
483 }
484 if sess.Completed() {
485 t.Error("session should NOT be completed after cancellation")
486 }
487}
488
489func TestEscExitIsNotCompleted(t *testing.T) {
490 t.Parallel()
491
492 s := New([]Screen{newFake("one")})
493 s.Init()
494
495 result, _ := s.Update(escMsg())
496 result, _ = result.(Session).Update(BackMsg{})
497 sess := result.(Session)
498
499 if !sess.done {
500 t.Error("session should be done after Esc on first screen")
501 }
502 if sess.Completed() {
503 t.Error("session should NOT be completed after Esc exit")
504 }
505}
506
507func TestWindowSizeAdjustedForChrome(t *testing.T) {
508 t.Parallel()
509
510 var receivedHeight int
511 screen := &sizeCaptureScreen{
512 fakeScreen: fakeScreen{title: "test"},
513 onSize: func(h int) { receivedHeight = h },
514 }
515
516 s := New([]Screen{screen})
517 s.Init()
518
519 s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
520
521 wantHeight := 30 - chromeLines
522 if receivedHeight != wantHeight {
523 t.Errorf("screen received height %d, want %d (terminal height minus chrome)", receivedHeight, wantHeight)
524 }
525}
526
527func TestSizeReplayedOnAdvance(t *testing.T) {
528 t.Parallel()
529
530 var receivedHeight int
531 a := newSelecting("first")
532 b := &sizeCaptureScreen{
533 fakeScreen: fakeScreen{title: "second"},
534 onSize: func(h int) { receivedHeight = h },
535 }
536
537 s := New([]Screen{a, b})
538 s.Init()
539
540 // Set terminal size so the session caches it.
541 result, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
542 sess := result.(Session)
543
544 // Advance to second screen. The session sends the adjusted size
545 // directly to the new screen during advance().
546 result, _ = sess.Update(enterMsg())
547 result.(Session).Update(DoneMsg{})
548
549 wantHeight := 30 - chromeLines
550 if receivedHeight != wantHeight {
551 t.Errorf("second screen received height %d after advance, want %d", receivedHeight, wantHeight)
552 }
553}