@@ -109,7 +109,9 @@ def bd_page(browser: Browser, env):
109109 try :
110110 page .goto (f"{ env .base_url } /" )
111111 goto_view (page , "body-double" )
112- page .wait_for_selector ("#bd-focus" , state = "visible" , timeout = 15_000 )
112+ # The picker, not a panel: it is the one thing an idle Body Double view
113+ # must always show.
114+ page .wait_for_selector ("#bd-activity-input" , state = "visible" , timeout = 15_000 )
113115 yield page
114116 finally :
115117 ctx .close ()
@@ -420,6 +422,239 @@ def test_the_panes_are_still_usable_after_the_session_ends(self, bd_page: Page)
420422 raise
421423
422424
425+ # ---------------------------------------------------------------------------
426+ # Body doubling is for anything — and a live session is the agent's screen
427+ # ---------------------------------------------------------------------------
428+
429+
430+ def _park_via_api (env , question : str ) -> None :
431+ import requests
432+
433+ response = requests .post (
434+ f"{ env .base_url } /api/parking/item" , json = {"question" : question , "notes" : "" }, timeout = 15
435+ )
436+ assert response .status_code in (200 , 201 ), response .text
437+
438+
439+ def _clear_parking (env ) -> None :
440+ import requests
441+
442+ with contextlib .suppress (Exception ):
443+ requests .post (
444+ f"{ env .base_url } /api/parking/clear" , json = {"all" : True , "hard" : True }, timeout = 15
445+ )
446+
447+
448+ def _end_any_session (env ) -> None :
449+ with contextlib .suppress (Exception ):
450+ urllib .request .urlopen (
451+ urllib .request .Request (f"{ env .base_url } /api/session/end" , data = b"" , method = "POST" ),
452+ timeout = 10 ,
453+ )
454+
455+
456+ _STUDY_TOPICS = ("Spark shuffle" , "SQL window functions" , "dbt tests" , "Glue bookmarks" )
457+
458+ _LIVE_GEOMETRY_JS = """() => {
459+ const rect = (selector) => {
460+ const el = document.querySelector(selector);
461+ if (!el) return null;
462+ const r = el.getBoundingClientRect();
463+ if (r.width === 0 && r.height === 0) return null;
464+ return {top: r.top, bottom: r.bottom, left: r.left, right: r.right, height: r.height};
465+ };
466+ return {
467+ innerHeight: window.innerHeight,
468+ content: rect('.content-area'),
469+ strip: rect('.bd-live-strip'),
470+ console: rect('.bd-console-panel'),
471+ viewHeader: rect('.body-double-view .body-double-header'),
472+ timerBlock: rect('.body-double-view .body-double-timer'),
473+ capture: rect('#bd-capture'),
474+ fab: rect('.quick-park-btn'),
475+ };
476+ }"""
477+
478+
479+ def _overlaps (a : dict , b : dict , slack : float = 0.5 ) -> bool :
480+ return (
481+ a ["left" ] < b ["right" ] - slack
482+ and b ["left" ] < a ["right" ] - slack
483+ and a ["top" ] < b ["bottom" ] - slack
484+ and b ["top" ] < a ["bottom" ] - slack
485+ )
486+
487+
488+ class TestBodyDoubleIsForAnything :
489+ """Reported 2026-09-28 with screenshots: "I should be able to run a body
490+ double session for anything I'm doing, no restrictions or list of Focus
491+ areas". The Focus card listed the three most recent pending STUDY topics
492+ with an "at capacity" chip, above the picker and above the live terminal.
493+ The activity field was always free text, so the card restricted nothing; it
494+ only looked as if it did, on a surface whose own spec says body doubling is
495+ not a new study thread (ADR-0003)."""
496+
497+ def test_the_surface_lists_no_study_topics (self , bd_page : Page , env ) -> None :
498+ try :
499+ # Fill every slot first, so a Focus card that still existed would have
500+ # to show topics and its at-capacity chip.
501+ for topic in _STUDY_TOPICS :
502+ _park_via_api (env , topic )
503+ bd_page .reload ()
504+ goto_view (bd_page , "body-double" )
505+ bd_page .wait_for_selector ("#bd-activity-input" , state = "visible" , timeout = 15_000 )
506+ bd_page .wait_for_function (
507+ "() => window.Alpine.$data(document.querySelector("
508+ "'[x-data=\" bodyDoubleSession()\" ]'))._initDone === true" ,
509+ timeout = 15_000 ,
510+ )
511+ view = bd_page .locator (".body-double-view" )
512+ assert view .locator ("#bd-focus" ).count () == 0 , "the Focus card is still on Body Double"
513+ text = view .inner_text ()
514+ for phrase in ("of 3 topics" , "at capacity" , * _STUDY_TOPICS ):
515+ assert phrase not in text , f"Body Double still shows { phrase !r} "
516+ options = bd_page .eval_on_selector_all (
517+ "#bd-note-topic option" , "(els) => els.map((e) => e.textContent.trim())"
518+ )
519+ assert options == ["No topic" ], f"the note composer offers study topics: { options } "
520+ except Exception :
521+ diag (bd_page , "bd-no-focus-list" , _watch_for (bd_page ))
522+ raise
523+ finally :
524+ _clear_parking (env )
525+
526+
527+ class TestTheAgentIsTheCentreOfALiveSession :
528+ """Same report: "I end up with a very 'busy' screen with the agent not being
529+ a clear central focus". Measured at 1440x900 before this change: the view's
530+ heading, the 25:00 timer block and the Focus card put the terminal 432px down
531+ the content area, its bottom fell below the window, the Capture card sat
532+ wholly below the fold, and the Park-a-thought button covered the terminal's
533+ bottom-right corner."""
534+
535+ @pytest .fixture (autouse = True )
536+ def _no_orphan_session (self , env ):
537+ yield
538+ _end_any_session (env )
539+
540+ @pytest .mark .parametrize ("size" , [(1440 , 900 ), (1024 , 768 )], ids = ["laptop" , "tablet" ])
541+ def test_one_strip_then_a_console_that_fills_the_window (
542+ self , browser : Browser , env , size : tuple [int , int ]
543+ ) -> None :
544+ width , height = size
545+ ctx = browser .new_context (viewport = {"width" : width , "height" : height })
546+ page = ctx .new_page ()
547+ watch = ConsoleWatch (page )
548+ try :
549+ page .goto (f"{ env .base_url } /" )
550+ goto_view (page , "body-double" )
551+ page .wait_for_selector ("#bd-activity-input" , state = "visible" , timeout = 15_000 )
552+ _start_session (page , "Reconcile the March invoices" )
553+ page .wait_for_selector (
554+ ".bd-console-panel .xterm-mount" , state = "visible" , timeout = 30_000
555+ )
556+ page .wait_for_timeout (600 )
557+ g = page .evaluate (_LIVE_GEOMETRY_JS )
558+
559+ assert g ["viewHeader" ] is None , "the view's heading still sits above a live session"
560+ assert g ["timerBlock" ] is None , "the 25:00 timer block still sits above a live session"
561+ content , strip , console = g ["content" ], g ["strip" ], g ["console" ]
562+ above = strip ["top" ] - content ["top" ]
563+ assert above <= 24 , f"{ above :.0f} px of other panels above the session strip"
564+ gap = console ["top" ] - strip ["bottom" ]
565+ assert gap <= 16 , f"{ gap :.0f} px between the session strip and the agent"
566+ below = console ["bottom" ] - g ["innerHeight" ]
567+ assert below <= 0.5 , f"the agent's console runs { below :.0f} px below the window"
568+ share = console ["height" ] / content ["height" ]
569+ assert share >= 0.6 , f"the agent's console gets { share :.0%} of the content area"
570+ for name in ("capture" , "fab" ):
571+ if g [name ] is not None :
572+ assert not _overlaps (console , g [name ]), f"the { name } covers the agent's console"
573+ except Exception :
574+ diag (page , f"bd-live-fill-{ width } " , watch )
575+ raise
576+ finally :
577+ ctx .close ()
578+
579+ def test_the_strip_carries_the_timer_and_the_end_control (self , bd_page : Page ) -> None :
580+ try :
581+ _start_session (bd_page , "Write the quarterly report" )
582+ strip = bd_page .locator (".bd-live-strip" )
583+ timer = strip .locator ("#bd-live-timer" )
584+ assert timer .is_visible (), "no timer on the session strip"
585+ assert timer .inner_text () == bd_page .evaluate (
586+ "() => window.Alpine.store('pomodoro').display"
587+ )
588+ strip .locator ("#bd-live-pomodoro" ).click ()
589+ bd_page .wait_for_function (
590+ "() => window.Alpine.store('pomodoro').running === true" , timeout = 5_000
591+ )
592+ assert strip .locator ("#bd-end-session" ).is_visible ()
593+ except Exception :
594+ diag (bd_page , "bd-live-strip-timer" , _watch_for (bd_page ))
595+ raise
596+ finally :
597+ with contextlib .suppress (Exception ):
598+ bd_page .evaluate ("() => window.Alpine.store('pomodoro').stop()" )
599+
600+ def test_capture_folds_for_the_session_and_opens_on_request (self , bd_page : Page ) -> None :
601+ try :
602+ assert bd_page .locator ("#bd-capture-body" ).is_visible (), "fixture: Capture starts open"
603+ _start_session (bd_page , "Tidy the garage inventory sheet" )
604+ bd_page .wait_for_selector ("#bd-capture-body" , state = "hidden" , timeout = 5_000 )
605+ assert bd_page .locator ("#bd-tab-note" ).is_visible (), (
606+ "the way to take a note must stay on screen while Capture is folded"
607+ )
608+
609+ bd_page .locator ("#bd-tab-note" ).click ()
610+ bd_page .wait_for_selector ("#bd-note-body" , state = "visible" , timeout = 5_000 )
611+ bd_page .wait_for_timeout (300 )
612+ g = bd_page .evaluate (_LIVE_GEOMETRY_JS )
613+ assert g ["console" ]["height" ] >= 240 , (
614+ f"opening Capture squeezed the agent to { g ['console' ]['height' ]:.0f} px"
615+ )
616+ assert not _overlaps (g ["console" ], g ["capture" ]), "Capture covers the agent's console"
617+
618+ bd_page .locator ("#bd-end-session" ).click ()
619+ bd_page .locator ("#bd-end-confirm-yes" ).click ()
620+ bd_page .wait_for_selector ("#bd-end-session" , state = "hidden" , timeout = 20_000 )
621+ assert bd_page .locator ("#bd-capture-body" ).is_visible (), (
622+ "ending must give back the idle layout the learner chose"
623+ )
624+ except Exception :
625+ diag (bd_page , "bd-live-capture-fold" , _watch_for (bd_page ))
626+ raise
627+
628+ def test_the_note_topic_is_what_you_are_working_on (self , bd_page : Page , env ) -> None :
629+ import requests
630+
631+ try :
632+ for topic in _STUDY_TOPICS [:3 ]:
633+ _park_via_api (env , topic )
634+ _start_session (bd_page , "Plan the allotment beds" )
635+ bd_page .locator ("#bd-tab-note" ).click ()
636+ bd_page .wait_for_selector ("#bd-note-topic" , state = "visible" , timeout = 5_000 )
637+ options = bd_page .eval_on_selector_all (
638+ "#bd-note-topic option" , "(els) => els.map((e) => e.textContent.trim())"
639+ )
640+ assert options == ["No topic" , "Plan the allotment beds" ], options
641+ assert bd_page .eval_on_selector ("#bd-note-topic" , "(el) => el.value" ) == (
642+ "Plan the allotment beds"
643+ )
644+
645+ bd_page .locator ("#bd-note-title" ).fill ("Filed under the activity" )
646+ bd_page .locator ("#bd-save-note" ).click ()
647+ bd_page .wait_for_selector ("#bd-note-saved" , state = "visible" , timeout = 10_000 )
648+ notes = requests .get (f"{ env .base_url } /api/notes?limit=5" , timeout = 15 ).json ()
649+ match = next (n for n in notes ["notes" ] if n ["title" ] == "Filed under the activity" )
650+ assert match ["topic" ] == "Plan the allotment beds" , match
651+ except Exception :
652+ diag (bd_page , "bd-note-topic-activity" , _watch_for (bd_page ))
653+ raise
654+ finally :
655+ _clear_parking (env )
656+
657+
423658# ---------------------------------------------------------------------------
424659# The transport picker and the renderer it names
425660# ---------------------------------------------------------------------------
0 commit comments