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:
@@ -610,20 +610,48 @@ def test_add_cell_shading_inserts_shd_in_tcPr():
|
|||||||
|
|
||||||
|
|
||||||
def test_register_multilevel_numbering_creates_abstractNum_with_3_levels():
|
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()
|
doc = Document()
|
||||||
num_id = register_multilevel_numbering(doc, levels=3, kind="bullet")
|
num_id = register_multilevel_numbering(doc, levels=3, kind="bullet")
|
||||||
assert isinstance(num_id, int)
|
assert isinstance(num_id, int)
|
||||||
# Inspect numbering.xml part
|
|
||||||
tmp = Path(__file__).parent / "_tmp_num.docx"
|
tmp = Path(__file__).parent / "_tmp_num.docx"
|
||||||
doc.save(tmp)
|
doc.save(tmp)
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(tmp) as z:
|
with zipfile.ZipFile(tmp) as z:
|
||||||
num_xml = z.read("word/numbering.xml")
|
num_xml = z.read("word/numbering.xml")
|
||||||
root = ET.fromstring(num_xml)
|
root = ET.fromstring(num_xml)
|
||||||
lvls = root.findall(f".//{{{W_NS}}}lvl")
|
|
||||||
# at least 3 levels in the abstractNum we just registered
|
# Find w:num matching our num_id
|
||||||
assert len(lvls) >= 3, f"expected >=3 levels, found {len(lvls)}"
|
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:
|
finally:
|
||||||
tmp.unlink()
|
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}"
|
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 = """
|
html = """
|
||||||
<ul>
|
<ul>
|
||||||
<li>Top
|
<li>Top
|
||||||
@@ -1141,10 +1174,55 @@ def test_nested_bullet_list_uses_increasing_ilvl():
|
|||||||
doc = Document()
|
doc = Document()
|
||||||
node = BeautifulSoup(html, "html.parser").find("ul")
|
node = BeautifulSoup(html, "html.parser").find("ul")
|
||||||
_render_list(doc, node, style=STYLES["elegant"])
|
_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")]
|
# Save and read both document.xml AND numbering.xml
|
||||||
# Expect ilvl 0, 1, 2 across the nesting
|
tmp = Path(__file__).parent / "_tmp_list_full.docx"
|
||||||
assert ilvls == [0, 1, 2], f"expected 0,1,2 nesting, got {ilvls}"
|
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():
|
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).
|
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
|
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")
|
code_fill = style.get("code_fill", "f7f8fa")
|
||||||
# Get text without collapse — preserves whitespace and indentation
|
# Get text without collapse — preserves whitespace and indentation
|
||||||
text = node.get_text()
|
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")
|
lines = text.split("\n")
|
||||||
|
while lines and lines[-1] == "":
|
||||||
|
lines.pop()
|
||||||
|
|
||||||
p = doc.add_paragraph()
|
p = doc.add_paragraph()
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
|
|||||||
Reference in New Issue
Block a user