Files
brain-of-reese/tests/unit/test_frontend_scroll.py
T
ducoterra 27b7cb96d5 feat(chat): thinking window scrolls again, follows the tail only while pinned
TODO.md L7: "Add scrolling back to the thinking block, but have it
autoscroll while thinking content is generating." Owner direction
2026-08-27 (roadmap A2) reverses the phase-21 no-scroll choice
(2026-08-24): details.thinking .thinking-text is user-scrollable again
(overflow-y: auto — the 320px clip stays, owner-confirmed), and the
phase-17 per-chunk bottom-pin is GATED: the window follows the live
tail only while the user is pinned near its bottom (THINKING_NEAR_
BOTTOM_PX = 32); scrolling up pauses the follow, returning to the
bottom re-arms it on the next chunk (the gate re-runs on every frame).

- frontend/assets/styles.css: .thinking-text overflow-y: hidden ->
  auto; the phase-21 owner-choice comment is replaced with the
  2026-08-27 direction; max-height: 320px and every other declaration
  in the rule byte-identical.
- frontend/assets/app.js: export const THINKING_NEAR_BOTTOM_PX = 32 +
  isThinkingNearBottom(textEl) (scrollHeight - scrollTop -
  clientHeight <= band); the thinking-handler pin becomes
  `if (block.open && isThinkingNearBottom(textEl))` — a scrolled-up
  reader is never re-pinned and a closed (restored) block is never
  pinned; everything else in the handler (and phase 42's no page
  scroll) untouched.
- tests/unit/test_thinking_scroll.py (new, replaces the deleted
  tests/unit/test_thinking_no_scroll.py): pins the CSS contract (auto
  + 320px + owner-direction comment, no hidden/scroll left), the
  exported 32px band, the gate math, the gated pin (no unconditional
  `if (block.open)` remains), and the surviving collapsed-restore pin.
- tests/e2e/test_thinking_scroll.py (new, mock-only, replaces the
  deleted tests/e2e/test_thinking_no_scroll.py — its pins asserted the
  reversed phase-21 behavior, so both phase-21 files are deleted in
  this commit): user scroll restored on the frozen 4s-hesitation tail
  (wheel is 1:1; click+Home keyboard — the plain div is not
  keyboard-focusable by design, tabindex is test scaffolding; the
  literal drag holds the user's position — headless Chromium's
  overlay scrollbars are not grabbable by synthetic mouse events,
  documented in the suite), follow-while-pinned at the 2nd-to-last
  and last chunk (±1px) with the last chunk's text inside the visible
  rectangle, no re-pin over ≥5 mid-stream chunks after a
  half-window scroll-up, re-pin on the next chunk after returning to
  the bottom, the CSS contract, plus the phase-11 (long answer: page
  scrolls, bubble overflow untouched) and phase-17 (restored
  collapsed block with full text) regressions.
- tests/unit/test_chat_persistence.py: the CSS pin flips with the
  contract (auto in, hidden out — owner direction 2026-08-27).
- tests/unit/test_frontend_scroll.py: the "page-level band constant is
  gone" pin now excludes the phase-43 window-level
  THINKING_NEAR_BOTTOM_PX (a different band — the window's, not the
  page's).

E2E (isolation): test_thinking_scroll 7/7 (twice); regressions
test_thinking_display 5/5, test_chat_persistence 4/4,
test_no_reply_autoscroll 5/5, test_smoke 3/3; unit+integration 725
passed, app/ coverage 99% (unchanged — frontend-only phase);
ruff + pyright clean.
2026-08-28 01:36:32 -04:00

186 lines
8.2 KiB
Python

"""Unit: the no-reply-autoscroll contract in the static frontend
(phase 42, owner direction 2026-08-27, TODO.md L5).
The owner removed the phase-18 follow-the-bottom auto-follow: the page
NEVER auto-scrolls while a turn streams (thinking / tool / delta frames
all leave the viewport alone), so a user reading earlier content is no
longer yanked down mid-answer. Scrolls happen only on explicit user
intent: the submit (the user's own message is revealed) and the phase-14
restore landing (one-shot, load-time).
The JS behavior itself is E2E-covered
(tests/e2e/test_no_reply_autoscroll.py); here we pin the source markers
of the new contract — the phase-18 gate is gone (no NEAR_BOTTOM_PX /
isNearBottom), scrollReveal scrolls unconditionally and is the single
scrollIntoView in app.js, addMessage takes an explicit `scroll` intent,
and the streaming handlers contain no page-scroll call at all — so a
silent regression back to per-frame autoscroll 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_phase18_gate_is_gone() -> None:
"""The follow-the-bottom machinery (phase 18) is removed by owner
direction 2026-08-27: no page-level band constant, no gate
function, and no document-scroller measurement left anywhere in
app.js. (The phase-43 window-level THINKING_NEAR_BOTTOM_PX /
isThinkingNearBottom pair is a different band — the thinking
window's, not the page's.)"""
js = _js()
assert not re.search(r"(?<![A-Z_])NEAR_BOTTOM_PX\b", js), (
"the 200px page-level band constant must be gone"
)
assert "isNearBottom" not in js, "the pinned-to-bottom gate must be gone"
assert "window.scrollY" not in js, (
"nothing in app.js measures the page scroll offset anymore"
)
def test_scroll_helper_is_unconditional() -> None:
"""scrollReveal is still the ONE scrollIntoView in app.js, but it now
scrolls unconditionally — no force-or-near-bottom condition in its
body, and the phase-18 `force` parameter is gone. A page scroll can
only ever happen where scrollReveal is CALLED (submit + restore)."""
js = _js()
body = _fn_body(js, "scrollReveal")
assert "scrollIntoView" in body
assert 'block: "end"' in body
assert "if (" not in body, "the helper must have no gate — it scrolls when called"
assert "force" not in body, "the phase-18 force parameter must be gone"
# Still smooth / reduced-motion-aware through the default behavior.
assert "behavior = SCROLL" in body
# The regression pin: exactly one actual scrollIntoView CALL in the
# whole file, and it lives inside scrollReveal (the word may appear
# in comments; the call must not).
assert js.count(".scrollIntoView(") == 1, (
"app.js must call scrollIntoView exactly once (inside scrollReveal)"
)
assert js.find(".scrollIntoView(") > js.find("function scrollReveal")
def test_add_message_takes_explicit_scroll_intent() -> None:
"""addMessage(who, html, scroll = false): the phase-18
scrollBehavior/force parameters are gone; the bubble scrolls only
when the caller explicitly asks (submit reveal, restore landing)."""
js = _js()
body = _fn_body(js, "addMessage")
assert "function addMessage(who, html, scroll = false)" in body
assert "if (scroll) scrollReveal(wrap)" in body
assert "force" not in body
assert "scrollBehavior" not in body
def test_submit_reveals_user_message() -> None:
"""User intent kept by the owner: submitting scrolls the viewport down
so the user's own message is visible — the submit addMessage passes
the scroll intent; the streaming brain-bubble creations in the same
function never do."""
js = _js()
send = js.find("async function handleSend")
assert send != -1, "handleSend must exist"
body = js[send : js.find("\n}\n", send)]
assert 'addMessage("user", renderMarkdown(text), true)' in body, (
"the submit must reveal the user message (scroll intent true)"
)
for call in re.findall(r'addMessage\("brain"([^)]*)\)', body):
assert "true" not in call, (
f"streaming brain bubbles must not scroll the page: {call!r}"
)
def test_streaming_handlers_never_scroll_the_page() -> None:
"""The heart of the phase-42 contract: the thinking / tool / delta
branches contain NO page-scroll call (no scrollReveal, no raw
scrollIntoView). The thinking branch keeps the block-INTERNAL pin
(textEl.scrollTop — phase 17, reworked in phase 43): that scrolls
the block's own clip, not the page."""
js = _js()
think = js.find('ev.type === "thinking"')
tool = js.find('ev.type === "tool"')
delta = js.find('ev.type === "delta"')
done = js.find('ev.type === "done"')
assert -1 < think < tool < delta < done, "the turn handler must branch in order"
for name, branch in (
("thinking", js[think:tool]),
("tool", js[tool:delta]),
("delta", js[delta:done]),
):
assert "scrollReveal" not in branch, f"the {name} branch must not scroll the page"
assert ".scrollIntoView(" not in branch, (
f"the {name} branch must not scroll the page"
)
# The thinking window pin survives (phase 17 — untouched by this phase).
thinking_branch = js[think:delta]
assert "textEl.scrollTop = textEl.scrollHeight" in thinking_branch
assert js.count("textEl.scrollTop = textEl.scrollHeight") == 1
def test_restore_landing_is_one_shot() -> None:
"""The phase-14 restore landing keeps its one-shot scroll
(owner-kept): both restore call sites pass the explicit scroll
intent and they are the only two restore scrolls; with the submit's
single reveal, exactly three `true` intents exist in the whole file.
The old forced "auto" landing is gone, and the one-shot, load-time
contract is documented at the call site."""
js = _js()
body = _fn_body(js, "renderStoredMessage")
assert 'addMessage("user", renderMarkdown(m.text), true)' in body
assert 'addMessage("brain", renderMarkdown(m.text), true)' in body
assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
# Submit reveal + the two restore landings — nothing else scrolls.
assert js.count(", true)") == 3, "only submit + the two restore calls may scroll"
# The marker comment documents the one-shot, load-time contract.
assert "restore landing" in body
assert "one-shot" in body
def test_typing_bubble_does_not_scroll() -> None:
"""A typing indicator appearing must not yank the page — the phase-18
scrollReveal call in addTyping is removed with the gate."""
js = _js()
body = _fn_body(js, "addTyping")
assert "scrollReveal" not in body
assert ".scrollIntoView(" not in body
def test_scroll_constant_reduced_motion_intact() -> None:
"""The SCROLL constant is untouched (calm, don't remove): smooth by
default, "auto" under prefers-reduced-motion — the two kept scroll
call sites ride it as the default behavior."""
js = _js()
assert 'const SCROLL = reducedMotion ? "auto" : "smooth";' in js
assert 'matchMedia("(prefers-reduced-motion: reduce)")' in js
assert "Calm, don't remove" in js
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.
preventScroll keeps the keyboard flow without the scroll."""
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