muse-agent-example/tests/skills/记录运行证据/test_lesson_registry_db.py

210 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""经验升格链对 muse-example 真实库的集成测试。
覆盖:
- 登记校验:kind/title/detail 闭集与形状;
- 禁止自动升格:proposed 不得跳过评审直接 promoted(应用层 + DB 触发器双保险);
- 人工升格闭环:reviewing → promoted 必须指明目标类型与落点;
- 终态不可再流转;驳回可从 proposed/reviewing 发起。
测试行用 unittest-lesson- 前缀的 run_id 隔离,结束物理清理(本表可变)。
跑法(需 Tailscale 内网可达 muse-example):
.venv/bin/python tests/skills/记录运行证据/test_lesson_registry_db.py
"""
from __future__ import annotations
import pathlib
import sys
import uuid
from psycopg.errors import RaiseException
PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[3]
SKILLS_DIR = PROJECT_ROOT / ".agent" / "skills"
SCRIPT_DIR = PROJECT_ROOT / "muse" / "authority" / "evidence" / "skills" / "记录运行证据" / "scripts"
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from lesson_registry import ( # noqa: E402
LessonError,
list_lessons,
promote,
propose_lesson,
propose_lesson_dedup,
reject,
start_review,
)
from muse_db import connect # noqa: E402
SUFFIX = uuid.uuid4().hex[:10]
RUN_PREFIX = f"unittest-lesson-{SUFFIX}"
_seq = {"n": 0}
def _run_id() -> str:
_seq["n"] += 1
return f"{RUN_PREFIX}-{_seq['n']}"
def _cleanup() -> None:
with connect() as conn:
conn.execute("DELETE FROM example_lesson WHERE run_id LIKE %s", (f"{RUN_PREFIX}%",))
conn.commit()
def _propose(**overrides):
kwargs = dict(kind="lesson", title=f"测试经验 {SUFFIX}",
detail={"situation": "机械门通过但语义未跑", "action": "补语义兜底",
"outcome": "接受通道失败关闭"}, run_id=_run_id())
kwargs.update(overrides)
return propose_lesson(**kwargs)
def test_propose_validates_inputs() -> None:
out = _propose()
assert out["status"] == "proposed" and out["lesson_id"] >= 1
win = _propose(kind="win", title="成功锚点命中")
assert win["status"] == "proposed"
for bad_kwargs in (
dict(kind="epiphany"),
dict(title=""),
dict(title="x" * 201),
dict(detail={}),
):
try:
_propose(**bad_kwargs)
raise AssertionError(f"非法输入必须被拒绝: {bad_kwargs}")
except AssertionError:
raise
except LessonError:
pass
def test_no_auto_promotion() -> None:
out = _propose()
# 应用层:proposed 直接 promote 被拒(期望来源是 reviewing)
try:
promote(out["lesson_id"], decided_by="founder", target_type="pattern",
target_ref="pattern:test")
raise AssertionError("不得跳过评审自动升格")
except AssertionError:
raise
except LessonError:
pass
# 绕过应用层直接 UPDATE 也被触发器拒绝
try:
with connect() as conn:
conn.execute(
"UPDATE example_lesson SET status='promoted', promotion_target_type='pattern', "
"promotion_target_ref='pattern:test' WHERE id=%s", (out["lesson_id"],))
conn.commit()
raise AssertionError("触发器必须拒绝自动升格")
except AssertionError:
raise
except RaiseException:
pass
rows = list_lessons(status="proposed")
assert any(row["id"] == out["lesson_id"] for row in rows)
def test_human_promotion_requires_target() -> None:
out = _propose(kind="win")
start_review(out["lesson_id"], decided_by="founder", rationale="复利候选")
# reviewing 升格必须指明目标
try:
promote(out["lesson_id"], decided_by="founder", target_type="ritual", target_ref="x")
raise AssertionError("非法目标类型必须被拒绝")
except AssertionError:
raise
except LessonError:
pass
try:
promote(out["lesson_id"], decided_by="founder", target_type="pattern", target_ref="")
raise AssertionError("缺落点必须被拒绝")
except AssertionError:
raise
except LessonError:
pass
promoted = promote(out["lesson_id"], decided_by="founder", target_type="skill",
target_ref="写下一章", rationale="篇幅自纠环有效")
assert promoted["status"] == "promoted"
# 终态不得再流转
for action in (start_review, reject):
try:
action(out["lesson_id"], decided_by="founder")
raise AssertionError("promoted 终态不得再流转")
except AssertionError:
raise
except LessonError:
pass
def test_reject_paths() -> None:
direct = _propose()
assert reject(direct["lesson_id"], decided_by="founder", rationale="n=1 证据不足")["status"] == "rejected"
reviewed = _propose()
start_review(reviewed["lesson_id"], decided_by="founder")
assert reject(reviewed["lesson_id"], decided_by="founder")["status"] == "rejected"
# rejected 终态
try:
start_review(reviewed["lesson_id"], decided_by="founder")
raise AssertionError("rejected 终态不得再流转")
except AssertionError:
raise
except LessonError:
pass
def test_missing_decided_by_rejected() -> None:
out = _propose()
try:
start_review(out["lesson_id"], decided_by="")
raise AssertionError("decided_by 必填")
except AssertionError:
raise
except LessonError:
pass
def test_propose_dedup_idempotent() -> None:
run_id = _run_id()
title = f"dedup-{SUFFIX}"
first = propose_lesson_dedup(
kind="lesson", title=title,
detail={"skill": "unittest", "note": "first"},
run_id=run_id,
)
second = propose_lesson_dedup(
kind="lesson", title=title,
detail={"skill": "unittest", "note": "second"},
run_id=run_id,
)
assert first["deduped"] is False and second["deduped"] is True
assert first["lesson_id"] == second["lesson_id"]
rows = list_lessons(kind="lesson")
assert sum(1 for row in rows if row["run_id"] == run_id and row["title"] == title) == 1
def main() -> None:
tests = (
("登记输入校验", test_propose_validates_inputs),
("禁止自动升格", test_no_auto_promotion),
("人工升格必须指明目标", test_human_promotion_requires_target),
("驳回路径与终态", test_reject_paths),
("decided_by 必填", test_missing_decided_by_rejected),
("run_id+title 幂等", test_propose_dedup_idempotent),
)
try:
for name, test in tests:
test()
print(f"PASS: {name}")
print("PASS:经验升格链真实库集成测试全部通过")
finally:
_cleanup()
if __name__ == "__main__":
main()