实现第一版内存清洗核心

落实不可变数据契约、组件基类、原子修改执行器与顺序流水线。补充稳定性复查、审计记录、测试和当前机制文档。
This commit is contained in:
2026-08-22 01:03:03 +08:00
parent 8ac4f1dd12
commit 3edeeaf30e
15 changed files with 2023 additions and 145 deletions
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from dataclasses import FrozenInstanceError
from hashlib import sha256
import pytest
from mdpolish import (
DocumentSnapshot,
ErrorStage,
ProposedChange,
ResidualProposal,
RunError,
RunStatus,
TextEdit,
TextSpan,
TransformResult,
)
from mdpolish.models import ProposalReference
def test_snapshot_preserves_exact_markdown_and_computes_hash() -> None:
markdown = "标题\r\nCafe\u0301\n🙂\n"
snapshot = DocumentSnapshot(markdown)
assert snapshot.markdown == markdown
assert snapshot.sha256 == sha256(markdown.encode("utf-8")).hexdigest()
def test_empty_snapshot_is_valid_and_hash_cannot_be_supplied() -> None:
snapshot = DocumentSnapshot("")
assert snapshot.sha256 == sha256(b"").hexdigest()
with pytest.raises(TypeError):
DocumentSnapshot("", sha256="0" * 64) # type: ignore[call-arg]
def test_snapshot_is_frozen() -> None:
snapshot = DocumentSnapshot("original")
with pytest.raises(FrozenInstanceError):
snapshot.markdown = "changed" # type: ignore[misc]
@pytest.mark.parametrize(
("start", "end", "error_type"),
[
(-1, 0, ValueError),
(2, 1, ValueError),
(True, 1, TypeError),
],
)
def test_span_rejects_invalid_indexes(start: int, end: int, error_type: type[Exception]) -> None:
with pytest.raises(error_type):
TextSpan(start, end)
def test_text_edit_supports_insert_delete_and_replace() -> None:
snapshot = DocumentSnapshot("中文abc")
insertion = TextEdit(snapshot.sha256, TextSpan(2, 2), "", "!")
deletion = TextEdit(snapshot.sha256, TextSpan(2, 3), "a", "")
replacement = TextEdit(snapshot.sha256, TextSpan(3, 5), "bc", "BC")
assert insertion.span.is_empty
assert deletion.replacement == ""
assert replacement.expected_text == "bc"
def test_text_edit_rejects_bad_digest_length_mismatch_and_no_op() -> None:
digest = DocumentSnapshot("abc").sha256
with pytest.raises(ValueError, match="SHA-256"):
TextEdit("bad", TextSpan(0, 1), "a", "b")
with pytest.raises(ValueError, match="length"):
TextEdit(digest, TextSpan(0, 2), "a", "b")
with pytest.raises(ValueError, match="must change"):
TextEdit(digest, TextSpan(0, 1), "a", "a")
def test_proposal_requires_reason_edits_and_one_matching_digest() -> None:
snapshot = DocumentSnapshot("abc")
other = DocumentSnapshot("xyz")
edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A")
other_edit = TextEdit(other.sha256, TextSpan(0, 1), "x", "X")
with pytest.raises(ValueError, match="reason"):
ProposedChange(snapshot.sha256, " ", (edit,))
with pytest.raises(ValueError, match="non-empty tuple"):
ProposedChange(snapshot.sha256, "reason", ())
with pytest.raises(ValueError, match="proposal digest"):
ProposedChange(snapshot.sha256, "reason", (other_edit,))
def test_transform_result_enforces_status_specific_output_fields() -> None:
snapshot = DocumentSnapshot("abc")
error = RunError("component", "1.0.0", 0, ErrorStage.TRANSFORM, "ExampleError", "safe")
edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A")
proposal = ProposedChange(snapshot.sha256, "reason", (edit,))
residual = ResidualProposal(
component_id="component",
component_version="1.0.0",
component_position=0,
proposal_ref=ProposalReference(0, snapshot.sha256, 0),
proposal=proposal,
)
success = TransformResult(
status=RunStatus.SUCCESS,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
output_markdown=snapshot.markdown,
)
failed = TransformResult(
status=RunStatus.FAILED,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
errors=(error,),
partial_markdown=snapshot.markdown,
)
unstable = TransformResult(
status=RunStatus.UNSTABLE,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
residual_proposals=(residual,),
partial_markdown=snapshot.markdown,
)
assert success.output_markdown == "abc"
assert failed.partial_markdown == "abc"
assert unstable.residual_proposals == (residual,)
with pytest.raises(ValueError, match="successful result"):
TransformResult(
status=RunStatus.SUCCESS,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
errors=(error,),
output_markdown=snapshot.markdown,
)
with pytest.raises(ValueError, match="failed result"):
TransformResult(
status=RunStatus.FAILED,
input_sha256=snapshot.sha256,
current_sha256=snapshot.sha256,
partial_markdown=snapshot.markdown,
)