初始化提交
This commit is contained in:
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 == "模板二·生辰"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user