Spaces:
Sleeping
Sleeping
Download backend/python-service/tests/test_track_changes.py from bardd/mike-backend: direct link, hf CLI and curl.
- Browser
- Download file 29.3 kB
-
https://huggingface.co/spaces/bardd/mike-backend/resolve/main/backend/python-service/tests/test_track_changes.py
- Command line
-
hf download hf://spaces/bardd/mike-backend/backend/python-service/tests/test_track_changes.py
-
curl -L -o test_track_changes.py https://huggingface.co/spaces/bardd/mike-backend/resolve/main/backend/python-service/tests/test_track_changes.py
29.3 kB
| import os | |
| import shutil | |
| import sys | |
| import tempfile | |
| import zipfile | |
| from pathlib import Path | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) | |
| from docx_comments import apply_docx_comments | |
| from models import ReviewFinding | |
| def _finding( | |
| *, | |
| finding_id: str = "test_finding_1", | |
| paragraph_id: str = "P_0008", | |
| target_text: str = "", | |
| anchor_mode: str = "paragraph", | |
| op_type: str = "COMMENT", | |
| new_text: str | None = None, | |
| insert_text: str | None = None, | |
| position: str | None = None, | |
| rewrite_mode: str | None = None, | |
| old_span: str | None = None, | |
| new_span: str | None = None, | |
| ) -> ReviewFinding: | |
| return ReviewFinding( | |
| id=finding_id, | |
| risk_level="high", | |
| clause_type="Indemnification", | |
| issue="Test issue", | |
| comment="This is a test comment from the Mike system.", | |
| review_reasoning={"clause_section": "test", "violated_standard": "test", "zamil_exposure": "Manual test run"}, | |
| target_text=target_text, | |
| anchor_mode=anchor_mode, | |
| start_paragraph_id=paragraph_id, | |
| end_paragraph_id=paragraph_id, | |
| start_anchor=target_text[:220], | |
| end_anchor=target_text[-220:] if target_text else "", | |
| before_anchor="", | |
| after_anchor="", | |
| confidence=1.0, | |
| op_type=op_type, | |
| new_text=new_text, | |
| insert_text=insert_text, | |
| position=position, | |
| rewrite_mode=rewrite_mode, | |
| old_span=old_span, | |
| new_span=new_span, | |
| ) | |
| def _sample_docx() -> Path: | |
| root = Path(__file__).resolve().parents[1] | |
| sample = root / "data" / "uploads" / "run_20260508_120049_e4b2552b55" / "test_contract.docx" | |
| if not sample.exists(): | |
| import pytest | |
| pytest.skip(f"Missing test DOCX fixture: {sample}") | |
| return sample | |
| def _require_dotnet(): | |
| if shutil.which("dotnet") is None: | |
| import pytest | |
| pytest.skip("dotnet is required for the DOCX commenter integration test") | |
| def _write_minimal_docx(path: Path, body_paragraphs: list[str]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| para_xml = "".join( | |
| f'<w:p><w:r><w:t>{t}</w:t></w:r></w:p>' for t in body_paragraphs | |
| ) | |
| with zipfile.ZipFile(path, "w") as zf: | |
| zf.writestr( | |
| "[Content_Types].xml", | |
| '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' | |
| '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' | |
| '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' | |
| '<Default Extension="xml" ContentType="application/xml"/>' | |
| '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' | |
| '</Types>', | |
| ) | |
| zf.writestr( | |
| "_rels/.rels", | |
| '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' | |
| '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' | |
| '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' | |
| '</Relationships>', | |
| ) | |
| zf.writestr( | |
| "word/_rels/document.xml.rels", | |
| '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' | |
| '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' | |
| '</Relationships>', | |
| ) | |
| zf.writestr( | |
| "word/document.xml", | |
| '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' | |
| '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' | |
| ' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' | |
| f'<w:body>{para_xml}<w:sectPr/></w:body></w:document>', | |
| ) | |
| def _docx_has_tracked_changes(path: Path) -> bool: | |
| with zipfile.ZipFile(path) as zf: | |
| doc_xml = zf.read("word/document.xml").decode("utf-8") | |
| return "w:ins" in doc_xml or "w:del" in doc_xml | |
| def _docx_has_comments(path: Path) -> bool: | |
| with zipfile.ZipFile(path) as zf: | |
| names = zf.namelist() | |
| return "word/comments.xml" in names | |
| # --------------------------------------------------------------------------- | |
| # Step 16: COMMENT-only backward compatibility | |
| # --------------------------------------------------------------------------- | |
| def test_comment_only_mode_backward_compatible(): | |
| _require_dotnet() | |
| sample = _sample_docx() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| out = Path(tmpdir) / "output.docx" | |
| results = apply_docx_comments( | |
| input_docx=sample, | |
| output_docx=out, | |
| findings=[_finding(target_text="")], | |
| work_dir=Path(tmpdir) / "work", | |
| track_changes=False, | |
| ) | |
| assert results | |
| assert results[0].status == "applied" | |
| assert results[0].failure_code == "editable" | |
| assert not _docx_has_tracked_changes(out) | |
| def test_comment_only_mode_with_track_changes_false(): | |
| _require_dotnet() | |
| sample = _sample_docx() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| out = Path(tmpdir) / "output.docx" | |
| results = apply_docx_comments( | |
| input_docx=sample, | |
| output_docx=out, | |
| findings=[_finding(target_text="")], | |
| work_dir=Path(tmpdir) / "work", | |
| track_changes=False, | |
| ) | |
| assert results[0].status == "applied" | |
| assert not _docx_has_tracked_changes(out) | |
| # --------------------------------------------------------------------------- | |
| # Step 17: REWRITE on body paragraphs | |
| # --------------------------------------------------------------------------- | |
| def test_rewrite_paragraph_produces_tracked_changes(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["First paragraph.", "Second paragraph to rewrite.", "Third paragraph."]) | |
| findings = [_finding( | |
| paragraph_id="P_0002", | |
| op_type="REWRITE_PARAGRAPH", | |
| new_text="Second paragraph rewritten content.", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| assert _docx_has_tracked_changes(out) | |
| assert _docx_has_comments(out) | |
| def test_rewrite_paragraph_missing_new_text_fails(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["First paragraph.", "Second paragraph.", "Third paragraph."]) | |
| findings = [_finding( | |
| paragraph_id="P_0002", | |
| op_type="REWRITE_PARAGRAPH", | |
| new_text=None, | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "not_applied" | |
| assert results[0].failure_code == "missing_new_text" | |
| def test_rewrite_span_mode_produces_tracked_changes(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["The Contractor shall pay thirty (30) calendar days after receipt."]) | |
| findings = [_finding( | |
| paragraph_id="P_0001", | |
| op_type="REWRITE_PARAGRAPH", | |
| rewrite_mode="span", | |
| old_span="thirty (30)", | |
| new_span="forty-five (45)", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| assert _docx_has_tracked_changes(out) | |
| def test_rewrite_span_missing_fields_fails(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["Some text."]) | |
| findings = [_finding( | |
| paragraph_id="P_0001", | |
| op_type="REWRITE_PARAGRAPH", | |
| rewrite_mode="span", | |
| old_span="Some", | |
| new_span=None, | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "not_applied" | |
| assert results[0].failure_code == "missing_span_fields" | |
| # --------------------------------------------------------------------------- | |
| # Step 18: REWRITE on table-cell paragraphs | |
| # --------------------------------------------------------------------------- | |
| def test_rewrite_table_cell_paragraph(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "table.docx" | |
| out = tmp / "table_out.docx" | |
| _write_minimal_table_docx(inp) | |
| findings = [_finding( | |
| paragraph_id="P_0002", | |
| op_type="REWRITE_PARAGRAPH", | |
| new_text="Rewritten table cell.", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| assert _docx_has_tracked_changes(out) | |
| # --------------------------------------------------------------------------- | |
| # Step 19: INSERT | |
| # --------------------------------------------------------------------------- | |
| def test_insert_paragraph_after_anchor(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["First paragraph.", "Second paragraph."]) | |
| findings = [_finding( | |
| paragraph_id="P_0001", | |
| op_type="INSERT", | |
| insert_text="Force Majeure. Neither party shall be liable for failure to perform due to unforeseen events.", | |
| position="after", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| assert _docx_has_tracked_changes(out) | |
| def test_insert_paragraph_before_anchor(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["First paragraph.", "Second paragraph."]) | |
| findings = [_finding( | |
| paragraph_id="P_0002", | |
| op_type="INSERT", | |
| insert_text="New clause inserted before second paragraph.", | |
| position="before", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| def test_insert_missing_insert_text_fails(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["First paragraph."]) | |
| findings = [_finding( | |
| paragraph_id="P_0001", | |
| op_type="INSERT", | |
| insert_text=None, | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "not_applied" | |
| assert results[0].failure_code == "missing_insert_text" | |
| # --------------------------------------------------------------------------- | |
| # Step 20: DELETE | |
| # --------------------------------------------------------------------------- | |
| def test_delete_paragraph(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["First paragraph.", "Second paragraph to delete.", "Third paragraph."]) | |
| findings = [_finding( | |
| paragraph_id="P_0002", | |
| op_type="DELETE_PARAGRAPH", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| assert _docx_has_tracked_changes(out) | |
| # --------------------------------------------------------------------------- | |
| # Step 21: Mixed output — comments + redlines in one file | |
| # --------------------------------------------------------------------------- | |
| def test_mixed_comment_and_rewrite(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, [ | |
| "First paragraph.", | |
| "Second paragraph with thirty days notice period.", | |
| "Third paragraph.", | |
| ]) | |
| findings = [ | |
| _finding( | |
| finding_id="comment_1", | |
| paragraph_id="P_0001", | |
| op_type="COMMENT", | |
| ), | |
| _finding( | |
| finding_id="rewrite_1", | |
| paragraph_id="P_0002", | |
| op_type="REWRITE_PARAGRAPH", | |
| rewrite_mode="span", | |
| old_span="thirty days", | |
| new_span="forty-five days", | |
| ), | |
| ] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert len(results) == 2 | |
| assert all(r.status == "applied" for r in results) | |
| assert _docx_has_tracked_changes(out) | |
| assert _docx_has_comments(out) | |
| def test_mixed_comment_rewrite_insert_delete(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, [ | |
| "Paragraph one for comment.", | |
| "Paragraph two to rewrite with some text.", | |
| "Paragraph three to delete entirely.", | |
| "Paragraph four as insert anchor.", | |
| ]) | |
| findings = [ | |
| _finding(finding_id="c1", paragraph_id="P_0001", op_type="COMMENT"), | |
| _finding(finding_id="r1", paragraph_id="P_0002", op_type="REWRITE_PARAGRAPH", new_text="Paragraph two rewritten completely."), | |
| _finding(finding_id="d1", paragraph_id="P_0003", op_type="DELETE_PARAGRAPH"), | |
| _finding(finding_id="i1", paragraph_id="P_0004", op_type="INSERT", insert_text="New force majeure clause.", position="after"), | |
| ] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert len(results) == 4 | |
| applied_count = sum(1 for r in results if r.status == "applied") | |
| assert applied_count == 4 | |
| assert _docx_has_tracked_changes(out) | |
| assert _docx_has_comments(out) | |
| # --------------------------------------------------------------------------- | |
| # Selective invariant check | |
| # --------------------------------------------------------------------------- | |
| def test_selective_invariant_untouched_paragraphs_preserved(): | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, [ | |
| "First paragraph stays unchanged.", | |
| "Second paragraph will be rewritten.", | |
| "Third paragraph also stays unchanged.", | |
| ]) | |
| findings = [_finding( | |
| paragraph_id="P_0002", | |
| op_type="REWRITE_PARAGRAPH", | |
| new_text="Second paragraph now different.", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| def test_strict_invariant_still_works_for_comments_only(): | |
| _require_dotnet() | |
| sample = _sample_docx() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| out = Path(tmpdir) / "output.docx" | |
| results = apply_docx_comments( | |
| input_docx=sample, | |
| output_docx=out, | |
| findings=[_finding(target_text="")], | |
| work_dir=Path(tmpdir) / "work", | |
| track_changes=False, | |
| ) | |
| assert results[0].status == "applied" | |
| # --------------------------------------------------------------------------- | |
| # Helper: minimal table DOCX (shared with original test file) | |
| # --------------------------------------------------------------------------- | |
| def _write_minimal_table_docx(path: Path) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with zipfile.ZipFile(path, "w") as zf: | |
| zf.writestr( | |
| "[Content_Types].xml", | |
| """<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"> | |
| <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/> | |
| <Default Extension="xml" ContentType="application/xml"/> | |
| <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/> | |
| </Types>""", | |
| ) | |
| zf.writestr( | |
| "_rels/.rels", | |
| """<?xml version="1.0" encoding="UTF-8" standalone="yes"?> | |
| <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"> | |
| <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/> | |
| </Relationships>""", | |
| ) | |
| zf.writestr( | |
| "word/_rels/document.xml.rels", | |
| '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' | |
| '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' | |
| '</Relationships>', | |
| ) | |
| zf.writestr( | |
| "word/document.xml", | |
| """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:r><w:t>Body paragraph.</w:t></w:r></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>Table cell clause.</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:sectPr/></w:body></w:document>""", | |
| ) | |
| def _write_multi_run_docx(path: Path) -> None: | |
| """Create a DOCX where P_0001 has multiple runs with different RunProperties (bold + normal).""" | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with zipfile.ZipFile(path, "w") as zf: | |
| zf.writestr( | |
| "[Content_Types].xml", | |
| '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' | |
| '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' | |
| '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' | |
| '<Default Extension="xml" ContentType="application/xml"/>' | |
| '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' | |
| '</Types>', | |
| ) | |
| zf.writestr( | |
| "_rels/.rels", | |
| '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' | |
| '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' | |
| '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' | |
| '</Relationships>', | |
| ) | |
| zf.writestr( | |
| "word/_rels/document.xml.rels", | |
| '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' | |
| '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' | |
| '</Relationships>', | |
| ) | |
| # Paragraph with a bold run followed by a normal run | |
| zf.writestr( | |
| "word/document.xml", | |
| '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' | |
| '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' | |
| ' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' | |
| '<w:body>' | |
| '<w:p>' | |
| '<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">Defined Term: </w:t></w:r>' | |
| '<w:r><w:t xml:space="preserve">means the agreement between the parties.</w:t></w:r>' | |
| '</w:p>' | |
| '<w:p><w:r><w:t>Second paragraph.</w:t></w:r></w:p>' | |
| '<w:sectPr/></w:body></w:document>', | |
| ) | |
| def _read_doc_xml(path: Path) -> str: | |
| with zipfile.ZipFile(path) as zf: | |
| return zf.read("word/document.xml").decode("utf-8") | |
| # --------------------------------------------------------------------------- | |
| # New tests: Clone-and-wrap rewrite preserves formatting | |
| # --------------------------------------------------------------------------- | |
| def test_rewrite_produces_single_del_and_single_ins(): | |
| """Full-paragraph rewrite should produce exactly one w:del and one w:ins.""" | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["Original text here.", "Keep this."]) | |
| findings = [_finding( | |
| paragraph_id="P_0001", | |
| op_type="REWRITE_PARAGRAPH", | |
| new_text="Replacement text here.", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| doc_xml = _read_doc_xml(out) | |
| # Exactly one DeletedRun and one InsertedRun in the rewritten paragraph | |
| assert doc_xml.count("w:del ") == 1 or doc_xml.count("w:del>") >= 1 | |
| assert "w:ins " in doc_xml or "w:ins>" in doc_xml | |
| # The deleted text should contain the original text | |
| assert "w:delText" in doc_xml | |
| assert "Original text here." in doc_xml | |
| # The inserted text should contain the new text | |
| assert "Replacement text here." in doc_xml | |
| def test_rewrite_multi_run_preserves_per_run_formatting(): | |
| """When a paragraph has multiple runs with different formatting (bold + normal), | |
| rewriting should preserve the original per-run RunProperties inside w:del.""" | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "multi_run.docx" | |
| out = tmp / "output.docx" | |
| _write_multi_run_docx(inp) | |
| findings = [_finding( | |
| paragraph_id="P_0001", | |
| op_type="REWRITE_PARAGRAPH", | |
| new_text="New Definition: refers to the updated contract terms.", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| doc_xml = _read_doc_xml(out) | |
| # The deleted content should preserve the bold RunProperties (w:b) from | |
| # the first original run inside the DeletedRun. | |
| # Note: OpenXML SDK serializes self-closing tags as '<w:b />' (with space) | |
| assert ("<w:b/>" in doc_xml or "<w:b " in doc_xml), "Bold RunProperties should be preserved in deleted content" | |
| assert "w:delText" in doc_xml | |
| # Both original text fragments should appear as deleted text | |
| assert "Defined Term" in doc_xml | |
| assert "means the agreement" in doc_xml | |
| # New text should appear in an InsertedRun | |
| assert "New Definition" in doc_xml | |
| def test_rewrite_does_not_modify_untouched_paragraphs(): | |
| """Paragraphs not targeted by rewrite should remain untouched.""" | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, [ | |
| "Untouched paragraph one.", | |
| "This will be rewritten.", | |
| "Untouched paragraph three.", | |
| ]) | |
| findings = [_finding( | |
| paragraph_id="P_0002", | |
| op_type="REWRITE_PARAGRAPH", | |
| new_text="This has been rewritten.", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| assert results[0].status == "applied" | |
| doc_xml = _read_doc_xml(out) | |
| # Untouched paragraphs should have normal w:t text, not w:delText | |
| assert "Untouched paragraph one." in doc_xml | |
| assert "Untouched paragraph three." in doc_xml | |
| # The rewritten paragraph should have del + ins | |
| assert "This will be rewritten." in doc_xml # as deleted text | |
| assert "This has been rewritten." in doc_xml # as inserted text | |
| def test_rewrite_empty_visible_text_rejects(): | |
| """Rewrite on an empty paragraph should be rejected (either as no visible text | |
| or anchor_not_found if the empty paragraph isn't indexed).""" | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| out = tmp / "output.docx" | |
| # Create a docx with an effectively empty paragraph | |
| _write_minimal_docx(inp, ["Real text.", ""]) | |
| findings = [_finding( | |
| paragraph_id="P_0002", | |
| op_type="REWRITE_PARAGRAPH", | |
| new_text="Should not apply.", | |
| )] | |
| results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=out, | |
| findings=findings, | |
| work_dir=tmp / "work", | |
| track_changes=True, | |
| ) | |
| # Empty paragraphs are either not indexed (anchor_not_found) or rejected | |
| # as having no visible text (paragraph_no_visible_text) | |
| assert results[0].status == "not_applied" | |
| assert results[0].failure_code in ("paragraph_no_visible_text", "anchor_not_found") | |
| def test_rewrite_with_existing_comment_preserves_it(): | |
| """If a paragraph already has a comment, rewrite should preserve it via anchor rescue.""" | |
| _require_dotnet() | |
| with tempfile.TemporaryDirectory() as tmpdir: | |
| tmp = Path(tmpdir) | |
| inp = tmp / "input.docx" | |
| mid = tmp / "mid.docx" | |
| out = tmp / "output.docx" | |
| _write_minimal_docx(inp, ["Clause to comment and rewrite.", "Other clause."]) | |
| # Step 1: Apply a comment first | |
| comment_results = apply_docx_comments( | |
| input_docx=inp, | |
| output_docx=mid, | |
| findings=[_finding( | |
| finding_id="comment_first", | |
| paragraph_id="P_0001", | |
| op_type="COMMENT", | |
| )], | |
| work_dir=tmp / "work1", | |
| track_changes=True, | |
| ) | |
| assert comment_results[0].status == "applied" | |
| assert _docx_has_comments(mid) | |
| # Step 2: Now rewrite the same paragraph on the commented document | |
| rewrite_results = apply_docx_comments( | |
| input_docx=mid, | |
| output_docx=out, | |
| findings=[_finding( | |
| finding_id="rewrite_second", | |
| paragraph_id="P_0001", | |
| op_type="REWRITE_PARAGRAPH", | |
| new_text="Clause rewritten after comment.", | |
| )], | |
| work_dir=tmp / "work2", | |
| track_changes=True, | |
| ) | |
| assert rewrite_results[0].status == "applied" | |
| assert _docx_has_tracked_changes(out) | |
| # The original comment should still be present | |
| assert _docx_has_comments(out) | |
| if __name__ == "__main__": | |
| test_rewrite_paragraph_produces_tracked_changes() | |