All verification complete. Final report: **Phase 85 final verification pass — all green** (tasks 01–03 already complete; independently re-verified from scratch) - Verified fix in tree: `styles.css` `.auth-gate` z-index 500→15 + rewritten stacking comment (no `z-index: 500` left); `index.html`/`document.html` comment-only; `token-gate.js` docstring-only (logic byte-identical); `test_mobile_hamburger_nav.py` real-click conversion + new `test_anonymous_toggle_tappable_with_gate_up`; new `tests/unit/test_gate_header_stacking.py` (3 pins); `test_api_tokens.py` untouched - `uv run pytest` → 1717 passed, 1 warning (exit 0) - `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (>90%) - E2E in isolation: `test_mobile_hamburger_nav.py` **8 passed**; `test_api_tokens.py` **9 passed** (unchanged); `test_smoke.py` **3 passed**; `test_document_viewer.py` **7 passed** - `uv run ruff check . && uv run pyright` → clean / 0 errors - Live probe (375×812, anonymous, fresh server): on `/` and `/history.html` `elementFromPoint` at toggle → SVG `path`, never `#auth-gate`; real click opens menu (`aria-expanded=true`); exactly one visible nav link (Chat) + sign-in copy; Chat link topmost over gate; `#main` inert — criteria 1, 2, 3 confirmed directly - Criteria: (1) real-click menu on / + /history ✅ (2) dropdown above gate ✅ (3) anonymous contents + #main inert ✅ (4) admin byte-identical (phase-46 tests green) ✅ (5) doc-viewer gate under bar (CSS pin + doc-viewer suite) ✅ (6) full gate green ✅ (7) diff scoped to the 5 code files + new unit test + phase files, nothing in `app/` ✅ (8) commit + todo→complete move left to harness per executor rules (task files already in `complete/`) - Note: `.agents/remediation_plan.md` (untracked) is a pre-existing artifact of the earlier security audit — left untouched; a missing "N passed" line seen mid-pass was my own `-q`+addopts `-q` = `-qq` quirk, not a defect - Next pending phase: `86_history_page_width`
546 lines
24 KiB
Python
546 lines
24 KiB
Python
"""Phase 46 E2E (Playwright): the mobile hamburger dropdown nav.
|
||
|
||
Story: ``.agents/user_stories/mobile-hamburger-nav.md``
|
||
TODO.md L9 (owner permission 2026-08-27): "The navbar on mobile is way
|
||
too squished. Make it a hamburger dropdown menu with a nice animation."
|
||
|
||
Run in isolation (mock LLM; DB up: ``podman compose up -d db``):
|
||
|
||
uv run pytest tests/e2e/test_mobile_hamburger_nav.py -v --no-cov
|
||
|
||
Contract under test: at ≤640px the nav links LEAVE the bar — a 44px
|
||
``#nav-toggle`` hamburger opens ``#app-nav`` as an animated (180ms
|
||
slide+fade) edge-to-edge dropdown with comfortable rows, the auth
|
||
visibility contract intact INSIDE the menu; at >640px the bar is
|
||
byte-identical to pre-phase-46 (hamburger absent, inline pills). No
|
||
document is ever needed — the suite exercises the shared header only.
|
||
|
||
Phase 85 (TODO.md L3): the anonymous visitor meets the token gate —
|
||
but the gate (z-index 15) now sits BELOW the sticky header (z 20), so
|
||
a REAL tap on ``#nav-toggle`` reaches the toggle for anonymous
|
||
visitors exactly as for admin: every test in this suite drives the
|
||
menu with real clicks (the phase-79 JS-dispatched workaround for the
|
||
gate-overlay interception is retired — the gate covers only ``#main``,
|
||
which stays ``inert``; it no longer covers the header).
|
||
|
||
The conftest ``page`` fixture is 1280×800, so the mobile tests create
|
||
fresh 375×812 pages via the session ``browser`` fixture (one page per
|
||
test; the reduced-motion test gets its own context).
|
||
|
||
Test → story mapping (Playwright Mapping Rule):
|
||
|
||
1. ``test_mobile_hamburger_visible_and_bar_roomy`` — 375px: the toggle
|
||
is a ≥44px visible button (``aria-expanded="false"``, closed), the
|
||
inline nav links are not visible in the bar (the closed dropdown is
|
||
opacity 0 + visibility hidden), and the page does not overflow
|
||
horizontally.
|
||
2. ``test_anonymous_menu_contents`` — anonymous at 375px: the menu
|
||
shows EXACTLY one visible link ("Chat"); the three admin-only links
|
||
stay ``hidden`` inside the menu; the open flips ``aria-expanded``.
|
||
3. ``test_anonymous_toggle_tappable_with_gate_up`` — the TODO.md L3
|
||
regression pin: anonymous toggle tappable with the gate up —
|
||
``elementFromPoint`` at the toggle center never ``#auth-gate``,
|
||
real click opens the menu above the gate.
|
||
4. ``test_admin_menu_contents`` — admin at 375px: the menu shows all
|
||
four links (the whoami reveal works inside the menu).
|
||
5. ``test_link_click_navigates_and_closes`` — admin at 375px: clicking
|
||
"RAG" navigates to /sources.html and the menu on the arrival
|
||
page ships closed.
|
||
6. ``test_esc_and_outside_close`` — Esc closes AND returns focus to the
|
||
toggle; an outside click does NOT close (accepted — see the test
|
||
docstring for why).
|
||
7. ``test_animation_and_reduced_motion`` — motion allowed: the
|
||
180ms opacity/transform transition pair is live and the open flips
|
||
class + aria; ``reducedMotion: "reduce"``: no transition in EITHER
|
||
state (the .is-open state included — the specificity trap) and
|
||
open/close still works.
|
||
8. ``test_desktop_unchanged`` — 1280×800 regression: the hamburger is
|
||
``display: none`` and the inline nav renders in the bar exactly as
|
||
before (admin: all four links, all inside the header band).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
from playwright.sync_api import Browser, Page, ViewportSize, expect
|
||
|
||
from e2e.auth_helpers import login
|
||
|
||
MOBILE: ViewportSize = {"width": 375, "height": 812} # the story's phone viewport
|
||
DESKTOP: ViewportSize = {"width": 1280, "height": 800} # the conftest page size
|
||
|
||
NAV_LINKS = ("#app-nav a[href='/']", "#nav-sources", "#nav-git-sources", "#nav-tuning")
|
||
LINK_TEXTS = ("Chat", "RAG", "Sources", "Tuning")
|
||
|
||
|
||
def _mobile_page(browser: Browser) -> Page:
|
||
"""A fresh 375×812 page (the conftest ``page`` is 1280×800)."""
|
||
return browser.new_page(viewport=MOBILE)
|
||
|
||
|
||
def _wait_settled_anonymous(page: Page) -> None:
|
||
"""Wait until whoami has resolved for the anonymous visitor: the bar
|
||
Sign in copy (``#sign-in-link``) loses its ship-hidden attribute —
|
||
the phase-16 settled state, probed by attribute (not visibility):
|
||
at ≤640px the bar copy is CSS-hidden behind the ``#sign-in-link-mobile``
|
||
dropdown copy (phase 46), so visibility is viewport-dependent."""
|
||
page.wait_for_function(
|
||
"() => !document.querySelector('#sign-in-link').hasAttribute('hidden')",
|
||
timeout=10_000,
|
||
)
|
||
|
||
|
||
def _wait_settled_admin(page: Page) -> None:
|
||
"""Wait until whoami has resolved for the admin: the whoami reveal
|
||
has un-hidden the admin-only nav links (the menu-contents assertions
|
||
must run on a settled auth state). The nav link is the
|
||
viewport-independent settled signal — the sign-out control is the
|
||
bar copy on desktop but the #sign-out-btn-mobile dropdown copy at
|
||
≤640px (phase-46 UX revision), so it is not a cross-viewport
|
||
probe."""
|
||
page.wait_for_function(
|
||
"() => !document.querySelector('#nav-sources').hasAttribute('hidden')",
|
||
timeout=10_000,
|
||
)
|
||
|
||
|
||
def _visible_nav_links(page: Page) -> list[str]:
|
||
"""The texts of the nav links that are actually visible (Playwright
|
||
visibility: non-empty box AND not visibility:hidden/display:none —
|
||
so the closed dropdown and the ``hidden`` admin links both count as
|
||
invisible)."""
|
||
return [
|
||
page.locator(sel).inner_text()
|
||
for sel in NAV_LINKS
|
||
if page.locator(sel).is_visible()
|
||
]
|
||
|
||
|
||
def _open_menu(page: Page) -> None:
|
||
"""Open the menu with a REAL click on the toggle.
|
||
|
||
Phase 85 (TODO.md L3): the gate (z-index 15) sits BELOW the sticky
|
||
header (z 20), so a real tap reaches the toggle for anonymous
|
||
visitors too — the phase-79 JS-dispatched workaround (the gate then
|
||
covered the header at z 500 and intercepted the click) is retired,
|
||
and every test in this suite uses this one real-click helper."""
|
||
page.click("#nav-toggle")
|
||
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
|
||
# to_have_class(string) is an EXACT match on the class attribute —
|
||
# the nav is "app-nav is-open", so match the token with a regex.
|
||
expect(page.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
|
||
expect(page.locator("#app-nav")).to_have_css("opacity", "1")
|
||
|
||
|
||
def _assert_menu_closed(page: Page) -> None:
|
||
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "false")
|
||
assert "is-open" not in (page.locator("#app-nav").get_attribute("class") or ""), (
|
||
"the closed menu must not carry the .is-open state"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Bar: the toggle is a roomy 44px target and the nav is out of the bar
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_mobile_hamburger_visible_and_bar_roomy(
|
||
browser: Browser, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""AC1/AC5: at 375px the bar carries a ≥44px ``#nav-toggle`` (closed,
|
||
``aria-expanded="false"``), the inline nav links are NOT visible in
|
||
the bar (the closed dropdown is opacity 0 + visibility hidden — the
|
||
layout box is top:100% less the 8px slide offset, so the story's
|
||
opacity-0 branch is what pins it), and there is no horizontal
|
||
overflow — the old squished pills are gone, so the bar has room."""
|
||
page = _mobile_page(browser)
|
||
try:
|
||
page.goto(app_url)
|
||
_wait_settled_anonymous(page)
|
||
|
||
# The hamburger: a visible ≥44px×44px touch target (the phase-07
|
||
# floor), shipped closed.
|
||
toggle = page.locator("#nav-toggle")
|
||
expect(toggle).to_be_visible()
|
||
box = toggle.bounding_box()
|
||
assert box is not None, "the toggle must have a box"
|
||
assert box["width"] >= 44 and box["height"] >= 44, (
|
||
f"the toggle must be a ≥44px touch target, got "
|
||
f"{box['width']:.0f}×{box['height']:.0f}"
|
||
)
|
||
expect(toggle).to_have_attribute("aria-expanded", "false")
|
||
|
||
# The inline nav links are not visible in the bar: the closed
|
||
# dropdown is opacity 0 + visibility hidden. (The layout box is
|
||
# top:100% minus the 8px slide offset — inside the band — but
|
||
# invisible, which is the acceptance branch: opacity 0.)
|
||
assert page.evaluate(
|
||
"() => getComputedStyle(document.querySelector('#app-nav')).opacity"
|
||
) == "0", "the closed menu must be opacity 0"
|
||
assert page.evaluate(
|
||
"() => getComputedStyle(document.querySelector('#app-nav')).visibility"
|
||
) == "hidden", "the closed menu must be visibility hidden"
|
||
for sel in NAV_LINKS:
|
||
assert not page.locator(sel).is_visible(), (
|
||
f"{sel} must not be visible in the bar with the menu closed"
|
||
)
|
||
|
||
# No horizontal overflow at 375px (the old four squished text
|
||
# pills are gone from the bar).
|
||
assert page.evaluate(
|
||
"() => document.documentElement.scrollWidth"
|
||
) <= page.evaluate("() => window.innerWidth"), (
|
||
"the 375px bar must not overflow horizontally"
|
||
)
|
||
finally:
|
||
page.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. Menu contents per auth state (the whoami contract inside the menu)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_anonymous_menu_contents(
|
||
browser: Browser, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""AC2 (anonymous): at 375px the opened menu shows EXACTLY one
|
||
visible link — "Chat". The three admin-only links keep their
|
||
ship-hidden state INSIDE the menu (the phase-19/35 contract is
|
||
preserved by reusing the same <nav> element); opening flips
|
||
aria-expanded true.
|
||
|
||
Phase 85 (TODO.md L3): the anonymous visitor meets the token gate
|
||
— but the gate (z 15) now sits BELOW the header (z 20), so the
|
||
toggle is a real, tappable target and the menu is driven with a
|
||
real click exactly as for admin."""
|
||
page = _mobile_page(browser)
|
||
try:
|
||
page.goto(app_url)
|
||
_wait_settled_anonymous(page)
|
||
_assert_menu_closed(page)
|
||
|
||
_open_menu(page)
|
||
assert _visible_nav_links(page) == ["Chat"], (
|
||
"anonymous: the menu must show exactly one visible link (Chat)"
|
||
)
|
||
for sel in ("#nav-sources", "#nav-git-sources", "#nav-tuning"):
|
||
expect(page.locator(sel)).to_be_hidden()
|
||
|
||
# A second click closes it again — aria-expanded round-trips.
|
||
page.click("#nav-toggle")
|
||
_assert_menu_closed(page)
|
||
finally:
|
||
page.close()
|
||
|
||
|
||
def test_anonymous_toggle_tappable_with_gate_up(
|
||
browser: Browser, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""TODO.md L3 regression pin (phase 85): the anonymous visitor meets
|
||
the token gate, but the gate (z-index 15) sits BELOW the sticky
|
||
header (z 20) — so the toggle is a REAL, tappable target:
|
||
``document.elementFromPoint`` at the toggle center resolves to the
|
||
toggle itself (or its SVG child), NEVER ``#auth-gate`` (the exact
|
||
probe that returned the gate in the bug reproduction), and a real
|
||
click opens the menu ABOVE the gate — the "Chat" link visible with
|
||
the gate still up (the dropdown's z context, 21 inside the
|
||
header's 20, paints over the gate's 15). The lock is untouched:
|
||
``#main`` stays ``inert``. Leaves the page clean — Esc settles the
|
||
menu closed."""
|
||
page = _mobile_page(browser)
|
||
try:
|
||
page.goto(app_url)
|
||
_wait_settled_anonymous(page)
|
||
|
||
# The gate is up and the app is locked (the phase-79 contract)…
|
||
expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000)
|
||
assert page.evaluate("() => document.getElementById('main').inert === true"), (
|
||
"the gate must keep #main inert while it is up"
|
||
)
|
||
|
||
# …but the probe at the toggle center no longer resolves to the
|
||
# gate: it hits the toggle itself or a descendant of it (the
|
||
# SVG path) — the exact probe that returned #auth-gate in the
|
||
# bug reproduction (TODO.md L3).
|
||
probe = page.evaluate(
|
||
"""() => {
|
||
const t = document.querySelector('#nav-toggle');
|
||
const r = t.getBoundingClientRect();
|
||
const el = document.elementFromPoint(
|
||
r.left + r.width / 2, r.top + r.height / 2);
|
||
return {
|
||
id: el ? el.id : null,
|
||
tag: el ? el.tagName.toLowerCase() : null,
|
||
toggle_or_descendant: el !== null && t.contains(el),
|
||
};
|
||
}"""
|
||
)
|
||
assert probe["id"] != "auth-gate", (
|
||
f"the toggle center must not resolve to the gate, "
|
||
f"got <{probe['tag']} id={probe['id']!r}> (the phase-79 bug)"
|
||
)
|
||
assert probe["toggle_or_descendant"], (
|
||
f"the toggle center must resolve to the toggle or its SVG "
|
||
f"child, got <{probe['tag']} id={probe['id']!r}>"
|
||
)
|
||
|
||
# A REAL click opens the menu (the owner's exact scenario —
|
||
# "clicking it … does not expand the menu" is fixed), and the
|
||
# menu is ABOVE the gate: with the gate still up the
|
||
# dropdown's "Chat" link is visible, and the topmost element at
|
||
# its center is the link (or a descendant), never #auth-gate.
|
||
_open_menu(page)
|
||
expect(page.locator("#auth-gate")).to_be_visible()
|
||
chat = page.locator("#app-nav a[href='/']")
|
||
expect(chat).to_be_visible()
|
||
link_probe = page.evaluate(
|
||
"""() => {
|
||
const a = document.querySelector("#app-nav a[href='/']");
|
||
const r = a.getBoundingClientRect();
|
||
const el = document.elementFromPoint(
|
||
r.left + r.width / 2, r.top + r.height / 2);
|
||
return {
|
||
id: el ? el.id : null,
|
||
tag: el ? el.tagName.toLowerCase() : null,
|
||
link_or_descendant: el !== null && a.contains(el),
|
||
};
|
||
}"""
|
||
)
|
||
assert link_probe["id"] != "auth-gate", (
|
||
f"the open dropdown must paint ABOVE the gate, the Chat "
|
||
f"link center resolved to the gate ({link_probe!r})"
|
||
)
|
||
assert link_probe["link_or_descendant"], (
|
||
f"the Chat link must be the topmost target at its center, "
|
||
f"got <{link_probe['tag']} id={link_probe['id']!r}>"
|
||
)
|
||
|
||
# Leave the page clean: Esc settles it closed.
|
||
page.keyboard.press("Escape")
|
||
_assert_menu_closed(page)
|
||
finally:
|
||
page.close()
|
||
|
||
|
||
def test_admin_menu_contents(
|
||
browser: Browser, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""AC2 (admin): at 375px the opened menu shows ALL FOUR links —
|
||
Chat / RAG / Sources / Tuning — i.e. the whoami reveal
|
||
works inside the menu exactly as it does inline (one <nav>, one
|
||
set of links, the same hidden attributes header.js drives)."""
|
||
page = _mobile_page(browser)
|
||
try:
|
||
login(page, app_url, next="/")
|
||
_wait_settled_admin(page)
|
||
|
||
_open_menu(page)
|
||
assert _visible_nav_links(page) == list(LINK_TEXTS), (
|
||
f"admin: the menu must show all four links, got {_visible_nav_links(page)}"
|
||
)
|
||
for sel in NAV_LINKS:
|
||
expect(page.locator(sel)).to_be_visible()
|
||
finally:
|
||
page.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. Link close + navigation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_link_click_navigates_and_closes(
|
||
browser: Browser, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""AC4: a menu link click navigates AND closes the menu — and the
|
||
arrival page ships the fresh (closed) header: aria-expanded false,
|
||
no .is-open, menu invisible."""
|
||
page = _mobile_page(browser)
|
||
try:
|
||
login(page, app_url, next="/")
|
||
_wait_settled_admin(page)
|
||
|
||
_open_menu(page)
|
||
expect(page.locator("#nav-sources")).to_be_visible()
|
||
page.click("#nav-sources")
|
||
expect(page).to_have_url(app_url + "/sources.html", timeout=15_000)
|
||
|
||
# The arrival page: a fresh header, shipped closed.
|
||
_assert_menu_closed(page)
|
||
expect(page.locator("#app-nav")).to_be_hidden()
|
||
finally:
|
||
page.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. Esc close (+ focus return) and the accepted outside-click behavior
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_esc_and_outside_close(
|
||
browser: Browser, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""AC4: Esc closes the menu AND returns focus to the toggle (the
|
||
opener — a keyboard user never loses their place).
|
||
|
||
Accepted behavior (NOT a defect, per the task-02 contract and story
|
||
AC 4): an OUTSIDE click does not close the menu. The locked close
|
||
set is Esc + link + resize — the story's AC 4 lists exactly those
|
||
three, and the owner-locked scope (2026-08-27) does not include a
|
||
backdrop click (there is no backdrop element at all — the menu is
|
||
the nav itself dropping out of the sticky header). This test pins
|
||
the menu STAYING OPEN on an outside click so an accidental
|
||
backdrop-close implementation cannot sneak in later."""
|
||
page = _mobile_page(browser)
|
||
try:
|
||
page.goto(app_url)
|
||
_wait_settled_anonymous(page)
|
||
|
||
# Phase 85 (TODO.md L3): the gate (z 15) sits below the header
|
||
# (z 20) — the real click reaches the anonymous toggle too.
|
||
# Esc closes + refocuses the opener.
|
||
_open_menu(page)
|
||
page.keyboard.press("Escape")
|
||
_assert_menu_closed(page)
|
||
assert page.evaluate("() => document.activeElement.id") == "nav-toggle", (
|
||
"Esc-close must return focus to the #nav-toggle opener"
|
||
)
|
||
|
||
# Outside click: the menu STAYS open (accepted behavior — the
|
||
# locked close set is Esc + link + resize, not backdrop click).
|
||
# Phase 85: for the anonymous visitor the "outside" point is
|
||
# the gate overlay itself — a REAL mouse click below the
|
||
# centered card, outside the nav (the gate covers the content
|
||
# but no longer the header; it carries no close listener).
|
||
_open_menu(page)
|
||
page.mouse.click(10, 780) # the gate overlay — a neutral, non-nav point
|
||
expect(page.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
|
||
assert "is-open" in (page.locator("#app-nav").get_attribute("class") or ""), (
|
||
"accepted behavior: an outside click must NOT close the menu"
|
||
)
|
||
# …and the menu is still fully usable (Esc still settles it).
|
||
page.keyboard.press("Escape")
|
||
_assert_menu_closed(page)
|
||
finally:
|
||
page.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. Animation + prefers-reduced-motion
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_animation_and_reduced_motion(
|
||
browser: Browser, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""AC3: motion allowed — the menu carries the 180ms opacity/transform
|
||
transition pair and opening flips the .is-open class + aria-expanded
|
||
in lockstep. reducedMotion: "reduce" — no transition in EITHER state
|
||
(the .is-open rule is higher-specificity than a bare .app-nav rule,
|
||
so the override must name both — pinned here against the live
|
||
computed style) and open/close still works, instantly."""
|
||
# Motion allowed: the 180ms slide+fade pair is live.
|
||
page = _mobile_page(browser)
|
||
try:
|
||
page.goto(app_url)
|
||
_wait_settled_anonymous(page)
|
||
report = page.evaluate(
|
||
"() => { const cs = getComputedStyle(document.querySelector('#app-nav'));"
|
||
" return { duration: cs.transitionDuration, property: cs.transitionProperty };"
|
||
" }"
|
||
)
|
||
assert "0.18s" in report["duration"], (
|
||
f"the menu must transition in 180ms, got {report['duration']!r}"
|
||
)
|
||
assert "opacity" in report["property"] and "transform" in report["property"], (
|
||
f"the transition must cover the opacity/transform slide+fade, "
|
||
f"got {report['property']!r}"
|
||
)
|
||
# Opening flips class + aria together (the animated state).
|
||
# Phase 85: the gate sits below the header — the real click
|
||
# reaches the anonymous toggle.
|
||
_open_menu(page)
|
||
page.keyboard.press("Escape")
|
||
_assert_menu_closed(page)
|
||
finally:
|
||
page.close()
|
||
|
||
# Reduced motion: stills in both states, open/close still works.
|
||
context = browser.new_context(
|
||
reduced_motion="reduce", viewport=MOBILE
|
||
)
|
||
rpage = context.new_page()
|
||
try:
|
||
rpage.goto(app_url)
|
||
_wait_settled_anonymous(rpage)
|
||
|
||
def _stilled(el: str) -> str:
|
||
return rpage.evaluate(
|
||
f"() => getComputedStyle(document.querySelector('{el}')).transitionDuration"
|
||
)
|
||
|
||
assert _stilled("#app-nav") == "0s", (
|
||
f"reduced motion: closed state must not transition, got {_stilled('#app-nav')!r}"
|
||
)
|
||
# Phase 85: real drive (the gate no longer intercepts the
|
||
# anonymous click — it sits below the header).
|
||
_open_menu(rpage)
|
||
expect(rpage.locator("#nav-toggle")).to_have_attribute("aria-expanded", "true")
|
||
expect(rpage.locator("#app-nav")).to_have_class(re.compile(r"\bis-open\b"))
|
||
assert _stilled("#app-nav") == "0s", (
|
||
"reduced motion: the .is-open state must not transition either "
|
||
"(the override must out-specificity .app-nav.is-open)"
|
||
)
|
||
rpage.keyboard.press("Escape")
|
||
_assert_menu_closed(rpage)
|
||
assert not rpage.locator("#app-nav").is_visible(), (
|
||
"reduced motion: the closed menu must be invisible"
|
||
)
|
||
finally:
|
||
context.close()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 6. Desktop regression: the bar is byte-identical to pre-phase-46
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_desktop_unchanged(
|
||
browser: Browser, app_url: str, db_ready: None
|
||
) -> None:
|
||
"""AC6 (regression): at 1280×800 the hamburger is absent
|
||
(``display: none`` — outside the ≤640px block) and the inline nav
|
||
renders in the bar exactly as before — admin sees all four links,
|
||
every one of them INSIDE the header band (no dropdown at this
|
||
width; the phase-34/35 bar contract is intact)."""
|
||
page = browser.new_page(viewport=DESKTOP)
|
||
try:
|
||
login(page, app_url, next="/")
|
||
_wait_settled_admin(page)
|
||
|
||
# The hamburger is absent on desktop (global display:none).
|
||
toggle = page.locator("#nav-toggle")
|
||
expect(toggle).to_be_hidden()
|
||
assert page.evaluate(
|
||
"() => getComputedStyle(document.querySelector('#nav-toggle')).display"
|
||
) == "none", "the toggle must be display:none outside the ≤640px block"
|
||
|
||
# The inline nav: all four links visible, each box inside the
|
||
# header band (the dropdown positioning only applies ≤640px).
|
||
expect(page.locator("#app-nav")).to_be_visible()
|
||
header_box = page.locator("header.app-header").bounding_box()
|
||
assert header_box is not None
|
||
for sel in NAV_LINKS:
|
||
link = page.locator(sel)
|
||
expect(link).to_be_visible()
|
||
box = link.bounding_box()
|
||
assert box is not None
|
||
assert box["y"] >= header_box["y"] - 1, f"{sel}: link is above the header"
|
||
assert box["y"] + box["height"] <= header_box["y"] + header_box["height"] + 1, (
|
||
f"{sel}: the inline link must sit inside the header band"
|
||
)
|
||
assert _visible_nav_links(page) == list(LINK_TEXTS)
|
||
finally:
|
||
page.close()
|