138 lines
5.4 KiB
Python
138 lines
5.4 KiB
Python
"""辰正 service 测试(流 A 实时快照 + 流 C 事件触发)。"""
|
||
|
||
from datetime import UTC, datetime, timedelta
|
||
|
||
import pytest
|
||
|
||
from app.db.session import AsyncSessionLocal
|
||
from app.models.chat import ChatMessage, ChatSession
|
||
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_compute_live_snapshot_returns_snapshot_and_factors():
|
||
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.factors is not None
|
||
assert result.context_text # 非空
|
||
assert "周" in result.context_text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_compute_live_snapshot_degrades_on_failure():
|
||
"""计算失败时降级返回最小快照(不抛异常)。"""
|
||
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),
|
||
timezone="Invalid/Zone",
|
||
)
|
||
# 降级:snapshot 仍返回(compute_snapshot 内部降级为 UTC),不抛
|
||
assert result.snapshot is not None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_emits_user_care_when_birthday():
|
||
"""user_birthday_today → care_kind=birthday → 发 time.user_care(不依赖 snapshot 时段/时区)。"""
|
||
async with AsyncSessionLocal() as session:
|
||
character = await get_active_character_model(session)
|
||
store = InMemoryEventStore()
|
||
result = await compute_live_snapshot(
|
||
session,
|
||
store,
|
||
character.id,
|
||
ModulationInput(user_birthday_today=True),
|
||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00
|
||
)
|
||
assert result.snapshot is not None
|
||
care_events = [e for e in result.emitted_events if e.type == "time.user_care"]
|
||
assert len(care_events) == 1
|
||
assert care_events[0].payload["care_kind"] == "birthday"
|
||
# birthday 不应触发 reunion(last_active≈now),也不命中 sentiment 节点
|
||
assert all(e.type != "time.reunion" for e in result.emitted_events)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_emits_reunion_when_offline_exceeds_threshold():
|
||
"""seed 5 天前消息 → offline.band=reunion_impact → 发 time.reunion + time.sentiment。"""
|
||
now = datetime(2026, 7, 4, 4, 0, tzinfo=UTC)
|
||
async with AsyncSessionLocal() as session:
|
||
character = await get_active_character_model(session)
|
||
# 先建会话(FK 约束),再 seed 一条 5 天前的消息
|
||
chat_session = ChatSession(character_id=character.id, status="active")
|
||
session.add(chat_session)
|
||
await session.flush()
|
||
session.add(
|
||
ChatMessage(
|
||
character_id=character.id,
|
||
session_id=chat_session.id,
|
||
role="user",
|
||
content="旧消息",
|
||
created_at=now - timedelta(days=5),
|
||
)
|
||
)
|
||
await session.flush()
|
||
|
||
store = InMemoryEventStore()
|
||
result = await compute_live_snapshot(
|
||
session,
|
||
store,
|
||
character.id,
|
||
ModulationInput(),
|
||
now=now,
|
||
)
|
||
|
||
assert result.snapshot is not None
|
||
assert result.snapshot.offline.band == "reunion_impact"
|
||
# 5 天 = 7200 min > 4320 min 阈值 → 重逢
|
||
reunion_events = [e for e in result.emitted_events if e.type == "time.reunion"]
|
||
assert len(reunion_events) == 1
|
||
payload = reunion_events[0].payload
|
||
assert payload["offline_band"] == "reunion_impact"
|
||
assert "reunion_intensity" in payload
|
||
assert payload["offline_minutes"] >= 4320
|
||
assert reunion_events[0].sensitivity == "medium"
|
||
# is_reunion=True + has_reunion_memory=is_reunion → sentiment=reunion_memory
|
||
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"] == "reunion_memory"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_compute_live_snapshot_attaches_run_id_to_events():
|
||
"""对话入口传 run_id → 触发的 time.* 事件全部带该 run_id(落当前 run,SSE 可见)。"""
|
||
async with AsyncSessionLocal() as session:
|
||
character = await get_active_character_model(session)
|
||
store = InMemoryEventStore()
|
||
# 凌晨场景触发 late_night care / sentiment
|
||
result = await compute_live_snapshot(
|
||
session,
|
||
store,
|
||
character.id,
|
||
ModulationInput(conversation_turns_today=3),
|
||
now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨
|
||
timezone="Asia/Shanghai",
|
||
run_id="run_test_plan4",
|
||
)
|
||
assert result.emitted_events, "凌晨 + 3 轮应触发至少一个 time.* 事件"
|
||
for ev in result.emitted_events:
|
||
assert ev.run_id == "run_test_plan4"
|