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).
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
"""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)"
|
||||
)
|
||||
Reference in New Issue
Block a user