Files
iAOP/core/cockpit/tests/test_layout.py
T
bot_dev1 ace09a6d16 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 与本实现一致,树脂模板资产校验通过。
2026-08-05 00:29:42 +08:00

294 lines
11 KiB
Python
Raw 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 -*-
"""驾驶舱布局 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()