初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,96 @@
|
||||
"""大周天演化候选抽取纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.extract import (
|
||||
EvolutionCandidateDraft,
|
||||
parse_evolution_candidates,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_json_array() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","level":"L4","scope":"character_scope",'
|
||||
'"title":"调整身份表达","proposal":"更主动介绍自身能力边界",'
|
||||
'"delta":{"identity_profile":"新的身份描述"},"confidence":0.8,'
|
||||
'"benefit_score":0.7,"risk_score":0.3,"auto_apply_allowed":false,'
|
||||
'"evidence":[0,1]}]'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
d = drafts[0]
|
||||
assert d.type == "personality"
|
||||
assert d.level == "L4"
|
||||
assert d.scope == "character_scope"
|
||||
assert d.title == "调整身份表达"
|
||||
assert d.proposal == "更主动介绍自身能力边界"
|
||||
assert d.delta == {"identity_profile": "新的身份描述"}
|
||||
assert d.confidence == 0.8
|
||||
assert d.auto_apply_allowed is False
|
||||
assert d.source == "big_cycle"
|
||||
assert d.evidence == [0, 1]
|
||||
|
||||
|
||||
def test_parse_markdown_code_block() -> None:
|
||||
raw = (
|
||||
'```json\n[{"type":"procedural","level":"L2","scope":"task_scope",'
|
||||
'"title":"t","proposal":"p"}]\n```'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].type == "procedural"
|
||||
|
||||
|
||||
def test_parse_text_mixed_json() -> None:
|
||||
raw = (
|
||||
'候选如下:\n[{"type":"mood","level":"L1","scope":"character_scope",'
|
||||
'"title":"t","proposal":"p"}]\n以上。'
|
||||
)
|
||||
drafts = parse_evolution_candidates(raw)
|
||||
assert len(drafts) == 1
|
||||
|
||||
|
||||
def test_parse_invalid_json_returns_empty() -> None:
|
||||
assert parse_evolution_candidates("不是JSON") == []
|
||||
assert parse_evolution_candidates("[]") == []
|
||||
|
||||
|
||||
def test_skip_entries_missing_required_fields() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","title":"缺proposal"},'
|
||||
'{"title":"缺type","proposal":"p"},'
|
||||
'{"type":"x","proposal":"缺title"}]'
|
||||
)
|
||||
assert parse_evolution_candidates(raw) == []
|
||||
|
||||
|
||||
def test_defaults_use_safe_pessimistic_values() -> None:
|
||||
"""缺省时取最严格默认:level=L4(必须人工)、auto_apply_allowed=False。"""
|
||||
raw = '[{"type":"personality","title":"t","proposal":"p"}]'
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.level == "L4"
|
||||
assert d.scope == "character_scope"
|
||||
assert d.auto_apply_allowed is False
|
||||
assert d.confidence == 0.5
|
||||
assert d.benefit_score == 0.5
|
||||
assert d.risk_score == 0.5
|
||||
assert d.drift_score == 0.0
|
||||
assert d.delta == {}
|
||||
|
||||
|
||||
def test_clamp_and_default_scores() -> None:
|
||||
raw = (
|
||||
'[{"type":"personality","title":"t","proposal":"p",'
|
||||
'"confidence":1.5,"benefit_score":-0.2,"risk_score":"high"}]'
|
||||
)
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.confidence == 1.0
|
||||
assert d.benefit_score == 0.0
|
||||
assert d.risk_score == 0.5 # 非法值 → 默认
|
||||
|
||||
|
||||
def test_auto_apply_allowed_truthy() -> None:
|
||||
raw = (
|
||||
'[{"type":"procedural","level":"L2","title":"t","proposal":"p","auto_apply_allowed":true}]'
|
||||
)
|
||||
d = parse_evolution_candidates(raw)[0]
|
||||
assert d.auto_apply_allowed is True
|
||||
assert isinstance(d, EvolutionCandidateDraft)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""大周天演化候选 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import (
|
||||
EvaluationSnapshot,
|
||||
build_evolution_extraction_messages,
|
||||
)
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=25,
|
||||
recent_memories=[("喜欢简洁回答", "用户偏好简洁"), ("来自北京", "用户所在地")],
|
||||
personality_summary="身份:魂镜;表达风格:温和",
|
||||
world_entity_count=3,
|
||||
conclusion="近期互动偏技术话题,表达可更简洁",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_evolution_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_contract_fields() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
for token in (
|
||||
"type",
|
||||
"level",
|
||||
"scope",
|
||||
"proposal",
|
||||
"delta",
|
||||
"auto_apply_allowed",
|
||||
):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_system_lists_evolution_types() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
for t in ("personality", "procedural", "tool_strategy", "memory"):
|
||||
assert t in system
|
||||
|
||||
|
||||
def test_system_states_level_semantics() -> None:
|
||||
system, _ = build_evolution_extraction_messages(_snapshot())
|
||||
assert "L4" in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_evolution_extraction_messages(_snapshot())
|
||||
assert "喜欢简洁回答" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "25" in user
|
||||
|
||||
|
||||
def test_user_handles_empty_personality() -> None:
|
||||
snap = _snapshot()
|
||||
snap.personality_summary = ""
|
||||
_, user = build_evolution_extraction_messages(snap)
|
||||
assert isinstance(user, str)
|
||||
assert len(user) > 0
|
||||
@@ -0,0 +1,95 @@
|
||||
"""大周天内驱力抽取纯函数测试。"""
|
||||
|
||||
from app.modules.drive.extract import (
|
||||
CuriosityDraft,
|
||||
SubgoalDraft,
|
||||
parse_curiosities,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_with_subgoals() -> None:
|
||||
raw = (
|
||||
'[{"question":"用户为何频繁提到项目X","source":"memory_gap",'
|
||||
'"expected_value":0.7,"urgency":0.5,"risk_level":"L2",'
|
||||
'"related_entities":["项目X"],'
|
||||
'"subgoals":[{"title":"询问项目X目标","description":"主动询问用户",'
|
||||
'"expected_information_gain":0.6,"interruption_cost":0.3,"risk_level":"L2"}]}]'
|
||||
)
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
c = drafts[0]
|
||||
assert c.question == "用户为何频繁提到项目X"
|
||||
assert c.source == "memory_gap"
|
||||
assert c.expected_value == 0.7
|
||||
assert c.risk_level == "L2"
|
||||
assert c.related_entities == ["项目X"]
|
||||
assert len(c.subgoals) == 1
|
||||
sg = c.subgoals[0]
|
||||
assert sg.title == "询问项目X目标"
|
||||
assert sg.expected_information_gain == 0.6
|
||||
assert sg.interruption_cost == 0.3
|
||||
|
||||
|
||||
def test_parse_markdown_block() -> None:
|
||||
raw = '```json\n[{"question":"q"}]\n```'
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].question == "q"
|
||||
|
||||
|
||||
def test_parse_invalid_returns_empty() -> None:
|
||||
assert parse_curiosities("不是JSON") == []
|
||||
assert parse_curiosities("[]") == []
|
||||
|
||||
|
||||
def test_skip_missing_question() -> None:
|
||||
raw = '[{"source":"memory_gap"},{"question":"q"}]'
|
||||
drafts = parse_curiosities(raw)
|
||||
assert len(drafts) == 1
|
||||
assert drafts[0].question == "q"
|
||||
|
||||
|
||||
def test_defaults_use_safe_pessimistic_values() -> None:
|
||||
"""缺省取最严格默认:risk_level=L4、source=reflection。"""
|
||||
raw = '[{"question":"q"}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert c.source == "reflection"
|
||||
assert c.risk_level == "L4"
|
||||
assert c.expected_value == 0.5
|
||||
assert c.urgency == 0.5
|
||||
assert c.related_entities == []
|
||||
assert c.subgoals == []
|
||||
|
||||
|
||||
def test_clamp_values() -> None:
|
||||
raw = '[{"question":"q","expected_value":1.5,"urgency":-0.2}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert c.expected_value == 1.0
|
||||
assert c.urgency == 0.0
|
||||
|
||||
|
||||
def test_invalid_source_defaults() -> None:
|
||||
raw = '[{"question":"q","source":"unknown_source"}]'
|
||||
assert parse_curiosities(raw)[0].source == "reflection"
|
||||
|
||||
|
||||
def test_invalid_risk_level_defaults() -> None:
|
||||
raw = '[{"question":"q","risk_level":"L9"}]'
|
||||
assert parse_curiosities(raw)[0].risk_level == "L4"
|
||||
|
||||
|
||||
def test_subgoal_defaults_and_types() -> None:
|
||||
raw = '[{"question":"q","subgoals":[{"title":"t","description":"d"}]}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert isinstance(c, CuriosityDraft)
|
||||
sg = c.subgoals[0]
|
||||
assert isinstance(sg, SubgoalDraft)
|
||||
assert sg.risk_level == "L4" # 安全默认
|
||||
assert sg.expected_information_gain == 0.5
|
||||
assert sg.interruption_cost == 0.5
|
||||
|
||||
|
||||
def test_skip_subgoal_missing_title_or_description() -> None:
|
||||
raw = '[{"question":"q","subgoals":[{"title":"t"},{"description":"d"},{"title":"t","description":"d"}]}]'
|
||||
c = parse_curiosities(raw)[0]
|
||||
assert len(c.subgoals) == 1 # 只有完整的保留
|
||||
@@ -0,0 +1,47 @@
|
||||
"""大周天内驱力 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
from app.modules.drive.prompts import build_drive_extraction_messages
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=15,
|
||||
recent_memories=[("用户偏好简洁", "用户偏好简洁直接的回答")],
|
||||
personality_summary="身份:魂镜",
|
||||
world_entity_count=4,
|
||||
conclusion="近期对话偏技术,存在项目背景缺口",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_drive_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_contract_fields() -> None:
|
||||
system, _ = build_drive_extraction_messages(_snapshot())
|
||||
for token in (
|
||||
"question",
|
||||
"source",
|
||||
"expected_value",
|
||||
"urgency",
|
||||
"risk_level",
|
||||
"subgoals",
|
||||
):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_system_lists_sources() -> None:
|
||||
system, _ = build_drive_extraction_messages(_snapshot())
|
||||
for src in ("world_uncertainty", "memory_gap", "reflection", "goal_tension"):
|
||||
assert src in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_drive_extraction_messages(_snapshot())
|
||||
assert "用户偏好简洁" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "15" in user
|
||||
@@ -0,0 +1,110 @@
|
||||
"""大周天记忆/世界抽取纯函数测试。"""
|
||||
|
||||
from app.modules.extraction.extract import (
|
||||
MemoryCandidateDraft,
|
||||
WorldEntityDraft,
|
||||
parse_extraction,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_standard_object() -> None:
|
||||
raw = (
|
||||
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
|
||||
'"importance":0.8,"sensitivity":"low","confidence":0.7,"reason":"多次提及"}],'
|
||||
'"entities":[{"type":"project","name":"项目X","attributes":{"status":"active"},'
|
||||
'"state_type":"inference","confidence":0.6}]}'
|
||||
)
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert len(memories) == 1
|
||||
assert memories[0].title == "喜欢简洁"
|
||||
assert memories[0].type == "preference"
|
||||
assert memories[0].importance == 0.8
|
||||
assert len(entities) == 1
|
||||
assert entities[0].name == "项目X"
|
||||
assert entities[0].type == "project"
|
||||
assert entities[0].attributes == {"status": "active"}
|
||||
assert entities[0].state_type == "inference"
|
||||
|
||||
|
||||
def test_parse_markdown_block() -> None:
|
||||
raw = '```json\n{"memories":[],"entities":[]}\n```'
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert memories == []
|
||||
assert entities == []
|
||||
|
||||
|
||||
def test_parse_text_mixed() -> None:
|
||||
raw = '抽取结果:\n{"memories":[{"title":"t","content":"c"}],"entities":[]}\n以上'
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert len(memories) == 1
|
||||
assert entities == []
|
||||
|
||||
|
||||
def test_parse_invalid_returns_empty() -> None:
|
||||
assert parse_extraction("不是JSON") == ([], [])
|
||||
assert parse_extraction("[]") == ([], []) # 数组不是对象
|
||||
|
||||
|
||||
def test_missing_keys_returns_empty_lists() -> None:
|
||||
memories, entities = parse_extraction('{"memories":[]}')
|
||||
assert memories == []
|
||||
assert entities == []
|
||||
|
||||
|
||||
def test_skip_memory_missing_title_or_content() -> None:
|
||||
raw = '{"memories":[{"title":"t"},{"content":"c"},{"title":"t","content":"c"}]}'
|
||||
memories, _ = parse_extraction(raw)
|
||||
assert len(memories) == 1
|
||||
|
||||
|
||||
def test_skip_entity_missing_name() -> None:
|
||||
raw = '{"entities":[{"type":"project"},{"name":"有名字"}]}'
|
||||
_, entities = parse_extraction(raw)
|
||||
assert len(entities) == 1
|
||||
assert entities[0].name == "有名字"
|
||||
|
||||
|
||||
def test_memory_safe_defaults() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c"}]}'
|
||||
m = parse_extraction(raw)[0][0]
|
||||
assert m.sensitivity == "low"
|
||||
assert m.type == "other"
|
||||
assert m.confidence == 0.5
|
||||
assert m.importance == 0.5
|
||||
|
||||
|
||||
def test_entity_safe_defaults() -> None:
|
||||
raw = '{"entities":[{"name":"n"}]}'
|
||||
e = parse_extraction(raw)[1][0]
|
||||
assert e.state_type == "inference" # 非 fact,降权
|
||||
assert e.type == "concept"
|
||||
assert e.confidence == 0.5
|
||||
assert e.attributes == {}
|
||||
|
||||
|
||||
def test_clamp_values() -> None:
|
||||
raw = (
|
||||
'{"memories":[{"title":"t","content":"c","importance":1.5,"confidence":-0.2}],'
|
||||
'"entities":[{"name":"n","confidence":2.0}]}'
|
||||
)
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert memories[0].importance == 1.0
|
||||
assert memories[0].confidence == 0.0
|
||||
assert entities[0].confidence == 1.0
|
||||
|
||||
|
||||
def test_invalid_sensitivity_defaults() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c","sensitivity":"unknown"}]}'
|
||||
assert parse_extraction(raw)[0][0].sensitivity == "low"
|
||||
|
||||
|
||||
def test_invalid_state_type_defaults() -> None:
|
||||
raw = '{"entities":[{"name":"n","state_type":"certain"}]}'
|
||||
assert parse_extraction(raw)[1][0].state_type == "inference"
|
||||
|
||||
|
||||
def test_draft_types() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c"}],"entities":[{"name":"n"}]}'
|
||||
memories, entities = parse_extraction(raw)
|
||||
assert isinstance(memories[0], MemoryCandidateDraft)
|
||||
assert isinstance(entities[0], WorldEntityDraft)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""大周天记忆/世界抽取 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.big_cycle.prompts import EvaluationSnapshot
|
||||
from app.modules.extraction.prompts import build_extraction_messages
|
||||
|
||||
|
||||
def _snapshot() -> EvaluationSnapshot:
|
||||
return EvaluationSnapshot(
|
||||
character_id="character_x",
|
||||
memory_count=15,
|
||||
recent_memories=[("用户偏好简洁", "用户偏好简洁直接的回答")],
|
||||
personality_summary="身份:魂镜",
|
||||
world_entity_count=4,
|
||||
conclusion="近期对话涉及项目X,存在背景缺口",
|
||||
)
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_extraction_messages(_snapshot())
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_memories_and_entities_structure() -> None:
|
||||
system, _ = build_extraction_messages(_snapshot())
|
||||
for token in ("memories", "entities", "state_type", "sensitivity", "importance"):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_user_includes_snapshot_signals() -> None:
|
||||
_, user = build_extraction_messages(_snapshot())
|
||||
assert "用户偏好简洁" in user
|
||||
assert "身份:魂镜" in user
|
||||
assert "15" in user
|
||||
@@ -0,0 +1,35 @@
|
||||
"""太一会话目标提取纯函数测试。"""
|
||||
|
||||
from app.modules.goal_orchestrator.extract import (
|
||||
extract_session_goal,
|
||||
format_goal_directive,
|
||||
)
|
||||
|
||||
|
||||
def test_extract_returns_stripped_content() -> None:
|
||||
assert extract_session_goal(" 帮我整理今天的笔记 ") == "帮我整理今天的笔记"
|
||||
|
||||
|
||||
def test_extract_empty_returns_empty_string() -> None:
|
||||
assert extract_session_goal("") == ""
|
||||
assert extract_session_goal(" ") == ""
|
||||
|
||||
|
||||
def test_extract_short_message_unchanged() -> None:
|
||||
assert extract_session_goal("今天天气如何?") == "今天天气如何?"
|
||||
|
||||
|
||||
def test_extract_truncates_over_limit() -> None:
|
||||
# 目标摘要上限 120 字(run.goal 字段容量 300,留余量);超出截断
|
||||
long = "目标描述" * 40 # 160 字
|
||||
goal = extract_session_goal(long)
|
||||
assert len(goal) == 120
|
||||
assert goal == "目标描述" * 30 # 120 字 = 30 个「目标描述」
|
||||
|
||||
|
||||
def test_format_goal_directive_with_goal() -> None:
|
||||
assert format_goal_directive("整理笔记") == "当前会话目标:整理笔记"
|
||||
|
||||
|
||||
def test_format_goal_directive_empty_returns_empty() -> None:
|
||||
assert format_goal_directive("") == ""
|
||||
Whitespace-only changes.
@@ -0,0 +1,104 @@
|
||||
"""角色卡解析器单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import struct
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
from app.modules.imports.card_parser import parse_character_card
|
||||
|
||||
|
||||
def _make_png_text(keyword: str, text: str) -> bytes:
|
||||
"""构造含一个 tEXt chunk 的最小 PNG。"""
|
||||
|
||||
def chunk(ctype: bytes, data: bytes) -> bytes:
|
||||
body = ctype + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
payload = keyword.encode("latin-1") + b"\x00" + text.encode("latin-1")
|
||||
return b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload) + chunk(b"IEND", b"")
|
||||
|
||||
|
||||
def test_parse_v2_json() -> None:
|
||||
card = {"spec": "chara_card_v2", "spec_version": "2.0", "data": {"name": "狂三", "description": "desc", "personality": "腹黑"}}
|
||||
parsed = parse_character_card(json.dumps(card).encode("utf-8"))
|
||||
assert parsed.name == "狂三"
|
||||
assert parsed.personality == "腹黑"
|
||||
assert parsed.spec_version == "2.0"
|
||||
|
||||
|
||||
def test_parse_v1_flat_normalized() -> None:
|
||||
card = {"name": "V1角色", "description": "d", "personality": "p"}
|
||||
parsed = parse_character_card(json.dumps(card).encode("utf-8"))
|
||||
assert parsed.name == "V1角色"
|
||||
assert parsed.spec_version == "1"
|
||||
|
||||
|
||||
def test_parse_png_chara_chunk() -> None:
|
||||
card = {"spec": "chara_card_v2", "spec_version": "2.0", "data": {"name": "PNG卡"}}
|
||||
text = base64.b64encode(json.dumps(card).encode("utf-8")).decode("ascii")
|
||||
parsed = parse_character_card(_make_png_text("chara", text))
|
||||
assert parsed.name == "PNG卡"
|
||||
|
||||
|
||||
def test_parse_png_ccv3_preferred_over_chara() -> None:
|
||||
# 同时含 chara(V2) 和 ccv3(V3),优先 ccv3
|
||||
card_v3 = {"spec_version": "3.0", "data": {"name": "V3卡", "nickname": "三三"}}
|
||||
|
||||
def chunk(ctype: bytes, data: bytes) -> bytes:
|
||||
body = ctype + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
v3_text = base64.b64encode(json.dumps(card_v3).encode()).decode()
|
||||
payload_chara = b"chara\x00" + base64.b64encode(json.dumps({"data": {"name": "旧"}}).encode())
|
||||
payload_ccv3 = b"ccv3\x00" + v3_text.encode("latin-1")
|
||||
png = b"\x89PNG\r\n\x1a\n" + chunk(b"tEXt", payload_chara) + chunk(b"tEXt", payload_ccv3) + chunk(b"IEND", b"")
|
||||
parsed = parse_character_card(png)
|
||||
assert parsed.name == "V3卡"
|
||||
assert parsed.nickname == "三三"
|
||||
|
||||
|
||||
def test_parse_charx_zip() -> None:
|
||||
card = {"spec_version": "3.0", "data": {"name": "CHARX卡"}}
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr("card.json", json.dumps(card))
|
||||
parsed = parse_character_card(buf.getvalue())
|
||||
assert parsed.name == "CHARX卡"
|
||||
|
||||
|
||||
def test_character_book_preserved() -> None:
|
||||
card = {
|
||||
"spec": "chara_card_v2",
|
||||
"spec_version": "2.0",
|
||||
"data": {
|
||||
"name": "x",
|
||||
"character_book": {"entries": [{"keys": ["a"], "content": "b"}]},
|
||||
},
|
||||
}
|
||||
parsed = parse_character_card(json.dumps(card).encode())
|
||||
assert parsed.character_book is not None
|
||||
assert parsed.character_book["entries"][0]["keys"] == ["a"]
|
||||
|
||||
|
||||
def test_alternate_greetings_and_tags() -> None:
|
||||
card = {"spec_version": "2.0", "data": {"name": "x", "alternate_greetings": ["hi", "yo"], "tags": ["anime"]}}
|
||||
parsed = parse_character_card(json.dumps(card).encode())
|
||||
assert parsed.alternate_greetings == ["hi", "yo"]
|
||||
assert parsed.tags == ["anime"]
|
||||
|
||||
|
||||
def test_png_without_metadata_raises() -> None:
|
||||
import pytest
|
||||
|
||||
def chunk(ctype: bytes, data: bytes) -> bytes:
|
||||
body = ctype + data
|
||||
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF)
|
||||
|
||||
png = b"\x89PNG\r\n\x1a\n" + chunk(b"IEND", b"")
|
||||
with pytest.raises(ValueError):
|
||||
parse_character_card(png)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""角色卡导出映射单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.character import Character
|
||||
from app.models.lorebook import Lorebook, LorebookEntry
|
||||
from app.modules.imports.export_mapping import character_to_card, lorebook_to_st_book
|
||||
from app.repositories.json_utils import dumps
|
||||
|
||||
|
||||
def _character(**kw: object) -> Character:
|
||||
base: dict[str, object] = {
|
||||
"name": "狂三",
|
||||
"profile_json": "{}",
|
||||
}
|
||||
base.update(kw)
|
||||
return Character(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_character_to_card_basic() -> None:
|
||||
char = _character(
|
||||
display_name="三三",
|
||||
personality_summary="腹黑",
|
||||
profile_json=dumps({"background": "desc", "scenario": "sch", "tags": ["a"],
|
||||
"source_meta": {"creator": "X", "character_version": "1"}}),
|
||||
)
|
||||
card = character_to_card(char)
|
||||
assert card["spec"] == "chara_card_v3"
|
||||
assert card["spec_version"] == "3.0"
|
||||
data = card["data"]
|
||||
assert data["name"] == "狂三"
|
||||
assert data["nickname"] == "三三"
|
||||
assert data["personality"] == "腹黑"
|
||||
assert data["description"] == "desc"
|
||||
assert data["creator"] == "X"
|
||||
assert data["character_version"] == "1"
|
||||
|
||||
|
||||
def test_no_nickname_when_same_as_name() -> None:
|
||||
char = _character(name="X", display_name="X")
|
||||
assert "nickname" not in character_to_card(char)["data"]
|
||||
|
||||
|
||||
def test_personality_from_daoti_snapshot() -> None:
|
||||
# 道体 personality_summary 是导出的 personality 来源(演化后的当前快照)
|
||||
char = _character(personality_summary="演化后的人格")
|
||||
assert character_to_card(char)["data"]["personality"] == "演化后的人格"
|
||||
|
||||
|
||||
def test_lorebook_exported_to_st_book() -> None:
|
||||
book = Lorebook(name="谱", scan_depth=4, recursive_scanning=True)
|
||||
entries = [
|
||||
LorebookEntry(keys_json='["猫"]', content="喜欢猫", constant=True, position="after_char"),
|
||||
]
|
||||
st_book = lorebook_to_st_book(book, entries)
|
||||
assert st_book["name"] == "谱"
|
||||
assert st_book["scan_depth"] == 4
|
||||
assert st_book["recursive_scanning"] is True
|
||||
assert st_book["entries"][0]["keys"] == ["猫"]
|
||||
assert st_book["entries"][0]["constant"] is True
|
||||
|
||||
|
||||
def test_no_character_book_when_lorebook_none() -> None:
|
||||
char = _character()
|
||||
assert "character_book" not in character_to_card(char)["data"]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""角色卡字段映射单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.imports.card_parser import CharacterCard
|
||||
from app.modules.imports.mapping import (
|
||||
card_to_daoti,
|
||||
card_to_profile,
|
||||
character_book_to_entries,
|
||||
)
|
||||
|
||||
|
||||
def _card(**kw: object) -> CharacterCard:
|
||||
base: dict[str, object] = {"spec_version": "2.0", "name": "狂三"}
|
||||
base.update(kw)
|
||||
return CharacterCard(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_profile_maps_core_fields() -> None:
|
||||
card = _card(
|
||||
description="desc",
|
||||
scenario="sch",
|
||||
first_mes="hi",
|
||||
tags=["a"],
|
||||
creator="X",
|
||||
character_version="1",
|
||||
)
|
||||
profile = card_to_profile(card)
|
||||
assert profile["background"] == "desc"
|
||||
assert profile["scenario"] == "sch"
|
||||
assert profile["first_mes"] == "hi"
|
||||
assert profile["tags"] == ["a"]
|
||||
assert profile["source_meta"]["creator"] == "X"
|
||||
assert profile["source_meta"]["spec_version"] == "2.0"
|
||||
|
||||
|
||||
def test_profile_preserves_system_prompt_and_extensions() -> None:
|
||||
card = _card(system_prompt="SP", extensions={"foo": {"bar": 1}})
|
||||
profile = card_to_profile(card)
|
||||
assert profile["system_prompt"] == "SP"
|
||||
assert profile["st_extensions"] == {"foo": {"bar": 1}}
|
||||
|
||||
|
||||
def test_daoti_personality_as_initial_summary() -> None:
|
||||
assert card_to_daoti(_card(personality="腹黑优雅"))["personality_summary"] == "腹黑优雅"
|
||||
|
||||
|
||||
def test_daoti_empty_personality_is_none() -> None:
|
||||
assert card_to_daoti(_card())["personality_summary"] is None
|
||||
|
||||
|
||||
def test_book_to_entries_maps_fields() -> None:
|
||||
book = {
|
||||
"entries": [
|
||||
{"keys": ["猫"], "content": "喜欢猫", "position": "after_char", "constant": True},
|
||||
{"keys": ["时间"], "content": "时间能力", "priority": 5},
|
||||
]
|
||||
}
|
||||
entries = character_book_to_entries(book)
|
||||
assert len(entries) == 2
|
||||
assert entries[0]["keys"] == ["猫"]
|
||||
assert entries[0]["constant"] is True
|
||||
assert entries[1]["priority"] == 5
|
||||
assert entries[1]["position"] == "after_char" # 默认
|
||||
|
||||
|
||||
def test_book_none_or_empty() -> None:
|
||||
assert character_book_to_entries(None) == []
|
||||
assert character_book_to_entries({}) == []
|
||||
assert character_book_to_entries({"entries": []}) == []
|
||||
@@ -0,0 +1,195 @@
|
||||
"""叩心枢测试共用 fixtures。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.base import Base
|
||||
from app.models.big_cycle import BigCycle
|
||||
from app.models.chat import ChatMessage
|
||||
from app.models.drive import CuriosityQuestion
|
||||
from app.models.evolution import EvolutionCandidate
|
||||
from app.models.memory import Memory
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
from app.models.time_anniversary import TimeAnniversary
|
||||
|
||||
# 用内存 SQLite 做测试数据库
|
||||
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_old_message(db_session: AsyncSession) -> None:
|
||||
"""插入一条 2 小时前的消息,供空闲超时条件触发。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id="sess_old",
|
||||
role="assistant",
|
||||
modality="text",
|
||||
content="旧消息",
|
||||
created_at=now - timedelta(hours=2),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_recent_message(db_session: AsyncSession) -> None:
|
||||
"""插入一条 1 分钟前的消息,供空闲超时条件不触发。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id="sess_recent",
|
||||
role="assistant",
|
||||
modality="text",
|
||||
content="新消息",
|
||||
created_at=now - timedelta(minutes=1),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_anniversary(db_session: AsyncSession) -> None:
|
||||
"""插入一条 2026-06-27 的生日纪念日。"""
|
||||
db_session.add(
|
||||
TimeAnniversary(
|
||||
character_id="char_test",
|
||||
name="测试生日",
|
||||
calendar="solar",
|
||||
date="2026-06-27",
|
||||
kind="birthday",
|
||||
subject_type="user",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_emotion_state(db_session: AsyncSession) -> None:
|
||||
"""插入一条初始情绪状态(neutral / 0.2)。"""
|
||||
db_session.add(
|
||||
CharacterEmotionState(
|
||||
character_id="char_test",
|
||||
mood="neutral",
|
||||
intensity=0.2,
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_curiosity(db_session: AsyncSession) -> None:
|
||||
"""插入一条活跃的好奇心问题。"""
|
||||
db_session.add(
|
||||
CuriosityQuestion(
|
||||
character_id="char_test",
|
||||
cycle_id="cycle_test",
|
||||
question="你为什么总是沉默?",
|
||||
source="drive",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_applied_evolution(db_session: AsyncSession) -> None:
|
||||
"""插入一条已应用的演化候选。"""
|
||||
db_session.add(
|
||||
EvolutionCandidate(
|
||||
character_id="char_test",
|
||||
cycle_id="cycle_test",
|
||||
type="personality",
|
||||
title="语气更柔和",
|
||||
proposal="调整表达风格,减少命令式语气,增加温和感",
|
||||
level="L3",
|
||||
status="applied",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_completed_big_cycle(db_session: AsyncSession) -> None:
|
||||
"""插入一条已完成的大周天循环。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
BigCycle(
|
||||
character_id="char_test",
|
||||
trigger="session_turn",
|
||||
status="completed",
|
||||
summary="发现自己最近更倾向于回忆过去,在对话中表现出怀旧倾向",
|
||||
completed_at=now - timedelta(minutes=5),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_new_memory(db_session: AsyncSession) -> None:
|
||||
"""插入一条活跃的新记忆。"""
|
||||
db_session.add(
|
||||
Memory(
|
||||
character_id="char_test",
|
||||
type="experience",
|
||||
title="今天去了海边",
|
||||
content="用户提到今天去了海边,看日落。角色回忆起自己也曾喜欢在海边散步。",
|
||||
status="active",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_many_messages(db_session: AsyncSession) -> None:
|
||||
"""插入多条消息,供关系里程碑(消息数)条件触发。"""
|
||||
now = datetime.now(UTC)
|
||||
for i in range(5):
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id=f"sess_rel_{i}",
|
||||
role="assistant" if i % 2 else "user",
|
||||
modality="text",
|
||||
content=f"第{i + 1}条消息",
|
||||
created_at=now - timedelta(minutes=10 - i),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def seed_old_relationship(db_session: AsyncSession) -> None:
|
||||
"""插入一条 60 天前的消息,供关系里程碑(天数)条件触发。"""
|
||||
now = datetime.now(UTC)
|
||||
db_session.add(
|
||||
ChatMessage(
|
||||
character_id="char_test",
|
||||
session_id="sess_old_rel",
|
||||
role="user",
|
||||
modality="text",
|
||||
content="你好",
|
||||
created_at=now - timedelta(days=60),
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
@@ -0,0 +1,303 @@
|
||||
"""叩心枢触发条件单元测试。"""
|
||||
|
||||
# ruff: noqa: ARG002 — pytest fixture params are intentionally unused in test signatures
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.modules.knocking.conditions import (
|
||||
AnniversaryCondition,
|
||||
BigCycleCompletedCondition,
|
||||
ConditionResult,
|
||||
CuriosityCondition,
|
||||
EmotionChangeCondition,
|
||||
EvolutionAppliedCondition,
|
||||
MemoryRecallCondition,
|
||||
ProactiveCondition,
|
||||
RelationshipMilestoneCondition,
|
||||
TimeElapsedCondition,
|
||||
TimeOfDayCondition,
|
||||
UserReunionCondition,
|
||||
)
|
||||
|
||||
|
||||
class TestTimeElapsedCondition:
|
||||
"""空闲超时条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_history(self, db_session: AsyncSession) -> None:
|
||||
cond = TimeElapsedCondition(min_idle_minutes=360)
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_idle_exceeds_threshold(
|
||||
self, db_session: AsyncSession, seed_old_message: None
|
||||
) -> None:
|
||||
cond = TimeElapsedCondition(min_idle_minutes=1)
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "idle"
|
||||
|
||||
async def test_not_triggered_when_recent_message(
|
||||
self, db_session: AsyncSession, seed_recent_message: None
|
||||
) -> None:
|
||||
cond = TimeElapsedCondition(min_idle_minutes=360)
|
||||
now = datetime.now(UTC)
|
||||
result = await cond.check("char_test", db_session, now=now)
|
||||
assert not result.triggered
|
||||
|
||||
|
||||
class TestTimeOfDayCondition:
|
||||
async def test_morning_match(self) -> None:
|
||||
cond = TimeOfDayCondition(windows=["morning"])
|
||||
now = datetime(2026, 6, 27, 7, 0, 0, tzinfo=UTC)
|
||||
result = await cond.check("char_test", None, now=now)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "time_of_day"
|
||||
|
||||
async def test_late_night_match(self) -> None:
|
||||
cond = TimeOfDayCondition(windows=["late_night"])
|
||||
now = datetime(2026, 6, 27, 23, 0, 0, tzinfo=UTC)
|
||||
result = await cond.check("char_test", None, now=now)
|
||||
assert result.triggered
|
||||
assert result.context.get("time_of_day") == "late_night"
|
||||
|
||||
async def test_no_match_outside_window(self) -> None:
|
||||
cond = TimeOfDayCondition(windows=["morning"])
|
||||
now = datetime(2026, 6, 27, 14, 0, 0, tzinfo=UTC)
|
||||
result = await cond.check("char_test", None, now=now)
|
||||
assert not result.triggered
|
||||
|
||||
|
||||
class TestAnniversaryCondition:
|
||||
async def test_not_triggered_on_first_call_no_data(self, db_session: AsyncSession) -> None:
|
||||
cond = AnniversaryCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_anniversary_exists(
|
||||
self, db_session: AsyncSession, seed_anniversary: None
|
||||
) -> None:
|
||||
cond = AnniversaryCondition()
|
||||
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
|
||||
result = await cond.check("char_test", db_session, now=now)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "anniversary"
|
||||
assert "birthday" in result.context.get("anniversary_kind", "")
|
||||
|
||||
async def test_not_duplicated_same_day(
|
||||
self, db_session: AsyncSession, seed_anniversary: None
|
||||
) -> None:
|
||||
cond = AnniversaryCondition()
|
||||
now = datetime(2026, 6, 27, 12, 0, 0, tzinfo=UTC)
|
||||
result1 = await cond.check("char_test", db_session, now=now)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session, now=now)
|
||||
assert not result2.triggered # 同一天不重复
|
||||
|
||||
|
||||
class TestUserReunionCondition:
|
||||
async def test_not_triggered_when_no_event(self, db_session: AsyncSession) -> None:
|
||||
cond = UserReunionCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
|
||||
class TestEmotionChangeCondition:
|
||||
async def test_not_triggered_on_first_check(
|
||||
self, db_session: AsyncSession, seed_emotion_state: None
|
||||
) -> None:
|
||||
cond = EmotionChangeCondition(min_delta=0.3)
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert not result.triggered # 首次无基线
|
||||
|
||||
async def test_triggered_when_intensity_changes(
|
||||
self, db_session: AsyncSession, seed_emotion_state: None
|
||||
) -> None:
|
||||
cond = EmotionChangeCondition(min_delta=0.3)
|
||||
await cond.check("char_test", db_session) # 首次建立基线
|
||||
|
||||
# 更新情绪 intensity 到 0.8(变化 0.6 > 0.3)
|
||||
from app.models.personality_emotion import CharacterEmotionState
|
||||
|
||||
result = await db_session.execute(
|
||||
select(CharacterEmotionState).where(CharacterEmotionState.character_id == "char_test")
|
||||
)
|
||||
state = result.scalar_one()
|
||||
state.intensity = 0.8
|
||||
state.mood = "melancholy"
|
||||
await db_session.commit()
|
||||
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "emotion"
|
||||
|
||||
|
||||
class TestCuriosityCondition:
|
||||
async def test_not_triggered_when_no_active_questions(self, db_session: AsyncSession) -> None:
|
||||
cond = CuriosityCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_active_question_exists(
|
||||
self, db_session: AsyncSession, seed_curiosity: None
|
||||
) -> None:
|
||||
cond = CuriosityCondition()
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "curiosity"
|
||||
assert "curiosity_question" in result.context
|
||||
assert result.context["curiosity_question"] == "你为什么总是沉默?"
|
||||
|
||||
|
||||
class TestEvolutionAppliedCondition:
|
||||
"""演化应用条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_applied(self, db_session: AsyncSession) -> None:
|
||||
cond = EvolutionAppliedCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_applied_exists(
|
||||
self, db_session: AsyncSession, seed_applied_evolution: None
|
||||
) -> None:
|
||||
cond = EvolutionAppliedCondition()
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "evolution_applied"
|
||||
assert result.context.get("evolution_title") == "语气更柔和"
|
||||
assert result.context.get("evolution_level") == "L3"
|
||||
assert "evolution_proposal" in result.context
|
||||
|
||||
async def test_not_duplicated(
|
||||
self, db_session: AsyncSession, seed_applied_evolution: None
|
||||
) -> None:
|
||||
cond = EvolutionAppliedCondition()
|
||||
result1 = await cond.check("char_test", db_session)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session)
|
||||
assert not result2.triggered # 同一条不重复触发
|
||||
|
||||
|
||||
class TestBigCycleCompletedCondition:
|
||||
"""大周天完成条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_completed(self, db_session: AsyncSession) -> None:
|
||||
cond = BigCycleCompletedCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_completed_exists(
|
||||
self, db_session: AsyncSession, seed_completed_big_cycle: None
|
||||
) -> None:
|
||||
cond = BigCycleCompletedCondition()
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "big_cycle_completed"
|
||||
assert "big_cycle_summary" in result.context
|
||||
assert "big_cycle_trigger" in result.context
|
||||
|
||||
async def test_not_duplicated(
|
||||
self, db_session: AsyncSession, seed_completed_big_cycle: None
|
||||
) -> None:
|
||||
cond = BigCycleCompletedCondition()
|
||||
result1 = await cond.check("char_test", db_session)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session)
|
||||
assert not result2.triggered # 同一条不重复触发
|
||||
|
||||
|
||||
class TestMemoryRecallCondition:
|
||||
"""记忆唤醒条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_memory(self, db_session: AsyncSession) -> None:
|
||||
cond = MemoryRecallCondition()
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_when_new_memory_exists(
|
||||
self, db_session: AsyncSession, seed_new_memory: None
|
||||
) -> None:
|
||||
cond = MemoryRecallCondition()
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "memory_recall"
|
||||
assert result.context.get("memory_title") == "今天去了海边"
|
||||
assert "memory_content" in result.context
|
||||
assert "memory_type" in result.context
|
||||
|
||||
async def test_not_duplicated(self, db_session: AsyncSession, seed_new_memory: None) -> None:
|
||||
cond = MemoryRecallCondition()
|
||||
result1 = await cond.check("char_test", db_session)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session)
|
||||
assert not result2.triggered # 同一条不重复触发
|
||||
|
||||
|
||||
class TestRelationshipMilestoneCondition:
|
||||
"""关系里程碑条件测试。"""
|
||||
|
||||
async def test_not_triggered_when_no_messages(self, db_session: AsyncSession) -> None:
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
|
||||
result = await cond.check("char_nonexistent", db_session)
|
||||
assert not result.triggered
|
||||
|
||||
async def test_triggered_by_message_count(
|
||||
self, db_session: AsyncSession, seed_many_messages: None
|
||||
) -> None:
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "relationship_milestone"
|
||||
assert result.context.get("milestone_type") == "messages"
|
||||
assert result.context.get("milestone_value") == 5
|
||||
assert result.context.get("milestone_threshold") == 5
|
||||
|
||||
async def test_triggered_by_days(
|
||||
self, db_session: AsyncSession, seed_old_relationship: None
|
||||
) -> None:
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[9999], day_milestones=[30])
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.condition_name == "relationship_milestone"
|
||||
assert result.context.get("milestone_type") == "days"
|
||||
assert result.context.get("milestone_value") >= 30
|
||||
assert result.context.get("milestone_threshold") == 30
|
||||
|
||||
async def test_not_duplicated(self, db_session: AsyncSession, seed_many_messages: None) -> None:
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[9999])
|
||||
result1 = await cond.check("char_test", db_session)
|
||||
assert result1.triggered
|
||||
result2 = await cond.check("char_test", db_session)
|
||||
assert not result2.triggered # 同一里程碑不重复触发
|
||||
|
||||
async def test_prefers_messages_over_days(
|
||||
self, db_session: AsyncSession, seed_many_messages: None, seed_old_relationship: None
|
||||
) -> None:
|
||||
"""同时满足消息和天数里程碑时,优先触发消息里程碑。"""
|
||||
cond = RelationshipMilestoneCondition(message_milestones=[5], day_milestones=[30])
|
||||
result = await cond.check("char_test", db_session)
|
||||
assert result.triggered
|
||||
assert result.context.get("milestone_type") == "messages" # 消息优先
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proactive_condition_protocol() -> None:
|
||||
"""验证 ProactiveCondition 可作为 Protocol 使用。"""
|
||||
|
||||
class DummyCondition(ProactiveCondition):
|
||||
async def check(
|
||||
self,
|
||||
_character_id: str,
|
||||
_session: AsyncSession | None = None,
|
||||
*,
|
||||
_now: datetime | None = None,
|
||||
) -> ConditionResult:
|
||||
return ConditionResult(triggered=True, trigger_reason="测试", condition_name="dummy")
|
||||
|
||||
cond: ProactiveCondition = DummyCondition()
|
||||
result = await cond.check("char_test")
|
||||
assert result.triggered
|
||||
@@ -0,0 +1,214 @@
|
||||
"""叩心枢(KnockingPivot)集成测试。
|
||||
|
||||
测试覆盖:
|
||||
1. Tick 触发 —— 活跃角色条件匹配,执行完整触发链并推送一炁事件
|
||||
2. Tick 空跑 —— 无活跃角色时跳过
|
||||
3. 反 spam 冷却 —— 触发后冷却期内 ``_can_trigger`` 返回 False
|
||||
4. 反 spam 日上限 —— 达到日上限后返回 False
|
||||
5. 静默时段 —— [23:00, 08:00) 返回 True,其余返回 False
|
||||
6. 活跃角色列表 —— DB 查询正确返回 ``status='active'`` 的角色 ID
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
# ── 模型注册 ──────────────────────────────────────────────────────────
|
||||
# 在 DB 表创建前将模型注册到 Base.metadata,确保 create_all 创建对应表。
|
||||
# conftest.py 已注册 ChatMessage / CuriosityQuestion / CharacterEmotionState / TimeAnniversary,
|
||||
# 但未注册 Character 与 ChatSession。
|
||||
from app.models.character import Character # noqa: F401
|
||||
from app.models.chat import ChatSession # noqa: F401
|
||||
from app.modules.knocking.conditions import ConditionResult
|
||||
from app.modules.knocking.opener import OpenerResult
|
||||
from app.modules.knocking.pivot import KnockingPivot
|
||||
from app.services.personality import PersonalitySummary
|
||||
|
||||
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def keeper_session_factory() -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
|
||||
"""Yield an async_sessionmaker backed by an in-memory SQLite database."""
|
||||
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
yield factory
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def dbevent_store() -> AsyncGenerator:
|
||||
"""In-memory event store (no DB needed, supports _events for assertion)."""
|
||||
from app.modules.runtime.events import InMemoryEventStore
|
||||
|
||||
yield InMemoryEventStore()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def keeper(
|
||||
keeper_session_factory: async_sessionmaker[AsyncSession],
|
||||
dbevent_store,
|
||||
) -> AsyncGenerator[KnockingPivot, None]:
|
||||
"""KnockingPivot instance backed by an in-memory SQLite database."""
|
||||
pivot = KnockingPivot(
|
||||
event_store=dbevent_store,
|
||||
session_factory=keeper_session_factory,
|
||||
tick_interval=3600, # 测试期间不真的 tick
|
||||
)
|
||||
yield pivot
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# 测试
|
||||
# ======================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_triggers_for_active_character(
|
||||
keeper: KnockingPivot,
|
||||
keeper_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""活跃角色 + 任意条件触发时,应完成完整触发链:开场 → 消息 → 事件。"""
|
||||
async with keeper_session_factory() as session:
|
||||
char = Character(
|
||||
id="char_test_1",
|
||||
name="测试人物",
|
||||
status="active",
|
||||
owner_scope="test",
|
||||
language="zh-CN",
|
||||
privacy_level="normal",
|
||||
)
|
||||
session.add(char)
|
||||
await session.commit()
|
||||
|
||||
with (
|
||||
patch.object(keeper.conditions[0], "check", AsyncMock(return_value=ConditionResult(triggered=True, trigger_reason="测试触发", condition_name="test"))),
|
||||
patch(
|
||||
"app.modules.knocking.pivot.build_personality_summary",
|
||||
return_value=PersonalitySummary(text="你是一个测试人物。"),
|
||||
),
|
||||
patch(
|
||||
"app.modules.knocking.pivot.generate_opener",
|
||||
return_value=OpenerResult(text="测试开场白。"),
|
||||
),
|
||||
):
|
||||
async with keeper_session_factory() as check_session:
|
||||
processed = await keeper._check_and_trigger(
|
||||
"char_test_1",
|
||||
check_session,
|
||||
now=datetime.now(UTC),
|
||||
)
|
||||
|
||||
assert processed is True
|
||||
|
||||
# 验证消息已创建
|
||||
async with keeper_session_factory() as session:
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await session.execute(
|
||||
select(ChatSession).where(ChatSession.character_id == "char_test_1")
|
||||
)
|
||||
assert result.scalar_one_or_none() is not None
|
||||
|
||||
# 验证一炁事件已推送
|
||||
events = keeper._event_store._events
|
||||
assert len(events) > 0
|
||||
last_event = events[-1]
|
||||
assert last_event.source == "knocking_pivot"
|
||||
assert last_event.character_id == "char_test_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_skips_when_no_active_characters(keeper: KnockingPivot) -> None:
|
||||
"""无活跃角色时 tick 不触发任何操作。"""
|
||||
with (
|
||||
patch("app.modules.knocking.pivot.create_proactive_message") as mock_create,
|
||||
patch("app.modules.knocking.pivot.generate_opener"),
|
||||
):
|
||||
await keeper._tick()
|
||||
|
||||
mock_create.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooldown_prevents_retrigger(keeper: KnockingPivot) -> None:
|
||||
"""触发后冷却期内 _can_trigger 返回 False。"""
|
||||
now = datetime.now(UTC)
|
||||
assert keeper._can_trigger("char_1", now) is True
|
||||
|
||||
keeper._mark_triggered("char_1", now)
|
||||
assert keeper._can_trigger("char_1", now) is False
|
||||
|
||||
# 冷却结束后应恢复
|
||||
after_cooldown = now + timedelta(minutes=keeper._cooldown_minutes + 1)
|
||||
assert keeper._can_trigger("char_1", after_cooldown) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_daily_limit_prevents_excess(keeper: KnockingPivot) -> None:
|
||||
"""同一天达到上限后 _can_trigger 返回 False。"""
|
||||
now = datetime.now(UTC)
|
||||
assert keeper._can_trigger("char_1", now) is True
|
||||
|
||||
keeper._daily_counts["char_1"] = (now.date().isoformat(), keeper._daily_limit)
|
||||
assert keeper._can_trigger("char_1", now) is False
|
||||
|
||||
# 第二天恢复
|
||||
next_day = now + timedelta(days=1)
|
||||
assert keeper._can_trigger("char_1", next_day) is True
|
||||
|
||||
|
||||
def test_quiet_hours(keeper: KnockingPivot) -> None:
|
||||
"""静默时段 23:00–08:00 _in_quiet_hours 返回 True。"""
|
||||
keeper._quiet_hours_start = 23
|
||||
keeper._quiet_hours_end = 8
|
||||
|
||||
# 静默时段内
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 23, 30, tzinfo=UTC)) is True
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 3, 0, tzinfo=UTC)) is True
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 7, 59, tzinfo=UTC)) is True
|
||||
|
||||
# 静默时段外
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 8, 0, tzinfo=UTC)) is False
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 12, 0, tzinfo=UTC)) is False
|
||||
assert keeper._in_quiet_hours(datetime(2026, 1, 1, 22, 59, tzinfo=UTC)) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_active_characters(
|
||||
keeper: KnockingPivot,
|
||||
keeper_session_factory: async_sessionmaker[AsyncSession],
|
||||
) -> None:
|
||||
"""_list_active_characters 只返回 status='active' 的角色。"""
|
||||
async with keeper_session_factory() as session:
|
||||
for i, status in enumerate(["active", "inactive", "paused", "active"]):
|
||||
session.add(
|
||||
Character(
|
||||
id=f"char_{i}",
|
||||
name=f"人物{i}",
|
||||
status=status,
|
||||
owner_scope="test",
|
||||
language="zh-CN",
|
||||
privacy_level="normal",
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async with keeper_session_factory() as session:
|
||||
result = await keeper._list_active_characters(session)
|
||||
|
||||
assert set(result) == {"char_0", "char_3"}
|
||||
Whitespace-only changes.
@@ -0,0 +1,130 @@
|
||||
"""世谱匹配 / 预算 / 注入分组单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.lorebook import (
|
||||
LorebookBook,
|
||||
LorebookEntry,
|
||||
MatchedEntry,
|
||||
build_injections,
|
||||
match_entries,
|
||||
select_within_budget,
|
||||
)
|
||||
|
||||
|
||||
def _entry(eid: str, **kw) -> LorebookEntry:
|
||||
return LorebookEntry(id=eid, **kw)
|
||||
|
||||
|
||||
def _book(entries: list[LorebookEntry], **kw) -> LorebookBook:
|
||||
return LorebookBook(id="lorebook_x", entries=entries, **kw)
|
||||
|
||||
|
||||
def test_keyword_match() -> None:
|
||||
book = _book([_entry("1", keys=["猫"], content="她喜欢猫。")])
|
||||
assert [m.entry.id for m in match_entries(book, "今天看到一只猫")] == ["1"]
|
||||
|
||||
|
||||
def test_no_match_when_keyword_absent() -> None:
|
||||
book = _book([_entry("1", keys=["猫"], content="她喜欢猫。")])
|
||||
assert match_entries(book, "今天天气不错") == []
|
||||
|
||||
|
||||
def test_case_insensitive_by_default() -> None:
|
||||
book = _book([_entry("1", keys=["cat"], content="cat info")])
|
||||
assert match_entries(book, "I have a CAT") != []
|
||||
|
||||
|
||||
def test_case_sensitive() -> None:
|
||||
book = _book([_entry("1", keys=["Cat"], content="x", case_sensitive=True)])
|
||||
assert match_entries(book, "a cat here") == []
|
||||
assert [m.entry.id for m in match_entries(book, "a Cat here")] == ["1"]
|
||||
|
||||
|
||||
def test_constant_always_matched() -> None:
|
||||
book = _book([_entry("1", keys=["不会命中"], content="恒定设定", constant=True)])
|
||||
matched = match_entries(book, "无关文本")
|
||||
assert [m.entry.id for m in matched] == ["1"]
|
||||
assert matched[0].reason == "constant"
|
||||
|
||||
|
||||
def test_disabled_entry_skipped() -> None:
|
||||
book = _book([_entry("1", keys=["猫"], content="x", enabled=False)])
|
||||
assert match_entries(book, "一只猫") == []
|
||||
|
||||
|
||||
def test_selective_requires_both_keys() -> None:
|
||||
book = _book(
|
||||
[
|
||||
_entry(
|
||||
"1",
|
||||
keys=["猫"],
|
||||
secondary_keys=["黑"],
|
||||
content="黑猫设定",
|
||||
selective=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
assert match_entries(book, "看到一只猫") == []
|
||||
assert [m.entry.id for m in match_entries(book, "看到一只黑猫")] == ["1"]
|
||||
|
||||
|
||||
def test_regex_match() -> None:
|
||||
book = _book([_entry("1", keys=[r"\b\d{4}\b"], content="年份", use_regex=True)])
|
||||
assert [m.entry.id for m in match_entries(book, "生于 1999 年")] == ["1"]
|
||||
assert match_entries(book, "生于 99 年") == []
|
||||
|
||||
|
||||
def test_recursive_scanning() -> None:
|
||||
# 条目 1 命中关键词,其 content 含触发条目 2 的关键词
|
||||
book = _book(
|
||||
[
|
||||
_entry("1", keys=["学校"], content="她在学校认识小明。"),
|
||||
_entry("2", keys=["小明"], content="小明是同班同学。"),
|
||||
],
|
||||
recursive_scanning=True,
|
||||
)
|
||||
matched = match_entries(book, "今天去了学校")
|
||||
ids = {m.entry.id for m in matched}
|
||||
assert ids == {"1", "2"}
|
||||
assert any(m.reason == "recursive" for m in matched if m.entry.id == "2")
|
||||
|
||||
|
||||
def test_budget_keeps_lower_priority() -> None:
|
||||
book = _book(
|
||||
[
|
||||
_entry("a", keys=["k"], content="aaaa", priority=10),
|
||||
_entry("b", keys=["k"], content="bbbb", priority=1),
|
||||
],
|
||||
token_budget=5,
|
||||
)
|
||||
matched = match_entries(book, "k")
|
||||
kept = select_within_budget(matched, token_budget=5)
|
||||
# 仅 b(cost=4)能进预算;a(cost=4,但 priority 低)在 b 之后超预算
|
||||
assert [m.entry.id for m in kept] == ["b"]
|
||||
|
||||
|
||||
def test_budget_none_keeps_all_sorted_by_order() -> None:
|
||||
book = _book(
|
||||
[
|
||||
_entry("a", keys=["k"], content="x", priority=5, insertion_order=20),
|
||||
_entry("b", keys=["k"], content="y", priority=1, insertion_order=10),
|
||||
]
|
||||
)
|
||||
kept = select_within_budget(match_entries(book, "k"))
|
||||
assert [m.entry.id for m in kept] == ["b", "a"]
|
||||
|
||||
|
||||
def test_build_injections_groups_by_position() -> None:
|
||||
matched = [
|
||||
MatchedEntry(entry=_entry("a", keys=["k"], content="前文", position="before_char")),
|
||||
MatchedEntry(entry=_entry("b", keys=["k"], content="后文1", position="after_char")),
|
||||
MatchedEntry(entry=_entry("c", keys=["k"], content="后文2", position="after_char")),
|
||||
]
|
||||
inj = build_injections(matched)
|
||||
assert inj == {"before_char": "前文", "after_char": "后文1\n后文2"}
|
||||
|
||||
|
||||
def test_unknown_position_falls_back_to_after_char() -> None:
|
||||
matched = [MatchedEntry(entry=_entry("a", keys=["k"], content="x", position="weird"))]
|
||||
assert build_injections(matched) == {"after_char": "x"}
|
||||
Whitespace-only changes.
@@ -0,0 +1,76 @@
|
||||
"""符文引擎单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.macros import MacroContext, render
|
||||
|
||||
|
||||
def test_replaces_char_and_user() -> None:
|
||||
ctx = MacroContext(char_name="狂三", user_name="士道")
|
||||
assert render("{{char}}对{{user}}说", ctx) == "狂三对士道说"
|
||||
|
||||
|
||||
def test_bot_is_char_alias() -> None:
|
||||
assert render("{{bot}}", MacroContext(char_name="狂三")) == "狂三"
|
||||
|
||||
|
||||
def test_original_macro() -> None:
|
||||
assert render("[{{original}}]", MacroContext(original="原辞")) == "[原辞]"
|
||||
|
||||
|
||||
def test_case_insensitive() -> None:
|
||||
assert render("{{Char}}", MacroContext(char_name="狂三")) == "狂三"
|
||||
|
||||
|
||||
def test_unknown_macro_preserved() -> None:
|
||||
assert render("{{unknown}}", MacroContext()) == "{{unknown}}"
|
||||
|
||||
|
||||
def test_empty_braces_preserved() -> None:
|
||||
assert render("a{{}}b", MacroContext()) == "a{{}}b"
|
||||
|
||||
|
||||
def test_resolver_exception_preserved() -> None:
|
||||
# {{roll:}} 无有效规格参数 → 解析器走默认 1d6,不应抛错;这里测无参 random 不抛
|
||||
assert render("{{random}}", MacroContext()) == ""
|
||||
|
||||
|
||||
def test_random_picks_from_args() -> None:
|
||||
ctx = MacroContext()
|
||||
for _ in range(20):
|
||||
assert render("{{random:甲,乙,丙}}", ctx) in {"甲", "乙", "丙"}
|
||||
|
||||
|
||||
def test_roll_in_range() -> None:
|
||||
for _ in range(20):
|
||||
result = render("{{roll:2d6}}", MacroContext())
|
||||
assert 2 <= int(result) <= 12
|
||||
|
||||
|
||||
def test_roll_default_and_invalid() -> None:
|
||||
# 默认 1d6
|
||||
assert 1 <= int(render("{{roll}}", MacroContext())) <= 6
|
||||
# 非法规格原样返回
|
||||
assert render("{{roll:xyz}}", MacroContext()) == "xyz"
|
||||
|
||||
|
||||
def test_getvar() -> None:
|
||||
ctx = MacroContext(variables={"mood": "怅然"})
|
||||
assert render("{{getvar:mood}}", ctx) == "怅然"
|
||||
assert render("{{getvar:missing}}", ctx) == ""
|
||||
|
||||
|
||||
def test_recursive_expansion() -> None:
|
||||
# char 值内含 {{user}},递归展开
|
||||
ctx = MacroContext(char_name="{{user}}的影子", user_name="士道")
|
||||
assert render("{{char}}", ctx) == "士道的影子"
|
||||
|
||||
|
||||
def test_time_macro_without_snapshot_is_empty() -> None:
|
||||
ctx = MacroContext()
|
||||
assert render("{{date}}", ctx) == ""
|
||||
assert render("{{shichen}}", ctx) == ""
|
||||
|
||||
|
||||
def test_no_braces_unchanged() -> None:
|
||||
assert render("普通文本无占位符", MacroContext()) == "普通文本无占位符"
|
||||
@@ -0,0 +1,182 @@
|
||||
"""灵核情绪状态机纯计算测试(modules/personality/emotion.py)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.personality.emotion import (
|
||||
MOODS,
|
||||
EmotionState,
|
||||
apply_signal,
|
||||
decay,
|
||||
map_signal_to_mood,
|
||||
parse_baseline,
|
||||
signal_intensity_delta,
|
||||
to_prompt_text,
|
||||
)
|
||||
|
||||
|
||||
def _baseline(mood: str = "neutral", intensity: float = 0.2) -> EmotionState:
|
||||
return EmotionState(mood=mood, intensity=intensity)
|
||||
|
||||
|
||||
# ---- map_signal_to_mood ----
|
||||
|
||||
|
||||
def test_map_reunion_to_touched() -> None:
|
||||
assert map_signal_to_mood("time.reunion", {"reunion_intensity": 0.8}) == "touched"
|
||||
|
||||
|
||||
def test_map_user_care_default_to_concerned() -> None:
|
||||
assert map_signal_to_mood("time.user_care", {"care_kind": "late_night"}) == "concerned"
|
||||
|
||||
|
||||
def test_map_user_care_festival_lonely_to_melancholy() -> None:
|
||||
assert map_signal_to_mood("time.user_care", {"care_kind": "festival_lonely"}) == "melancholy"
|
||||
|
||||
|
||||
def test_map_sentiment_default_to_wistful() -> None:
|
||||
assert map_signal_to_mood("time.sentiment", {"trigger_type": "reunion_memory"}) == "wistful"
|
||||
|
||||
|
||||
def test_map_sentiment_late_night_to_melancholy() -> None:
|
||||
assert map_signal_to_mood("time.sentiment", {"trigger_type": "late_night"}) == "melancholy"
|
||||
|
||||
|
||||
def test_map_unknown_event_returns_none() -> None:
|
||||
assert map_signal_to_mood("tool.started", {}) is None
|
||||
|
||||
|
||||
# ---- signal_intensity_delta ----
|
||||
|
||||
|
||||
def test_delta_reunion_uses_reunion_intensity() -> None:
|
||||
assert signal_intensity_delta("time.reunion", {"reunion_intensity": 0.7}) == 0.7
|
||||
|
||||
|
||||
def test_delta_user_care_uses_care_suggest() -> None:
|
||||
assert signal_intensity_delta("time.user_care", {"care_suggest": 0.6}) == 0.6
|
||||
|
||||
|
||||
def test_delta_sentiment_default_05() -> None:
|
||||
assert signal_intensity_delta("time.sentiment", {}) == 0.5
|
||||
|
||||
|
||||
def test_delta_unknown_event_zero() -> None:
|
||||
assert signal_intensity_delta("tool.started", {}) == 0.0
|
||||
|
||||
|
||||
# ---- apply_signal ----
|
||||
|
||||
|
||||
def test_apply_signal_changes_mood_and_sets_intensity_from_baseline() -> None:
|
||||
state = _baseline() # neutral 0.2
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.6}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 0.8 # 0.2 baseline + 0.6 delta
|
||||
|
||||
|
||||
def test_apply_signal_same_mood_accumulates() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.5)
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.3}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 0.8 # 0.5 current + 0.3 delta
|
||||
|
||||
|
||||
def test_apply_signal_clamps_to_1() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.9)
|
||||
new = apply_signal(state, "time.reunion", {"reunion_intensity": 0.5}, _baseline())
|
||||
assert new.mood == "touched"
|
||||
assert new.intensity == 1.0
|
||||
|
||||
|
||||
def test_apply_signal_unknown_event_returns_unchanged() -> None:
|
||||
state = EmotionState(mood="pleased", intensity=0.5)
|
||||
new = apply_signal(state, "tool.started", {}, _baseline())
|
||||
assert new == state
|
||||
|
||||
|
||||
# ---- decay ----
|
||||
|
||||
|
||||
def test_decay_toward_baseline_half_life() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.8)
|
||||
baseline = _baseline() # neutral 0.2
|
||||
# 半衰期 6 回合,elapsed 6 → intensity 向 baseline 走一半
|
||||
decayed = decay(state, baseline, elapsed_turns=6.0)
|
||||
assert decayed.mood == "touched"
|
||||
assert abs(decayed.intensity - 0.5) < 0.01 # 0.2 + (0.8-0.2)*0.5 = 0.5
|
||||
|
||||
|
||||
def test_decay_below_threshold_returns_baseline_mood() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.3)
|
||||
baseline = _baseline()
|
||||
# elapsed 大 → intensity 衰减到 < 0.15 → mood 回 baseline
|
||||
decayed = decay(state, baseline, elapsed_turns=30.0)
|
||||
assert decayed.mood == baseline.mood
|
||||
assert decayed.intensity == baseline.intensity
|
||||
|
||||
|
||||
def test_decay_zero_elapsed_returns_unchanged() -> None:
|
||||
state = EmotionState(mood="touched", intensity=0.8)
|
||||
baseline = _baseline()
|
||||
assert decay(state, baseline, elapsed_turns=0) == state
|
||||
|
||||
|
||||
# ---- parse_baseline ----
|
||||
|
||||
|
||||
def test_parse_baseline_from_dict() -> None:
|
||||
assert parse_baseline({"mood": "neutral", "intensity": 0.3}) == EmotionState(
|
||||
mood="neutral", intensity=0.3
|
||||
)
|
||||
|
||||
|
||||
def test_parse_baseline_from_valid_json() -> None:
|
||||
assert parse_baseline('{"mood": "pleased", "intensity": 0.4}') == EmotionState(
|
||||
mood="pleased", intensity=0.4
|
||||
)
|
||||
|
||||
|
||||
def test_parse_baseline_invalid_json_returns_neutral_default() -> None:
|
||||
assert parse_baseline("not json") == EmotionState()
|
||||
|
||||
|
||||
def test_parse_baseline_none_returns_neutral_default() -> None:
|
||||
assert parse_baseline(None) == EmotionState()
|
||||
|
||||
|
||||
def test_parse_baseline_clamps_intensity() -> None:
|
||||
assert parse_baseline({"mood": "neutral", "intensity": 1.5}).intensity == 1.0
|
||||
assert parse_baseline({"mood": "neutral", "intensity": -0.2}).intensity == 0.0
|
||||
|
||||
|
||||
def test_parse_baseline_unknown_mood_falls_back_neutral() -> None:
|
||||
assert parse_baseline({"mood": "unknown_mood", "intensity": 0.5}).mood == "neutral"
|
||||
|
||||
|
||||
# ---- to_prompt_text ----
|
||||
|
||||
|
||||
def test_to_prompt_text_includes_chinese_label_and_pct() -> None:
|
||||
text = to_prompt_text(EmotionState(mood="touched", intensity=0.7))
|
||||
assert "感动" in text
|
||||
assert "70" in text
|
||||
|
||||
|
||||
def test_to_prompt_text_unknown_mood_uses_raw() -> None:
|
||||
text = to_prompt_text(EmotionState(mood="custom", intensity=0.5))
|
||||
assert "custom" in text
|
||||
|
||||
|
||||
# ---- MOODS ----
|
||||
|
||||
|
||||
def test_moods_contains_seven() -> None:
|
||||
assert set(MOODS) == {
|
||||
"neutral",
|
||||
"pleased",
|
||||
"touched",
|
||||
"wistful",
|
||||
"concerned",
|
||||
"festive",
|
||||
"melancholy",
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"""小周天每轮反思抽取纯函数测试。"""
|
||||
|
||||
from app.modules.extraction.extract import MemoryCandidateDraft
|
||||
from app.modules.reflection.extract import parse_reflection
|
||||
|
||||
|
||||
def test_parse_standard() -> None:
|
||||
raw = (
|
||||
'{"memories":[{"type":"preference","title":"喜欢简洁","content":"用户偏好简洁",'
|
||||
'"importance":0.8,"sensitivity":"low","confidence":0.7,"reason":"多次提及"}],'
|
||||
'"reflection":"本轮应更主动确认用户意图。"}'
|
||||
)
|
||||
memories, reflection = parse_reflection(raw)
|
||||
assert len(memories) == 1
|
||||
assert memories[0].title == "喜欢简洁"
|
||||
assert isinstance(memories[0], MemoryCandidateDraft)
|
||||
assert reflection == "本轮应更主动确认用户意图。"
|
||||
|
||||
|
||||
def test_parse_markdown_block() -> None:
|
||||
raw = '```json\n{"memories":[],"reflection":"反思文本"}\n```'
|
||||
memories, reflection = parse_reflection(raw)
|
||||
assert memories == []
|
||||
assert reflection == "反思文本"
|
||||
|
||||
|
||||
def test_parse_invalid_returns_empty() -> None:
|
||||
assert parse_reflection("不是JSON") == ([], "")
|
||||
assert parse_reflection("[]") == ([], "")
|
||||
|
||||
|
||||
def test_missing_reflection_returns_empty_string() -> None:
|
||||
memories, reflection = parse_reflection('{"memories":[]}')
|
||||
assert memories == []
|
||||
assert reflection == ""
|
||||
|
||||
|
||||
def test_missing_memories_returns_empty_list() -> None:
|
||||
memories, reflection = parse_reflection('{"reflection":"只有反思"}')
|
||||
assert memories == []
|
||||
assert reflection == "只有反思"
|
||||
|
||||
|
||||
def test_memory_safe_defaults() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c"}],"reflection":"r"}'
|
||||
m = parse_reflection(raw)[0][0]
|
||||
assert m.sensitivity == "low"
|
||||
assert m.confidence == 0.5
|
||||
assert m.importance == 0.5
|
||||
|
||||
|
||||
def test_skip_memory_missing_title_or_content() -> None:
|
||||
raw = (
|
||||
'{"memories":[{"title":"t"},{"content":"c"},{"title":"t","content":"c"}],"reflection":"r"}'
|
||||
)
|
||||
assert len(parse_reflection(raw)[0]) == 1
|
||||
|
||||
|
||||
def test_clamp_values() -> None:
|
||||
raw = '{"memories":[{"title":"t","content":"c","importance":1.5}],"reflection":"r"}'
|
||||
assert parse_reflection(raw)[0][0].importance == 1.0
|
||||
|
||||
|
||||
def test_reflection_strips_whitespace() -> None:
|
||||
raw = '{"memories":[],"reflection":" 带空格的反思 "}'
|
||||
assert parse_reflection(raw)[1] == "带空格的反思"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""小周天反思 prompt 构建纯函数测试。"""
|
||||
|
||||
from app.modules.reflection.prompts import build_reflection_messages
|
||||
|
||||
|
||||
def test_messages_return_system_user_pair() -> None:
|
||||
system, user = build_reflection_messages("你好", "你好,我是魂镜")
|
||||
assert isinstance(system, str) and isinstance(user, str)
|
||||
assert system and user
|
||||
|
||||
|
||||
def test_system_describes_memories_and_reflection() -> None:
|
||||
system, _ = build_reflection_messages("u", "a")
|
||||
for token in ("memories", "reflection", "importance", "sensitivity"):
|
||||
assert token in system
|
||||
|
||||
|
||||
def test_user_includes_dialogue() -> None:
|
||||
_, user = build_reflection_messages("用户说的话", "助手的回复")
|
||||
assert "用户说的话" in user
|
||||
assert "助手的回复" in user
|
||||
@@ -0,0 +1,77 @@
|
||||
"""事件存储测试:订阅推送、sequence 单调、run 隔离。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.runtime.events import Event, InMemoryEventStore
|
||||
|
||||
|
||||
async def test_subscribe_receives_appended_event() -> None:
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe("run_1")
|
||||
await store.append(
|
||||
Event(type="tool.started", source="tool", run_id="run_1", payload={"tool": "calc"})
|
||||
)
|
||||
event = await asyncio.wait_for(queue.get(), timeout=1.0)
|
||||
assert event.type == "tool.started"
|
||||
assert event.run_id == "run_1"
|
||||
|
||||
|
||||
async def test_sequence_increments_per_run() -> None:
|
||||
store = InMemoryEventStore()
|
||||
first = await store.append(Event(type="tool.started", source="tool", run_id="r1"))
|
||||
second = await store.append(Event(type="tool.completed", source="tool", run_id="r1"))
|
||||
assert first.sequence == 1
|
||||
assert second.sequence == 2
|
||||
|
||||
|
||||
async def test_subscribe_isolated_by_run() -> None:
|
||||
store = InMemoryEventStore()
|
||||
queue_a = store.subscribe("run_a")
|
||||
await store.append(Event(type="x", source="tool", run_id="run_b"))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(queue_a.get(), timeout=0.3)
|
||||
|
||||
|
||||
async def test_unsubscribe_stops_delivery() -> None:
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe("run_1")
|
||||
store.unsubscribe("run_1", queue)
|
||||
await store.append(Event(type="x", source="tool", run_id="run_1"))
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(queue.get(), timeout=0.3)
|
||||
|
||||
|
||||
async def test_subscribe_by_pattern_glob_match() -> None:
|
||||
"""subscribe_by_pattern('time.*') 仅收 type 匹配的事件,不收其它 type。"""
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe_by_pattern("time.*")
|
||||
await store.append(Event(type="time.reunion", source="time_sense"))
|
||||
await store.append(Event(type="run.started", source="run"))
|
||||
# time.reunion 命中,立即可取(append 内 put_nowait 同步执行)
|
||||
received = queue.get_nowait()
|
||||
assert received.type == "time.reunion"
|
||||
# run.started 不命中,队列应空
|
||||
assert queue.empty()
|
||||
|
||||
|
||||
async def test_unsubscribe_by_pattern_stops_delivery() -> None:
|
||||
"""unsubscribe_by_pattern 后不再收到事件。"""
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe_by_pattern("time.*")
|
||||
store.unsubscribe_by_pattern("time.*", queue)
|
||||
await store.append(Event(type="time.reunion", source="time_sense"))
|
||||
assert queue.empty()
|
||||
|
||||
|
||||
async def test_subscribe_by_pattern_wildcard_all() -> None:
|
||||
"""subscribe_by_pattern('*') 收所有 type(Dispatcher 用此订阅全量)。"""
|
||||
store = InMemoryEventStore()
|
||||
queue = store.subscribe_by_pattern("*")
|
||||
await store.append(Event(type="time.reunion", source="time_sense"))
|
||||
await store.append(Event(type="run.started", source="run"))
|
||||
assert queue.get_nowait().type == "time.reunion"
|
||||
assert queue.get_nowait().type == "run.started"
|
||||
@@ -0,0 +1,50 @@
|
||||
"""测试目标上下文注入。"""
|
||||
|
||||
from app.modules.runtime.goals import build_goals_context
|
||||
|
||||
|
||||
def test_build_goals_context_with_active_goals():
|
||||
"""生成了 Active Goals 系统提示片段。"""
|
||||
goals = [
|
||||
{
|
||||
"title": "学习 Python",
|
||||
"level": "long_term",
|
||||
"status": "active",
|
||||
"priority": 80,
|
||||
"progress": 0.5,
|
||||
},
|
||||
{
|
||||
"title": "完成项目文档",
|
||||
"level": "task",
|
||||
"status": "active",
|
||||
"priority": 60,
|
||||
"progress": 0.0,
|
||||
"parent_id": "goal_1",
|
||||
},
|
||||
]
|
||||
result = build_goals_context(goals)
|
||||
assert "学习 Python" in result
|
||||
assert "任务目标" in result or "task" in result or "长期目标" in result
|
||||
assert result.startswith("当前活跃目标:") or "当前" in result
|
||||
|
||||
|
||||
def test_build_goals_context_empty():
|
||||
assert build_goals_context([]) == ""
|
||||
|
||||
|
||||
def test_build_goals_context_sorts_by_priority():
|
||||
"""按优先级降序排列输出。"""
|
||||
goals = [
|
||||
{"title": "low", "level": "task", "status": "active", "priority": 10, "progress": 0.0},
|
||||
{
|
||||
"title": "high",
|
||||
"level": "long_term",
|
||||
"status": "active",
|
||||
"priority": 90,
|
||||
"progress": 0.5,
|
||||
},
|
||||
]
|
||||
result = build_goals_context(goals)
|
||||
high_idx = result.index("high")
|
||||
low_idx = result.index("low")
|
||||
assert high_idx < low_idx # high priority first
|
||||
@@ -0,0 +1,120 @@
|
||||
"""DbConfirmationStore 单元测试。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import pytest_asyncio
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.db.base import Base
|
||||
from app.modules.safety.confirmation import (
|
||||
DbConfirmationStore,
|
||||
PendingConfirmation,
|
||||
new_confirmation_id,
|
||||
)
|
||||
|
||||
_TEST_DATABASE_URL = "sqlite+aiosqlite://"
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
engine = create_async_engine(_TEST_DATABASE_URL, echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
async with async_session() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def store(db_session: AsyncSession) -> DbConfirmationStore:
|
||||
"""创建绑定 db_session 的 DbConfirmationStore。"""
|
||||
session_factory = sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False)
|
||||
return DbConfirmationStore(session_factory)
|
||||
|
||||
|
||||
class TestDbConfirmationStore:
|
||||
"""数据库确认存储 CRUD 测试。"""
|
||||
|
||||
async def test_create_and_get(self, store: DbConfirmationStore) -> None:
|
||||
pending = PendingConfirmation(
|
||||
confirmation_id=new_confirmation_id(),
|
||||
tool="fetch_url",
|
||||
input={"q": "test"},
|
||||
risk_level="L2",
|
||||
reason="测试确认",
|
||||
)
|
||||
created = await store.create(pending)
|
||||
assert created.confirmation_id == pending.confirmation_id
|
||||
|
||||
fetched = await store.get(pending.confirmation_id)
|
||||
assert fetched is not None
|
||||
assert fetched.tool == "fetch_url"
|
||||
assert fetched.input == {"q": "test"}
|
||||
|
||||
async def test_get_non_existent(self, store: DbConfirmationStore) -> None:
|
||||
result = await store.get("confirm_nonexistent")
|
||||
assert result is None
|
||||
|
||||
async def test_pop_removes_and_returns(self, store: DbConfirmationStore) -> None:
|
||||
pending = PendingConfirmation(
|
||||
confirmation_id=new_confirmation_id(),
|
||||
tool="shell",
|
||||
input={"cmd": "ls"},
|
||||
risk_level="L3",
|
||||
reason="高风险操作",
|
||||
)
|
||||
await store.create(pending)
|
||||
|
||||
popped = await store.pop(pending.confirmation_id)
|
||||
assert popped is not None
|
||||
assert popped.tool == "shell"
|
||||
|
||||
# pop 后不应再可取到
|
||||
fetched = await store.get(pending.confirmation_id)
|
||||
assert fetched is None
|
||||
|
||||
async def test_pop_non_existent(self, store: DbConfirmationStore) -> None:
|
||||
result = await store.pop("confirm_nonexistent")
|
||||
assert result is None
|
||||
|
||||
async def test_list_pending(self, store: DbConfirmationStore) -> None:
|
||||
# 插入两条
|
||||
id1 = new_confirmation_id()
|
||||
id2 = new_confirmation_id()
|
||||
await store.create(
|
||||
PendingConfirmation(
|
||||
confirmation_id=id1, tool="fetch_url", input={}, risk_level="L1", reason="a"
|
||||
)
|
||||
)
|
||||
await store.create(
|
||||
PendingConfirmation(
|
||||
confirmation_id=id2, tool="shell", input={}, risk_level="L3", reason="b"
|
||||
)
|
||||
)
|
||||
items = await store.list_pending()
|
||||
assert len(items) == 2
|
||||
# 按创建时间升序
|
||||
assert items[0].confirmation_id == id1
|
||||
assert items[1].confirmation_id == id2
|
||||
|
||||
async def test_list_pending_empty(self, store: DbConfirmationStore) -> None:
|
||||
items = await store.list_pending()
|
||||
assert items == []
|
||||
|
||||
async def test_popped_not_in_list_pending(self, store: DbConfirmationStore) -> None:
|
||||
pending = PendingConfirmation(
|
||||
confirmation_id=new_confirmation_id(),
|
||||
tool="fetch_url",
|
||||
input={},
|
||||
risk_level="L2",
|
||||
reason="测试",
|
||||
)
|
||||
await store.create(pending)
|
||||
await store.pop(pending.confirmation_id)
|
||||
items = await store.list_pending()
|
||||
ids = [i.confirmation_id for i in items]
|
||||
assert pending.confirmation_id not in ids
|
||||
@@ -0,0 +1,66 @@
|
||||
"""司命决策器测试:风险等级 × 权限策略 × 是否确认 的决策矩阵,以及策略字典加载。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.modules.safety.governor import (
|
||||
PermissionPolicy,
|
||||
SafetyDecision,
|
||||
SafetyGovernor,
|
||||
ToolPolicy,
|
||||
)
|
||||
|
||||
|
||||
def test_low_risk_defaults_to_allow() -> None:
|
||||
governor = SafetyGovernor()
|
||||
result = governor.evaluate(tool="calc", risk_level="L1")
|
||||
assert result.decision is SafetyDecision.allow
|
||||
|
||||
|
||||
def test_medium_risk_unconfirmed_asks() -> None:
|
||||
governor = SafetyGovernor()
|
||||
result = governor.evaluate(tool="x", risk_level="L2")
|
||||
assert result.decision is SafetyDecision.ask
|
||||
|
||||
|
||||
def test_confirmed_becomes_allow() -> None:
|
||||
governor = SafetyGovernor()
|
||||
result = governor.evaluate(tool="x", risk_level="L3", confirmed=True)
|
||||
assert result.decision is SafetyDecision.allow
|
||||
|
||||
|
||||
def test_l4_always_denied() -> None:
|
||||
governor = SafetyGovernor()
|
||||
result = governor.evaluate(tool="x", risk_level="L4")
|
||||
assert result.decision is SafetyDecision.deny
|
||||
|
||||
|
||||
def test_forbidden_policy_denied() -> None:
|
||||
governor = SafetyGovernor(
|
||||
{"pay": ToolPolicy(risk_level="L3", permission_policy=PermissionPolicy.forbidden)}
|
||||
)
|
||||
result = governor.evaluate(tool="pay", risk_level="L3")
|
||||
assert result.decision is SafetyDecision.deny
|
||||
assert result.policy is PermissionPolicy.forbidden
|
||||
|
||||
|
||||
def test_explicit_tool_policy_overrides_default() -> None:
|
||||
governor = SafetyGovernor(
|
||||
{"read": ToolPolicy(risk_level="L2", permission_policy=PermissionPolicy.allow)}
|
||||
)
|
||||
result = governor.evaluate(tool="read")
|
||||
assert result.decision is SafetyDecision.allow
|
||||
|
||||
|
||||
def test_from_policy_dict_loads_policies() -> None:
|
||||
governor = SafetyGovernor.from_policy_dict(
|
||||
{"tools": {"custom": {"risk_level": "L2", "permission_policy": "allow"}}}
|
||||
)
|
||||
result = governor.evaluate(tool="custom")
|
||||
assert result.decision is SafetyDecision.allow
|
||||
|
||||
|
||||
def test_from_policy_dict_empty_returns_default() -> None:
|
||||
governor = SafetyGovernor.from_policy_dict({})
|
||||
# 空 dict → 空 Governor,未列工具按 risk 推导:L2 → ask
|
||||
result = governor.evaluate(tool="x", risk_level="L2")
|
||||
assert result.decision is SafetyDecision.ask
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Skill 关键词触发匹配纯函数测试。"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.modules.skills.matcher import match_skills
|
||||
|
||||
|
||||
def _skill(name: str, trigger: str, status: str = "enabled") -> SimpleNamespace:
|
||||
return SimpleNamespace(name=name, trigger=trigger, status=status)
|
||||
|
||||
|
||||
def test_match_by_keyword() -> None:
|
||||
skills = [_skill("coding", "代码,编程")]
|
||||
assert len(match_skills("帮我写代码", skills)) == 1
|
||||
|
||||
|
||||
def test_no_match() -> None:
|
||||
skills = [_skill("coding", "代码,编程")]
|
||||
assert match_skills("今天天气不错", skills) == []
|
||||
|
||||
|
||||
def test_case_insensitive() -> None:
|
||||
skills = [_skill("en", "Python")]
|
||||
assert len(match_skills("用 python 写", skills)) == 1
|
||||
|
||||
|
||||
def test_multiple_keywords_any_match() -> None:
|
||||
skills = [_skill("dev", "代码,debug,编程")]
|
||||
assert len(match_skills("帮我 debug", skills)) == 1
|
||||
|
||||
|
||||
def test_disabled_not_matched() -> None:
|
||||
skills = [_skill("coding", "代码", status="disabled")]
|
||||
assert match_skills("写代码", skills) == []
|
||||
|
||||
|
||||
def test_empty_trigger_not_matched() -> None:
|
||||
skills = [_skill("empty", "")]
|
||||
assert match_skills("任意文本", skills) == []
|
||||
|
||||
|
||||
def test_multiple_skills_order_preserved() -> None:
|
||||
skills = [_skill("a", "代码"), _skill("b", "天气"), _skill("c", "编程")]
|
||||
matched = match_skills("写代码和编程", skills)
|
||||
assert [s.name for s in matched] == ["a", "c"]
|
||||
|
||||
|
||||
def test_empty_context() -> None:
|
||||
skills = [_skill("a", "代码")]
|
||||
assert match_skills("", skills) == []
|
||||
|
||||
|
||||
def test_whitespace_only_trigger_ignored() -> None:
|
||||
skills = [_skill("ws", " , , ")]
|
||||
assert match_skills("任意文本", skills) == []
|
||||
Whitespace-only changes.
@@ -0,0 +1,39 @@
|
||||
"""temporal_context 文本组装测试(辰正 §8.1)。"""
|
||||
|
||||
from app.modules.time_sense.context_text import render_temporal_context
|
||||
|
||||
|
||||
def test_context_contains_shichen_and_period():
|
||||
from app.modules.time_sense.modulation import ModulationInput, compute_modulation
|
||||
from tests.modules.time_sense.test_modulation import _snapshot
|
||||
|
||||
snap = _snapshot(day_period="深夜", local_hour=23)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
text = render_temporal_context(snap, factors)
|
||||
assert "深夜" in text
|
||||
assert "周" in text
|
||||
|
||||
|
||||
def test_context_reunion_line():
|
||||
from app.modules.time_sense.duration import OfflineDuration
|
||||
from app.modules.time_sense.modulation import ModulationInput, compute_modulation
|
||||
from tests.modules.time_sense.test_modulation import _snapshot
|
||||
|
||||
snap = _snapshot(
|
||||
offline=OfflineDuration(minutes=4320 * 2, band="reunion_impact", reunion_intensity=0.7)
|
||||
)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
text = render_temporal_context(snap, factors)
|
||||
assert "久别重逢" in text
|
||||
|
||||
|
||||
def test_context_no_lunar_does_not_crash():
|
||||
"""lunar=None 时第二行跳过,不抛异常。"""
|
||||
from app.modules.time_sense.modulation import ModulationInput, compute_modulation
|
||||
from tests.modules.time_sense.test_modulation import _snapshot
|
||||
|
||||
snap = _snapshot(lunar=None)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
text = render_temporal_context(snap, factors)
|
||||
assert isinstance(text, str)
|
||||
assert len(text) > 0
|
||||
@@ -0,0 +1,104 @@
|
||||
"""时间深度与离线档位测试。"""
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.modules.time_sense.duration import (
|
||||
OfflineThresholds,
|
||||
classify_offline_band,
|
||||
compute_acquaintance_duration,
|
||||
format_duration_cn,
|
||||
)
|
||||
|
||||
|
||||
def test_acquaintance_duration_zero_days():
|
||||
anchor = datetime(2026, 7, 3, tzinfo=UTC)
|
||||
now = anchor
|
||||
d = compute_acquaintance_duration(anchor, now)
|
||||
assert d.total_days == 0
|
||||
assert "今天" in d.natural_language or "刚" in d.natural_language
|
||||
|
||||
|
||||
def test_acquaintance_duration_287_days():
|
||||
anchor = datetime(2026, 7, 3, tzinfo=UTC) - timedelta(days=287)
|
||||
now = datetime(2026, 7, 3, tzinfo=UTC)
|
||||
d = compute_acquaintance_duration(anchor, now)
|
||||
assert d.total_days == 287
|
||||
assert d.years == 0
|
||||
assert d.months == 9
|
||||
assert d.days == 17 # 287 % 30 = 17(按 30 天/月近似)
|
||||
assert "287" in d.natural_language
|
||||
|
||||
|
||||
def test_acquaintance_duration_one_year():
|
||||
anchor = datetime(2025, 7, 3, tzinfo=UTC)
|
||||
now = datetime(2026, 7, 3, tzinfo=UTC)
|
||||
d = compute_acquaintance_duration(anchor, now)
|
||||
assert d.total_days == 365
|
||||
assert d.years == 1
|
||||
|
||||
|
||||
def test_format_duration_cn_100_days():
|
||||
# 100 天 = 3 个月 10 天(按 30 天/月近似),实现走月日分支
|
||||
assert format_duration_cn(100) == "3 个月 10 天(100 天)"
|
||||
|
||||
|
||||
def test_format_duration_cn_365_days():
|
||||
text = format_duration_cn(365)
|
||||
assert "1 年" in text
|
||||
|
||||
|
||||
def test_classify_offline_band_active():
|
||||
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
|
||||
last = now - timedelta(minutes=30)
|
||||
t = OfflineThresholds()
|
||||
result = classify_offline_band(last, now, t)
|
||||
assert result.band == "active"
|
||||
assert result.minutes == 30
|
||||
|
||||
|
||||
def test_classify_offline_band_short_absence():
|
||||
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
|
||||
last = now - timedelta(hours=5)
|
||||
result = classify_offline_band(last, now, OfflineThresholds())
|
||||
assert result.band == "short_absence"
|
||||
|
||||
|
||||
def test_classify_offline_band_reunion_impact():
|
||||
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
|
||||
last = now - timedelta(days=5)
|
||||
result = classify_offline_band(last, now, OfflineThresholds())
|
||||
assert result.band == "reunion_impact"
|
||||
assert result.reunion_intensity > 0.5
|
||||
|
||||
|
||||
def test_classify_offline_boundary_59min_is_active():
|
||||
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
|
||||
last = now - timedelta(minutes=59)
|
||||
assert classify_offline_band(last, now, OfflineThresholds()).band == "active"
|
||||
|
||||
|
||||
def test_classify_offline_boundary_60min_is_short_absence():
|
||||
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
|
||||
last = now - timedelta(minutes=60)
|
||||
assert classify_offline_band(last, now, OfflineThresholds()).band == "short_absence"
|
||||
|
||||
|
||||
def test_offline_thresholds_violates_monotonicity():
|
||||
with pytest.raises(ValidationError):
|
||||
OfflineThresholds(active_minutes=100, short_absence_minutes=50)
|
||||
|
||||
|
||||
def test_reunion_intensity_clamped_05_to_1():
|
||||
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
|
||||
# 刚过 reunion_impact 阈值(4320 分钟 + 1 分钟)
|
||||
last = now - timedelta(minutes=4321)
|
||||
intensity = classify_offline_band(last, now, OfflineThresholds()).reunion_intensity
|
||||
assert 0.5 <= intensity <= 1.0
|
||||
|
||||
# 超大间隔:30 天 = 43200 分钟,应趋近 1.0
|
||||
last_far = now - timedelta(minutes=43200)
|
||||
intensity_far = classify_offline_band(last_far, now, OfflineThresholds()).reunion_intensity
|
||||
assert intensity_far > 0.9
|
||||
@@ -0,0 +1,101 @@
|
||||
"""农历/节气/节日引擎测试。"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from app.modules.time_sense.lunar import (
|
||||
Shichen,
|
||||
get_day_period,
|
||||
get_festivals,
|
||||
get_shichen,
|
||||
get_solar_term,
|
||||
solar_to_lunar,
|
||||
)
|
||||
|
||||
|
||||
def test_solar_to_lunar_spring_festival_2026():
|
||||
# 2026-02-17 是农历丙午年正月初一(春节)
|
||||
result = solar_to_lunar(date(2026, 2, 17))
|
||||
assert result.year_ganzhi == "丙午"
|
||||
assert result.month == 1
|
||||
assert result.day == 1
|
||||
assert result.is_leap_month is False
|
||||
|
||||
|
||||
def test_solar_to_lunar_leap_month_recognized():
|
||||
# 农历闰月标记:取一个已知闰月日期(如 2025 农历闰六月初一 ≈ 2025-07-25)
|
||||
result = solar_to_lunar(date(2025, 7, 25))
|
||||
assert result.month == 6
|
||||
assert result.is_leap_month is True
|
||||
|
||||
|
||||
def test_get_shichen_zi():
|
||||
# 子时:23 点与 0 点都属子
|
||||
assert get_shichen(23).name == "子"
|
||||
assert get_shichen(0).name == "子"
|
||||
|
||||
|
||||
def test_get_shichen_wu():
|
||||
assert get_shichen(12).name == "午"
|
||||
|
||||
|
||||
def test_get_day_period_deep_night():
|
||||
assert get_day_period(2) == "凌晨"
|
||||
assert get_day_period(23) == "深夜"
|
||||
|
||||
|
||||
def test_get_solar_term_returns_current_or_nearest():
|
||||
# 立春通常在 2/3-2/5;2026 年立春为 2/4
|
||||
info = get_solar_term(date(2026, 2, 4))
|
||||
assert info.current_name == "立春"
|
||||
|
||||
|
||||
def test_get_solar_term_cross_year_next_in_chinese():
|
||||
# 2026-12-25 跨年带入项,下一节气小寒在 getJieQiTable() 中以 XIAO_HAN 出现
|
||||
info = get_solar_term(date(2026, 12, 25))
|
||||
assert info.next_name == "小寒"
|
||||
assert info.current_name is None
|
||||
|
||||
|
||||
def test_get_solar_term_non_term_day_current_none():
|
||||
_ALL_JIEQI_CN = {
|
||||
"立春",
|
||||
"雨水",
|
||||
"惊蛰",
|
||||
"春分",
|
||||
"清明",
|
||||
"谷雨",
|
||||
"立夏",
|
||||
"小满",
|
||||
"芒种",
|
||||
"夏至",
|
||||
"小暑",
|
||||
"大暑",
|
||||
"立秋",
|
||||
"处暑",
|
||||
"白露",
|
||||
"秋分",
|
||||
"寒露",
|
||||
"霜降",
|
||||
"立冬",
|
||||
"小雪",
|
||||
"大雪",
|
||||
"冬至",
|
||||
"小寒",
|
||||
"大寒",
|
||||
}
|
||||
info = get_solar_term(date(2026, 7, 15))
|
||||
assert info.current_name is None
|
||||
assert info.next_name in _ALL_JIEQI_CN
|
||||
assert info.days_to_next > 0
|
||||
|
||||
|
||||
def test_get_festivals_spring_festival():
|
||||
festivals = get_festivals(date(2026, 2, 17))
|
||||
names = [f.name for f in festivals]
|
||||
assert "春节" in names
|
||||
|
||||
|
||||
def test_shichen_model_has_range():
|
||||
s = Shichen(name="子", start_hour=23, end_hour=1)
|
||||
assert s.name == "子"
|
||||
assert s.start_hour == 23
|
||||
@@ -0,0 +1,217 @@
|
||||
"""调制因子派生测试(辰正 §5.2 / §7.2 / §7.3)。"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from app.modules.time_sense.duration import OfflineDuration
|
||||
from app.modules.time_sense.lunar import Festival
|
||||
from app.modules.time_sense.modulation import (
|
||||
ModulationInput,
|
||||
compute_modulation,
|
||||
)
|
||||
from app.modules.time_sense.snapshot import (
|
||||
LocationContext,
|
||||
SnapshotInput,
|
||||
TemporalSnapshot,
|
||||
compute_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _festival_snapshot(festival_name: str) -> TemporalSnapshot:
|
||||
"""构造一个命中指定节日、白天的快照(用真实 compute_snapshot)。"""
|
||||
# 2026-02-17 = 春节正月初一;其它节日按需选日期
|
||||
festival_dates = {
|
||||
"春节": "2026-02-17",
|
||||
"中秋节": "2026-09-25", # 中秋八十五
|
||||
"国庆节": "2026-10-01",
|
||||
}
|
||||
iso = festival_dates.get(festival_name, "2026-07-04")
|
||||
y, m, d = (int(x) for x in iso.split("-"))
|
||||
inp = SnapshotInput(
|
||||
now=datetime(y, m, d, 4, 0, tzinfo=UTC), # UTC 04:00 = 上海 12:00(上午/中午)
|
||||
timezone="Asia/Shanghai",
|
||||
location=LocationContext(timezone="Asia/Shanghai", city="上海"),
|
||||
anniversaries=[],
|
||||
acquaintance_anchor=datetime(y, m, d, tzinfo=UTC),
|
||||
last_active_at=datetime(y, m, d, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
return compute_snapshot(inp)
|
||||
|
||||
|
||||
def _snapshot(**overrides) -> TemporalSnapshot:
|
||||
"""构造一个无节日的基线快照(上海上午、周末)。"""
|
||||
inp = SnapshotInput(
|
||||
now=datetime(2026, 7, 4, 4, 0, tzinfo=UTC), # 上海 12:00
|
||||
timezone="Asia/Shanghai",
|
||||
location=LocationContext(timezone="Asia/Shanghai", city="上海"),
|
||||
anniversaries=[],
|
||||
acquaintance_anchor=datetime(2025, 9, 19, tzinfo=UTC),
|
||||
last_active_at=datetime(2026, 7, 4, 3, 0, tzinfo=UTC),
|
||||
)
|
||||
snap = compute_snapshot(inp)
|
||||
# 允许测试覆盖关键字段
|
||||
mutated = snap.model_dump()
|
||||
mutated.update(overrides)
|
||||
return TemporalSnapshot(**mutated)
|
||||
|
||||
|
||||
def test_festival_joy_no_festival_low():
|
||||
factors = compute_modulation(_snapshot(), ModulationInput())
|
||||
assert factors.festival_joy == pytest.approx(0.1, abs=0.01)
|
||||
|
||||
|
||||
def test_festival_joy_spring_festival_high():
|
||||
snap = _festival_snapshot("春节")
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
assert factors.festival_joy == pytest.approx(0.9, abs=0.01)
|
||||
|
||||
|
||||
def test_festival_joy_multi_takes_max():
|
||||
# 多节日同时命中:取最大权重(Finding 3:覆盖 _festival_joy 的 max 聚合路径)
|
||||
snap = _snapshot(
|
||||
festivals=[
|
||||
Festival(name="春节", calendar="lunar"),
|
||||
Festival(name="元旦", calendar="solar"),
|
||||
]
|
||||
)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
assert factors.festival_joy == pytest.approx(0.9, abs=0.01)
|
||||
|
||||
|
||||
def test_late_night_fragility_deep_night_with_turns():
|
||||
# 凌晨 + 连续轮数 → 脆弱度上升
|
||||
snap = _snapshot(day_period="凌晨", local_hour=2)
|
||||
f0 = compute_modulation(snap, ModulationInput(conversation_turns_today=0))
|
||||
f3 = compute_modulation(snap, ModulationInput(conversation_turns_today=3))
|
||||
assert f0.late_night_fragility == pytest.approx(0.7, abs=0.01)
|
||||
assert f3.late_night_fragility > f0.late_night_fragility
|
||||
assert f3.late_night_fragility <= 1.0
|
||||
|
||||
|
||||
def test_late_night_fragility_daytime_low():
|
||||
factors = compute_modulation(_snapshot(), ModulationInput()) # 上午
|
||||
assert factors.late_night_fragility == pytest.approx(0.1, abs=0.01)
|
||||
|
||||
|
||||
def test_reunion_intensity_passes_through():
|
||||
snap = _snapshot(
|
||||
offline=OfflineDuration(minutes=10000, band="reunion_impact", reunion_intensity=0.85)
|
||||
)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
assert factors.reunion_intensity == pytest.approx(0.85, abs=0.01)
|
||||
|
||||
|
||||
def test_weekend_relax_weekend_with_festival():
|
||||
snap = _festival_snapshot("国庆节") # 2026-10-01 周四 → 不是周末,手工覆盖
|
||||
snap = snap.model_copy(update={"is_weekend": True, "festivals": snap.festivals})
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
assert factors.weekend_relax == pytest.approx(0.9, abs=0.01)
|
||||
|
||||
|
||||
def test_weekend_relax_workday():
|
||||
snap = _snapshot(is_weekend=False)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
assert factors.weekend_relax == pytest.approx(0.2, abs=0.01)
|
||||
|
||||
|
||||
def test_season_resonance_in_range():
|
||||
factors = compute_modulation(_snapshot(), ModulationInput()) # 夏
|
||||
assert 0.0 <= factors.season_resonance <= 1.0
|
||||
assert factors.season_resonance == pytest.approx(0.6, abs=0.01)
|
||||
|
||||
|
||||
def test_same_period_resonance_morning_low():
|
||||
factors = compute_modulation(_snapshot(), ModulationInput()) # 上午
|
||||
assert factors.same_period_resonance == pytest.approx(0.2, abs=0.01)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"signals, snapshot_overrides, expected_kind",
|
||||
[
|
||||
# 深夜 + 时差 ≥ 3h → jetlag
|
||||
(
|
||||
{"user_birthday_today": False},
|
||||
{"day_period": "凌晨", "local_hour": 2, "home_tz_diff_minutes": 200},
|
||||
"jetlag",
|
||||
),
|
||||
# 用户生日 → birthday(优先级高)
|
||||
({"user_birthday_today": True}, {}, "birthday"),
|
||||
# 清晨 4-5 点首条消息 → early_morning
|
||||
(
|
||||
{"is_first_message_today": True},
|
||||
{"day_period": "清晨", "local_hour": 5},
|
||||
"early_morning",
|
||||
),
|
||||
# 深夜工作话题 → overwork
|
||||
({"content_work_related": True}, {"day_period": "深夜", "local_hour": 23}, "overwork"),
|
||||
# 节日 + 孤独 → festival_lonely
|
||||
(
|
||||
{"content_lonely": True},
|
||||
{"festivals": [Festival(name="春节", calendar="lunar")]},
|
||||
"festival_lonely",
|
||||
),
|
||||
# 凌晨 + 连续 ≥ 3 轮 → late_night
|
||||
({"conversation_turns_today": 3}, {"day_period": "凌晨", "local_hour": 2}, "late_night"),
|
||||
# 深夜 + 连续 ≥ 3 轮 → late_night(Finding 1 回归防护:深夜也触发)
|
||||
({"conversation_turns_today": 3}, {"day_period": "深夜", "local_hour": 23}, "late_night"),
|
||||
],
|
||||
)
|
||||
def test_care_kind_triggers(signals, snapshot_overrides, expected_kind):
|
||||
snap = _snapshot(**snapshot_overrides)
|
||||
factors = compute_modulation(snap, ModulationInput(**signals))
|
||||
assert factors.care_kind == expected_kind
|
||||
|
||||
|
||||
def test_care_kind_none_when_no_trigger():
|
||||
factors = compute_modulation(_snapshot(), ModulationInput()) # 白天、无信号
|
||||
assert factors.care_kind is None
|
||||
|
||||
|
||||
def test_care_suggest_high_when_care_kind_hit():
|
||||
snap = _snapshot(day_period="凌晨", local_hour=2, home_tz_diff_minutes=200)
|
||||
factors = compute_modulation(snap, ModulationInput())
|
||||
assert factors.care_suggest >= 0.7
|
||||
|
||||
|
||||
def test_care_suggest_low_when_no_care():
|
||||
factors = compute_modulation(_snapshot(), ModulationInput()) # 上午
|
||||
assert factors.care_suggest < 0.2
|
||||
|
||||
|
||||
def test_all_factors_in_unit_range():
|
||||
snap = _snapshot(day_period="凌晨", local_hour=2)
|
||||
factors = compute_modulation(snap, ModulationInput(conversation_turns_today=5))
|
||||
for name in (
|
||||
"festival_joy",
|
||||
"late_night_fragility",
|
||||
"reunion_intensity",
|
||||
"weekend_relax",
|
||||
"season_resonance",
|
||||
"same_period_resonance",
|
||||
"care_suggest",
|
||||
):
|
||||
v = getattr(factors, name)
|
||||
assert 0.0 <= v <= 1.0, f"{name}={v} 超出 [0,1]"
|
||||
|
||||
|
||||
def test_care_kind_jetlag_threshold_configurable():
|
||||
"""设计 §7.3:jetlag 阈值经 ModulationInput 可调(守护配置接入,防回归摆设)。"""
|
||||
snap = _snapshot(day_period="凌晨", local_hour=2, home_tz_diff_minutes=200)
|
||||
# 默认阈值 180:200 >= 180 → 触发 jetlag
|
||||
assert compute_modulation(snap, ModulationInput()).care_kind == "jetlag"
|
||||
# 抬高阈值到 300:200 < 300 → 不再 jetlag(其余条件也不命中 → None)
|
||||
raised = compute_modulation(snap, ModulationInput(jetlag_threshold_minutes=300))
|
||||
assert raised.care_kind != "jetlag"
|
||||
|
||||
|
||||
def test_care_kind_late_night_turns_configurable():
|
||||
"""设计 §7.3:late_night 最小轮数经 ModulationInput 可调。"""
|
||||
snap = _snapshot(day_period="凌晨", local_hour=2)
|
||||
# 2 轮 < 默认 3 → 不触发
|
||||
assert compute_modulation(snap, ModulationInput(conversation_turns_today=2)).care_kind is None
|
||||
# 阈值降到 2:2 >= 2 → 触发 late_night
|
||||
lowered = compute_modulation(
|
||||
snap, ModulationInput(conversation_turns_today=2, late_night_min_turns=2)
|
||||
)
|
||||
assert lowered.care_kind == "late_night"
|
||||
@@ -0,0 +1,70 @@
|
||||
"""感慨触发纯函数测试(辰正 §7.2)。"""
|
||||
|
||||
from app.modules.time_sense.sentiment import SentimentInput, detect_sentiment
|
||||
from app.modules.time_sense.snapshot import TemporalSnapshot
|
||||
|
||||
|
||||
def _snapshot(**overrides) -> TemporalSnapshot:
|
||||
"""复用 modulation 测试的快照构造思路:构造最小可用 snapshot。"""
|
||||
from tests.modules.time_sense.test_modulation import _snapshot as _base_snapshot
|
||||
|
||||
return _base_snapshot(**overrides)
|
||||
|
||||
|
||||
def test_duration_node_trigger():
|
||||
snap = _snapshot()
|
||||
signals = SentimentInput(duration_days=100, duration_nodes={100, 365})
|
||||
assert detect_sentiment(snap, signals) == "duration_node"
|
||||
|
||||
|
||||
def test_anniversary_prewarm_trigger():
|
||||
snap = _snapshot()
|
||||
signals = SentimentInput(duration_days=50, next_milestone_days=5, prewarm_days=7)
|
||||
assert detect_sentiment(snap, signals) == "anniversary_prewarm"
|
||||
|
||||
|
||||
def test_anniversary_prewarm_not_today():
|
||||
# next_milestone_days=0 表示今日命中(active_milestone),不算 prewarm
|
||||
snap = _snapshot()
|
||||
signals = SentimentInput(duration_days=50, next_milestone_days=0, prewarm_days=7)
|
||||
assert detect_sentiment(snap, signals) != "anniversary_prewarm"
|
||||
|
||||
|
||||
def test_reunion_memory_trigger():
|
||||
snap = _snapshot()
|
||||
signals = SentimentInput(duration_days=10, is_reunion=True, has_reunion_memory=True)
|
||||
assert detect_sentiment(snap, signals) == "reunion_memory"
|
||||
|
||||
|
||||
def test_late_night_overlay_trigger():
|
||||
snap = _snapshot(day_period="凌晨")
|
||||
signals = SentimentInput(duration_days=10, is_reunion=True)
|
||||
assert detect_sentiment(snap, signals) == "late_night_overlay"
|
||||
|
||||
|
||||
def test_solar_term_trigger():
|
||||
from app.modules.time_sense.lunar import SolarTermInfo
|
||||
|
||||
snap = _snapshot(
|
||||
solar_term=SolarTermInfo(current_name="立春", next_name="雨水", days_to_next=15)
|
||||
)
|
||||
signals = SentimentInput(duration_days=10, strong_solar_terms={"立春"})
|
||||
assert detect_sentiment(snap, signals) == "solar_term"
|
||||
|
||||
|
||||
def test_no_trigger_returns_none():
|
||||
snap = _snapshot()
|
||||
signals = SentimentInput(duration_days=10)
|
||||
assert detect_sentiment(snap, signals) is None
|
||||
|
||||
|
||||
def test_priority_duration_over_prewarm():
|
||||
"""认识 100 天且距周年 3 天 → duration_node 优先。"""
|
||||
snap = _snapshot()
|
||||
signals = SentimentInput(
|
||||
duration_days=100,
|
||||
next_milestone_days=3,
|
||||
duration_nodes={100},
|
||||
prewarm_days=7,
|
||||
)
|
||||
assert detect_sentiment(snap, signals) == "duration_node"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""时态快照测试。"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.modules.time_sense.duration import OfflineThresholds
|
||||
from app.modules.time_sense.snapshot import (
|
||||
AnniversaryRef,
|
||||
LocationContext,
|
||||
SnapshotInput,
|
||||
compute_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _input(**overrides):
|
||||
base = {
|
||||
"now": datetime(2026, 7, 3, 2, 42, tzinfo=UTC),
|
||||
"timezone": "Asia/Shanghai",
|
||||
"location": LocationContext(timezone="Asia/Shanghai", city="上海"),
|
||||
"anniversaries": [],
|
||||
"acquaintance_anchor": datetime(2025, 9, 19, tzinfo=UTC),
|
||||
"last_active_at": datetime(2026, 7, 3, 2, 0, tzinfo=UTC),
|
||||
"home_timezone": "Asia/Shanghai",
|
||||
"offline_thresholds": OfflineThresholds(),
|
||||
}
|
||||
base.update(overrides)
|
||||
return SnapshotInput(**base)
|
||||
|
||||
|
||||
def test_snapshot_basic_fields():
|
||||
snap = compute_snapshot(_input())
|
||||
assert snap.timezone == "Asia/Shanghai"
|
||||
# 上海 UTC+8,02:42 UTC -> 10:42 本地
|
||||
assert snap.local_hour == 10
|
||||
assert snap.shichen in {"辰", "巳"} # 7-11 点区间
|
||||
assert snap.day_period in {"上午"}
|
||||
assert snap.weekday is not None
|
||||
|
||||
|
||||
def test_snapshot_acquaintance_and_offline():
|
||||
snap = compute_snapshot(_input())
|
||||
assert snap.acquaintance.total_days > 280
|
||||
assert snap.offline.band == "active"
|
||||
|
||||
|
||||
def test_snapshot_home_tz_diff_zero():
|
||||
snap = compute_snapshot(_input())
|
||||
assert snap.home_tz_diff_minutes == 0
|
||||
|
||||
|
||||
def test_snapshot_next_milestone_from_anniversary():
|
||||
# 下一个相识周年:相识日 2025-09-19,到 2026-07-03 距下一周年(2026-09-19)约 78 天
|
||||
anni = AnniversaryRef(
|
||||
id="anniv_1",
|
||||
name="相识纪念日",
|
||||
calendar="solar",
|
||||
date_iso="2025-09-19",
|
||||
recurs_yearly=True,
|
||||
kind="anniversary",
|
||||
)
|
||||
snap = compute_snapshot(_input(anniversaries=[anni]))
|
||||
assert snap.next_milestone is not None
|
||||
assert snap.next_milestone.name == "相识纪念日"
|
||||
assert 70 < snap.next_milestone.days_until < 85
|
||||
|
||||
|
||||
def test_snapshot_active_milestone_today():
|
||||
# 相识日恰好今天(构造一个今日命中的纪念日)
|
||||
today = datetime(2026, 7, 3, tzinfo=UTC)
|
||||
|
||||
anni = AnniversaryRef(
|
||||
id="anniv_birthday",
|
||||
name="小明生日",
|
||||
calendar="solar",
|
||||
date_iso="2026-07-03",
|
||||
recurs_yearly=False,
|
||||
kind="birthday",
|
||||
)
|
||||
snap = compute_snapshot(_input(anniversaries=[anni], acquaintance_anchor=today))
|
||||
assert any(m.name == "小明生日" for m in snap.active_milestones)
|
||||
assert snap.next_milestone is None or snap.next_milestone.name != "小明生日"
|
||||
|
||||
|
||||
def test_snapshot_lunar_present():
|
||||
snap = compute_snapshot(_input())
|
||||
# 农历字段非空(lunar_python 可用时)
|
||||
assert snap.lunar is not None
|
||||
assert snap.lunar.year_ganzhi == "丙午"
|
||||
|
||||
|
||||
def test_snapshot_2_29_anniversary_in_non_leap_year():
|
||||
# 2/29 周年在非闰年不应崩溃,且应回退到 2/28 后能算出下一周年
|
||||
anni = AnniversaryRef(
|
||||
id="anniv_leap",
|
||||
name="闰年相识",
|
||||
calendar="solar",
|
||||
date_iso="2024-02-29",
|
||||
recurs_yearly=True,
|
||||
kind="anniversary",
|
||||
)
|
||||
snap = compute_snapshot(_input(anniversaries=[anni]))
|
||||
assert snap.next_milestone is not None
|
||||
assert snap.next_milestone.name == "闰年相识"
|
||||
|
||||
|
||||
def test_snapshot_recurring_milestone_today():
|
||||
# recurs_yearly=True 且月日命中今天:应进入 active_milestones
|
||||
anni = AnniversaryRef(
|
||||
id="anniv_today",
|
||||
name="今日周年",
|
||||
calendar="solar",
|
||||
date_iso="2026-07-03",
|
||||
recurs_yearly=True,
|
||||
kind="anniversary",
|
||||
)
|
||||
snap = compute_snapshot(_input(anniversaries=[anni]))
|
||||
assert any(m.id == "anniv_today" for m in snap.active_milestones)
|
||||
Reference in new issue
Block a user