"""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"