Files
brain-of-reese/tests/unit/test_frontend_turn_loader.py
T
ducoterra 3a81793565
Build and Push Containers / build-and-push-app (push) Successful in 4m39s
Build and Push Containers / build-and-push-db (push) Successful in 35s
phase: 109_turn_progress_loader
All gates green. Final report:

**Phase 109 — final verification pass (all 3 tasks were already complete; no re-implementation needed)**

- Verified D15 (thinking-handler re-open `block.open = true` after `ensureThinkingBlock`; delta keeps closing; "never reopens" narrative removed; restore path still collapses) and D16 (single static `#turn-loader` in composer row, `aria-hidden`, sole `turnLoader.hidden = !inFlight` write in `setUiState`; CSS reuses `typing` keyframes + reduced-motion override after the main rule + provenance; global `[hidden]` guard intact; mock `TURN_PROGRESS_TRIGGER` repro marker; `app/` untouched — pure UI phase).
- Unit pins: `tests/unit/test_frontend_turn_loader.py` (9 tests) — green via full run.
- `uv run pytest tests/e2e/test_turn_progress_loader.py -v --no-cov` → **3 passed** (isolation, DB up): loader visible send→tool-gap→final-thinking, hidden after done; re-opened scratchpad carries `marker-thought-42`/`marker-final-thought-42`; `#send-status` carries state text, back to empty idle; reduced-motion context stills dots (computed `animation-name: none`) with loader still visible.
- Regressions in isolation: `test_thinking_display` 5 passed, `test_stop_generation` 3 passed, `test_big_read_progress` 4 passed, `test_loading_feedback` 5 passed.
- `uv run pytest` → **2314 passed**; `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (>90%); `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings.
- All terminal paths (done→idle L2554, stream error→error L2543, timeout→error L2247) funnel through `setUiState` — never-stale by construction.
- No defects found; no code changes made in this pass. Commit + phase-dir move left to the harness per pipeline rules (working tree carries all changes).
- Next pending phase: `108_history_wire_check` (still in `todo/`; out of scope here).
2026-09-14 01:27:16 -04:00

320 lines
14 KiB
Python

"""Unit: the phase-109 never-frozen-turn frontend contract (tasks 01-02).
No new Python app logic exists for this task — the behavior lives in
``frontend/assets/app.js`` and is E2E-gated by the story suite (task 03).
Like the other frontend-adjacent unit files (the house
read-the-assets-as-text pattern, cf. ``test_frontend_tool_states.py``),
this module pins the JS markers the phase depends on: the thinking block
becomes a TOGGLE (D15) — ``thinking`` frames open it (idempotent),
``delta`` frames close it — while the phase-14 restore path and the
phase-17 follow-the-tail pin logic stay byte-for-byte untouched. Task 02
pins the persistent in-turn loader (D16): the static ``#turn-loader``
markup, ``setUiState`` as its SOLE visibility owner (the structural
never-stale guarantee), and the CSS contract (reused typing-dot
animation + reduced-motion + provenance).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
INDEX_HTML = FRONTEND / "index.html"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _html() -> str:
return INDEX_HTML.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
def _thinking_branch() -> str:
"""The `thinking` handler branch (between the thinking and tool
branches of the turn dispatch)."""
js = _js()
thinking_idx = js.find('ev.type === "thinking"')
tool_idx = js.find('ev.type === "tool"')
assert -1 < thinking_idx < tool_idx, (
"the turn handler must branch on thinking frames"
)
return js[thinking_idx:tool_idx]
def test_thinking_handler_reopens_the_collapsed_block() -> None:
"""Phase 109 (D15): the `thinking` handler re-opens the block —
``block.open = true`` sits in the handler, positioned AFTER the
``ensureThinkingBlock(wrap)`` line (the block must exist before it
can be opened). Idempotent: a no-op while already open (the
pre-delta live flow), a re-open after a `delta` closed it (the next
agent round — the reported freeze, TODO.md L3)."""
branch = _thinking_branch()
ensure_idx = branch.find("ensureThinkingBlock(wrap)")
assert ensure_idx != -1, "the handler must ensure the block first"
reopen_idx = branch.find("block.open = true")
assert reopen_idx != -1, (
"the `thinking` handler must open the block (D15: "
"open-while-thinking)"
)
assert ensure_idx < reopen_idx, (
"`block.open = true` must sit AFTER `ensureThinkingBlock(wrap)` "
"— the block must exist before it is opened"
)
def test_delta_handler_still_closes_the_block() -> None:
"""Phase 109 (D15): the close side of the toggle survives — the
`delta` handler still calls ``closeThinkingBlock(wrap)`` (closed
while answering); nothing else about the block's lifecycle changes."""
js = _js()
delta_idx = js.find('ev.type === "delta"')
done_idx = js.find('ev.type === "done"')
assert -1 < delta_idx < done_idx, "the turn handler must branch on delta"
branch = js[delta_idx:done_idx]
assert "closeThinkingBlock(wrap)" in branch, (
"the `delta` handler must keep closing the block (D15: "
"closed-while-answering)"
)
def test_close_thinking_block_docstring_says_toggle_not_one_way() -> None:
"""Phase 109 (D15): the narrative flips. The section comment
documenting ``closeThinkingBlock`` no longer claims "never reopens"
— nowhere in app.js does — and documents the toggle contract instead:
open-while-thinking / closed-while-answering, naming the `thinking`
handler's re-open and the `delta` handler's close. The function
body itself is unchanged (idempotent close, no-op without a
block)."""
js = _js()
fn = js.find("function closeThinkingBlock")
assert fn != -1, "closeThinkingBlock must exist"
body = js[fn : js.find("\n}\n", fn)]
assert "block.open = false" in body, "the close side is unchanged"
# The block comment that documents the helpers: walk back from the
# function to its section comment start.
comment_start = js.rfind("/*", 0, fn)
assert comment_start != -1
section = js[comment_start:fn]
assert "never reopens" not in section, (
"the one-way-door claim is gone (phase 109, D15)"
)
assert "never reopens" not in js, (
"the 'never reopens' narrative must not survive anywhere in app.js"
)
# The toggle contract is documented — and it names both sides:
# the `thinking` handler's re-open and the `delta` handler's close.
assert "TOGGLE" in section, "the contract is named: a toggle"
assert "open-while-thinking" in section
assert "closed-while-answering" in section
assert "`thinking`" in section, "the contract names the thinking handler"
assert "re-opens" in section, "the contract names the thinking handler's re-open"
assert "`delta`" in section, "the contract names the delta handler's close"
def test_restore_path_still_collapses_stored_blocks() -> None:
"""Phase-14 contract regression: ``renderStoredMessage`` restores
stored thinking blocks COLLAPSED (``block.open = false``) — the
phase-109 toggle only changes the LIVE `thinking` handler, the
restore path is untouched."""
js = _js()
fn = js.find("function renderStoredMessage")
assert fn != -1, "renderStoredMessage must exist"
body = js[fn : js.find("\n}\n", fn)]
assert "if (m.thinking)" in body
assert "ensureThinkingBlock(wrap)" in body
assert "block.open = false" in body, "stored blocks restore collapsed"
# The live re-open must NOT have leaked into the restore path:
# only the collapse assignment may appear in the function body.
assert "block.open = true" not in body, (
"the restore path must never open a stored block (phase-14)"
)
def test_follow_the_tail_pin_logic_is_untouched() -> None:
"""Cross-file regression (phase 17): the follow-the-tail pin is
unchanged — ``THINKING_NEAR_BOTTOM_PX`` (32px band) +
``isThinkingNearBottom`` and the pre-render capture
``const pinned = block.open && isThinkingNearBottom(textEl);``
(measured BEFORE the re-render) still sit in the `thinking` handler.
The capture already keys off ``block.open``, so a re-opened block
resumes pinned tail-following exactly like the live pre-delta
block — no pin logic change needed for D15."""
js = _js()
assert "export const THINKING_NEAR_BOTTOM_PX = 32;" in js, (
"the 32px window band is unchanged"
)
fn = re.search(
r"function isThinkingNearBottom\(textEl\) \{([\s\S]*?)\n\}", js
)
assert fn, "isThinkingNearBottom must still exist"
assert "THINKING_NEAR_BOTTOM_PX" in fn.group(1)
branch = _thinking_branch()
capture = "const pinned = block.open && isThinkingNearBottom(textEl);"
assert capture in branch, (
"the pre-render `block.open &&` guard line is unchanged — D15 "
"keeps the pin logic untouched (it already reads block.open "
"before the re-render)"
)
capture_idx = branch.find(capture)
render_idx = branch.find("textEl.innerHTML = renderMarkdown(thinkingAcc)")
assert -1 < capture_idx < render_idx, (
"pin state is still measured BEFORE the re-render (phase-17 "
"regression — the 2-newline-gap fix)"
)
assert "textEl.scrollTop = textEl.scrollHeight" in branch, (
"pinned tail-following is intact"
)
# ---------- task 02: the persistent in-turn loader (D16) ----------
def test_index_html_carries_exactly_one_turn_loader() -> None:
"""Phase 109 (D16): ``index.html`` carries exactly ONE static
``#turn-loader`` — an empty ``<div>`` (never constructed in JS: the
createElement/textContent house rule), ``aria-hidden="true"``
(decorative — ``#send-status`` carries the meaning), ``hidden`` by
default (idle on load), and it sits INSIDE the composer form — the
composer's status row, the visible companion of the ``#send-status``
line."""
html = _html()
assert html.count('id="turn-loader"') == 1, (
"exactly one #turn-loader — static markup, never JS-built"
)
match = re.search(r'<div\s+id="turn-loader"[^>]*>', html)
assert match, "the loader must be a static <div>"
tag = match.group(0)
assert 'class="turn-loader"' in tag
assert 'aria-hidden="true"' in tag, "decorative — aria-hidden"
# the hidden attribute (a standalone word in the tag, not the
# substring of some other attribute value): ships hidden (idle).
assert re.search(r"\shidden\s*/?>$", tag), (
"the loader ships hidden (idle on load)"
)
# empty element: the closing tag follows immediately — no children,
# no JS-built HTML anywhere near it.
assert html[match.end() : match.end() + 6] == "</div>", (
"the loader element is empty (static shell)"
)
# inside the composer form (the status row), not elsewhere in the
# shell.
composer_idx = html.find('<form class="composer" id="composer"')
assert composer_idx != -1
form_end = html.find("</form>", composer_idx)
assert composer_idx < match.start() < form_end, (
"the loader sits in the composer's status row"
)
def test_set_ui_state_is_the_sole_owner_of_the_loader() -> None:
"""Phase 109 (D16) — the never-stale guarantee, structural: in
``app.js`` the string ``turnLoader.hidden`` appears EXACTLY ONCE,
inside ``setUiState`` (the cross-file single-owner check — any
second write site fails this test). The toggle is
``turnLoader.hidden = !inFlight`` next to the existing ``is-stop``
toggle: shown iff ``uiState`` is thinking|streaming. Every terminal
path funnels through ``setUiState`` (done → idle, error → error,
stop/timeout → their landings), so the loader hides in every
terminal state BY CONSTRUCTION — no per-handler cleanup."""
js = _js()
# The element lookup joins the other module-top lookups.
assert 'const turnLoader = document.querySelector("#turn-loader");' in js
# THE invariant: exactly one write site in the whole file.
assert js.count("turnLoader.hidden") == 1, (
"setUiState is the SOLE writer of the loader's hidden attribute — "
"a second write site breaks the §7.4 never-stale guarantee"
)
# ...and it sits INSIDE setUiState, next to the is-stop toggle.
fn = js.find("export function setUiState")
assert fn != -1
body_end = js.find("\n}\n", fn)
owner_idx = js.find("turnLoader.hidden")
assert fn < owner_idx < body_end, (
"the toggle must live inside setUiState (the single entry point)"
)
stop_idx = js.find('sendBtn.classList.toggle("is-stop", inFlight);', fn)
assert -1 < stop_idx < owner_idx, (
"the loader toggle joins the existing is-stop toggle"
)
assert "turnLoader.hidden = !inFlight;" in js, (
"shown iff inFlight (thinking|streaming)"
)
def test_loader_css_reuses_the_typing_animation_and_reduced_motion() -> None:
"""Phase 109 (D16) — the CSS contract: the ``.turn-loader`` rule
sits NEXT TO the typing-dots rules (after the ``@keyframes typing``
block), reuses the SAME animation name (``typing`` — no new
animation family), a ``prefers-reduced-motion`` block covers it
(static dots, no pulse — §7.2 house law), and the provenance
comment names phase 109 + ``TODO.md``."""
css = _css()
keyframes_idx = css.find("@keyframes typing")
assert keyframes_idx != -1, "the typing-dot keyframes exist"
# The MAIN rule (".turn-loader {" — the reduced-motion block's
# selector list never starts that exact string) must sit next to
# (after) the typing-dots rules.
rule_idx = css.find(".turn-loader {")
assert rule_idx != -1, "styles.css must carry a .turn-loader rule"
assert keyframes_idx < rule_idx, (
"the .turn-loader rule sits next to (after) the typing-dots rules"
)
# The provenance comment: the /* ... */ block immediately preceding
# the rule names phase 109 and TODO.md.
comment_start = css.rfind("/*", 0, rule_idx)
assert comment_start != -1
comment = css[comment_start:rule_idx]
assert "Phase 109" in comment, "the provenance comment names phase 109"
assert "TODO.md" in comment, "the provenance comment cites the source"
# Reuses the EXISTING typing-dot animation: the same animation name
# in the rule and its pseudo-element rules (no new @keyframes
# family anywhere).
rule_block = css[rule_idx : rule_idx + 1200]
assert re.search(r"animation:\s*typing\b", rule_block), (
"the loader reuses the typing dots' animation (same name — no "
"new animation family)"
)
assert "@keyframes turn" not in css, "no new animation family for the loader"
# The prefers-reduced-motion block covers it: static dots, no pulse.
reduced_ok = False
for m in re.finditer(r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css):
if ".turn-loader" in m.group(1) and "animation: none" in m.group(1):
reduced_ok = True
break
assert reduced_ok, (
"a prefers-reduced-motion block must cover .turn-loader (static "
"dots, no pulse — §7.2)"
)
def test_loader_is_aria_hidden_and_status_untouched() -> None:
"""Phase 109 (D16) — the a11y split is untouched: the loader is
``aria-hidden`` (decoration), and ``#send-status`` keeps its exact
attributes in ``index.html`` — still the sole ``aria-live``
announcer (the house a11y split: visual cues are aria-hidden, the
live region carries the state text)."""
html = _html()
match = re.search(r'<div\s+id="turn-loader"[^>]*>', html)
assert match and 'aria-hidden="true"' in match.group(0)
# #send-status is unchanged: one element, the exact same tag —
# the visually-hidden polite live region inside the send button,
# still the chat view's state announcer.
assert html.count('id="send-status"') == 1
status = re.search(r'<span[^>]*id="send-status"[^>]*></span>', html)
assert status, "the #send-status live region is intact"
tag = status.group(0)
assert tag == (
'<span class="visually-hidden" aria-live="polite" '
'id="send-status"></span>'
), "the #send-status attributes are byte-unchanged"
# the loader added no a11y surface: it is aria-hidden and carries
# no live region of its own.
assert 'aria-live' not in match.group(0)