"""结构化 HTML 模板(kind=structured,先拿 no.1 落地)的引擎/存储/路由测试。 覆盖: * seed/meta:结构化 no1 四件套正确入库,单文件模板(如 no2)不受影响; * 契约语义与单文件时代等价:``reference_data`` 读 data.json 与旧 ``const DATA=`` 行 解析结果一致,``validate_data`` / ``merge_with_reference`` 行为不变; * 组合:``read_template_text`` / ``fill_template`` 产出无残留标记的自包含文档, 数据注入可被页面 ``JSON.parse`` 还原,标题/副标题/更新时间生效; * data.json 持久化(管理页「编辑数据」):整体替换 + 契约校验(顶层键不可增删); * 骨架编辑标记校验、结构化重置; * 旧单文件存储副本的自动升级:未手改 → 升级;手改过 → 保留旧版; * 路由:content 的 doc/raw 双形态、data 的 GET/PUT 与权限。 """ from __future__ import annotations import json import re from types import SimpleNamespace from typing import Any import pytest from fastapi import HTTPException from test_api_read_cache import FakeRedis, _config from app.gateway import api_read_cache from app.gateway.routers import html_templates from deerflow.runtime.scheduler import template_fill, template_store # no1 是当前唯一的结构化模板。 TPL = "no1" LEGACY_FILE = template_fill._TEMPLATES_ROOT / TPL / "taiwan_littoral_ui_redwhite_v2.html" @pytest.fixture def tmp_store(monkeypatch, tmp_path): """隔离的运行时存储:全部用例不触碰真实 .deer-flow 目录。""" root = tmp_path / "html-templates" monkeypatch.setattr(template_store, "store_root", lambda: root) template_fill.invalidate_cache() template_store.ensure_seeded() yield root template_fill.invalidate_cache() @pytest.fixture def api_request(monkeypatch, tmp_store): """可直接调路由函数的最小 request(admin 身份,目录缓存走 FakeRedis)。""" redis = FakeRedis() monkeypatch.setattr("deerflow.persistence.engine.get_session_factory", lambda: None) monkeypatch.setattr(api_read_cache, "_api_read_cache_settings_cache", None) monkeypatch.setattr(api_read_cache, "_client", lambda *a, **k: (redis, "test:catalog")) request = SimpleNamespace( app=SimpleNamespace(state=SimpleNamespace(config=_config())), state=SimpleNamespace(user=SimpleNamespace(id="admin-1", system_role="admin")), ) async def current_user(req): return req.state.user monkeypatch.setattr(html_templates, "get_optional_user_from_request", current_user) return request def _page_data_element(html: str) -> dict: match = re.search( r'', html, re.DOTALL ) assert match is not None, "组合产物缺少 page-data JSON 元素" return json.loads(match.group(1)) # ── seed / meta ────────────────────────────────────────────────────────────── def test_structured_seed_and_single_untouched(tmp_store): meta = template_store.get_template_meta(TPL) assert meta is not None assert meta["kind"] == "structured" assert meta["convertible"] is True assert meta["manifest_var"] == "MANIFEST" assert sorted(p.name for p in (tmp_store / TPL).iterdir()) == [ "data.json", "fields.json", "manifest.json", "meta.json", "runtime.js", "template.html", ] # 单文件模板照旧(kind=single,正文 = 完整文档,无结构化部件)。 no2 = template_store.get_template_meta("no2") assert no2 is not None and no2["kind"] == "single" assert not (tmp_store / "no2" / "runtime.js").exists() def test_reference_data_matches_legacy_const_line(tmp_store): """data.json 的契约语义必须与单文件时代的 const DATA= 行完全一致。""" legacy = template_fill._extract_const_object(LEGACY_FILE.read_text(encoding="utf-8"), "DATA") ref = template_fill.reference_data(TPL) assert ref == legacy assert template_fill.validate_data(TPL, legacy) == [] assert set(template_fill.top_level_keys(TPL)) == set(legacy.keys()) # ── 组合 / 填充 ───────────────────────────────────────────────────────────── def test_read_template_text_composes_default_document(tmp_store): html = template_fill.read_template_text(TPL) for marker in template_fill.STRUCTURED_MARKERS: assert marker not in html assert 'id="page-data"' in html and 'id="page-manifest"' in html assert "const DATA=JSON.parse" in html # 页面内 JSON 编辑器随 runtime 内联。 assert 'id="editorOverlay"' in html and 'id="applyJson"' in html assert _page_data_element(html)["overview"] == template_fill.reference_data(TPL)["overview"] def test_fill_template_structured_injects_data_titles_and_date(tmp_store): ref = template_fill.reference_data(TPL) modified = json.loads(json.dumps(ref)) modified["overview"] = { "核心指标": [{"名称": "测试指标", "数值": 7, "单位": "个", "说明": "单元测试"}] } import datetime as dt html = template_fill.fill_template( TPL, modified, update_date=dt.date(2026, 11, 4), title="新标题", subtitle="新副标题", ) for marker in template_fill.STRUCTURED_MARKERS: assert marker not in html assert "
标记丢了
") # 保留标记的骨架编辑可以通过,且不影响 kind/convertible。 skeleton = template_store.read_template_html(TPL) edited = skeleton.replace("JSON 编辑器", "JSON 编辑器·编辑", 1) meta = template_store.update_template(TPL, html=edited) assert meta["kind"] == "structured" and meta["convertible"] is True assert template_fill.read_template_text(TPL).count("JSON 编辑器·编辑") == 1 def test_reset_structured_restores_all_parts(tmp_store): template_store.write_template_data( TPL, _with_first_metric_name("持久化指标") ) template_store.update_template(TPL, html=template_store.read_template_html(TPL) + "\n") template_store.reset_template(TPL) assert template_store.read_template_data(TPL)["overview"]["核心指标"][0]["名称"] != "持久化指标" assert "" not in template_store.read_template_html(TPL) assert template_fill.read_template_text(TPL) # 组合仍可用 def _with_first_metric_name(name: str) -> dict: ref = template_fill.reference_data(TPL) modified = json.loads(json.dumps(ref)) modified["overview"]["核心指标"][0]["名称"] = name return modified # ── 旧单文件存储副本的自动升级 ───────────────────────────────────────────── def _make_legacy_copy(store_root, *, modified: bool) -> None: import shutil tpl_dir = store_root / TPL if tpl_dir.exists(): shutil.rmtree(tpl_dir) tpl_dir.mkdir(parents=True) text = LEGACY_FILE.read_text(encoding="utf-8") if modified: text += "\n" (tpl_dir / "template.html").write_text(text, encoding="utf-8") (tpl_dir / "meta.json").write_text( json.dumps({"id": TPL, "name": "no.1", "builtin": True, "convertible": True}), encoding="utf-8", ) def test_auto_upgrade_untouched_legacy_copy(tmp_store): _make_legacy_copy(tmp_store, modified=False) assert template_store.ensure_seeded() is True meta = template_store.get_template_meta(TPL) assert meta["kind"] == "structured" assert (tmp_store / TPL / "runtime.js").is_file() # 升级后的模板可正常组合。 assert "__PAGE_" not in template_fill.read_template_text(TPL) def test_auto_upgrade_skips_hand_modified_copy(tmp_store): _make_legacy_copy(tmp_store, modified=True) template_store.ensure_seeded() meta = template_store.get_template_meta(TPL) assert meta["kind"] == "single" assert "" in (tmp_store / TPL / "template.html").read_text(encoding="utf-8") # 管理页「重置」可手动升级到结构化版。 template_store.reset_template(TPL) assert template_store.get_template_meta(TPL)["kind"] == "structured" assert (tmp_store / TPL / "runtime.js").is_file() # ── 路由 ──────────────────────────────────────────────────────────────────── @pytest.mark.asyncio async def test_router_content_doc_and_raw(tmp_store, api_request): doc = await html_templates.get_html_template_content(TPL, api_request) assert "__PAGE_DATA__" not in doc["html"] assert 'id="page-data"' in doc["html"] raw = await html_templates.get_html_template_content(TPL, api_request, raw=True) assert "__PAGE_DATA__" in raw["html"] and "__PAGE_RUNTIME__" in raw["html"] @pytest.mark.asyncio async def test_router_data_get_put_and_guards(tmp_store, api_request): got = await html_templates.get_html_template_data(TPL, api_request) assert "overview" in got["data"] # MANIFEST 随 GET 返回(管理页「系统设置」模块)。 assert got["manifest"]["系统"]["名称"] == "台湾滨海防卫知识数据展示系统" # 模块中文名(按 MANIFEST.页面 解析,管理页「编辑数据」的模块导航用)。 labels = {m["key"]: m["label"] for m in got["modules"]} assert labels["overview"] == "总览" assert labels["regions"] == "沿岸区域" assert labels["organization_charts"] == "组织与单位" assert labels["events"] == "演习与项目" assert set(labels) == set(got["data"].keys()) payload = got["data"] payload["overview"]["核心指标"][0]["名称"] = "路由持久化" manifest = json.loads(json.dumps(got["manifest"])) manifest["系统"]["名称"] = "路由改名系统" meta = await html_templates.update_html_template_data( TPL, html_templates.TemplateDataUpdateRequest(data=payload, manifest=manifest), api_request, ) assert meta["kind"] == "structured" assert template_store.read_template_data(TPL)["overview"]["核心指标"][0]["名称"] == "路由持久化" assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "路由改名系统" # 仅传 data(未动 MANIFEST)时不回写 manifest.json。 await html_templates.update_html_template_data( TPL, html_templates.TemplateDataUpdateRequest(data=payload), api_request ) assert template_store.read_template_manifest(TPL)["系统"]["名称"] == "路由改名系统" # 契约被破坏(删顶层键) → 400。 broken = {k: v for k, v in payload.items() if k != "regions"} with pytest.raises(HTTPException) as exc: await html_templates.update_html_template_data( TPL, html_templates.TemplateDataUpdateRequest(data=broken), api_request ) assert exc.value.status_code == 400 # 单文件模板 → 400。 with pytest.raises(HTTPException) as exc2: await html_templates.get_html_template_data("no2", api_request) assert exc2.value.status_code == 400 # 非管理员不可写。 api_request.state.user = SimpleNamespace(id="u2", system_role="user") with pytest.raises(HTTPException) as exc3: await html_templates.update_html_template_data( TPL, html_templates.TemplateDataUpdateRequest(data=payload), api_request ) assert exc3.value.status_code == 403 @pytest.mark.asyncio async def test_scheduler_fill_path_yields_selfcontained_html(tmp_store): """调度器模板填充路径的最终产物自包含:单文件、无标记、数据可解析。""" data_obj = template_fill.merge_with_reference(TPL, {"overview": _with_first_metric_name("调度指标")["overview"]}) html = template_fill.fill_template(TPL, data_obj, update_date=__import__("datetime").date(2026, 1, 1)) assert html.startswith("") and "__PAGE_" not in html assert "调度指标" in html assert _page_data_element(html)["overview"]["核心指标"][0]["名称"] == "调度指标" # ── 字段显示 / 自定义模块 ─────────────────────────────────────────────────── def test_field_catalog_and_visibility_roundtrip(tmp_store): catalog = template_store.read_template_fields(TPL) labels = {f["label"] for f in catalog} assert "节点类别" in labels and "来源" in labels assert all(f.get("description") for f in catalog) # 隐藏两个字段 → manifest 记 {label: false},其余全部显示。 template_store.write_template_field_visibility(TPL, ["节点类别", "来源"]) manifest = template_store.read_template_manifest(TPL) assert manifest["字段显示"] == {"节点类别": False, "来源": False} # 组合产物带隐藏配置,runtime 的 F() 据此不渲染。 composed = template_fill.read_template_text(TPL) assert '"字段显示"' in composed # 目录外字段拒绝。 with pytest.raises(template_store.TemplateStoreError, match="未知字段"): template_store.write_template_field_visibility(TPL, ["不存在的字段"]) # 置空 = 全部显示。 template_store.write_template_field_visibility(TPL, []) assert template_store.read_template_manifest(TPL)["字段显示"] == {} def test_custom_modules_write_and_sanitize(tmp_store): raw = ( '' ) template_store.write_template_custom_modules( TPL, [{"编号": "c1", "页面": "overview", "标题": "测试模块", "html": raw}] ) saved = template_store.read_template_manifest(TPL)["自定义模块"] assert len(saved) == 1 html = saved[0]["html"] assert "正常内容" in html assert "