"""Drift-detection between TUI presentation discipline and web JS presenter per issue #16 INV-008. The JSON fixture at `tests/fixtures/presentation_contract.json` enumerates the expected browser-facing event payload for each Event type. Server-side serialization (`_event_to_browser_payload`) is unit-tested against the fixture. JS-side rendering in `src/ratatoskr/web/static/index.html` consumes the same shape — if this fixture changes, both sides update in lockstep. """ from __future__ import annotations import json from pathlib import Path from ratatoskr.sse_client import ( AffectUpdate, AwaitingLlmFirstToken, Cancelled, Done, Error, SseId, Text, TextBoundary, Thinking, ToolResult, ToolStart, WorkerPhase, ) from ratatoskr.web.server import _event_to_browser_payload def _load_fixture() -> dict: path = Path(__file__).parent / "fixtures" / "presentation_contract.json" return json.loads(path.read_text()) def _check(name: str, event: object) -> None: """Assert (event_type, data) for `event` matches the fixture entry.""" fixture = _load_fixture() assert name in fixture, f"fixture missing entry for {name!r}" expected = fixture[name] event_type, data = _event_to_browser_payload(event) assert event_type == expected["event_type"], ( f"{name}: event_type {event_type!r} != fixture {expected['event_type']!r}" ) assert data == expected["data"], ( f"{name}: data mismatch\n got: {data}\n fixture: {expected['data']}" ) def test_worker_phase_matches_fixture() -> None: _check( "worker_phase", WorkerPhase(sse_id=SseId(42, 3), phase="BuildingPrompt", turn_id=42), ) def test_thinking_matches_fixture() -> None: _check( "thinking", Thinking(sse_id=SseId(42, 5), content="Let me think..."), ) def test_text_matches_fixture() -> None: _check( "text", Text(sse_id=SseId(42, 7), content="Hello there"), ) def test_text_boundary_matches_fixture() -> None: _check( "text_boundary", TextBoundary( sse_id=SseId(42, 8), kind="sentence", char_offset=11, ts="2026-05-28T00:00:00Z", ), ) def test_tool_start_matches_fixture() -> None: _check( "tool_start", ToolStart(sse_id=SseId(42, 9), name="search", arguments={"q": "ratatoskr"}), ) def test_tool_result_matches_fixture() -> None: _check( "tool_result", ToolResult( sse_id=SseId(42, 10), name="search", result={"n": 1}, duration_ms=12, ), ) def test_done_matches_fixture() -> None: _check( "done", Done( sse_id=SseId(42, 11), phase="succeeded", response="Hello there", model="qwen3.6-35-a3b", duration_ms=1234, usage={ "prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150, "cached_input_tokens": 0, }, ), ) def test_error_matches_fixture() -> None: _check( "error", Error( sse_id=SseId(42, 11), phase="failed", message="llm output invalid", error_code="llm_output_invalid", ), ) def test_cancelled_matches_fixture() -> None: _check( "cancelled", Cancelled( sse_id=SseId(42, 11), phase="cancelled", turn_id=42, reason="user_cancel", partial_message_id=None, ), ) def test_affect_update_matches_fixture() -> None: _check( "affect_update", AffectUpdate( sse_id=SseId(42, 1), status="current", turn_id=42, snapshot={ "agent_id": "mimir", "pad": {"pleasure": 0.52, "arousal": 0.47, "dominance": 0.50}, "dominant_emotion": "curiosity", "emotions_active": [ {"type": "curiosity", "intensity": 0.6, "decay_remaining_s": 202.7} ], "baseline_pad": {"pleasure": 0.50, "arousal": 0.40, "dominance": 0.50}, "mood_drift": {"valence_delta": 0.02, "arousal_delta": 0.07}, "last_updated_at": "2026-05-28T00:00:00+00:00", }, ), ) def test_awaiting_llm_first_token_matches_fixture() -> None: _check( "awaiting_llm_first_token", AwaitingLlmFirstToken( sse_id=SseId(42, 2), turn_id=42, elapsed_ms_since_building_prompt=5012.3, ), )