Files
brain-of-reese/tests/unit/test_big_read_progress.py
T
ducoterra 7cfe58fb21
Build and Push Containers / build-and-push-app (push) Successful in 2m48s
Build and Push Containers / build-and-push-db (push) Successful in 19s
phase: 87_big_read_progress
All criteria verified — no defects found, nothing to fix. Final report:

**Phase 87 — big read progress: final verification pass (all tasks already complete in `complete/`)**
- Verified implementation vs. overview: `TOOL_LINE_ELAPSED_AFTER_MS = 5_000`, single live `armToolLineClock` site, settle on thinking/retry/delta, `stopToolLineClock` in `setUiState`, visible `.typing-elapsed` (aria kept byte-identical), CSS AA pairing — all match design; `app/` byte-identical (0 changes)
- **Tests/lint (exact outcomes):**
  - `uv run pytest --cov=app --cov-report=term` → 1732 passed, coverage **99%** (>90% ✓)
  - `uv run pytest tests/e2e/test_big_read_progress.py -v --no-cov` → **4 passed** (ticking suffix, visible hint, settle, no-timer restore)
  - `test_thinking_display.py` → 5 passed · `test_agent_document_tools.py` → 4 passed · `test_smoke.py` → 3 passed (all isolated)
  - 3 pinned frontend suites + new unit pins → 62 passed · `uv run ruff check . && uv run pyright` → clean, 0 errors
- **Completion criteria:** E2E pins 1–4 ✓ · guard/state-machine byte-identical ✓ (diff is additive only) · diff scope limited to `app.js`, `styles.css`, 2 new test files, phase files; nothing in `app/` ✓
- **Notable:** no deviations; commit + `00_phase.md` move left to the harness per executor rules (task files already in `complete/`)
- **Next pending phase:** none — `todo/` contains only this phase (87 is the last)
2026-09-08 05:51:23 -04:00

334 lines
15 KiB
Python

"""Unit: the phase-87 big-read progress contract (source-level pins).
Phase 87 (TODO.md L5 — "Need indication that prompt processing is
happening during a big read, it can look frozen."): during any
frameless gap of an in-flight turn the UI must show, to sighted AND
screen-reader users, that processing is ongoing — a ticking
elapsed-seconds suffix on the latest tool line (after 5s of silence)
and a VISIBLE elapsed hint on the typing indicator (the existing 10s
pre-token clock promoted from aria-only to visible text). Both settle
the instant content resumes; persisted/restored turns never show
timers (A6 — the arming call lives only in the live frame branches).
Locked decisions (phase overview): frontend-only (A4 — the server, the
SSE event set, and the 120s guard are byte-identical), named
thresholds owned by the state machine (A5 — ``TOOL_LINE_ELAPSED_AFTER_MS``),
and the exact tool-line template literals stay byte-identical (the
suffix is a separate element the clock appends; ``appendToolLine``
renders exactly as before, which is also what makes A6 fall out for
free on restore).
This module reads ``frontend/assets/app.js`` + ``styles.css`` as text
(no browser — house pattern, cf. test_frontend_feedback.py); the live
behavior is E2E-gated by ``tests/e2e/test_big_read_progress.py``
(task 03). Task 01 pins the visible typing-indicator hint below;
task 02 extends the module with the tool-line clock pins.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
def _js() -> str:
return APP_JS.read_text(encoding="utf-8")
def _css() -> str:
return STYLES_CSS.read_text(encoding="utf-8")
# ---------- task 01: the visible typing-indicator elapsed hint ----------
def test_typing_elapsed_hint_is_built_with_createelement_and_textcontent() -> None:
"""Phase 87 task 01: after 10s of pre-token silence the typing
bubble gains a visible ``.typing-elapsed`` "Ns" hint — ensured
(created at most once, appended as the bubble's LAST child, after
the three dot spans) and written with ``textContent`` only. No
``innerHTML`` anywhere on the typing bubble: the ``addTyping``
template writes ``wrap.innerHTML`` (the static skeleton), and the
clock must never rewrite turn data into the DOM as HTML."""
js = _js()
assert ".typing-elapsed" in js, "the hint class must exist in app.js"
# The ensure pattern: find-or-create, then append to the bubble.
assert 'bubble.querySelector(".typing-elapsed")' in js
assert 'el.className = "typing-elapsed"' in js
assert "document.createElement(\"span\")" in js
assert "bubble.appendChild(el)" in js, "the hint is the bubble's last child"
# textContent-only write (the bubble is role="status" — announced).
assert 'el.textContent = secs + "s"' in js, (
"the hint text is the plain ticking \"Ns\" (A5 — no added wording)"
)
# No innerHTML on the typing bubble — the skeleton template
# (wrap.innerHTML in addTyping) is the only bubble-adjacent HTML
# write, and it must not gain a second one.
assert js.count("bubble.innerHTML") == 0, (
"the clock must never assign innerHTML to the typing bubble"
)
def test_typing_elapsed_hint_lives_in_start_thinking_clock() -> None:
"""The hint belongs to the EXISTING 10s pre-token clock (A5 — the
typing hint reuses its ``secs < 10`` gate): the ensure/write logic
sits inside ``startThinkingClock``'s 1s interval, right after the
aria-label update. ``addTyping``/``removeTyping`` stay untouched —
the hint lives and dies with the indicator the state machine owns."""
js = _js()
fn = js.find("function startThinkingClock")
assert fn != -1, "startThinkingClock must exist"
body = js[fn : js.find("\n}\n", fn)]
assert ".typing-elapsed" in body, "the hint is ensured inside the clock"
assert 'el.textContent = secs + "s"' in body
# The 10s gate is kept (aria + visible share it).
assert "secs < 10" in body
# addTyping/removeTyping never touch the hint (state-machine-owned).
for name in ("function addTyping", "function removeTyping"):
f = js.find(name)
assert f != -1, f"{name} must exist"
b = js[f : js.find("\n}\n", f)]
assert ".typing-elapsed" not in b, f"{name} must stay untouched"
def test_typing_aria_label_contract_stays_byte_identical() -> None:
"""The pinned aria channel survives the promotion to visible text —
both users, same clock (mirrors the phase-39 brand pin so this
module is self-documenting): the exact template literal, built at
call time via brand(), after the 10s gate."""
js = _js()
assert "`${brand()} is still thinking (${secs}s)`" in js
assert "secs < 10" in js, "the hint must only appear after 10s of silence"
assert "role=\"status\"" in js, "the typing bubble stays role=status"
def test_typing_elapsed_css_rule_is_the_aa_pairing() -> None:
"""The ``.typing-elapsed`` rule: small mono in ink-soft on the
bubble's --surface (the documented ≥4.5:1 AA pairing), the same
language as every status line. Plain text — ``animation: none`` +
no background, and the dot-geometry reset (the span is a sibling of
the dots inside the .typing bubble, so without the reset it would
render as an 8px bouncing dot, not a hint)."""
css = _css()
block = re.search(r"\.typing-elapsed \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .typing-elapsed"
body = block.group(1)
for prop in (
"font-family: var(--mono)",
"font-size: 0.75rem",
"color: var(--ink-soft)",
"margin-left: 0.5rem",
# the dot-geometry reset (see the rule's comment):
"width: auto",
"background: none",
"opacity: 1",
"animation: none",
):
assert prop in body, f".typing-elapsed must keep {prop}"
# Reduced motion: the hint is plain text, not motion — it keeps full
# opacity (the AA pairing) while the dots calm to 0.7.
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
)
assert any(".typing span.typing-elapsed" in b and "opacity: 1" in b for b in blocks), (
"the hint must keep full contrast under reduced motion"
)
# The existing reduced-motion dot fallback is untouched (phase 06 pin).
assert any(".typing span" in b and "animation: none" in b for b in blocks)
# ---------- task 02: the per-tool-line elapsed clock ----------
def test_tool_line_elapsed_constant_is_named_and_five_seconds() -> None:
"""A5: the visible "processing" threshold is the NAMED module constant
``TOOL_LINE_ELAPSED_AFTER_MS = 5_000`` (below it a frameless gap reads
as normal latency; at/above it the latest line proves it is still
processing) — a pinned constant, not a magic number in the tick."""
js = _js()
assert re.search(r"TOOL_LINE_ELAPSED_AFTER_MS\s*=\s*5_?000", js), (
"the 5s threshold must be the named constant TOOL_LINE_ELAPSED_AFTER_MS"
)
def test_tool_line_clock_state_is_turn_scoped_module_state() -> None:
"""One clock per turn: the three state vars live at module scope next
to the existing ``thinkingClock`` / ``turnTimeout`` state (re-armed
per `tool` frame, so each line counts its OWN silence)."""
js = _js()
for decl in (
"let toolLineTimer = 0",
"let toolLineStart = 0",
"let toolLineWrap = null",
):
assert decl in js, f"{decl} must be module-scope state"
anchor = js.find("let turnTimeoutCb = null")
assert anchor != -1, "the existing timer state must exist"
assert (
js.find("let toolLineTimer = 0") - anchor < 1500
), "the clock state sits next to the existing timer state"
def test_arm_has_exactly_one_live_call_site() -> None:
"""A6 (live-only): ``armToolLineClock(`` appears EXACTLY twice in
app.js — the definition + the single live call site in the `tool`
frame branch (right after the line's append). The restore path
(phase 14, ``renderStoredMessage``) never arms: a restored line reads
exactly as it did pre-phase (the permanent record, no stale timer)."""
js = _js()
assert js.count("armToolLineClock(") == 2, (
"the arm must be the definition + exactly one live call site"
)
tool_idx = js.find('ev.type === "tool"')
delta_idx = js.find('ev.type === "delta"')
branch = js[tool_idx:delta_idx]
assert "armToolLineClock(wrap)" in branch, "the live tool branch arms the clock"
append = branch.find("appendToolLine(wrap, name, argument)")
arm = branch.find("armToolLineClock(wrap)")
assert -1 < append < arm, (
"the arm follows the line's append — the baseline resets per line"
)
restore_fn = js.find("function renderStoredMessage")
restore_end = js.find("function restoreConversation")
assert -1 < restore_fn < restore_end
assert "armToolLineClock" not in js[restore_fn:restore_end], (
"the restore path must never arm the clock (A6)"
)
def test_settle_covers_the_three_live_frame_branches() -> None:
"""Settle = REMOVE the suffix: the `thinking`, `retry`, and `delta`
branches each call ``settleToolLine()`` at the TOP of the branch
(a frame arrived — the line is no longer "processing"), and the
settle clears the interval + removes every ``.tool-elapsed`` from the
wrap. Definition + three branches → at least 4 occurrences."""
js = _js()
assert js.count("settleToolLine(") >= 4, (
"the definition + the three frame branches must settle"
)
for branch_open, branch_close, first_work in (
('ev.type === "thinking"', 'ev.type === "tool"', "thinkingAcc += ev.text"),
('ev.type === "retry"', 'ev.type === "delta"', "const attempt"),
('ev.type === "delta"', 'ev.type === "done"', "acc += ev.text"),
):
start = js.find(branch_open)
end = js.find(branch_close, start)
assert -1 < start < end, f"the {branch_open!r} branch must exist"
seg = js[start:end]
assert "settleToolLine();" in seg, f"the {branch_open!r} branch must settle"
assert seg.index("settleToolLine();") < seg.index(first_work), (
f"the settle sits at the TOP of the {branch_open!r} branch, before its "
"content work (the line is no longer 'processing' the instant the "
"frame arrives)"
)
fn = js.find("function settleToolLine")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
assert "clearInterval(toolLineTimer)" in body, "the settle drops the interval"
assert 'querySelectorAll?.(".tool-elapsed")' in body and "el.remove()" in body, (
"the settle REMOVES every suffix (a frozen timestamp is noise)"
)
def test_stop_lives_in_setui_state_next_to_the_other_stops() -> None:
"""House invariant — "a stuck button is impossible" applied to a
stuck timer: ``stopToolLineClock()`` is called inside
``setUiState`` so EVERY transition stops/clears the clock. Documented
pin: the call sits in the function body AFTER the
``stopThinkingClock();`` line and BEFORE the first button write
(``sendBtn.disabled``) — stable under comment churn, and it forces
the call into the timer-clear block rather than a later branch.
``stopToolLineClock`` itself settles + forgets the wrap (no residue
across turns)."""
js = _js()
fn = js.find("export function setUiState")
assert fn != -1
body = js[fn : js.find("\n}\n", fn)]
assert "stopToolLineClock();" in body, "setUiState must stop the clock"
assert (
body.index("stopThinkingClock();")
< body.index("stopToolLineClock();")
< body.index("sendBtn.disabled")
), (
"the stop sits in setUiState's clear block (after stopThinkingClock, "
"before the button writes)"
)
sf = js.find("function stopToolLineClock")
assert sf != -1
sbody = js[sf : js.find("\n}\n", sf)]
assert "settleToolLine();" in sbody, "stop settles (clear interval + remove suffixes)"
assert "toolLineWrap = null" in sbody, "stop forgets the wrap — no residue"
def test_suffix_is_textcontent_only_on_the_latest_line() -> None:
"""The suffix is a parenthesized "(Ns)" status suffix (e.g. "📄
Reading src/app.py (12s)" — the typing hint stays bare "Ns") written
with ``textContent`` on a ``createElement`` span — never innerHTML —
appended as a SIBLING after the line's existing children (the pinned
template text + the <code> argument). It targets the LATEST line only
(`.tool-call:last-child` — older lines keep their permanent record),
and a missing container (New-Chat click mid-gap) makes the tick a
no-op (the null-safe chain is the guard)."""
js = _js()
fn = js.find("function armToolLineClock")
assert fn != -1, "armToolLineClock must exist"
body = js[fn : js.find("\n}\n", fn)]
assert 'querySelector?.(".tool-calls .tool-call:last-child")' in body, (
"the suffix targets the LATEST line only"
)
assert 'line.querySelector(".tool-elapsed")' in body, "find-or-create the suffix span"
assert 'el.className = "tool-elapsed"' in body
assert 'document.createElement("span")' in body, "the span is createElement'd"
assert "line.appendChild(el)" in body, (
"the suffix is a SIBLING appended AFTER the line's existing children"
)
assert "`(${secs}s)`" in body, ("the parenthesized '(Ns)' suffix, textContent-built")
assert "innerHTML" not in body, "no HTML write in the clock — textContent only"
assert "TOOL_LINE_ELAPSED_AFTER_MS" in body, "the tick gates on the named constant"
assert "toolLineWrap?.querySelector?" in body, (
"null-safe: a wrap reset mid-gap (New Chat) makes the tick a no-op"
)
def test_tool_line_template_literals_stay_byte_identical() -> None:
"""The exact ``line.textContent = "…"`` template literals survive —
mirrors test_frontend_tool_states.py so this module is
self-documenting: the suffix is a separate element the clock appends,
and a rewrite of the line text would break the emoji-guard strip set
(it strips precisely those) plus the phase-37 pins. The helper stays
clock-free — that is also what makes A6 fall out for free on restore."""
js = _js()
fn = js.find("function appendToolLine")
assert fn != -1, "appendToolLine must exist"
body = js[fn : js.find("\n}\n", fn)]
for lit in (
'line.textContent = "📄 Reading "',
'line.textContent = "🔎 Searching for "',
'line.textContent = "🔎 Listing documents in "',
'line.textContent = "🔎 Listing documents"',
):
assert lit in body, f"the pinned tool-line literal {lit!r} must stay byte-identical"
assert "tool-elapsed" not in body, "appendToolLine stays clock-free (A6)"
assert "armToolLineClock" not in body
def test_tool_elapsed_css_rule_is_the_aa_pairing() -> None:
"""The ``.tool-elapsed`` rule (next to the .tool-call rules): small
mono in ink-soft — the same AA pairing as the task-01 hint — with
``white-space: nowrap`` so "(12s)" never wraps in the row."""
css = _css()
block = re.search(r"\.tool-elapsed \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .tool-elapsed"
body = block.group(1)
for prop in (
"font-family: var(--mono)",
"font-size: 0.75rem",
"color: var(--ink-soft)",
"margin-left: 0.5rem",
"white-space: nowrap",
):
assert prop in body, f".tool-elapsed must keep {prop}"