初始化提交

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

No files matched your search

@@ -0,0 +1,48 @@
"""司命台 time.* 事件展示测试(辰正 §8 司命台接入)。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.runtime import Event as EventRow
from app.repositories.json_utils import dumps
from app.services.admin import list_time_events
@pytest.mark.asyncio
async def test_list_time_events_filters_by_source():
"""list_time_events 只返回 source='time_sense' 的事件。"""
async with AsyncSessionLocal() as session:
time_ev = EventRow(
id="event_time1",
type="time.reunion",
source="time_sense",
payload_json=dumps({"offline_minutes": 7200}),
visibility="internal",
sensitivity="medium",
run_id="run_x",
character_id="char_x",
trace_id="t1",
sequence=1,
evidence_ids_json="[]",
)
other_ev = EventRow(
id="event_other1",
type="run.started",
source="model",
payload_json=dumps({}),
visibility="internal",
sensitivity="low",
run_id="run_x",
character_id="char_x",
trace_id="t2",
sequence=2,
evidence_ids_json="[]",
)
session.add_all([time_ev, other_ev])
await session.commit()
items, total = await list_time_events(limit=50, offset=0)
types = [e.type for e in items]
assert "time.reunion" in types
assert "run.started" not in types
assert total >= 1
@@ -0,0 +1,84 @@
"""认证服务单元测试。"""
from datetime import UTC
import pytest
from app.core.errors import AppError
from app.services.auth import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
verify_password,
)
class TestPasswordHashing:
def test_hash_and_verify(self):
plain = "test_password_123"
hashed = hash_password(plain)
assert hashed != plain
assert verify_password(plain, hashed) is True
def test_wrong_password(self):
hashed = hash_password("correct")
assert verify_password("wrong", hashed) is False
def test_same_password_different_hashes(self):
h1 = hash_password("password")
h2 = hash_password("password")
assert h1 != h2 # argon2 自动盐值
class TestJWT:
def test_create_and_decode_access_token(self, monkeypatch):
monkeypatch.setattr("app.core.config.settings.jwt_secret", "test-secret")
token = create_access_token(subject="user_test", extra={"owner_scope": "user_test"})
assert isinstance(token, str)
payload = decode_token(token)
assert payload["sub"] == "user_test"
assert payload["type"] == "access"
assert payload["owner_scope"] == "user_test"
def test_create_and_decode_refresh_token(self, monkeypatch):
monkeypatch.setattr("app.core.config.settings.jwt_secret", "test-secret")
token = create_refresh_token(subject="user_test")
payload = decode_token(token)
assert payload["sub"] == "user_test"
assert payload["type"] == "refresh"
def test_expired_token(self, monkeypatch):
from datetime import datetime, timedelta
from jose import jwt
secret = "test-secret"
monkeypatch.setattr("app.core.config.settings.jwt_secret", secret)
payload = {
"sub": "user_test",
"type": "access",
"iat": datetime.now(UTC) - timedelta(hours=2),
"exp": datetime.now(UTC) - timedelta(hours=1),
}
token = jwt.encode(payload, secret, algorithm="HS256")
with pytest.raises(AppError):
decode_token(token)
def test_wrong_signature(self, monkeypatch):
from datetime import datetime, timedelta
from jose import jwt
monkeypatch.setattr("app.core.config.settings.jwt_secret", "real-secret")
wrong_token = jwt.encode(
{
"sub": "test",
"type": "access",
"exp": datetime.now(UTC) + timedelta(hours=1),
},
"wrong-secret",
algorithm="HS256",
)
with pytest.raises(AppError):
decode_token(wrong_token)
@@ -0,0 +1,66 @@
"""角色卡导入服务测试(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"
@@ -0,0 +1,135 @@
"""人物模板配置加载与 Config→DB 同步测试。"""
from __future__ import annotations
import pytest
from app.core.config import settings
from app.db.session import AsyncSessionLocal
from app.repositories import characters as character_repo
from app.repositories.json_utils import loads_dict
from app.services.character_templates import get_default_template, load_character_templates
def test_load_templates_from_example():
""".example.yaml 加载成功且含 default 模板。"""
templates = load_character_templates()
assert "default" in templates
cfg = templates["default"]
assert cfg.key == "default"
assert cfg.name == "魂镜"
assert cfg.display_name == "魂镜"
assert cfg.language == "zh-CN"
assert cfg.privacy_level == "normal"
assert cfg.profile["background"] == "默认人物"
assert cfg.profile["speaking_style"] == "温和、清晰、简洁"
assert cfg.profile["temperament"] == "沉静而温暖"
assert isinstance(cfg.profile["hobbies"], list)
assert "哲学" in cfg.profile["hobbies"]
assert isinstance(cfg.profile["values"], list)
assert "真诚" in cfg.profile["values"]
assert cfg.model_policy["memory_extraction_model_hint"] == "memory-extraction"
assert cfg.model_policy["personality_analysis_model_hint"] == "personality-analysis"
assert cfg.model_policy["embedding_model_hint"] == "embedding"
def test_get_default_template():
"""取 default 模板配置成功。"""
cfg = get_default_template()
assert cfg.key == "default"
assert cfg.name == "魂镜"
@pytest.mark.asyncio
async def test_ensure_default_system_template_creates_from_config():
"""第一次调用 → 按 config 创建系统模板并写入 DB。"""
from app.services.characters import ensure_default_system_template
async with AsyncSessionLocal() as session:
existing = await character_repo.get_system_template_by_key(session, "default")
if existing:
await session.delete(existing)
await session.flush()
template = await ensure_default_system_template(session)
assert template is not None
assert template.name == "魂镜"
assert template.display_name == "魂镜"
assert template.language == "zh-CN"
assert template.privacy_level == "normal"
assert template.template_key == "default"
assert template.is_system_template is True
assert template.owner_scope == "system"
assert template.status == "active"
profile = loads_dict(template.profile_json)
assert profile.get("background") == "默认人物"
assert profile.get("temperament") == "沉静而温暖"
assert isinstance(profile.get("hobbies"), list)
assert isinstance(profile.get("values"), list)
assert isinstance(profile.get("birthday"), str) # _ensure_default_birthday 兜底
policy = loads_dict(template.model_policy_json)
assert policy.get("default_chat_model_hint") == settings.default_model_hint
assert policy.get("memory_extraction_model_hint") == "memory-extraction"
assert policy.get("personality_analysis_model_hint") == "personality-analysis"
assert policy.get("embedding_model_hint") == "embedding"
await session.rollback()
@pytest.mark.asyncio
async def test_ensure_default_system_template_updates_changed_fields():
"""已有模板时 config 字段变化 → 覆盖更新。"""
from app.services.characters import ensure_default_system_template
async with AsyncSessionLocal() as session:
template = await ensure_default_system_template(session)
await session.flush()
template_id = template.id
template.name = "旧名覆盖测试"
await session.flush()
template2 = await ensure_default_system_template(session)
assert template2.name == "魂镜" # 被 config 覆盖回来
assert template2.id == template_id # 同一条记录
await session.rollback()
@pytest.mark.asyncio
async def test_template_resync_no_spurious_updates():
"""config 与 DB 一致时同步不应触发无意义更新。"""
from app.services.characters import ensure_default_system_template
async with AsyncSessionLocal() as session:
template = await ensure_default_system_template(session)
await session.flush()
template2 = await ensure_default_system_template(session)
assert template2.id == template.id
await session.rollback()
@pytest.mark.asyncio
async def test_create_character_uses_new_defaults():
"""create_character 使用 ``_MODEL_POLICY_CREATE_DEFAULTS``。"""
from app.schemas.character import CreateCharacterRequest
from app.services.characters import create_character
async with AsyncSessionLocal() as session:
character = await create_character(
session,
CreateCharacterRequest(name="测试人物"),
)
assert character is not None
assert character.name == "测试人物"
assert character.status == "wuji"
# create_character 返回 CharacterResponse,model_policy 是 dict
assert "default_chat_model_hint" in character.model_policy
assert "memory_extraction_model_hint" in character.model_policy
await session.rollback()
@@ -0,0 +1,132 @@
"""人物服务测试:默认生日兜底(创建时未设 birthday → 用 created_at 兜底)。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.repositories import time_anniversaries as anni_repo
from app.repositories.json_utils import loads_dict
from app.schemas.character import CreateCharacterRequest
from app.services.characters import create_character, create_character_from_template
def _birthday_anniversaries(items: list) -> list:
return [a for a in items if a.kind == "birthday"]
@pytest.mark.asyncio
async def test_create_character_without_birthday_applies_default():
"""未传 birthday → profile.birthday 与纪念日均取 created_at 的 ISO 日期。"""
async with AsyncSessionLocal() as session:
resp = await create_character(session, CreateCharacterRequest(name="lingxi"))
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
character = await session.get(Character, resp.id)
birthday_iso = character.created_at.strftime("%Y-%m-%d")
assert resp.profile["birthday"] == birthday_iso
birthday_annivs = _birthday_anniversaries(anniversaries)
assert len(birthday_annivs) == 1
a = birthday_annivs[0]
assert a.date == birthday_iso
assert a.calendar == "solar"
assert a.recurs_yearly is True
assert a.kind == "birthday"
assert a.subject_type == "character_self"
assert a.source == "auto"
# display_name 为 None → 回落到 name
assert a.name == "lingxi·生辰"
@pytest.mark.asyncio
async def test_create_character_with_birthday_keeps_user_value():
"""用户传了 birthday(即便为自由文本)→ 保持用户值,且不自动建生日纪念日。"""
async with AsyncSessionLocal() as session:
resp = await create_character(
session,
CreateCharacterRequest(name="jingwu", profile={"birthday": "霜降之夜"}),
)
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
assert resp.profile["birthday"] == "霜降之夜"
assert _birthday_anniversaries(anniversaries) == []
@pytest.mark.asyncio
async def test_create_character_with_blank_birthday_applies_default():
"""birthday 为空白字符串视为未设 → 兜底。"""
async with AsyncSessionLocal() as session:
resp = await create_character(
session,
CreateCharacterRequest(name="chenyu", profile={"birthday": " "}),
)
anniversaries = await anni_repo.list_anniversaries(session, resp.id)
character = await session.get(Character, resp.id)
birthday_iso = character.created_at.strftime("%Y-%m-%d")
assert resp.profile["birthday"] == birthday_iso
assert len(_birthday_anniversaries(anniversaries)) == 1
@pytest.mark.asyncio
async def test_create_character_from_template_without_birthday_uses_own_created_at():
"""模板 profile 无 birthday → 子角色用自己的 created_at 兜底。"""
async with AsyncSessionLocal() as session:
template = Character(
name="tpl",
display_name="模板",
status="active",
language="zh-CN",
profile_json='{"background": "测试模板"}', # 无 birthday
model_policy_json="{}",
privacy_level="normal",
owner_scope="system",
is_system_template=True,
source_template_id=None,
template_key="test_tpl",
)
session.add(template)
await session.flush()
character = await create_character_from_template(session, template, "local-user")
profile = loads_dict(character.profile_json)
anniversaries = await anni_repo.list_anniversaries(session, character.id)
birthday_iso = character.created_at.strftime("%Y-%m-%d")
assert profile["birthday"] == birthday_iso
birthday_annivs = _birthday_anniversaries(anniversaries)
assert len(birthday_annivs) == 1
assert birthday_annivs[0].date == birthday_iso
# display_name 优先于 name
assert birthday_annivs[0].name == "模板·生辰"
@pytest.mark.asyncio
async def test_create_character_from_template_inherits_explicit_birthday():
"""模板 profile 有显式 birthday → 子角色继承该值,不兜底。"""
async with AsyncSessionLocal() as session:
template = Character(
name="tpl2",
display_name="模板二",
status="active",
language="zh-CN",
profile_json='{"birthday": "2020-01-01"}',
model_policy_json="{}",
privacy_level="normal",
owner_scope="system",
is_system_template=True,
source_template_id=None,
template_key="test_tpl2",
)
session.add(template)
await session.flush()
character = await create_character_from_template(session, template, "local-user")
profile = loads_dict(character.profile_json)
anniversaries = await anni_repo.list_anniversaries(session, character.id)
assert profile["birthday"] == "2020-01-01" # 继承模板的显式 birthday
birthday_annivs = _birthday_anniversaries(anniversaries)
assert len(birthday_annivs) == 1
assert birthday_annivs[0].date == "2020-01-01"
assert birthday_annivs[0].name == "模板二·生辰"
+123
View File
@@ -0,0 +1,123 @@
"""Dispatcher 单元测试:注册、glob 分发、异常隔离、生命周期。
单元测试直接驱动 Dispatcher(start 后手动塞 all_queue 验证 pump + consumer 行为),
不依赖 EventStore.append 的真实 notify 链路;端到端验证见 test_dispatcher_integration.py。
"""
from __future__ import annotations
import asyncio
import pytest
from app.modules.runtime.events import Event, InMemoryEventStore
from app.services.dispatcher import Dispatcher
async def test_register_before_start_stores_subscription() -> None:
"""register 在 start 前调用 → subscription 入表(pattern + handler 透传)。"""
dispatcher = Dispatcher(InMemoryEventStore())
async def handler(_event: Event) -> None:
pass
dispatcher.register("time.*", handler)
assert len(dispatcher._subscriptions) == 1
assert dispatcher._subscriptions[0].pattern == "time.*"
assert dispatcher._subscriptions[0].handler is handler
async def test_register_after_start_raises() -> None:
"""start 后 register 抛 RuntimeError(约束:handler 必须在 lifespan startup 前注册)。"""
dispatcher = Dispatcher(InMemoryEventStore())
async def handler(_event: Event) -> None:
pass
await dispatcher.start()
try:
with pytest.raises(RuntimeError, match="已启动"):
dispatcher.register("time.*", handler)
finally:
await dispatcher.stop()
async def test_pump_glob_match() -> None:
"""_pump 按 glob 分发:time.* subscription 收 time.reunion 不收 run.started;* subscription 全收。"""
dispatcher = Dispatcher(InMemoryEventStore())
received_time: list[Event] = []
received_all: list[Event] = []
async def time_handler(event: Event) -> None:
received_time.append(event)
async def all_handler(event: Event) -> None:
received_all.append(event)
dispatcher.register("time.*", time_handler)
dispatcher.register("*", all_handler)
await dispatcher.start()
try:
# 直接 put 到 all_queue(模拟 EventStore append 经 pattern notify 投递到 '*' 订阅)
assert dispatcher._all_queue is not None
dispatcher._all_queue.put_nowait(Event(type="time.reunion", source="time_sense"))
dispatcher._all_queue.put_nowait(Event(type="run.started", source="run"))
# 给 pump + consumer task 时间处理(asyncio 调度)
await asyncio.sleep(0.05)
assert [e.type for e in received_time] == ["time.reunion"]
assert [e.type for e in received_all] == ["time.reunion", "run.started"]
finally:
await dispatcher.stop()
async def test_consumer_isolates_handler_exception() -> None:
"""handler A raise → 被 try/except 吞(test 不崩)+ handler B 仍收后续事件(含触发异常的事件)。"""
dispatcher = Dispatcher(InMemoryEventStore())
received_b: list[Event] = []
async def handler_a(event: Event) -> None:
if event.type == "time.boom":
raise ValueError("handler A 故障")
async def handler_b(event: Event) -> None:
received_b.append(event)
dispatcher.register("time.*", handler_a)
dispatcher.register("time.*", handler_b)
await dispatcher.start()
try:
assert dispatcher._all_queue is not None
dispatcher._all_queue.put_nowait(Event(type="time.boom", source="test"))
dispatcher._all_queue.put_nowait(Event(type="time.reunion", source="test"))
await asyncio.sleep(0.05)
# handler A 对 time.boom raise(被 logger.exception 吞),handler B 两事件都收到
assert [e.type for e in received_b] == ["time.boom", "time.reunion"]
finally:
await dispatcher.stop()
async def test_start_stop_lifecycle() -> None:
"""start 后 pump + consumer task 存在 + all_queue 在 store 订阅表;stop 后 task cancelled + all_queue 从订阅表移除。"""
store = InMemoryEventStore()
dispatcher = Dispatcher(store)
async def handler(_event: Event) -> None:
pass
dispatcher.register("time.*", handler)
await dispatcher.start()
try:
assert dispatcher._pump_task is not None
assert not dispatcher._pump_task.cancelled()
assert len(dispatcher._consumer_tasks) == 1
assert dispatcher._all_queue is not None
assert dispatcher._all_queue in store._pattern_subscribers["*"]
finally:
all_queue = dispatcher._all_queue
await dispatcher.stop()
# stop 后状态清空
assert dispatcher._pump_task is None
assert dispatcher._consumer_tasks == []
assert dispatcher._all_queue is None
# all_queue 已从 store 的 pattern 订阅列表移除
assert all_queue not in store._pattern_subscribers.get("*", [])
@@ -0,0 +1,85 @@
"""Dispatcher 端到端集成测试:InMemoryEventStore + Dispatcher + fake handler 全链路。
验证 EventStore.append → pattern notify → Dispatcher all_queue → pump glob →
per-subscription queue → consumer handler 的完整链路(单元测试 test_dispatcher.py 不覆盖
EventStore 与 Dispatcher 的接合)。
"""
from __future__ import annotations
import asyncio
from app.modules.runtime.events import Event, InMemoryEventStore
from app.services.dispatcher import Dispatcher
async def test_end_to_end_inmemory_store_to_handler() -> None:
"""append time.reunion → handler 收到;append run.started → handler 不收到。"""
store = InMemoryEventStore()
dispatcher = Dispatcher(store)
received: list[Event] = []
async def handler(event: Event) -> None:
received.append(event)
dispatcher.register("time.*", handler)
await dispatcher.start()
try:
await store.append(Event(type="time.reunion", source="time_sense"))
await store.append(Event(type="run.started", source="run"))
await asyncio.sleep(0.05)
assert [e.type for e in received] == ["time.reunion"]
finally:
await dispatcher.stop()
async def test_end_to_end_multiple_handlers_same_event() -> None:
"""handler_A(time.*) + handler_B(time.reunion):time.reunion 两者都收;time.sentiment 仅 A 收。"""
store = InMemoryEventStore()
dispatcher = Dispatcher(store)
received_a: list[Event] = []
received_b: list[Event] = []
async def handler_a(event: Event) -> None:
received_a.append(event)
async def handler_b(event: Event) -> None:
received_b.append(event)
dispatcher.register("time.*", handler_a)
dispatcher.register("time.reunion", handler_b)
await dispatcher.start()
try:
await store.append(Event(type="time.reunion", source="time_sense"))
await store.append(Event(type="time.sentiment", source="time_sense"))
await asyncio.sleep(0.05)
assert sorted(e.type for e in received_a) == ["time.reunion", "time.sentiment"]
assert [e.type for e in received_b] == ["time.reunion"]
finally:
await dispatcher.stop()
async def test_handler_exception_does_not_block_dispatch() -> None:
"""handler raise 后,后续 append 的事件仍被其它 handler 收到(at-most-once 隔离)。"""
store = InMemoryEventStore()
dispatcher = Dispatcher(store)
received_b: list[Event] = []
async def handler_a(_event: Event) -> None:
raise RuntimeError("handler A 永远失败")
async def handler_b(event: Event) -> None:
received_b.append(event)
dispatcher.register("time.*", handler_a)
dispatcher.register("time.*", handler_b)
await dispatcher.start()
try:
await store.append(Event(type="time.reunion", source="time_sense"))
await asyncio.sleep(0.02)
await store.append(Event(type="time.sentiment", source="time_sense"))
await asyncio.sleep(0.05)
# handler_a 两次都 raise(被吞),handler_b 两次都收到
assert [e.type for e in received_b] == ["time.reunion", "time.sentiment"]
finally:
await dispatcher.stop()
@@ -0,0 +1,97 @@
"""内驱力审核服务测试(次生目标 approve/defer/reject)。"""
import pytest
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.drive import CuriosityQuestion, ExploratorySubgoal
from app.services import drive as drive_service
from app.services.characters import get_active_character_model
async def _seed_subgoal(session, character_id: str, status: str = "candidate") -> str:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
curios = CuriosityQuestion(
character_id=character_id,
cycle_id=cycle.id,
question="q",
source="memory_gap",
expected_value=0.5,
urgency=0.5,
risk_level="L2",
)
session.add(curios)
await session.flush()
sub = ExploratorySubgoal(
character_id=character_id,
cycle_id=cycle.id,
curiosity_question_id=curios.id,
title="询问项目目标",
description="主动询问",
expected_information_gain=0.6,
interruption_cost=0.3,
risk_level="L2",
status=status,
)
session.add(sub)
await session.flush()
return sub.id
@pytest.mark.asyncio
async def test_approve_candidate_subgoal() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed_subgoal(session, character.id)
await session.commit()
resp = await drive_service.approve_subgoal(sid)
assert resp.status == "approved"
assert resp.reviewed_by == "user"
@pytest.mark.asyncio
async def test_defer_candidate_subgoal() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed_subgoal(session, character.id)
await session.commit()
resp = await drive_service.defer_subgoal(sid)
assert resp.status == "deferred"
@pytest.mark.asyncio
async def test_reject_candidate_subgoal() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed_subgoal(session, character.id)
await session.commit()
resp = await drive_service.reject_subgoal(sid)
assert resp.status == "rejected"
@pytest.mark.asyncio
async def test_review_non_candidate_conflicts() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
sid = await _seed_subgoal(session, character.id, status="approved")
await session.commit()
with pytest.raises(AppError):
await drive_service.approve_subgoal(sid)
@pytest.mark.asyncio
async def test_approve_not_found() -> None:
with pytest.raises(AppError):
await drive_service.approve_subgoal("subgoal_missing")
@@ -0,0 +1,87 @@
"""灵核情绪状态机端到端集成测试: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
+110
View File
@@ -0,0 +1,110 @@
"""DbEventStore 集成测试:sqlite 建表 → 写入 → 检索,验证持久化与实时推送。"""
from __future__ import annotations
from collections.abc import AsyncIterator
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base
from app.modules.runtime.events import Event
from app.services.event_store import DbEventStore
@pytest.fixture
async def store() -> AsyncIterator[DbEventStore]:
"""内存 sqlite + 全表建表(FK 不强制,故无需 characters/runs 先验行)。"""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
yield DbEventStore(factory)
await engine.dispose()
async def test_append_assigns_monotonic_sequence(store: DbEventStore) -> None:
await store.append(Event(type="tool.started", source="tool", run_id="run_1"))
await store.append(Event(type="tool.completed", source="tool", run_id="run_1"))
events = await store.list_by_run("run_1")
assert [e.type for e in events] == ["tool.started", "tool.completed"]
assert [e.sequence for e in events] == [1, 2]
async def test_payload_and_evidence_ids_roundtrip(store: DbEventStore) -> None:
await store.append(
Event(
type="memory.candidate.created",
source="memory",
run_id="run_1",
character_id="char_1",
payload={"summary": "测试", "score": 0.8},
evidence_ids=["evidence_a", "evidence_b"],
sensitivity="medium",
)
)
events = await store.list_by_run("run_1")
assert events[0].payload == {"summary": "测试", "score": 0.8}
assert events[0].evidence_ids == ["evidence_a", "evidence_b"]
assert events[0].character_id == "char_1"
assert events[0].sensitivity == "medium"
async def test_run_without_run_id_persists(store: DbEventStore) -> None:
"""运行外事件(run_id=None)可持久化,不撞 NOT NULL / 唯一约束。"""
await store.append(Event(type="drive.need.detected", source="drive"))
# 用 run_id 查不到,但写入不应报错;这里仅验证无异常 + run 内事件仍正常
await store.append(Event(type="tool.started", source="tool", run_id="run_x"))
events = await store.list_by_run("run_x")
assert len(events) == 1
async def test_subscribe_receives_realtime(store: DbEventStore) -> None:
queue = store.subscribe("run_1")
await store.append(Event(type="tool.started", source="tool", run_id="run_1"))
await store.append(Event(type="tool.completed", source="tool", run_id="run_1"))
first = queue.get_nowait()
second = queue.get_nowait()
assert first.type == "tool.started"
assert second.type == "tool.completed"
store.unsubscribe("run_1", queue)
async def test_persistence_across_sessions(tmp_path) -> None:
"""重启后(新 engine/session)事件仍在——验证真正落盘。"""
db_path = tmp_path / "events.sqlite3"
url = f"sqlite+aiosqlite:///{db_path}"
engine = create_async_engine(url)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
store1 = DbEventStore(factory)
await store1.append(
Event(type="run.started", source="run", run_id="run_persist", character_id="char_1")
)
await engine.dispose()
# 新 engine 同一文件
engine2 = create_async_engine(url)
factory2 = async_sessionmaker(engine2, expire_on_commit=False)
store2 = DbEventStore(factory2)
events = await store2.list_by_run("run_persist")
await engine2.dispose()
assert len(events) == 1
assert events[0].type == "run.started"
assert events[0].character_id == "char_1"
async def test_subscribe_by_pattern_glob_match(store: DbEventStore) -> None:
"""DbEventStore 的 subscribe_by_pattern 与 InMemoryEventStore 语义一致。"""
queue = store.subscribe_by_pattern("time.*")
await store.append(Event(type="time.reunion", source="time_sense", run_id="run_1"))
await store.append(Event(type="run.started", source="run", run_id="run_1"))
received = queue.get_nowait()
assert received.type == "time.reunion"
assert queue.empty()
store.unsubscribe_by_pattern("time.*", queue)
@@ -0,0 +1,120 @@
"""演化候选派生 applier 测试。"""
import pytest
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.evolution import EvolutionCandidate
from app.models.memory import Memory
from app.repositories import evolution as evolution_repo
from app.repositories import personality as personality_repo
from app.repositories.json_utils import dumps
from app.services import evolution as evolution_service
from app.services.characters import get_active_character_model
from app.services.personality import create_default_personality
async def _setup(session) -> tuple:
character = await get_active_character_model(session)
if await personality_repo.get_latest_personality(session, character.id) is None:
await create_default_personality(session, character.id)
await session.flush()
cycle = BigCycle(
character_id=character.id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
return character, cycle
def _candidate(character_id: str, cycle_id: str, **overrides) -> EvolutionCandidate:
base = {
"character_id": character_id,
"cycle_id": cycle_id,
"type": "personality",
"source": "big_cycle",
"level": "L4",
"scope": "character_scope",
"title": "t",
"proposal": "p",
"delta_json": "{}",
"evidence_ids_json": "[]",
"confidence": 0.5,
"benefit_score": 0.5,
"risk_score": 0.5,
"drift_score": 0.0,
"auto_apply_allowed": False,
"status": "pending",
}
base.update(overrides)
return EvolutionCandidate(**base)
@pytest.mark.asyncio
async def test_apply_personality_candidate_derives_version() -> None:
async with AsyncSessionLocal() as session:
character, cycle = await _setup(session)
base_version = await personality_repo.get_latest_personality(session, character.id)
candidate = _candidate(
character.id,
cycle.id,
type="personality",
delta_json=dumps({"expression_style": "更简洁、更主动"}),
title="表达风格演化",
)
await evolution_repo.add_candidates(session, [candidate])
await session.flush()
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
await session.commit()
assert target_id is not None
async with AsyncSessionLocal() as session:
new_version = await personality_repo.get_personality(session, target_id)
character2 = await get_active_character_model(session)
assert new_version is not None
assert new_version.version == base_version.version + 1
assert new_version.created_by == "big_cycle"
assert "更简洁" in new_version.expression_style
assert character2.current_personality_version_id == target_id
@pytest.mark.asyncio
async def test_apply_procedural_candidate_creates_memory() -> None:
async with AsyncSessionLocal() as session:
character, cycle = await _setup(session)
candidate = _candidate(
character.id,
cycle.id,
type="procedural",
level="L2",
delta_json=dumps({"title": "先确认关键假设", "content": "技术问题先确认前提再作答"}),
)
await evolution_repo.add_candidates(session, [candidate])
await session.flush()
target_id = await evolution_service.apply_evolution_candidate(session, candidate)
await session.commit()
assert target_id is not None
async with AsyncSessionLocal() as session:
mem = await session.get(Memory, target_id)
assert mem is not None
assert mem.type == "procedural"
assert mem.title == "先确认关键假设"
assert mem.source == "big_cycle"
@pytest.mark.asyncio
async def test_apply_unimplemented_type_returns_none() -> None:
async with AsyncSessionLocal() as session:
character, cycle = await _setup(session)
candidate = _candidate(character.id, cycle.id, type="tool_strategy", level="L2")
await evolution_repo.add_candidates(session, [candidate])
await session.flush()
result = await evolution_service.apply_evolution_candidate(session, candidate)
assert result is None
@@ -0,0 +1,156 @@
"""大周天抽取候选审核服务测试(记忆候选 + 世界实体候选)。"""
import pytest
from sqlalchemy import select
from app.core.errors import AppError
from app.db.session import AsyncSessionLocal
from app.models.big_cycle import BigCycle
from app.models.candidates import MemoryCandidate
from app.models.memory import Memory
from app.models.world import CharacterWorldEntity
from app.services import extraction as extraction_service
from app.services.characters import get_active_character_model
async def _seed_cycle(session, character_id: str) -> str:
cycle = BigCycle(
character_id=character_id,
trigger="test",
status="completed",
memory_count_at_trigger=20,
)
session.add(cycle)
await session.flush()
return cycle.id
async def _seed_memory_candidate(
session, character_id: str, cycle_id: str, status: str = "pending"
) -> str:
cand = MemoryCandidate(
character_id=character_id,
cycle_id=cycle_id,
type="preference",
title="喜欢简洁",
content="用户偏好简洁直接的回答",
confidence=0.7,
importance=0.8,
sensitivity="low",
status=status,
)
session.add(cand)
await session.flush()
return cand.id
async def _seed_world_entity(session, character_id: str, status: str = "pending") -> str:
e = CharacterWorldEntity(
character_id=character_id,
type="project",
name="项目X",
state_type="inference",
confidence=0.6,
status=status,
)
session.add(e)
await session.flush()
return e.id
@pytest.mark.asyncio
async def test_approve_memory_candidate_derives_memory() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
cid = await _seed_memory_candidate(session, character.id, cycle_id)
await session.commit()
resp = await extraction_service.approve_memory_proposal(cid)
assert resp.status == "applied"
async with AsyncSessionLocal() as session:
mem = (
await session.execute(
select(Memory).where(
Memory.character_id == character.id,
Memory.title == "喜欢简洁",
)
)
).scalar_one_or_none()
assert mem is not None
assert mem.source == "big_cycle"
@pytest.mark.asyncio
async def test_reject_memory_candidate() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
cid = await _seed_memory_candidate(session, character.id, cycle_id)
await session.commit()
resp = await extraction_service.reject_memory_proposal(cid)
assert resp.status == "rejected"
@pytest.mark.asyncio
async def test_approve_non_pending_memory_conflicts() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
cid = await _seed_memory_candidate(session, character.id, cycle_id, status="applied")
await session.commit()
with pytest.raises(AppError):
await extraction_service.approve_memory_proposal(cid)
@pytest.mark.asyncio
async def test_approve_world_entity_confirms() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
eid = await _seed_world_entity(session, character.id)
await session.commit()
resp = await extraction_service.approve_world_entity(eid)
assert resp.status == "confirmed"
@pytest.mark.asyncio
async def test_reject_world_entity() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
eid = await _seed_world_entity(session, character.id)
await session.commit()
resp = await extraction_service.reject_world_entity(eid)
assert resp.status == "rejected"
@pytest.mark.asyncio
async def test_list_memory_proposals_source_cycle() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cycle_id = await _seed_cycle(session, character.id)
await _seed_memory_candidate(session, character.id, cycle_id)
# 导入来源候选(import_job_id 非空)
session.add(
MemoryCandidate(
character_id=character.id,
import_job_id="import_test",
type="user",
title="导入记忆",
content="来自导入",
confidence=0.5,
importance=0.5,
sensitivity="low",
status="pending",
)
)
await session.commit()
page = await extraction_service.list_memory_proposals(character_id=character.id, source="cycle")
assert page.total == 1
assert page.items[0].title == "喜欢简洁"
assert page.items[0].cycle_id == cycle_id
@@ -0,0 +1,126 @@
"""测试 governance service 中的 goal 事件写入与会话目标自动持久化。"""
import pytest
from app.core.errors import AppError
from app.models.governance import Goal
# ── auto_persist_session_goal 测试 ──
@pytest.mark.asyncio
async def test_auto_persist_session_goal_creates_new():
"""auto_persist_session_goal: 当没有活跃的 session 目标时,创建一个新的。"""
from app.db.session import AsyncSessionLocal
from app.services.goal_persist import auto_persist_session_goal
async with AsyncSessionLocal() as session:
goal = await auto_persist_session_goal(session, "char_c", "帮助用户解决编程问题")
assert goal is not None
assert goal.level == "session"
assert goal.character_id == "char_c"
assert "编程" in goal.title
@pytest.mark.asyncio
async def test_auto_persist_session_goal_reuses_active():
"""auto_persist_session_goal: 已有活跃 session 目标时,不创建新的。"""
from app.db.session import AsyncSessionLocal
from app.services.goal_persist import auto_persist_session_goal
async with AsyncSessionLocal() as session:
existing = Goal(
id="goal_session_existing",
character_id="char_d",
level="session",
status="active",
title="已有目标",
priority=50,
)
session.add(existing)
await session.flush()
goal = await auto_persist_session_goal(session, "char_d", "新对话内容要足够长才能通过验证")
assert goal is not None
assert goal.id == "goal_session_existing" # 复用了已有目标
@pytest.mark.asyncio
async def test_auto_persist_session_goal_short_content():
"""auto_persist_session_goal: 内容过短时返回 None。"""
from app.db.session import AsyncSessionLocal
from app.services.goal_persist import auto_persist_session_goal
async with AsyncSessionLocal() as session:
goal = await auto_persist_session_goal(session, "char_c", "你好")
assert goal is None
# ── governance service CRUD 测试(事件写入通过 emit_goal_event 静默跳过)──
@pytest.mark.asyncio
async def test_create_goal_success():
"""create_goal: 创建目标成功,返回 GoalNode。"""
from app.schemas.admin import CreateGoalRequest
from app.services.governance import create_goal
payload = CreateGoalRequest(
title="测试目标",
description="这是一个测试目标",
level="session",
priority=50,
)
result = await create_goal(payload)
assert result.title == "测试目标"
assert result.level == "session"
assert result.status == "pending"
@pytest.mark.asyncio
async def test_update_goal_success():
"""update_goal: 更新目标成功。"""
from app.schemas.admin import CreateGoalRequest, UpdateGoalRequest
from app.services.governance import create_goal, update_goal
payload = CreateGoalRequest(
title="待更新目标",
description="稍后更新",
level="task",
priority=50,
)
created = await create_goal(payload)
update = UpdateGoalRequest(title="已更新目标", status="active")
updated = await update_goal(created.id, update)
assert updated.title == "已更新目标"
assert updated.status == "active"
@pytest.mark.asyncio
async def test_update_goal_not_found():
"""update_goal: 目标不存在时抛出 AppError。"""
from app.schemas.admin import UpdateGoalRequest
from app.services.governance import update_goal
update = UpdateGoalRequest(title="不存在")
with pytest.raises(AppError) as exc:
await update_goal("goal_nonexistent", update)
assert "not_found" in str(exc.value)
@pytest.mark.asyncio
async def test_delete_goal_success():
"""delete_goal: 删除目标成功。"""
from app.schemas.admin import CreateGoalRequest
from app.services.governance import create_goal, delete_goal, list_goals
payload = CreateGoalRequest(
title="待删除目标",
description="稍后删除",
level="task",
priority=50,
)
created = await create_goal(payload)
await delete_goal(created.id)
all_goals = await list_goals()
assert not any(g.id == created.id for g in all_goals)
@@ -0,0 +1,112 @@
"""世谱注入编排测试(DB 集成)。"""
from __future__ import annotations
import json
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.lorebook import Lorebook, LorebookEntry
from app.services.lorebook import build_lorebook_injection
async def _make_character(session, owner_scope: str = "local-user") -> Character:
char = Character(name="tester", language="zh-CN", owner_scope=owner_scope)
session.add(char)
await session.flush()
return char
async def _add_entry(session, lorebook_id: str, keys: list[str], content: str, **kw) -> None:
session.add(
LorebookEntry(
lorebook_id=lorebook_id,
keys_json=json.dumps(keys),
content=content,
**kw,
)
)
await session.commit()
async def test_no_books_returns_none() -> None:
async with AsyncSessionLocal() as session:
before, after = await build_lorebook_injection(
session, "character_missing", "local-user", "任意文本"
)
assert before is None and after is None
async def test_keyword_match_injects_after_char() -> None:
async with AsyncSessionLocal() as session:
char = await _make_character(session)
book = Lorebook(character_id=char.id, owner_scope="local-user", scope="character")
session.add(book)
await session.flush()
await _add_entry(session, book.id, ["猫"], "她喜欢猫。", position="after_char")
before, after = await build_lorebook_injection(
session, char.id, "local-user", "今天看到一只猫"
)
assert before is None
assert after == "她喜欢猫。"
async def test_no_match_returns_none() -> None:
async with AsyncSessionLocal() as session:
char = await _make_character(session)
book = Lorebook(character_id=char.id, owner_scope="local-user")
session.add(book)
await session.flush()
await _add_entry(session, book.id, ["猫"], "她喜欢猫。")
before, after = await build_lorebook_injection(
session, char.id, "local-user", "无关文本"
)
assert before is None and after is None
async def test_before_position() -> None:
async with AsyncSessionLocal() as session:
char = await _make_character(session)
book = Lorebook(character_id=char.id, owner_scope="local-user")
session.add(book)
await session.flush()
await _add_entry(
session, book.id, ["魔法"], "世界观:存在魔法。", position="before_char"
)
before, after = await build_lorebook_injection(
session, char.id, "local-user", "使用魔法"
)
assert before == "世界观:存在魔法。"
assert after is None
async def test_global_scope_book_matched_for_any_character() -> None:
async with AsyncSessionLocal() as session:
char = await _make_character(session)
# global 书:character_id=None,所有人物共享
book = Lorebook(character_id=None, owner_scope="local-user", scope="global")
session.add(book)
await session.flush()
await _add_entry(session, book.id, ["龙"], "龙是传说生物。")
before, after = await build_lorebook_injection(
session, char.id, "local-user", "提到龙"
)
assert after == "龙是传说生物。"
async def test_disabled_entry_not_matched() -> None:
async with AsyncSessionLocal() as session:
char = await _make_character(session)
book = Lorebook(character_id=char.id, owner_scope="local-user")
session.add(book)
await session.flush()
await _add_entry(session, book.id, ["猫"], "x", enabled=False)
before, after = await build_lorebook_injection(
session, char.id, "local-user", "一只猫"
)
assert before is None and after is None
@@ -0,0 +1,226 @@
"""灵核情绪状态服务测试(设计 §11)。"""
from datetime import UTC, datetime, timedelta
import pytest
from app.db.session import AsyncSessionLocal
from app.models.character import Character
from app.models.chat import ChatMessage, ChatSession
from app.models.personality import PersonalityVersion
from app.models.personality_emotion import CharacterEmotionState
from app.modules.personality.emotion import EmotionState
from app.modules.runtime.events import Event
from app.services.characters import get_active_character_model
from app.services.personality_emotion import (
apply_time_event,
compute_current_state,
handle_time_event,
)
async def _seed_personality(
session, character_id: str, baseline_json: str = '{"mood": "neutral", "intensity": 0.2}'
) -> PersonalityVersion:
"""创建人格版本并绑定到 character.current_personality_version_id。"""
pv = PersonalityVersion(
character_id=character_id,
identity_profile="测试身份",
emotion_baseline=baseline_json,
)
session.add(pv)
await session.flush()
character = await session.get(Character, character_id)
assert character is not None
character.current_personality_version_id = pv.id
return pv
def _reunion_event(
character_id: str, intensity: float = 0.6, event_id: str = "event_test1"
) -> Event:
return Event(
id=event_id,
type="time.reunion",
source="time_sense",
payload={"reunion_intensity": intensity, "offline_band": "reunion_impact"},
character_id=character_id,
)
# ---- compute_current_state ----
@pytest.mark.asyncio
async def test_compute_no_state_returns_baseline() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid, '{"mood": "pleased", "intensity": 0.4}')
await session.commit()
async with AsyncSessionLocal() as session:
state = await compute_current_state(session, cid)
assert state == EmotionState(mood="pleased", intensity=0.4)
@pytest.mark.asyncio
async def test_compute_no_state_no_personality_returns_default() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await session.commit()
async with AsyncSessionLocal() as session:
state = await compute_current_state(session, cid)
assert state == EmotionState() # neutral 0.2
@pytest.mark.asyncio
async def test_compute_with_state_zero_turns_returns_state_unchanged() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid)
session.add(
CharacterEmotionState(
character_id=cid,
mood="touched",
intensity=0.8,
baseline_mood="neutral",
baseline_intensity=0.2,
)
)
await session.commit()
async with AsyncSessionLocal() as session:
state = await compute_current_state(session, cid)
# 无后续 user 回合 → 不 decay
assert state is not None
assert state.mood == "touched"
assert abs(state.intensity - 0.8) < 0.001
@pytest.mark.asyncio
async def test_compute_decays_after_user_messages() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid)
# 写入 touched 0.8 状态,updated_at 手动设为 1 小时前
old = datetime.now(UTC) - timedelta(hours=1)
row = CharacterEmotionState(
character_id=cid,
mood="touched",
intensity=0.8,
baseline_mood="neutral",
baseline_intensity=0.2,
)
session.add(row)
await session.flush()
# SQLite 读回 naive;写入时也用 naive 保持一致
row.updated_at = old.replace(tzinfo=None)
# 6 条 user 消息 = 半衰期回合数 → intensity 衰减一半
chat_session = ChatSession(character_id=cid)
session.add(chat_session)
await session.flush()
for i in range(6):
session.add(
ChatMessage(
character_id=cid,
session_id=chat_session.id,
role="user",
content=f"msg {i}",
)
)
await session.commit()
async with AsyncSessionLocal() as session:
state = await compute_current_state(session, cid)
# elapsed=6, factor=0.5 → 0.2 + (0.8-0.2)*0.5 = 0.5
assert state is not None
assert state.mood == "touched"
assert abs(state.intensity - 0.5) < 0.01
# ---- apply_time_event ----
@pytest.mark.asyncio
async def test_apply_reunion_initializes_from_baseline() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid, '{"mood": "neutral", "intensity": 0.2}')
await apply_time_event(_reunion_event(cid, intensity=0.6), session)
await session.commit()
async with AsyncSessionLocal() as session:
from app.repositories.personality_emotion import get_state
row = await get_state(session, cid)
assert row is not None
assert row.mood == "touched"
assert abs(row.intensity - 0.8) < 0.001 # 0.2 baseline + 0.6 delta
assert row.last_trigger_event_id == "event_test1"
character = await session.get(Character, cid)
assert character is not None
assert character.current_mood_id == "touched"
@pytest.mark.asyncio
async def test_apply_reunion_accumulates_when_same_mood() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid)
await apply_time_event(_reunion_event(cid, intensity=0.5), session)
await apply_time_event(_reunion_event(cid, intensity=0.3, event_id="event_test2"), session)
await session.commit()
async with AsyncSessionLocal() as session:
from app.repositories.personality_emotion import get_state
row = await get_state(session, cid)
assert row is not None
assert row.mood == "touched"
# 首次 0.2+0.5=0.7;同 mood 累加 0.7+0.3=1.0
assert abs(row.intensity - 1.0) < 0.001
assert row.last_trigger_event_id == "event_test2"
@pytest.mark.asyncio
async def test_apply_event_without_character_id_is_noop() -> None:
async with AsyncSessionLocal() as session:
await apply_time_event(
Event(type="time.reunion", source="time_sense", payload={}, character_id=None),
session,
)
await session.commit()
# 无异常即通过;无副作用可验证(无 character_id 无法写)
# ---- handle_time_event ----
@pytest.mark.asyncio
async def test_handle_time_event_commits_via_own_session() -> None:
async with AsyncSessionLocal() as session:
character = await get_active_character_model(session)
cid = character.id
await _seed_personality(session, cid)
await session.commit()
# handle_time_event 自管 session + 提交
await handle_time_event(_reunion_event(cid, intensity=0.6))
async with AsyncSessionLocal() as session:
from app.repositories.personality_emotion import get_state
row = await get_state(session, cid)
assert row is not None
assert row.mood == "touched"
@pytest.mark.asyncio
async def test_handle_time_event_swallows_exception() -> None:
"""降级原则:handler 异常不抛(不阻塞事件流)。"""
# character_id 指向不存在的角色 → _resolve_baseline 返回默认,apply 仍尝试写
# 但更直接:用一个会触发异常的事件(如 DB 约束)。这里验证无 character_id 不抛 + 任意异常被吞。
# 构造一个正常事件,handler 应正常完成不抛
await handle_time_event(
Event(type="time.reunion", source="time_sense", payload={}, character_id="char_nonexistent")
)
# 无抛出即通过
@@ -0,0 +1,75 @@
"""Skill 服务测试(CRUD + 关键词触发匹配)。"""
import pytest
from app.core.errors import AppError
from app.services import skills as skills_service
async def _create(
name: str = "coding",
trigger: str = "代码,编程",
status: str = "enabled",
) -> None:
await skills_service.create_skill(
name=name,
description="代码辅助",
trigger=trigger,
prompt_fragment="你是代码助手",
tool_bindings=["read_file"],
risk_level="L1",
status=status,
)
@pytest.mark.asyncio
async def test_create_and_list() -> None:
resp = await skills_service.create_skill(
name="coding",
trigger="代码",
prompt_fragment="你是代码助手",
tool_bindings=["read_file"],
)
assert resp.name == "coding"
assert resp.tool_bindings == ["read_file"]
listed = await skills_service.list_skills()
assert len(listed) == 1
@pytest.mark.asyncio
async def test_create_duplicate_name_conflicts() -> None:
await _create("dup")
with pytest.raises(AppError):
await _create("dup")
@pytest.mark.asyncio
async def test_update_skill_status() -> None:
await _create()
listed = await skills_service.list_skills()
sid = listed[0].id
updated = await skills_service.update_skill(sid, status="disabled")
assert updated.status == "disabled"
@pytest.mark.asyncio
async def test_match_active_skills() -> None:
await _create("coding", trigger="代码,编程")
await _create("weather", trigger="天气", status="disabled")
matched = await skills_service.match_active_skills("帮我写代码")
assert len(matched) == 1
assert matched[0].name == "coding"
assert matched[0].prompt_fragment == "你是代码助手"
@pytest.mark.asyncio
async def test_delete_skill() -> None:
await _create()
listed = await skills_service.list_skills()
sid = listed[0].id
await skills_service.delete_skill(sid)
assert await skills_service.get_skill(sid) is None
@@ -0,0 +1,112 @@
"""辰正集成测试:重逢流事件链 + 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}
@@ -0,0 +1,137 @@
"""辰正 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"