初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+195
View File
@@ -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"}