"""Fishbowl feed renderer — pure HTML over a real ``view_model_at`` snapshot. Zero mocks: we build a genuine ``vm`` from an engine ledger (narrate / say-with-thought / poke / verdict) and assert the renderer emits the exact prototype CSS classes, honours the mind-reader toggle, and slices the head narrate line for the typewriter effect. """ from __future__ import annotations from src.core.events import Event from src.core.registry import default_registry from src.tools.builtins import default_tool_registry from src.ui.fishbowl import view_model_at from src.ui.fishbowl.render.feed import render_feed def _ev(kind: str, actor: str, turn: int = 1, **payload) -> Event: return Event(run_id="r", turn=turn, kind=kind, actor=actor, payload=payload) def _events() -> tuple[Event, ...]: return ( _ev("run.started", "conductor", turn=0, seed="seed", goal="g"), # Genesis narration: emitted by the scenario itself → the anonymous narrator voice. _ev("world.observed", "thousand-token-wood", turn=0, text="the wood wakes & stirs"), # The seedkeeper is a cast member → credited to it as a say line, not the narrator. _ev("world.observed", "scene-whisperer", turn=1, text="the seedkeeper hums"), _ev("agent.spoke", "pocket-actor", turn=2, text="I want the moon", thought="secretly scared", mood="panic"), _ev("user.injected", "visitor", turn=3, text="a lantern hums", label="POKE"), _ev("judge.verdict", "mischief-critic", turn=4, text="keep it"), ) def _cast(): scenario = default_registry().build_scenario("thousand-token-wood", tools=default_tool_registry()) return [a.manifest for a in scenario.agents] class TestRenderFeed: def test_emits_all_line_classes_and_voice(self): vm = view_model_at(_events(), 999, _cast(), scenario_name="thousand-token-wood") html = render_feed(vm, mind_reader=True) # container + every line class assert 'class="feed scroll"' in html assert "fe narr" in html assert "fe say" in html assert "fe poke" in html assert "verdict-fe" in html # narrator voice + said text + verdict + poke assert vm["voice_meta"]["name"] in html assert "I want the moon" in html assert "keep it" in html assert "a lantern hums" in html def test_mind_reader_gates_the_thought(self): vm = view_model_at(_events(), 999, _cast()) on = render_feed(vm, mind_reader=True) off = render_feed(vm, mind_reader=False) assert "secretly scared" in on assert 'class="thought"' in on assert "secretly scared" not in off assert "thought" not in off def test_typewriter_slices_head_narrate(self): vm = view_model_at(_events(), 999, _cast()) sliced = render_feed(vm, mind_reader=True, typed_n=3) # the narrate text "the wood wakes & stirs" → first 3 chars only assert "the wood wakes" not in sliced assert "the" in sliced assert "caret" in sliced # still typing → caret shown # full text returns when typed_n is None full = render_feed(vm, mind_reader=True) assert "the wood wakes & stirs" in full def test_html_is_escaped(self): vm = { "feed": [{"kind": "say", "agent": "x", "said": "1 < 2 & 3", "thought": None}], "voice_meta": {"name": "NARRATOR"}, } html = render_feed(vm, mind_reader=True) assert "x" not in html assert "<b>x</b>" in html assert "1 < 2 & 3" in html def test_dense_modifier(self): vm = {"feed": [], "voice_meta": {"name": "NARRATOR"}} assert 'class="feed scroll dense"' in render_feed(vm, mind_reader=False, dense=True) assert 'class="feed scroll"' in render_feed(vm, mind_reader=False, dense=False)