初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1,195 @@
|
||||
"""叩心枢测试共用 fixtures。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.chat import ChatMessage
|
||||
from app.models.drive import CuriosityQuestion
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.models.memory import Memory
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
from app.models.time_anniversary import TimeAnniversary
|
||||
|
||||
# 用内存 SQLite 做测试数据库
|
||||
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_old_message(db_session: AsyncSession) -> None:
|
||||
"""插入一条 2 小时前的消息,供空闲超时条件触发。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id="sess_old",
|
||||
role="assistant",
|
||||
modality="text",
|
||||
content="旧消息",
|
||||
created_at=now - timedelta(hours=2),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_recent_message(db_session: AsyncSession) -> None:
|
||||
"""插入一条 1 分钟前的消息,供空闲超时条件不触发。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id="sess_recent",
|
||||
role="assistant",
|
||||
modality="text",
|
||||
content="新消息",
|
||||
created_at=now - timedelta(minutes=1),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_anniversary(db_session: AsyncSession) -> None:
|
||||
"""插入一条 2026-06-27 的生日纪念日。"""
|
||||
db_session.add(
|
||||
TimeAnniversary(
|
||||
character_id="char_test",
|
||||
name="测试生日",
|
||||
calendar="solar",
|
||||
date="2026-06-27",
|
||||
kind="birthday",
|
||||
subject_type="user",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_emotion_state(db_session: AsyncSession) -> None:
|
||||
"""插入一条初始情绪状态(neutral / 0.2)。"""
|
||||
db_session.add(
|
||||
CharacterEmotionState(
|
||||
character_id="char_test",
|
||||
mood="neutral",
|
||||
intensity=0.2,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_curiosity(db_session: AsyncSession) -> None:
|
||||
"""插入一条活跃的好奇心问题。"""
|
||||
db_session.add(
|
||||
CuriosityQuestion(
|
||||
character_id="char_test",
|
||||
cycle_id="cycle_test",
|
||||
question="你为什么总是沉默?",
|
||||
source="drive",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_applied_evolution(db_session: AsyncSession) -> None:
|
||||
"""插入一条已应用的演化候选。"""
|
||||
db_session.add(
|
||||
EvolutionCandidate(
|
||||
character_id="char_test",
|
||||
cycle_id="cycle_test",
|
||||
type="personality",
|
||||
title="语气更柔和",
|
||||
proposal="调整表达风格,减少命令式语气,增加温和感",
|
||||
level="L3",
|
||||
status="applied",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_completed_big_cycle(db_session: AsyncSession) -> None:
|
||||
"""插入一条已完成的大周天循环。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
BigCycle(
|
||||
character_id="char_test",
|
||||
trigger="session_turn",
|
||||
status="completed",
|
||||
summary="发现自己最近更倾向于回忆过去,在对话中表现出怀旧倾向",
|
||||
completed_at=now - timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_new_memory(db_session: AsyncSession) -> None:
|
||||
"""插入一条活跃的新记忆。"""
|
||||
db_session.add(
|
||||
Memory(
|
||||
character_id="char_test",
|
||||
type="experience",
|
||||
title="今天去了海边",
|
||||
content="用户提到今天去了海边,看日落。角色回忆起自己也曾喜欢在海边散步。",
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_many_messages(db_session: AsyncSession) -> None:
|
||||
"""插入多条消息,供关系里程碑(消息数)条件触发。"""
|
||||
now = datetime.now(UTC)
|
||||
for i in range(5):
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id=f"sess_rel_{i}",
|
||||
role="assistant" if i % 2 else "user",
|
||||
modality="text",
|
||||
content=f"第{i + 1}条消息",
|
||||
created_at=now - timedelta(minutes=10 - i),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_old_relationship(db_session: AsyncSession) -> None:
|
||||
"""插入一条 60 天前的消息,供关系里程碑(天数)条件触发。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id="sess_old_rel",
|
||||
role="user",
|
||||
modality="text",
|
||||
content="你好",
|
||||
created_at=now - timedelta(days=60),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
@@ -0,0 +1,303 @@
|
||||
"""叩心枢触发条件单元测试。"""
|
||||
|
||||
# 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
|
||||
@@ -0,0 +1,214 @@
|
||||
"""叩心枢(KnockingPivot)集成测试。
|
||||
|
||||
测试覆盖:
|
||||
1. Tick 触发 —— 活跃角色条件匹配,执行完整触发链并推送一炁事件
|
||||
2. Tick 空跑 —— 无活跃角色时跳过
|
||||
3. 反 spam 冷却 —— 触发后冷却期内 ``_can_trigger`` 返回 False
|
||||
4. 反 spam 日上限 —— 达到日上限后返回 False
|
||||
5. 静默时段 —— [23:00, 08:00) 返回 True,其余返回 False
|
||||
6. 活跃角色列表 —— DB 查询正确返回 ``status='active'`` 的角色 ID
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
# ── 模型注册 ──────────────────────────────────────────────────────────
|
||||
# 在 DB 表创建前将模型注册到 Base.metadata,确保 create_all 创建对应表。
|
||||
# conftest.py 已注册 ChatMessage / CuriosityQuestion / CharacterEmotionState / TimeAnniversary,
|
||||
# 但未注册 Character 与 ChatSession。
|
||||
from app.models.character import Character # noqa: F401
|
||||
from app.models.chat import ChatSession # noqa: F401
|
||||
from app.modules.knocking.conditions import ConditionResult
|
||||
from app.modules.knocking.opener import OpenerResult
|
||||
from app.modules.knocking.pivot import KnockingPivot
|
||||
from app.services.personality import PersonalitySummary
|
||||
|
||||
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def keeper_session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
|
||||
"""Yield an async_sessionmaker backed by an in-memory SQLite database."""
|
||||
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def dbevent_store() -> AsyncGenerator:
|
||||
"""In-memory event store (no DB needed, supports _events for assertion)."""
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
|
||||
yield InMemoryEventStore()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def keeper(
|
||||
keeper_session_factory: async_sessionmaker[AsyncSession],
|
||||
dbevent_store,
|
||||
) -> AsyncGenerator[KnockingPivot, None]:
|
||||
"""KnockingPivot instance backed by an in-memory SQLite database."""
|
||||
pivot = KnockingPivot(
|
||||
event_store=dbevent_store,
|
||||
session_factory=keeper_session_factory,
|
||||
tick_interval=3600, # 测试期间不真的 tick
|
||||
)
|
||||
yield pivot
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试
|
||||
# ======================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_triggers_for_active_character(
|
||||
keeper: KnockingPivot,
|
||||
keeper_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""活跃角色 + 任意条件触发时,应完成完整触发链:开场 → 消息 → 事件。"""
|
||||
async with keeper_session_factory() as session:
|
||||
char = Character(
|
||||
id="char_test_1",
|
||||
name="测试人物",
|
||||
status="active",
|
||||
owner_scope="test",
|
||||
language="zh-CN",
|
||||
privacy_level="normal",
|
||||
)
|
||||
session.add(char)
|
||||
await session.commit()
|
||||
|
||||
with (
|
||||
patch.object(keeper.conditions[0], "check", AsyncMock(return_value=ConditionResult(triggered=True, trigger_reason="测试触发", condition_name="test"))),
|
||||
patch(
|
||||
"app.modules.knocking.pivot.build_personality_summary",
|
||||
return_value=PersonalitySummary(text="你是一个测试人物。"),
|
||||
),
|
||||
patch(
|
||||
"app.modules.knocking.pivot.generate_opener",
|
||||
return_value=OpenerResult(text="测试开场白。"),
|
||||
),
|
||||
):
|
||||
async with keeper_session_factory() as check_session:
|
||||
processed = await keeper._check_and_trigger(
|
||||
"char_test_1",
|
||||
check_session,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
|
||||
assert processed is True
|
||||
|
||||
# 验证消息已创建
|
||||
async with keeper_session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(ChatSession).where(ChatSession.character_id == "char_test_1")
|
||||
)
|
||||
assert result.scalar_one_or_none() is not None
|
||||
|
||||
# 验证一炁事件已推送
|
||||
events = keeper._event_store._events
|
||||
assert len(events) > 0
|
||||
last_event = events[-1]
|
||||
assert last_event.source == "knocking_pivot"
|
||||
assert last_event.character_id == "char_test_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_skips_when_no_active_characters(keeper: KnockingPivot) -> None:
|
||||
"""无活跃角色时 tick 不触发任何操作。"""
|
||||
with (
|
||||
patch("app.modules.knocking.pivot.create_proactive_message") as mock_create,
|
||||
patch("app.modules.knocking.pivot.generate_opener"),
|
||||
):
|
||||
await keeper._tick()
|
||||
|
||||
mock_create.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooldown_prevents_retrigger(keeper: KnockingPivot) -> None:
|
||||
"""触发后冷却期内 _can_trigger 返回 False。"""
|
||||
now = datetime.now(UTC)
|
||||
assert keeper._can_trigger("char_1", now) is True
|
||||
|
||||
keeper._mark_triggered("char_1", now)
|
||||
assert keeper._can_trigger("char_1", now) is False
|
||||
|
||||
# 冷却结束后应恢复
|
||||
after_cooldown = now + timedelta(minutes=keeper._cooldown_minutes + 1)
|
||||
assert keeper._can_trigger("char_1", after_cooldown) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_daily_limit_prevents_excess(keeper: KnockingPivot) -> None:
|
||||
"""同一天达到上限后 _can_trigger 返回 False。"""
|
||||
now = datetime.now(UTC)
|
||||
assert keeper._can_trigger("char_1", now) is True
|
||||
|
||||
keeper._daily_counts["char_1"] = (now.date().isoformat(), keeper._daily_limit)
|
||||
assert keeper._can_trigger("char_1", now) is False
|
||||
|
||||
# 第二天恢复
|
||||
next_day = now + timedelta(days=1)
|
||||
assert keeper._can_trigger("char_1", next_day) is True
|
||||
|
||||
|
||||
def test_quiet_hours(keeper: KnockingPivot) -> None:
|
||||
"""静默时段 23:00–08:00 _in_quiet_hours 返回 True。"""
|
||||
keeper._quiet_hours_start = 23
|
||||
keeper._quiet_hours_end = 8
|
||||
|
||||
# 静默时段内
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 23, 30, tzinfo=UTC)) is True
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 3, 0, tzinfo=UTC)) is True
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 7, 59, tzinfo=UTC)) is True
|
||||
|
||||
# 静默时段外
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 8, 0, tzinfo=UTC)) is False
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 12, 0, tzinfo=UTC)) is False
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 22, 59, tzinfo=UTC)) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_active_characters(
|
||||
keeper: KnockingPivot,
|
||||
keeper_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""_list_active_characters 只返回 status='active' 的角色。"""
|
||||
async with keeper_session_factory() as session:
|
||||
for i, status in enumerate(["active", "inactive", "paused", "active"]):
|
||||
session.add(
|
||||
Character(
|
||||
id=f"char_{i}",
|
||||
name=f"人物{i}",
|
||||
status=status,
|
||||
owner_scope="test",
|
||||
language="zh-CN",
|
||||
privacy_level="normal",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with keeper_session_factory() as session:
|
||||
result = await keeper._list_active_characters(session)
|
||||
|
||||
assert set(result) == {"char_0", "char_3"}
|
||||
Reference in new issue
Block a user