192 lines
8.0 KiB
Python
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"
|