"""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 white-arc spinner; the 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