271 lines
11 KiB
Python
271 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""投影登记与恢复对 muse-example 真实库的集成测试。
|
||
|
||
覆盖:
|
||
- 提交即登记:accept 同事务登记 pending 投影,绑正文 revision 与文本哈希;重放不重复登记;
|
||
- 换版即失效:新 revision 提交后旧投影全部 stale;
|
||
- 失败不冒充完成:stale/failed 不得直接置 completed(触发器兜底),failed 显式 retry 才回 pending;
|
||
- 恢复巡检:refresh_staleness 按当前正文对账,漂移投影标 stale。
|
||
|
||
测试数据 unittest-proj- 前缀隔离,结束物理清理(本表可变,直接 DELETE)。
|
||
|
||
跑法(需 Tailscale 内网可达 muse-example):
|
||
.venv/bin/python tests/skills/决定正文候选去留/test_projection_db.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
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" / "content" / "work" / "skills" / "sovereignty" / "决定正文候选去留" / "scripts"
|
||
for path in (SCRIPT_DIR,):
|
||
if str(path) not in sys.path:
|
||
sys.path.insert(0, str(path))
|
||
|
||
from muse_db import connect # noqa: E402
|
||
from projection_registry import ( # noqa: E402
|
||
ProjectionError,
|
||
finish_projection,
|
||
refresh_staleness,
|
||
retry_projection,
|
||
)
|
||
from write_canonical import accept # noqa: E402
|
||
|
||
SUFFIX = uuid.uuid4().hex[:10]
|
||
WORK_TITLE = f"unittest-proj-{SUFFIX}"
|
||
_command_ids: list[str] = []
|
||
_candidate_ids: list[int] = []
|
||
_work_id: int | None = None
|
||
|
||
|
||
def _sha(text: str) -> str:
|
||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def _cleanup() -> None:
|
||
if _work_id is None:
|
||
return
|
||
with connect() as conn:
|
||
try:
|
||
conn.execute(
|
||
"ALTER TABLE example_user_decision DISABLE TRIGGER trg_example_user_decision_append_only")
|
||
try:
|
||
if _candidate_ids:
|
||
conn.execute("DELETE FROM example_user_decision WHERE candidate_id = ANY(%s)",
|
||
(_candidate_ids,))
|
||
if _command_ids:
|
||
conn.execute("DELETE FROM muse_content_command_log WHERE command_id = ANY(%s)",
|
||
(_command_ids,))
|
||
conn.execute("DELETE FROM example_projection_run WHERE work_id=%s", (_work_id,))
|
||
conn.execute("DELETE FROM muse_content_block_source_attribution WHERE work_id=%s",
|
||
(_work_id,))
|
||
conn.execute("DELETE FROM muse_content_block WHERE work_id=%s", (_work_id,))
|
||
conn.execute("DELETE FROM muse_content_chapter WHERE work_id=%s", (_work_id,))
|
||
if _candidate_ids:
|
||
conn.execute("DELETE FROM example_candidate WHERE id = ANY(%s)",
|
||
(_candidate_ids,))
|
||
conn.execute("DELETE FROM muse_content_work WHERE id=%s", (_work_id,))
|
||
conn.commit()
|
||
finally:
|
||
conn.execute(
|
||
"ALTER TABLE example_user_decision ENABLE TRIGGER trg_example_user_decision_append_only")
|
||
conn.commit()
|
||
except Exception:
|
||
conn.rollback()
|
||
raise
|
||
|
||
|
||
def _setup() -> int:
|
||
global _work_id
|
||
with connect() as conn:
|
||
work_id = conn.execute(
|
||
"INSERT INTO muse_content_work(owner_user_id, title, status, creator) "
|
||
"VALUES (1,%s,'writing','unittest') RETURNING id", (WORK_TITLE,)).fetchone()[0]
|
||
conn.execute(
|
||
"INSERT INTO muse_content_chapter(work_id, title, order_no, creator) "
|
||
"VALUES (%s,'测试章',1,'unittest')", (work_id,))
|
||
conn.commit()
|
||
_work_id = work_id
|
||
return work_id
|
||
|
||
|
||
def _insert_candidate(work_id: int, *, body: str, version: str) -> int:
|
||
with connect() as conn:
|
||
candidate_id = conn.execute(
|
||
"INSERT INTO example_candidate(work_id, target_chapter, run_id, attempt, run_type, "
|
||
"candidate_version, candidate_sha256, candidate_body, quality_policy_version, mode, "
|
||
"source_role, state, acceptance_eligible, semantic_status, semantic_report_sha256, creator) "
|
||
"VALUES (%s,1,%s,1,'production',%s,%s,%s,'writer-production-v1','continuation','writer',"
|
||
"'passed',TRUE,'passed',%s,'unittest') RETURNING id",
|
||
(work_id, f"unittest-proj-run-{SUFFIX}-{version}", version, _sha(body), body,
|
||
_sha("sem:" + body))).fetchone()[0]
|
||
conn.commit()
|
||
_candidate_ids.append(candidate_id)
|
||
return candidate_id
|
||
|
||
|
||
def _command_id(tag: str) -> str:
|
||
cid = f"unittest-proj-{tag}-{SUFFIX}"
|
||
_command_ids.append(cid)
|
||
return cid
|
||
|
||
|
||
def _projections(work_id: int) -> list[tuple]:
|
||
with connect(readonly=True) as conn:
|
||
return conn.execute(
|
||
"SELECT id, kind, source_revision, source_text_hash, status, attempt "
|
||
"FROM example_projection_run WHERE work_id=%s ORDER BY id", (work_id,)).fetchall()
|
||
|
||
|
||
def test_commit_registers_and_replay_is_noop(work_id: int) -> None:
|
||
body = f"投影测试正文第一版 {SUFFIX}"
|
||
candidate_id = _insert_candidate(work_id, body=body, version="1")
|
||
command_id = _command_id("accept-1")
|
||
result = accept(candidate_id, rationale="unittest", expected_revision=0,
|
||
command_id=command_id, projection_kinds=("extraction", "summary"))
|
||
assert result["status"] == "accepted"
|
||
rows = _projections(work_id)
|
||
assert len(rows) == 2 and result["projection_ids"] == [row[0] for row in rows]
|
||
for row in rows:
|
||
assert row[2] == result["revision"], "投影必须绑本次正文 revision"
|
||
assert row[3] == _sha(body), "投影必须绑本次正文哈希"
|
||
assert row[4] == "pending"
|
||
|
||
replay = accept(candidate_id, rationale="unittest", expected_revision=1,
|
||
command_id=command_id, projection_kinds=("extraction", "summary"))
|
||
assert replay["status"] == "already_applied"
|
||
assert len(_projections(work_id)) == 2, "重放不得重复登记投影"
|
||
|
||
|
||
def test_new_revision_stales_old_projections(work_id: int) -> None:
|
||
body = f"投影测试正文第二版 {SUFFIX}"
|
||
candidate_id = _insert_candidate(work_id, body=body, version="2")
|
||
result = accept(candidate_id, rationale="unittest", expected_revision=1,
|
||
command_id=_command_id("accept-2"), projection_kinds=("extraction",))
|
||
assert result["status"] == "accepted" and result["revision"] == 2
|
||
rows = _projections(work_id)
|
||
stale_rows = [row for row in rows if row[4] == "stale"]
|
||
pending_rows = [row for row in rows if row[4] == "pending"]
|
||
# rev1 的 extraction+summary 全部 stale;rev2 的 extraction 是唯一 pending
|
||
assert len(stale_rows) == 2 and all(row[2] == 1 for row in stale_rows)
|
||
assert len(pending_rows) == 1 and pending_rows[0][2] == 2
|
||
assert pending_rows[0][1] == "extraction"
|
||
|
||
|
||
def test_failed_never_masquerades_completed(work_id: int) -> None:
|
||
rows = _projections(work_id)
|
||
pending = next(row for row in rows if row[4] == "pending")
|
||
# worker 报告失败
|
||
finish_projection(pending[0], "failed", detail={"reason": "extractor_unbuilt"})
|
||
# 失败不得直接洗白成 completed(应用层条件 UPDATE 不命中)
|
||
try:
|
||
finish_projection(pending[0], "completed")
|
||
raise AssertionError("failed 投影不得报告 completed")
|
||
except AssertionError:
|
||
raise
|
||
except ProjectionError:
|
||
pass
|
||
# 绕过应用层直接 UPDATE 也被触发器拒绝
|
||
try:
|
||
with connect() as conn:
|
||
conn.execute("UPDATE example_projection_run SET status='completed' WHERE id=%s",
|
||
(pending[0],))
|
||
conn.commit()
|
||
raise AssertionError("触发器必须拒绝 failed→completed")
|
||
except AssertionError:
|
||
raise
|
||
except RaiseException:
|
||
pass
|
||
# 显式 retry 才能回 pending,且 attempt+1
|
||
retried = retry_projection(pending[0])
|
||
assert retried["status"] == "pending" and retried["attempt"] == 2
|
||
ok = finish_projection(pending[0], "completed")
|
||
assert ok["status"] == "completed"
|
||
# completed 不得重试
|
||
try:
|
||
retry_projection(pending[0])
|
||
raise AssertionError("completed 投影不得重试")
|
||
except AssertionError:
|
||
raise
|
||
except ProjectionError:
|
||
pass
|
||
|
||
|
||
def test_stale_projections_cannot_report_outcomes(work_id: int) -> None:
|
||
stale_row = next(row for row in _projections(work_id) if row[4] == "stale")
|
||
try:
|
||
with connect() as conn:
|
||
conn.execute("UPDATE example_projection_run SET status='completed' WHERE id=%s",
|
||
(stale_row[0],))
|
||
conn.commit()
|
||
raise AssertionError("stale→completed 必须被拒绝")
|
||
except AssertionError:
|
||
raise
|
||
except RaiseException:
|
||
pass
|
||
try:
|
||
with connect() as conn:
|
||
conn.execute("UPDATE example_projection_run SET status='failed' WHERE id=%s",
|
||
(stale_row[0],))
|
||
conn.commit()
|
||
raise AssertionError("stale→failed 必须被拒绝")
|
||
except AssertionError:
|
||
raise
|
||
except RaiseException:
|
||
pass
|
||
# stale 走 retry 恢复:attempt+1 回 pending,随后可以正常完成
|
||
retried = retry_projection(stale_row[0])
|
||
assert retried["status"] == "pending"
|
||
finish_projection(stale_row[0], "completed")
|
||
|
||
|
||
def test_refresh_staleness_detects_hash_drift(work_id: int) -> None:
|
||
# 手工造一条"漂移"投影:revision 与当前正文一致但哈希是旧的(模拟派生后正文被改)
|
||
with connect(readonly=True) as conn:
|
||
current = conn.execute(
|
||
"SELECT COALESCE(MAX(b.revision),0) FROM muse_content_block b "
|
||
"JOIN muse_content_chapter c ON b.chapter_id=c.id AND c.deleted=false "
|
||
"WHERE c.work_id=%s AND c.order_no=1 AND b.deleted=false", (work_id,)).fetchone()[0]
|
||
with connect() as conn:
|
||
drifted_id = conn.execute(
|
||
"INSERT INTO example_projection_run(work_id, target_chapter, kind, source_revision, "
|
||
"source_text_hash, status, idempotency_key, creator) "
|
||
"VALUES (%s,1,'dashboard',%s,%s,'completed',%s,'unittest') RETURNING id",
|
||
(work_id, current, "f" * 64, f"unittest-drift-{SUFFIX}")).fetchone()[0]
|
||
conn.commit()
|
||
stale_ids = refresh_staleness(work_id=work_id)
|
||
assert drifted_id in stale_ids, "哈希漂移的投影必须被标 stale"
|
||
with connect(readonly=True) as conn:
|
||
status = conn.execute(
|
||
"SELECT status FROM example_projection_run WHERE id=%s", (drifted_id,)).fetchone()[0]
|
||
assert status == "stale"
|
||
|
||
|
||
def main() -> None:
|
||
work_id = _setup()
|
||
tests = (
|
||
("提交即登记且重放幂等", test_commit_registers_and_replay_is_noop),
|
||
("换版即失效旧投影", test_new_revision_stales_old_projections),
|
||
("失败不冒充完成与重试", test_failed_never_masquerades_completed),
|
||
("stale 不得报告结果", test_stale_projections_cannot_report_outcomes),
|
||
("恢复巡检发现哈希漂移", test_refresh_staleness_detects_hash_drift),
|
||
)
|
||
try:
|
||
for name, test in tests:
|
||
test(work_id)
|
||
print(f"PASS: {name}")
|
||
print("PASS:投影登记与恢复真实库集成测试全部通过")
|
||
finally:
|
||
_cleanup()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|