- template_mapper: 按 design §6.5 仅 Heading level<=1 起章,H2/H3 归入
父章 sub_headings(此前每个 heading 独立成章,无 {{section:id}} 锚点的
6 个子章生成后被静默丢弃)
- models: ChapterSpec.sub_headings 字段;to_vars 暴露 sub_headings 变量
- writer_agent: prompt 新增【小节约束】(有子节时按小节顺序以 level=2
heading 组织,不得遗漏或新增)
- docx_injector: 注入后删除同章内与已生成标题同名且完全无内容的模板裸
H2/H3(保守策略:带内容的模板子节保留)
- 真实试运行验证:7 章 / 无静默丢弃告警 / 14 个 H2 无重复
186 lines
7.4 KiB
Python
186 lines
7.4 KiB
Python
"""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
|