"""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. Phase 39: the label is BUILT at call time from window.BOR_BRAND (the classic brand.js layer) — `TYPING_LABEL()` — with the default name as the only fallback, so the default path renders the same bytes as before.""" js = _js() assert "TYPING_LABEL = () =>" in js, "the label must be built at call time (phase 39)" assert "`${brand()} is thinking`" in js, "the label must resolve the name via brand()" assert 'window.BOR_BRAND || "Brain of Reese"' in js, ( "the no-config fallback must keep the default name" ) 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"