"""灵核情绪状态服务测试(设计 §11)。""" from datetime import UTC, datetime, timedelta import pytest from app.db.session import AsyncSessionLocal from app.models.character import Character from app.models.chat import ChatMessage, ChatSession from app.models.personality import PersonalityVersion from app.models.personality_emotion import CharacterEmotionState from app.modules.personality.emotion import EmotionState from app.modules.runtime.events import Event from app.services.characters import get_active_character_model from app.services.personality_emotion import ( apply_time_event, compute_current_state, handle_time_event, ) async def _seed_personality( session, character_id: str, baseline_json: str = '{"mood": "neutral", "intensity": 0.2}' ) -> PersonalityVersion: """创建人格版本并绑定到 character.current_personality_version_id。""" pv = PersonalityVersion( character_id=character_id, identity_profile="测试身份", emotion_baseline=baseline_json, ) session.add(pv) await session.flush() character = await session.get(Character, character_id) assert character is not None character.current_personality_version_id = pv.id return pv def _reunion_event( character_id: str, intensity: float = 0.6, event_id: str = "event_test1" ) -> Event: return Event( id=event_id, type="time.reunion", source="time_sense", payload={"reunion_intensity": intensity, "offline_band": "reunion_impact"}, character_id=character_id, ) # ---- compute_current_state ---- @pytest.mark.asyncio async def test_compute_no_state_returns_baseline() -> None: async with AsyncSessionLocal() as session: character = await get_active_character_model(session) cid = character.id await _seed_personality(session, cid, '{"mood": "pleased", "intensity": 0.4}') await session.commit() async with AsyncSessionLocal() as session: state = await compute_current_state(session, cid) assert state == EmotionState(mood="pleased", intensity=0.4) @pytest.mark.asyncio async def test_compute_no_state_no_personality_returns_default() -> None: async with AsyncSessionLocal() as session: character = await get_active_character_model(session) cid = character.id await session.commit() async with AsyncSessionLocal() as session: state = await compute_current_state(session, cid) assert state == EmotionState() # neutral 0.2 @pytest.mark.asyncio async def test_compute_with_state_zero_turns_returns_state_unchanged() -> None: async with AsyncSessionLocal() as session: character = await get_active_character_model(session) cid = character.id await _seed_personality(session, cid) session.add( CharacterEmotionState( character_id=cid, mood="touched", intensity=0.8, baseline_mood="neutral", baseline_intensity=0.2, ) ) await session.commit() async with AsyncSessionLocal() as session: state = await compute_current_state(session, cid) # 无后续 user 回合 → 不 decay assert state is not None assert state.mood == "touched" assert abs(state.intensity - 0.8) < 0.001 @pytest.mark.asyncio async def test_compute_decays_after_user_messages() -> None: async with AsyncSessionLocal() as session: character = await get_active_character_model(session) cid = character.id await _seed_personality(session, cid) # 写入 touched 0.8 状态,updated_at 手动设为 1 小时前 old = datetime.now(UTC) - timedelta(hours=1) row = CharacterEmotionState( character_id=cid, mood="touched", intensity=0.8, baseline_mood="neutral", baseline_intensity=0.2, ) session.add(row) await session.flush() # SQLite 读回 naive;写入时也用 naive 保持一致 row.updated_at = old.replace(tzinfo=None) # 6 条 user 消息 = 半衰期回合数 → intensity 衰减一半 chat_session = ChatSession(character_id=cid) session.add(chat_session) await session.flush() for i in range(6): session.add( ChatMessage( character_id=cid, session_id=chat_session.id, role="user", content=f"msg {i}", ) ) await session.commit() async with AsyncSessionLocal() as session: state = await compute_current_state(session, cid) # elapsed=6, factor=0.5 → 0.2 + (0.8-0.2)*0.5 = 0.5 assert state is not None assert state.mood == "touched" assert abs(state.intensity - 0.5) < 0.01 # ---- apply_time_event ---- @pytest.mark.asyncio async def test_apply_reunion_initializes_from_baseline() -> None: async with AsyncSessionLocal() as session: character = await get_active_character_model(session) cid = character.id await _seed_personality(session, cid, '{"mood": "neutral", "intensity": 0.2}') await apply_time_event(_reunion_event(cid, intensity=0.6), session) await session.commit() async with AsyncSessionLocal() as session: from app.repositories.personality_emotion import get_state row = await get_state(session, cid) assert row is not None assert row.mood == "touched" assert abs(row.intensity - 0.8) < 0.001 # 0.2 baseline + 0.6 delta assert row.last_trigger_event_id == "event_test1" character = await session.get(Character, cid) assert character is not None assert character.current_mood_id == "touched" @pytest.mark.asyncio async def test_apply_reunion_accumulates_when_same_mood() -> None: async with AsyncSessionLocal() as session: character = await get_active_character_model(session) cid = character.id await _seed_personality(session, cid) await apply_time_event(_reunion_event(cid, intensity=0.5), session) await apply_time_event(_reunion_event(cid, intensity=0.3, event_id="event_test2"), session) await session.commit() async with AsyncSessionLocal() as session: from app.repositories.personality_emotion import get_state row = await get_state(session, cid) assert row is not None assert row.mood == "touched" # 首次 0.2+0.5=0.7;同 mood 累加 0.7+0.3=1.0 assert abs(row.intensity - 1.0) < 0.001 assert row.last_trigger_event_id == "event_test2" @pytest.mark.asyncio async def test_apply_event_without_character_id_is_noop() -> None: async with AsyncSessionLocal() as session: await apply_time_event( Event(type="time.reunion", source="time_sense", payload={}, character_id=None), session, ) await session.commit() # 无异常即通过;无副作用可验证(无 character_id 无法写) # ---- handle_time_event ---- @pytest.mark.asyncio async def test_handle_time_event_commits_via_own_session() -> None: async with AsyncSessionLocal() as session: character = await get_active_character_model(session) cid = character.id await _seed_personality(session, cid) await session.commit() # handle_time_event 自管 session + 提交 await handle_time_event(_reunion_event(cid, intensity=0.6)) async with AsyncSessionLocal() as session: from app.repositories.personality_emotion import get_state row = await get_state(session, cid) assert row is not None assert row.mood == "touched" @pytest.mark.asyncio async def test_handle_time_event_swallows_exception() -> None: """降级原则:handler 异常不抛(不阻塞事件流)。""" # character_id 指向不存在的角色 → _resolve_baseline 返回默认,apply 仍尝试写 # 但更直接:用一个会触发异常的事件(如 DB 约束)。这里验证无 character_id 不抛 + 任意异常被吞。 # 构造一个正常事件,handler 应正常完成不抛 await handle_time_event( Event(type="time.reunion", source="time_sense", payload={}, character_id="char_nonexistent") ) # 无抛出即通过