Plan: fix H3 spy tests + _render_pre trailing empty

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).
This commit is contained in:
2026-07-28 00:00:07 +03:00
parent 5891ed892c
commit e36e87966d
+93 -11
View File
@@ -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 <w:num> 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 <w:abstractNum> 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 = """
<ul>
<li>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 <w:br/> + empty run.
lines = text.split("\n")
while lines and lines[-1] == "":
lines.pop()
p = doc.add_paragraph()
for i, line in enumerate(lines):