161 lines
4.9 KiB
Python
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
|