初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,226 @@
|
||||
"""灵核情绪状态服务测试(设计 §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")
|
||||
)
|
||||
# 无抛出即通过
|
||||
Reference in new issue
Block a user