304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""叩心枢触发条件单元测试。"""
|
||
|
||
# ruff: noqa: ARG002 — pytest fixture params are intentionally unused in test signatures
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
|
||
import pytest
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.modules.knocking.conditions import (
|
||
AnniversaryCondition,
|
||
BigCycleCompletedCondition,
|
||
ConditionResult,
|
||
CuriosityCondition,
|
||
EmotionChangeCondition,
|
||
EvolutionAppliedCondition,
|
||
MemoryRecallCondition,
|
||
ProactiveCondition,
|
||
RelationshipMilestoneCondition,
|
||
TimeElapsedCondition,
|
||
TimeOfDayCondition,
|
||
UserReunionCondition,
|
||
)
|
||
|
||
|
||
class TestTimeElapsedCondition:
|
||
"""空闲超时条件测试。"""
|
||
|
||
async def test_not_triggered_when_no_history(self, db_session: AsyncSession) -> None:
|
||
cond = TimeElapsedCondition(min_idle_minutes=360)
|
||
result = await cond.check("char_nonexistent", db_session)
|
||
assert not result.triggered
|
||
|
||
async def test_triggered_when_idle_exceeds_threshold(
|
||
self, db_session: AsyncSession, seed_old_message: None
|
||
) -> None:
|
||
cond = TimeElapsedCondition(min_idle_minutes=1)
|
||
result = await cond.check("char_test", db_session)
|
||
assert result.triggered
|
||
assert result.condition_name == "idle"
|
||
|
||
async def test_not_triggered_when_recent_message(
|
||
self, db_session: AsyncSession, seed_recent_message: None
|
||
) -> None:
|
||
cond = TimeElapsedCondition(min_idle_minutes=360)
|
||
now = datetime.now(UTC)
|
||
result = await cond.check("char_test", db_session, now=now)
|
||
assert not result.triggered
|
||
|
||
|
||
class TestTimeOfDayCondition:
|
||
async def test_morning_match(self) -> None:
|
||
cond = TimeOfDayCondition(windows=["morning"])
|
||
now = datetime(2026, 6, 27, 7, 0, 0, tzinfo=UTC)
|
||
result = await cond.check("char_test", None, now=now)
|
||
assert result.triggered
|
||
assert result.condition_name == "time_of_day"
|
||
|
||
async def test_late_night_match(self) -> None:
|
||
cond = TimeOfDayCondition(windows=["late_night"])
|
||
now = datetime(2026, 6, 27, 23, 0, 0, tzinfo=UTC)
|
||
result = await cond.check("char_test", None, now=now)
|
||
assert result.triggered
|
||
assert result.context.get("time_of_day") == "late_night"
|
||
|
||
async def test_no_match_outside_window(self) -> None:
|
||
cond = TimeOfDayCondition(windows=["morning"])
|
||
now = datetime(2026, 6, 27, 14, 0, 0, tzinfo=UTC)
|
||
result = await cond.check("char_test", None, now=now)
|
||
assert not result.triggered
|
||
|
||
|
||
class TestAnniversaryCondition:
|
||
async def test_not_triggered_on_first_call_no_data(self, db_session: AsyncSession) -> None:
|
||
cond = AnniversaryCondition()
|
||
result = await cond.check("char_nonexistent", db_session)
|
||
assert not result.triggered
|
||
|
||
async def test_triggered_when_anniversary_exists(
|
||
self, db_session: AsyncSession, seed_anniversary: None
|
||
) -> None:
|
||
cond = AnniversaryCondition()
|
||
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
|
||
result = await cond.check("char_test", db_session, now=now)
|
||
assert result.triggered
|
||
assert result.condition_name == "anniversary"
|
||
assert "birthday" in result.context.get("anniversary_kind", "")
|
||
|
||
async def test_not_duplicated_same_day(
|
||
self, db_session: AsyncSession, seed_anniversary: None
|
||
) -> None:
|
||
cond = AnniversaryCondition()
|
||
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
|
||
result1 = await cond.check("char_test", db_session, now=now)
|
||
assert result1.triggered
|
||
result2 = await cond.check("char_test", db_session, now=now)
|
||
assert not result2.triggered # 同一天不重复
|
||
|
||
|
||
class TestUserReunionCondition:
|
||
async def test_not_triggered_when_no_event(self, db_session: AsyncSession) -> None:
|
||
cond = UserReunionCondition()
|
||
result = await cond.check("char_nonexistent", db_session)
|
||
assert not result.triggered
|
||
|
||
|
||
class TestEmotionChangeCondition:
|
||
async def test_not_triggered_on_first_check(
|
||
self, db_session: AsyncSession, seed_emotion_state: None
|
||
) -> None:
|
||
cond = EmotionChangeCondition(min_delta=0.3)
|
||
result = await cond.check("char_test", db_session)
|
||
assert not result.triggered # 首次无基线
|
||
|
||
async def test_triggered_when_intensity_changes(
|
||
self, db_session: AsyncSession, seed_emotion_state: None
|
||
) -> None:
|
||
cond = EmotionChangeCondition(min_delta=0.3)
|
||
await cond.check("char_test", db_session) # 首次建立基线
|
||
|
||
# 更新情绪 intensity 到 0.8(变化 0.6 > 0.3)
|
||
from app.models.personality_emotion import CharacterEmotionState
|
||
|
||
result = await db_session.execute(
|
||
select(CharacterEmotionState).where(CharacterEmotionState.character_id == "char_test")
|
||
)
|
||
state = result.scalar_one()
|
||
state.intensity = 0.8
|
||
state.mood = "melancholy"
|
||
await db_session.commit()
|
||
|
||
result = await cond.check("char_test", db_session)
|
||
assert result.triggered
|
||
assert result.condition_name == "emotion"
|
||
|
||
|
||
class TestCuriosityCondition:
|
||
async def test_not_triggered_when_no_active_questions(self, db_session: AsyncSession) -> None:
|
||
cond = CuriosityCondition()
|
||
result = await cond.check("char_nonexistent", db_session)
|
||
assert not result.triggered
|
||
|
||
async def test_triggered_when_active_question_exists(
|
||
self, db_session: AsyncSession, seed_curiosity: None
|
||
) -> None:
|
||
cond = CuriosityCondition()
|
||
result = await cond.check("char_test", db_session)
|
||
assert result.triggered
|
||
assert result.condition_name == "curiosity"
|
||
assert "curiosity_question" in result.context
|
||
assert result.context["curiosity_question"] == "你为什么总是沉默?"
|
||
|
||
|
||
class TestEvolutionAppliedCondition:
|
||
"""演化应用条件测试。"""
|
||
|
||
async def test_not_triggered_when_no_applied(self, db_session: AsyncSession) -> None:
|
||
cond = EvolutionAppliedCondition()
|
||
result = await cond.check("char_nonexistent", db_session)
|
||
assert not result.triggered
|
||
|
||
async def test_triggered_when_applied_exists(
|
||
self, db_session: AsyncSession, seed_applied_evolution: None
|
||
) -> None:
|
||
cond = EvolutionAppliedCondition()
|
||
result = await cond.check("char_test", db_session)
|
||
assert result.triggered
|
||
assert result.condition_name == "evolution_applied"
|
||
assert result.context.get("evolution_title") == "语气更柔和"
|
||
assert result.context.get("evolution_level") == "L3"
|
||
assert "evolution_proposal" in result.context
|
||
|
||
async def test_not_duplicated(
|
||
self, db_session: AsyncSession, seed_applied_evolution: None
|
||
) -> None:
|
||
cond = EvolutionAppliedCondition()
|
||
result1 = await cond.check("char_test", db_session)
|
||
assert result1.triggered
|
||
result2 = await cond.check("char_test", db_session)
|
||
assert not result2.triggered # 同一条不重复触发
|
||
|
||
|
||
class TestBigCycleCompletedCondition:
|
||
"""大周天完成条件测试。"""
|
||
|
||
async def test_not_triggered_when_no_completed(self, db_session: AsyncSession) -> None:
|
||
cond = BigCycleCompletedCondition()
|
||
result = await cond.check("char_nonexistent", db_session)
|
||
assert not result.triggered
|
||
|
||
async def test_triggered_when_completed_exists(
|
||
self, db_session: AsyncSession, seed_completed_big_cycle: None
|
||
) -> None:
|
||
cond = BigCycleCompletedCondition()
|
||
result = await cond.check("char_test", db_session)
|
||
assert result.triggered
|
||
assert result.condition_name == "big_cycle_completed"
|
||
assert "big_cycle_summary" in result.context
|
||
assert "big_cycle_trigger" in result.context
|
||
|
||
async def test_not_duplicated(
|
||
self, db_session: AsyncSession, seed_completed_big_cycle: None
|
||
) -> None:
|
||
cond = BigCycleCompletedCondition()
|
||
result1 = await cond.check("char_test", db_session)
|
||
assert result1.triggered
|
||
result2 = await cond.check("char_test", db_session)
|
||
assert not result2.triggered # 同一条不重复触发
|
||
|
||
|
||
class TestMemoryRecallCondition:
|
||
"""记忆唤醒条件测试。"""
|
||
|
||
async def test_not_triggered_when_no_memory(self, db_session: AsyncSession) -> None:
|
||
cond = MemoryRecallCondition()
|
||
result = await cond.check("char_nonexistent", db_session)
|
||
assert not result.triggered
|
||
|
||
async def test_triggered_when_new_memory_exists(
|
||
self, db_session: AsyncSession, seed_new_memory: None
|
||
) -> None:
|
||
cond = MemoryRecallCondition()
|
||
result = await cond.check("char_test", db_session)
|
||
assert result.triggered
|
||
assert result.condition_name == "memory_recall"
|
||
assert result.context.get("memory_title") == "今天去了海边"
|
||
assert "memory_content" in result.context
|
||
assert "memory_type" in result.context
|
||
|
||
async def test_not_duplicated(self, db_session: AsyncSession, seed_new_memory: None) -> None:
|
||
cond = MemoryRecallCondition()
|
||
result1 = await cond.check("char_test", db_session)
|
||
assert result1.triggered
|
||
result2 = await cond.check("char_test", db_session)
|
||
assert not result2.triggered # 同一条不重复触发
|
||
|
||
|
||
class TestRelationshipMilestoneCondition:
|
||
"""关系里程碑条件测试。"""
|
||
|
||
async def test_not_triggered_when_no_messages(self, db_session: AsyncSession) -> None:
|
||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
|
||
result = await cond.check("char_nonexistent", db_session)
|
||
assert not result.triggered
|
||
|
||
async def test_triggered_by_message_count(
|
||
self, db_session: AsyncSession, seed_many_messages: None
|
||
) -> None:
|
||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
|
||
result = await cond.check("char_test", db_session)
|
||
assert result.triggered
|
||
assert result.condition_name == "relationship_milestone"
|
||
assert result.context.get("milestone_type") == "messages"
|
||
assert result.context.get("milestone_value") == 5
|
||
assert result.context.get("milestone_threshold") == 5
|
||
|
||
async def test_triggered_by_days(
|
||
self, db_session: AsyncSession, seed_old_relationship: None
|
||
) -> None:
|
||
cond = RelationshipMilestoneCondition(message_milestones=[9999], day_milestones=[30])
|
||
result = await cond.check("char_test", db_session)
|
||
assert result.triggered
|
||
assert result.condition_name == "relationship_milestone"
|
||
assert result.context.get("milestone_type") == "days"
|
||
assert result.context.get("milestone_value") >= 30
|
||
assert result.context.get("milestone_threshold") == 30
|
||
|
||
async def test_not_duplicated(self, db_session: AsyncSession, seed_many_messages: None) -> None:
|
||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
|
||
result1 = await cond.check("char_test", db_session)
|
||
assert result1.triggered
|
||
result2 = await cond.check("char_test", db_session)
|
||
assert not result2.triggered # 同一里程碑不重复触发
|
||
|
||
async def test_prefers_messages_over_days(
|
||
self, db_session: AsyncSession, seed_many_messages: None, seed_old_relationship: None
|
||
) -> None:
|
||
"""同时满足消息和天数里程碑时,优先触发消息里程碑。"""
|
||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
|
||
result = await cond.check("char_test", db_session)
|
||
assert result.triggered
|
||
assert result.context.get("milestone_type") == "messages" # 消息优先
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_proactive_condition_protocol() -> None:
|
||
"""验证 ProactiveCondition 可作为 Protocol 使用。"""
|
||
|
||
class DummyCondition(ProactiveCondition):
|
||
async def check(
|
||
self,
|
||
_character_id: str,
|
||
_session: AsyncSession | None = None,
|
||
*,
|
||
_now: datetime | None = None,
|
||
) -> ConditionResult:
|
||
return ConditionResult(triggered=True, trigger_reason="测试", condition_name="dummy")
|
||
|
||
cond: ProactiveCondition = DummyCondition()
|
||
result = await cond.check("char_test")
|
||
assert result.triggered
|