Files
2026Technology-Competition/tests/test_docx_injector.py
T
lhl d9aa3a3325 fix(writer): 修复概要设计书输出塌缩与章间引用缺失,并补 parser 防灾
- Writer: 表格表头行/Table Grid 边框、列表 List Bullet/Number 样式、行内字符格式不再塌缩(外视 #6 反转)
- Writer: 打通章间引用(WriterState 摘要 → 后章 prompt prior_summaries)
- Writer: 删除 _chunk_source 死代码,章节数据经 DataGate 控 token 预算
- Writer: 章节结果落盘快照,命中即跳过 LLM(录制重拍可续跑,损坏快照自动忽略)
- Parser: 按 body 顺序遍历正文+单元格(含嵌套表、合并单元格去重),修复表格内锚点漏检导致的静默丢章
- Parser/服务层: .xls 显式拒绝(可操作提示),上传即校验扩展名,rules 对齐 docx-only
- 测试: 全量 680 通过,覆盖率 99.37%(红线 99%)
2026-09-15 21:30:55 +08:00

355 lines
14 KiB
Python
Raw 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 docx.shared import Pt
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", text="テーブル一覧", headers=["テーブル", "説明"],
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
# ---------- 表格:表头行 + 边框 + 标题(外视 #6 反转) ----------
def test_table_header_row_rendered(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
out = DocxInjector(tpl).inject({"db_design": _section_blocks()}, {"doc_title": "T"})
tbl = out.tables[0]
assert [c.text for c in tbl.rows[0].cells] == ["テーブル", "説明"]
assert [c.text for c in tbl.rows[1].cells] == ["TB001", "社員"]
def test_table_has_grid_borders(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
out = DocxInjector(tpl).inject({"db_design": _section_blocks()}, {"doc_title": "T"})
assert out.tables[0].style is not None
assert out.tables[0].style.name == "Table Grid"
def test_table_caption_rendered(tmp_path):
tpl = _make_template(tmp_path, "{{section:db_design}}")
out = DocxInjector(tpl).inject({"db_design": _section_blocks()}, {"doc_title": "T"})
assert any("テーブル一覧" in p.text for p in out.paragraphs)
# ---------- 列表:逐项 + 样式(外视 #6 反转) ----------
def test_list_block_uses_bullet_style(tmp_path):
tpl = _make_template(tmp_path, "{{section:x}}")
blocks = [Block(kind="list", items=["項目一", "項目二"], style="bullet")]
out = DocxInjector(tpl).inject({"x": blocks}, {"doc_title": "T"})
bullets = [p for p in out.paragraphs if p.style.name == "List Bullet"]
assert [p.text for p in bullets] == ["項目一", "項目二"]
def test_list_block_uses_numbered_style(tmp_path):
tpl = _make_template(tmp_path, "{{section:x}}")
blocks = [Block(kind="list", items=["A", "B"], style="numbered")]
out = DocxInjector(tpl).inject({"x": blocks}, {"doc_title": "T"})
numbered = [p for p in out.paragraphs if p.style.name == "List Number"]
assert [p.text for p in numbered] == ["A", "B"]
# ---------- 行内替换:保留字符格式 ----------
def test_inline_replacement_preserves_run_format(tmp_path):
doc = Document()
para = doc.add_paragraph()
run = para.add_run("標題:{{doc_title}}")
run.font.size = Pt(20)
run.bold = True
path = tmp_path / "tpl_fmt.docx"
doc.save(str(path))
out = DocxInjector(str(path)).inject({}, {"doc_title": "概要設計書"})
out_para = out.paragraphs[0]
assert out_para.text == "標題:概要設計書"
assert out_para.runs[0].font.size == Pt(20)
assert out_para.runs[0].bold is True
# ---------- 防御分支:非数字 Heading / 无 run / 多 run 跨段 ----------
def test_heading_level_non_numeric_tail_returns_none():
assert DocxInjector._heading_level("Heading X") is None
assert DocxInjector._heading_level(None) is None
def test_bare_subheading_followed_by_table_is_kept(tmp_path):
"""裸子节后紧跟表格 → 非「纯空」(表格为其内容),保留不删。"""
doc = Document()
doc.add_paragraph("{{section:x}}")
doc.add_paragraph("補足", style="Heading 2")
doc.add_paragraph("") # 空段落 → 扫描继续
doc.add_table(rows=1, cols=1) # 表格 → 终止「裸」判定
path = tmp_path / "tpl_tbl.docx"
doc.save(str(path))
out = DocxInjector(str(path)).inject({"x": []}, {"doc_title": "T"})
assert any(p.text.strip() == "補足" for p in out.paragraphs)
def test_set_paragraph_text_adds_run_when_none(tmp_path):
"""无 run 的段落:走 add_run 分支且不报错。"""
target = Document().add_paragraph()
DocxInjector(str(tmp_path / "unused.docx"))._set_paragraph_text(target, "新文本")
assert target.text == "新文本"
def test_inline_replacement_across_multiple_runs(tmp_path):
"""行内占位符跨多个 run → 保留首个 run 格式并清空其余 run。"""
doc = Document()
para = doc.add_paragraph()
r1 = para.add_run("{{")
r1.bold = True
para.add_run("doc_title}}")
path = tmp_path / "tpl_multi.docx"
doc.save(str(path))
out = DocxInjector(str(path)).inject({}, {"doc_title": "表題"})
p = out.paragraphs[0]
assert p.text == "表題"
assert p.runs[0].bold is True
# ---------- P2-1:表格单元格内的占位符注入(此前只扫正文段落 → 漏注入/漏检残留) ----------
def _all_text(doc) -> str:
"""正文段落 + 表格单元格段落的全部文本(用于单元格注入断言)。
合并单元格的 row.cells 会重复指向同一 tc → 按 tc 去重,避免重复计数。
"""
parts = [p.text for p in doc.paragraphs]
seen_tc: set = set()
for tbl in doc.tables:
for row in tbl.rows:
for cell in row.cells:
if id(cell._tc) in seen_tc:
continue
seen_tc.add(id(cell._tc))
parts.extend(p.text for p in cell.paragraphs)
return "\n".join(parts)
def _cell_anchor_template(tmp_path, inner="{{section:db_design}}", name="tpl_cell.docx") -> str:
doc = Document()
doc.add_paragraph("{{doc_title}}")
tbl = doc.add_table(rows=1, cols=1)
tbl.rows[0].cells[0].paragraphs[0].text = inner
doc.add_paragraph("尾部固定内容")
path = tmp_path / name
doc.save(str(path))
return str(path)
def test_section_placeholder_inside_table_cell_replaced(tmp_path):
tpl = _cell_anchor_template(tmp_path)
out = DocxInjector(tpl).inject({"db_design": _section_blocks()}, {"doc_title": "T"})
text = _all_text(out)
assert "3.1 テーブル一覧" in text # 标题块注入进单元格
assert "以下がDB表定义です。" in text
assert "{{section:db_design}}" not in text # 占位符已被替换
def test_inline_meta_inside_table_cell_replaced(tmp_path):
tpl = _cell_anchor_template(tmp_path, inner="{{doc_title}}", name="tpl_cell_meta.docx")
out = DocxInjector(tpl).inject({}, {"doc_title": "表題"})
text = _all_text(out)
assert "表題" in text and "{{doc_title}}" not in text
def test_residue_inside_table_cell_detected(tmp_path):
"""单元格内未替换的占位符必须被残留检查捕获(否则静默丢内容)。"""
tpl = _cell_anchor_template(tmp_path, inner="{{section:unknown}}", name="tpl_cell_res.docx")
with pytest.raises(DocxInjectError, match="残留"):
DocxInjector(tpl).inject({}, {"doc_title": "T"})
def test_merged_cell_placeholder_injected_once(tmp_path):
doc = Document()
tbl = doc.add_table(rows=1, cols=2)
tbl.rows[0].cells[0].merge(tbl.rows[0].cells[1])
tbl.rows[0].cells[0].paragraphs[0].text = "{{section:x}}"
path = tmp_path / "tpl_merge.docx"
doc.save(str(path))
blocks = [Block(kind="paragraph", text="注入内容")]
out = DocxInjector(str(path)).inject({"x": blocks}, {})
assert _all_text(out).count("注入内容") == 1