"""docx 注入原型测试(T17,OV8)。 OV8 裁定:最难成功标准(格式精度)排关键路径末尾 → docx 注入原型提前验证。 本文件测试 DocxInjector:模板占位符替换(章节级/行内)、残留检查、格式精度 (注入 heading 继承模板 Heading 样式,原有内容样式不被破坏)。 """ from __future__ import annotations import pytest from docx import Document from genesis.writer.docx_injector import ( Block, DocxInjectError, DocxInjector, ) def _make_template(tmp_path, body: str) -> str: doc = Document() doc.add_paragraph("{{doc_title}}") # 行内占位符 doc.add_paragraph(body) # 章节占位符所在段落 doc.add_paragraph("尾部固定内容") path = tmp_path / "template.docx" doc.save(str(path)) return str(path) def _section_blocks() -> list[Block]: return [ Block(kind="heading", text="3.1 テーブル一覧", level=2), Block(kind="paragraph", text="以下がDB表定义です。"), Block(kind="table", rows=[["テーブル", "説明"], ["TB001", "社員"]]), ] # ---------- 章节级占位符替换 ---------- def test_section_placeholder_replaced(tmp_path): tpl = _make_template(tmp_path, "{{section:db_design}}") inj = DocxInjector(tpl) out = inj.inject({"db_design": _section_blocks()}, {"doc_title": "概要設計書"}) full_text = "\n".join(p.text for p in out.paragraphs) assert "{{section:db_design}}" not in full_text assert "3.1 テーブル一覧" in full_text assert "以下がDB表定义です。" in full_text def test_section_placeholder_case_insensitive(tmp_path): # 宽容:docx 中锚点为 {{Section:id}}(大写键)也能注入 tpl = _make_template(tmp_path, "{{Section:db_design}}") inj = DocxInjector(tpl) out = inj.inject({"db_design": _section_blocks()}, {"doc_title": "概要設計書"}) full_text = "\n".join(p.text for p in out.paragraphs) assert "{{Section:db_design}}" not in full_text assert "3.1 テーブル一覧" in full_text def test_section_placeholder_fullwidth_colon(tmp_path): # 宽容:docx 中锚点为 {{section:id}}(全角冒号)也能注入 tpl = _make_template(tmp_path, "{{section:db_design}}") inj = DocxInjector(tpl) out = inj.inject({"db_design": _section_blocks()}, {"doc_title": "概要設計書"}) full_text = "\n".join(p.text for p in out.paragraphs) assert "{{section:db_design}}" not in full_text assert "3.1 テーブル一覧" in full_text # ---------- 行内占位符替换 ---------- def test_inline_meta_replaced(tmp_path): tpl = _make_template(tmp_path, "{{section:db_design}}") inj = DocxInjector(tpl) out = inj.inject({"db_design": []}, {"doc_title": "概要設計書"}) assert "{{doc_title}}" not in "\n".join(p.text for p in out.paragraphs) assert "概要設計書" in "\n".join(p.text for p in out.paragraphs) # ---------- 占位符残留检查 ---------- def test_residue_detection_raises(tmp_path): tpl = _make_template(tmp_path, "{{section:unknown_chapter}}") inj = DocxInjector(tpl) with pytest.raises(DocxInjectError, match="残留"): inj.inject({}, {"doc_title": "X"}) # ---------- 格式精度:heading 继承模板样式 ---------- def test_heading_inherits_template_style(tmp_path): tpl = _make_template(tmp_path, "{{section:db_design}}") inj = DocxInjector(tpl) out = inj.inject({"db_design": _section_blocks()}, {"doc_title": "T"}) # 注入的 heading block(level=2)应渲染为模板中存在的 Heading 2 样式段落 heading_paras = [p for p in out.paragraphs if p.style.name == "Heading 2"] assert any("3.1 テーブル一覧" in p.text for p in heading_paras) def test_original_content_style_preserved(tmp_path): tpl = _make_template(tmp_path, "{{section:db_design}}") inj = DocxInjector(tpl) out = inj.inject({"db_design": []}, {"doc_title": "T"}) # 模板原有段落(尾部固定内容)在注入后仍存在且未被破坏 assert any("尾部固定内容" in p.text for p in out.paragraphs) # ---------- Block 支持 table/list/note 等 kind(renderer 依赖) ---------- def test_block_accepts_table_list_note(): assert Block(kind="table", text="t", rows=[["x"]]).kind == "table" assert Block(kind="list", text="a\nb").kind == "list" assert Block(kind="note", text="n").kind == "note" # ---------- 裸子节去重(design §6.5:H2 归并生成后,模板原有空子节标题删除) ---------- def _make_subheading_template(tmp_path) -> str: """模板:H1 章 → 锚点 → 模板自带两个空 H2 子节(无任何内容)。""" doc = Document() doc.add_paragraph("{{doc_title}}") doc.add_paragraph("2. 機能一覧", style="Heading 1") doc.add_paragraph("{{section:function_list}}") doc.add_paragraph("2.1 機能一覧表", style="Heading 2") # 裸模板子节 doc.add_paragraph("2.2 機能詳細", style="Heading 2") # 裸模板子节 path = tmp_path / "tpl_sub.docx" doc.save(str(path)) return str(path) def _subheading_blocks() -> list[Block]: return [ Block(kind="heading", text="2.1 機能一覧表", level=2), Block(kind="paragraph", text="機能一覧の内容"), Block(kind="heading", text="2.2 機能詳細", level=2), Block(kind="paragraph", text="機能詳細の内容"), ] def test_bare_duplicate_template_subheadings_removed(tmp_path): tpl = _make_subheading_template(tmp_path) out = DocxInjector(tpl).inject( {"function_list": _subheading_blocks()}, {"doc_title": "T"} ) h2_texts = [p.text.strip() for p in out.paragraphs if p.style.name == "Heading 2"] # 生成的同名 H2 已带内容,模板原有的空 H2 应被删除(不重复) assert h2_texts.count("2.1 機能一覧表") == 1 assert h2_texts.count("2.2 機能詳細") == 1 joined = "\n".join(p.text for p in out.paragraphs) assert "機能一覧の内容" in joined and "機能詳細の内容" in joined def test_non_duplicate_bare_subheading_kept(tmp_path): """模板独有的裸子节(本章未生成同名标题)→ 保留,不误删。""" doc = Document() doc.add_paragraph("{{doc_title}}") doc.add_paragraph("{{section:x}}") doc.add_paragraph("付録注記", style="Heading 2") # 模板独有裸子节 path = tmp_path / "tpl_keep.docx" doc.save(str(path)) blocks = [Block(kind="paragraph", text="章内容")] out = DocxInjector(str(path)).inject({"x": blocks}, {"doc_title": "T"}) assert any(p.text.strip() == "付録注記" for p in out.paragraphs) def test_duplicate_subheading_with_content_not_removed(tmp_path): """后出现的重复子节若带内容(非裸)→ 保留内容,只删纯重复标题场景之外不动。""" doc = Document() doc.add_paragraph("{{doc_title}}") doc.add_paragraph("{{section:x}}") doc.add_paragraph("2.1 表", style="Heading 2") doc.add_paragraph("テーブル定義は別紙参照。") # 模板子节下有实质内容 → 非裸 path = tmp_path / "tpl_content.docx" doc.save(str(path)) blocks = [Block(kind="heading", text="2.1 表", level=2), Block(kind="paragraph", text="生成内容")] out = DocxInjector(str(path)).inject({"x": blocks}, {"doc_title": "T"}) texts = [p.text for p in out.paragraphs] # 模板子节有内容 → 不删(保守策略:仅删除完全空的重复标题) assert "テーブル定義は別紙参照。" in texts