Files
bot_dev1 d23dfae776 feat(#51): 图表组件配置化渲染引擎(CockpitLayout→RenderPlan,PRD 5.5 配置化驾驶舱渲染层适配器)
在 #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 测试无回归。
2026-08-05 01:26:05 +08:00

318 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""图表组件配置化渲染引擎测试(issue #51 / PRD 5.5)。
覆盖:
1. ``render_layout`` 把合法布局编译为合法 ``RenderPlan``(PRD 5.5 氯化车间示例);
2. 树脂模板兼容(与 #50 ``templates/resin/dashboard/cockpit.resin.yaml`` 等价布局);
3. 栅格换算正确:CSS Grid 1-based 起点与 span、宽度百分比;
4. 各 widget type → 前端组件映射 + props 组装正确(含四状态/趋势窗口/KPI 标签兜底);
5. 主题 token 注入(dark / light 两套 CSS 变量);
6. 性能策略:组件数 ≥ 30 触发全局 ``perf_flags`` 与高频组件逐项 perf 标记;
7. 非法布局 / 非法主题 / 未知 type 被拒绝(``RenderError``);
8. 序列化往返:``plan_to_dict`` / ``plan_to_json`` 与渲染计划一致、JSON 可解析。
"""
from __future__ import annotations
import copy
import json
import unittest
from cockpit import ( # type: ignore[import-not-found]
LAYOUT_SCHEMA_ID,
PERF_WIDGET_THRESHOLD,
RenderError,
RenderPlan,
THEME_TOKENS,
WIDGET_COMPONENT,
WidgetRendererSpec,
compute_grid_placement,
load_layout,
plan_to_dict,
plan_to_json,
render_layout,
render_widget,
)
from cockpit.layout import Grid, Widget # type: ignore[import-not-found]
def _ti_layout() -> dict:
"""PRD 5.5 氯化车间示例(与 #50 test_layout 等价)。"""
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},
],
}
class TestRenderLayoutHappyPath(unittest.TestCase):
"""合法布局 → 合法 RenderPlan。"""
def test_ti_layout_renders(self) -> None:
layout = load_layout(_ti_layout())
plan = render_layout(layout)
self.assertIsInstance(plan, RenderPlan)
self.assertEqual(plan.schema, LAYOUT_SCHEMA_ID)
self.assertEqual(plan.title, "氯化车间驾驶舱")
self.assertEqual(plan.theme, "dark")
self.assertEqual(plan.widget_count, 5)
self.assertEqual(len(plan.specs), 5)
def test_resin_template_compatible(self) -> None:
"""切换到树脂模板同样可渲染(PRD 5.5「切换模板零改码」基础)。"""
plan = render_layout(load_layout(_resin_like_layout()))
self.assertEqual(plan.title, "吸附树脂车间驾驶舱")
self.assertEqual(plan.widget_count, 6)
# 两个 trend 组件共存,id 必须唯一(虚拟滚动 key)
ids = [s.id for s in plan.specs]
self.assertEqual(len(ids), len(set(ids)))
class TestGridPlacement(unittest.TestCase):
"""栅格整数坐标 → CSS Grid 定位 + 百分比换算。"""
def test_origin_top_left(self) -> None:
w = Widget(type="kpi_card", x=0, y=0, w=3, h=2, metric="m")
gp = compute_grid_placement(w, grid_columns=12)
self.assertEqual(gp.column_start, 1)
self.assertEqual(gp.row_start, 1)
self.assertEqual(gp.column_span, 3)
self.assertEqual(gp.row_span, 2)
self.assertEqual(gp.width_pct, 25.0)
self.assertIn("grid-column: 1 / span 3", gp.style)
self.assertIn("grid-row: 1 / span 2", gp.style)
def test_offset_position(self) -> None:
w = Widget(type="kpi_card", x=6, y=2, w=6, h=2, metric="m")
gp = compute_grid_placement(w, grid_columns=12)
self.assertEqual(gp.column_start, 7) # 0-based 6 → 1-based 7
self.assertEqual(gp.row_start, 3)
self.assertEqual(gp.width_pct, 50.0)
def test_full_width(self) -> None:
w = Widget(type="alarm_panel", x=0, y=0, w=12, h=3)
gp = compute_grid_placement(w, grid_columns=12)
self.assertEqual(gp.column_span, 12)
self.assertEqual(gp.width_pct, 100.0)
def test_non_default_grid_columns(self) -> None:
"""栅格基线非 12 时百分比按实际列数换算。"""
w = Widget(type="kpi_card", x=0, y=0, w=1, h=1, metric="m")
gp = compute_grid_placement(w, grid_columns=24)
self.assertAlmostEqual(gp.width_pct, 4.1667, places=3)
def test_invalid_columns_raises(self) -> None:
w = Widget(type="kpi_card", x=0, y=0, w=1, h=1, metric="m")
with self.assertRaises(RenderError):
compute_grid_placement(w, grid_columns=0)
class TestWidgetRendering(unittest.TestCase):
"""单 widget 渲染:组件映射 + props 组装。"""
def test_process_view_props(self) -> None:
w = Widget(type="process_view", x=0, y=0, w=12, h=4, src="ti_four_state.svg")
spec = render_widget(w, 0, 12)
self.assertEqual(spec.component, "ProcessView")
self.assertEqual(spec.props["src"], "ti_four_state.svg")
self.assertEqual(spec.props["states"],
["running", "warning", "alarm", "offline"])
self.assertEqual(spec.id, "process_view-0")
def test_trend_props(self) -> None:
w = Widget(type="trend", x=0, y=0, w=6, h=2, bind="CLF-01.TEMP")
spec = render_widget(w, 1, 12)
self.assertEqual(spec.component, "TrendChart")
self.assertEqual(spec.props["series"], "CLF-01.TEMP")
self.assertEqual(spec.props["window"], "PT30M")
def test_kpi_card_label_fallback(self) -> None:
"""kpi_card 缺 label 时回退到 metric(PRD 展示容错)。"""
w = Widget(type="kpi_card", x=0, y=0, w=3, h=2, metric="Ti_purity")
spec = render_widget(w, 2, 12)
self.assertEqual(spec.component, "KpiCard")
self.assertEqual(spec.props["metric"], "Ti_purity")
self.assertEqual(spec.props["label"], "Ti_purity") # 兜底
def test_kpi_card_label_present(self) -> None:
w = Widget(type="kpi_card", x=0, y=0, w=3, h=2, metric="Ti_purity", label="Ti 纯度")
spec = render_widget(w, 2, 12)
self.assertEqual(spec.props["label"], "Ti 纯度")
def test_alarm_panel_props(self) -> None:
w = Widget(type="alarm_panel", x=0, y=0, w=12, h=3)
spec = render_widget(w, 3, 12)
self.assertEqual(spec.component, "AlarmPanel")
self.assertEqual(spec.props["subscribe"], "alarm_stream")
def test_nl_query_props(self) -> None:
w = Widget(type="nl_query", x=0, y=0, w=3, h=2)
spec = render_widget(w, 4, 12)
self.assertEqual(spec.component, "NlQuery")
self.assertIn("placeholder", spec.props)
def test_all_widget_types_have_component(self) -> None:
"""PRD 5.5 全部 widget type 都有前端组件映射。"""
for wtype in ("process_view", "trend", "kpi_card", "alarm_panel", "nl_query"):
self.assertIn(wtype, WIDGET_COMPONENT)
def test_unknown_type_raises(self) -> None:
w = Widget(type="ghost", x=0, y=0, w=1, h=1)
with self.assertRaises(RenderError):
render_widget(w, 0, 12)
def test_description_propagated(self) -> None:
w = Widget(type="kpi_card", x=0, y=0, w=3, h=2, metric="m", description="当批产率")
spec = render_widget(w, 0, 12)
self.assertEqual(spec.description, "当批产率")
class TestThemeTokens(unittest.TestCase):
"""主题 → CSS 变量注入。"""
def test_dark_theme_tokens(self) -> None:
plan = render_layout(load_layout(_ti_layout()))
self.assertEqual(plan.theme_tokens, THEME_TOKENS["dark"])
self.assertIn("--cockpit-bg", plan.theme_tokens)
self.assertIn("--cockpit-accent", plan.theme_tokens)
def test_light_theme_tokens(self) -> None:
data = copy.deepcopy(_ti_layout())
data["theme"] = "light"
plan = render_layout(load_layout(data))
self.assertEqual(plan.theme, "light")
self.assertEqual(plan.theme_tokens, THEME_TOKENS["light"])
def test_theme_tokens_cover_both_palettes(self) -> None:
self.assertEqual(set(THEME_TOKENS.keys()), {"dark", "light"})
class TestPerformanceFlags(unittest.TestCase):
"""组件数 ≥ 阈值触发全局 + 逐组件性能策略。"""
def _many_widgets(self, n: int) -> dict:
widgets = [
{"type": "trend", "bind": f"P-{i}", "x": i % 12, "y": (i // 12) * 2, "w": 1, "h": 2}
for i in range(n)
]
return {"$schema": LAYOUT_SCHEMA_ID, "title": "大屏", "theme": "dark", "widgets": widgets}
def test_under_threshold_no_perf(self) -> None:
plan = render_layout(load_layout(self._many_widgets(5)))
self.assertFalse(all(plan.perf_flags.values()))
def test_over_threshold_enables_perf(self) -> None:
plan = render_layout(load_layout(self._many_widgets(PERF_WIDGET_THRESHOLD)))
self.assertTrue(plan.perf_flags["virtualScroll"])
self.assertTrue(plan.perf_flags["downsample"])
self.assertTrue(plan.perf_flags["worker"])
# 高频刷新组件(trend)逐项启用采样降频 + worker
trend_specs = [s for s in plan.specs if s.type == "trend"]
self.assertTrue(len(trend_specs) > 0)
for s in trend_specs:
self.assertTrue(s.perf["downsample"])
self.assertTrue(s.perf["worker"])
# perfHint 透传自 #50 校验结果
self.assertIsNotNone(plan.perf_hint)
class TestRenderErrorCases(unittest.TestCase):
"""非法输入被拒绝。"""
def test_invalid_theme_rejected_by_load_layout(self) -> None:
"""非法主题在布局层 #50 即被拦(render_layout 入口前)。"""
from cockpit.layout import LayoutValidationError
bad = copy.deepcopy(_ti_layout())
bad["theme"] = "purple" # 非法主题
with self.assertRaises(LayoutValidationError):
load_layout(bad)
def test_tampered_layout_re_validated(self) -> None:
"""构造合法布局后篡改内存模型,render_layout 内部复校验应拒绝。
直接改内存模型绕过 #50 构造期校验(模拟后续篡改),
``render_layout`` 复用 ``validate_layout`` 应抛 ``RenderError``。
"""
layout = load_layout(_ti_layout())
layout.theme = "purple" # 篡改为非法主题
with self.assertRaises(RenderError):
render_layout(layout)
class TestSerialization(unittest.TestCase):
"""渲染计划序列化往返。"""
def test_plan_to_dict_structure(self) -> None:
plan = render_layout(load_layout(_ti_layout()))
d = plan_to_dict(plan)
self.assertEqual(d["$schema"], LAYOUT_SCHEMA_ID)
self.assertEqual(d["title"], "氯化车间驾驶舱")
self.assertIn("themeTokens", d)
self.assertIn("widgets", d)
self.assertEqual(d["widgetCount"], 5)
self.assertIn("perfFlags", d)
# 每个 widget 都带渲染描述符字段
for w in d["widgets"]:
self.assertIn("component", w)
self.assertIn("grid", w)
self.assertIn("props", w)
self.assertIn("perf", w)
self.assertIn("style", w["grid"])
def test_plan_to_json_parseable(self) -> None:
plan = render_layout(load_layout(_resin_like_layout()))
text = plan_to_json(plan)
parsed = json.loads(text)
self.assertEqual(parsed["title"], "吸附树脂车间驾驶舱")
self.assertEqual(parsed["widgetCount"], 6)
# 中文不应被转义(ensure_ascii=False)
self.assertIn("吸附树脂车间驾驶舱", text)
def test_plan_to_json_indent(self) -> None:
plan = render_layout(load_layout(_ti_layout()))
text = plan_to_json(plan, indent=None)
self.assertNotIn("\n", text) # 紧凑模式无换行
def test_grid_placement_to_dict(self) -> None:
plan = render_layout(load_layout(_ti_layout()))
first = plan.specs[0]
gpd = first.grid.to_dict()
self.assertEqual(gpd["columnStart"], 1)
self.assertEqual(gpd["rowStart"], 1)
self.assertEqual(gpd["widthPct"], 50.0)
def test_spec_to_dict_roundtrip_fields(self) -> None:
plan = render_layout(load_layout(_ti_layout()))
for spec in plan.specs:
sd = spec.to_dict()
self.assertEqual(set(sd.keys()),
{"id", "component", "type", "grid", "description", "props", "perf"})
if __name__ == "__main__":
unittest.main()