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 "