Skip to content

Commit 7dde69c

Browse files
test(web): RED -- Body Double is for anything, and live it is the agent's screen
Reported 2026-09-28 with two screenshots: "I should be able to run a body double session for anything I'm doing, no restrictions or list of Focus areas. If I start one any way it allows me but I end up with a very 'busy' screen with the agent not being a clear central focus." The Focus card listed the three most recent pending study topics with an "at capacity" chip, above the picker and above the live terminal. The activity field was always free text, so the card restricted nothing; it only looked as if it did, on a surface whose spec already says body doubling is not a new study thread (ADR-0003). Measured live at 1440x900: the view's heading, the 25:00 timer block and the Focus card put the terminal 432px down the content area, its bottom fell below the window, the Capture card sat wholly below the fold, and the Park-a-thought button covered the terminal's bottom-right corner. New tests, all red on this commit for those reasons: * no Focus card, no study topic anywhere on the view (every slot filled first so a surviving card would have to show), note topics offer none; * live at 1440x900 and 1024x768: no heading or timer block, one strip, then a console that reaches the window's bottom with >= 60% of the content area, covered by nothing; * the strip carries the Pomodoro time and its control beside End; * Capture folds for the session, a tab opens it without squeezing the agent below 240px, and ending gives back the learner's idle layout; * a note is filed under what you are working on, not a study topic. The bd_page fixture now waits for the picker rather than the Focus card.
1 parent 66577cb commit 7dde69c

1 file changed

Lines changed: 236 additions & 1 deletion

File tree

‎packages/studyloop/tests/e2e/test_body_double_workspace.py‎

Lines changed: 236 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)