一、技能重组(动作-对象命名) - 旧目录 clean/confirm/continuation/db/detect/embed/… 重组为 clean-book-text/decide-candidate/write-next-chapter/access-database/ check-content-consistency/embed-knowledge/…(git 识别为 rename,内容保持) - agents/*.md、AGENTS.md/CLAUDE.md 收编、example_skill 登记表同步新名 二、先审后入创作闭环(本次核心) 正文接受从"机械门一过就写正典"改为"机械门+语义审查双通过+用户批准+单事务原子提交", DB 级兜底,编排层跳步即被硬拒。 - candidate_cas.py + example_candidate_cas(109):持久化 CAS 状态链 - fact_delta.py + example_fact_delta/example_fact_ledger(106):结构化事实增量, 模型只提六型闭集增量+正文证据引文,仅用户批准的增量随正文同事务入账本 - projection_registry.py + example_projection_run(107):投影登记与恢复 - acceptance_state.py:接受前置实时状态重读 - lesson_registry.py + example_lesson(108):经验升格链,禁止自动升格 - DDL 105:example_candidate 增 semantic_status/semantic_report_sha256 - write_canonical.accept:语义兜底+同事务合并增量+登记投影; run_writer_pipeline/persist_writer_run/run_writer_semantic_detector/step2 接入全链 - claude_runtime:兼容新 CLI modelUsage 信息字段 三、审查修复(独立子代理四维审查后) - 事实增量 propose→approve 翻态正道,不撞唯一键 - 冻结配置探针重刷(CLI 2.1.211→2.1.231 漂移),profileSha256/adapterVersion 再登记 - 可视化合同悬空路径/五六空间矛盾、 SoT 旧技能名漂移、行尾空白清理 测试:离线 65 套 + 真实库集成 5 套(CAS/接受故障注入/事实增量/投影/经验升格)+ 回放 79 项全绿。 创作内容(docs/design、生成正文 artifacts)按"框架与创作分开"未入本提交。
628 lines
24 KiB
Python
628 lines
24 KiB
Python
#!/usr/bin/env python3
|
||
"""正文候选 Shadow 准入、用户三决策与接受前置检查测试。
|
||
|
||
夹具自包含:本文件内自行构造合法的 WriterContext v1 与 CandidateEnvelope v2,
|
||
不跨 skill 借用其它测试文件的夹具,避免上游合同变化时本测试静默腐烂。
|
||
只依赖生产模块(writer_contract / check_writer_acceptance / run_writer_pipeline)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import pathlib
|
||
import sys
|
||
import unittest
|
||
|
||
SCRIPT_DIR = pathlib.Path(__file__).resolve().parent
|
||
SKILLS_DIR = SCRIPT_DIR.parents[1]
|
||
CONTINUATION_DIR = SKILLS_DIR / "write-next-chapter" / "scripts"
|
||
READ_CONTEXT_DIR = SKILLS_DIR / "assemble-context" / "scripts"
|
||
for path in (SCRIPT_DIR, CONTINUATION_DIR, READ_CONTEXT_DIR):
|
||
if str(path) not in sys.path:
|
||
sys.path.insert(0, str(path))
|
||
|
||
from check_writer_acceptance import ( # noqa: E402
|
||
AcceptanceError,
|
||
check_shadow_ready,
|
||
check_writer_acceptance,
|
||
)
|
||
from run_writer_pipeline import InMemoryCasStateStore, run_writer_pipeline # noqa: E402
|
||
from writer_contract import ( # noqa: E402
|
||
build_candidate_envelope,
|
||
canonical_json,
|
||
han_count,
|
||
retrieval_identity,
|
||
)
|
||
|
||
|
||
def _valid_context() -> dict:
|
||
"""构造覆盖全部必填字段、哈希自绑定的最小合法生产 WriterContext v1。
|
||
|
||
检索计划、manifest 与上下文快照的身份哈希都用生产 `retrieval_identity`
|
||
重新计算并自绑定,保证通过 `validate_writer_context` 的失败关闭校验。
|
||
"""
|
||
|
||
prose_evidence = []
|
||
for chapter in range(485, 489):
|
||
text = f"第{chapter}章冻结原文。"
|
||
prose_evidence.append(
|
||
{
|
||
"evidenceId": f"prose:{chapter}",
|
||
"chapter": chapter,
|
||
"sourceRef": {
|
||
"sourceId": f"chapter:{chapter}",
|
||
"sourceVersion": f"chapter-{chapter}-v1",
|
||
"chapter": chapter,
|
||
"blockId": 1,
|
||
"startCodePoint": 0,
|
||
"endCodePoint": len(text),
|
||
},
|
||
"contentSha256": "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest(),
|
||
"purpose": "recent_full_chapter",
|
||
"text": text,
|
||
"isRecentBaseline": True,
|
||
}
|
||
)
|
||
context = {
|
||
"schemaVersion": "writer-context-v1",
|
||
"runId": "writer-run-001",
|
||
"attempt": 1,
|
||
"mode": "production",
|
||
"purpose": "production",
|
||
"qualityPolicyVersion": "writer-production-v1",
|
||
"workId": 8,
|
||
"targetChapter": 489,
|
||
"asOf": 488,
|
||
"contextSnapshot": {
|
||
"manifestId": "sha256:" + "1" * 64,
|
||
"contextSha256": "sha256:" + "2" * 64,
|
||
"generatedAt": "2026-07-20T00:00:00Z",
|
||
},
|
||
"sourceVersion": "raw-file-v1:sha256:" + "3" * 64,
|
||
"authorizationSnapshot": {
|
||
"snapshotId": "auth-001",
|
||
"allowedPurpose": "generation",
|
||
"verifiedAt": "2026-07-20T00:00:00Z",
|
||
},
|
||
"sourceStatus": "active",
|
||
"retrievalPlan": {
|
||
"planVersion": "writer-retrieval-plan-v1",
|
||
"planId": "sha256:" + "4" * 64,
|
||
"runId": "writer-run-001",
|
||
"asOf": 488,
|
||
"queries": [],
|
||
"cardIndexVersion": "card-v1",
|
||
"proseIndexVersion": "prose-v1",
|
||
"filters": {
|
||
"workId": 8,
|
||
"asOfChapter": 488,
|
||
"sourceStatus": "active",
|
||
"authorizationRequired": True,
|
||
},
|
||
"tieBreak": "score DESC, sourceVersion ASC, sourceId ASC, sourceOffset ASC",
|
||
"tokenBudget": {"maxContextChars": 20000},
|
||
},
|
||
"retrievalManifest": {
|
||
"manifestVersion": "writer-retrieval-manifest-v1",
|
||
"manifestId": "sha256:" + "1" * 64,
|
||
"planId": "sha256:" + "4" * 64,
|
||
"sources": [],
|
||
"omittedSources": [],
|
||
},
|
||
"fineOutline": {
|
||
"sourceRef": {
|
||
"sourceId": "fine-outline:489",
|
||
"sourceVersion": "fine-outline-v3",
|
||
"chapter": 489,
|
||
},
|
||
"hardConstraints": ["必须完成围攻突围"],
|
||
"adjustableBeats": [],
|
||
"declaredNewFacts": [],
|
||
},
|
||
"narrativeState": {
|
||
"time": "围攻当日",
|
||
"location": "圣蒂曼",
|
||
"characterPositions": {},
|
||
"immediateSituation": "战斗持续",
|
||
},
|
||
"factEvidence": [],
|
||
"proseEvidence": prose_evidence,
|
||
"patternReferences": [],
|
||
"evidenceCoverage": [],
|
||
"outputContract": {
|
||
"targetChars": 4000,
|
||
"minChars": 3600,
|
||
"maxChars": 4400,
|
||
"frontmatterRequired": False,
|
||
},
|
||
"tokenBudget": {"maxContextChars": 20000, "usedContextChars": 0},
|
||
"omittedSources": [],
|
||
"acceptanceEligible": True,
|
||
}
|
||
plan_payload = {key: value for key, value in context["retrievalPlan"].items() if key != "planId"}
|
||
context["retrievalPlan"]["planId"] = retrieval_identity(plan_payload)
|
||
context["retrievalManifest"]["planId"] = context["retrievalPlan"]["planId"]
|
||
manifest_payload = {key: value for key, value in context["retrievalManifest"].items() if key != "manifestId"}
|
||
context["retrievalManifest"]["manifestId"] = retrieval_identity(manifest_payload)
|
||
context["contextSnapshot"]["manifestId"] = context["retrievalManifest"]["manifestId"]
|
||
context["contextSnapshot"]["contextSha256"] = retrieval_identity(context)
|
||
return context
|
||
|
||
|
||
def _candidate_body() -> str:
|
||
"""构造满足机械门锚点与动态篇幅合同的 4000 汉字正文。
|
||
|
||
含硬事件「完成围攻突围」、角色「林澈」、伏笔「旧徽章」,并以章末钩子
|
||
「城门忽然打开」收尾(距结尾 20 字以内),正好落在 [3600, 4400] 区间。
|
||
"""
|
||
|
||
prefix = "林澈完成围攻突围,又把旧徽章压回掌心。"
|
||
hook = "城门忽然打开"
|
||
return prefix + "文" * (4000 - han_count(prefix) - han_count(hook)) + hook
|
||
|
||
|
||
def _requirements() -> dict:
|
||
"""构造机械门要求:硬事件、必备角色、伏笔动作与章末钩子锚点。"""
|
||
|
||
return {
|
||
"requiredEvents": [{"requirementId": "event-1", "anchors": ["完成围攻突围"]}],
|
||
"requiredCharacters": ["林澈"],
|
||
"foreshadowingActions": [{"requirementId": "foreshadow-1", "anchors": ["旧徽章"]}],
|
||
"chapterEndHook": {"requirementId": "hook-1", "anchors": ["城门忽然打开"], "maxDistanceFromEnd": 20},
|
||
}
|
||
|
||
|
||
def _candidate(context: dict, *, candidate_version: int = 1, body: str | None = None) -> dict:
|
||
"""经可信 adapter 合同 `build_candidate_envelope` 构造合法 CandidateEnvelope v2。"""
|
||
|
||
return build_candidate_envelope(
|
||
context,
|
||
{"candidateBody": body or _candidate_body()},
|
||
candidate_version=candidate_version,
|
||
)
|
||
|
||
|
||
def _valid_pair() -> tuple[dict, dict]:
|
||
"""返回一对自绑定的合法上下文与候选。"""
|
||
|
||
context = _valid_context()
|
||
return context, _candidate(context)
|
||
|
||
|
||
def _detector_result(candidate: dict) -> dict:
|
||
"""构造同时通过机械门和语义接口的最终 detector 结果。"""
|
||
|
||
return {
|
||
"schemaVersion": "writer-pipeline-result-v1",
|
||
"runId": candidate["runId"],
|
||
"status": "PASSED",
|
||
"attempt": candidate["attempt"],
|
||
"candidateVersion": candidate["candidateVersion"],
|
||
"candidateSha256": candidate["candidateSha256"],
|
||
"failureCode": None,
|
||
"evidenceRequestCount": 0,
|
||
"rewriteCount": 0,
|
||
"trace": [
|
||
{
|
||
"attempt": candidate["attempt"],
|
||
"candidateVersion": candidate["candidateVersion"],
|
||
"candidateSha256": candidate["candidateSha256"],
|
||
"mechanicalPassed": True,
|
||
"semanticStatus": "passed",
|
||
"failureCodes": [],
|
||
}
|
||
],
|
||
}
|
||
|
||
|
||
def _semantic_report(context: dict, candidate: dict, *, status: str = "passed") -> dict:
|
||
"""构造绑定当前候选、字段与哈希自洽的 SemanticDetection v3 报告。
|
||
|
||
reportSha256 用与生产 `_validate_semantic_report` 完全一致的算法计算
|
||
(对剔除 reportSha256 后的报告取规范 JSON 的 SHA-256),保证不漂移。
|
||
"""
|
||
|
||
report = {
|
||
"schemaVersion": "semantic-detection-v3",
|
||
"runId": context["runId"],
|
||
"sampleId": "acceptance-sample",
|
||
"opaqueArmId": "acceptance-candidate",
|
||
"inputSha256": "sha256:" + "1" * 64,
|
||
"candidateVersion": candidate["candidateVersion"],
|
||
"candidateSha256": candidate["candidateSha256"],
|
||
"contextSnapshotSha256": context["contextSnapshot"]["contextSha256"],
|
||
"modelReceiptSha256": "sha256:" + "2" * 64,
|
||
"status": status,
|
||
"claims": [],
|
||
"findings": [],
|
||
"assertionVerdicts": [],
|
||
"hardConstraintVerdicts": [],
|
||
"newSettingCandidates": [],
|
||
"evidenceGaps": [],
|
||
}
|
||
report["reportSha256"] = "sha256:" + hashlib.sha256(
|
||
canonical_json({key: value for key, value in report.items() if key != "reportSha256"}).encode("utf-8")
|
||
).hexdigest()
|
||
return report
|
||
|
||
|
||
def _semantic_pass(context: dict, candidate: dict, _mechanical: dict) -> dict:
|
||
"""语义 detector 桩:返回无阻塞项的 passed 报告。"""
|
||
|
||
return _semantic_report(context, candidate)
|
||
|
||
|
||
def _live_state(context: dict) -> dict:
|
||
"""构造接受时重新读取的可信实时状态。"""
|
||
|
||
return {
|
||
"qualityPolicyVersion": context["qualityPolicyVersion"],
|
||
"contextSnapshotId": context["contextSnapshot"]["manifestId"],
|
||
"contextSnapshotSha256": context["contextSnapshot"]["contextSha256"],
|
||
"authorizationSnapshotId": context["authorizationSnapshot"]["snapshotId"],
|
||
"authorizationValid": True,
|
||
"sourceStatus": "active",
|
||
"candidateExpiresAt": "2026-07-20T12:00:00Z",
|
||
"checkedAt": "2026-07-20T11:00:00Z",
|
||
"canonicalRevision": 7,
|
||
}
|
||
|
||
|
||
def _valid_inputs() -> tuple[dict, dict, dict, dict]:
|
||
"""返回合法上下文、候选、detector 终态和实时状态。"""
|
||
|
||
context, candidate = _valid_pair()
|
||
return context, candidate, _detector_result(candidate), _live_state(context)
|
||
|
||
|
||
def _rehash(candidate: dict) -> None:
|
||
"""按规范正文重算 candidateSha256,保持信封自洽。"""
|
||
|
||
candidate["candidateSha256"] = "sha256:" + hashlib.sha256(
|
||
candidate["candidateBody"].encode("utf-8")
|
||
).hexdigest()
|
||
|
||
|
||
def _edited_candidate(candidate: dict) -> dict:
|
||
"""模拟用户编辑后生成严格递增、重新哈希的新候选。
|
||
|
||
CandidateEnvelope v2 只绑定正文哈希,不含 claimLedger 等写手侧字段,
|
||
因此编辑只需替换正文并重算 candidateSha256。
|
||
"""
|
||
|
||
edited = copy.deepcopy(candidate)
|
||
edited["candidateVersion"] += 1
|
||
edited["candidateBody"] = "章" + edited["candidateBody"][1:]
|
||
_rehash(edited)
|
||
return edited
|
||
|
||
|
||
class WriterAcceptanceTest(unittest.TestCase):
|
||
def _assert_error(self, expected_code: str, function, **kwargs) -> None:
|
||
"""断言纯函数失败关闭并返回稳定错误码。"""
|
||
|
||
with self.assertRaises(AcceptanceError) as raised:
|
||
function(**kwargs)
|
||
self.assertEqual(raised.exception.code, expected_code)
|
||
self.assertFalse(raised.exception.acceptance_eligible)
|
||
|
||
def test_acceptance_eligible_false_is_hard_rejected(self):
|
||
context, candidate, detector, live = _valid_inputs()
|
||
candidate["acceptanceEligible"] = False
|
||
|
||
self._assert_error(
|
||
"ACCEPTANCE_NOT_ELIGIBLE",
|
||
check_shadow_ready,
|
||
context=context,
|
||
candidate=candidate,
|
||
detector_result=detector,
|
||
live_state=live,
|
||
)
|
||
|
||
def test_shadow_requires_detector_context_hash_policy_authorization_and_freshness(self):
|
||
context, candidate, detector, live = _valid_inputs()
|
||
cases = []
|
||
|
||
rejected_detector = copy.deepcopy(detector)
|
||
rejected_detector["status"] = "REJECTED"
|
||
rejected_detector["failureCode"] = "REWRITE_LIMIT_REACHED"
|
||
cases.append(("DETECTOR_NOT_PASSED", candidate, rejected_detector, live))
|
||
|
||
wrong_detector_schema = copy.deepcopy(detector)
|
||
wrong_detector_schema["schemaVersion"] = "writer-detector-report-v1"
|
||
cases.append(("DETECTOR_REPORT_INVALID", candidate, wrong_detector_schema, live))
|
||
|
||
stale_detector = copy.deepcopy(detector)
|
||
stale_detector["candidateSha256"] = "sha256:" + "f" * 64
|
||
cases.append(("DETECTOR_BINDING_MISMATCH", candidate, stale_detector, live))
|
||
|
||
mechanical_red = copy.deepcopy(detector)
|
||
mechanical_red["trace"][-1]["mechanicalPassed"] = False
|
||
cases.append(("DETECTOR_NOT_PASSED", candidate, mechanical_red, live))
|
||
|
||
semantic_pending = copy.deepcopy(detector)
|
||
semantic_pending["trace"][-1]["semanticStatus"] = "pending"
|
||
cases.append(("DETECTOR_NOT_PASSED", candidate, semantic_pending, live))
|
||
|
||
trace_has_failure = copy.deepcopy(detector)
|
||
trace_has_failure["trace"][-1]["failureCodes"] = ["HARD_EVENT_MISSING"]
|
||
cases.append(("DETECTOR_NOT_PASSED", candidate, trace_has_failure, live))
|
||
|
||
stale_trace = copy.deepcopy(detector)
|
||
stale_trace["trace"][-1]["candidateVersion"] = 0
|
||
cases.append(("DETECTOR_BINDING_MISMATCH", candidate, stale_trace, live))
|
||
|
||
bad_hash = copy.deepcopy(candidate)
|
||
bad_hash["candidateSha256"] = "sha256:" + "e" * 64
|
||
cases.append(("CANDIDATE_CONTRACT_INVALID", bad_hash, detector, live))
|
||
|
||
too_short = copy.deepcopy(candidate)
|
||
too_short["candidateBody"] = "文" * 1000
|
||
_rehash(too_short)
|
||
cases.append(
|
||
(
|
||
"CANDIDATE_LENGTH_OUT_OF_RANGE",
|
||
too_short,
|
||
_detector_result(too_short),
|
||
live,
|
||
)
|
||
)
|
||
|
||
stale_context = copy.deepcopy(live)
|
||
stale_context["contextSnapshotSha256"] = "sha256:" + "d" * 64
|
||
cases.append(("CONTEXT_STALE", candidate, detector, stale_context))
|
||
|
||
stale_policy = copy.deepcopy(live)
|
||
stale_policy["qualityPolicyVersion"] = "writer-production-v2"
|
||
cases.append(("QUALITY_POLICY_STALE", candidate, detector, stale_policy))
|
||
|
||
stale_authorization = copy.deepcopy(live)
|
||
stale_authorization["authorizationValid"] = False
|
||
cases.append(("AUTHORIZATION_STALE", candidate, detector, stale_authorization))
|
||
|
||
stale_source = copy.deepcopy(live)
|
||
stale_source["sourceStatus"] = "revoked"
|
||
cases.append(("SOURCE_STALE", candidate, detector, stale_source))
|
||
|
||
expired = copy.deepcopy(live)
|
||
expired["checkedAt"] = expired["candidateExpiresAt"]
|
||
cases.append(("CANDIDATE_EXPIRED", candidate, detector, expired))
|
||
|
||
for expected_code, current_candidate, current_detector, current_live in cases:
|
||
with self.subTest(expected_code=expected_code):
|
||
self._assert_error(
|
||
expected_code,
|
||
check_shadow_ready,
|
||
context=copy.deepcopy(context),
|
||
candidate=copy.deepcopy(current_candidate),
|
||
detector_result=copy.deepcopy(current_detector),
|
||
live_state=copy.deepcopy(current_live),
|
||
)
|
||
|
||
def test_valid_candidate_enters_shadow_without_writing_canonical(self):
|
||
context, candidate, detector, live = _valid_inputs()
|
||
|
||
result = check_shadow_ready(
|
||
context=context,
|
||
candidate=candidate,
|
||
detector_result=detector,
|
||
live_state=live,
|
||
)
|
||
|
||
self.assertEqual(result["status"], "SHADOW_READY")
|
||
self.assertEqual(result["candidateVersion"], candidate["candidateVersion"])
|
||
self.assertFalse(result["canonicalMutationPerformed"])
|
||
|
||
def test_pipeline_passed_result_is_accepted_by_shadow_contract(self):
|
||
"""直接验证生产管线产出与 Shadow 消费合同一致。"""
|
||
|
||
context, candidate, _detector, live = _valid_inputs()
|
||
pipeline_result = run_writer_pipeline(
|
||
context=context,
|
||
requirements=_requirements(),
|
||
writer=lambda current, version: _candidate(current, candidate_version=version),
|
||
evidence_provider=lambda *_args: self.fail("通过候选不得补证"),
|
||
semantic_detector=_semantic_pass,
|
||
state_store=InMemoryCasStateStore(),
|
||
)
|
||
pipeline_candidate = pipeline_result["candidateArtifact"]
|
||
|
||
result = check_shadow_ready(
|
||
context=context,
|
||
candidate=pipeline_candidate,
|
||
detector_result=pipeline_result,
|
||
live_state=live,
|
||
)
|
||
|
||
self.assertEqual(pipeline_candidate, candidate)
|
||
self.assertEqual(
|
||
pipeline_result["trace"][-1]["mechanicalReport"]["schemaVersion"],
|
||
"writer-detector-report-v1",
|
||
)
|
||
self.assertEqual(
|
||
pipeline_result["trace"][-1]["semanticReport"]["status"], "passed"
|
||
)
|
||
self.assertEqual(result["status"], "SHADOW_READY")
|
||
|
||
def test_accept_merge_and_discard_all_require_explicit_confirmation(self):
|
||
context, candidate, detector, live = _valid_inputs()
|
||
for decision in ("accept", "merge", "discard"):
|
||
kwargs = {
|
||
"decision": decision,
|
||
"confirmed": False,
|
||
"context": context,
|
||
"candidate": candidate,
|
||
"detector_result": detector,
|
||
"live_state": live,
|
||
"expected_revision": 7,
|
||
}
|
||
if decision == "merge":
|
||
edited = _edited_candidate(candidate)
|
||
kwargs["candidate"] = edited
|
||
kwargs["detector_result"] = _detector_result(edited)
|
||
kwargs["previous_candidate"] = candidate
|
||
with self.subTest(decision=decision):
|
||
self._assert_error("CONFIRMATION_REQUIRED", check_writer_acceptance, **kwargs)
|
||
|
||
def test_expected_revision_conflict_never_returns_command_intent(self):
|
||
context, candidate, detector, live = _valid_inputs()
|
||
|
||
self._assert_error(
|
||
"REVISION_CONFLICT",
|
||
check_writer_acceptance,
|
||
decision="accept",
|
||
confirmed=True,
|
||
context=context,
|
||
candidate=candidate,
|
||
detector_result=detector,
|
||
live_state=live,
|
||
expected_revision=6,
|
||
)
|
||
|
||
def test_merge_requires_next_candidate_version_and_rerun_detector(self):
|
||
context, candidate, _detector, live = _valid_inputs()
|
||
edited = _edited_candidate(candidate)
|
||
|
||
same_version = copy.deepcopy(edited)
|
||
same_version["candidateVersion"] = candidate["candidateVersion"]
|
||
self._assert_error(
|
||
"EDIT_VERSION_INVALID",
|
||
check_writer_acceptance,
|
||
decision="merge",
|
||
confirmed=True,
|
||
context=context,
|
||
candidate=same_version,
|
||
previous_candidate=candidate,
|
||
detector_result=_detector_result(same_version),
|
||
live_state=live,
|
||
expected_revision=7,
|
||
)
|
||
|
||
stale_detector = _detector_result(candidate)
|
||
self._assert_error(
|
||
"DETECTOR_BINDING_MISMATCH",
|
||
check_writer_acceptance,
|
||
decision="merge",
|
||
confirmed=True,
|
||
context=context,
|
||
candidate=edited,
|
||
previous_candidate=candidate,
|
||
detector_result=stale_detector,
|
||
live_state=live,
|
||
expected_revision=7,
|
||
)
|
||
|
||
def test_merge_rejects_version_jump_unchanged_body_and_invalid_base(self):
|
||
"""编辑候选必须是有实际变更的严格下一版本,并绑定合法基线。"""
|
||
|
||
context, candidate, _detector, live = _valid_inputs()
|
||
|
||
jumped = _edited_candidate(candidate)
|
||
jumped["candidateVersion"] += 1
|
||
unchanged = copy.deepcopy(candidate)
|
||
unchanged["candidateVersion"] += 1
|
||
# 旧合同用删除 selfCheck 制造非法基线;CandidateEnvelope v2 已无该字段,
|
||
# 改为删除现行合同必填字段 acceptanceEligible,触发同等的 EDIT_BASE_INVALID。
|
||
invalid_base = copy.deepcopy(candidate)
|
||
del invalid_base["acceptanceEligible"]
|
||
cases = (
|
||
("EDIT_VERSION_INVALID", jumped, candidate),
|
||
("EDIT_BODY_UNCHANGED", unchanged, candidate),
|
||
("EDIT_BASE_REQUIRED", _edited_candidate(candidate), None),
|
||
("EDIT_BASE_INVALID", _edited_candidate(candidate), invalid_base),
|
||
)
|
||
for expected_code, current, previous in cases:
|
||
with self.subTest(expected_code=expected_code):
|
||
self._assert_error(
|
||
expected_code,
|
||
check_writer_acceptance,
|
||
decision="merge",
|
||
confirmed=True,
|
||
context=context,
|
||
candidate=current,
|
||
previous_candidate=previous,
|
||
detector_result=_detector_result(current),
|
||
live_state=live,
|
||
expected_revision=7,
|
||
)
|
||
|
||
def test_accept_and_merge_only_return_async_extraction_command_intent(self):
|
||
context, candidate, detector, live = _valid_inputs()
|
||
edited = _edited_candidate(candidate)
|
||
cases = (
|
||
("accept", candidate, detector, None),
|
||
("merge", edited, _detector_result(edited), candidate),
|
||
)
|
||
for decision, current, current_detector, previous in cases:
|
||
with self.subTest(decision=decision):
|
||
result = check_writer_acceptance(
|
||
decision=decision,
|
||
confirmed=True,
|
||
context=context,
|
||
candidate=current,
|
||
previous_candidate=previous,
|
||
detector_result=current_detector,
|
||
live_state=live,
|
||
expected_revision=7,
|
||
)
|
||
self.assertEqual(result["status"], "ACCEPTANCE_INTENT_READY")
|
||
self.assertFalse(result["canonicalMutationPerformed"])
|
||
self.assertEqual(result["commandIntent"]["command"], "queue_chapter_extraction")
|
||
self.assertEqual(result["commandIntent"]["dispatch"], "async")
|
||
self.assertEqual(result["commandIntent"]["executeAfter"], "canonical_commit")
|
||
self.assertFalse(result["commandIntent"]["allowed"])
|
||
self.assertTrue(result["commandIntent"]["requiresCanonicalCommit"])
|
||
|
||
def test_discard_returns_no_extraction_intent_and_performs_no_write(self):
|
||
context, candidate, detector, live = _valid_inputs()
|
||
|
||
result = check_writer_acceptance(
|
||
decision="discard",
|
||
confirmed=True,
|
||
context=context,
|
||
candidate=candidate,
|
||
detector_result=detector,
|
||
live_state=live,
|
||
expected_revision=7,
|
||
)
|
||
|
||
self.assertEqual(result["status"], "DISCARD_INTENT_READY")
|
||
self.assertEqual(result["runId"], candidate["runId"])
|
||
self.assertEqual(result["candidateVersion"], candidate["candidateVersion"])
|
||
self.assertEqual(result["candidateSha256"], candidate["candidateSha256"])
|
||
self.assertEqual(result["commandIntent"]["command"], "close_shadow_candidate")
|
||
self.assertEqual(
|
||
result["commandIntent"]["expectedCandidateVersion"],
|
||
candidate["candidateVersion"],
|
||
)
|
||
self.assertEqual(
|
||
result["commandIntent"]["expectedCandidateSha256"],
|
||
candidate["candidateSha256"],
|
||
)
|
||
self.assertFalse(result["canonicalMutationPerformed"])
|
||
|
||
def test_discard_rejects_malformed_candidate_instead_of_closing_unknown_target(self):
|
||
"""丢弃也必须绑定合法候选身份,不能返回无目标关闭意图。"""
|
||
|
||
context, candidate, detector, live = _valid_inputs()
|
||
# 旧合同用删除 selfCheck 制造非法候选;CandidateEnvelope v2 已无该字段,
|
||
# 改为删除现行合同必填字段 acceptanceEligible,触发同等的 CANDIDATE_CONTRACT_INVALID。
|
||
del candidate["acceptanceEligible"]
|
||
|
||
self._assert_error(
|
||
"CANDIDATE_CONTRACT_INVALID",
|
||
check_writer_acceptance,
|
||
decision="discard",
|
||
confirmed=True,
|
||
context=context,
|
||
candidate=candidate,
|
||
detector_result=detector,
|
||
live_state=live,
|
||
expected_revision=7,
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|