Files
hunjing/backend/tests/services/test_personality_emotion_service.py
T
2026-08-21 16:00:13 +08:00

227 lines
8.3 KiB
Python

"""灵核情绪状态服务测试(设计 §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")
)
# 无抛出即通过