"""Skip narration service tests (T53). The skip-narration service generates short transition prose between an in-progress moment and a post-skip moment. Two flavors: * ``elision`` — collapses an in-progress activity to its expected end-state in 1-2 sentences (e.g. "skip ahead to when we arrive"). * ``jump`` — bridges a longer fiction-time delta in 2-3 sentences (e.g. "next morning", "a week later"). Output is free-form prose, not structured JSON, so the service goes through ``client.generate`` directly rather than the classifier path. A deterministic template fallback fires on any LLM failure so the skip flow never blocks even when the model is down. """ from __future__ import annotations from typing import AsyncIterator, Sequence import pytest from chat.llm.client import Message from chat.llm.mock import MockLLMClient from chat.services.skip_narration import narrate_skip _SPEAKER = { "id": "bot1", "name": "Aria", "persona": "thoughtful, observant", } @pytest.mark.asyncio async def test_narrate_elision_returns_classifier_output(): canned = ( "She closes her laptop and slings her bag over her shoulder. " "The office shrinks behind her as she steps into the late " "afternoon light." ) mock = MockLLMClient(canned=[canned]) result = await narrate_skip( mock, narrative_model="x", skip_kind="elision", speaker_bot=_SPEAKER, you_name="Me", current_time="3:42 PM", new_time="5:10 PM", current_activity="finishing up at her desk", landing_state_hint="walking out into the parking lot", ) assert "office" in result or result == canned @pytest.mark.asyncio async def test_narrate_jump_returns_classifier_output(): canned = ( "Morning light spills through the kitchen window. The coffee " "maker hums. She's already at the table, scrolling her phone." ) mock = MockLLMClient(canned=[canned]) result = await narrate_skip( mock, narrative_model="x", skip_kind="jump", speaker_bot=_SPEAKER, you_name="Me", current_time="late evening", new_time="next morning", current_activity="winding down for the night", landing_state_hint="having coffee in the kitchen", ) assert result lower = result.lower() assert "morning" in lower or "coffee" in lower class _RaisingMock: """Mock LLMClient whose ``generate`` always raises. ``MockLLMClient.generate`` raises ``IndexError`` once the canned list is empty, but the test wants a clear, unambiguous failure regardless of canned-list state, so we ship a tiny dedicated mock instead. """ async def generate( self, messages: Sequence[Message], *, model: str, **params ) -> str: raise RuntimeError("LLM is down") async def stream( self, messages: Sequence[Message], *, model: str, **params ) -> AsyncIterator[str]: raise RuntimeError("LLM is down") yield # pragma: no cover - make this a generator class _RecordingMock: """Mock LLMClient that records the kwargs passed to ``generate``. Used to assert that callers plumb through optional parameters like ``timeout_s`` instead of swallowing them. Returns a fixed string so the surrounding fallback path is not exercised. """ def __init__(self) -> None: self.captured_kwargs: dict | None = None async def generate( self, messages: Sequence[Message], *, model: str, **params ) -> str: self.captured_kwargs = dict(params) return "ok" async def stream( self, messages: Sequence[Message], *, model: str, **params ) -> AsyncIterator[str]: raise RuntimeError("not used") yield # pragma: no cover - make this a generator @pytest.mark.asyncio async def test_narrate_skip_passes_timeout_through(): mock = _RecordingMock() await narrate_skip( mock, narrative_model="x", skip_kind="jump", speaker_bot=_SPEAKER, you_name="Me", current_time="late evening", new_time="next morning", current_activity="winding down for the night", landing_state_hint="having coffee in the kitchen", timeout_s=12.5, ) assert mock.captured_kwargs is not None assert mock.captured_kwargs.get("timeout_s") == 12.5 @pytest.mark.asyncio async def test_narrate_falls_back_on_generation_failure(): new_time = "next morning" result = await narrate_skip( _RaisingMock(), narrative_model="x", skip_kind="jump", speaker_bot=_SPEAKER, you_name="Me", current_time="late evening", new_time=new_time, current_activity="winding down for the night", landing_state_hint="having coffee in the kitchen", ) # Fallback template includes the new_time so callers can see *what* # we skipped to even when the LLM never answered. assert new_time in result