实现第一版内存清洗核心
落实不可变数据契约、组件基类、原子修改执行器与顺序流水线。补充稳定性复查、审计记录、测试和当前机制文档。
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from mdpolish import (
|
||||
ComponentInfo,
|
||||
DocumentSnapshot,
|
||||
EditValidationError,
|
||||
ProposedChange,
|
||||
TextEdit,
|
||||
TextSpan,
|
||||
apply_component_batch,
|
||||
)
|
||||
|
||||
COMPONENT = ComponentInfo(
|
||||
component_id="test.component",
|
||||
version="1.2.3",
|
||||
parameters=(),
|
||||
applicability="测试精确文本编辑,只处理测试字符串,排除其他输入。",
|
||||
)
|
||||
|
||||
|
||||
def make_edit(snapshot: DocumentSnapshot, start: int, end: int, replacement: str) -> TextEdit:
|
||||
return TextEdit(
|
||||
snapshot_sha256=snapshot.sha256,
|
||||
span=TextSpan(start, end),
|
||||
expected_text=snapshot.markdown[start:end],
|
||||
replacement=replacement,
|
||||
)
|
||||
|
||||
|
||||
def make_proposal(snapshot: DocumentSnapshot, *edits: TextEdit, reason: str = "test reason") -> ProposedChange:
|
||||
return ProposedChange(snapshot_sha256=snapshot.sha256, reason=reason, edits=edits)
|
||||
|
||||
|
||||
def test_applies_insert_delete_and_replace() -> None:
|
||||
insert_snapshot = DocumentSnapshot("ab")
|
||||
delete_snapshot = DocumentSnapshot("abc")
|
||||
replace_snapshot = DocumentSnapshot("abc")
|
||||
|
||||
inserted = apply_component_batch(
|
||||
insert_snapshot,
|
||||
(make_proposal(insert_snapshot, make_edit(insert_snapshot, 1, 1, "X")),),
|
||||
COMPONENT,
|
||||
0,
|
||||
)
|
||||
deleted = apply_component_batch(
|
||||
delete_snapshot,
|
||||
(make_proposal(delete_snapshot, make_edit(delete_snapshot, 1, 2, "")),),
|
||||
COMPONENT,
|
||||
0,
|
||||
)
|
||||
replaced = apply_component_batch(
|
||||
replace_snapshot,
|
||||
(make_proposal(replace_snapshot, make_edit(replace_snapshot, 1, 2, "X")),),
|
||||
COMPONENT,
|
||||
0,
|
||||
)
|
||||
|
||||
assert inserted.snapshot.markdown == "aXb"
|
||||
assert deleted.snapshot.markdown == "ac"
|
||||
assert replaced.snapshot.markdown == "aXc"
|
||||
|
||||
|
||||
def test_multiple_edits_apply_backwards_but_report_in_source_order() -> None:
|
||||
snapshot = DocumentSnapshot("abcdef")
|
||||
proposal = make_proposal(
|
||||
snapshot,
|
||||
make_edit(snapshot, 4, 6, "F"),
|
||||
make_edit(snapshot, 0, 1, "A"),
|
||||
reason="normalize two locations",
|
||||
)
|
||||
|
||||
applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 3)
|
||||
|
||||
assert applied.snapshot.markdown == "AbcdF"
|
||||
assert [change.span.start for change in applied.changes] == [0, 4]
|
||||
assert {change.before_sha256 for change in applied.changes} == {snapshot.sha256}
|
||||
assert {change.after_sha256 for change in applied.changes} == {applied.snapshot.sha256}
|
||||
assert {change.proposal_ref for change in applied.changes} == {
|
||||
applied.changes[0].proposal_ref,
|
||||
}
|
||||
assert {change.reason for change in applied.changes} == {"normalize two locations"}
|
||||
assert [change.edit_index for change in applied.changes] == [1, 0]
|
||||
assert all(change.component_position == 3 for change in applied.changes)
|
||||
|
||||
|
||||
def test_adjacent_nonempty_ranges_are_allowed() -> None:
|
||||
snapshot = DocumentSnapshot("abcd")
|
||||
proposal = make_proposal(
|
||||
snapshot,
|
||||
make_edit(snapshot, 0, 2, "A"),
|
||||
make_edit(snapshot, 2, 4, "D"),
|
||||
)
|
||||
|
||||
applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
|
||||
|
||||
assert applied.snapshot.markdown == "AD"
|
||||
|
||||
|
||||
def test_overlapping_ranges_fail_without_changing_snapshot() -> None:
|
||||
snapshot = DocumentSnapshot("abcdef")
|
||||
proposal = make_proposal(
|
||||
snapshot,
|
||||
make_edit(snapshot, 1, 4, "X"),
|
||||
make_edit(snapshot, 3, 5, "Y"),
|
||||
)
|
||||
|
||||
with pytest.raises(EditValidationError, match="conflicting"):
|
||||
apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
|
||||
|
||||
assert snapshot.markdown == "abcdef"
|
||||
assert snapshot.sha256 == DocumentSnapshot("abcdef").sha256
|
||||
|
||||
|
||||
def test_duplicate_edits_fail_explicitly() -> None:
|
||||
snapshot = DocumentSnapshot("abc")
|
||||
edit = make_edit(snapshot, 0, 1, "A")
|
||||
proposal = make_proposal(snapshot, edit, edit)
|
||||
|
||||
with pytest.raises(EditValidationError, match="duplicate"):
|
||||
apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
|
||||
|
||||
|
||||
def test_distinct_insert_points_are_allowed() -> None:
|
||||
snapshot = DocumentSnapshot("abcd")
|
||||
proposal = make_proposal(
|
||||
snapshot,
|
||||
make_edit(snapshot, 1, 1, "X"),
|
||||
make_edit(snapshot, 3, 3, "Y"),
|
||||
)
|
||||
|
||||
applied = apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
|
||||
|
||||
assert applied.snapshot.markdown == "aXbcYd"
|
||||
|
||||
|
||||
def test_same_insert_point_conflicts() -> None:
|
||||
snapshot = DocumentSnapshot("abc")
|
||||
proposal = make_proposal(
|
||||
snapshot,
|
||||
make_edit(snapshot, 1, 1, "X"),
|
||||
make_edit(snapshot, 1, 1, "Y"),
|
||||
)
|
||||
|
||||
with pytest.raises(EditValidationError, match="conflicting"):
|
||||
apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("insert_position", [1, 2, 3])
|
||||
def test_insert_at_start_inside_or_end_of_nonempty_range_conflicts(insert_position: int) -> None:
|
||||
snapshot = DocumentSnapshot("abcd")
|
||||
proposal = make_proposal(
|
||||
snapshot,
|
||||
make_edit(snapshot, 1, 3, "X"),
|
||||
make_edit(snapshot, insert_position, insert_position, "Y"),
|
||||
)
|
||||
|
||||
with pytest.raises(EditValidationError, match="conflicting"):
|
||||
apply_component_batch(snapshot, (proposal,), COMPONENT, 0)
|
||||
|
||||
|
||||
def test_stale_hash_out_of_range_and_expected_text_mismatch_fail() -> None:
|
||||
original = DocumentSnapshot("abc")
|
||||
current = DocumentSnapshot("abd")
|
||||
stale = make_proposal(original, make_edit(original, 0, 1, "A"))
|
||||
|
||||
with pytest.raises(EditValidationError, match="stale"):
|
||||
apply_component_batch(current, (stale,), COMPONENT, 0)
|
||||
|
||||
out_of_range_edit = TextEdit(current.sha256, TextSpan(2, 5), "dxx", "D")
|
||||
out_of_range = make_proposal(current, out_of_range_edit)
|
||||
with pytest.raises(EditValidationError, match="outside"):
|
||||
apply_component_batch(current, (out_of_range,), COMPONENT, 0)
|
||||
|
||||
mismatch_edit = TextEdit(current.sha256, TextSpan(0, 1), "z", "A")
|
||||
mismatch = make_proposal(current, mismatch_edit)
|
||||
with pytest.raises(EditValidationError, match="expected_text"):
|
||||
apply_component_batch(current, (mismatch,), COMPONENT, 0)
|
||||
|
||||
|
||||
def test_conflict_across_proposals_rejects_whole_component_batch() -> None:
|
||||
snapshot = DocumentSnapshot("abcdef")
|
||||
first = make_proposal(snapshot, make_edit(snapshot, 0, 3, "X"), reason="first")
|
||||
second = make_proposal(snapshot, make_edit(snapshot, 2, 4, "Y"), reason="second")
|
||||
|
||||
with pytest.raises(EditValidationError, match="conflicting"):
|
||||
apply_component_batch(snapshot, (first, second), COMPONENT, 0)
|
||||
|
||||
assert snapshot.markdown == "abcdef"
|
||||
|
||||
|
||||
def test_empty_component_batch_keeps_same_snapshot_and_records_nothing() -> None:
|
||||
snapshot = DocumentSnapshot("abc")
|
||||
|
||||
applied = apply_component_batch(snapshot, (), COMPONENT, 0)
|
||||
|
||||
assert applied.snapshot is snapshot
|
||||
assert applied.changes == ()
|
||||
Reference in New Issue
Block a user