294 lines
11 KiB
Python
294 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
||||
|
|
"""驾驶舱布局 JSON Schema 校验/解析测试(issue #50 / PRD 5.5)。
|
|||
|
|
|
|||
|
|
覆盖:
|
|||
|
|
1. PRD 5.5 示例布局(氯化车间)合法 → 校验通过、可解析为 ``CockpitLayout``;
|
|||
|
|
2. 现有树脂模板资产兼容(``iAOP-cockpit-layout-v1``);
|
|||
|
|
3. 各类非法情况:错误的 $schema / theme / type、缺失必填、坐标越界、
|
|||
|
|
空组件清单、栅格列数非法、特有字段缺失;
|
|||
|
|
4. 性能提示:组件数 ≥ 30 触发 ``perf_hint``;
|
|||
|
|
5. ``load_layout`` 校验失败抛 ``LayoutValidationError`` 并携带全部错误。
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import copy
|
|||
|
|
import unittest
|
|||
|
|
|
|||
|
|
from cockpit import ( # type: ignore[import-not-found]
|
|||
|
|
LAYOUT_SCHEMA_ID,
|
|||
|
|
LAYOUT_SCHEMA_VERSION,
|
|||
|
|
PERF_WIDGET_THRESHOLD,
|
|||
|
|
VALID_THEMES,
|
|||
|
|
VALID_WIDGET_TYPES,
|
|||
|
|
CockpitLayout,
|
|||
|
|
LayoutValidationError,
|
|||
|
|
Widget,
|
|||
|
|
load_layout,
|
|||
|
|
validate_layout,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _valid_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 的结构(已引用 v1 schema)。"""
|
|||
|
|
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,
|
|||
|
|
"description": "四状态工艺流程"},
|
|||
|
|
{"type": "trend", "bind": "R-801.TEMP", "x": 0, "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 TestSchemaConstants(unittest.TestCase):
|
|||
|
|
"""版本标识与合法性集合。"""
|
|||
|
|
|
|||
|
|
def test_schema_id_matches_template_reference(self):
|
|||
|
|
# templates/resin/dashboard/cockpit.resin.yaml 引用的 $schema 必须与此处一致
|
|||
|
|
self.assertEqual(LAYOUT_SCHEMA_ID, "iAOP-cockpit-layout-v1")
|
|||
|
|
self.assertEqual(LAYOUT_SCHEMA_VERSION, 1)
|
|||
|
|
|
|||
|
|
def test_valid_themes(self):
|
|||
|
|
self.assertEqual(set(VALID_THEMES), {"dark", "light"})
|
|||
|
|
|
|||
|
|
def test_valid_widget_types_cover_prd_capabilities(self):
|
|||
|
|
# PRD 5.5 能力:四状态流程视图 / 实时趋势 / KPI卡片 / 告警面板 / NL查询入口
|
|||
|
|
for required in ("process_view", "trend", "kpi_card", "alarm_panel", "nl_query"):
|
|||
|
|
self.assertIn(required, VALID_WIDGET_TYPES)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestValidateValidLayouts(unittest.TestCase):
|
|||
|
|
"""合法布局应通过校验。"""
|
|||
|
|
|
|||
|
|
def test_prd_example_is_valid(self):
|
|||
|
|
res = validate_layout(_valid_layout())
|
|||
|
|
self.assertTrue(res.ok, msg=f"expected ok, errors={res.errors}")
|
|||
|
|
self.assertEqual(res.widget_count, 5)
|
|||
|
|
self.assertIsNone(res.perf_hint)
|
|||
|
|
|
|||
|
|
def test_resin_like_layout_is_valid(self):
|
|||
|
|
# 现有树脂模板资产兼容(向后兼容关键)
|
|||
|
|
res = validate_layout(_resin_like_layout())
|
|||
|
|
self.assertTrue(res.ok, msg=f"errors={res.errors}")
|
|||
|
|
|
|||
|
|
def test_light_theme_valid(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["theme"] = "light"
|
|||
|
|
self.assertTrue(validate_layout(data).ok)
|
|||
|
|
|
|||
|
|
def test_custom_grid_columns_valid(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["grid"] = {"columns": 24}
|
|||
|
|
self.assertTrue(validate_layout(data).ok)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestValidateInvalidLayouts(unittest.TestCase):
|
|||
|
|
"""各类非法布局应在 errors 中给出对应原因。"""
|
|||
|
|
|
|||
|
|
def test_root_not_dict(self):
|
|||
|
|
res = validate_layout(["not", "a", "dict"])
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
|
|||
|
|
def test_wrong_schema(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["$schema"] = "iAOP-cockpit-layout-v0"
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("$schema" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_missing_schema(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
del data["$schema"]
|
|||
|
|
self.assertFalse(validate_layout(data).ok)
|
|||
|
|
|
|||
|
|
def test_invalid_theme(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["theme"] = "neon"
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("theme" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_empty_title(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["title"] = " "
|
|||
|
|
self.assertFalse(validate_layout(data).ok)
|
|||
|
|
|
|||
|
|
def test_empty_widgets(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["widgets"] = []
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("widgets" in e and "空" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_unknown_widget_type(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["widgets"][0]["type"] = "magic_chart"
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("type" in e and "非法" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_missing_required_widget_field_process_view(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
del data["widgets"][0]["src"] # process_view 要求 src
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("src" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_missing_required_widget_field_trend_bind(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
del data["widgets"][1]["bind"] # trend 要求 bind
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("bind" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_missing_required_widget_field_kpi_metric(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
del data["widgets"][2]["metric"] # kpi_card 要求 metric
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("metric" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_negative_x(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["widgets"][0]["x"] = -1
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("x" in e and "≥ 0" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_zero_w(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["widgets"][0]["w"] = 0
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("w" in e and "> 0" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_bool_not_accepted_as_int(self):
|
|||
|
|
# bool 是 int 子类,栅格坐标不应接受 True/False
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["widgets"][0]["x"] = True
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("x" in e and "bool" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_grid_columns_non_positive(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["grid"] = {"columns": 0}
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("columns" in e and "> 0" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_overflow_grid_warning(self):
|
|||
|
|
# x+w 超过栅格列数 → 记一条提示(用于配置台纠正),仍可在 errors 中体现
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["widgets"][0]["x"] = 10
|
|||
|
|
data["widgets"][0]["w"] = 6 # 10+6=16 > 12
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertTrue(any("超过栅格列数" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
def test_widget_not_dict(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["widgets"][0] = "broken"
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertFalse(res.ok)
|
|||
|
|
self.assertTrue(any("对象" in e for e in res.errors))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestPerfHint(unittest.TestCase):
|
|||
|
|
"""组件数 ≥ 30 时应给出性能提示(PRD 5.5 复杂仪表盘性能)。"""
|
|||
|
|
|
|||
|
|
def test_perf_hint_when_many_widgets(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["widgets"] = [
|
|||
|
|
{"type": "kpi_card", "metric": f"m{i}", "x": 0, "y": 0, "w": 1, "h": 1}
|
|||
|
|
for i in range(PERF_WIDGET_THRESHOLD)
|
|||
|
|
]
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
# 全部 kpi_card 合法 → ok=True,但有性能提示
|
|||
|
|
self.assertEqual(res.widget_count, PERF_WIDGET_THRESHOLD)
|
|||
|
|
self.assertIsNotNone(res.perf_hint)
|
|||
|
|
self.assertIn("虚拟滚动", res.perf_hint or "")
|
|||
|
|
|
|||
|
|
def test_no_perf_hint_below_threshold(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
res = validate_layout(data)
|
|||
|
|
self.assertIsNone(res.perf_hint)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLoadLayout(unittest.TestCase):
|
|||
|
|
"""load_layout 解析合法资产为 CockpitLayout,非法时抛异常。"""
|
|||
|
|
|
|||
|
|
def test_load_valid_returns_model(self):
|
|||
|
|
layout = load_layout(_valid_layout())
|
|||
|
|
self.assertIsInstance(layout, CockpitLayout)
|
|||
|
|
self.assertEqual(layout.title, "氯化车间驾驶舱")
|
|||
|
|
self.assertEqual(layout.theme, "dark")
|
|||
|
|
self.assertEqual(layout.schema, LAYOUT_SCHEMA_ID)
|
|||
|
|
self.assertEqual(len(layout.widgets), 5)
|
|||
|
|
# 栅格默认 12 列
|
|||
|
|
self.assertEqual(layout.grid.columns, 12)
|
|||
|
|
# 组件字段映射
|
|||
|
|
pv = layout.widgets[0]
|
|||
|
|
self.assertIsInstance(pv, Widget)
|
|||
|
|
self.assertEqual(pv.type, "process_view")
|
|||
|
|
self.assertEqual(pv.src, "ti_four_state.svg")
|
|||
|
|
kpi = layout.widgets[2]
|
|||
|
|
self.assertEqual(kpi.metric, "Ti_purity")
|
|||
|
|
self.assertEqual(kpi.label, "Ti 纯度")
|
|||
|
|
|
|||
|
|
def test_load_invalid_raises_with_errors(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
data["theme"] = "neon"
|
|||
|
|
del data["widgets"][1]["bind"]
|
|||
|
|
with self.assertRaises(LayoutValidationError) as cm:
|
|||
|
|
load_layout(data)
|
|||
|
|
# 异常应携带全部错误(不止一条)
|
|||
|
|
self.assertGreaterEqual(len(cm.exception.errors), 2)
|
|||
|
|
|
|||
|
|
def test_roundtrip_to_dict(self):
|
|||
|
|
# 解析后再序列化,结构应可再次通过校验(round-trip 稳定)
|
|||
|
|
layout = load_layout(_valid_layout())
|
|||
|
|
again = validate_layout(layout.to_dict())
|
|||
|
|
self.assertTrue(again.ok, msg=f"roundtrip errors={again.errors}")
|
|||
|
|
|
|||
|
|
def test_roundtrip_preserves_resin_layout(self):
|
|||
|
|
layout = load_layout(_resin_like_layout())
|
|||
|
|
again = validate_layout(layout.to_dict())
|
|||
|
|
self.assertTrue(again.ok, msg=f"errors={again.errors}")
|
|||
|
|
self.assertEqual(layout.title, "吸附树脂车间驾驶舱")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestDeepCopySafety(unittest.TestCase):
|
|||
|
|
"""校验不应污染入参;调用方可继续使用原 dict。"""
|
|||
|
|
|
|||
|
|
def test_validate_does_not_mutate_input(self):
|
|||
|
|
data = _valid_layout()
|
|||
|
|
snapshot = copy.deepcopy(data)
|
|||
|
|
validate_layout(data)
|
|||
|
|
self.assertEqual(data, snapshot)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
unittest.main()
|