muse-agent-example/tests/单元/test_离线修订回放.py
zizi 9e6f1c4481 R2 改造交付:新版模块化单体全量成果
- src/muse 新版全模块(装配/共享/上下文/任务运行/作品规划/故事世界/正文写作/审校修订/知识方法/作者经验/效果评测/交付连载/资料研究/正式变更/元数据/接入/基础设施/编排)+ 测试树(单元/契约/集成/架构/迁移/端到端/夹具)
- 129 项功能全部实现与自动验证(功能覆盖.json/矩阵),含 W31 补齐的规则与代价/节奏安排/伏笔与承诺
- 旧实现按处置清单退出(702 条中 324 删,保护合同与未迁移条目留存有据);web/app.py 旧工作台退役,新工作台为唯一写入口
- 数据库/旧库迁移:真实旧库内容批次迁移链(端点守卫/PG作品正文映射/质量资产缺省投影)
- 运行手册 docs/运行手册.md;W30 本机服务阶段一已运行(infra PG 为正式内容权威)
- R2 执行证据与私有运行材料在 .agents.local/改造/R2-20260909/(不入库)
2026-09-15 12:47:42 +08:00

244 lines
9.6 KiB
Python

"""离线原观察承接:真实重算诊断和保真,文件产物不能成为正式批准。"""
import json
from copy import deepcopy
from dataclasses import asdict, replace
import pytest
from pydantic import TypeAdapter, ValidationError
from muse.共享.错误 import Muse错误
from muse.审校修订.接口 import 允许选区, 离线修订回放, 离线修订请求, 诊断文本, 读取内置规则
from muse.正式变更.接口 import 固定哈希
from muse.正文写作.接口 import 可见文本, 换行节点, 文本节点, 正文草稿, 段落, 段落哈希, 选段修改
def 请求副本():
doc = 正文草稿((段落("p1", (文本节点("值得注意的是,门外已经下起了雨。"),)),))
artifact = 诊断文本(
"值得注意的是,门外已经下起了雨。",
work_id="synthetic:demo",
规则库=读取内置规则(),
预览候选=True,
)
finding = next(f for f in artifact["findings"] if f["rule_id"] == "l002")
patch = 选段修改("p1", 段落哈希(doc.paragraphs[0]), 0, 7, "值得注意的是,", "", finding["id"])
return TypeAdapter(离线修订请求).validate_python(
{
"work_id": "synthetic:demo",
"document": doc,
"diagnosis": artifact,
"patches": (patch,),
"allowed_ranges": (允许选区("p1", 0, 7),),
"approved_findings": (finding["id"],),
"purpose": "删除作者确认无功能的元话语",
"author_approved": True,
"author_choice": "candidate",
"fact_snapshot": {
"payload": {"entities": []},
"content_hash": 固定哈希({"entities": []}),
},
}
)
def test_修订机械门回放保真__239009():
"""只验证机械回放;独立成对选择由原观察集成测试另行核验。"""
req = 请求副本()
before = deepcopy(asdict(req))
report = 离线修订回放(req)
assert report["hard_gate"]["mechanical_pass"]
assert report["regression_gate"]["pass"]
assert report["candidate_text"] == "门外已经下起了雨。"
assert report["author_choice"] == "candidate" and report["final"]["action"] == "candidate"
assert report["summary"]["requested_changes"] == 1
assert not report["persisted"] and not report["model_verified"]
assert report["production_validity"] == "not_checked"
assert asdict(req) == before
def test_artifact_header_missing_blocks_patch__7c28d3():
req = 请求副本()
for field in ("mode", "text_hash", "rule_library_version", "schema_version"):
artifact = deepcopy(req.diagnosis)
artifact.pop(field)
with pytest.raises(Muse错误):
离线修订回放(replace(req, diagnosis=artifact))
def test_no_artifact_refuses_to_start__2a3922():
with pytest.raises(Muse错误, match="诊断"):
replace(请求副本(), diagnosis={})
def test_no_patches_refuses_to_start__c6ee33():
with pytest.raises(Muse错误, match="修改"):
replace(请求副本(), patches=())
def test_missing_snapshot_downgrades_to_audit__313365():
req = replace(请求副本(), fact_snapshot=None)
result = 离线修订回放(req)
assert result["final"]["action"] == "audit_only" and result["proposed_document"] is None
assert result["candidate_text"] == result["original_text"]
def test_snapshot_missing_with_explicit_authorization_proceeds__5ede83():
req = replace(请求副本(), fact_snapshot=None, no_snapshot_authorization=True)
result = 离线修订回放(req)
assert result["final"]["action"] == "candidate"
assert result["candidate_text"] == "门外已经下起了雨。"
assert result["fact_snapshot_verification"] == "missing" and not result["persisted"]
def test_forged_deterministic_finding_is_rejected__2fe050():
req = 请求副本()
artifact = deepcopy(req.diagnosis)
artifact["findings"][0]["rule_id"] = "l999"
with pytest.raises(Muse错误, match="确定性发现"):
离线修订回放(replace(req, diagnosis=artifact))
def test_unarbitrated_finding_is_rejected__860662():
req = replace(请求副本(), approved_findings=())
with pytest.raises(Muse错误, match="作者批准"):
离线修订回放(req)
def test_report_rejects_score_fields__1d7b17():
value = asdict(请求副本())
value["quality_score"] = 100
with pytest.raises(ValidationError):
TypeAdapter(离线修订请求).validate_python(value)
value.pop("quality_score")
value["hard_gate"] = {"pass": True, "checks": {}, "unverified": [], "human_score": 0.9}
with pytest.raises(ValidationError):
TypeAdapter(离线修订请求).validate_python(value)
artifact = deepcopy(value["diagnosis"])
artifact["human_probability"] = 1
with pytest.raises(Muse错误):
离线修订回放(replace(请求副本(), diagnosis=artifact))
def test_cli_offline_patch_chain_writes_report_without_db__9cc771(tmp_path, monkeypatch, capsys):
import muse.接入.cli.入口 as cli
def 禁止构建(*args, **kwargs):
pytest.fail("显式离线回放不应装配或读取数据库配置")
monkeypatch.setattr(cli, "构建", 禁止构建)
monkeypatch.setattr(cli, "读取配置", 禁止构建)
request = tmp_path / "request.json"
request.write_text(json.dumps(asdict(请求副本()), ensure_ascii=False))
assert cli.main(["审校", "不存在的配置.toml", "修订回放", str(request), "--离线"]) == 0
text = capsys.readouterr().out
output = tmp_path / "report.json"
output.write_text(text)
report = json.loads(output.read_text())
assert report["candidate_text"] == "门外已经下起了雨。"
assert report["mode"] == "offline_preview" and not report["persisted"]
def test_cli_requires_artifact_file__8b943d(tmp_path, capsys):
from muse.接入.cli.入口 import main
assert main(["审校", "-", "修订回放", str(tmp_path / "missing.json"), "--离线"]) != 0
error = json.loads(capsys.readouterr().err)
assert error["code"] and "请求文件" in error["message"]
def 以原文请求(doc, start, end, rule="l002"):
text = 可见文本(doc)
artifact = 诊断文本(text, work_id="synthetic:demo", 规则库=读取内置规则(), 预览候选=True)
finding = next(f for f in artifact["findings"] if f["rule_id"] == rule)
patch = 选段修改(
"p1", 段落哈希(doc.paragraphs[0]), start, end, text[start:end], "", finding["id"]
)
return replace(
请求副本(),
document=doc,
diagnosis=artifact,
patches=(patch,),
allowed_ranges=(允许选区("p1", start, end),),
approved_findings=(finding["id"],),
)
def test_patch_without_finding_rejected__3d92e0():
req = 请求副本()
with pytest.raises(Muse错误, match="真实发现"):
离线修订回放(replace(req, patches=(replace(req.patches[0], finding_id="absent"),)))
def test_patch_nonunique_match_rejected__1ef3f9():
text = "值得注意的是,值得注意的是,他来了。"
doc = 正文草稿((段落("p1", (文本节点(text),)),))
value = asdict(以原文请求(doc, 0, 7))
value["patches"] = [
{
"finding_id": value["approved_findings"][0],
"original": "值得注意的是,",
"replacement": "",
}
]
with pytest.raises(ValidationError):
TypeAdapter(离线修订请求).validate_python(value)
assert 可见文本(doc) == text
def test_patch_not_covering_span_rejected__c0739c():
req = 请求副本()
patch = replace(req.patches[0], start=1, original="得注意的是,")
with pytest.raises(Muse错误, match="未覆盖"):
离线修订回放(replace(req, patches=(patch,)))
def test_skip_patch_cannot_smuggle_replacement__96af2d():
value = asdict(请求副本())
patch = dict(value["patches"][0], action="skip", replacement="他突然笑了")
value["patches"] = [patch]
with pytest.raises(ValidationError):
TypeAdapter(离线修订请求).validate_python(value)
def test_patch_does_not_normalize_unrelated_blank_lines__2da14f():
doc = 正文草稿(
(
段落(
"p1",
(
文本节点("前文。"),
换行节点(),
换行节点(),
换行节点(),
文本节点("值得注意的是,他来了。"),
),
),
)
)
result = 离线修订回放(以原文请求(doc, 6, 13))
assert result["candidate_text"] == "前文。\n\n\n他来了。"
def test_adjacent_punctuation_extension_allowed__4de189():
text = "他打量她,嘴角微微上扬。「姑娘」"
doc = 正文草稿((段落("p1", (文本节点(text),)),))
result = 离线修订回放(以原文请求(doc, 4, 11, rule="l003"))
assert result["candidate_text"] == "他打量她。「姑娘」"
def test_patch_outside_author_scope_is_rejected__35024a():
req = replace(请求副本(), allowed_ranges=(允许选区("another-paragraph", 0, 7),))
with pytest.raises(Muse错误, match="允许范围"):
离线修订回放(req)
def test_fact_delta_blocks_new_number__b9e7a8():
doc = 正文草稿((段落("p1", (文本节点("值得注意的是,他付了三百两。"),)),))
req = 以原文请求(doc, 0, 7)
req = replace(req, patches=(replace(req.patches[0], replacement="他付了三百五十两,"),))
result = 离线修订回放(req)
assert not result["hard_gate"]["mechanical_pass"]
assert any("数字" in e for e in result["hard_gate"]["errors"])
assert result["candidate_text"] == result["original_text"]