"""Tests for the event-lifecycle detection service (T52). Per Phase 3, after each narrated turn we ask a classifier whether any active events transitioned (started, completed, cancelled). The bias is strongly toward an empty result — most turns do NOT resolve or start a known event, and the turn-flow caller (T61) only appends an event_started/completed/cancelled record when this service yields one. These tests cover: * The classifier returning a single transition is honored end-to-end. * An empty ``active_events`` list short-circuits before any LLM call, so callers that hold no live events pay zero classifier cost. * Three rounds of malformed JSON exhaust ``classify``'s retries and we fall back to the empty default — graceful degradation per §3.3. """ from __future__ import annotations import json import pytest from chat.llm.mock import MockLLMClient from chat.services.event_lifecycle import ( EventLifecycleDecision, detect_event_transitions, ) @pytest.mark.asyncio async def test_detects_one_transition_happy_path(): canned = json.dumps( { "transitions": [ { "event_id": "evt_1", "new_status": "completed", "reason": "they arrived at the park", } ] } ) mock = MockLLMClient(canned=[canned]) result = await detect_event_transitions( mock, classifier_model="x", narrative_text="They walked through the park gate, finally there.", active_events=[ { "event_id": "evt_1", "kind": "date_at_park", "status": "active", "props": {}, } ], ) assert isinstance(result, EventLifecycleDecision) assert len(result.transitions) == 1 assert result.transitions[0].event_id == "evt_1" assert result.transitions[0].new_status == "completed" assert result.transitions[0].reason == "they arrived at the park" @pytest.mark.asyncio async def test_empty_active_events_short_circuits_without_classifier_call(): """No active events -> no classifier call. The mock has an empty canned list; any ``generate`` call would raise ``IndexError`` from ``list.pop(0)``. The test passing proves the short-circuit holds. """ mock = MockLLMClient(canned=[]) result = await detect_event_transitions( mock, classifier_model="x", narrative_text="Just a quiet moment between them.", active_events=[], ) assert isinstance(result, EventLifecycleDecision) assert result.transitions == [] @pytest.mark.asyncio async def test_classifier_failure_returns_empty_default(): """``classify`` retries 3 times; after all fail it returns the empty default so the turn flow keeps moving (§3.3 graceful degradation).""" mock = MockLLMClient(canned=["bad", "bad", "bad"]) result = await detect_event_transitions( mock, classifier_model="x", narrative_text="Some text the classifier will choke on.", active_events=[ { "event_id": "evt_1", "kind": "date_at_park", "status": "active", "props": {}, } ], ) assert isinstance(result, EventLifecycleDecision) assert result.transitions == []