תיקוני-תבנית DOCX (WS5 ת1–ת3): הטמפלט decision_template.docx מכיל 3 טבלאות- דוגמה (כותרת מוסדית / הרכב / חתימות). _clear_body הסיר רק <w:p> והשאיר את הטבלאות. כתוצאה: - ת3 (שורש): טבלת-החתימות שרדה במיקום-הטמפלט (למעלה, צמוד לטבלת-הכותרת/בלוק-ד), וכל תוכן-ההחלטה נדחף אחריה. החתימות הופיעו בראש המסמך במקום בסוף. - ת1: טבלאות הכותרת/הרכב הזריקו נתוני בלוק-א–ד שלא חולצו מהפרוטוקול. התיקון בשכבת-הרינדור (G1 — לתקן במקור, לא לשכפל סימפטום): _clear_body מסיר כעת את כל גוף-המסמך (פסקאות, טבלאות, ו-bookmarks יתומים) ומשאיר רק sectPr. ההחלטה משוחזרת כולה מ-decision_blocks (INV-EX1) — החתימות מגיעות מ-block-yod-bet שמרונדר אחרון בסדר-הבלוקים, ולכן טבלאות-הדוגמה הן פיגום מיושן שצריך להימחק. ת2 כבר תקין: block-dalet מרנדר רק את הכותרת "החלטה" (Heading 1, מתעלם מתוכן- ה-DB) — הוסף test מפורש לנעילה. תוקן גם ב-analysis_docx_exporter._clear_body (אותו באג, סימטריה G2). Tests: 4 regression חדשים + 27 docx-tests עוברים. Invariants: INV-EX1 (DOCX נתון-נגזר מ-decision_blocks, ללא דליפת תוכן-טמפלט) · G1 (תיקון-במקור) · G2 (סימטריה בין שני ה-exporters) · G11/block-schema (block-ד = כותרת בלבד, block-יב = חתימות בסוף). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
307 lines
12 KiB
Python
307 lines
12 KiB
Python
"""בדיקות ל-bookmark helpers ב-docx_exporter.
|
||
|
||
הבדיקות מתרכזות ב-helper functions בלבד (לא בכל ה-export flow שדורש DB).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import zipfile
|
||
from pathlib import Path
|
||
|
||
from docx import Document
|
||
from lxml import etree
|
||
|
||
from legal_mcp.services.docx_exporter import (
|
||
_BOOKMARK_ID_START,
|
||
HEBREW_FONT,
|
||
TEMPLATE_PATH,
|
||
_add_styled_paragraph,
|
||
_clear_body,
|
||
_insert_bookmark_end,
|
||
_insert_bookmark_start,
|
||
_mark_paragraph_rtl,
|
||
_mark_run_rtl,
|
||
_strip_dashes,
|
||
_wrap_block_with_bookmarks,
|
||
_write_block_to_docx,
|
||
)
|
||
from legal_mcp.services.docx_reviser import NSMAP, _w, list_bookmarks
|
||
|
||
from docx.oxml.ns import qn
|
||
|
||
|
||
def test_insert_bookmark_helpers_create_valid_xml(tmp_path: Path) -> None:
|
||
doc = Document()
|
||
p = doc.add_paragraph("תוכן בלוק י")
|
||
_insert_bookmark_start(p, "block-yod", 10001)
|
||
_insert_bookmark_end(p, 10001)
|
||
|
||
out = tmp_path / "out.docx"
|
||
doc.save(str(out))
|
||
|
||
# Verify via list_bookmarks (uses the same XML)
|
||
assert list_bookmarks(out) == ["block-yod"]
|
||
|
||
|
||
def test_wrap_block_with_bookmarks_wraps_multiple_paragraphs(tmp_path: Path) -> None:
|
||
doc = Document()
|
||
doc.add_paragraph("ראשון — לפני") # noise before
|
||
|
||
bm_counter = [_BOOKMARK_ID_START]
|
||
|
||
def writer() -> None:
|
||
doc.add_paragraph("בלוק — פסקה 1")
|
||
doc.add_paragraph("בלוק — פסקה 2")
|
||
doc.add_paragraph("בלוק — פסקה 3")
|
||
|
||
_wrap_block_with_bookmarks(doc, "block-yod", writer, bm_counter)
|
||
doc.add_paragraph("אחרי — אחרון") # noise after
|
||
|
||
out = tmp_path / "out.docx"
|
||
doc.save(str(out))
|
||
|
||
# The bookmark should wrap exactly the 3 middle paragraphs
|
||
with zipfile.ZipFile(out, "r") as zf:
|
||
tree = etree.fromstring(zf.read("word/document.xml"))
|
||
|
||
paragraphs = tree.findall(".//w:p", NSMAP)
|
||
# Find para index of bookmarkStart and bookmarkEnd
|
||
start_idx = end_idx = None
|
||
for i, p in enumerate(paragraphs):
|
||
if p.find(".//w:bookmarkStart", NSMAP) is not None:
|
||
start_idx = i
|
||
if p.find(".//w:bookmarkEnd", NSMAP) is not None:
|
||
end_idx = i
|
||
assert start_idx is not None
|
||
assert end_idx is not None
|
||
# The paragraph containing start must be the first new one ("פסקה 1")
|
||
start_text = "".join(paragraphs[start_idx].itertext())
|
||
end_text = "".join(paragraphs[end_idx].itertext())
|
||
assert "פסקה 1" in start_text
|
||
assert "פסקה 3" in end_text
|
||
|
||
|
||
def test_wrap_block_skipped_when_writer_adds_nothing(tmp_path: Path) -> None:
|
||
doc = Document()
|
||
bm_counter = [_BOOKMARK_ID_START]
|
||
_wrap_block_with_bookmarks(doc, "block-empty", lambda: None, bm_counter)
|
||
out = tmp_path / "out.docx"
|
||
doc.save(str(out))
|
||
assert list_bookmarks(out) == []
|
||
|
||
|
||
def test_multiple_blocks_get_unique_bookmark_ids(tmp_path: Path) -> None:
|
||
doc = Document()
|
||
bm_counter = [_BOOKMARK_ID_START]
|
||
for name in ("block-alef", "block-bet", "block-gimel"):
|
||
_wrap_block_with_bookmarks(
|
||
doc, name,
|
||
lambda n=name: doc.add_paragraph(f"תוכן של {n}"),
|
||
bm_counter,
|
||
)
|
||
out = tmp_path / "out.docx"
|
||
doc.save(str(out))
|
||
|
||
with zipfile.ZipFile(out, "r") as zf:
|
||
tree = etree.fromstring(zf.read("word/document.xml"))
|
||
|
||
ids = [el.get(_w("id")) for el in tree.iterfind(".//w:bookmarkStart", NSMAP)]
|
||
assert len(ids) == 3
|
||
assert len(set(ids)) == 3
|
||
|
||
names = list_bookmarks(out)
|
||
assert set(names) == {"block-alef", "block-bet", "block-gimel"}
|
||
|
||
|
||
# ── RTL / David-font invariants ───────────────────────────────────
|
||
# These guard against regressions where Hebrew renders LTR or in the wrong
|
||
# font slot (Times New Roman instead of David). See plan file for context.
|
||
|
||
|
||
def test_mark_paragraph_rtl_adds_bidi_directly_in_pPr() -> None:
|
||
doc = Document()
|
||
p = doc.add_paragraph("טקסט בעברית")
|
||
_mark_paragraph_rtl(p)
|
||
pPr = p._p.find(qn("w:pPr"))
|
||
assert pPr is not None
|
||
# <w:bidi/> must be a direct child of pPr (paragraph direction),
|
||
# NOT nested inside <w:rPr>.
|
||
assert pPr.find(qn("w:bidi")) is not None
|
||
# paragraph-mark rPr still gets <w:rtl/>
|
||
rPr = pPr.find(qn("w:rPr"))
|
||
assert rPr is not None and rPr.find(qn("w:rtl")) is not None
|
||
|
||
|
||
def test_mark_run_rtl_forces_david_on_all_font_slots() -> None:
|
||
doc = Document()
|
||
p = doc.add_paragraph()
|
||
run = p.add_run("טקסט")
|
||
_mark_run_rtl(run)
|
||
rPr = run._r.find(qn("w:rPr"))
|
||
assert rPr is not None
|
||
fonts = rPr.find(qn("w:rFonts"))
|
||
assert fonts is not None
|
||
for slot in ("w:ascii", "w:hAnsi", "w:cs", "w:eastAsia"):
|
||
assert fonts.get(qn(slot)) == HEBREW_FONT, f"{slot} not {HEBREW_FONT}"
|
||
assert rPr.find(qn("w:rtl")) is not None
|
||
|
||
|
||
def test_styled_paragraph_applies_bidi_and_david() -> None:
|
||
"""End-to-end: _add_styled_paragraph produces pPr/bidi + rFonts/cs=David."""
|
||
doc = Document()
|
||
_add_styled_paragraph(doc, "פסקה עברית", style="Normal")
|
||
p = doc.paragraphs[-1]
|
||
assert p._p.find(qn("w:pPr")).find(qn("w:bidi")) is not None
|
||
run = p.runs[0]
|
||
fonts = run._r.find(qn("w:rPr")).find(qn("w:rFonts"))
|
||
assert fonts.get(qn("w:cs")) == HEBREW_FONT
|
||
|
||
|
||
def test_block_dalet_does_not_use_title_style() -> None:
|
||
"""Title style uses theme fonts and 28pt — avoid for Hebrew."""
|
||
doc = Document()
|
||
_write_block_to_docx(doc, "block-dalet", title="", content="")
|
||
styles_used = {p.style.name for p in doc.paragraphs}
|
||
assert "Title" not in styles_used, (
|
||
f"block-dalet should not produce a Title-styled paragraph, got {styles_used}"
|
||
)
|
||
# The 'החלטה' text must still appear somewhere
|
||
texts = [p.text for p in doc.paragraphs]
|
||
assert any("החלטה" in t for t in texts)
|
||
|
||
|
||
def test_block_dalet_is_heading_only_ignores_db_content() -> None:
|
||
"""ת2 — block-ד is the 'החלטה' heading marker, NOT a data block.
|
||
|
||
Whatever content sits in decision_blocks for block-dalet must be ignored;
|
||
the only non-empty text line produced is the literal heading 'החלטה'.
|
||
"""
|
||
doc = Document()
|
||
_write_block_to_docx(
|
||
doc, "block-dalet", title="כותרת",
|
||
content="נתון ישן שצריך להיות מתועלם\nשורה שנייה",
|
||
)
|
||
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
|
||
assert text_lines == ["החלטה"], (
|
||
f"block-dalet must render ONLY the heading 'החלטה', got {text_lines}"
|
||
)
|
||
|
||
|
||
# ── _clear_body strips ALL scaffolding (ת1 + ת3 root cause) ────────
|
||
# The template ships 3 sample tables (header / panel / signatures). Removing
|
||
# only <w:p> left them behind: header/panel tables injected block-א–ד data
|
||
# never extracted from the protocol (ת1), and the signatures table floated to
|
||
# the top against the header (ת3 — חתימות צמודות לבלוק-ד במקום בסוף). The
|
||
# decision is rebuilt purely from decision_blocks (INV-EX1), so the template's
|
||
# sample tables are stale scaffolding and must be cleared.
|
||
|
||
|
||
def test_clear_body_removes_tables_and_leaves_only_sectpr() -> None:
|
||
doc = Document(str(TEMPLATE_PATH))
|
||
body = doc.element.body
|
||
# Sanity: the template really ships sample tables (else this test is moot).
|
||
assert len(body.findall(qn("w:tbl"))) > 0, "template expected to ship sample tables"
|
||
|
||
_clear_body(doc)
|
||
|
||
remaining = [c.tag.split("}")[-1] for c in list(body)]
|
||
assert remaining == ["sectPr"], (
|
||
f"_clear_body must leave only sectPr, got {remaining}"
|
||
)
|
||
assert len(body.findall(qn("w:tbl"))) == 0, "no template tables may survive"
|
||
assert len(body.findall(qn("w:p"))) == 0, "no template paragraphs may survive"
|
||
|
||
|
||
def test_signatures_block_renders_after_body_not_at_top() -> None:
|
||
"""ת3 — block-yod-bet (חתימות) renders LAST, after a cleared body.
|
||
|
||
Regression for the bug where the leftover template signatures table sat at
|
||
the top against the header table. After the _clear_body fix, signatures
|
||
come only from block-yod-bet, which the export loop emits last in block
|
||
order.
|
||
"""
|
||
doc = Document(str(TEMPLATE_PATH))
|
||
_clear_body(doc)
|
||
|
||
# Emit a header block, a body block, then signatures last (canonical order).
|
||
_write_block_to_docx(doc, "block-alef", title="", content="מדינת ישראל")
|
||
_write_block_to_docx(doc, "block-vav", title="", content="רקע עובדתי\n1. המקרקעין.")
|
||
_write_block_to_docx(
|
||
doc, "block-yod-bet", title="",
|
||
content='ניתנה פה אחד היום.\nדפנה תמיר, עו"ד',
|
||
)
|
||
|
||
text_lines = [p.text for p in doc.paragraphs if p.text.strip()]
|
||
assert text_lines[0] == "מדינת ישראל", text_lines
|
||
# The signatures block (block-yod-bet) emits each line as its own paragraph,
|
||
# so its content must occupy the document's tail — after the body block.
|
||
body_idx = text_lines.index("רקע עובדתי")
|
||
sig_idx = next(i for i, t in enumerate(text_lines) if "ניתנה פה אחד" in t)
|
||
assert sig_idx > body_idx, (
|
||
f"signatures must come AFTER the body, got tail={text_lines[-3:]}"
|
||
)
|
||
assert any("דפנה תמיר" in t for t in text_lines[sig_idx:]), text_lines[sig_idx:]
|
||
# No template signatures table ("מזכירת ועדת ערר") leaked anywhere.
|
||
assert all(
|
||
"מזכירת ועדת ערר" not in (p.text or "") for p in doc.paragraphs
|
||
), "template signatures-table text leaked into the body"
|
||
|
||
|
||
# ── Heading overrides, numbered-list, dash strip ──────────────────
|
||
|
||
|
||
def test_strip_dashes_removes_em_and_en_dashes() -> None:
|
||
assert _strip_dashes("תכנית 1454198 — אושרה ביום") == "תכנית 1454198 אושרה ביום"
|
||
assert _strip_dashes("א – ב") == "א ב"
|
||
assert _strip_dashes("no dash") == "no dash"
|
||
# Collapsed whitespace
|
||
assert _strip_dashes("רקע — עובדתי") == "רקע עובדתי"
|
||
|
||
|
||
def test_heading2_gets_justified_and_no_numbering() -> None:
|
||
"""Section heading → Heading 2 with jc=both and numId=0."""
|
||
doc = Document()
|
||
_write_block_to_docx(doc, "block-vav", title="", content="דיון והכרעה")
|
||
heading = next(p for p in doc.paragraphs if p.style.name == "Heading 2")
|
||
pPr = heading._p.find(qn("w:pPr"))
|
||
jc = pPr.find(qn("w:jc"))
|
||
assert jc is not None and jc.get(qn("w:val")) == "both"
|
||
numPr = pPr.find(qn("w:numPr"))
|
||
assert numPr is not None
|
||
numId = numPr.find(qn("w:numId"))
|
||
assert numId is not None and numId.get(qn("w:val")) == "0"
|
||
|
||
|
||
def test_heading3_gets_justified_not_centered() -> None:
|
||
"""Heading 3 in template has jc=center — override to jc=both."""
|
||
doc = Document()
|
||
_write_block_to_docx(doc, "block-vav", title="", content="**המצב התכנוני**")
|
||
heading = next(p for p in doc.paragraphs if p.style.name == "Heading 3")
|
||
jc = heading._p.find(qn("w:pPr")).find(qn("w:jc"))
|
||
assert jc is not None and jc.get(qn("w:val")) == "both"
|
||
|
||
|
||
def test_numbered_paragraph_uses_list_paragraph_and_strips_prefix() -> None:
|
||
"""'1. text' → List Paragraph style, literal '1. ' removed."""
|
||
doc = Document()
|
||
_write_block_to_docx(
|
||
doc, "block-vav", title="",
|
||
content="1. עניינו של ערר זה.\n2. שכונת נווה יעקב.",
|
||
)
|
||
lp = [p for p in doc.paragraphs if p.style.name == "List Paragraph"]
|
||
assert len(lp) == 2
|
||
assert lp[0].text.startswith("עניינו")
|
||
assert not lp[0].text.startswith("1.")
|
||
assert lp[1].text.startswith("שכונת")
|
||
|
||
|
||
def test_body_content_has_no_em_dashes() -> None:
|
||
"""Content with em-dashes is rendered without them."""
|
||
doc = Document()
|
||
_write_block_to_docx(
|
||
doc, "block-vav", title="",
|
||
content="3. תכנית 5924 — קובעת את שטחי הבנייה.",
|
||
)
|
||
texts = "\n".join(p.text for p in doc.paragraphs)
|
||
assert "—" not in texts
|