在 #50 布局 JSON Schema 之上实现渲染层适配器,把 CockpitLayout 编译为与前端框架无关的 RenderPlan: - 新增 core/cockpit/renderer.py:RenderPlan/WidgetRendererSpec/GridPlacement 渲染描述符,栅格整数坐标→CSS Grid 定位+百分比换算,widget type→前端组件映射(ProcessView/TrendChart/KpiCard/AlarmPanel/NlQuery)+ props 组装(四状态/趋势窗口/KPI标签兜底/告警订阅/NL占位),dark/light 两套主题 CSS 变量注入,组件数≥30 触发全局+逐组件性能策略(虚拟滚动/采样降频/WebWorker,PRD 5.5),render_layout 复用 #50 校验器防篡改。 - plan_to_dict/plan_to_json 序列化:切换行业模板=换 RenderPlan,前端组件注册表固定即可「切换模板零改码」。 - 28 个单元测试全通过 + verify_renderer.py sanity check 通过(氯化车间示例 + 树脂模板兼容 + 主题切换 + 序列化往返)。全 cockpit 模块 58 测试无回归。
152 lines
6.0 KiB
Python
152 lines
6.0 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""图表组件渲染引擎验证脚本(issue #51,PRD 5.5 渲染层适配器验收)。
|
||
|
||
验证四个能力点:
|
||
1. **PRD 5.5 原始示例**(氯化车间)经布局校验后可渲染为合法 ``RenderPlan``,
|
||
且每类 widget 映射到正确前端组件 + props;
|
||
2. **现有树脂模板兼容**:与 ``templates/resin/dashboard/cockpit.resin.yaml``
|
||
等价的布局可渲染,且两个 trend 组件 id 唯一(虚拟滚动 key);
|
||
3. **主题 token 注入**:dark / light 两套 CSS 变量按主题切换;
|
||
4. **渲染计划可序列化**:``plan_to_json`` 输出可被 ``json.loads`` 还原,
|
||
且字段结构对齐前端 ``<WidgetHost>`` 约定。
|
||
|
||
用法(在 core/cockpit 目录下):
|
||
python scripts/verify_renderer.py
|
||
退出码:0 = 全部通过;1 = 存在未达标项。
|
||
|
||
说明:本脚本不依赖 PyYAML(与 #50 verify_layout_schema.py 一致,避免引入
|
||
运行时依赖),使用等价 dict 复现树脂驾驶舱布局;若装了 PyYAML 可直接解析
|
||
原 YAML 文件复现(见文末注释)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
|
||
# 本脚本位于 core/cockpit/scripts/,需要把 core/ 加入 sys.path
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||
|
||
from cockpit import ( # noqa: E402
|
||
LAYOUT_SCHEMA_ID,
|
||
THEME_TOKENS,
|
||
WIDGET_COMPONENT,
|
||
plan_to_json,
|
||
render_layout,
|
||
validate_layout,
|
||
)
|
||
from cockpit.layout import load_layout # noqa: E402
|
||
|
||
|
||
def _ti_layout() -> dict:
|
||
"""PRD 5.5 氯化车间示例。"""
|
||
return {
|
||
"$schema": "iAOP-cockpit-layout-v1",
|
||
"title": "氯化车间驾驶舱",
|
||
"theme": "dark",
|
||
"widgets": [
|
||
{"type": "process_view", "src": "ti_four_state.svg", "x": 0, "y": 0, "w": 6, "h": 4},
|
||
{"type": "trend", "bind": "CLF-01.TEMP", "x": 6, "y": 0, "w": 6, "h": 2},
|
||
{"type": "kpi_card", "metric": "Ti_purity", "label": "Ti 纯度", "x": 6, "y": 2, "w": 3, "h": 2},
|
||
{"type": "alarm_panel", "x": 0, "y": 4, "w": 12, "h": 3},
|
||
{"type": "nl_query", "x": 9, "y": 2, "w": 3, "h": 2},
|
||
],
|
||
}
|
||
|
||
|
||
def _resin_like_layout() -> dict:
|
||
"""等价于 templates/resin/dashboard/cockpit.resin.yaml 的布局。"""
|
||
return {
|
||
"$schema": "iAOP-cockpit-layout-v1",
|
||
"title": "吸附树脂车间驾驶舱",
|
||
"theme": "dark",
|
||
"widgets": [
|
||
{"type": "process_view", "src": "resin_four_state.svg", "x": 0, "y": 0, "w": 12, "h": 4},
|
||
{"type": "trend", "bind": "R-801.TEMP", "x": 0, "y": 4, "w": 6, "h": 2},
|
||
{"type": "trend", "bind": "R-801.AGIT", "x": 6, "y": 4, "w": 6, "h": 2},
|
||
{"type": "kpi_card", "metric": "resin_exchange_capacity", "label": "交换容量",
|
||
"x": 0, "y": 6, "w": 3, "h": 2},
|
||
{"type": "alarm_panel", "x": 0, "y": 8, "w": 9, "h": 3},
|
||
{"type": "nl_query", "x": 9, "y": 8, "w": 3, "h": 3},
|
||
],
|
||
}
|
||
|
||
|
||
def _check_render(name: str, data: dict) -> bool:
|
||
"""校验 + 渲染一份布局,打印结果,返回是否通过。"""
|
||
res = validate_layout(data)
|
||
if not res.ok:
|
||
print(f"[FAIL] {name}: 布局校验未通过")
|
||
for e in res.errors:
|
||
print(f" - {e}")
|
||
return False
|
||
plan = render_layout(load_layout(data))
|
||
print(f"[PASS] {name}: 渲染 {plan.widget_count} 个组件,主题={plan.theme}")
|
||
for spec in plan.specs:
|
||
print(f" - {spec.id} → {spec.component} "
|
||
f"({spec.grid.width_pct}%, {spec.props})")
|
||
return True
|
||
|
||
|
||
def _check_themes() -> bool:
|
||
"""主题 token:dark / light 两套 CSS 变量都存在且不同。"""
|
||
ok = True
|
||
for theme in ("dark", "light"):
|
||
data = _ti_layout()
|
||
data["theme"] = theme
|
||
plan = render_layout(load_layout(data))
|
||
tokens = plan.theme_tokens
|
||
match = tokens == THEME_TOKENS[theme]
|
||
print(f"[{'PASS' if match else 'FAIL'}] 主题 token({theme}): {len(tokens)} 个 CSS 变量")
|
||
ok = ok and match
|
||
if THEME_TOKENS["dark"]["--cockpit-bg"] == THEME_TOKENS["light"]["--cockpit-bg"]:
|
||
print("[FAIL] dark/light 底色不应相同")
|
||
ok = False
|
||
else:
|
||
print("[PASS] dark/light 底色不同(确为两套配色)")
|
||
return ok
|
||
|
||
|
||
def _check_serialization() -> bool:
|
||
"""渲染计划可 JSON 序列化往返。"""
|
||
import json
|
||
plan = render_layout(load_layout(_resin_like_layout()))
|
||
text = plan_to_json(plan)
|
||
parsed = json.loads(text)
|
||
ok = parsed["title"] == "吸附树脂车间驾驶舱" and parsed["widgetCount"] == 6
|
||
# 字段结构对齐前端 <WidgetHost> 约定
|
||
fields_ok = all({"component", "grid", "props", "perf"} <= set(w) for w in parsed["widgets"])
|
||
print(f"[{'PASS' if ok and fields_ok else 'FAIL'}] 序列化往返: JSON 可解析、字段齐全")
|
||
return ok and fields_ok
|
||
|
||
|
||
def main() -> int:
|
||
print("=" * 70)
|
||
print("图表组件渲染引擎验证(issue #51 / PRD 5.5 渲染层适配器)")
|
||
print("=" * 70)
|
||
print(f"已注册前端组件映射: {WIDGET_COMPONENT}")
|
||
print(f"布局版本: {LAYOUT_SCHEMA_ID}")
|
||
print("-" * 70)
|
||
|
||
all_ok = True
|
||
all_ok &= _check_render("PRD 5.5 氯化车间示例", _ti_layout())
|
||
all_ok &= _check_render("树脂模板兼容", _resin_like_layout())
|
||
all_ok &= _check_themes()
|
||
all_ok &= _check_serialization()
|
||
|
||
print("-" * 70)
|
||
print(f"结果: {'全部通过 ✅' if all_ok else '存在未达标项 ❌'}")
|
||
return 0 if all_ok else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 附:若安装了 PyYAML,可直接解析树脂模板原文件复现渲染:
|
||
# import yaml
|
||
# with open("../../templates/resin/dashboard/cockpit.resin.yaml", encoding="utf-8") as f:
|
||
# data = yaml.safe_load(f)
|
||
# render_layout(load_layout(data))
|
||
# 字段语义与本脚本的 _resin_like_layout() 完全一致(iAOP-cockpit-layout-v1)。
|
||
# ---------------------------------------------------------------------------
|