Files
chat/tests/test_skip_narration.py

161 lines
4.9 KiB
Python

"""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