Files
brain-of-reese/tests/unit/test_frontend_feedback.py
T

192 lines
8.0 KiB
Python

"""Unit: the loading-feedback contract in the static frontend (phase 06).
The JS behavior itself is E2E-covered (tests/e2e/test_loading_feedback.py);
here we pin the exported constants and state-machine markers that the
story depends on, so a silent regression in app.js/styles.css 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"
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")
def test_turn_timeout_constant_exported_at_120s() -> None:
"""The 120s client-side guard (PLAN §7.4) must be an *exported*
constant — testable, and the single value the E2E timeout story keys
off."""
js = _js()
match = re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*120_?000\s*;", js)
assert match, "app.js must export `const TURN_TIMEOUT_MS = 120000`"
def test_state_machine_has_all_four_states() -> None:
"""idle → thinking → streaming → done | error → idle (PLAN §7.4).
`done` is not a UI state: it settles into `idle` in the turn handler's
finally block, so the state machine itself has exactly four states.
"""
js = _js()
for state in ("idle", "thinking", "streaming", "error"):
assert re.search(rf'{state}:\s*"{state}"', js), f"state {state!r} missing"
assert "function setUiState" in js, "setUiState must exist as the single entry point"
assert "export function setUiState" in js, "setUiState must be exported (testable)"
def test_typing_indicator_contract_strings() -> None:
"""The indicator's accessible label and the 10s elapsed-seconds hint
are the strings screen readers (and the E2E) rely on."""
js = _js()
assert 'TYPING_LABEL = "Brain of Reese is thinking"' in js
assert "still thinking" in js, "elapsed-seconds hint must update the aria label"
assert "secs < 10" in js, "the hint must only appear after 10s of silence"
assert "role=\"status\"" in js, "typing bubble must be role=status"
def test_error_banner_has_actionable_hint() -> None:
"""Every error path (SSE error event, non-2xx, 120s timeout) must
surface the same actionable retry hint (story AC4)."""
js = _js()
assert "check the LLM is reachable" in js
assert '"role", "alert"' in js, "error banner must be role=alert"
# the banner must be the red error variant
assert "is-error" in js
def test_reduced_motion_calm_not_removed() -> None:
"""prefers-reduced-motion: feedback is calmed, never removed (story
AC7). Dots go static; the spinner only slows down."""
css = _css()
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
)
assert blocks, "styles.css must contain prefers-reduced-motion rules"
assert any(".typing span" in b and "animation: none" in b for b in blocks), (
"typing dots must have a static fallback under reduced motion"
)
assert any(".spinner" in b and "animation-duration" in b for b in blocks), (
"spinner must slow down (not vanish) under reduced motion"
)
def test_busy_button_style_tokens() -> None:
"""Story spec: busy send button is #a5b4fc with the 16px dark-arc
spinner (--bg on #a5b4fc = 9.7:1, phase 08); label swaps Send ↔ Thinking…."""
css = _css()
js = _js()
assert ".send-btn:disabled" in css
assert "#a5b4fc" in css
assert re.search(r"\.spinner \{[^}]*width: 16px", css)
assert "Thinking…" in js
assert 'sendLabel.textContent' in js
# ---------- thinking display (phase 17) ----------
def test_thinking_event_is_a_first_class_turn_branch() -> None:
"""Phase 17: `thinking` SSE frames stream live into the collapsible
Thinking block — the typing dots make way, the 120s pre-token guard
clears (the stream is alive), and the text renders through the
escape-first markdown renderer (XSS-safe). While open, the stream is
pinned to the bottom of the block."""
js = _js()
thinking_idx = js.find('ev.type === "thinking"')
delta_idx = js.find('ev.type === "delta"')
assert -1 < thinking_idx < delta_idx, "the turn handler must branch on thinking frames"
branch = js[thinking_idx:delta_idx]
assert "thinkingAcc += ev.text" in branch
assert "sawThinking = true" in branch
assert "clearTurnTimeout()" in branch, "first thinking frame clears the 120s guard"
assert "removeTyping()" in branch, "the live block replaces the typing dots"
assert "ensureThinkingBlock(wrap)" in branch
assert "renderMarkdown(thinkingAcc)" in branch, "escape-first renderer (XSS-safe)"
assert "textEl.scrollTop = textEl.scrollHeight" in branch, "bottom-pinned while open"
def test_thinking_block_helpers_are_idempotent() -> None:
"""ensureThinkingBlock returns the existing `.thinking` details or
creates it OPEN above the .bubble; closeThinkingBlock is a no-op
without a block and never reopens one once the answer started."""
js = _js()
fn = js.find("function ensureThinkingBlock")
assert fn != -1, "ensureThinkingBlock must exist (near addTyping/removeTyping)"
body = js[fn : js.find("\n}\n", fn)]
assert "block.open = true" in body, "created open — the stream is the show"
assert "insertBefore" in body
assert 'querySelector(".bubble")' in body, "the block sits ABOVE the bubble"
fn2 = js.find("function closeThinkingBlock")
assert fn2 != -1, "closeThinkingBlock must exist"
body2 = js[fn2 : js.find("\n}\n", fn2)]
assert "block.open = false" in body2
def test_delta_branch_collapses_block_and_transitions_to_streaming() -> None:
"""The first answer delta transitions thinking → streaming (even when
thinking created the wrap first) and auto-collapses the block —
idempotent, and it never reopens once the answer started."""
js = _js()
delta_idx = js.find('ev.type === "delta"')
done_idx = js.find('ev.type === "done"')
assert -1 < delta_idx < done_idx
branch = js[delta_idx:done_idx]
assert "uiState === UI_STATE.thinking" in branch
assert "setUiState(UI_STATE.streaming)" in branch
assert "closeThinkingBlock(wrap)" in branch
def test_done_branch_sets_sawdone_and_closes_block() -> None:
"""On `done` the turn marks itself complete (sawDone — the stream-drop
guard keys off it) and settles the thinking block closed."""
js = _js()
done_idx = js.find('ev.type === "done"')
error_idx = js.find('ev.type === "error"')
assert -1 < done_idx < error_idx
branch = js[done_idx:error_idx]
assert "sawDone = true" in branch
assert "closeThinkingBlock(wrap)" in branch
def test_stream_drop_guard_reports_severed_stream() -> None:
"""A stream that delivered frames but no `done` event ends in the error
state (never a silent idle with a half bubble); the zero-frame case
falls through to the existing empty-answer fallback. The guard runs
after readSSE, before that fallback."""
js = _js()
assert "let sawDone = false" in js
assert re.search(r"if \(!sawDone && !aborted && \(acc \|\| thinkingAcc\)\)", js), (
"sawDone stream-drop guard missing after readSSE"
)
assert "The stream ended before my answer finished" in js
sse_idx = js.find("await readSSE(res,")
guard_idx = js.find("!sawDone && !aborted")
fallback_idx = js.find("!aborted && !wrap")
assert -1 < sse_idx < guard_idx < fallback_idx, (
"guard must sit between readSSE and the zero-frame fallback"
)
def test_thinking_chevron_stills_under_reduced_motion() -> None:
"""Phase 17: the only motion in the thinking block (the summary
chevron rotation) is disabled under prefers-reduced-motion."""
css = _css()
blocks = re.findall(
r"@media \(prefers-reduced-motion: reduce\) \{([\s\S]*?)\n\}", css
)
assert any(
"details.thinking summary::before" in b and "transition: none" in b
for b in blocks
), "chevron transition must still under reduced motion"