Files
iAOP/core/cockpit/tests/test_layout.py
T

294 lines
11 KiB
Python
Raw Normal View History

# -*- 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()