实现第一版内存清洗核心
落实不可变数据契约、组件基类、原子修改执行器与顺序流水线。补充稳定性复查、审计记录、测试和当前机制文档。
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user