מקור-תבנית-יחיד לכל מפיקי-מסמכי-ההחלטה: כולם מחילים סגנונות דרך skills/docx/decision_template.docx בלבד; אסור hand-setting של פונט/גודל. - docs/spec/06-export.md — INV-EX4 חדש (מקור-תבנית-יחיד, →G2) - docx_reviser: מסלול-העריכה (revise_draft/apply_user_edit) הכניס פסקאות עם jc=right קשיח + David/sz ידני → איבד יישור דו-צדדי של התבנית. עכשיו מחיל pStyle מן-הטמפלט (Normal/Heading 2/Quote) + jc=both קנוני, בלי rFonts/sz ידני (הגופן/גודל מגיעים מהסגנון). - שומר-CI test_docx_template_single_source — נכשל על בניית rFonts/sz במפיקים מחוץ לטמפלט (חריג מסומן # INV-EX4-ok ל-_mark_run_rtl, שמחזק את גופן-התבנית David כעקיפת באג-RTL של Word). - אומת: build_party_claims_summary_docx (סיכום-מנהלים, WS3) + export_decision (סופי/טיוטת-ביניים) כבר תבניתיים ותואמי-INV-EX4. - ארכוב scripts/exec_summary_1043.py (one-off; הוחלף ב-build_party_claims_summary_docx). - עדכון 2 טסטים שאימתו את ההתנהגות הישנה (rFonts על run מוכנס). Invariants: מקיים INV-EX4 (חדש), INV-G2 (מקור-יחיד, אין מסלול-סגנון מקביל), INV-EX1 (DOCX נגזר). נוגע ב-docx_reviser/docx_exporter/analysis_docx_exporter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
9.4 KiB
Python
240 lines
9.4 KiB
Python
"""בדיקות end-to-end לזרימה המלאה: exporter → retrofit → reviser.
|
||
|
||
הבדיקות האלה מחברות את כל השכבות של ארכיטקטורת Track Changes ומוודאות
|
||
שהזרימה עובדת על מסמכים שנוצרו על-ידי ה-exporter עצמו (בלוקים עם bookmarks
|
||
מובנים) ועל מסמכים רגילים שעברו retrofit.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import zipfile
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from docx import Document
|
||
from docx.oxml import OxmlElement
|
||
from docx.oxml.ns import qn
|
||
from lxml import etree
|
||
|
||
from legal_mcp.services import docx_retrofit, docx_reviser
|
||
from legal_mcp.services.docx_exporter import (
|
||
_BOOKMARK_ID_START,
|
||
_wrap_block_with_bookmarks,
|
||
)
|
||
from legal_mcp.services.docx_reviser import (
|
||
NSMAP,
|
||
Revision,
|
||
_w,
|
||
apply_tracked_revisions,
|
||
list_bookmarks,
|
||
)
|
||
|
||
|
||
# ── Helpers ────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_exporter_style_docx(path: Path) -> None:
|
||
"""Simulate what docx_exporter produces: paragraphs wrapped in bookmarks
|
||
for each of the 12 blocks, with David font and RTL."""
|
||
doc = Document()
|
||
bm_counter = [_BOOKMARK_ID_START]
|
||
|
||
blocks = [
|
||
("block-alef", "בפני: דפנה תמיר, יו\"ר ועדת הערר"),
|
||
("block-bet", "ערר מספר 1033-25"),
|
||
("block-heh", "רקע\nהנכס מצוי ברחוב הר בשן"),
|
||
("block-yod", "דיון והכרעה\nלאחר שבחנו את טענות הצדדים"),
|
||
("block-yod-bet", "ההחלטה\nהערר מתקבל בחלקו"),
|
||
]
|
||
|
||
for name, content in blocks:
|
||
def writer(c=content):
|
||
for line in c.split("\n"):
|
||
if line.strip():
|
||
doc.add_paragraph(line.strip())
|
||
_wrap_block_with_bookmarks(doc, name, writer, bm_counter)
|
||
|
||
doc.save(str(path))
|
||
|
||
|
||
def _make_user_edited_docx(path: Path) -> None:
|
||
"""Simulate what a user produces by editing in Word: no bookmarks,
|
||
heading-style paragraphs in Daphna style."""
|
||
doc = Document()
|
||
for text in [
|
||
"בפני: דפנה תמיר, יו\"ר ועדת הערר מחוז ירושלים",
|
||
"ערר מספר 9999-25",
|
||
"רקע",
|
||
"הנכס מצוי ברחוב שמואל הנגיד 10, ירושלים",
|
||
"תמצית טענות הצדדים",
|
||
"העוררים טוענים שהבנייה חורגת מהתכנית",
|
||
"תגובת המשיבה",
|
||
"הוועדה המקומית טוענת שהבקשה תואמת",
|
||
"ההליכים בפני ועדת הערר",
|
||
"קיימנו דיון בנוכחות הצדדים",
|
||
"דיון והכרעה",
|
||
"לאחר שבחנו את טענות הצדדים בחון מעמיק",
|
||
"סוף דבר",
|
||
"הערר נדחה",
|
||
]:
|
||
doc.add_paragraph(text)
|
||
doc.save(str(path))
|
||
|
||
|
||
# ── Exporter-style (built-in bookmarks) ──────────────────────────
|
||
|
||
|
||
def test_exporter_output_works_with_reviser(tmp_path: Path) -> None:
|
||
src = tmp_path / "exported.docx"
|
||
_make_exporter_style_docx(src)
|
||
|
||
# All 5 bookmarks should be present directly from "export"
|
||
bookmarks = list_bookmarks(src)
|
||
assert set(bookmarks) >= {"block-alef", "block-bet", "block-heh",
|
||
"block-yod", "block-yod-bet"}
|
||
|
||
out = tmp_path / "revised.docx"
|
||
revs = [
|
||
Revision(id="r1", type="insert_after", anchor_bookmark="block-yod",
|
||
content="תוספת מערכת: פסק הלכה חדש", style="body"),
|
||
]
|
||
result = apply_tracked_revisions(src, out, revs)
|
||
assert result.applied == 1
|
||
|
||
with zipfile.ZipFile(out, "r") as zf:
|
||
tree = etree.fromstring(zf.read("word/document.xml"))
|
||
raw_text = "".join(tree.itertext())
|
||
assert "תוספת מערכת" in raw_text
|
||
# The revision is tracked (inside <w:ins>)
|
||
ins_list = tree.findall(".//w:ins", NSMAP)
|
||
assert any("תוספת מערכת" in "".join(el.itertext()) for el in ins_list)
|
||
|
||
|
||
# ── User-edited DOCX (no bookmarks) — needs retrofit first ──────
|
||
|
||
|
||
def test_retrofit_then_revise_on_user_edit(tmp_path: Path) -> None:
|
||
user_file = tmp_path / "user_edit.docx"
|
||
_make_user_edited_docx(user_file)
|
||
|
||
# Initially no named bookmarks
|
||
assert list_bookmarks(user_file) == []
|
||
|
||
# Retrofit — should detect blocks via heading heuristic
|
||
result = docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
||
added = set(result["bookmarks_added"])
|
||
# Must include at least block-yod (for common "insert pasak halacha" task)
|
||
assert "block-yod" in added
|
||
# Plus block-heh (רקע) and block-zayin (תמצית טענות)
|
||
assert "block-heh" in added
|
||
assert "block-zayin" in added
|
||
|
||
# Now apply a revision on the retrofitted file
|
||
out = tmp_path / "revised.docx"
|
||
revs = [Revision(id="r1", type="insert_after",
|
||
anchor_bookmark="block-yod",
|
||
content="פסק הלכה שהוסף: בבג\"ץ 1/23 נקבע כי...",
|
||
style="body")]
|
||
rr = apply_tracked_revisions(user_file, out, revs)
|
||
assert rr.applied == 1
|
||
|
||
# Verify output has the insertion inside <w:ins>
|
||
with zipfile.ZipFile(out, "r") as zf:
|
||
tree = etree.fromstring(zf.read("word/document.xml"))
|
||
ins_texts = ["".join(el.itertext()) for el in tree.iterfind(".//w:ins", NSMAP)]
|
||
assert any("פסק הלכה שהוסף" in t for t in ins_texts)
|
||
|
||
|
||
def test_retrofit_preserves_original_paragraphs(tmp_path: Path) -> None:
|
||
user_file = tmp_path / "user.docx"
|
||
_make_user_edited_docx(user_file)
|
||
|
||
before_doc = Document(str(user_file))
|
||
before_texts = [p.text for p in before_doc.paragraphs]
|
||
|
||
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
||
|
||
after_doc = Document(str(user_file))
|
||
after_texts = [p.text for p in after_doc.paragraphs]
|
||
# Paragraph texts should be identical (we only added bookmark markers)
|
||
assert before_texts == after_texts
|
||
|
||
|
||
def test_idempotent_retrofit_and_revise(tmp_path: Path) -> None:
|
||
"""Running retrofit twice + revising should still produce valid output."""
|
||
user_file = tmp_path / "user.docx"
|
||
_make_user_edited_docx(user_file)
|
||
|
||
# First retrofit
|
||
r1 = docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
||
# Second retrofit — should add no new bookmarks
|
||
r2 = docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
||
assert r2["bookmarks_added"] == []
|
||
assert set(r2["existing_bookmarks"]) >= set(r1["bookmarks_added"])
|
||
|
||
# Then revise works normally
|
||
out = tmp_path / "revised.docx"
|
||
revs = [Revision(id="r1", type="insert_after",
|
||
anchor_bookmark="block-yod", content="x")]
|
||
result = apply_tracked_revisions(user_file, out, revs)
|
||
assert result.applied == 1
|
||
|
||
|
||
def test_multiple_revisions_all_tracked_independently(tmp_path: Path) -> None:
|
||
"""Verify multiple tracked changes each get independent ins ids so
|
||
user can Accept/Reject each one separately in Word."""
|
||
user_file = tmp_path / "user.docx"
|
||
_make_user_edited_docx(user_file)
|
||
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
||
|
||
out = tmp_path / "revised.docx"
|
||
revs = [
|
||
Revision(id="r1", type="insert_after",
|
||
anchor_bookmark="block-heh", content="תוספת 1"),
|
||
Revision(id="r2", type="insert_after",
|
||
anchor_bookmark="block-yod", content="תוספת 2"),
|
||
Revision(id="r3", type="insert_before",
|
||
anchor_bookmark="block-yod-alef", content="תוספת 3"),
|
||
]
|
||
result = apply_tracked_revisions(user_file, out, revs)
|
||
assert result.applied == 3
|
||
|
||
with zipfile.ZipFile(out, "r") as zf:
|
||
tree = etree.fromstring(zf.read("word/document.xml"))
|
||
ins_ids = {el.get(_w("id")) for el in tree.iterfind(".//w:ins", NSMAP)}
|
||
assert len(ins_ids) >= 3 # at least one unique id per revision
|
||
|
||
|
||
def test_rtl_preserved_in_tracked_insertion(tmp_path: Path) -> None:
|
||
"""Inserted paragraph must have bidi + an <w:rtl/> run marker so Hebrew
|
||
renders via the template's cs=David slot — but font/size come from the
|
||
template's named paragraph style (INV-EX4), not hand-set rFonts/sz."""
|
||
user_file = tmp_path / "user.docx"
|
||
_make_user_edited_docx(user_file)
|
||
docx_retrofit.retrofit_bookmarks(user_file, backup=False)
|
||
|
||
out = tmp_path / "out.docx"
|
||
revs = [Revision(id="r1", type="insert_after",
|
||
anchor_bookmark="block-yod", content="עברית RTL")]
|
||
apply_tracked_revisions(user_file, out, revs)
|
||
|
||
with zipfile.ZipFile(out, "r") as zf:
|
||
tree = etree.fromstring(zf.read("word/document.xml"))
|
||
|
||
# Find the ins that holds runs
|
||
for ins in tree.iterfind(".//w:ins", NSMAP):
|
||
runs = ins.findall(".//w:r", NSMAP)
|
||
for r in runs:
|
||
text_els = r.findall(".//w:t", NSMAP)
|
||
if any("עברית RTL" in (t.text or "") for t in text_els):
|
||
rPr = r.find(_w("rPr"))
|
||
assert rPr is not None
|
||
# RTL marker preserved…
|
||
assert rPr.find(_w("rtl")) is not None
|
||
# …but NO hand-set font/size on the run (INV-EX4).
|
||
assert rPr.find(_w("rFonts")) is None
|
||
assert rPr.find(_w("sz")) is None
|
||
return
|
||
pytest.fail("tracked insertion with 'עברית RTL' not found")
|