session_test.go

  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}