From e36e87966dde8d45ba02e16c2e92bc773fb6451a Mon Sep 17 00:00:00 2001 From: Sebastian Petrescu Date: Tue, 28 Jul 2026 00:00:07 +0300 Subject: [PATCH] Plan: fix H3 spy tests + _render_pre trailing empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empirical audit (Stage 2) found 2 critical spy tests guarding the H3 keystone: - test_register_multilevel_numbering_creates_abstractNum_with_3_levels - test_nested_bullet_list_uses_increasing_ilvl Both passed when register_multilevel_numbering was mutated to 'return 1' (no XML written). Rewrote both to BEHAVIORAL form: trace numId → w:num → w:abstractNumId → w:abstractNum, assert THAT abstractNum has ilvl=[0,1,2]. Also: _render_pre now drops trailing empty line (text.split('\n') artifact). --- docs/plans/2026-07-27-md2doc.md | 104 ++++++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 11 deletions(-) diff --git a/docs/plans/2026-07-27-md2doc.md b/docs/plans/2026-07-27-md2doc.md index 847aee0..8f1609f 100644 --- a/docs/plans/2026-07-27-md2doc.md +++ b/docs/plans/2026-07-27-md2doc.md @@ -610,20 +610,48 @@ def test_add_cell_shading_inserts_shd_in_tcPr(): def test_register_multilevel_numbering_creates_abstractNum_with_3_levels(): - """H3 Route B: register creates abstractNum with 3 w:lvl elements.""" + """H3 Route B: register creates ONE abstractNum with 3 w:lvl elements (ilvl 0/1/2). + + BEHAVIORAL: traces num_id → w:num → w:abstractNumId → w:abstractNum, then + asserts THAT abstractNum (not all 9 default abstractNums in the template) + has exactly the levels we registered. A `return 1` no-op mutation must FAIL + because no w:num with our id exists. + """ doc = Document() num_id = register_multilevel_numbering(doc, levels=3, kind="bullet") assert isinstance(num_id, int) - # Inspect numbering.xml part + tmp = Path(__file__).parent / "_tmp_num.docx" doc.save(tmp) try: with zipfile.ZipFile(tmp) as z: num_xml = z.read("word/numbering.xml") root = ET.fromstring(num_xml) - lvls = root.findall(f".//{{{W_NS}}}lvl") - # at least 3 levels in the abstractNum we just registered - assert len(lvls) >= 3, f"expected >=3 levels, found {len(lvls)}" + + # Find w:num matching our num_id + our_num = None + for num_el in root.findall(f"{{{W_NS}}}num"): + if num_el.get(f"{{{W_NS}}}numId") == str(num_id): + our_num = num_el + break + assert our_num is not None, f"No with numId={num_id} (function did nothing)" + + # Follow to abstractNumId + abstract_ref = our_num.find(f"{{{W_NS}}}abstractNumId") + assert abstract_ref is not None, "missing w:abstractNumId in our w:num" + abstract_id = abstract_ref.get(f"{{{W_NS}}}val") + + # Find that abstractNum and verify it has the 3 levels + our_abstract = None + for an in root.findall(f"{{{W_NS}}}abstractNum"): + if an.get(f"{{{W_NS}}}abstractNumId") == abstract_id: + our_abstract = an + break + assert our_abstract is not None, f"No with id={abstract_id}" + + lvls = our_abstract.findall(f"{{{W_NS}}}lvl") + ilvls = sorted(int(l.get(f"{{{W_NS}}}ilvl")) for l in lvls) + assert ilvls == [0, 1, 2], f"expected ilvl=[0,1,2] in OUR abstractNum, got {ilvls}" finally: tmp.unlink() @@ -1124,7 +1152,12 @@ def test_flat_bullet_list_produces_3_items_at_ilvl_0(): assert ilvls == [0, 0, 0], f"expected all ilvl=0, got {ilvls}" -def test_nested_bullet_list_uses_increasing_ilvl(): +def test_nested_bullet_list_uses_increasing_ilvl_and_resolves_to_multilevel_abstractNum(): + """BEHAVIORAL: not only must paragraphs have ilvl=[0,1,2], but the numId they + reference must resolve to an abstractNum that actually defines those levels. + A no-op register_multilevel_numbering (return 1) would leave paragraphs + pointing to default-template numId=1 (single-level), so the test must FAIL. + """ html = """
  • Top @@ -1141,10 +1174,55 @@ def test_nested_bullet_list_uses_increasing_ilvl(): doc = Document() node = BeautifulSoup(html, "html.parser").find("ul") _render_list(doc, node, style=STYLES["elegant"]) - root = get_doc_xml(doc) - ilvls = [int(el.get(f"{{{W_NS}}}val")) for el in root.iter(f"{{{W_NS}}}ilvl")] - # Expect ilvl 0, 1, 2 across the nesting - assert ilvls == [0, 1, 2], f"expected 0,1,2 nesting, got {ilvls}" + + # Save and read both document.xml AND numbering.xml + tmp = Path(__file__).parent / "_tmp_list_full.docx" + doc.save(tmp) + try: + with zipfile.ZipFile(tmp) as z: + doc_xml = z.read("word/document.xml") + num_xml = z.read("word/numbering.xml") + doc_root = ET.fromstring(doc_xml) + num_root = ET.fromstring(num_xml) + + # 1. Paragraphs must have ilvl=[0,1,2] + ilvls = [int(el.get(f"{{{W_NS}}}val")) + for el in doc_root.iter(f"{{{W_NS}}}ilvl")] + assert ilvls == [0, 1, 2], f"expected paragraph ilvl=[0,1,2], got {ilvls}" + + # 2. All paragraphs must reference the SAME numId (our registered numbering) + numIds = [el.get(f"{{{W_NS}}}val") + for el in doc_root.iter(f"{{{W_NS}}}numId")] + assert len(set(numIds)) == 1, \ + f"expected all paragraphs to share one numId, got {set(numIds)}" + our_num_id = numIds[0] + + # 3. That numId must resolve (via w:num → w:abstractNumId) to an abstractNum + # that defines ilvl=0,1,2 — otherwise Word renders flat lists. + our_num = None + for num_el in num_root.findall(f"{{{W_NS}}}num"): + if num_el.get(f"{{{W_NS}}}numId") == our_num_id: + our_num = num_el + break + assert our_num is not None, f"our numId={our_num_id} not found in numbering.xml" + + abstract_ref = our_num.find(f"{{{W_NS}}}abstractNumId") + assert abstract_ref is not None + abstract_id = abstract_ref.get(f"{{{W_NS}}}val") + + our_abstract = None + for an in num_root.findall(f"{{{W_NS}}}abstractNum"): + if an.get(f"{{{W_NS}}}abstractNumId") == abstract_id: + our_abstract = an + break + assert our_abstract is not None, f"abstractNum {abstract_id} not found" + + abstract_ilvls = sorted(int(l.get(f"{{{W_NS}}}ilvl")) + for l in our_abstract.findall(f"{{{W_NS}}}lvl")) + assert abstract_ilvls == [0, 1, 2], \ + f"our abstractNum must define ilvl=[0,1,2], got {abstract_ilvls}" + finally: + tmp.unlink() def test_numbered_list_uses_decimal_format(): @@ -1406,12 +1484,16 @@ def _render_pre(doc, node, style: dict): Preserves newlines via add_break() between lines (Word ignores literal \\n in runs). Leading whitespace on each line is preserved as literal spaces (monospace font - keeps alignment). + keeps alignment). Trailing empty line from markdown's final \\n is dropped. """ code_fill = style.get("code_fill", "f7f8fa") # Get text without collapse — preserves whitespace and indentation text = node.get_text() + # Drop trailing empty line(s) from markdown's terminal \n — would create + # spurious extra + empty run. lines = text.split("\n") + while lines and lines[-1] == "": + lines.pop() p = doc.add_paragraph() for i, line in enumerate(lines):