session_test.go

  1package ui
  2
  3import (
  4	"image/color"
  5	"strings"
  6	"testing"
  7
  8	"charm.land/bubbles/v2/key"
  9	tea "charm.land/bubbletea/v2"
 10
 11	"git.secluded.site/keld/internal/theme"
 12)
 13
 14// fakeScreen is a minimal Screen implementation for testing session
 15// navigation without depending on real UI components. It returns
 16// BackCmd on Esc to signal back-navigation, matching the expected
 17// behaviour for screens that don't use Esc internally.
 18type fakeScreen struct {
 19	title     string
 20	selection string
 21	initCalls int
 22}
 23
 24func newFake(title string) *fakeScreen {
 25	return &fakeScreen{title: title}
 26}
 27
 28func (f *fakeScreen) Init() tea.Cmd { f.initCalls++; return nil }
 29
 30func (f *fakeScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
 31	if kp, ok := msg.(tea.KeyPressMsg); ok {
 32		if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
 33			return f, BackCmd
 34		}
 35	}
 36	return f, nil
 37}
 38
 39func (f *fakeScreen) View() string               { return f.title + " view" }
 40func (f *fakeScreen) Title() string              { return f.title }
 41func (f *fakeScreen) KeyBindings() []key.Binding { return nil }
 42func (f *fakeScreen) Selection() string          { return f.selection }
 43
 44// complete marks the fake screen as having a selection for breadcrumb
 45// display purposes.
 46func (f *fakeScreen) complete(sel string) { f.selection = sel }
 47
 48// selectingScreen completes itself on any non-Esc key press by setting
 49// its selection and returning DoneCmd to signal advancement.
 50type selectingScreen struct {
 51	fakeScreen
 52}
 53
 54func newSelecting(title string) *selectingScreen {
 55	return &selectingScreen{fakeScreen: fakeScreen{title: title}}
 56}
 57
 58func (s *selectingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
 59	if kp, ok := msg.(tea.KeyPressMsg); ok {
 60		if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
 61			return s, BackCmd
 62		}
 63		s.selection = s.title
 64		return s, DoneCmd
 65	}
 66	return s, nil
 67}
 68
 69// escConsumingScreen handles Esc internally (e.g. like a file picker)
 70// and does not return BackCmd.
 71type escConsumingScreen struct {
 72	fakeScreen
 73	escHandled bool
 74}
 75
 76func newEscConsuming(title string) *escConsumingScreen {
 77	return &escConsumingScreen{fakeScreen: fakeScreen{title: title}}
 78}
 79
 80func (e *escConsumingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
 81	if kp, ok := msg.(tea.KeyPressMsg); ok {
 82		if key.Matches(kp, key.NewBinding(key.WithKeys("esc"))) {
 83			e.escHandled = true
 84			return e, nil // consume Esc, no BackMsg
 85		}
 86	}
 87	return e, nil
 88}
 89
 90// sizeCaptureScreen records the height from WindowSizeMsg for testing
 91// that the session adjusts dimensions correctly.
 92type sizeCaptureScreen struct {
 93	fakeScreen
 94	onSize func(int)
 95}
 96
 97func (s *sizeCaptureScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
 98	if wsm, ok := msg.(tea.WindowSizeMsg); ok {
 99		if s.onSize != nil {
100			s.onSize(wsm.Height)
101		}
102	}
103	return s.fakeScreen.Update(msg)
104}
105
106// defaultStyles returns a *theme.Styles for use in tests. Callers
107// pass this to both New() and any screen constructors that need it.
108func defaultStyles() *theme.Styles {
109	s := theme.New(true)
110	return &s
111}
112
113func escMsg() tea.Msg {
114	return tea.KeyPressMsg{Code: tea.KeyEscape}
115}
116
117func ctrlCMsg() tea.Msg {
118	return tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
119}
120
121func enterMsg() tea.Msg {
122	return tea.KeyPressMsg{Code: tea.KeyEnter}
123}
124
125// resizeMsg simulates a window resize — an unrelated background message.
126func resizeMsg() tea.Msg {
127	return tea.WindowSizeMsg{Width: 80, Height: 24}
128}
129
130func TestSessionInit(t *testing.T) {
131	t.Parallel()
132
133	a := newFake("first")
134	b := newFake("second")
135	s := New([]Screen{a, b}, defaultStyles())
136	s.Init()
137
138	if a.initCalls != 1 {
139		t.Errorf("first screen Init called %d times, want 1", a.initCalls)
140	}
141	if b.initCalls != 0 {
142		t.Errorf("second screen Init called %d times, want 0", b.initCalls)
143	}
144}
145
146func TestCtrlCExits(t *testing.T) {
147	t.Parallel()
148
149	s := New([]Screen{newFake("one")}, defaultStyles())
150	s.Init()
151
152	result, cmd := s.Update(ctrlCMsg())
153	sess := result.(Session)
154
155	if !sess.done {
156		t.Error("session should be done after Ctrl+C")
157	}
158	if cmd == nil {
159		t.Error("expected tea.Quit command")
160	}
161}
162
163func TestEscOnFirstScreenExits(t *testing.T) {
164	t.Parallel()
165
166	s := New([]Screen{newFake("one")}, defaultStyles())
167	s.Init()
168
169	// Esc is forwarded to the screen, which returns BackCmd.
170	result, _ := s.Update(escMsg())
171	// BackMsg triggers navigateBack.
172	result, cmd := result.(Session).Update(BackMsg{})
173	sess := result.(Session)
174
175	if !sess.done {
176		t.Error("Esc on first screen should exit")
177	}
178	if cmd == nil {
179		t.Error("expected tea.Quit command")
180	}
181}
182
183func TestEscNavigatesBack(t *testing.T) {
184	t.Parallel()
185
186	a := newFake("first")
187	b := newFake("second")
188	a.complete("picked")
189
190	s := New([]Screen{a, b}, defaultStyles())
191	s.cursor = 1
192
193	// Esc → screen returns BackCmd → session receives BackMsg.
194	result, _ := s.Update(escMsg())
195	result, _ = result.(Session).Update(BackMsg{})
196	sess := result.(Session)
197
198	if sess.cursor != 0 {
199		t.Errorf("cursor = %d, want 0 after Esc", sess.cursor)
200	}
201}
202
203func TestBackNavigationPreservesState(t *testing.T) {
204	t.Parallel()
205
206	a := newFake("first")
207	b := newFake("second")
208	a.complete("picked")
209
210	s := New([]Screen{a, b}, defaultStyles())
211	s.cursor = 1
212
213	result, _ := s.Update(escMsg())
214	result, _ = result.(Session).Update(BackMsg{})
215	sess := result.(Session)
216
217	screen := sess.screens[0].(*fakeScreen)
218	if screen.Selection() != "picked" {
219		t.Errorf("selection = %q, want %q after back navigation", screen.Selection(), "picked")
220	}
221}
222
223func TestBackNavDoesNotReAdvance(t *testing.T) {
224	t.Parallel()
225
226	a := newFake("first")
227	b := newFake("second")
228	a.complete("picked")
229
230	s := New([]Screen{a, b}, defaultStyles())
231	s.cursor = 1
232
233	// Navigate back to the first screen.
234	result, _ := s.Update(escMsg())
235	result, _ = result.(Session).Update(BackMsg{})
236	sess := result.(Session)
237
238	if sess.cursor != 0 {
239		t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor)
240	}
241
242	// Send an unrelated message (window resize). The session should
243	// NOT re-advance because advancement only happens via DoneMsg,
244	// not by polling Selection().
245	result, _ = sess.Update(resizeMsg())
246	sess = result.(Session)
247
248	if sess.cursor != 0 {
249		t.Errorf("cursor = %d after resize on completed screen, want 0 (should not re-advance)", sess.cursor)
250	}
251}
252
253func TestScreenAdvancesOnDoneMsg(t *testing.T) {
254	t.Parallel()
255
256	a := newSelecting("first")
257	b := newFake("second")
258
259	s := New([]Screen{a, b}, defaultStyles())
260	s.Init()
261
262	// Send a key to trigger selection on the first screen. The
263	// selectingScreen returns DoneCmd, which produces DoneMsg.
264	result, cmd := s.Update(enterMsg())
265	// The cmd contains DoneCmd; execute it to produce DoneMsg.
266	if cmd == nil {
267		t.Fatal("expected DoneCmd from selecting screen")
268	}
269	// Simulate the runtime delivering the DoneMsg.
270	result, _ = result.(Session).Update(DoneMsg{})
271	sess := result.(Session)
272
273	if sess.cursor != 1 {
274		t.Errorf("cursor = %d, want 1 after DoneMsg", sess.cursor)
275	}
276	if b.initCalls != 1 {
277		t.Errorf("second screen Init called %d times, want 1", b.initCalls)
278	}
279}
280
281func TestReAdvanceAfterBackAndNewSelection(t *testing.T) {
282	t.Parallel()
283
284	a := newSelecting("first")
285	b := newFake("second")
286
287	s := New([]Screen{a, b}, defaultStyles())
288	s.Init()
289
290	// Advance past the first screen.
291	result, _ := s.Update(enterMsg())
292	result, _ = result.(Session).Update(DoneMsg{})
293	sess := result.(Session)
294
295	if sess.cursor != 1 {
296		t.Fatalf("cursor = %d, want 1 after first advance", sess.cursor)
297	}
298
299	// Navigate back.
300	result, _ = sess.Update(escMsg())
301	result, _ = result.(Session).Update(BackMsg{})
302	sess = result.(Session)
303
304	if sess.cursor != 0 {
305		t.Fatalf("cursor = %d, want 0 after back nav", sess.cursor)
306	}
307
308	// Make a new selection. This should advance again.
309	result, _ = sess.Update(enterMsg())
310	result, _ = result.(Session).Update(DoneMsg{})
311	sess = result.(Session)
312
313	if sess.cursor != 1 {
314		t.Errorf("cursor = %d, want 1 after re-selection", sess.cursor)
315	}
316}
317
318func TestEscConsumedByScreen(t *testing.T) {
319	t.Parallel()
320
321	a := newFake("first")
322	b := newEscConsuming("picker")
323	a.complete("restore")
324
325	s := New([]Screen{a, b}, defaultStyles())
326	s.cursor = 1
327
328	// Esc is forwarded to the screen, which handles it internally
329	// and does NOT return BackCmd.
330	result, _ := s.Update(escMsg())
331	sess := result.(Session)
332
333	// Should still be on the same screen.
334	if sess.cursor != 1 {
335		t.Errorf("cursor = %d, want 1 (screen consumed Esc)", sess.cursor)
336	}
337
338	screen := sess.screens[1].(*escConsumingScreen)
339	if !screen.escHandled {
340		t.Error("screen should have handled Esc internally")
341	}
342}
343
344func TestBreadcrumbEmpty(t *testing.T) {
345	t.Parallel()
346
347	s := New([]Screen{newFake("one")}, defaultStyles())
348	crumb := s.breadcrumb()
349
350	if crumb != "" {
351		t.Errorf("breadcrumb on first screen = %q, want empty", crumb)
352	}
353}
354
355func TestBreadcrumbShowsCompletedSelections(t *testing.T) {
356	t.Parallel()
357
358	a := newFake("cmd")
359	b := newFake("preset")
360	c := newFake("details")
361
362	a.complete("restore")
363	b.complete("home@cloud")
364
365	s := New([]Screen{a, b, c}, defaultStyles())
366	s.cursor = 2
367
368	crumb := s.breadcrumb()
369	if !strings.Contains(crumb, "restore") {
370		t.Errorf("breadcrumb missing 'restore': %q", crumb)
371	}
372	if !strings.Contains(crumb, "home@cloud") {
373		t.Errorf("breadcrumb missing 'home@cloud': %q", crumb)
374	}
375}
376
377func TestBreadcrumbUpdatesOnBack(t *testing.T) {
378	t.Parallel()
379
380	a := newFake("cmd")
381	b := newFake("preset")
382	c := newFake("details")
383
384	a.complete("restore")
385	b.complete("home@cloud")
386
387	s := New([]Screen{a, b, c}, defaultStyles())
388	s.cursor = 2
389
390	result, _ := s.Update(escMsg())
391	result, _ = result.(Session).Update(BackMsg{})
392	sess := result.(Session)
393
394	crumb := sess.breadcrumb()
395	if !strings.Contains(crumb, "restore") {
396		t.Errorf("breadcrumb after back missing 'restore': %q", crumb)
397	}
398	// On screen b now — b's selection should not be in the crumb
399	// because the breadcrumb only shows screens before the current one.
400	if strings.Contains(crumb, "home@cloud") {
401		t.Errorf("breadcrumb after back should not contain current screen's selection: %q", crumb)
402	}
403}
404
405func TestViewIncludesChrome(t *testing.T) {
406	t.Parallel()
407
408	a := newFake("first")
409	a.complete("restore")
410	b := newFake("Second Step")
411
412	s := New([]Screen{a, b}, defaultStyles())
413	s.cursor = 1
414	s.width = 80
415	s.height = 24
416
417	view := s.View()
418
419	if !strings.Contains(view.Content, "Second Step") {
420		t.Error("view should contain the screen title")
421	}
422	if !strings.Contains(view.Content, "restore") {
423		t.Error("view should contain the breadcrumb")
424	}
425	if !strings.Contains(view.Content, "esc") {
426		t.Error("view should contain the help bar with esc binding")
427	}
428}
429
430func TestViewEmptyScreens(t *testing.T) {
431	t.Parallel()
432
433	s := New([]Screen{}, defaultStyles())
434	view := s.View()
435	if view.Content != "" {
436		t.Errorf("view with no screens should be empty, got %q", view.Content)
437	}
438}
439
440func TestBackNavReInitsScreen(t *testing.T) {
441	t.Parallel()
442
443	a := newFake("first")
444	b := newFake("second")
445	a.complete("cmd")
446
447	s := New([]Screen{a, b}, defaultStyles())
448	s.cursor = 1
449
450	result, _ := s.Update(escMsg())
451	result.(Session).Update(BackMsg{})
452
453	// After navigating back, the first screen should be re-inited.
454	if a.initCalls != 1 {
455		t.Errorf("first screen Init called %d times after back nav, want 1", a.initCalls)
456	}
457}
458
459func TestDoneOnLastScreenCompletes(t *testing.T) {
460	t.Parallel()
461
462	a := newSelecting("only")
463	s := New([]Screen{a}, defaultStyles())
464	s.Init()
465
466	// Select on the only screen — DoneMsg should complete the session.
467	result, _ := s.Update(enterMsg())
468	result, cmd := result.(Session).Update(DoneMsg{})
469	sess := result.(Session)
470
471	if !sess.done {
472		t.Error("session should be done after DoneMsg on last screen")
473	}
474	if !sess.Completed() {
475		t.Error("session should be completed (not cancelled)")
476	}
477	if cmd == nil {
478		t.Error("expected tea.Quit command")
479	}
480}
481
482func TestCtrlCIsNotCompleted(t *testing.T) {
483	t.Parallel()
484
485	s := New([]Screen{newFake("one")}, defaultStyles())
486	s.Init()
487
488	result, _ := s.Update(ctrlCMsg())
489	sess := result.(Session)
490
491	if !sess.done {
492		t.Error("session should be done after Ctrl+C")
493	}
494	if sess.Completed() {
495		t.Error("session should NOT be completed after cancellation")
496	}
497}
498
499func TestEscExitIsNotCompleted(t *testing.T) {
500	t.Parallel()
501
502	s := New([]Screen{newFake("one")}, defaultStyles())
503	s.Init()
504
505	result, _ := s.Update(escMsg())
506	result, _ = result.(Session).Update(BackMsg{})
507	sess := result.(Session)
508
509	if !sess.done {
510		t.Error("session should be done after Esc on first screen")
511	}
512	if sess.Completed() {
513		t.Error("session should NOT be completed after Esc exit")
514	}
515}
516
517func TestWindowSizeAdjustedForChrome(t *testing.T) {
518	t.Parallel()
519
520	var receivedHeight int
521	screen := &sizeCaptureScreen{
522		fakeScreen: fakeScreen{title: "test"},
523		onSize:     func(h int) { receivedHeight = h },
524	}
525
526	s := New([]Screen{screen}, defaultStyles())
527	s.Init()
528
529	s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
530
531	wantHeight := 30 - chromeLines
532	if receivedHeight != wantHeight {
533		t.Errorf("screen received height %d, want %d (terminal height minus chrome)", receivedHeight, wantHeight)
534	}
535}
536
537// themeAwareScreen holds a *theme.Styles pointer, as real screen
538// implementations will. This lets us verify that the session's theme
539// update propagates through the shared pointer.
540type themeAwareScreen struct {
541	fakeScreen
542	styles *theme.Styles
543}
544
545// bgCapturingScreen records whether it received a BackgroundColorMsg.
546type bgCapturingScreen struct {
547	fakeScreen
548	onBg func()
549}
550
551func (b *bgCapturingScreen) Update(msg tea.Msg) (Screen, tea.Cmd) {
552	if _, ok := msg.(tea.BackgroundColorMsg); ok {
553		if b.onBg != nil {
554			b.onBg()
555		}
556		return b, nil
557	}
558	return b.fakeScreen.Update(msg)
559}
560
561func newThemeAware(title string, styles *theme.Styles) *themeAwareScreen {
562	return &themeAwareScreen{
563		fakeScreen: fakeScreen{title: title},
564		styles:     styles,
565	}
566}
567
568func TestThemeUpdatePropagesToScreens(t *testing.T) {
569	t.Parallel()
570
571	// Caller owns the styles allocation and passes the same pointer
572	// to both screen constructors and the session.
573	styles := defaultStyles()
574	screen := newThemeAware("test", styles)
575	s := New([]Screen{screen}, styles)
576
577	if !screen.styles.Dark {
578		t.Fatal("precondition: styles should default to dark")
579	}
580
581	// Simulate a light terminal background detection.
582	result, _ := s.Update(tea.BackgroundColorMsg{Color: color.White})
583	_ = result.(Session)
584
585	// The screen's pointer should now see the updated (light) styles
586	// because the session mutated through the shared pointer.
587	if screen.styles.Dark {
588		t.Error("screen still sees dark styles after light BackgroundColorMsg; session replaced pointer instead of mutating in place")
589	}
590}
591
592func TestBackgroundColorForwardedToScreen(t *testing.T) {
593	t.Parallel()
594
595	// Screens that cache themed state need to see BackgroundColorMsg
596	// so they can rebuild. Verify the session forwards it.
597	var received bool
598	screen := &bgCapturingScreen{
599		fakeScreen: fakeScreen{title: "test"},
600		onBg:       func() { received = true },
601	}
602
603	styles := defaultStyles()
604	s := New([]Screen{screen}, styles)
605	s.Init()
606
607	s.Update(tea.BackgroundColorMsg{Color: color.White})
608
609	if !received {
610		t.Error("BackgroundColorMsg was not forwarded to the active screen")
611	}
612}
613
614func TestExtendAppendsScreens(t *testing.T) {
615	t.Parallel()
616
617	a := newSelecting("first")
618	s := New([]Screen{a}, defaultStyles())
619	s.Init()
620
621	// Extend the session with two more screens before the current
622	// screen completes.
623	b := newFake("second")
624	c := newFake("third")
625	result, _ := s.Update(ExtendMsg{Screens: []Screen{b, c}})
626	sess := result.(Session)
627
628	if len(sess.screens) != 3 {
629		t.Fatalf("screens = %d, want 3 after extend", len(sess.screens))
630	}
631
632	// The cursor should still be on the first screen.
633	if sess.cursor != 0 {
634		t.Errorf("cursor = %d, want 0 (extend should not advance)", sess.cursor)
635	}
636}
637
638func TestExtendedScreensReachableViaAdvance(t *testing.T) {
639	t.Parallel()
640
641	a := newSelecting("first")
642	s := New([]Screen{a}, defaultStyles())
643	s.Init()
644
645	// Extend before completing the first screen.
646	b := newFake("second")
647	result, _ := s.Update(ExtendMsg{Screens: []Screen{b}})
648	sess := result.(Session)
649
650	// Complete the first screen. Without extend, this would finish
651	// the session. With extend, it should advance to b.
652	result, _ = sess.Update(enterMsg())
653	result, _ = result.(Session).Update(DoneMsg{})
654	sess = result.(Session)
655
656	if sess.cursor != 1 {
657		t.Errorf("cursor = %d, want 1 after advance to extended screen", sess.cursor)
658	}
659	if sess.done {
660		t.Error("session should not be done — extended screen is still pending")
661	}
662}
663
664func TestExtendedScreensGetSizeOnActivation(t *testing.T) {
665	t.Parallel()
666
667	var receivedHeight int
668	a := newSelecting("first")
669	b := &sizeCaptureScreen{
670		fakeScreen: fakeScreen{title: "extended"},
671		onSize:     func(h int) { receivedHeight = h },
672	}
673
674	s := New([]Screen{a}, defaultStyles())
675	s.Init()
676
677	// Set terminal size, then extend, then advance.
678	result, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
679	sess := result.(Session)
680
681	result, _ = sess.Update(ExtendMsg{Screens: []Screen{b}})
682	sess = result.(Session)
683
684	result, _ = sess.Update(enterMsg())
685	result.(Session).Update(DoneMsg{})
686
687	wantHeight := 30 - chromeLines
688	if receivedHeight != wantHeight {
689		t.Errorf("extended screen received height %d, want %d", receivedHeight, wantHeight)
690	}
691}
692
693func TestExtendEmptySliceIsNoop(t *testing.T) {
694	t.Parallel()
695
696	a := newFake("only")
697	s := New([]Screen{a}, defaultStyles())
698	s.Init()
699
700	result, _ := s.Update(ExtendMsg{Screens: nil})
701	sess := result.(Session)
702
703	if len(sess.screens) != 1 {
704		t.Errorf("screens = %d, want 1 after empty extend", len(sess.screens))
705	}
706}
707
708func TestExtendBackNavThroughExtendedScreens(t *testing.T) {
709	t.Parallel()
710
711	a := newSelecting("first")
712	b := newFake("extended")
713
714	s := New([]Screen{a}, defaultStyles())
715	s.Init()
716
717	// Extend, advance, then back-nav.
718	result, _ := s.Update(ExtendMsg{Screens: []Screen{b}})
719	sess := result.(Session)
720
721	// Advance past first screen.
722	result, _ = sess.Update(enterMsg())
723	result, _ = result.(Session).Update(DoneMsg{})
724	sess = result.(Session)
725
726	if sess.cursor != 1 {
727		t.Fatalf("cursor = %d, want 1 before back nav", sess.cursor)
728	}
729
730	// Back nav should return to the first screen.
731	result, _ = sess.Update(escMsg())
732	result, _ = result.(Session).Update(BackMsg{})
733	sess = result.(Session)
734
735	if sess.cursor != 0 {
736		t.Errorf("cursor = %d, want 0 after back through extended screen", sess.cursor)
737	}
738}
739
740func TestExtendTruncatesScreensBeyondCursor(t *testing.T) {
741	t.Parallel()
742
743	// If the user backs up and a new ExtendMsg arrives, screens after
744	// the cursor should be replaced by the new ones (the old future
745	// is no longer valid).
746	a := newSelecting("cmd")
747	b := newFake("old-future")
748
749	s := New([]Screen{a, b}, defaultStyles())
750	s.Init()
751
752	// Advance to b, then back to a.
753	result, _ := s.Update(enterMsg())
754	result, _ = result.(Session).Update(DoneMsg{})
755	sess := result.(Session)
756
757	result, _ = sess.Update(escMsg())
758	result, _ = result.(Session).Update(BackMsg{})
759	sess = result.(Session)
760
761	// Now extend with new screens. The old b should be replaced.
762	c := newFake("new-future-1")
763	d := newFake("new-future-2")
764	result, _ = sess.Update(ExtendMsg{Screens: []Screen{c, d}})
765	sess = result.(Session)
766
767	// Should have a + c + d = 3 screens, not a + b + c + d.
768	if len(sess.screens) != 3 {
769		t.Errorf("screens = %d, want 3 (old future should be replaced)", len(sess.screens))
770	}
771
772	// The screen at index 1 should be c, not b.
773	if sess.screens[1].Title() != "new-future-1" {
774		t.Errorf("screen[1].Title() = %q, want %q", sess.screens[1].Title(), "new-future-1")
775	}
776}
777
778func TestSizeReplayedOnAdvance(t *testing.T) {
779	t.Parallel()
780
781	var receivedHeight int
782	a := newSelecting("first")
783	b := &sizeCaptureScreen{
784		fakeScreen: fakeScreen{title: "second"},
785		onSize:     func(h int) { receivedHeight = h },
786	}
787
788	s := New([]Screen{a, b}, defaultStyles())
789	s.Init()
790
791	// Set terminal size so the session caches it.
792	result, _ := s.Update(tea.WindowSizeMsg{Width: 80, Height: 30})
793	sess := result.(Session)
794
795	// Advance to second screen. The session sends the adjusted size
796	// directly to the new screen during advance().
797	result, _ = sess.Update(enterMsg())
798	result.(Session).Update(DoneMsg{})
799
800	wantHeight := 30 - chromeLines
801	if receivedHeight != wantHeight {
802		t.Errorf("second screen received height %d after advance, want %d", receivedHeight, wantHeight)
803	}
804}