370 lines
13 KiB
Python
370 lines
13 KiB
Python
"""Word处理器章节填充逻辑单元测试"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from tempfile import TemporaryDirectory
|
|
|
|
import pytest
|
|
from docx import Document
|
|
|
|
from bidmaster.nodes.content.save_to_word import SaveToWordNode
|
|
from bidmaster.nodes.content.cleanup_markdown_in_word import CleanupMarkdownInWordNode
|
|
from bidmaster.nodes.base import NodeContext
|
|
from bidmaster.tools.word import WordProcessor
|
|
|
|
|
|
@pytest.fixture()
|
|
def word_processor() -> WordProcessor:
|
|
return WordProcessor()
|
|
|
|
|
|
def _create_document(path: Path, headings: list[tuple[str, int]]) -> None:
|
|
doc = Document()
|
|
for text, level in headings:
|
|
doc.add_heading(text, level=level)
|
|
doc.save(path)
|
|
|
|
|
|
def _count_heading_paragraphs(doc: Document) -> int:
|
|
return sum(1 for para in doc.paragraphs if para.style.name.startswith("Heading"))
|
|
|
|
|
|
def test_fill_chapter_with_numbered_heading(word_processor: WordProcessor) -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "numbered.docx"
|
|
_create_document(doc_path, [("1 商务和技术偏差表", 1)])
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_1",
|
|
"title": "商务和技术偏差表",
|
|
"level": 1,
|
|
"placeholder": "{{chapter_1_content}}",
|
|
"normalized_title": word_processor._normalize_heading_text("商务和技术偏差表"),
|
|
"heading_number": "1",
|
|
"order_index": 1,
|
|
}
|
|
|
|
word_processor.fill_chapter_content(doc_path, chapter_meta, "测试段落内容")
|
|
|
|
doc = Document(doc_path)
|
|
assert any("测试段落内容" in para.text for para in doc.paragraphs)
|
|
|
|
|
|
def test_fill_chapter_without_number_uses_title(word_processor: WordProcessor) -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "title_only.docx"
|
|
_create_document(doc_path, [("商务和技术偏差表", 1)])
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_1",
|
|
"title": "商务和技术偏差表",
|
|
"level": 1,
|
|
"placeholder": "{{chapter_1_content}}",
|
|
"normalized_title": word_processor._normalize_heading_text("商务和技术偏差表"),
|
|
"heading_number": None,
|
|
"order_index": 1,
|
|
}
|
|
|
|
word_processor.fill_chapter_content(doc_path, chapter_meta, "服务方案内容")
|
|
|
|
doc = Document(doc_path)
|
|
assert any("服务方案内容" in para.text for para in doc.paragraphs)
|
|
|
|
|
|
def test_fill_chapter_sequence_fallback(word_processor: WordProcessor) -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "sequence.docx"
|
|
_create_document(
|
|
doc_path,
|
|
[
|
|
("项目背景", 1),
|
|
("售后服务和质保期服务计划", 1),
|
|
],
|
|
)
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_11",
|
|
"title": "售后服务和质保期服务计划",
|
|
"level": 1,
|
|
"placeholder": "{{chapter_11_content}}",
|
|
"normalized_title": word_processor._normalize_heading_text("售后服务和质保期服务计划"),
|
|
"heading_number": None,
|
|
"order_index": 2,
|
|
}
|
|
|
|
word_processor.fill_chapter_content(doc_path, chapter_meta, "售后保障措施")
|
|
|
|
doc = Document(doc_path)
|
|
assert any("售后保障措施" in para.text for para in doc.paragraphs)
|
|
|
|
|
|
def test_fill_chapter_appends_when_not_found(word_processor: WordProcessor) -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "fallback.docx"
|
|
_create_document(doc_path, [("项目概述", 1)])
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_missing",
|
|
"title": "未匹配章节",
|
|
"level": 1,
|
|
"placeholder": "{{chapter_missing_content}}",
|
|
"normalized_title": word_processor._normalize_heading_text("未匹配章节"),
|
|
"heading_number": None,
|
|
"order_index": 2,
|
|
}
|
|
|
|
word_processor.fill_chapter_content(doc_path, chapter_meta, "新增内容")
|
|
|
|
doc = Document(doc_path)
|
|
assert any("【系统提示】" in para.text for para in doc.paragraphs)
|
|
assert any("新增内容" in para.text for para in doc.paragraphs)
|
|
|
|
|
|
def test_fill_does_not_create_additional_headings(word_processor: WordProcessor) -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "no_new_headings.docx"
|
|
_create_document(
|
|
doc_path,
|
|
[
|
|
("1 总体方案", 1),
|
|
("1.1 服务范围", 2),
|
|
],
|
|
)
|
|
|
|
before = Document(doc_path)
|
|
before_count = _count_heading_paragraphs(before)
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_1_1",
|
|
"title": "服务范围",
|
|
"level": 2,
|
|
"placeholder": "{{chapter_1_1_content}}",
|
|
"normalized_title": word_processor._normalize_heading_text("服务范围"),
|
|
"heading_number": "1.1",
|
|
"order_index": 1,
|
|
}
|
|
|
|
content = "## 小节标题\n服务内容描述"
|
|
word_processor.fill_chapter_content(doc_path, chapter_meta, content)
|
|
|
|
after = Document(doc_path)
|
|
after_count = _count_heading_paragraphs(after)
|
|
assert after_count == before_count
|
|
|
|
|
|
def test_markdown_headings_render_as_bold_paragraphs(word_processor: WordProcessor) -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "markdown.docx"
|
|
_create_document(doc_path, [("1 技术方案", 1)])
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_1",
|
|
"title": "技术方案",
|
|
"level": 1,
|
|
"placeholder": "{{chapter_1_content}}",
|
|
"normalized_title": word_processor._normalize_heading_text("技术方案"),
|
|
"heading_number": "1",
|
|
"order_index": 1,
|
|
}
|
|
|
|
word_processor.fill_chapter_content(
|
|
doc_path,
|
|
chapter_meta,
|
|
"## 子标题\n段落内容",
|
|
)
|
|
|
|
doc = Document(doc_path)
|
|
bold_paras = [para for para in doc.paragraphs if para.text.strip() == "子标题"]
|
|
assert bold_paras, "应生成加粗段落"
|
|
assert all(not para.style.name.startswith("Heading") for para in bold_paras)
|
|
assert any(any(run.bold for run in para.runs) for para in bold_paras)
|
|
|
|
|
|
def test_fill_matches_higher_level_heading(word_processor: WordProcessor) -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "deep_heading.docx"
|
|
_create_document(doc_path, [("1.1.1.1 深度章节", 4)])
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_1_1_1_1",
|
|
"title": "深度章节",
|
|
"level": 4,
|
|
"placeholder": "{{chapter_1_1_1_1_content}}",
|
|
"normalized_title": word_processor._normalize_heading_text("深度章节"),
|
|
"heading_number": "1.1.1.1",
|
|
"order_index": 1,
|
|
}
|
|
|
|
word_processor.fill_chapter_content(doc_path, chapter_meta, "深度内容")
|
|
|
|
doc = Document(doc_path)
|
|
assert any("深度内容" in para.text for para in doc.paragraphs)
|
|
|
|
|
|
def test_save_node_auto_converts_markdown_table() -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "table_from_md.docx"
|
|
_create_document(doc_path, [("1 表格章节", 1)])
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_1",
|
|
"title": "表格章节",
|
|
"level": 1,
|
|
"placeholder": "{{chapter_1_content}}",
|
|
"normalized_title": WordProcessor()._normalize_heading_text("表格章节"),
|
|
"heading_number": "1",
|
|
"order_index": 1,
|
|
}
|
|
|
|
content = """表格说明\n| 项目 | 数量 |\n| --- | --- |\n| A | 1 |\n| B | 2 |"""
|
|
|
|
node = SaveToWordNode()
|
|
node._fill_word_document(str(doc_path), chapter_meta, content)
|
|
|
|
doc = Document(doc_path)
|
|
assert len(doc.tables) == 1
|
|
table = doc.tables[0]
|
|
assert table.cell(0, 0).text.strip() == "项目"
|
|
assert table.cell(1, 1).text.strip() == "1"
|
|
|
|
|
|
def test_table_borders_are_solid() -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "table_border.docx"
|
|
_create_document(doc_path, [("1 边框", 1)])
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_1",
|
|
"title": "边框",
|
|
"level": 1,
|
|
"placeholder": "{{chapter_1_content}}",
|
|
"normalized_title": WordProcessor()._normalize_heading_text("边框"),
|
|
"heading_number": "1",
|
|
"order_index": 1,
|
|
}
|
|
|
|
content = """| A | B |
|
|
| --- | --- |
|
|
| 1 | 2 |"""
|
|
|
|
node = SaveToWordNode()
|
|
node._fill_word_document(str(doc_path), chapter_meta, content)
|
|
|
|
doc = Document(doc_path)
|
|
table = doc.tables[0]
|
|
xml = table._element.tblPr.xml if table._element.tblPr is not None else ""
|
|
assert "tblBorders" in xml
|
|
assert "w:val=\"single\"" in xml
|
|
|
|
|
|
def test_fill_with_structured_table_block(word_processor: WordProcessor) -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "table_block.docx"
|
|
_create_document(doc_path, [("1 技术方案", 1)])
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_1",
|
|
"title": "技术方案",
|
|
"level": 1,
|
|
"placeholder": "{{chapter_1_content}}",
|
|
"normalized_title": word_processor._normalize_heading_text("技术方案"),
|
|
"heading_number": "1",
|
|
"order_index": 1,
|
|
}
|
|
|
|
content = json.dumps({
|
|
"blocks": [
|
|
{"type": "paragraph", "text": "方案概述"},
|
|
{
|
|
"type": "table",
|
|
"title": "设备清单",
|
|
"headers": ["名称", "数量"],
|
|
"rows": [["A型设备", "2"], ["B型设备", "4"]],
|
|
"style": "Table Grid",
|
|
"column_widths_cm": [6, 3],
|
|
},
|
|
]
|
|
})
|
|
|
|
word_processor.fill_chapter_content(doc_path, chapter_meta, content)
|
|
|
|
doc = Document(doc_path)
|
|
assert any("方案概述" in para.text for para in doc.paragraphs)
|
|
assert len(doc.tables) == 1
|
|
table = doc.tables[0]
|
|
assert table.cell(0, 0).text == "名称"
|
|
assert table.cell(1, 0).text == "A型设备"
|
|
|
|
|
|
def test_cleanup_node_preserves_placeholders() -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "placeholder.docx"
|
|
doc = Document()
|
|
doc.add_paragraph("{{chapter_1_content}}")
|
|
doc.save(doc_path)
|
|
|
|
node = CleanupMarkdownInWordNode()
|
|
state = {
|
|
"word_file": str(doc_path),
|
|
"last_generated_content": "**触发清理**",
|
|
}
|
|
node.execute(state, NodeContext())
|
|
|
|
after = Document(doc_path)
|
|
assert any("{{chapter_1_content}}" in para.text for para in after.paragraphs)
|
|
|
|
|
|
def test_cleanup_node_removes_markdown_from_structured_blocks(word_processor: WordProcessor) -> None:
|
|
with TemporaryDirectory() as tmp_dir:
|
|
doc_path = Path(tmp_dir) / "cleanup_structured.docx"
|
|
_create_document(doc_path, [("1 技术方案", 1)])
|
|
|
|
chapter_meta = {
|
|
"id": "chapter_1",
|
|
"title": "技术方案",
|
|
"level": 1,
|
|
"placeholder": "{{chapter_1_content}}",
|
|
"normalized_title": word_processor._normalize_heading_text("技术方案"),
|
|
"heading_number": "1",
|
|
"order_index": 1,
|
|
}
|
|
|
|
content = {
|
|
"blocks": [
|
|
{"type": "paragraph", "text": "## 子标题"},
|
|
{
|
|
"type": "paragraph",
|
|
"text": "这是 **加粗**、`代码` 和 [链接](http://example.com) 以及 ~~删除线~~",
|
|
},
|
|
{
|
|
"type": "table",
|
|
"headers": ["**名称**", "`数量`"],
|
|
"rows": [["**A**", "`1`"], ["B", "2"]],
|
|
},
|
|
]
|
|
}
|
|
|
|
word_processor.fill_chapter_content(doc_path, chapter_meta, content)
|
|
|
|
node = CleanupMarkdownInWordNode()
|
|
state = {
|
|
"word_file": str(doc_path),
|
|
"last_generated_content": content,
|
|
}
|
|
node.execute(state, NodeContext())
|
|
|
|
after = Document(doc_path)
|
|
text = "\n".join(para.text for para in after.paragraphs)
|
|
assert "##" not in text
|
|
assert "**" not in text
|
|
assert "`" not in text
|
|
assert "](" not in text
|
|
assert "~~" not in text
|
|
assert any(para.text.strip() == "子标题" for para in after.paragraphs)
|
|
|
|
assert after.tables
|
|
table = after.tables[0]
|
|
assert table.cell(0, 0).text.strip() == "名称"
|
|
assert table.cell(0, 1).text.strip() == "数量"
|
|
assert table.cell(1, 0).text.strip() == "A"
|
|
assert table.cell(1, 1).text.strip() == "1" |