feat(ui): explicit chat state machine — typing indicator, streaming progress, timeout and error recovery
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""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
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
import json
|
||||
|
||||
from app.api.chat import sse_event
|
||||
from app.schemas import ChatErrorEvent
|
||||
|
||||
|
||||
def _payload(frame: str) -> dict:
|
||||
@@ -46,3 +47,17 @@ def test_multi_line_text_stays_one_frame() -> None:
|
||||
frame = sse_event({"type": "delta", "text": "line1\nline2\n\n"})
|
||||
assert frame.count("\n\n") == 1 # only the frame terminator
|
||||
assert _payload(frame)["text"] == "line1\nline2\n\n"
|
||||
|
||||
|
||||
def test_error_event_model_serializes_exact_frame() -> None:
|
||||
"""The ``ChatErrorEvent`` model is the wire shape of every server-side
|
||||
failure the UI's state machine (phase 06) must recover from."""
|
||||
frame = sse_event(ChatErrorEvent(detail="boom").model_dump())
|
||||
assert frame == 'data: {"type": "error", "detail": "boom"}\n\n'
|
||||
assert _payload(frame) == {"type": "error", "detail": "boom"}
|
||||
|
||||
|
||||
def test_error_event_shape_is_type_and_detail_only() -> None:
|
||||
dumped = ChatErrorEvent(detail="The chat model dropped the connection").model_dump()
|
||||
assert set(dumped.keys()) == {"type", "detail"}
|
||||
assert dumped["type"] == "error" # default — call sites never spell it out
|
||||
|
||||
Reference in New Issue
Block a user