- 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%)
355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""docx 注入原型测试(T17,OV8)。
|
||
|
||
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 中锚点为 {{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
|
||
|
||
|
||
# ---------- 表格:表头行 + 边框 + 标题(外视 #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
|