"""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. Post worldtree-sdk cutover (#20): the presenter consumes SDK `TurnEvent`s. `_event_to_browser_payload` derives the browser payload from the SDK's `raw` (the wire body) plus the composite `sse_id` string — the SAME shape the old dataclasses produced, so the fixture is unchanged. These events are built via the SDK's own `build_event` from the wire body. """ from __future__ import annotations import json from pathlib import Path from worldtree_sdk.events import build_event 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 _ev(ev_type: str, sse_id: str, **fields: object) -> object: """Build an SDK TurnEvent from its wire body (raw includes `type`); turn_id is the turn component of the composite sse_id.""" turn = int(sse_id.split(":", 1)[0]) return build_event(ev_type, sse_id, turn, {"type": ev_type, **fields}) 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", _ev("worker_phase", "42:3", phase="BuildingPrompt", turn_id=42)) def test_thinking_matches_fixture() -> None: _check("thinking", _ev("thinking", "42:5", content="Let me think...")) def test_text_matches_fixture() -> None: _check("text", _ev("text", "42:7", content="Hello there")) def test_text_boundary_matches_fixture() -> None: _check( "text_boundary", _ev("text_boundary", "42:8", kind="sentence", char_offset=11, ts="2026-05-28T00:00:00Z"), ) def test_tool_start_matches_fixture() -> None: _check("tool_start", _ev("tool_start", "42:9", name="search", arguments={"q": "ratatoskr"})) def test_tool_result_matches_fixture() -> None: _check( "tool_result", _ev("tool_result", "42:10", name="search", result={"n": 1}, duration_ms=12), ) def test_done_matches_fixture() -> None: _check( "done", _ev( "done", "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", _ev( "error", "42:11", phase="failed", message="llm output invalid", error_code="llm_output_invalid", ), ) def test_cancelled_matches_fixture() -> None: _check( "cancelled", _ev( "cancelled", "42:11", phase="cancelled", turn_id=42, reason="user_cancel", partial_message_id=None, ), ) def test_affect_update_matches_fixture() -> None: _check( "affect_update", _ev( "affect_update", "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", _ev( "awaiting_llm_first_token", "42:2", turn_id=42, elapsed_ms_since_building_prompt=5012.3, ), )