from __future__ import annotations from collections.abc import Callable from dataclasses import replace import pytest from mdpolish._artifact_replay import ( ReplayChange, ReplayComponent, ReplayError, replay_change_chain, ) from mdpolish.models import markdown_sha256 def change( *, component_position: int, before_text: str, after_text: str, start: int, end: int, before_sha256: str, after_sha256: str, proposal_index: int = 0, edit_index: int = 0, ) -> ReplayChange: return ReplayChange( component_id=f"test.{component_position}", component_version="1.0.0", component_position=component_position, proposal_component_position=component_position, proposal_snapshot_sha256=before_sha256, proposal_index=proposal_index, edit_index=edit_index, start=start, end=end, before=before_text, after=after_text, before_sha256=before_sha256, after_sha256=after_sha256, ) def test_replay_builds_zero_change_stages_and_utf16_editor_ranges() -> None: original = "\ufeff😀 old\r\nCafe\u0301" final = "\ufeff😀 new\r\nCafe\u0301" input_hash = markdown_sha256(original) final_hash = markdown_sha256(final) recorded = change( component_position=0, before_text="old", after_text="new", start=3, end=6, before_sha256=input_hash, after_sha256=final_hash, ) replayed = replay_change_chain( input_markdown=original, input_sha256=input_hash, components=(ReplayComponent("test.0", "1.0.0"), ReplayComponent("test.1", "1.0.0")), changes=(recorded,), current_sha256=final_hash, current_markdown=final, include_zero_change_stages=True, ) assert replayed.current_markdown == final assert len(replayed.stages) == 2 assert replayed.stages[1].before_markdown == final assert replayed.stages[1].after_markdown == final assert replayed.stages[1].changes == () assert (replayed.changes[0].line, replayed.changes[0].column) == (1, 4) assert (replayed.changes[0].editor_start, replayed.changes[0].editor_end) == (4, 7) def test_replay_uses_full_descending_application_key_not_record_order() -> None: original = "abcd" final = "aXXcYY" input_hash = markdown_sha256(original) final_hash = markdown_sha256(final) right = change( component_position=0, before_text="d", after_text="YY", start=3, end=4, before_sha256=input_hash, after_sha256=final_hash, proposal_index=1, ) left = change( component_position=0, before_text="b", after_text="XX", start=1, end=2, before_sha256=input_hash, after_sha256=final_hash, ) replayed = replay_change_chain( input_markdown=original, input_sha256=input_hash, components=(ReplayComponent("test.0", "1.0.0"),), changes=(right, left), current_sha256=final_hash, current_markdown=final, include_zero_change_stages=True, ) assert replayed.current_markdown == final @pytest.mark.parametrize( ("mutate", "message"), [ (lambda item: replace(item, component_position=2), "component position"), (lambda item: replace(item, proposal_component_position=1), "proposal reference"), (lambda item: replace(item, before="bad"), "recorded snapshot"), (lambda item: replace(item, after_sha256="0" * 64), "batch hash"), ], ) def test_replay_rejects_untrusted_change_chains( mutate: Callable[[ReplayChange], ReplayChange], message: str ) -> None: original = "old" final = "new" input_hash = markdown_sha256(original) final_hash = markdown_sha256(final) valid = change( component_position=0, before_text="old", after_text="new", start=0, end=3, before_sha256=input_hash, after_sha256=final_hash, ) tampered = mutate(valid) with pytest.raises(ReplayError, match=message): replay_change_chain( input_markdown=original, input_sha256=input_hash, components=(ReplayComponent("test.0", "1.0.0"),), changes=(tampered,), current_sha256=final_hash, current_markdown=final, include_zero_change_stages=True, ) def test_replay_rejects_conflicting_ranges() -> None: original = "abc" input_hash = markdown_sha256(original) first = change( component_position=0, before_text="ab", after_text="x", start=0, end=2, before_sha256=input_hash, after_sha256="0" * 64, ) second = change( component_position=0, before_text="bc", after_text="y", start=1, end=3, before_sha256=input_hash, after_sha256="0" * 64, proposal_index=1, ) with pytest.raises(ReplayError, match="conflicting"): replay_change_chain( input_markdown=original, input_sha256=input_hash, components=(ReplayComponent("test.0", "1.0.0"),), changes=(first, second), current_sha256=input_hash, current_markdown=None, include_zero_change_stages=False, )