4afaf01de7
Phase 4.5 carry-over from Phase 3. Tests across test_turn_flow.py, test_meanwhile_turn_flow.py, and the phase3/4 integration suites built positional canned-response arrays for MockLLMClient — adding a new classifier call to a code path required updating the array index in many places. This change ships tests/fixtures.py with a fluent CannedQueue builder that lets tests declare classifier expectations by name and call order instead of by index. Each method appends one item to an internal queue and returns self for chaining; build() emits the flat list[str] queue that MockLLMClient(canned=...) already consumes. The mock's contract is unchanged. Builder methods cover: parse_turn, detect_addressee, state_update (with zero_state alias), detect_interjection, detect_interjection_targeted, detect_scene_close, detect_event_transitions, summarize_scene_pov, detect_threads, meanwhile_digest, score_significance, and a narrative() helper for streaming bot beats. raw() is a passthrough escape hatch. Migration scope: ship the builder + 2 sanity tests + migrate 3 representative tests in test_turn_flow.py as proof of concept (test_single_bot_turn_no_guest_regression, test_turn_with_event_transition_appends_started_event, test_turn_with_no_active_events_skips_classifier). The remaining positional-array tests stay as-is; the builder docstring documents the migration template for Phase 5 work.
141 lines
4.8 KiB
Python
141 lines
4.8 KiB
Python
"""Sanity tests for :mod:`tests.fixtures` — the structured CannedQueue
|
|
builder for ``MockLLMClient`` (T116).
|
|
|
|
The builder is a thin shaping layer over JSON dicts; these tests pin
|
|
the JSON shapes and the ``MockLLMClient`` round-trip so nothing
|
|
silently regresses if a default field name or shape gets renamed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from chat.llm.mock import MockLLMClient
|
|
from tests.fixtures import CannedQueue
|
|
|
|
|
|
def test_canned_queue_build_emits_expected_shapes():
|
|
"""Each builder method emits the JSON shape its classifier consumer
|
|
expects. The narrative slot is a bare string (stream).
|
|
"""
|
|
canned = (
|
|
CannedQueue()
|
|
.parse_turn(segments=[{"kind": "dialogue", "text": "hello"}])
|
|
.detect_addressee(addressee_id="bot_a", reason="host")
|
|
.narrative("Hi there.")
|
|
.state_update()
|
|
.state_update(affinity_delta=1, trust_delta=2)
|
|
.detect_interjection(should_interject=False, reason="calm")
|
|
.detect_event_transitions(
|
|
[{"event_id": "evt_1", "new_status": "active", "reason": "they arrived"}]
|
|
)
|
|
.detect_scene_close(should_close=False, reason="no signal")
|
|
.summarize_scene_pov(summary="BotA noticed the day winding down.")
|
|
.detect_threads(
|
|
[
|
|
{
|
|
"action": "open",
|
|
"title": "Maya's job hunt",
|
|
"summary": "Maya is looking for a new job",
|
|
"existing_thread_id": None,
|
|
}
|
|
]
|
|
)
|
|
.build()
|
|
)
|
|
|
|
# All slots are strings (the MockLLMClient pops strings).
|
|
assert all(isinstance(slot, str) for slot in canned)
|
|
assert len(canned) == 10
|
|
|
|
# Slot 0: parse_turn — defaults intent="narrative".
|
|
parse = json.loads(canned[0])
|
|
assert parse["segments"] == [{"kind": "dialogue", "text": "hello"}]
|
|
assert parse["intent"] == "narrative"
|
|
assert parse["landing_state_hint"] == ""
|
|
|
|
# Slot 1: detect_addressee.
|
|
addr = json.loads(canned[1])
|
|
assert addr["addressee_id"] == "bot_a"
|
|
assert addr["confidence"] == "medium"
|
|
assert addr["reason"] == "host"
|
|
|
|
# Slot 2: narrative — bare string, NOT JSON.
|
|
assert canned[2] == "Hi there."
|
|
with pytest.raises(json.JSONDecodeError):
|
|
json.loads(canned[2])
|
|
|
|
# Slot 3: state_update with all defaults — zero deltas, no facts.
|
|
su0 = json.loads(canned[3])
|
|
assert su0 == {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
|
|
|
# Slot 4: state_update with custom deltas.
|
|
su1 = json.loads(canned[4])
|
|
assert su1["affinity_delta"] == 1
|
|
assert su1["trust_delta"] == 2
|
|
assert su1["knowledge_facts"] == []
|
|
|
|
# Slot 5: detect_interjection.
|
|
interj = json.loads(canned[5])
|
|
assert interj == {"should_interject": False, "reason": "calm"}
|
|
|
|
# Slot 6: detect_event_transitions.
|
|
transitions = json.loads(canned[6])
|
|
assert transitions["transitions"][0]["event_id"] == "evt_1"
|
|
assert transitions["transitions"][0]["new_status"] == "active"
|
|
|
|
# Slot 7: detect_scene_close.
|
|
close = json.loads(canned[7])
|
|
assert close == {"should_close": False, "reason": "no signal"}
|
|
|
|
# Slot 8: summarize_scene_pov.
|
|
pov = json.loads(canned[8])
|
|
assert pov["summary"] == "BotA noticed the day winding down."
|
|
assert pov["knowledge_facts"] == []
|
|
assert pov["relationship_summary"] == ""
|
|
|
|
# Slot 9: detect_threads.
|
|
threads = json.loads(canned[9])
|
|
assert threads["candidates"][0]["action"] == "open"
|
|
assert threads["candidates"][0]["title"] == "Maya's job hunt"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_canned_queue_round_trips_through_mock_llm_client():
|
|
"""Building a queue and feeding it to ``MockLLMClient`` produces the
|
|
same items back via ``generate`` (in order). This is the contract
|
|
every migrated test relies on.
|
|
"""
|
|
canned = (
|
|
CannedQueue()
|
|
.parse_turn(segments=[{"kind": "dialogue", "text": "hi"}])
|
|
.narrative("Hello back.")
|
|
.state_update()
|
|
.build()
|
|
)
|
|
mock = MockLLMClient(canned=canned)
|
|
|
|
# generate() pops from the front.
|
|
parse_str = await mock.generate([], model="x")
|
|
assert json.loads(parse_str)["segments"] == [
|
|
{"kind": "dialogue", "text": "hi"}
|
|
]
|
|
|
|
# The narrative slot is a raw string — generate returns it as-is.
|
|
narr_str = await mock.generate([], model="x")
|
|
assert narr_str == "Hello back."
|
|
|
|
# The state_update slot has zero-delta defaults.
|
|
su_str = await mock.generate([], model="x")
|
|
assert json.loads(su_str) == {
|
|
"affinity_delta": 0,
|
|
"trust_delta": 0,
|
|
"knowledge_facts": [],
|
|
}
|
|
|
|
# Queue fully drained.
|
|
with pytest.raises(IndexError):
|
|
await mock.generate([], model="x")
|