Files
brain-of-reese/tests/unit/test_big_read_progress.py
ducoterra 10fd367962
Build and Push Containers / build-and-push-app (push) Successful in 1m43s
Build and Push Containers / build-and-push-db (push) Successful in 15s
fix(chat): render typing-indicator elapsed hint as horizontal text
The phase-87 ".typing-elapsed" dot-geometry reset (specificity 0,1,0)
lost every shared declaration to the ".typing span" dot rule
(0,1,1): the hint rendered as an 8x8px bouncing dot and the "Ns" text
wrapped one character per line below the bubble (overflow-wrap:
anywhere on .bubble). Phase 87's e2e checked text values only, so the
squish shipped unseen.

- retarget the reset at ".typing span.typing-elapsed" (0,2,1) so it
  actually wins; center the dots while the hint line is taller
- unit: pin the reset's selector context (specificity regression guard)
- e2e: layout pin on the live hint — no dot animation, not an 8px box,
  horizontal single-line bounding box
- before/after verification screenshots in
  .agents/reports/87_big_read_progress/

Verified: unit 41 passed, phase-87 e2e 4 passed (isolated), ruff +
pyright clean.
2026-09-08 10:03:10 -04:00

345 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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).
The reset's SELECTOR is pinned too (phase-87 regression, 2026-09):
a bare ``.typing-elapsed`` (specificity 0,1,0) loses every shared
declaration to the dot rule ``.typing span`` (0,1,1) — the hint
then rendered as an 8×8px bouncing dot with the "Ns" text wrapping
one character per line below the bubble. The reset must carry the
span context (``.typing span.typing-elapsed``, 0,2,1) to win."""
css = _css()
assert ".typing span.typing-elapsed {" in css, (
"the dot-geometry reset must target the span in .typing context "
"(bare .typing-elapsed loses the specificity war to .typing span)"
)
block = re.search(r"\.typing span\.typing-elapsed \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .typing span.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}"