feat(#50): 驾驶舱布局 JSON Schema 定义(PRD 5.5 配置化驾驶舱)
新增 core/cockpit 模块,定义 iAOP-cockpit-layout-v1 布局资产 schema 的 权威实现,对齐 PRD 5.5「⑤ 配置化驾驶舱」验收口径(切换模板零改码)。 交付内容: - core/cockpit/layout.py:布局字段规范、合法性集合(主题/组件类型)、 validate_layout 校验器(结构+语义)、load_layout 解析器、 CockpitLayout/Widget/Grid 内存模型(dataclass)、LayoutValidationError。 - core/cockpit/__init__.py:对外导出。 - core/cockpit/tests/test_layout.py:30 个单测(PRD 示例/树脂模板兼容/ 各类非法/性能提示/解析/round-trip/不变入参)。 - core/cockpit/scripts/verify_layout_schema.py:端到端验证脚本(exit=0)。 兼容性:现有 templates/resin/dashboard/cockpit.resin.yaml 引用的 $schema: iAOP-cockpit-layout-v1 与本实现一致,树脂模板资产校验通过。
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user