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'{t}' for t in body_paragraphs ) with zipfile.ZipFile(path, "w") as zf: zf.writestr( "[Content_Types].xml", '' '' '' '' '' '', ) zf.writestr( "_rels/.rels", '' '' '' '', ) zf.writestr( "word/_rels/document.xml.rels", '' '' '', ) zf.writestr( "word/document.xml", '' '' f'{para_xml}', ) 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", """ """, ) zf.writestr( "_rels/.rels", """ """, ) zf.writestr( "word/_rels/document.xml.rels", '' '' '', ) zf.writestr( "word/document.xml", """Body paragraph.Table cell clause.""", ) 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", '' '' '' '' '' '', ) zf.writestr( "_rels/.rels", '' '' '' '', ) zf.writestr( "word/_rels/document.xml.rels", '' '' '', ) # Paragraph with a bold run followed by a normal run zf.writestr( "word/document.xml", '' '' '' '' 'Defined Term: ' 'means the agreement between the parties.' '' 'Second paragraph.' '', ) 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 '' (with space) assert ("" in doc_xml or "