Files
lhl 0a498e4f7a fix(writer): 无锚点子章归并父章生成 + 注入后裸重复子节去重
- 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 无重复
2026-08-24 12:23:35 +08:00

186 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""docx 注入原型测试(T17OV8)。
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 中锚点为 {{sectionid}}(全角冒号)也能注入
tpl = _make_template(tmp_path, "{{sectiondb_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 "{{sectiondb_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 blocklevel=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 等 kindrenderer 依赖) ----------
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