"""辰正集成测试:重逢流事件链 + milestone 联动(设计 §6.3)。""" from datetime import UTC, datetime import pytest from app.db.session import AsyncSessionLocal from app.models.chat import ChatMessage, ChatSession from app.models.time_anniversary import TimeAnniversary from app.modules.runtime.events import InMemoryEventStore from app.modules.time_sense.modulation import ModulationInput from app.services.characters import get_active_character_model from app.services.time_sense import compute_live_snapshot @pytest.mark.asyncio async def test_reunion_flow_emits_reunion_and_sentiment(): """离线 > 3 天 → time.reunion + time.sentiment(reunion_memory)。""" async with AsyncSessionLocal() as session: character = await get_active_character_model(session) chat_session = ChatSession(character_id=character.id, status="active") session.add(chat_session) await session.flush() old_msg = ChatMessage( character_id=character.id, session_id=chat_session.id, role="user", content="5 天前的消息", created_at=datetime(2026, 6, 29, 4, 0, tzinfo=UTC), ) session.add(old_msg) await session.flush() store = InMemoryEventStore() result = await compute_live_snapshot( session, store, character.id, ModulationInput(), now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 5 天后 ) assert result.snapshot is not None assert result.snapshot.offline.band == "reunion_impact" event_types = {e.type for e in result.emitted_events} assert "time.reunion" in event_types assert "time.sentiment" in event_types # reunion_memory 触发 sentiment = next(e for e in result.emitted_events if e.type == "time.sentiment") assert sentiment.payload["trigger_type"] == "reunion_memory" @pytest.mark.asyncio async def test_no_event_when_active(): """active 档(无历史消息,last_active=now)→ 无 reunion/sentiment 事件。""" async with AsyncSessionLocal() as session: character = await get_active_character_model(session) store = InMemoryEventStore() result = await compute_live_snapshot( session, store, character.id, ModulationInput(), now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00 上午 ) assert result.snapshot is not None assert result.snapshot.offline.band == "active" event_types = {e.type for e in result.emitted_events} assert "time.reunion" not in event_types assert "time.sentiment" not in event_types assert "time.user_care" not in event_types # 白天 + 无 birthday 等信号 @pytest.mark.asyncio async def test_milestone_at_late_night_emits_sentiment_overlay(): """凌晨 + 今日命中纪念日 → active_milestones 非空 + time.sentiment(late_night_overlay)。 验证 anniversary 命中 + 凌晨时段联动 sentiment 的集成路径(Task 4 未覆盖)。 """ async with AsyncSessionLocal() as session: character = await get_active_character_model(session) anni = TimeAnniversary( character_id=character.id, name="我们的相遇日", calendar="solar", date="2026-07-05", # 今日(now=18:00 UTC → 上海 02:00 本地日期 07-05)命中 recurs_yearly=True, kind="anniversary", subject_type="relationship", source="manual", ) session.add(anni) await session.flush() store = InMemoryEventStore() result = await compute_live_snapshot( session, store, character.id, ModulationInput(), now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨 ) assert result.snapshot is not None assert result.snapshot.day_period == "凌晨" assert len(result.snapshot.active_milestones) >= 1 # duration_node(0 不在 nodes)/anniversary_prewarm(next=365>7)/reunion_memory(active 档) 都不触发 # → late_night_overlay(凌晨 + has_active_milestone) sentiment_events = [e for e in result.emitted_events if e.type == "time.sentiment"] assert len(sentiment_events) == 1 assert sentiment_events[0].payload["trigger_type"] == "late_night_overlay" # active 档(无 chat 历史)→ 不触发 reunion assert "time.reunion" not in {e.type for e in result.emitted_events}