tests/test_docx_skill.py
tests/test_docx_skill.pyBrowse 12 files
5,722 tokens
21,267 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1# MIT License. End-to-end tests for the docx skill.2"""Pytest suite proving create / read / edit / template round-trips.3 4Runs the scripts as subprocesses (argparse CLIs) and also verifies the5outputs with python-docx directly. Stdlib + python-docx only; all6fixtures are generated on the fly; no network.7"""8from __future__ import annotations9 10import json11import os12import struct13import subprocess14import sys15import zlib16from pathlib import Path17 18import pytest19from docx import Document20 21SKILL = Path(__file__).resolve().parent.parent22SCRIPTS = SKILL / "scripts"23 24NON_ASCII = "Фамилия — ‘test’"25 26 27def make_png(path: Path) -> None:28 """Write a tiny valid 2x2 red PNG using only stdlib."""29 def chunk(tag: bytes, data: bytes) -> bytes:30 return (struct.pack(">I", len(data)) + tag + data31 + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF))32 33 ihdr = struct.pack(">IIBBBBB", 2, 2, 8, 2, 0, 0, 0)34 raw = b"".join(b"\x00" + b"\xff\x00\x00" * 2 for _ in range(2))35 png = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr)36 + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b""))37 path.write_bytes(png)38 39 40def run(script: str, *args: str) -> dict:41 env = dict(os.environ)42 env["LC_ALL"] = "C" # prove no locale-default text reads43 env["PYTHONIOENCODING"] = "utf-8"44 proc = subprocess.run(45 [sys.executable, str(SCRIPTS / script), *map(str, args)],46 capture_output=True, env=env)47 assert proc.returncode == 0, proc.stderr.decode("utf-8", "replace")48 return json.loads(proc.stdout.decode("utf-8"))49 50 51@pytest.fixture(scope="module")52def workdir(tmp_path_factory) -> Path:53 return tmp_path_factory.mktemp("docxskill")54 55 56@pytest.fixture(scope="module")57def created(workdir: Path) -> Path:58 """Create a document exercising every create feature."""59 png = workdir / "pic.png"60 make_png(png)61 spec = {62 "page": {"width_mm": 210, "height_mm": 297,63 "margins_mm": {"top": 25, "bottom": 25,64 "left": 20, "right": 20}},65 "header": "Report header",66 "footer": "Page footer",67 "styles": [{"name": "FancyNote", "base": "Normal", "font": "Arial",68 "size_pt": 11, "italic": True, "color": "1F4E79"}],69 "blocks": [70 {"type": "heading", "text": "Main Title", "level": 1},71 {"type": "heading", "text": "Section One", "level": 2},72 {"type": "paragraph", "runs": [73 {"text": "plain "},74 {"text": "boldbit", "bold": True},75 {"text": " italicbit", "italic": True},76 {"text": " underbit", "underline": True}]},77 {"type": "paragraph", "text": "Styled note.",78 "style": "FancyNote"},79 {"type": "bullet_list", "items": ["alpha", "beta"]},80 {"type": "numbered_list", "items": ["first", "second"]},81 {"type": "table", "header": ["Name", "Qty"],82 "rows": [["Widget", "3"], ["Gadget", "5"]],83 "style": "Table Grid", "header_bold": True},84 {"type": "image", "path": str(png), "width_mm": 30},85 {"type": "page_break"},86 {"type": "paragraph", "text": "After the break."},87 ],88 }89 spec_path = workdir / "spec.json"90 spec_path.write_text(json.dumps(spec), encoding="utf-8")91 out = workdir / "created.docx"92 res = run("docx_create.py", spec_path, out)93 assert res["ok"] and out.exists()94 return out95 96 97class TestCreateAndRead:98 def test_text_roundtrip(self, created: Path):99 text = run("docx_read.py", created, "--text")100 body = "\n".join(text["body"])101 for expected in ("Main Title", "plain boldbit italicbit underbit",102 "Styled note.", "alpha", "second",103 "After the break."):104 assert expected in body105 assert text["tables"] == [[["Name", "Qty"], ["Widget", "3"],106 ["Gadget", "5"]]]107 assert "Report header" in text["headers"]108 assert "Page footer" in text["footers"]109 110 def test_structure(self, created: Path):111 st = run("docx_read.py", created, "--structure")112 outline = [(h["level"], h["text"]) for h in st["outline"]]113 assert (1, "Main Title") in outline114 assert (2, "Section One") in outline115 assert st["table_count"] == 1116 assert st["tables"][0] == {"rows": 3, "cols": 2}117 118 def test_styles_used(self, created: Path):119 styles = run("docx_read.py", created, "--styles")["styles"]120 for s in ("Heading 1", "FancyNote", "List Bullet", "List Number",121 "Table Grid"):122 assert s in styles123 124 def test_images_extracted(self, created: Path, workdir: Path):125 outdir = workdir / "media"126 res = run("docx_read.py", created, "--images", outdir)127 assert len(res["images"]) == 1128 img = Path(res["images"][0])129 assert img.read_bytes().startswith(b"\x89PNG")130 131 def test_run_formatting_persisted(self, created: Path):132 doc = Document(str(created))133 para = next(p for p in doc.paragraphs if "boldbit" in p.text)134 flags = {r.text.strip(): (r.bold, r.italic, r.underline)135 for r in para.runs if r.text.strip()}136 assert flags["boldbit"][0] is True137 assert flags["italicbit"][1] is True138 assert flags["underbit"][2] is True139 140 def test_page_setup(self, created: Path):141 sec = Document(str(created)).sections[0]142 assert round(sec.page_width.mm) == 210143 assert round(sec.top_margin.mm) == 25144 145 def test_revisions_detection(self, created: Path):146 rev = run("docx_read.py", created, "--revisions")147 assert rev["has_tracked_changes"] is False148 assert rev["comments"] is False149 150 151class TestEdit:152 def test_replace_preserves_formatting(self, created: Path, workdir: Path):153 out = workdir / "edited.docx"154 res = run("docx_edit.py", "replace", created, "--find", "boldbit",155 "--replace", "REPLACED", "-o", out)156 assert res["replacements"] == 1157 doc = Document(str(out))158 para = next(p for p in doc.paragraphs if "REPLACED" in p.text)159 run_ = next(r for r in para.runs if "REPLACED" in r.text)160 assert run_.bold is True # formatting survived161 162 def test_set_cell(self, created: Path, workdir: Path):163 out = workdir / "cell.docx"164 run("docx_edit.py", "set-cell", created, "--table", "0", "--row",165 "1", "--col", "1", "--text", "99", "-o", out)166 assert Document(str(out)).tables[0].cell(1, 1).text == "99"167 168 def test_insert_and_delete(self, created: Path, workdir: Path):169 out = workdir / "ins.docx"170 run("docx_edit.py", "insert", created, "--index", "0", "--text",171 "Inserted first", "-o", out)172 doc = Document(str(out))173 assert doc.paragraphs[0].text == "Inserted first"174 out2 = workdir / "del.docx"175 run("docx_edit.py", "delete", out, "--index", "0", "-o", out2)176 assert Document(str(out2)).paragraphs[0].text != "Inserted first"177 178 def test_apply_style(self, created: Path, workdir: Path):179 out = workdir / "styled.docx"180 doc = Document(str(created))181 idx = next(i for i, p in enumerate(doc.paragraphs)182 if p.text == "After the break.")183 run("docx_edit.py", "style", created, "--index", str(idx),184 "--style", "Heading 2", "-o", out)185 doc2 = Document(str(out))186 assert doc2.paragraphs[idx].style.name == "Heading 2"187 188 189class TestTemplate:190 def test_fill_everywhere_non_ascii(self, workdir: Path):191 # Build a template: tokens in body, split runs, table, header, footer.192 tpl = workdir / "tpl.docx"193 doc = Document()194 doc.sections[0].header.paragraphs[0].text = "H: {{name}}"195 doc.sections[0].footer.paragraphs[0].text = "F: {{date}}"196 p = doc.add_paragraph()197 p.add_run("Dear {{na") # token split across runs198 p.add_run("me}}, hello.")199 t = doc.add_table(rows=1, cols=2)200 t.cell(0, 0).text = "{{name}}"201 t.cell(0, 1).text = "{{ date }}" # spaced variant202 doc.add_paragraph("Unfilled: {{missing}}")203 doc.save(str(tpl))204 205 values = workdir / "values.json"206 values.write_text(207 json.dumps({"name": NON_ASCII, "date": "2026-08-08"},208 ensure_ascii=False), encoding="utf-8")209 out = workdir / "filled.docx"210 res = run("docx_template.py", tpl, values, out)211 assert res["ok"] is True212 assert res["unfilled_tokens"] == ["missing"]213 214 text = run("docx_read.py", out, "--text")215 assert f"Dear {NON_ASCII}, hello." in text["body"]216 assert text["tables"][0][0] == [NON_ASCII, "2026-08-08"]217 assert f"H: {NON_ASCII}" in text["headers"]218 assert "F: 2026-08-08" in text["footers"]219 220 def test_strict_fails_on_unfilled(self, workdir: Path):221 tpl = workdir / "tpl2.docx"222 doc = Document()223 doc.add_paragraph("{{gone}}")224 doc.save(str(tpl))225 values = workdir / "empty.json"226 values.write_text("{}", encoding="utf-8")227 env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")228 proc = subprocess.run(229 [sys.executable, str(SCRIPTS / "docx_template.py"), str(tpl),230 str(values), str(workdir / "out2.docx"), "--strict"],231 capture_output=True, env=env)232 assert proc.returncode == 1233 payload = json.loads(proc.stdout.decode("utf-8"))234 assert payload["unfilled_tokens"] == ["gone"]235 236 237# --------------------------------------------------------------- new parity238 239W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"240 241 242def q(tag: str) -> str:243 return f"{{{W}}}{tag}"244 245 246def run_raw(script: str, *args: str):247 env = dict(os.environ, LC_ALL="C", PYTHONIOENCODING="utf-8")248 return subprocess.run(249 [sys.executable, str(SCRIPTS / script), *map(str, args)],250 capture_output=True, env=env)251 252 253def _add_ins(para, rev_id: int, text: str, author="Editor"):254 from lxml import etree255 ins = etree.SubElement(para._p, q("ins"))256 ins.set(q("id"), str(rev_id))257 ins.set(q("author"), author)258 ins.set(q("date"), "2026-01-02T03:04:05Z")259 r = etree.SubElement(ins, q("r"))260 t = etree.SubElement(r, q("t"))261 t.text = text262 t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")263 264 265def _add_del(para, rev_id: int, text: str, author="Editor"):266 from lxml import etree267 dele = etree.SubElement(para._p, q("del"))268 dele.set(q("id"), str(rev_id))269 dele.set(q("author"), author)270 dele.set(q("date"), "2026-01-02T03:04:05Z")271 r = etree.SubElement(dele, q("r"))272 t = etree.SubElement(r, q("delText"))273 t.text = text274 t.set("{http://www.w3.org/XML/1998/namespace}space", "preserve")275 276 277@pytest.fixture()278def tracked(tmp_path: Path) -> Path:279 """Doc with a tracked insertion + deletion in body AND in a table."""280 doc = Document()281 p = doc.add_paragraph("Base ")282 _add_ins(p, 1, "ADDED")283 _add_del(p, 2, "REMOVED")284 table = doc.add_table(rows=1, cols=1)285 cp = table.cell(0, 0).paragraphs[0]286 cp.add_run("Cell ")287 _add_ins(cp, 3, "CELLADD")288 _add_del(cp, 4, "CELLGONE")289 path = tmp_path / "tracked.docx"290 doc.save(str(path))291 return path292 293 294class TestRevisions:295 def test_list(self, tracked: Path):296 res = run("docx_revisions.py", "list", tracked)297 revs = {r["id"]: r for r in res["revisions"]}298 assert len(revs) == 4299 assert revs["1"] == {"id": "1", "author": "Editor",300 "date": "2026-01-02T03:04:05Z",301 "type": "insertion", "text": "ADDED"}302 assert revs["2"]["type"] == "deletion"303 assert revs["2"]["text"] == "REMOVED"304 assert revs["3"]["text"] == "CELLADD" # inside table305 assert revs["4"]["type"] == "deletion"306 307 def test_accept_all(self, tracked: Path, tmp_path: Path):308 out = tmp_path / "acc.docx"309 res = run("docx_revisions.py", "accept-all", tracked, "-o", out)310 assert res["resolved"] == 4311 doc = Document(str(out))312 assert doc.paragraphs[0].text == "Base ADDED"313 assert doc.tables[0].cell(0, 0).text == "Cell CELLADD"314 assert run("docx_revisions.py", "list", out)["revisions"] == []315 316 def test_reject_all(self, tracked: Path, tmp_path: Path):317 out = tmp_path / "rej.docx"318 run("docx_revisions.py", "reject-all", tracked, "-o", out)319 doc = Document(str(out))320 assert doc.paragraphs[0].text == "Base REMOVED"321 assert doc.tables[0].cell(0, 0).text == "Cell CELLGONE"322 323 def test_accept_single_by_id(self, tracked: Path, tmp_path: Path):324 out = tmp_path / "one.docx"325 res = run("docx_revisions.py", "accept", tracked, "--id", "1",326 "-o", out)327 assert res["resolved"] == 1328 doc = Document(str(out))329 assert doc.paragraphs[0].text == "Base ADDED" # del 2 unresolved330 remaining = run("docx_revisions.py", "list", out)["revisions"]331 assert sorted(r["id"] for r in remaining) == ["2", "3", "4"]332 333 def test_reject_single_by_id(self, tracked: Path, tmp_path: Path):334 out = tmp_path / "rone.docx"335 run("docx_revisions.py", "reject", tracked, "--id", "2", "-o", out)336 doc = Document(str(out))337 assert doc.paragraphs[0].text == "Base REMOVED" # ins 1 unresolved338 339 def test_unknown_id_fails(self, tracked: Path, tmp_path: Path):340 proc = run_raw("docx_revisions.py", "accept", tracked, "--id",341 "999", "-o", tmp_path / "x.docx")342 assert proc.returncode == 1343 344 345class TestComments:346 @pytest.fixture()347 def base(self, tmp_path: Path) -> Path:348 doc = Document()349 doc.add_paragraph("The quarterly revenue rose sharply.")350 doc.add_paragraph("Second paragraph.")351 path = tmp_path / "base.docx"352 doc.save(str(path))353 return path354 355 def test_add_list_delete(self, base: Path, tmp_path: Path):356 out = tmp_path / "com.docx"357 res = run("docx_comments.py", "add", base, "--target",358 "quarterly revenue", "--text", "Needs a source",359 "--author", "Reviewer", "--initials", "R", "-o", out)360 assert res["ok"] is True361 cid = res["comment_id"]362 363 listed = run("docx_comments.py", "list", out)["comments"]364 assert len(listed) == 1365 c = listed[0]366 assert c["id"] == cid367 assert c["author"] == "Reviewer"368 assert c["text"] == "Needs a source"369 assert c["anchored_text"] == "quarterly revenue"370 assert c["date"]371 372 # document text unchanged by anchoring373 text = run("docx_read.py", out, "--text")374 assert "The quarterly revenue rose sharply." in text["body"]375 376 out2 = tmp_path / "nocom.docx"377 run("docx_comments.py", "delete", out, "--id", cid, "-o", out2)378 assert run("docx_comments.py", "list", out2)["comments"] == []379 text2 = run("docx_read.py", out2, "--text")380 assert "The quarterly revenue rose sharply." in text2["body"]381 382 def test_xml_fallback_path(self, base: Path, tmp_path: Path):383 out = tmp_path / "xmlcom.docx"384 res = run("docx_comments.py", "add", base, "--target",385 "Second paragraph", "--text", "fallback note",386 "--author", "Bot", "--xml", "-o", out)387 assert res["native_api"] is False388 listed = run("docx_comments.py", "list", out)["comments"]389 assert listed[0]["text"] == "fallback note"390 assert listed[0]["anchored_text"] == "Second paragraph"391 # file still opens cleanly392 assert Document(str(out)).paragraphs[1].text == "Second paragraph."393 394 def test_missing_target_fails(self, base: Path, tmp_path: Path):395 proc = run_raw("docx_comments.py", "add", base, "--target",396 "not present", "--text", "x", "-o",397 tmp_path / "y.docx")398 assert proc.returncode == 1399 400 401class TestValidate:402 def test_healthy_file_passes(self, created: Path):403 res = run("docx_validate.py", created)404 assert res["ok"] is True405 assert all(i["severity"] != "error" for i in res["issues"])406 407 def test_not_a_zip(self, tmp_path: Path):408 bad = tmp_path / "bad.docx"409 bad.write_bytes(b"this is not a zip file")410 proc = run_raw("docx_validate.py", bad)411 assert proc.returncode == 1412 rep = json.loads(proc.stdout.decode("utf-8"))413 assert rep["issues"][0]["code"] == "not-a-zip"414 415 def test_dangling_rel_and_empty_image(self, created: Path,416 tmp_path: Path):417 import shutil418 import zipfile419 broken = tmp_path / "broken.docx"420 shutil.copy(created, broken)421 # rebuild the zip: drop the image part, zero out nothing else422 src = zipfile.ZipFile(str(created))423 with zipfile.ZipFile(str(broken), "w") as dst:424 for item in src.infolist():425 if item.filename.startswith("word/media/"):426 dst.writestr(item.filename, b"") # empty image427 else:428 dst.writestr(item, src.read(item.filename))429 proc = run_raw("docx_validate.py", broken)430 assert proc.returncode == 1431 rep = json.loads(proc.stdout.decode("utf-8"))432 codes = {i["code"] for i in rep["issues"]}433 assert "empty-image" in codes434 435 def test_missing_style(self, tmp_path: Path):436 import zipfile437 doc = Document()438 doc.add_paragraph("styled", style="Heading 1")439 path = tmp_path / "styles.docx"440 doc.save(str(path))441 # rewrite document.xml to reference a style id that doesn't exist442 src = zipfile.ZipFile(str(path))443 broken = tmp_path / "badstyle.docx"444 with zipfile.ZipFile(str(broken), "w") as dst:445 for item in src.infolist():446 data = src.read(item.filename)447 if item.filename == "word/document.xml":448 data = data.replace(b'w:val="Heading1"',449 b'w:val="GhostStyle"')450 dst.writestr(item, data)451 proc = run_raw("docx_validate.py", broken)452 assert proc.returncode == 1453 rep = json.loads(proc.stdout.decode("utf-8"))454 assert any(i["code"] == "missing-style" and "GhostStyle"455 in i["detail"] for i in rep["issues"])456 457 458class TestNormalize:459 def test_merges_split_runs(self, tmp_path: Path):460 doc = Document()461 p = doc.add_paragraph()462 p.add_run("Hel") # identical (no) formatting, split463 p.add_run("lo wo")464 p.add_run("rld")465 b = p.add_run("BOLD1")466 b.bold = True467 b2 = p.add_run("BOLD2")468 b2.bold = True469 i = p.add_run("ital")470 i.italic = True471 path = tmp_path / "split.docx"472 doc.save(str(path))473 474 out = tmp_path / "norm.docx"475 res = run("docx_edit.py", "normalize", path, "-o", out)476 assert res["runs_merged"] == 3 # 2 plain merges + 1 bold merge477 478 doc2 = Document(str(out))479 para = doc2.paragraphs[0]480 assert para.text == "Hello worldBOLD1BOLD2ital"481 assert [r.text for r in para.runs] == \482 ["Hello world", "BOLD1BOLD2", "ital"]483 assert para.runs[1].bold is True484 assert para.runs[2].italic is True485 486 487class TestFields:488 def test_toc_and_page_numbers_via_edit(self, created: Path,489 tmp_path: Path):490 out = tmp_path / "fields.docx"491 run("docx_edit.py", "toc", created, "--index", "0", "-o", out)492 run("docx_edit.py", "page-numbers", out)493 494 import zipfile495 doc_xml = zipfile.ZipFile(str(out)).read(496 "word/document.xml").decode("utf-8")497 assert "TOC \\o" in doc_xml498 assert "fldChar" in doc_xml499 footer_names = [n for n in zipfile.ZipFile(str(out)).namelist()500 if n.startswith("word/footer")]501 footers = "".join(zipfile.ZipFile(str(out)).read(n).decode("utf-8")502 for n in footer_names)503 assert "PAGE" in footers and "NUMPAGES" in footers504 # still a valid document505 assert run("docx_validate.py", out)["ok"] is True506 507 def test_toc_and_footer_in_create_spec(self, tmp_path: Path):508 spec = {509 "footer_page_numbers": True,510 "blocks": [511 {"type": "toc"},512 {"type": "heading", "text": "Chapter", "level": 1},513 ],514 }515 spec_path = tmp_path / "fspec.json"516 spec_path.write_text(json.dumps(spec), encoding="utf-8")517 out = tmp_path / "fcreate.docx"518 run("docx_create.py", spec_path, out)519 520 import zipfile521 z = zipfile.ZipFile(str(out))522 assert "TOC \\o" in z.read("word/document.xml").decode("utf-8")523 footers = "".join(z.read(n).decode("utf-8") for n in z.namelist()524 if n.startswith("word/footer"))525 assert "NUMPAGES" in footers526