- 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%)
180 lines
6.4 KiB
Python
180 lines
6.4 KiB
Python
from genesis.data_models import ChapterMarker, ParsedTemplate
|
||
from genesis.parsers._word_common import heading_level
|
||
from genesis.parsers.word_template_parser import WordTemplateParser
|
||
|
||
from tests.docx_helpers import new_document, save_document
|
||
|
||
|
||
def test_heading_level_parses_numeric_suffix():
|
||
assert heading_level("Heading 1") == 1
|
||
assert heading_level("Heading 2") == 2
|
||
assert heading_level("Heading 3") == 3
|
||
|
||
|
||
def test_heading_level_fallback_on_invalid():
|
||
# 兜底分支(非数字 / 无后缀)→ 1,分支覆盖必须命中
|
||
assert heading_level("Heading X") == 1
|
||
assert heading_level("Heading") == 1
|
||
|
||
|
||
def test_parse_extracts_heading_levels(tmp_path):
|
||
doc = new_document()
|
||
doc.add_heading("1. はじめに", level=1)
|
||
doc.add_heading("2.1 画面遷移図", level=2)
|
||
doc.add_heading("2.1.1 詳細", level=3)
|
||
path = save_document(tmp_path, doc)
|
||
|
||
result = WordTemplateParser().parse(path)
|
||
|
||
assert isinstance(result, ParsedTemplate)
|
||
headings = [s for s in result.sections if s.type == "heading"]
|
||
assert [(s.name, s.level) for s in headings] == [
|
||
("1. はじめに", 1),
|
||
("2.1 画面遷移図", 2),
|
||
("2.1.1 詳細", 3),
|
||
]
|
||
|
||
|
||
def test_parse_extracts_bookmark(tmp_path):
|
||
from docx.oxml.ns import qn
|
||
doc = new_document()
|
||
para = doc.add_paragraph("アンカー")
|
||
bm_start = para._p.makeelement(qn("w:bookmarkStart"), {qn("w:id"): "0", qn("w:name"): "template_start"})
|
||
para._p.insert(0, bm_start)
|
||
# 无 name 的书签:覆盖 if name 假分支,应被跳过
|
||
bm_anon = para._p.makeelement(qn("w:bookmarkStart"), {qn("w:id"): "1"})
|
||
para._p.insert(1, bm_anon)
|
||
path = save_document(tmp_path, doc)
|
||
|
||
result = WordTemplateParser().parse(path)
|
||
|
||
bookmarks = [s for s in result.sections if s.type == "bookmark"]
|
||
assert [s.name for s in bookmarks] == ["template_start"]
|
||
|
||
|
||
def test_parse_extracts_placeholders(tmp_path):
|
||
doc = new_document()
|
||
doc.add_paragraph("{{doc_title}}")
|
||
doc.add_paragraph("{{section:introduction}}")
|
||
doc.add_paragraph("{{section:function_list}}")
|
||
path = save_document(tmp_path, doc)
|
||
|
||
result = WordTemplateParser().parse(path)
|
||
|
||
assert result.placeholders == {
|
||
"doc_title": "{{doc_title}}",
|
||
"section:introduction": "{{section:introduction}}",
|
||
"section:function_list": "{{section:function_list}}",
|
||
}
|
||
ph = [s for s in result.sections if s.type == "placeholder"]
|
||
assert [s.name for s in ph] == ["doc_title", "section:introduction", "section:function_list"]
|
||
|
||
|
||
def test_parse_invalid_placeholder_kept_as_text(tmp_path):
|
||
doc = new_document()
|
||
doc.add_paragraph("{{ invalid }}")
|
||
doc.add_paragraph("ただの {text}")
|
||
path = save_document(tmp_path, doc)
|
||
|
||
result = WordTemplateParser().parse(path)
|
||
|
||
assert result.placeholders == {}
|
||
assert [s for s in result.sections if s.type == "placeholder"] == []
|
||
|
||
|
||
def test_parse_empty_document(tmp_path):
|
||
doc = new_document()
|
||
path = save_document(tmp_path, doc)
|
||
|
||
result = WordTemplateParser().parse(path)
|
||
|
||
assert result.sections == []
|
||
assert result.placeholders == {}
|
||
assert "Normal" in result.styles["defined"]
|
||
|
||
|
||
def test_parse_styles_collected(tmp_path):
|
||
doc = new_document()
|
||
doc.add_heading("章", level=1)
|
||
doc.add_paragraph("本文")
|
||
path = save_document(tmp_path, doc)
|
||
|
||
result = WordTemplateParser().parse(path)
|
||
|
||
assert "Heading 1" in result.styles["used"]
|
||
assert "Normal" in result.styles["used"]
|
||
assert "Heading 1" in result.styles["defined"]
|
||
|
||
|
||
def test_parse_extracts_placeholder_case_insensitive(tmp_path):
|
||
# 宽容:键名大小写不敏感 → 归一为小写 section:id
|
||
doc = new_document()
|
||
doc.add_paragraph("{{Section:2}}")
|
||
path = save_document(tmp_path, doc)
|
||
|
||
result = WordTemplateParser().parse(path)
|
||
|
||
assert result.placeholders == {"section:2": "{{Section:2}}"}
|
||
ph = [s for s in result.sections if s.type == "placeholder"]
|
||
assert [s.name for s in ph] == ["section:2"]
|
||
|
||
|
||
def test_parse_extracts_placeholder_fullwidth_colon(tmp_path):
|
||
# 宽容:全角冒号(:)也视为分隔符 → 归一为半角 section:id
|
||
doc = new_document()
|
||
doc.add_paragraph("{{section:2}}")
|
||
path = save_document(tmp_path, doc)
|
||
|
||
result = WordTemplateParser().parse(path)
|
||
|
||
assert result.placeholders == {"section:2": "{{section:2}}"}
|
||
ph = [s for s in result.sections if s.type == "placeholder"]
|
||
assert [s.name for s in ph] == ["section:2"]
|
||
|
||
|
||
# ---------- P2-1:表格单元格内的占位符(此前漏检 → 静默丢章) ----------
|
||
|
||
def _doc_with_cell_placeholder():
|
||
"""H1 → 表格(单元格内含锚点) → H1。锚点必须归属其前的 H1。"""
|
||
doc = new_document()
|
||
doc.add_heading("1. はじめに", level=1)
|
||
tbl = doc.add_table(rows=1, cols=1)
|
||
tbl.rows[0].cells[0].paragraphs[0].text = "{{section:introduction}}"
|
||
doc.add_heading("2. 機能一覧", level=1)
|
||
doc.add_paragraph("{{section:function_list}}")
|
||
return doc
|
||
|
||
|
||
def test_parse_placeholder_inside_table_cell(tmp_path):
|
||
path = save_document(tmp_path, _doc_with_cell_placeholder())
|
||
result = WordTemplateParser().parse(path)
|
||
assert "section:introduction" in result.placeholders
|
||
ph = [s.name for s in result.sections if s.type == "placeholder"]
|
||
assert "section:introduction" in ph
|
||
|
||
|
||
def test_parse_table_placeholder_keeps_document_order(tmp_path):
|
||
"""单元格锚点须按文档顺序落位(在其前的 H1 与在其后的 H1 之间),不得错绑。"""
|
||
path = save_document(tmp_path, _doc_with_cell_placeholder())
|
||
result = WordTemplateParser().parse(path)
|
||
names = [(s.type, s.name) for s in result.sections]
|
||
i_intro = names.index(("heading", "1. はじめに"))
|
||
i_ph = names.index(("placeholder", "section:introduction"))
|
||
i_func = names.index(("heading", "2. 機能一覧"))
|
||
assert i_intro < i_ph < i_func
|
||
|
||
|
||
def test_parse_merged_cell_placeholder_not_duplicated(tmp_path):
|
||
"""合并单元格的 row.cells 会重复指向同一 tc → 不得重复收集。"""
|
||
doc = new_document()
|
||
doc.add_heading("1. 章", level=1)
|
||
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:db_design}}"
|
||
path = save_document(tmp_path, doc)
|
||
|
||
result = WordTemplateParser().parse(path)
|
||
|
||
ph = [s.name for s in result.sections if s.type == "placeholder"]
|
||
assert ph.count("section:db_design") == 1
|