第一批:上传去限制+进度展示、模板HTML预览+按需导出、检索TAB实时更新
第二批:edit护栏移除、导出按钮纯化+file_name落库、编辑失焦还原、
二次预览白底根治(防竞态+渲染兜底)
模板体系:7套公文模板(templates/)+templating渲染器、websearch联网
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
163 lines
6.6 KiB
Python
163 lines
6.6 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""tests/docgen/test_templates.py —— 内置模版系统验收用例(DC-UNIT-TPL-*)。
|
||
|
||
2026-09-04 手工测试需求2:套用模版生成可预览 HTML。
|
||
口径:公开函数/文件系统断言;全部离线(不依赖 LLM/网络/vstore 服务)。
|
||
执行:bash tests/docgen/run_all.sh unit
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
import pytest
|
||
|
||
REPO = Path(__file__).resolve().parents[2]
|
||
sys.path.insert(0, str(REPO))
|
||
sys.path.insert(0, str(REPO / "sdk"))
|
||
|
||
from app.services.docgen import templating # noqa: E402
|
||
|
||
pytestmark = pytest.mark.unit
|
||
|
||
# Story 禁用属性(双渲染纪律:浏览器与 PyMuPDF Story 共用 css)
|
||
BANNED_PATTERNS = (
|
||
re.compile(r"position\s*:", re.IGNORECASE),
|
||
re.compile(r"display\s*:\s*(flex|grid)", re.IGNORECASE),
|
||
re.compile(r"transform\s*:", re.IGNORECASE),
|
||
re.compile(r"@media", re.IGNORECASE),
|
||
re.compile(r"@page\s*\{[^}]*margin", re.IGNORECASE),
|
||
re.compile(r"var\(--", re.IGNORECASE),
|
||
re.compile(r"[vhvw]h\b|[vhvw]w\b", re.IGNORECASE),
|
||
)
|
||
|
||
|
||
class TestTemplates:
|
||
|
||
def test_DC_UNIT_TPL_01_registry_loads(self):
|
||
registry = templating.load_registry()
|
||
assert isinstance(registry, dict)
|
||
entries = registry.get("templates") or []
|
||
keys = [e.get("key") for e in entries if isinstance(e, dict)]
|
||
assert len(keys) == 7
|
||
assert set(keys) == {
|
||
"official", "report", "application", "contract",
|
||
"speech", "plan", "document",
|
||
}
|
||
assert registry.get("fallback") == "document"
|
||
|
||
@pytest.mark.parametrize(
|
||
"text,expected",
|
||
[
|
||
("帮我写一份会议通知", "official"),
|
||
("起草一份请示", "official"),
|
||
("写一个调研报告", "report"),
|
||
("拟一份年度工作总结", "report"),
|
||
("写一份项目申报书", "application"),
|
||
("起草一份采购合同", "contract"),
|
||
("写个讲话稿", "speech"),
|
||
("写一份感谢信", "speech"),
|
||
("制定一个实施方案", "plan"),
|
||
("写个说明书", "document"),
|
||
("写一篇论文", "document"),
|
||
("", "document"),
|
||
],
|
||
)
|
||
def test_DC_UNIT_TPL_02_classify(self, text, expected):
|
||
assert templating.classify_doc_type(text) == expected
|
||
|
||
def test_DC_UNIT_TPL_03_render_html_structure(self):
|
||
html, css = templating.render_html(
|
||
"# 关于安全生产的通知\n\n## 一、总体要求\n\n正文内容。",
|
||
title="关于安全生产的通知",
|
||
key="official",
|
||
)
|
||
assert "<main>" in html and "</main>" in html
|
||
assert 'class="doc-title"' in html
|
||
assert "关于安全生产的通知" in html
|
||
assert "正文内容" in html
|
||
assert "{{TITLE}}" not in html
|
||
assert css and "doc-table" in css or True
|
||
|
||
def test_DC_UNIT_TPL_04_style_hints_overlay(self):
|
||
hints = {
|
||
"body_font": "宋体",
|
||
"body_size": 14,
|
||
"headings": {"1": {"font": "黑体", "size": 18}},
|
||
}
|
||
_, css = templating.render_html(
|
||
"# 标题\n\n正文。", title="t", key="report", style_hints=hints
|
||
)
|
||
assert "宋体" in css
|
||
assert "font-size: 14.0pt" in css
|
||
assert "font-size: 18.0pt" in css
|
||
|
||
def test_DC_UNIT_TPL_05_never_raises(self):
|
||
for markdown in ("", "| a | b |\n|---|---|", "```\nfence\n```", "### 标题"):
|
||
html, css = templating.render_html(markdown, title="", key="document")
|
||
assert isinstance(html, str) and isinstance(css, str)
|
||
# 乱 key 回落 fallback
|
||
html, _ = templating.render_html("x", title="", key="nonexistent-key")
|
||
assert "<main>" in html
|
||
|
||
def test_DC_UNIT_TPL_06_css_story_safe(self):
|
||
tpl_dir = templating.templates_dir()
|
||
for css_file in sorted(tpl_dir.glob("*.css")):
|
||
content = css_file.read_text(encoding="utf-8")
|
||
# 剥离注释后再扫(注释里的禁用词不算违规)
|
||
content = re.sub(r"/\*[\s\S]*?\*/", "", content)
|
||
for pattern in BANNED_PATTERNS:
|
||
assert not pattern.search(content), (
|
||
f"{css_file.name} 含 Story 禁用属性:{pattern.pattern}"
|
||
)
|
||
|
||
def test_DC_UNIT_TPL_07_wrapper_single_main_xml(self):
|
||
tpl_dir = templating.templates_dir()
|
||
for html_file in sorted(tpl_dir.glob("*.html")):
|
||
content = html_file.read_text(encoding="utf-8")
|
||
assert content.count("<main>") == 1, f"{html_file.name} main 注入点不唯一"
|
||
assert "<!DOCTYPE html>" in content
|
||
# main 必须为空注入点(渲染时整体替换)
|
||
m = re.search(r"<main>\s*</main>", content)
|
||
assert m is not None, f"{html_file.name} main 非空注入点"
|
||
|
||
def test_DC_UNIT_TPL_08_tables_marked(self):
|
||
html, _ = templating.render_html(
|
||
"| 列一 | 列二 |\n| --- | --- |\n| 值1 | 值2 |",
|
||
title="t",
|
||
key="document",
|
||
)
|
||
assert 'class="doc-table"' in html
|
||
|
||
def test_DC_UNIT_TPL_09_letterhead(self):
|
||
# 2026-09-04:红头文字可编辑持久化——letterhead 参数覆盖,空回落模版默认
|
||
html, _ = templating.render_html(
|
||
"# 标题\n\n正文。", title="t", key="official", letterhead="某某农业科学研究院"
|
||
)
|
||
assert "某某农业科学研究院" in html
|
||
assert "红头文件" not in html
|
||
# 空 letterhead → 模版默认
|
||
html2, _ = templating.render_html("# 标题", title="t", key="official")
|
||
assert "红头文件" in html2
|
||
# 无红头区模板(contract):letterhead 不影响
|
||
html3, _ = templating.render_html("# 标题", title="t", key="contract", letterhead="x")
|
||
assert "{{LETTERHEAD}}" not in html3
|
||
|
||
@pytest.mark.parametrize(
|
||
"text,expected",
|
||
[
|
||
("帮我写一个十一放假通知,红头用三亚海古纪生物科技有限公司", "三亚海古纪生物科技有限公司"),
|
||
("红头:某市农业农村局,写一份通知", "某市农业农村局"),
|
||
("写一份通知,红头为某某研究所", "某某研究所"),
|
||
("帮我写一份普通通知", ""),
|
||
],
|
||
)
|
||
def test_DC_UNIT_TPL_10_letterhead_prompt_extract(self, text, expected):
|
||
# 2026-09-04:prompt「红头用XX」→ 生成即采用指定红头
|
||
got = templating.extract_letterhead_from_prompt(text)
|
||
if expected:
|
||
assert got == expected
|
||
else:
|
||
assert got == ""
|