Files
chat/tests/test_fixtures.py
T
Joseph Doherty 4afaf01de7 test: structured CannedQueue fixture builder for classifier mocks (T116)
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.
2026-04-27 07:03:20 -04:00

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