"""rendering/ golden 基线:四 profile 渲染同一份富 fixture,docx 内部 XML 逐字节对账。 目的:给 md 解析路径的重构(块收集器下沉 common 等)上一道「前后字节一致」的硬闸—— 上次 rendering 抽取(§8.6)就是靠同款验收零回归的,这里把它固化成测试。 对账面:word/document.xml(正文全部段落/run/表格/超链/书签,无时间戳,确定性) + word/footer*.xml(brief 页脚页码域)。core.xml 的创建时间等元数据刻意不比。 golden 更新:行为**有意**变化时(改样式/改解析语义)设 `ZCBOT_REGEN_GOLDENS=1` 重跑本文件再生成,并在 commit message 里说明为什么变;重构类改动 golden 必须零 diff。 fixture 覆盖:H1 信息带(时间窗)/TL;DR 卡片/判断 callout/blockquote/表格/代码块 (含 mermaid 回退)/缺图占位/多种列表模式/参考文献 DOI·URL 条目/引文上标 [1][2] 相邻 /化学式下标/粗斜体行内代码/HR/软换行并合/refs 段外的 [n] 行(并合语义)。 """ from __future__ import annotations import difflib import os import sys import tempfile import unittest import zipfile from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from rendering import docx_brief, docx_manuscript # noqa: E402 GOLDEN_DIR = Path(__file__).parent / "golden" / "rendering" _REGEN = os.getenv("ZCBOT_REGEN_GOLDENS", "").strip() in ("1", "true", "yes") _FIXTURE = """# 2026 低碳水泥月度简报 方向:低碳胶凝材料 | 时间窗:2026-06 | 深度:精读 | 数据源:院库+Web | 受众:课题组 ## 一句话要点 - **LC3 体系**中试放大取得进展,C3S 活性提升 12%[1][2] - 碳化养护路线成本下降,见 *Nature* 报道[W1] ## 背景与判断 面向**低碳水泥**,重点关注 CO2 矿化与 C3S/Na2SO4 激发体系,不误伤 LC3、EN 197-5。 这一行是软换行, 应当与上一行并合成同一段。 **判断**:碳化养护是当前性价比最高的路线,建议排期中试。 > 取舍纪律:只收录有 DOI 的一手文献; > 预印本单独标注。 --- ### 数据表 | 体系 | 抗压 MPa | 备注 | |---|---|---| | LC3-50 | 42.5 | `EN 197-5` | | CO2 养护 | 48.0 | **中试** | ### 代码与图 ```python def f(x): return x + 1 # 缩进必须保留 ``` ```mermaid graph TD %% caption: 工艺流程 A-->B ``` ![工艺示意](figures/missing_process.png) 1. 有序列表一 (2) 括号序号 ① 圈号 第一条 申报书条款样式(仅 proposal 列表模式命中) [9] 这行在参考文献段之外,应按普通段落并合语义处理 [10] 与上一行并成同一段 ## 参考文献 [1] 10.1016/j.cemconres.2026.107891 [2] 低碳水泥进展. 张三, 水泥学报, 2026-05. DOI: 10.1016/j.jclepro.2026.135790. [W1] Nature 报道 www.nature.com/articles/x123 碳化养护 [W2] 纯文本条目,无链接可挂 """ def _write_sections(d: Path) -> Path: sec = d / "sections" sec.mkdir() (sec / "00_main.md").write_text(_FIXTURE, encoding="utf-8") return sec def _extract_xml(docx_path: Path) -> str: """document.xml + footer*.xml 拼成一份对账文本(部件名做分隔头)。""" parts: list[str] = [] with zipfile.ZipFile(docx_path) as z: names = [n for n in sorted(z.namelist()) if n == "word/document.xml" or (n.startswith("word/footer") and n.endswith(".xml"))] for n in names: parts.append(f"===== {n} =====") parts.append(z.read(n).decode("utf-8")) return "\n".join(parts) def _check_golden(test: unittest.TestCase, name: str, actual: str) -> None: golden_path = GOLDEN_DIR / f"{name}.xml" if _REGEN: golden_path.parent.mkdir(parents=True, exist_ok=True) golden_path.write_text(actual, encoding="utf-8", newline="\n") return if not golden_path.is_file(): test.fail(f"golden 缺失:{golden_path}(首次生成:ZCBOT_REGEN_GOLDENS=1 重跑)") expected = golden_path.read_text(encoding="utf-8") if actual != expected: diff = "\n".join(difflib.unified_diff( expected.splitlines(), actual.splitlines(), fromfile=f"golden/{name}", tofile="actual", lineterm="", n=2, )) test.fail( f"{name} 渲染输出与 golden 不一致(前 4000 字符 diff):\n{diff[:4000]}\n" f"—— 若为有意的样式/语义变更,ZCBOT_REGEN_GOLDENS=1 重生成并在 commit 里说明;" f"重构类改动必须零 diff。" ) class RenderingGoldenTests(unittest.TestCase): def _render_all(self, d: Path) -> dict[str, Path]: sec = _write_sections(d) outs = { "brief_color": d / "brief_color.docx", "brief_bw": d / "brief_bw.docx", "paper_zh": d / "paper_zh.docx", "proposal": d / "proposal.docx", "report_toc": d / "report_toc.docx", } docx_brief.render_sections(sec, outs["brief_color"], color=True) docx_brief.render_sections(sec, outs["brief_bw"], color=False) docx_manuscript.render_sections("paper", sec, outs["paper_zh"], lang="zh") docx_manuscript.render_sections("proposal", sec, outs["proposal"], fund_type="key_rd") docx_manuscript.render_sections("report", sec, outs["report_toc"], toc=True) return outs def test_all_profiles_match_golden(self): with tempfile.TemporaryDirectory() as td: outs = self._render_all(Path(td)) for name, path in outs.items(): with self.subTest(profile=name): _check_golden(self, name, _extract_xml(path)) if __name__ == "__main__": unittest.main()