318 lines
13 KiB
Python
318 lines
13 KiB
Python
# -*- 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()
|