"""Unit: the follow-the-bottom scroll contract in the static frontend (phase 18, owner choice 2026-08-23). The JS behavior itself is E2E-covered (tests/e2e/test_follow_bottom_scroll.py); here we pin the exported band constant and the single-gate markers that the story depends on — scrollIntoView appears exactly once in app.js, inside scrollReveal — so a silent regression back to unconditional per-delta / per-chunk scrolls is caught without a browser. """ from __future__ import annotations import re from pathlib import Path FRONTEND = Path(__file__).resolve().parents[2] / "frontend" APP_JS = FRONTEND / "assets" / "app.js" def _js() -> str: return APP_JS.read_text(encoding="utf-8") def _fn_body(js: str, name: str) -> str: """Source of the function starting at `function ` (to its closing brace at column 0) — same slicing style as test_frontend_feedback.py.""" fn = js.find(f"function {name}") assert fn != -1, f"{name} must exist in app.js" return js[fn : js.find("\n}\n", fn)] def test_near_bottom_constant_exported_at_200px() -> None: """The "pinned to the bottom" band (the composer zone) must be an *exported* constant — unit-pinned, same pattern as TURN_TIMEOUT_MS.""" js = _js() assert re.search(r"export\s+const\s+NEAR_BOTTOM_PX\s*=\s*200\s*;", js), ( "app.js must export `const NEAR_BOTTOM_PX = 200`" ) def test_is_near_bottom_uses_document_scroller() -> None: """isNearBottom measures the DOCUMENT scroller (there is no inner scroll container — the page scrolls on the window): distance from the bottom of the document <= NEAR_BOTTOM_PX.""" js = _js() body = _fn_body(js, "isNearBottom") for ref in ( "documentElement.scrollHeight", "window.scrollY", "window.innerHeight", "NEAR_BOTTOM_PX", ): assert ref in body, f"isNearBottom must reference {ref!r}" assert "<=" in body, "the pinned band is an upper bound, not exact equality" def test_single_scroll_gate() -> None: """scrollReveal is the ONE scroll call site in app.js: it fires only when forced or when the user is pinned to the bottom, keeps `block: "end"`, and both addMessage (behavior + force passthrough) and addTyping (defaults) delegate to it.""" js = _js() body = _fn_body(js, "scrollReveal") assert "force || isNearBottom()" in body, "gate: force OR pinned to the bottom" assert "scrollIntoView" in body assert 'block: "end"' in body # The regression pin: exactly one scrollIntoView in the whole file, and # it lives inside scrollReveal. assert js.count("scrollIntoView") == 1, ( "app.js must call scrollIntoView exactly once (inside scrollReveal)" ) assert js.find("scrollIntoView") > js.find("function scrollReveal") # addMessage passes its behavior/force through; addTyping uses defaults. add_body = _fn_body(js, "addMessage") assert "scrollReveal(wrap, scrollBehavior, force)" in add_body assert "force = false" in add_body typing_body = _fn_body(js, "addTyping") assert "scrollReveal(wrap)" in typing_body def test_submit_reveal_is_gated() -> None: """Submit keeps the plain default call — no force: the gate decides, and it does in real use because submitting from the composer means the user is pinned (inside the 200px band); a submit with the viewport away from the bottom does not yank it.""" js = _js() send = js.find("async function handleSend") assert send != -1, "handleSend must exist" call = 'addMessage("user", renderMarkdown(text));' idx = js.find(call, send) assert idx != -1, "handleSend must reveal the user message via the plain default" assert 'addMessage("user", renderMarkdown(text),' not in js, ( "the submit call must not pass a third/fourth argument (no force)" ) def test_restore_force_landing() -> None: """Both restore call sites are the only `force`d scrolls: one-shot, non-smooth ("auto") landing on the last restored message (phase-14 behavior preserved).""" js = _js() body = _fn_body(js, "renderStoredMessage") assert 'addMessage("user", renderMarkdown(m.text), "auto", true)' in body assert 'addMessage("brain", renderMarkdown(m.text), "auto", true)' in body # Forced restores are restore-only: exactly two ("auto", true) sites. assert js.count('"auto", true') == 2, "only the two restore calls may force" def test_streaming_scrolls_only_through_gate() -> None: """The per-chunk scrolls that used to yank the viewport (the phase-17 thinking branch and the streaming delta branch) now go through scrollReveal with no raw scrollIntoView at either call site; the block's internal bottom-pinning (its own overflow, not the page) stays.""" js = _js() thinking_idx = js.find('ev.type === "thinking"') delta_idx = js.find('ev.type === "delta"') done_idx = js.find('ev.type === "done"') assert -1 < thinking_idx < delta_idx < done_idx thinking_branch = js[thinking_idx:delta_idx] delta_branch = js[delta_idx:done_idx] assert "scrollReveal(wrap)" in thinking_branch assert "scrollReveal(wrap)" in delta_branch assert "scrollIntoView" not in thinking_branch assert "scrollIntoView" not in delta_branch assert "textEl.scrollTop = textEl.scrollHeight" in thinking_branch def test_turn_end_focus_does_not_scroll() -> None: """The turn-end focus-back (phase 06's "always focus back") must not move the viewport: focusing the composer while the user is scrolled up would yank them to the bottom at the moment the turn ends — the exact defect phase 18 removes. preventScroll keeps the keyboard flow. startNewChat keeps plain focus (the list is cleared, nothing to yank past).""" js = _js() finally_idx = js.find("// done | error → idle: always settle, always focus back") assert finally_idx != -1, "the turn's finally block must exist" block = js[finally_idx : js.find("\n}", finally_idx)] assert 'input.focus({ preventScroll: true })' in block assert "input.focus()" not in block