M2b:导入改造产出可审阅灵胚(启镜物化世谱)与组装管线(符文展开/火候进入 IR),Kernel 打通采样参数链路
This commit is contained in:
1 parent
e6a3b7121f
commit
5f36e3785c
86 files changed
+424
-14455
No files matched your search
@@ -1,97 +0,0 @@
|
||||
"""角色卡导入 API 测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
def _make_png_text(keyword: str, text: str) -> bytes:
|
||||
def chunk(ctype: bytes, data: bytes) -> bytes:
|
||||
body = ctype + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1")
|
||||
return b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
async def test_import_json_card(auth_client: httpx.AsyncClient) -> None:
|
||||
card = {"spec_version": "2.0", "data": {"name": "API卡", "personality": "p"}}
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/characters/import-card",
|
||||
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["name"] == "API卡"
|
||||
assert data["character_id"].startswith("character_")
|
||||
|
||||
|
||||
async def test_import_png_card(auth_client: httpx.AsyncClient) -> None:
|
||||
card = {"spec_version": "2.0", "data": {"name": "PNG卡"}}
|
||||
text = base64.b64encode(json.dumps(card).encode("utf-8")).decode("ascii")
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/characters/import-card",
|
||||
files={"file": ("card.png", _make_png_text("chara", text), "image/png")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["name"] == "PNG卡"
|
||||
|
||||
|
||||
async def test_import_card_with_lorebook(auth_client: httpx.AsyncClient) -> None:
|
||||
card = {
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "带书",
|
||||
"character_book": {"entries": [{"keys": ["a"], "content": "b"}]},
|
||||
},
|
||||
}
|
||||
resp = await auth_client.post(
|
||||
"/api/v1/characters/import-card",
|
||||
files={"file": ("card.json", json.dumps(card).encode("utf-8"), "application/json")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["lorebook_id"] is not None
|
||||
assert data["entry_count"] == 1
|
||||
# 导入的人物可被列出
|
||||
listed = await auth_client.get("/api/v1/characters")
|
||||
assert any(c["id"] == data["character_id"] for c in listed.json()["items"])
|
||||
|
||||
|
||||
async def test_export_card_round_trip(auth_client: httpx.AsyncClient) -> None:
|
||||
# 导入一张带世谱的卡
|
||||
card = {
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "往返",
|
||||
"nickname": "返返",
|
||||
"personality": "p",
|
||||
"character_book": {"entries": [{"keys": ["k"], "content": "v"}]},
|
||||
},
|
||||
}
|
||||
imp = await auth_client.post(
|
||||
"/api/v1/characters/import-card",
|
||||
files={"file": ("c.json", json.dumps(card).encode("utf-8"), "application/json")},
|
||||
)
|
||||
assert imp.status_code == 200
|
||||
cid = imp.json()["character_id"]
|
||||
|
||||
# 导出
|
||||
exp = await auth_client.get(f"/api/v1/characters/{cid}/export-card")
|
||||
assert exp.status_code == 200
|
||||
exported = exp.json()
|
||||
assert exported["spec"] == "chara_card_v3"
|
||||
assert exported["data"]["name"] == "往返"
|
||||
assert exported["data"]["nickname"] == "返返"
|
||||
assert exported["data"]["personality"] == "p"
|
||||
assert exported["data"]["character_book"]["entries"][0]["keys"] == ["k"]
|
||||
|
||||
|
||||
async def test_export_unknown_character_404(auth_client: httpx.AsyncClient) -> None:
|
||||
exp = await auth_client.get("/api/v1/characters/character_missing/export-card")
|
||||
assert exp.status_code == 404
|
||||
@@ -1,59 +0,0 @@
|
||||
"""道体注入与完整性校验测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
from app.modules.runtime.integrity import validate_daoti_integrity
|
||||
|
||||
|
||||
def _contract(**kw: object) -> RuntimeContract:
|
||||
base: dict[str, object] = {
|
||||
"character_id": "c",
|
||||
"session_id": "s",
|
||||
"run_id": "r",
|
||||
"model_hint": "m",
|
||||
"trace_id": "t",
|
||||
}
|
||||
base.update(kw)
|
||||
return RuntimeContract(**base)
|
||||
|
||||
|
||||
def test_system_prompt_injected() -> None:
|
||||
msgs = _contract_system_messages(_contract(system_prompt="特殊系统指令"))
|
||||
assert "特殊系统指令" in msgs
|
||||
|
||||
|
||||
def test_mission_injected() -> None:
|
||||
msgs = _contract_system_messages(_contract(mission="守护用户"))
|
||||
assert any("守护用户" in m for m in msgs)
|
||||
|
||||
|
||||
def test_safety_guidelines_precede_safety_constraints() -> None:
|
||||
c = _contract(safety_guidelines="道体边界", safety_constraints=["司命约束"])
|
||||
msgs = _contract_system_messages(c)
|
||||
assert msgs.index("行为边界:\n道体边界") < msgs.index("安全约束:\n- 司命约束")
|
||||
|
||||
|
||||
def test_integrity_pass_when_consistent() -> None:
|
||||
c = _contract(mission="M", safety_guidelines="S")
|
||||
validate_daoti_integrity(c, mission="M", safety_guidelines="S") # 不抛即通过
|
||||
|
||||
|
||||
def test_integrity_raises_on_mission_tamper() -> None:
|
||||
c = _contract(mission="M")
|
||||
with pytest.raises(ValueError):
|
||||
validate_daoti_integrity(c, mission="被篡改")
|
||||
|
||||
|
||||
def test_integrity_raises_on_safety_tamper() -> None:
|
||||
c = _contract(safety_guidelines="S")
|
||||
with pytest.raises(ValueError):
|
||||
validate_daoti_integrity(c, safety_guidelines="被篡改")
|
||||
|
||||
|
||||
def test_integrity_skips_none_fields() -> None:
|
||||
# 道体字段未设(None)时跳过校验
|
||||
validate_daoti_integrity(_contract(), mission=None, safety_guidelines=None)
|
||||
@@ -1,48 +0,0 @@
|
||||
"""灵核情绪状态 system prompt 注入测试(设计 §11.5)。
|
||||
|
||||
验证 ``_contract_system_messages`` 把 ``emotion_state`` 注入到正确位置
|
||||
(personality_summary 后、memory_context 前),且 emotion_state=None 时不注入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def _contract(**overrides) -> RuntimeContract:
|
||||
base = {
|
||||
"character_id": "c1",
|
||||
"session_id": "s1",
|
||||
"run_id": "r1",
|
||||
"model_hint": "m",
|
||||
"trace_id": "t",
|
||||
}
|
||||
base.update(overrides)
|
||||
return RuntimeContract(**base)
|
||||
|
||||
|
||||
def test_emotion_state_injected_between_personality_and_memory() -> None:
|
||||
contract = _contract(
|
||||
personality_summary="人格X",
|
||||
emotion_state="[当前情绪] 感动(强度 70%)",
|
||||
memory_context="记忆Y",
|
||||
)
|
||||
messages = _contract_system_messages(contract)
|
||||
personality_idx = next(i for i, m in enumerate(messages) if "人格X" in m)
|
||||
emotion_idx = next(i for i, m in enumerate(messages) if "感动" in m)
|
||||
memory_idx = next(i for i, m in enumerate(messages) if "记忆Y" in m)
|
||||
assert personality_idx < emotion_idx < memory_idx
|
||||
|
||||
|
||||
def test_emotion_state_omitted_when_none() -> None:
|
||||
contract = _contract(personality_summary="人格X", memory_context="记忆Y")
|
||||
messages = _contract_system_messages(contract)
|
||||
assert all("当前情绪" not in m for m in messages)
|
||||
|
||||
|
||||
def test_emotion_state_included_without_memory() -> None:
|
||||
"""emotion_state 独立于 memory_context 存在。"""
|
||||
contract = _contract(emotion_state="[当前情绪] 关切(强度 50%)")
|
||||
messages = _contract_system_messages(contract)
|
||||
assert any("关切" in m for m in messages)
|
||||
@@ -1,41 +0,0 @@
|
||||
"""世谱注入位置测试:before/after 相对 personality_summary。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def _contract(**kw: object) -> RuntimeContract:
|
||||
base: dict[str, object] = {
|
||||
"character_id": "c",
|
||||
"session_id": "s",
|
||||
"run_id": "r",
|
||||
"model_hint": "m",
|
||||
"trace_id": "t",
|
||||
}
|
||||
base.update(kw)
|
||||
return RuntimeContract(**base)
|
||||
|
||||
|
||||
def test_lorebook_before_precedes_personality() -> None:
|
||||
c = _contract(personality_summary="人格X", lorebook_before="世界设定前")
|
||||
msgs = _contract_system_messages(c)
|
||||
assert msgs.index("世界设定前") < msgs.index("当前人格:\n人格X")
|
||||
|
||||
|
||||
def test_lorebook_after_follows_personality() -> None:
|
||||
c = _contract(personality_summary="人格X", lorebook_after="世界设定后")
|
||||
msgs = _contract_system_messages(c)
|
||||
assert msgs.index("当前人格:\n人格X") < msgs.index("世界设定后")
|
||||
|
||||
|
||||
def test_no_lorebook_when_none() -> None:
|
||||
msgs = _contract_system_messages(_contract())
|
||||
assert not any("世界设定" in m for m in msgs)
|
||||
|
||||
|
||||
def test_lorebook_without_personality_still_injects() -> None:
|
||||
c = _contract(lorebook_before="前", lorebook_after="后")
|
||||
msgs = _contract_system_messages(c)
|
||||
assert "前" in msgs and "后" in msgs
|
||||
@@ -1,8 +0,0 @@
|
||||
"""测试 send_message 中的目标自动注入(集成验证)。"""
|
||||
|
||||
|
||||
def test_auto_persist_session_goal_is_callable():
|
||||
"""验证 auto_persist_session_goal 函数可导入且可调用。"""
|
||||
from app.services.goal_persist import auto_persist_session_goal
|
||||
|
||||
assert callable(auto_persist_session_goal)
|
||||
@@ -1,179 +0,0 @@
|
||||
"""对话 temporal_context 注入测试(辰正 §8.1)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.application.agent.runtime import AgentTurnResult
|
||||
from app.application.chat.send_message import _contract_system_messages
|
||||
from app.main import app
|
||||
from app.modules.runtime.contract import RuntimeContract
|
||||
|
||||
|
||||
def _make_contract(temporal: str | None) -> RuntimeContract:
|
||||
return RuntimeContract(
|
||||
character_id="char_x",
|
||||
session_id="sess_x",
|
||||
run_id="run_x",
|
||||
model_hint="default",
|
||||
system_instructions=["base"],
|
||||
personality_summary="人格X",
|
||||
memory_context="记忆Y",
|
||||
temporal_context=temporal,
|
||||
trace_id="trace_x",
|
||||
)
|
||||
|
||||
|
||||
def test_contract_system_messages_includes_temporal_context():
|
||||
messages = _contract_system_messages(_make_contract("现在是子时·深夜(01:42)。"))
|
||||
assert any("子时·深夜" in m for m in messages)
|
||||
|
||||
|
||||
def test_contract_system_messages_no_temporal_context_when_none():
|
||||
messages = _contract_system_messages(_make_contract(None))
|
||||
assert all("子时" not in m for m in messages)
|
||||
|
||||
|
||||
def test_contract_system_messages_temporal_after_memory_before_safety():
|
||||
"""注入位置:记忆之后、安全约束之前(设计 §8.1)。"""
|
||||
contract = RuntimeContract(
|
||||
character_id="c",
|
||||
session_id="s",
|
||||
run_id="r",
|
||||
model_hint="m",
|
||||
system_instructions=["base"],
|
||||
memory_context="MEMORY_MARKER",
|
||||
temporal_context="TEMPORAL_MARKER",
|
||||
safety_constraints=["SAFETY_MARKER"],
|
||||
trace_id="t",
|
||||
)
|
||||
messages = _contract_system_messages(contract)
|
||||
joined = "\n".join(messages)
|
||||
assert joined.index("MEMORY_MARKER") < joined.index("TEMPORAL_MARKER")
|
||||
assert joined.index("TEMPORAL_MARKER") < joined.index("SAFETY_MARKER")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# §12 降级路径守护(设计 §12:辰正永不阻塞对话)
|
||||
# 1. event_store=None → 跳过辰正调用,temporal_context 保持 None,对话照常完成
|
||||
# 2. compute_live_snapshot raise → except 捕获,temporal_context 保持 None,对话不阻塞
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _create_session(client: AsyncClient) -> str:
|
||||
resp = await client.post("/api/v1/chat/sessions", json={"title": "辰正降级"})
|
||||
assert resp.status_code == 200
|
||||
return str(resp.json()["session_id"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def temporal_client(
|
||||
auth_headers: dict[str, str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
"""HTTP client:mock run_agent_turn,并通过 messages 捕获最终 system 消息用于断言。
|
||||
|
||||
复用 test_send_message_agent.py 的 mock 边界(只 mock run_agent_turn,跑真实 send_message
|
||||
全链路),让降级路径在生产代码路径中被真实驱动。
|
||||
"""
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult:
|
||||
captured["messages"] = messages
|
||||
return AgentTurnResult(content="好的。", reasoning="")
|
||||
|
||||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
http_client._temporal_captured = captured # type: ignore[attr-defined]
|
||||
yield http_client
|
||||
|
||||
|
||||
def _system_text(messages: list[Any]) -> str:
|
||||
return "\n".join(str(m.content) for m in messages if getattr(m, "role", "") == "system")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_context_skipped_when_event_store_is_none(
|
||||
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""§12 降径 1:event_store=None 时跳过辰正调用,对话照常完成且不注入 temporal_context。"""
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
|
||||
# 降径 1:明确将 event_store 置 None(route 经 getattr 默认也是 None,此处显式断言降径)
|
||||
# 测试 app.state 默认无 event_store 属性,monkeypatch.setattr 对对象属性可在不存在时
|
||||
# 创建并在 teardown 时删除(恢复"无属性"原状)。
|
||||
monkeypatch.setattr(app.state, "event_store", None, raising=False)
|
||||
|
||||
# spy:若被调用即标记(不应发生)
|
||||
called: dict[str, bool] = {"hit": False}
|
||||
|
||||
import app.services.time_sense as time_sense_mod
|
||||
|
||||
real_compute = time_sense_mod.compute_live_snapshot
|
||||
|
||||
async def spy_compute(*args: Any, **kwargs: Any) -> Any:
|
||||
called["hit"] = True
|
||||
return await real_compute(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(time_sense_mod, "compute_live_snapshot", spy_compute)
|
||||
|
||||
session_id = await _create_session(temporal_client)
|
||||
sent = await temporal_client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
await drain_pending_streams()
|
||||
|
||||
# 真实行为断言:辰正未被调用
|
||||
assert called["hit"] is False
|
||||
# 对话完成:run_agent_turn 被正常调用且 system 消息中无辰正上下文标记
|
||||
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
|
||||
sys_text = _system_text(captured["messages"])
|
||||
assert "今日命中" not in sys_text # render_temporal_context 的稳定首行尾块标记
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporal_context_swallowed_when_compute_raises(
|
||||
temporal_client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""§12 降径 2:compute_live_snapshot raise 时 except 捕获,对话不阻塞且 temporal_context=None。"""
|
||||
|
||||
async def boom(*_args: Any, **_kwargs: Any) -> Any:
|
||||
raise RuntimeError("辰正服务故障(模拟)")
|
||||
|
||||
monkeypatch.setattr("app.services.time_sense.compute_live_snapshot", boom)
|
||||
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
|
||||
session_id = await _create_session(temporal_client)
|
||||
sent = await temporal_client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
)
|
||||
# 真实行为断言:异常被 except 吞掉,HTTP 仍 200,对话照常完成
|
||||
assert sent.status_code == 200
|
||||
run_id = str(sent.json()["run_id"])
|
||||
await drain_pending_streams()
|
||||
|
||||
# 对话成功落库:run 进入终态 completed(而非 failed)
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.runtime import Run
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one()
|
||||
assert run.status == "completed"
|
||||
|
||||
# temporal_context 未注入:system 消息中无辰正上下文标记
|
||||
captured = temporal_client._temporal_captured # type: ignore[attr-defined]
|
||||
sys_text = _system_text(captured["messages"])
|
||||
assert "子时" not in sys_text and "时态" not in sys_text
|
||||
@@ -1,195 +0,0 @@
|
||||
"""叩心枢测试共用 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()
|
||||
@@ -1,303 +0,0 @@
|
||||
"""叩心枢触发条件单元测试。"""
|
||||
|
||||
# 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
|
||||
@@ -1,214 +0,0 @@
|
||||
"""叩心枢(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"}
|
||||
@@ -1,93 +0,0 @@
|
||||
"""灵核情绪状态数据访问测试(设计 §11.4)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
from app.repositories import personality_emotion as emotion_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
def _state(character_id: str, **overrides) -> CharacterEmotionState:
|
||||
base = {
|
||||
"character_id": character_id,
|
||||
"mood": "touched",
|
||||
"intensity": 0.7,
|
||||
"baseline_mood": "neutral",
|
||||
"baseline_intensity": 0.2,
|
||||
"last_trigger_event_id": "evt_abc",
|
||||
}
|
||||
base.update(overrides)
|
||||
return CharacterEmotionState(**base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_state_returns_none_when_absent() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
assert await emotion_repo.get_state(session, character.id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_state_inserts_new() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
created = await emotion_repo.upsert_state(session, _state(cid))
|
||||
await session.commit()
|
||||
assert created.mood == "touched"
|
||||
assert created.intensity == 0.7
|
||||
async with AsyncSessionLocal() as session:
|
||||
fetched = await emotion_repo.get_state(session, cid)
|
||||
assert fetched is not None
|
||||
assert fetched.mood == "touched"
|
||||
assert fetched.last_trigger_event_id == "evt_abc"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_state_updates_existing() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await emotion_repo.upsert_state(session, _state(cid, mood="touched", intensity=0.7))
|
||||
await emotion_repo.upsert_state(
|
||||
session,
|
||||
_state(
|
||||
cid,
|
||||
mood="concerned",
|
||||
intensity=0.5,
|
||||
last_trigger_event_id="evt_def",
|
||||
),
|
||||
)
|
||||
await session.commit()
|
||||
async with AsyncSessionLocal() as session:
|
||||
fetched = await emotion_repo.get_state(session, cid)
|
||||
assert fetched is not None
|
||||
assert fetched.mood == "concerned"
|
||||
assert fetched.intensity == 0.5
|
||||
assert fetched.last_trigger_event_id == "evt_def"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upsert_state_single_row_per_character() -> None:
|
||||
"""单行 per character:重复 upsert 不产生重复行。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await emotion_repo.upsert_state(session, _state(cid))
|
||||
await emotion_repo.upsert_state(session, _state(cid))
|
||||
await emotion_repo.upsert_state(session, _state(cid))
|
||||
await session.commit()
|
||||
async with AsyncSessionLocal() as session:
|
||||
from sqlalchemy import func, select
|
||||
|
||||
count = int(
|
||||
(
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(CharacterEmotionState)
|
||||
.where(CharacterEmotionState.character_id == cid)
|
||||
)
|
||||
).scalar_one()
|
||||
)
|
||||
assert count == 1
|
||||
@@ -1,79 +0,0 @@
|
||||
"""时态纪念日数据访问测试(辰正 §5.3)。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.time_anniversary import TimeAnniversary
|
||||
from app.repositories import time_anniversaries as anni_repo
|
||||
from app.services.characters import get_active_character_model
|
||||
|
||||
|
||||
def _anniversary(character_id: str, **overrides) -> TimeAnniversary:
|
||||
base = {
|
||||
"character_id": character_id,
|
||||
"name": "我们的相遇日",
|
||||
"calendar": "solar",
|
||||
"date": "2025-09-19",
|
||||
"recurs_yearly": True,
|
||||
"kind": "anniversary",
|
||||
"subject_type": "relationship",
|
||||
"source": "auto",
|
||||
"metadata_json": "{}",
|
||||
}
|
||||
base.update(overrides)
|
||||
return TimeAnniversary(**base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get_anniversary() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
created = await anni_repo.create_anniversary(session, _anniversary(character.id))
|
||||
fetched = await anni_repo.get_anniversary(session, created.id)
|
||||
assert fetched is not None
|
||||
assert fetched.name == "我们的相遇日"
|
||||
assert fetched.id.startswith("anniv_")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_anniversaries_by_character() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
await anni_repo.create_anniversary(session, _anniversary(character.id, date="2025-03-01"))
|
||||
await anni_repo.create_anniversary(session, _anniversary(character.id, date="2025-09-19"))
|
||||
items = await anni_repo.list_anniversaries(session, character.id)
|
||||
# 默认人物 seed 时已带 1 条 birthday 纪念日;此处只验证 anniversary kind 的列表行为
|
||||
anniversary_items = [a for a in items if a.kind == "anniversary"]
|
||||
assert len(anniversary_items) == 2
|
||||
# list 默认按 date 升序
|
||||
assert anniversary_items[0].date < anniversary_items[1].date
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_anniversary() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
created = await anni_repo.create_anniversary(session, _anniversary(character.id))
|
||||
updated = await anni_repo.update_anniversary(
|
||||
session, created.id, name="相识纪念日", source="manual"
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated.name == "相识纪念日"
|
||||
assert updated.source == "manual"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_anniversary_missing_returns_none() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await anni_repo.update_anniversary(session, "anniv_nonexistent", name="x")
|
||||
assert result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_anniversary() -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
created = await anni_repo.create_anniversary(session, _anniversary(character.id))
|
||||
assert await anni_repo.delete_anniversary(session, created.id) is True
|
||||
assert await anni_repo.get_anniversary(session, created.id) is None
|
||||
assert await anni_repo.delete_anniversary(session, created.id) is False
|
||||
@@ -1,66 +0,0 @@
|
||||
"""角色卡导入服务测试(DB 集成)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.repositories import lorebook as lorebook_repo
|
||||
from app.services.card_import import import_character_card
|
||||
|
||||
|
||||
async def test_import_creates_character_and_lorebook() -> None:
|
||||
card = {
|
||||
"spec": "chara_card_v2",
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "狂三",
|
||||
"nickname": "三三",
|
||||
"personality": "腹黑优雅",
|
||||
"character_book": {
|
||||
"name": "狂三世谱",
|
||||
"entries": [
|
||||
{"keys": ["猫"], "content": "喜欢猫。"},
|
||||
{"keys": ["时间"], "content": "时间能力。", "constant": True},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
raw = json.dumps(card).encode("utf-8")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await import_character_card(session, raw, "local-user")
|
||||
books = await lorebook_repo.list_books(session, character.id, "local-user")
|
||||
entries = await lorebook_repo.list_book_entries(session, books[0].id)
|
||||
|
||||
assert character.name == "狂三"
|
||||
assert character.display_name == "三三"
|
||||
assert character.personality_summary == "腹黑优雅"
|
||||
assert len(books) == 1
|
||||
assert books[0].name == "狂三世谱"
|
||||
assert len(entries) == 2
|
||||
assert any(e.constant for e in entries)
|
||||
|
||||
|
||||
async def test_import_without_book_creates_only_character() -> None:
|
||||
card = {"spec_version": "2.0", "data": {"name": "无书角色", "personality": "温和"}}
|
||||
raw = json.dumps(card).encode("utf-8")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await import_character_card(session, raw, "local-user")
|
||||
books = await lorebook_repo.list_books(session, character.id, "local-user")
|
||||
|
||||
assert character.name == "无书角色"
|
||||
assert character.personality_summary == "温和"
|
||||
assert books == []
|
||||
|
||||
|
||||
async def test_import_v1_flat_card() -> None:
|
||||
# V1 扁平结构,无 data 包裹
|
||||
card = {"name": "V1角色", "personality": "p"}
|
||||
raw = json.dumps(card).encode("utf-8")
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await import_character_card(session, raw, "local-user")
|
||||
assert character.name == "V1角色"
|
||||
assert character.personality_summary == "p"
|
||||
@@ -1,87 +0,0 @@
|
||||
"""灵核情绪状态机端到端集成测试:Dispatcher + handle_time_event + DB。
|
||||
|
||||
验证一炁订阅基础设施(计划 5)+ 灵核情绪状态机(设计 §11)全链路:
|
||||
EventStore.append(time.reunion) → Dispatcher 分发 → handle_time_event → DB 情绪状态更新。
|
||||
这是 main.py lifespan ``dispatcher.register("time.*", handle_time_event)`` 的端到端验证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.personality import PersonalityVersion
|
||||
from app.modules.runtime.events import Event, InMemoryEventStore
|
||||
from app.repositories.personality_emotion import get_state
|
||||
from app.services.characters import get_active_character_model
|
||||
from app.services.dispatcher import Dispatcher
|
||||
from app.services.personality_emotion import handle_time_event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatcher_drives_emotion_state_update() -> None:
|
||||
"""append time.reunion → dispatcher 分发 → handle_time_event 写入 DB。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
pv = PersonalityVersion(
|
||||
character_id=cid,
|
||||
identity_profile="测试身份",
|
||||
emotion_baseline='{"mood": "neutral", "intensity": 0.2}',
|
||||
)
|
||||
session.add(pv)
|
||||
await session.flush()
|
||||
character.current_personality_version_id = pv.id
|
||||
await session.commit()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
dispatcher = Dispatcher(store)
|
||||
dispatcher.register("time.*", handle_time_event)
|
||||
await dispatcher.start()
|
||||
try:
|
||||
await store.append(
|
||||
Event(
|
||||
type="time.reunion",
|
||||
source="time_sense",
|
||||
payload={"reunion_intensity": 0.6, "offline_band": "reunion_impact"},
|
||||
character_id=cid,
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.1) # 等 pump + consumer 异步完成
|
||||
finally:
|
||||
await dispatcher.stop()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
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
|
||||
character = await session.get(Character, cid)
|
||||
assert character is not None
|
||||
assert character.current_mood_id == "touched"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatcher_ignores_non_time_events() -> None:
|
||||
"""非 time.* 事件不触发 handle_time_event(无副作用:无 state row 写入)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
cid = character.id
|
||||
await session.commit()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
dispatcher = Dispatcher(store)
|
||||
dispatcher.register("time.*", handle_time_event)
|
||||
await dispatcher.start()
|
||||
try:
|
||||
await store.append(Event(type="run.started", source="run", character_id=cid))
|
||||
await asyncio.sleep(0.05)
|
||||
finally:
|
||||
await dispatcher.stop()
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 非 time.* 事件 → handler 不被调用 → 无 state row
|
||||
assert await get_state(session, cid) is None
|
||||
@@ -1,226 +0,0 @@
|
||||
"""灵核情绪状态服务测试(设计 §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")
|
||||
)
|
||||
# 无抛出即通过
|
||||
@@ -1,112 +0,0 @@
|
||||
"""辰正集成测试:重逢流事件链 + milestone 联动(设计 §6.3)。"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.time_anniversary import TimeAnniversary
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
from app.modules.time_sense.modulation import ModulationInput
|
||||
from app.services.characters import get_active_character_model
|
||||
from app.services.time_sense import compute_live_snapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reunion_flow_emits_reunion_and_sentiment():
|
||||
"""离线 > 3 天 → time.reunion + time.sentiment(reunion_memory)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
chat_session = ChatSession(character_id=character.id, status="active")
|
||||
session.add(chat_session)
|
||||
await session.flush()
|
||||
old_msg = ChatMessage(
|
||||
character_id=character.id,
|
||||
session_id=chat_session.id,
|
||||
role="user",
|
||||
content="5 天前的消息",
|
||||
created_at=datetime(2026, 6, 29, 4, 0, tzinfo=UTC),
|
||||
)
|
||||
session.add(old_msg)
|
||||
await session.flush()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 5 天后
|
||||
)
|
||||
|
||||
assert result.snapshot is not None
|
||||
assert result.snapshot.offline.band == "reunion_impact"
|
||||
event_types = {e.type for e in result.emitted_events}
|
||||
assert "time.reunion" in event_types
|
||||
assert "time.sentiment" in event_types # reunion_memory 触发
|
||||
sentiment = next(e for e in result.emitted_events if e.type == "time.sentiment")
|
||||
assert sentiment.payload["trigger_type"] == "reunion_memory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_event_when_active():
|
||||
"""active 档(无历史消息,last_active=now)→ 无 reunion/sentiment 事件。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00 上午
|
||||
)
|
||||
assert result.snapshot is not None
|
||||
assert result.snapshot.offline.band == "active"
|
||||
event_types = {e.type for e in result.emitted_events}
|
||||
assert "time.reunion" not in event_types
|
||||
assert "time.sentiment" not in event_types
|
||||
assert "time.user_care" not in event_types # 白天 + 无 birthday 等信号
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_milestone_at_late_night_emits_sentiment_overlay():
|
||||
"""凌晨 + 今日命中纪念日 → active_milestones 非空 + time.sentiment(late_night_overlay)。
|
||||
|
||||
验证 anniversary 命中 + 凌晨时段联动 sentiment 的集成路径(Task 4 未覆盖)。
|
||||
"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
anni = TimeAnniversary(
|
||||
character_id=character.id,
|
||||
name="我们的相遇日",
|
||||
calendar="solar",
|
||||
date="2026-07-05", # 今日(now=18:00 UTC → 上海 02:00 本地日期 07-05)命中
|
||||
recurs_yearly=True,
|
||||
kind="anniversary",
|
||||
subject_type="relationship",
|
||||
source="manual",
|
||||
)
|
||||
session.add(anni)
|
||||
await session.flush()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨
|
||||
)
|
||||
|
||||
assert result.snapshot is not None
|
||||
assert result.snapshot.day_period == "凌晨"
|
||||
assert len(result.snapshot.active_milestones) >= 1
|
||||
# duration_node(0 不在 nodes)/anniversary_prewarm(next=365>7)/reunion_memory(active 档) 都不触发
|
||||
# → late_night_overlay(凌晨 + has_active_milestone)
|
||||
sentiment_events = [e for e in result.emitted_events if e.type == "time.sentiment"]
|
||||
assert len(sentiment_events) == 1
|
||||
assert sentiment_events[0].payload["trigger_type"] == "late_night_overlay"
|
||||
# active 档(无 chat 历史)→ 不触发 reunion
|
||||
assert "time.reunion" not in {e.type for e in result.emitted_events}
|
||||
@@ -1,137 +0,0 @@
|
||||
"""辰正 service 测试(流 A 实时快照 + 流 C 事件触发)。"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
from app.modules.time_sense.modulation import ModulationInput
|
||||
from app.services.characters import get_active_character_model
|
||||
from app.services.time_sense import compute_live_snapshot
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_live_snapshot_returns_snapshot_and_factors():
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00
|
||||
)
|
||||
assert result.snapshot is not None
|
||||
assert result.factors is not None
|
||||
assert result.context_text # 非空
|
||||
assert "周" in result.context_text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_live_snapshot_degrades_on_failure():
|
||||
"""计算失败时降级返回最小快照(不抛异常)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
# 传入非法时区触发降级
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC),
|
||||
timezone="Invalid/Zone",
|
||||
)
|
||||
# 降级:snapshot 仍返回(compute_snapshot 内部降级为 UTC),不抛
|
||||
assert result.snapshot is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_user_care_when_birthday():
|
||||
"""user_birthday_today → care_kind=birthday → 发 time.user_care(不依赖 snapshot 时段/时区)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(user_birthday_today=True),
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00
|
||||
)
|
||||
assert result.snapshot is not None
|
||||
care_events = [e for e in result.emitted_events if e.type == "time.user_care"]
|
||||
assert len(care_events) == 1
|
||||
assert care_events[0].payload["care_kind"] == "birthday"
|
||||
# birthday 不应触发 reunion(last_active≈now),也不命中 sentiment 节点
|
||||
assert all(e.type != "time.reunion" for e in result.emitted_events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emits_reunion_when_offline_exceeds_threshold():
|
||||
"""seed 5 天前消息 → offline.band=reunion_impact → 发 time.reunion + time.sentiment。"""
|
||||
now = datetime(2026, 7, 4, 4, 0, tzinfo=UTC)
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
# 先建会话(FK 约束),再 seed 一条 5 天前的消息
|
||||
chat_session = ChatSession(character_id=character.id, status="active")
|
||||
session.add(chat_session)
|
||||
await session.flush()
|
||||
session.add(
|
||||
ChatMessage(
|
||||
character_id=character.id,
|
||||
session_id=chat_session.id,
|
||||
role="user",
|
||||
content="旧消息",
|
||||
created_at=now - timedelta(days=5),
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
store = InMemoryEventStore()
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(),
|
||||
now=now,
|
||||
)
|
||||
|
||||
assert result.snapshot is not None
|
||||
assert result.snapshot.offline.band == "reunion_impact"
|
||||
# 5 天 = 7200 min > 4320 min 阈值 → 重逢
|
||||
reunion_events = [e for e in result.emitted_events if e.type == "time.reunion"]
|
||||
assert len(reunion_events) == 1
|
||||
payload = reunion_events[0].payload
|
||||
assert payload["offline_band"] == "reunion_impact"
|
||||
assert "reunion_intensity" in payload
|
||||
assert payload["offline_minutes"] >= 4320
|
||||
assert reunion_events[0].sensitivity == "medium"
|
||||
# is_reunion=True + has_reunion_memory=is_reunion → sentiment=reunion_memory
|
||||
sentiment_events = [e for e in result.emitted_events if e.type == "time.sentiment"]
|
||||
assert len(sentiment_events) == 1
|
||||
assert sentiment_events[0].payload["trigger_type"] == "reunion_memory"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_live_snapshot_attaches_run_id_to_events():
|
||||
"""对话入口传 run_id → 触发的 time.* 事件全部带该 run_id(落当前 run,SSE 可见)。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
character = await get_active_character_model(session)
|
||||
store = InMemoryEventStore()
|
||||
# 凌晨场景触发 late_night care / sentiment
|
||||
result = await compute_live_snapshot(
|
||||
session,
|
||||
store,
|
||||
character.id,
|
||||
ModulationInput(conversation_turns_today=3),
|
||||
now=datetime(2026, 7, 4, 18, 0, tzinfo=UTC), # 上海 02:00 凌晨
|
||||
timezone="Asia/Shanghai",
|
||||
run_id="run_test_plan4",
|
||||
)
|
||||
assert result.emitted_events, "凌晨 + 3 轮应触发至少一个 time.* 事件"
|
||||
for ev in result.emitted_events:
|
||||
assert ev.run_id == "run_test_plan4"
|
||||
@@ -1,87 +0,0 @@
|
||||
"""辰正 time API 端点测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers=auth_headers,
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config(client: AsyncClient) -> None:
|
||||
response = await client.get("/api/v1/time/config")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user_timezone"] == "Asia/Shanghai"
|
||||
assert data["default_city"] == "上海"
|
||||
assert data["care_enabled"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anniversaries_crud(client: AsyncClient) -> None:
|
||||
create = await client.post(
|
||||
"/api/v1/time/anniversaries",
|
||||
json={
|
||||
"name": "测试纪念日",
|
||||
"calendar": "solar",
|
||||
"date": "2025-09-19",
|
||||
"recurs_yearly": True,
|
||||
"kind": "anniversary",
|
||||
"subject_type": "relationship",
|
||||
"source": "manual",
|
||||
},
|
||||
)
|
||||
assert create.status_code == 201
|
||||
anniv_id = create.json()["id"]
|
||||
assert anniv_id.startswith("anniv_")
|
||||
|
||||
listed = await client.get("/api/v1/time/anniversaries")
|
||||
assert listed.status_code == 200
|
||||
assert any(a["id"] == anniv_id for a in listed.json())
|
||||
|
||||
patched = await client.patch(
|
||||
f"/api/v1/time/anniversaries/{anniv_id}", json={"name": "改后的名字"}
|
||||
)
|
||||
assert patched.status_code == 200
|
||||
assert patched.json()["name"] == "改后的名字"
|
||||
|
||||
deleted = await client.delete(f"/api/v1/time/anniversaries/{anniv_id}")
|
||||
assert deleted.status_code == 204
|
||||
|
||||
not_found = await client.get(f"/api/v1/time/anniversaries/{anniv_id}")
|
||||
assert not_found.status_code == 404
|
||||
assert not_found.json()["error"]["code"] == "not_found"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_snapshot(client: AsyncClient) -> None:
|
||||
# ASGITransport 可能不触发 lifespan 装配 event_store;get_event_store 未就绪时返回 503
|
||||
response = await client.get("/api/v1/time/snapshot")
|
||||
assert response.status_code in (200, 503)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
assert "context_text" in data
|
||||
assert "snapshot" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_acquaintance(client: AsyncClient) -> None:
|
||||
# 与 /snapshot 同因:ASGITransport 可能不触发 lifespan;snapshot 降级时返回 503
|
||||
response = await client.get("/api/v1/time/acquaintance")
|
||||
assert response.status_code in (200, 503)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
assert isinstance(data["total_days"], int)
|
||||
assert "natural_language" in data
|
||||
@@ -1,56 +0,0 @@
|
||||
"""审计测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.ai_provider.types.chat import ChatChunk
|
||||
from app.application.agent.runtime import AgentTurnResult
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.safety import AuditEvent
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
|
||||
await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。"))
|
||||
return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="")
|
||||
|
||||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers=auth_headers,
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_and_memory_write_audit_events(client: AsyncClient) -> None:
|
||||
session_response = await client.post("/api/v1/chat/sessions", json={"title": "审计"})
|
||||
session_id = session_response.json()["session_id"]
|
||||
sent = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
await drain_pending_streams()
|
||||
memory = await client.post(
|
||||
"/api/v1/memory",
|
||||
json={"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"},
|
||||
)
|
||||
assert memory.status_code == 200
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
actions = [item.action for item in (await db.execute(select(AuditEvent))).scalars().all()]
|
||||
assert "run.started" in actions
|
||||
assert "model.request.started" in actions
|
||||
assert "model.response.completed" in actions
|
||||
assert "memory.created" in actions
|
||||
@@ -1,100 +0,0 @@
|
||||
"""幂等键测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.ai_provider.types.chat import ChatChunk
|
||||
from app.application.agent.runtime import AgentTurnResult
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.chat import ChatMessage
|
||||
from app.models.idempotency import IdempotencyKey
|
||||
from app.models.memory import Memory
|
||||
from app.models.runtime import Run
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
|
||||
await on_chunk(ChatChunk(delta_content="你好,我是测试模型回复。"))
|
||||
return AgentTurnResult(content="你好,我是测试模型回复。", reasoning="")
|
||||
|
||||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport,
|
||||
base_url="http://test",
|
||||
headers=auth_headers,
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
async def _create_session(client: AsyncClient) -> str:
|
||||
response = await client.post("/api/v1/chat/sessions", json={"title": "幂等测试"})
|
||||
assert response.status_code == 200
|
||||
return str(response.json()["session_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_idempotency_replays_same_response(client: AsyncClient) -> None:
|
||||
session_id = await _create_session(client)
|
||||
headers = {"Idempotency-Key": "send-same-1"}
|
||||
payload = {"content": "你好", "modality": "text"}
|
||||
first = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages", json=payload, headers=headers
|
||||
)
|
||||
second = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages", json=payload, headers=headers
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert second.json() == first.json()
|
||||
await drain_pending_streams()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
runs = (await db.execute(select(Run))).scalars().all()
|
||||
messages = (await db.execute(select(ChatMessage))).scalars().all()
|
||||
keys = (await db.execute(select(IdempotencyKey))).scalars().all()
|
||||
assert len(runs) == 1
|
||||
assert len(messages) == 2
|
||||
assert len(keys) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idempotency_key_conflict(client: AsyncClient) -> None:
|
||||
session_id = await _create_session(client)
|
||||
headers = {"Idempotency-Key": "send-conflict-1"}
|
||||
first = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
headers=headers,
|
||||
)
|
||||
conflict = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "不同内容", "modality": "text"},
|
||||
headers=headers,
|
||||
)
|
||||
assert first.status_code == 200
|
||||
assert conflict.status_code == 409
|
||||
assert conflict.json()["error"]["code"] == "conflict"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_create_idempotency(client: AsyncClient) -> None:
|
||||
headers = {"Idempotency-Key": "memory-same-1"}
|
||||
payload = {"type": "user", "title": "偏好", "content": "用户喜欢简洁回答。"}
|
||||
first = await client.post("/api/v1/memory", json=payload, headers=headers)
|
||||
second = await client.post("/api/v1/memory", json=payload, headers=headers)
|
||||
assert first.status_code == 200
|
||||
assert second.status_code == 200
|
||||
assert second.json()["id"] == first.json()["id"]
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
memories = (await db.execute(select(Memory))).scalars().all()
|
||||
assert len(memories) == 1
|
||||
@@ -0,0 +1,208 @@
|
||||
"""M2b 导入改造与组装管线测试。
|
||||
|
||||
- 导入 ST 卡(JSON V2)→ 可审阅灵胚(不直接建人物);启镜后 character_book 物化为世谱;
|
||||
- 组装管线:{{char}}/{{user}} 符文在投影 prompt 中展开;火候(GenerationPreferences)
|
||||
的 temperature/max_tokens 进入 llm.requested 的 IR。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.kernel import Kernel, KernelEventLog
|
||||
from app.services.event_store import DbEventStore
|
||||
from app.shenshi_client import EchoShenshi
|
||||
|
||||
_CARD_V2 = {
|
||||
"spec": "chara_card_v2",
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "绯樱",
|
||||
"description": "一位安静的图书馆管理员。",
|
||||
"personality": "温柔、寡言",
|
||||
"scenario": "午后的图书馆",
|
||||
"first_mes": "……你来了。",
|
||||
"mes_example": "",
|
||||
"creator": "tester",
|
||||
"character_version": "1.0",
|
||||
"character_book": {
|
||||
"entries": {
|
||||
"0": {
|
||||
"keys": ["图书馆", "书"],
|
||||
"content": "图书馆只在午后开放。",
|
||||
"enabled": True,
|
||||
"insertion_order": 10,
|
||||
},
|
||||
"1": {
|
||||
"keys": ["绯樱"],
|
||||
"content": "绯樱从不谈论自己的过去。",
|
||||
"enabled": True,
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
from app.main import app
|
||||
|
||||
event_store = DbEventStore(AsyncSessionLocal)
|
||||
app.state.kernel = Kernel(
|
||||
shenshi=EchoShenshi(),
|
||||
session_factory=AsyncSessionLocal,
|
||||
event_log=KernelEventLog(event_store),
|
||||
timeout_seconds=10.0,
|
||||
)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
|
||||
yield c
|
||||
app.state.kernel = None
|
||||
|
||||
|
||||
async def test_import_card_creates_reviewable_embryo(client: AsyncClient) -> None:
|
||||
files = {"file": ("card.json", json.dumps(_CARD_V2).encode(), "application/json")}
|
||||
imported = await client.post("/api/v1/characters/import-card", files=files)
|
||||
assert imported.status_code == 201
|
||||
body = imported.json()
|
||||
assert body["source"] == "imported"
|
||||
assert body["name"] == "绯樱"
|
||||
assert body["status"] == "drafting"
|
||||
assert body["book_entry_count"] == 2
|
||||
|
||||
# 未启镜:本用户名下无人物、无世谱(库内系统模板人物不计)
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.character import Character
|
||||
from app.models.lorebook import Lorebook
|
||||
|
||||
me = (await client.get("/api/v1/auth/me")).json()
|
||||
owner = me["id"]
|
||||
|
||||
async def _own_counts() -> tuple[int, int]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
characters = int(
|
||||
await session.scalar(
|
||||
select(func.count(Character.id)).where(Character.owner_scope == owner)
|
||||
)
|
||||
)
|
||||
books = int(
|
||||
await session.scalar(
|
||||
select(func.count(Lorebook.id)).where(Lorebook.owner_scope == owner)
|
||||
)
|
||||
)
|
||||
return characters, books
|
||||
|
||||
# 导入只产灵胚:本用户人物/世谱数量不变(种子会预建一个默认人物,按差量断言)
|
||||
before = await _own_counts()
|
||||
assert before == await _own_counts()
|
||||
|
||||
# 启镜:物化人物 + 世谱书 + 条目(dict 形 entries 兼容)
|
||||
activated = await client.post(f"/api/v1/embryo-drafts/{body['embryo_id']}/activate")
|
||||
assert activated.status_code == 200
|
||||
character_id = activated.json()["character_id"]
|
||||
after = await _own_counts()
|
||||
assert after[0] == before[0] + 1 # 启镜才产生人物
|
||||
assert after[1] == before[1] + 1 # 启镜才物化世谱书
|
||||
|
||||
from app.models.lorebook import LorebookEntry
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
books = (
|
||||
await session.execute(select(Lorebook).where(Lorebook.character_id == character_id))
|
||||
).scalars().all()
|
||||
assert len(books) == 1
|
||||
entries = (
|
||||
await session.execute(
|
||||
select(LorebookEntry).where(LorebookEntry.lorebook_id == books[0].id)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(entries) == 2
|
||||
by_key = {json.loads(e.keys_json)[0]: e for e in entries}
|
||||
assert by_key["图书馆"].content == "图书馆只在午后开放。"
|
||||
|
||||
|
||||
async def test_macro_expansion_and_preset_in_request(client: AsyncClient) -> None:
|
||||
# 火候:按当前用户写一行 GenerationPreferences
|
||||
from app.models.governance import GenerationPreferences
|
||||
|
||||
me = (await client.get("/api/v1/auth/me")).json()
|
||||
async with AsyncSessionLocal() as session:
|
||||
session.add(
|
||||
GenerationPreferences(
|
||||
owner_scope=me["id"],
|
||||
default_model_hint="chat",
|
||||
temperature=0.3,
|
||||
max_output_tokens=512,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# 建树(灵胚启镜后),问题含 {{user}} 符文;指引含 {{char}}
|
||||
embryo = (
|
||||
await client.post(
|
||||
"/api/v1/embryo-drafts", json={"source": "original", "name": "符文灵"}
|
||||
)
|
||||
).json()
|
||||
character_id = (
|
||||
(await client.post(f"/api/v1/embryo-drafts/{embryo['id']}/activate")).json()["character_id"]
|
||||
)
|
||||
tree = (
|
||||
await client.post(
|
||||
"/api/v1/trees", json={"character_id": character_id, "title": "符文树"}
|
||||
)
|
||||
).json()
|
||||
q = (
|
||||
await client.post(
|
||||
f"/api/v1/trees/{tree['id']}/nodes",
|
||||
json={"content": "{{user}} 想请教 {{char}} 一个问题"},
|
||||
)
|
||||
).json()
|
||||
|
||||
spawned = (
|
||||
await client.post(f"/api/v1/nodes/{q['id']}/answers", json={"instructions": "请以 {{char}} 的口吻回答"})
|
||||
).json()
|
||||
for _ in range(300):
|
||||
status = (await client.get(f"/api/v1/runs/{spawned['run_id']}")).json()["status"]
|
||||
if status in ("completed", "interrupted", "failed"):
|
||||
break
|
||||
await asyncio.sleep(0.02)
|
||||
assert status == "completed"
|
||||
|
||||
# llm.requested 事件:符文已展开、火候参数已进入 IR
|
||||
events = (await client.get(f"/api/v1/runs/{spawned['run_id']}/events")).json()
|
||||
requested = next(e for e in events if e["type"] == "llm.requested")
|
||||
ir = requested["payload"]["request"]
|
||||
prompt = ir["messages"][-1]["content"]
|
||||
assert "{{user}}" not in prompt and "{{char}}" not in prompt
|
||||
assert "符文灵" in prompt # {{char}} 展开为人物名
|
||||
assert ir["temperature"] == 0.3
|
||||
assert ir["max_tokens"] == 512
|
||||
|
||||
|
||||
async def test_import_v1_flat_card(client: AsyncClient) -> None:
|
||||
"""V1 扁平卡(无 data 包装)同样产灵胚。"""
|
||||
v1 = {
|
||||
"name": "旧式卡",
|
||||
"description": "一位老友。",
|
||||
"personality": "爽朗",
|
||||
"scenario": "巷口",
|
||||
"first_mes": "来了?",
|
||||
"mes_example": "",
|
||||
}
|
||||
files = {"file": ("card.json", json.dumps(v1).encode(), "application/json")}
|
||||
imported = await client.post("/api/v1/characters/import-card", files=files)
|
||||
assert imported.status_code == 201
|
||||
body = imported.json()
|
||||
assert body["name"] == "旧式卡"
|
||||
assert body["book_entry_count"] == 0
|
||||
@@ -1,206 +0,0 @@
|
||||
"""send_message 接入 run_agent_turn 的验证测试(阶段2b 步骤B)。
|
||||
|
||||
mock 边界:
|
||||
- ``app.application.chat.send_message.run_agent_turn`` —— 不真实跑 Agent 循环,
|
||||
只校验 send_message 是否正确接入(传 tools/default_tools、on_chunk、on_tool_event)。
|
||||
- ``app.application.chat.send_message.default_tools`` —— 校验被调用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.ai_provider.types.chat import ChatChunk
|
||||
from app.application.agent.runtime import AgentTurnResult
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.main import app
|
||||
from app.models.runtime import Event
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
# 清理前序(test_tools 等)残留的 app.state 避免污染
|
||||
|
||||
for _attr in ("tool_runtime", "confirmation_registry", "confirmation_store", "event_store"):
|
||||
app.state.__dict__.pop(_attr, None)
|
||||
|
||||
agent_content = "agent 最终回复正文。"
|
||||
|
||||
async def fake_run_agent_turn(
|
||||
*,
|
||||
on_chunk: Any,
|
||||
on_tool_event: Any,
|
||||
**_kwargs: object,
|
||||
) -> AgentTurnResult:
|
||||
# 模拟 Agent 内部流:先一段 reasoning(若 thinking 启用)、再 content chunk
|
||||
await on_chunk(ChatChunk(delta_reasoning="思考摘要"))
|
||||
await on_chunk(ChatChunk(delta_content=agent_content))
|
||||
# 模拟一次工具调用生命周期
|
||||
await on_tool_event("tool.started", {"tool_name": "echo", "tool_call_id": "c1"})
|
||||
await on_tool_event(
|
||||
"tool.completed", {"tool_name": "echo", "tool_call_id": "c1", "status": "success"}
|
||||
)
|
||||
return AgentTurnResult(content=agent_content, reasoning="思考摘要")
|
||||
|
||||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
async def _create_session(client: AsyncClient) -> str:
|
||||
resp = await client.post("/api/v1/chat/sessions", json={"title": "agent 接入"})
|
||||
assert resp.status_code == 200
|
||||
return str(resp.json()["session_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_invokes_run_agent_turn_with_tools_and_callbacks(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""send_message 调用 run_agent_turn 时传了 tools=default_tools()。"""
|
||||
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
captured_count: int = 0
|
||||
|
||||
real_default_tools = __import__(
|
||||
"app.application.chat.send_message", fromlist=["default_tools"]
|
||||
).default_tools
|
||||
|
||||
def spy_default_tools(*args: Any, **kwargs: Any) -> list[dict[str, Any]]:
|
||||
nonlocal captured_count
|
||||
captured_count += 1
|
||||
captured["kwargs"] = kwargs
|
||||
captured["tools"] = real_default_tools(*args, **kwargs)
|
||||
return captured["tools"]
|
||||
|
||||
monkeypatch.setattr("app.application.chat.send_message.default_tools", spy_default_tools)
|
||||
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
|
||||
session_id = await _create_session(client)
|
||||
sent = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text"},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
await drain_pending_streams()
|
||||
|
||||
# default_tools 被调用,至少包含 5 个核心工具
|
||||
assert captured_count > 0, f"spy_default_tools never called, captured={captured}"
|
||||
assert "tools" in captured
|
||||
tool_names = {t["function"]["name"] for t in captured["tools"]}
|
||||
assert tool_names >= {"datetime", "read_file", "list_dir", "write_file", "shell"}
|
||||
assert captured["tools"][0]["type"] == "function"
|
||||
names = {t["function"]["name"] for t in captured["tools"]}
|
||||
assert {"datetime", "shell"} <= names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_persists_agent_result_content_and_tool_events(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
"""message.completed 的 content == agent_result.content;tool.* 事件落库 source=agent。"""
|
||||
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
|
||||
session_id = await _create_session(client)
|
||||
sent = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "你好", "modality": "text", "thinking": True},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
run_id = str(sent.json()["run_id"])
|
||||
await drain_pending_streams()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
events = (await db.execute(select(Event).where(Event.run_id == run_id))).scalars().all()
|
||||
|
||||
by_type: dict[str, list[Event]] = {}
|
||||
for ev in events:
|
||||
by_type.setdefault(ev.type, []).append(ev)
|
||||
|
||||
# 关键事件齐全
|
||||
assert "run.started" in by_type
|
||||
assert "message.delta" in by_type # on_chunk 触发了 content flush
|
||||
assert "message.completed" in by_type
|
||||
assert "run.completed" in by_type
|
||||
|
||||
# tool 事件存在且 source=agent
|
||||
assert "tool.started" in by_type
|
||||
assert "tool.completed" in by_type
|
||||
assert all(ev.source == "agent" for ev in by_type["tool.started"])
|
||||
assert all(ev.source == "agent" for ev in by_type["tool.completed"])
|
||||
|
||||
# message.completed 的 content == agent_result.content(权威值)
|
||||
completed = by_type["message.completed"][-1]
|
||||
import json
|
||||
|
||||
payload = json.loads(completed.payload_json)
|
||||
assert payload["content"] == "agent 最终回复正文。"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_writes_extracted_session_goal_to_run(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
"""太一最小闭环:会话目标从用户消息提取写入 run.goal,取代默认值。"""
|
||||
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
from app.models.runtime import Run
|
||||
|
||||
session_id = await _create_session(client)
|
||||
sent = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "帮我整理今天的笔记", "modality": "text"},
|
||||
)
|
||||
assert sent.status_code == 200
|
||||
run_id = str(sent.json()["run_id"])
|
||||
await drain_pending_streams()
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
run = (await db.execute(select(Run).where(Run.id == run_id))).scalar_one()
|
||||
|
||||
assert run.goal == "帮我整理今天的笔记"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_message_injects_session_goal_into_system_messages(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""洛书最小闭环:会话目标作为 system instruction 注入 ReAct。"""
|
||||
|
||||
from app.application.chat.send_message import drain_pending_streams
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def capture_agent_turn(*, messages: Any, **_kwargs: object) -> AgentTurnResult:
|
||||
captured["messages"] = messages
|
||||
return AgentTurnResult(content="好的。", reasoning="")
|
||||
|
||||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", capture_agent_turn)
|
||||
|
||||
session_id = await _create_session(client)
|
||||
await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": "帮我整理笔记", "modality": "text"},
|
||||
)
|
||||
await drain_pending_streams()
|
||||
|
||||
# to_langchain_messages 把每个 system message 作为独立 SystemMessage,
|
||||
# 目标片段出现在某个 system message 中(非 messages[0])
|
||||
system_text = "\n".join(
|
||||
str(m.content) for m in captured["messages"] if getattr(m, "role", "") == "system"
|
||||
)
|
||||
assert "当前会话目标:帮我整理笔记" in system_text
|
||||
@@ -1,77 +0,0 @@
|
||||
"""SSE 契约测试。"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.ai_provider.types.chat import ChatChunk
|
||||
from app.application.agent.runtime import AgentTurnResult
|
||||
from app.core.config import settings
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(
|
||||
monkeypatch: pytest.MonkeyPatch, auth_headers: dict[str, str]
|
||||
) -> AsyncGenerator[AsyncClient, None]:
|
||||
_content = "你好,我是测试模型回复,会根据你的输入生成足够长的内容用于 SSE 分块验证。" * 4
|
||||
|
||||
async def fake_run_agent_turn(*, on_chunk, **_kwargs: object):
|
||||
await on_chunk(ChatChunk(delta_content=_content))
|
||||
return AgentTurnResult(content=_content, reasoning="")
|
||||
|
||||
monkeypatch.setattr("app.application.chat.send_message.run_agent_turn", fake_run_agent_turn)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(
|
||||
transport=transport, base_url="http://test", headers=auth_headers
|
||||
) as http_client:
|
||||
yield http_client
|
||||
|
||||
|
||||
async def _completed_run(client: AsyncClient, content: str = "你好") -> str:
|
||||
created = await client.post("/api/v1/chat/sessions", json={"title": "SSE"})
|
||||
session_id = created.json()["session_id"]
|
||||
sent = await client.post(
|
||||
f"/api/v1/chat/sessions/{session_id}/messages",
|
||||
json={"content": content, "modality": "text"},
|
||||
)
|
||||
return str(sent.json()["run_id"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_run_stream_outputs_events(client: AsyncClient) -> None:
|
||||
run_id = await _completed_run(client)
|
||||
response = await client.get(f"/api/v1/runs/{run_id}/stream")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
assert "event: run.started" in text
|
||||
assert "event: message.delta" in text
|
||||
assert "event: message.completed" in text
|
||||
assert "event: run.completed" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completed_run_stream_outputs_chunked_message_deltas(client: AsyncClient) -> None:
|
||||
run_id = await _completed_run(client, content="你好" * 80)
|
||||
response = await client.get(f"/api/v1/runs/{run_id}/stream")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
assert text.count("event: message.delta") >= 2
|
||||
assert "event: message.completed" in text
|
||||
assert "event: run.completed" in text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_not_found_and_disabled(
|
||||
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
not_found = await client.get("/api/v1/runs/run_missing/stream")
|
||||
assert not_found.status_code == 404
|
||||
assert not_found.json()["error"]["code"] == "not_found"
|
||||
|
||||
run_id = await _completed_run(client)
|
||||
monkeypatch.setattr(settings, "enable_sse", False)
|
||||
disabled = await client.get(f"/api/v1/runs/{run_id}/stream")
|
||||
assert disabled.status_code == 400
|
||||
assert disabled.json()["error"]["code"] == "invalid_request"
|
||||
Reference in new issue
Block a user