# -*- coding: utf-8 -*- """海绵钛驾驶舱布局校验器测试(Issue #55)。 覆盖: 1. 真实 cockpit.ti.yaml + point_dict.default.csv 全部通过(端到端); 2. widget 类型合法集合(非法类型 → ERROR); 3. 12 列网格校验(越界/负坐标/非正 w/h); 4. bind point_id 在点位字典内(漂移 → ERROR); 5. process_view 四状态覆盖(缺 stage id / order 非单调 / 缺必需状态); 6. $schema 头校验; 7. YAML 解析(flow map / 嵌套); 8. 点位字典 CSV 加载(缺表头/空文件); 9. 空布局 / 边界。 """ import os import unittest import _bootstrap # noqa: F401 (sys.path 挂载) from layout_validator import ( GRID_COLUMNS, LayoutError, LayoutValidator, REQUIRED_STAGES, Severity, load_point_ids, ) HERE = os.path.dirname(os.path.abspath(__file__)) DASHBOARD_DIR = os.path.dirname(HERE) LAYOUT_YAML = os.path.join(DASHBOARD_DIR, "cockpit.ti.yaml") POINT_DICT_CSV = os.path.join( DASHBOARD_DIR, os.pardir, "point-dict", "point_dict.default.csv") def _write_layout(tmp_path: str, content: str) -> str: """把布局内容写到临时文件,返回路径。""" path = os.path.join(tmp_path, "cockpit.test.yaml") with open(path, "w", encoding="utf-8") as fh: fh.write(content) return path def _write_point_dict(tmp_path: str, ids: list) -> str: """写一个最小点位字典 CSV(仅 point_id 列)。""" path = os.path.join(tmp_path, "points.csv") with open(path, "w", encoding="utf-8") as fh: fh.write("device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\n") for i, pid in enumerate(ids): fh.write(f"D{i},{pid},n,u,float,1000,true,n,simulator\n") return path # 最小合法布局模板(便于构造各类变形) _VALID_LAYOUT = """\ $schema: iAOP-cockpit-layout-v1 title: 测试驾驶舱 theme: dark widgets: - type: process_view src: ti_four_state.svg x: 0 y: 0 w: 12 h: 4 description: 四状态工艺流程 stages: - id: chlorination name: 氯化 order: 1 - id: purification name: 精制 order: 2 - id: reduction name: 还原 order: 3 - id: distillation name: 蒸馏 order: 4 - type: trend bind: CLF-01.TEMP x: 0 y: 4 w: 6 h: 2 description: 氯化炉温度 """ class TestEndToEndRealAssets(unittest.TestCase): """真实 cockpit.ti.yaml + point_dict.default.csv 端到端校验。""" def test_real_layout_passes(self): report = LayoutValidator(LAYOUT_YAML, POINT_DICT_CSV).validate() if not report.passed: for issue in report.errors: print("ERROR:", issue.widget_id, issue.field, issue.reason) self.assertTrue(report.passed, "真实布局应通过全部校验") self.assertGreater(report.widget_count, 0) def test_real_layout_has_process_view_with_four_stages(self): report = LayoutValidator(LAYOUT_YAML, POINT_DICT_CSV).validate() # 无 stages 相关 ERROR stage_errors = [i for i in report.errors if i.field == "stages"] self.assertEqual(stage_errors, []) class TestWidgetType(unittest.TestCase): """widget 类型合法性。""" def test_invalid_widget_type_error(self): import tempfile with tempfile.TemporaryDirectory() as td: layout = _VALID_LAYOUT.replace("type: trend", "type: radar_chart") path = _write_layout(td, layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() errors = [i for i in report.errors if i.field == "type"] self.assertEqual(len(errors), 1) self.assertIn("非法 widget 类型", errors[0].reason) class TestGridBounds(unittest.TestCase): """12 列网格校验。""" def test_x_plus_w_exceeds_columns(self): import tempfile with tempfile.TemporaryDirectory() as td: # trend x=10 w=6 → 16 > 12 layout = _VALID_LAYOUT.replace( " bind: CLF-01.TEMP\n x: 0\n y: 4\n w: 6\n h: 2", " bind: CLF-01.TEMP\n x: 10\n y: 4\n w: 6\n h: 2") path = _write_layout(td, layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() grid_errors = [i for i in report.errors if i.field == "grid" and "越出" in i.reason] self.assertEqual(len(grid_errors), 1) def test_negative_x_rejected(self): import tempfile with tempfile.TemporaryDirectory() as td: layout = _VALID_LAYOUT.replace(" x: 0\n y: 4\n w: 6\n h: 2", " x: -1\n y: 4\n w: 6\n h: 2") path = _write_layout(td, layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() neg = [i for i in report.errors if "坐标不能为负" in i.reason] self.assertEqual(len(neg), 1) def test_zero_width_rejected(self): import tempfile with tempfile.TemporaryDirectory() as td: layout = _VALID_LAYOUT.replace(" x: 0\n y: 4\n w: 6\n h: 2", " x: 0\n y: 4\n w: 0\n h: 2") path = _write_layout(td, layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() wh = [i for i in report.errors if "w/h 必须为正整数" in i.reason] self.assertEqual(len(wh), 1) class TestBindPointId(unittest.TestCase): """bind point_id 在点位字典内。""" def test_bind_not_in_dict_error(self): import tempfile with tempfile.TemporaryDirectory() as td: path = _write_layout(td, _VALID_LAYOUT) # 点位字典不含 CLF-01.TEMP csv_path = _write_point_dict(td, ["OTHER-01.X"]) report = LayoutValidator(path, csv_path).validate() bind_err = [i for i in report.errors if i.field == "bind"] self.assertEqual(len(bind_err), 1) self.assertIn("不在点位字典内", bind_err[0].reason) def test_bind_in_dict_passes(self): import tempfile with tempfile.TemporaryDirectory() as td: path = _write_layout(td, _VALID_LAYOUT) csv_path = _write_point_dict(td, ["CLF-01.TEMP"]) report = LayoutValidator(path, csv_path).validate() bind_err = [i for i in report.errors if i.field == "bind"] self.assertEqual(bind_err, []) class TestProcessViewStages(unittest.TestCase): """process_view 四状态覆盖。""" def test_missing_process_view_error(self): import tempfile with tempfile.TemporaryDirectory() as td: # 删除 process_view 块(保留 trend) layout = """\ $schema: iAOP-cockpit-layout-v1 title: t widgets: - type: trend bind: CLF-01.TEMP x: 0 y: 0 w: 6 h: 2 """ path = _write_layout(td, layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() pv_err = [i for i in report.errors if i.field == "process_view"] self.assertEqual(len(pv_err), 1) def test_missing_required_stage_error(self): import tempfile with tempfile.TemporaryDirectory() as td: # 删除 distillation stage layout = _VALID_LAYOUT.replace( " - id: distillation\n name: 蒸馏\n order: 4\n", "") path = _write_layout(td, layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() missing = [i for i in report.errors if "未覆盖必需四状态" in i.reason] self.assertEqual(len(missing), 1) self.assertIn("distillation", missing[0].reason) def test_non_monotonic_order_error(self): import tempfile with tempfile.TemporaryDirectory() as td: # 把 reduction order 改为 5(> distillation 的 4)→ 非单调 layout = _VALID_LAYOUT.replace(" - id: reduction\n name: 还原\n order: 3", " - id: reduction\n name: 还原\n order: 5") path = _write_layout(td, layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() order_err = [i for i in report.errors if "order 非单调递增" in i.reason] self.assertEqual(len(order_err), 1) def test_duplicate_stage_id_warn(self): import tempfile with tempfile.TemporaryDirectory() as td: # 重复 chlorination(覆盖必需状态校验仍过,但 WARN 重复) layout = _VALID_LAYOUT + """\ """ # 构造一个有重复 stage 的 process_view(替换 stages 块) dup_layout = _VALID_LAYOUT.replace( " - id: distillation\n name: 蒸馏\n order: 4", " - id: distillation\n name: 蒸馏\n order: 4\n" " - id: chlorination\n name: 氯化2\n order: 5") path = _write_layout(td, dup_layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() dup = [i for i in report.issues if "stage id 重复" in i.reason] self.assertEqual(len(dup), 1) class TestSchemaHeader(unittest.TestCase): """$schema 头校验。""" def test_wrong_schema_error(self): import tempfile with tempfile.TemporaryDirectory() as td: layout = _VALID_LAYOUT.replace("iAOP-cockpit-layout-v1", "some-other-schema") path = _write_layout(td, layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() schema_err = [i for i in report.errors if i.field == "$schema"] self.assertEqual(len(schema_err), 1) class TestPointDictLoader(unittest.TestCase): """点位字典 CSV 加载。""" def test_load_point_ids(self): import tempfile with tempfile.TemporaryDirectory() as td: csv_path = _write_point_dict(td, ["A.X", "B.Y"]) ids = load_point_ids(csv_path) self.assertEqual(ids, ["A.X", "B.Y"]) def test_missing_csv_raises(self): with self.assertRaises(LayoutError): load_point_ids("/nonexistent/points.csv") def test_csv_missing_point_id_column_raises(self): import tempfile with tempfile.TemporaryDirectory() as td: path = os.path.join(td, "bad.csv") with open(path, "w", encoding="utf-8") as fh: fh.write("device_id,name\nD1,n\n") with self.assertRaises(LayoutError): load_point_ids(path) class TestReportExport(unittest.TestCase): """报告序列化 + 边界。""" def test_empty_layout_error(self): import tempfile with tempfile.TemporaryDirectory() as td: layout = """\ $schema: iAOP-cockpit-layout-v1 title: t widgets: [] """ path = _write_layout(td, layout) report = LayoutValidator(path, _write_point_dict(td, ["CLF-01.TEMP"])).validate() self.assertFalse(report.passed) def test_report_to_dict(self): report = LayoutValidator(LAYOUT_YAML, POINT_DICT_CSV).validate() d = report.to_dict() self.assertEqual(d["passed"], True) self.assertIn("widget_count", d) self.assertEqual(d["error_count"], 0) if __name__ == "__main__": unittest.main()