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 == ()