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