140 lines
6.0 KiB
Python
140 lines
6.0 KiB
Python
"""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 <name>` (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
|