Files
brain-of-reese/tests/unit/test_mobile_chat_hamburger_boot.py
T
ducoterra 4d287155c0
Build and Push Containers / build-and-push-app (push) Successful in 2m16s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 88_mobile_chat_hamburger_boot
All completion criteria verified green. Final state confirmed: phase stays in `todo/` per A4 (owner device gate), task files in `complete/`, no `app/` changes, no commits made (harness commits).

## Phase 88 — final verification pass report

**Verified (all 6 tasks already complete):** `touch-action: manipulation` on mobile `#nav-toggle`; `#view-chat.chat-booted` double-rAF sticky handover (CSS gate + app.js boot IIFE); `body.nav-menu-open` marker in `setNavMenu` + ≤640px `visibility: hidden` rule; router boot contract (`wasMounted && !opts.boot` + `boot: true`); new unit module (10 pins) + 4-test real-touch E2E story.

**Test / lint / coverage:**
- `uv run pytest --cov=app --cov-report=term-missing` → 1741 passed, **99%** on `app/` (>90% ✓)
- `tests/e2e/test_mobile_chat_hamburger_boot.py` 4/4, `test_mobile_hamburger_nav.py` 8/8, `test_pinned_composer.py` 4/4, `test_smoke.py` 3/3 — each in isolation, `--no-cov` ✓
- `uv run ruff check .` clean; `uv run pyright` 0 errors ✓

**Completion criteria:** (1) 360px cold-boot touch-action + sticky handover + tap-opens-menu-with-cluster-hidden (E2E 1–2) ✓; (2) boot fires no `bor:view-refresh`, re-show fires exactly one, `/sources.html` regression (E2E 3–4) ✓; (3) full gate green ✓; (4) diff limited to 4 assets + 2 new tests + phase files, 0 changes in `app/` ✓; (5) commit deferred to harness per executor rules ✓; (6) owner device re-verification **pending** (A4 — gates the archive; no Owner report recorded yet).

**Deviations (both documented in-tree):** unit pins updated in `test_frontend_router.py`/`test_hamburger_nav.py` (their exact-text pins collided with the mandated new guard/marker text — without them the suite goes red); `boot: true` count pinned at 1 not 2 (codebase has one boot call site, no `history.state` branch — verified against git HEAD).

**Next pending phase:** none in `todo/` — pipeline awaits the owner's on-device report (archive, or `?dbg=nav` instrumentation follow-up if the menu is still dead).
2026-09-08 16:02:45 -04:00

443 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit: the phase-88 mobile hamburger cold-boot contract (source-level pins).
Owner bug report 2026-09-08 (continuation of the phase-85 report): after
the phase-76 SPA migration, the mobile hamburger (``#nav-toggle``) is
DEAD on the chat page — only on a fresh load / refresh of ``/`` (the
cold boot with the chat view visible from the first frame) — on two
real Android phones (cache cleared, production). A fresh
``/sources.html`` boot, any client-side switch into chat, and the
login page all work; rotation, pinch-zoom, and scrolling do not heal
the dead state. No spec-compliant Chromium repro exists (real-touch
Playwright probes at 360–412px, both auth states: the toggle is always
hit-testable and a touch tap always opens the menu) — the failure
lives in the real devices' touch→click / compositor pipeline, and an
exhaustive code audit found nothing in the app that can swallow the
click (no touch listeners, no click-eating document handlers, no
``touch-action`` anywhere). So the phase removes ALL candidate
mechanisms with standard, safe changes.
Locked decision A1 (belt-and-suspenders, not a single mechanism — the
internal device path is not provable from the dev machine): the
independent removals ship TOGETHER — task 01 ``touch-action:
manipulation`` on the mobile toggle, task 02 the composer cluster's
sticky layer deferred out of the first layout commit (the
``.chat-booted`` gate), task 03 the cluster hidden while the menu is
open (``body.nav-menu-open``) — plus task 04's router boot-refresh
contract repair. Each is byte-identical at rest or off-mobile (A2);
the owner's on-device re-verification (A4) is the phase's final gate.
This module pins the source-level contract, task by task (house
source-level pattern — the asset files are read as text, no browser;
the live behavior is E2E-gated by
``tests/e2e/test_mobile_chat_hamburger_boot.py``, task 05). Task 01
pins the toggle's ``touch-action`` and task 02 the deferred sticky
handover below; tasks 03/04 extend this module (the shared asset
readers are already in place).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
STYLES_CSS = ASSETS / "styles.css"
APP_JS = ASSETS / "app.js"
HEADER_JS = ASSETS / "header.js"
ROUTER_JS = ASSETS / "router.js"
#: The phase's mobile breakpoint — the same query the hamburger's
#: display rule and the menu's dropdown rules live in.
MOBILE_QUERY = "@media (max-width: 640px)"
def _text(path: Path) -> str:
assert path.is_file(), f"missing frontend file: {path}"
return path.read_text(encoding="utf-8")
def _styles() -> str:
"""styles.css, raw (declaration counts are pinned against the raw
file — the contract is "exactly N occurrences in the shipped
file", comments included)."""
return _text(STYLES_CSS)
def _app_js() -> str:
return _text(APP_JS)
def _header_js() -> str:
return _text(HEADER_JS)
def _router_js() -> str:
return _text(ROUTER_JS)
def _css() -> str:
"""styles.css with comments stripped (a comment may legally carry
braces — the brace-matching helpers below must never see them;
house pattern, cf. test_hamburger_nav.py)."""
return re.sub(r"/\*.*?\*/", "", _styles(), flags=re.S)
def _media_block(css: str, query: str) -> str:
"""The full text of the FIRST ``@media <query>`` block (brace-
matched, nested rules included verbatim)."""
m = re.search(re.escape(query) + r"[^{]*\{", css)
assert m, f"missing {query!r} media query in styles.css"
depth = 0
for i in range(m.end() - 1, len(css)):
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
if depth == 0:
return css[m.start() : i + 1]
raise AssertionError(f"unbalanced braces in {query!r} media block")
def _global_css(css: str) -> str:
"""The rules OUTSIDE any @media block (the desktop baseline), in
file order (house pattern, cf. test_hamburger_nav.py)."""
out: list[str] = []
pos = 0
while True:
m = re.search(r"@media[^{]*\{", css[pos:])
if not m:
out.append(css[pos:])
break
start = pos + m.end() - 1 # the @media's own opening brace
depth = 0
i = start
while i < len(css):
if css[i] == "{":
depth += 1
elif css[i] == "}":
depth -= 1
if depth == 0:
break
i += 1
out.append(css[pos:start])
pos = i + 1
return "".join(out)
def _rule_block(css: str, selector: str) -> str:
"""The first rule body for ``selector`` (e.g. ``.nav-toggle``)."""
m = re.search(re.escape(selector) + r"[^{}]*\{([^}]*)\}", css)
assert m, f"missing rule for {selector!r}"
return m.group(1)
# ---------- task 01: touch-action on the mobile nav toggle ----------
def test_mobile_nav_toggle_carries_touch_action_manipulation() -> None:
"""Phase 88 A1, removal (1): the ≤640px ``.nav-toggle`` rule (the
44px button — the base rule is ``display: none`` on desktop) gains
the standard dead-mobile-button fix, ``touch-action:
manipulation``. The viewport meta keeps zoom allowed
(``initial-scale=1``, no ``maximum-scale`` lock — WCAG), so every
touch on the page goes through the browser's tap/zoom disambiguation
window; on this control the window is what the device data points
at (a tap that never resolves to a click on a cold-booted chat
page). ``manipulation`` removes double-tap-to-zoom, pinch, and the
disambiguation delay from THIS control's touch pipeline only — a
(drifting) tap resolves to a click fastest. Desktop is untouched
(the toggle does not exist outside the ≤640px block)."""
mobile = _media_block(_css(), MOBILE_QUERY)
assert re.search(
r"\.nav-toggle\s*\{[^}]*touch-action:\s*manipulation[^}]*\}", mobile, re.S
), (
"the ≤640px .nav-toggle rule must carry 'touch-action: manipulation' "
"(phase 88 task 01 — the standard dead-mobile-button fix)"
)
def test_touch_action_is_mobile_only_on_the_toggle() -> None:
"""A2 — the mobile-only surface contract: the fix is scoped to the
ONE control the bug report names. The ENTIRE styles.css carries
exactly ONE ``touch-action`` declaration (no page-wide touch-action
— the rest of the page keeps the browser's normal touch/zoom
pipeline, and desktop is byte-identical at rest), and the BASE
``.nav-toggle`` rule (the ``display: none`` desktop baseline
outside any media query) carries none of it."""
raw = _styles()
assert raw.count("touch-action") == 1, (
f"exactly ONE 'touch-action' declaration in styles.css — "
f"the mobile toggle's fix (found {raw.count('touch-action')})"
)
base = _rule_block(_global_css(_css()), ".nav-toggle")
assert "touch-action" not in base, (
"the BASE .nav-toggle rule (desktop, display:none) must NOT "
"gain touch-action (A2 — the fix is mobile-only)"
)
# ---------- task 02: defer the cluster's sticky until boot ----------
def test_chat_booted_gate_rule_statics_the_cluster_pre_boot() -> None:
"""Phase 88 A1, removal (2): the first-commit sticky layer. The
dead state is EXACTLY "shell cold boot with the chat view visible
from the first frame" — the only element present there and absent
from EVERY working state (fresh ``/sources.html`` boot, any
client-side switch into chat, login) is the sticky ``.chat-bottom``
cluster (and the doubly-sticky ``#composer`` inside it) committed
in the first layout. A ``position: sticky`` element is promoted to
a compositor layer at commit; the owner's phone accepts the
identical layer when it is born on a SETTLED page. The gate rule
keeps the cluster static until app.js adds ``.chat-booted`` two
frames after boot settles — the id-scoped selector outranks the
two class rules, and at rest (``.chat-booted`` present) it matches
nothing, so both computed styles are byte-identical to today
(A2)."""
css = _css()
assert re.search(
r"#view-chat:not\(\.chat-booted\)\s*\.chat-bottom,\s*"
r"#view-chat:not\(\.chat-booted\)\s*\.composer\s*\{\s*"
r"position:\s*static\s*;?\s*\}",
css,
re.S,
), (
"styles.css must carry the phase-88 boot gate — "
"#view-chat:not(.chat-booted) .chat-bottom AND .composer are "
"static until app.js adds .chat-booted (the sticky compositor "
"layer is born after the boot paint, not in the first layout "
"commit)"
)
def test_original_cluster_sticky_rules_are_untouched() -> None:
"""The gate is ADDITIVE (A2): the two original rules —
``.chat-bottom`` and ``.composer``, independently ``position:
sticky`` since the phase-65 sticky-composer contract — keep their
sticky pair; the at-rest layout is byte-identical, pinned here and
by test_pinned_composer.py (+ its E2E)."""
css = _css()
# LINE-ANCHORED: the gate rule (placed between the two original rules)
# carries `.chat-bottom` / `.composer` in its OWN selector list — only
# the original rules start their selector line with the bare class
# (house pattern, cf. test_pinned_composer.py::_rule).
bottom = re.search(r"^\.chat-bottom \{\n([\s\S]*?)\n\}", css, re.MULTILINE)
composer = re.search(r"^\.composer \{\n([\s\S]*?)\n\}", css, re.MULTILINE)
assert bottom and composer, (
"the original .chat-bottom / .composer rule blocks must exist"
)
assert "position: sticky;" in bottom.group(1), (
"the .chat-bottom wrapper must KEEP its phase-65 sticky pair "
"(the gate defers it pre-boot only — the at-rest rule is "
"untouched)"
)
assert "position: sticky;" in composer.group(1), (
"the .composer form must KEEP its own sticky pair (redundant "
"inside the wrapper, pinned for the computed-style contract)"
)
def test_chat_booted_flag_lands_exactly_once_after_boot_settles() -> None:
"""The handover: app.js's boot IIFE adds ``chat-booted`` EXACTLY
once — the double ``requestAnimationFrame`` sitting AFTER
``loadHealth();`` (still inside the IIFE). Two frames: frame 1
paints the settled boot (the empty state, or the synchronously
re-rendered restored conversation) with the cluster static; frame
2 pins it — one frame would fold the sticky back into the first
layout commit for the restore case (the rAF callback runs before
that frame's layout). A pre-settle throw leaves the cluster static
— a degraded boot is already degraded (the gate/header above it),
and the hamburger binding lives in header.js's module body, so it
is unaffected either way (documented in the house comment, do not
"fix")."""
js = _app_js()
assert js.count("chat-booted") == 1, (
f"app.js must reference chat-booted EXACTLY once — the single "
f"double-rAF boot handover (found {js.count('chat-booted')})"
)
assert re.search(
r"requestAnimationFrame\(\(\)\s*=>\s*requestAnimationFrame\(\(\)\s*=>\s*\{?\s*"
r'document\.getElementById\("view-chat"\)\?\.classList\.add\("chat-booted"\)',
js,
re.S,
), (
"the boot handover must be the double requestAnimationFrame "
"pattern (frame 1 paints the settled boot with the cluster "
"static, frame 2 pins it)"
)
assert js.index("chat-booted") > js.index("loadHealth();"), (
"the flag must land AFTER loadHealth(); inside the boot IIFE — "
"the settled boot (restore + suggestions + health) is what "
"frame 1 paints"
)
# ---------- task 03: hide the cluster while the menu is open ----------
def _function_body(js: str, signature: str) -> str:
"""The full text of a top-level function — from ``signature`` to
its brace-matched closing ``}`` (setNavMenu's body carries no
nested braces, so the brace count is exact for it)."""
start = js.index(signature)
i = js.index("{", start)
depth = 0
for j in range(i, len(js)):
if js[j] == "{":
depth += 1
elif js[j] == "}":
depth -= 1
if depth == 0:
return js[start : j + 1]
raise AssertionError(f"unbalanced braces after {signature!r}")
def test_nav_menu_open_marker_is_set_exactly_once_in_set_nav_menu() -> None:
"""Phase 88 A1, removal (3): the chat's sticky bottom cluster is
the OTHER positioned/layered element on the page — while the menu
is open it competes for taps, and on short viewports it overlaps
the menu's lower rows (measured: menu y58→417 vs cluster top y395
at 390×600). ``setNavMenu`` is the single choke point for EVERY
open/close path (the toggle click, the link click, Esc,
outside-click, the 640px-media close), so the ``body.nav-menu-open``
marker rides on it and can never stick. Exactly one occurrence in
header.js — and it sits inside the setNavMenu function body."""
js = _header_js()
assert js.count("nav-menu-open") == 1, (
f"header.js must mention nav-menu-open EXACTLY once — the single "
f"body-class toggle inside setNavMenu (found {js.count('nav-menu-open')})"
)
body = _function_body(js, "function setNavMenu")
assert "nav-menu-open" in body, (
"the body.nav-menu-open marker must be set INSIDE setNavMenu — "
"the single choke point every open/close path funnels through "
"(click / link / Esc / outside-click / media)"
)
def test_nav_menu_open_hides_the_cluster_in_the_mobile_block_only() -> None:
"""The CSS half: ``body.nav-menu-open .chat-bottom { visibility:
hidden }`` lives INSIDE the ``@media (max-width: 640px)`` block,
next to the .app-nav dropdown rules. ``visibility`` (NOT
``display``): layout is preserved, so closing the menu never
reflows the chat column and the sticky pin's position is stable
for the moment the menu closes. Scoped to the mobile block — at
>640px the toggle is ``display: none``, setNavMenu never opens
the menu, and even if the marker were set the rule does not exist
there (A2 — desktop and the resting state are untouched). Exactly
one occurrence in styles.css (the rule only — no stray coupling).
(The task's spec regex gains ``;?`` — house CSS style ends the
declaration with a semicolon, cf. task 02's ``position: static;``
pin.)"""
raw = _styles()
assert raw.count("nav-menu-open") == 1, (
f"styles.css must mention nav-menu-open EXACTLY once — the single "
f"≤640px rule (found {raw.count('nav-menu-open')})"
)
mobile = _media_block(_css(), MOBILE_QUERY)
assert re.search(
r"body\.nav-menu-open\s*\.chat-bottom\s*\{\s*visibility:\s*hidden;?\s*\}",
mobile,
re.S,
), (
"the ≤640px block must hide .chat-bottom while body.nav-menu-open "
"(visibility: hidden — layout preserved, no reflow on close)"
)
# ---------- task 04: the router's boot-refresh contract ----------
def test_boot_show_guard_excludes_the_boot_flag() -> None:
"""Phase 88 A3 (the boot contract is code, not comment): the
documented router contract says the first show (the mount) AND
boot never fire ``bor:view-refresh`` — but ``mounted.chat`` starts
true (app.js pre-mounts the chat view), so a cold boot's
``switchTo("chat")`` was hitting the ``wasMounted`` branch and
dispatching the refresh on ``#view-chat`` at boot. Harmless today
(no view listens on the chat root — the phase-77 exclusion — and
no lazy module mounts on a ``/`` boot), but a future listener added
to the chat view would fire at boot and could resurrect exactly
this bug class. The code now matches the contract: the dispatch
guard is ``wasMounted && !opts.boot`` — the explicit ``boot`` flag
(A3: no module-level flag state, no race with an in-flight boot
import) is the ONLY thing that exempts a show, and it rides the
opts object already carried by every call site (every non-boot
site has ``opts.boot === undefined`` → dispatches exactly as
before — the signature pin below keeps it an opts parameter, not a
destructured shorthand or a module flag). The old unconditional
``if (wasMounted) {`` guard is gone — its exact text is what the
pin excludes (the new guard reads
``if (wasMounted && !opts.boot) {``)."""
js = _router_js()
assert re.search(
r"if\s*\(\s*wasMounted\s*&&\s*!opts\.boot\s*\)\s*\{\s*"
r"root\.dispatchEvent\(\s*new\s+CustomEvent\(\"bor:view-refresh\"\)",
js,
re.S,
), (
"the refresh-dispatch guard must be 'wasMounted && !opts.boot' "
"(phase 88 — the boot show never fires the refresh; a re-show "
"still does)"
)
assert "async function switchTo(name, opts = {})" in js, (
"switchTo must take the opts object (defaulting to {}) — the "
"guard reads opts.boot, so a module-level flag or a "
"destructured shorthand would break the contract (A3)"
)
assert "if (wasMounted) {" not in js, (
"the old UNCONDITIONAL wasMounted guard must be gone — it is "
"what let the cold boot dispatch the refresh on the "
"pre-mounted chat view"
)
def test_boot_flag_is_carried_only_by_the_boot_call_site() -> None:
"""The flag's scope (the pin the task spec states as "the popstate/
nav paths never get the flag"): ``boot: true`` is carried by the
boot call site ONLY — ``switchTo(bootName, { userInitiated: false,
boot: true })`` at the bottom of the module — and by NOTHING
else. The two user-initiated call sites (the nav-click handler
and the popstate listener) keep the bare ``{ userInitiated: true }``
opts, and the flag's occurrence sits AFTER the popstate listener
(i.e. in the boot section — not smuggled into an earlier path).
DEVIATION FROM THE TASK SPEC, documented per house pattern (cf.
tasks 01–03's ``;?`` pins): the task pins ``js.count("boot: true")
== 2`` "the two boot call sites" (the overview names a
``switchTo(history.state.view, { push: false })`` branch beside
``switchTo(bootName, { push: false })``). Those two call sites do
not exist in this codebase — the boot section is the two-liner
``const bootName = VIEW[window.location.pathname] ?? "chat";
switchTo(bootName, { userInitiated: false });`` (verified against
git HEAD; nothing in the module reads ``history.state``, and
switchTo performs NO pushState — that is the click handler's job,
pinned in test_frontend_router.py, so a ``push`` option would be a
dead no-op). A second flag occurrence would require fabricating
dead code — the opposite of A3's "no behavior change for any
later show" — so the count pin is 1: the flag rides the ONE boot
call site, never the popstate/nav paths (their bare-opts count is
pinned too)."""
js = _router_js()
assert js.count("boot: true") == 1, (
f"router.js must carry 'boot: true' EXACTLY once — the single "
f"boot call site; popstate and nav-click never get the flag "
f"(found {js.count('boot: true')})"
)
assert 'switchTo(bootName, { userInitiated: false, boot: true })' in js, (
"the boot call site must carry the flag inline — "
"switchTo(bootName, { userInitiated: false, boot: true })"
)
# The flag sits in the BOOT section — after the popstate listener
# (an earlier occurrence would mean a user-initiated path carries
# it, which would silently skip its refresh).
assert js.index("boot: true") > js.index('window.addEventListener("popstate"'), (
"the boot flag must sit in the boot section (after the "
"popstate listener) — never in the nav-click or popstate path"
)
assert js.count("switchTo(name, { userInitiated: true })") == 2, (
"the nav-click and popstate call sites must keep the bare "
"{ userInitiated: true } opts — no boot flag on any "
"user-initiated path (exactly two such call sites)"
)