diff --git a/core/cockpit/__init__.py b/core/cockpit/__init__.py new file mode 100644 index 0000000..a835179 --- /dev/null +++ b/core/cockpit/__init__.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +"""配置化驾驶舱内核模块(iAOP-Core / cockpit)—— 对齐 PRD 5.5「⑤ 配置化驾驶舱」。 + +对外暴露: + - ``LAYOUT_SCHEMA_VERSION`` / ``LAYOUT_SCHEMA_ID``:布局 JSON Schema 标识。 + - ``CockpitLayout`` / ``Widget``:布局与组件的内存模型(dataclass)。 + - ``validate_layout``:对一份布局资产做结构 + 语义校验,返回校验结果。 + - ``load_layout``:从已解析的 dict 构造 ``CockpitLayout``(校验失败抛 ``LayoutValidationError``)。 + +设计目标(PRD 5.5 验收口径): + 切换行业模板后,驾驶舱按布局配置自动重排,**无需改前端代码**。 +本模块只负责"布局资产的定义 + 校验 + 解析",不依赖任何前端框架; +渲染层(Vue3 / 配置台)只需消费 ``CockpitLayout`` 的内存模型。 +""" +from __future__ import annotations + +from .layout import ( # noqa: F401 + DEFAULT_GRID_COLUMNS, + LAYOUT_SCHEMA_ID, + LAYOUT_SCHEMA_VERSION, + PERF_WIDGET_THRESHOLD, + VALID_THEMES, + VALID_WIDGET_TYPES, + CockpitLayout, + Grid, + LayoutValidationError, + LayoutValidationResult, + Widget, + load_layout, + validate_layout, +) + +__all__ = [ + "LAYOUT_SCHEMA_ID", + "LAYOUT_SCHEMA_VERSION", + "VALID_THEMES", + "VALID_WIDGET_TYPES", + "DEFAULT_GRID_COLUMNS", + "PERF_WIDGET_THRESHOLD", + "CockpitLayout", + "Grid", + "Widget", + "LayoutValidationError", + "LayoutValidationResult", + "load_layout", + "validate_layout", +] diff --git a/core/cockpit/layout.py b/core/cockpit/layout.py new file mode 100644 index 0000000..d4d1603 --- /dev/null +++ b/core/cockpit/layout.py @@ -0,0 +1,378 @@ +# -*- coding: utf-8 -*- +"""驾驶舱布局 JSON Schema 定义 + 校验器 + 解析器(issue #50 / PRD 5.5)。 + +本文件是 ``$schema: iAOP-cockpit-layout-v1`` 的权威实现:定义布局资产的 +字段规范、合法性集合,以及一份与现有行业模板(如 +``templates/resin/dashboard/cockpit.resin.yaml``)完全兼容的校验/解析管线。 + +布局资产结构(PRD 5.5 示例):: + + { + "$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", "x":6,"y":2,"w":3,"h":2}, + {"type": "alarm_panel", "x":0,"y":4,"w":12,"h":3} + ] + } + +字段规范(PRD 5.5「布局 JSON Schema 定义」+「配置点」): + $schema string 必填 布局版本标识,固定 ``iAOP-cockpit-layout-v1``。 + title string 必填 驾驶舱标题(行业模板级,如"氯化车间驾驶舱")。 + theme enum 必填 主题:``dark`` / ``light``。 + widgets list 必填 组件清单,至少 1 个;每个 widget 见下表。 + grid object 选填 栅格基线(默认 12 列);见 ``Grid``。 + +widget 字段: + type enum 必填 组件类型(见 ``VALID_WIDGET_TYPES``)。 + x, y int 必填 栅格左上角坐标(≥0)。 + w, h int 必填 宽/高(>0)。 + description string 选填 组件说明(实施/行业工程师可读)。 + src string 条件必填 ``process_view`` 必填,流程图 SVG 资源名。 + bind string 条件必填 ``trend`` 必填,绑定的点位/指标 ID(如 CLF-01.TEMP)。 + metric string 条件必填 ``kpi_card`` 必填,指标键(如 Ti_purity)。 + label string 选填 ``kpi_card`` 展示标签。 + +复杂仪表盘性能(PRD 5.5)由渲染层负责:组件 ≥ 30 或数据点 ≥ 500 时启用 +虚拟滚动 + 采样降频 + WebWorker,保证首屏 ≤ 2s。本 schema 不强制该阈值, +仅在 ``LayoutValidationResult`` 中提示组件数,便于上层决策。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# 布局版本标识(被 templates/resin/dashboard/cockpit.resin.yaml 的 $schema 引用) +# --------------------------------------------------------------------------- +LAYOUT_SCHEMA_ID: str = "iAOP-cockpit-layout-v1" +LAYOUT_SCHEMA_VERSION: int = 1 + +# --------------------------------------------------------------------------- +# 合法性集合 +# --------------------------------------------------------------------------- +# 主题(PRD 5.5「配置点:主题」) +VALID_THEMES: Tuple[str, ...] = ("dark", "light") + +# 组件类型(PRD 5.5「能力:四状态工艺流程视图、实时趋势、KPI卡片、告警面板、NL查询入口」) +VALID_WIDGET_TYPES: Tuple[str, ...] = ( + "process_view", # 四状态工艺流程视图 + "trend", # 实时趋势 + "kpi_card", # KPI 卡片 + "alarm_panel", # 告警面板 + "nl_query", # 自然语言查询入口 +) + +# 默认栅格基线(PRD 5.5 示例使用 12 列;resin 模板亦按 12 列布局) +DEFAULT_GRID_COLUMNS: int = 12 + +# 性能提示阈值(PRD 5.5「复杂仪表盘性能」) +PERF_WIDGET_THRESHOLD: int = 30 + +# 各组件类型必填的特有字段(type → 字段名) +_WIDGET_REQUIRED_FIELDS: Dict[str, Tuple[str, ...]] = { + "process_view": ("src",), + "trend": ("bind",), + "kpi_card": ("metric",), + "alarm_panel": (), + "nl_query": (), +} + + +# --------------------------------------------------------------------------- +# 异常 / 结果 +# --------------------------------------------------------------------------- +class LayoutValidationError(ValueError): + """布局资产校验失败。``load_layout`` 在校验不通过时抛出。 + + ``errors`` 收集全部字段级错误,便于配置台「错误列表(行号+原因)」展示。 + """ + + def __init__(self, errors: List[str]): + super().__init__("; ".join(errors) if errors else "layout validation failed") + self.errors: List[str] = list(errors) + + +@dataclass +class LayoutValidationResult: + """``validate_layout`` 的返回值,区分「是否合法」与「全部错误清单」。""" + + ok: bool + errors: List[str] = field(default_factory=list) + widget_count: int = 0 + perf_hint: Optional[str] = None + + +# --------------------------------------------------------------------------- +# 内存模型(dataclass) +# --------------------------------------------------------------------------- +@dataclass +class Widget: + """单个驾驶舱组件的内存模型。""" + + type: str + x: int + y: int + w: int + h: int + description: Optional[str] = None + # 以下为按 type 选填/必填的特有字段,统一存放,解析时已校验存在性 + src: Optional[str] = None + bind: Optional[str] = None + metric: Optional[str] = None + label: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """序列化回布局资产 dict(仅保留有值/必填字段,便于发布)。""" + d: Dict[str, Any] = { + "type": self.type, + "x": self.x, + "y": self.y, + "w": self.w, + "h": self.h, + } + if self.description is not None: + d["description"] = self.description + if self.src is not None: + d["src"] = self.src + if self.bind is not None: + d["bind"] = self.bind + if self.metric is not None: + d["metric"] = self.metric + if self.label is not None: + d["label"] = self.label + return d + + +@dataclass +class Grid: + """栅格基线(默认 12 列,可由行业模板覆盖)。""" + + columns: int = DEFAULT_GRID_COLUMNS + + +@dataclass +class CockpitLayout: + """一份完整驾驶舱布局的内存模型。 + + 渲染层(Vue3 / 配置台)仅消费本对象:切换行业模板 = 加载另一份 + ``CockpitLayout``,**前端代码零改动**即满足 PRD 5.5 验收口径。 + """ + + title: str + theme: str + widgets: List[Widget] + grid: Grid = field(default_factory=Grid) + schema: str = LAYOUT_SCHEMA_ID + + def to_dict(self) -> Dict[str, Any]: + """序列化为可发布的布局资产 dict(结构对齐 PRD 5.5 示例)。""" + return { + "$schema": self.schema, + "title": self.title, + "theme": self.theme, + "grid": {"columns": self.grid.columns}, + "widgets": [w.to_dict() for w in self.widgets], + } + + +# --------------------------------------------------------------------------- +# 校验器 +# --------------------------------------------------------------------------- +def _require_type(value: Any, name: str, expected: type, errors: List[str], ctx: str) -> None: + """检查 ``value`` 是 ``expected`` 类型,失败则追加一条错误。""" + # bool 是 int 的子类,栅格坐标不应接受 bool;此处显式排除 + if expected is int and isinstance(value, bool): + errors.append(f"{ctx}: {name} 必须是整数,实际为 bool") + return + if not isinstance(value, expected): + errors.append(f"{ctx}: {name} 必须是 {expected.__name__},实际为 {type(value).__name__}") + + +def _validate_widget(widget: Any, index: int, grid_columns: int, errors: List[str]) -> None: + """校验单个 widget dict,错误追加到 ``errors``。""" + ctx = f"widgets[{index}]" + if not isinstance(widget, dict): + errors.append(f"{ctx}: 组件必须是对象(dict)") + return + + # type + wtype = widget.get("type") + if not isinstance(wtype, str) or not wtype: + errors.append(f"{ctx}: type 缺失或非字符串") + wtype = "" + elif wtype not in VALID_WIDGET_TYPES: + errors.append( + f"{ctx}: type '{wtype}' 非法,合法值 {list(VALID_WIDGET_TYPES)}" + ) + + # 栅格坐标 x/y/w/h(必填整数) + for key in ("x", "y", "w", "h"): + if key not in widget: + errors.append(f"{ctx}: 缺少必填字段 {key}") + else: + _require_type(widget[key], key, int, errors, ctx) + val = widget[key] + if isinstance(val, int) and not isinstance(val, bool): + if key in ("x", "y") and val < 0: + errors.append(f"{ctx}: {key} 必须 ≥ 0,实际 {val}") + if key in ("w", "h") and val <= 0: + errors.append(f"{ctx}: {key} 必须 > 0,实际 {val}") + # 越界:x + w 不应超过栅格列数(提示级,不阻断合法性,但记一条 warning 风格错误便于配置台纠正) + try: + x = widget["x"] + w = widget["w"] + if ( + isinstance(x, int) + and isinstance(w, int) + and not isinstance(x, bool) + and not isinstance(w, bool) + and x + w > grid_columns + ): + errors.append( + f"{ctx}: x+w={x + w} 超过栅格列数 {grid_columns},布局会被压缩" + ) + except (KeyError, TypeError): + pass + + # type 特有必填字段 + if wtype in _WIDGET_REQUIRED_FIELDS: + for fname in _WIDGET_REQUIRED_FIELDS[wtype]: + val = widget.get(fname) + if not isinstance(val, str) or not val: + errors.append(f"{ctx}: type='{wtype}' 要求字段 {fname} 非空字符串") + + # kpi_card 可选 label(若有则必须字符串) + label = widget.get("label") + if label is not None and not isinstance(label, str): + errors.append(f"{ctx}: label 必须是字符串") + + +def validate_layout(data: Any) -> LayoutValidationResult: + """对一份布局资产(已解析的 dict)做完整校验。 + + 返回 ``LayoutValidationResult``:``ok`` 表示是否通过,``errors`` 收集全部 + 字段级错误,``widget_count`` / ``perf_hint`` 供上层做性能决策。 + """ + errors: List[str] = [] + + if not isinstance(data, dict): + return LayoutValidationResult(ok=False, errors=["布局根必须是对象(dict)"]) + + # $schema + schema = data.get("$schema") + if schema is None: + errors.append("缺少 $schema 字段") + elif schema != LAYOUT_SCHEMA_ID: + errors.append( + f"$schema='{schema}' 不被支持,当前版本 '{LAYOUT_SCHEMA_ID}'" + ) + + # title + title = data.get("title") + if not isinstance(title, str) or not title.strip(): + errors.append("title 缺失或为空字符串") + + # theme + theme = data.get("theme") + if theme is None: + errors.append("缺少 theme 字段") + elif theme not in VALID_THEMES: + errors.append(f"theme='{theme}' 非法,合法值 {list(VALID_THEMES)}") + + # grid(可选) + grid_columns = DEFAULT_GRID_COLUMNS + grid = data.get("grid") + if grid is not None: + if not isinstance(grid, dict): + errors.append("grid 必须是对象(dict)") + else: + cols = grid.get("columns") + if cols is None: + errors.append("grid.columns 缺失") + else: + _require_type(cols, "columns", int, errors, "grid") + if isinstance(cols, int) and not isinstance(cols, bool) and cols <= 0: + errors.append(f"grid.columns 必须 > 0,实际 {cols}") + grid_columns = cols + elif isinstance(cols, int) and not isinstance(cols, bool): + grid_columns = cols + + # widgets + widgets = data.get("widgets") + if widgets is None: + errors.append("缺少 widgets 字段") + widgets = [] + if not isinstance(widgets, list): + errors.append("widgets 必须是数组(list)") + widgets = [] + elif len(widgets) == 0: + errors.append("widgets 不能为空(至少 1 个组件)") + + for idx, w in enumerate(widgets): + _validate_widget(w, idx, grid_columns, errors) + + widget_count = len(widgets) if isinstance(widgets, list) else 0 + perf_hint = None + if widget_count >= PERF_WIDGET_THRESHOLD: + perf_hint = ( + f"组件数 {widget_count} ≥ {PERF_WIDGET_THRESHOLD},渲染层应启用" + "虚拟滚动 + 采样降频 + WebWorker(PRD 5.5)" + ) + + return LayoutValidationResult( + ok=len(errors) == 0, + errors=errors, + widget_count=widget_count, + perf_hint=perf_hint, + ) + + +# --------------------------------------------------------------------------- +# 解析器 +# --------------------------------------------------------------------------- +def _coerce_int(v: Any) -> int: + """已校验为 int(非 bool)后取值。""" + return v # type: ignore[return-value] + + +def load_layout(data: Dict[str, Any]) -> CockpitLayout: + """从已解析的 dict 构造 ``CockpitLayout``;校验失败抛 ``LayoutValidationError``。 + + 调用前通常先用 ``validate_layout`` 判断,但本方法内部仍会再校验一次, + 确保构造出的内存模型恒为合法资产。 + """ + result = validate_layout(data) + if not result.ok: + raise LayoutValidationError(result.errors) + + grid_raw = data.get("grid") or {} + grid = Grid(columns=grid_raw.get("columns", DEFAULT_GRID_COLUMNS)) + + widgets: List[Widget] = [] + for w in data["widgets"]: + widgets.append( + Widget( + type=w["type"], + x=_coerce_int(w["x"]), + y=_coerce_int(w["y"]), + w=_coerce_int(w["w"]), + h=_coerce_int(w["h"]), + description=w.get("description"), + src=w.get("src"), + bind=w.get("bind"), + metric=w.get("metric"), + label=w.get("label"), + ) + ) + + return CockpitLayout( + title=data["title"], + theme=data["theme"], + widgets=widgets, + grid=grid, + schema=data.get("$schema", LAYOUT_SCHEMA_ID), + ) diff --git a/core/cockpit/scripts/verify_layout_schema.py b/core/cockpit/scripts/verify_layout_schema.py new file mode 100644 index 0000000..1923947 --- /dev/null +++ b/core/cockpit/scripts/verify_layout_schema.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""驾驶舱布局 JSON Schema 验证脚本(issue #50,PRD 5.5 验收口径)。 + +验证三个能力点: +1. **PRD 5.5 原始示例**(氯化车间)通过 ``iAOP-cockpit-layout-v1`` 校验; +2. **现有树脂模板资产兼容**:``templates/resin/dashboard/cockpit.resin.yaml`` + 所描述的布局(等价 dict)通过同一套校验,证明"切换模板零改码"已具备基础; +3. **负例**:一份故意破坏的布局被正确拒绝(证明阈值判断有效)。 + +用法(在 core/cockpit 目录下): + python scripts/verify_layout_schema.py +退出码:0 = 全部通过;1 = 存在未达标项。 + +说明:树脂驾驶舱资产本身是 YAML;本脚本不依赖 PyYAML(避免引入运行时依赖), +而是用与该 YAML 文件内容等价的 dict 进行校验——schema 字段语义完全一致, +若安装了 PyYAML,可直接解析原文件复现(见文末注释)。 +""" +from __future__ import annotations + +import os +import sys + +# 本脚本位于 core/cockpit/scripts/,需要把 core/ 加入 sys.path, +# 才能 `from cockpit import ...`(cockpit 包位于 core/cockpit) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from cockpit import LAYOUT_SCHEMA_ID, validate_layout # noqa: E402 + + +def _check(name: str, data: dict, expect_ok: bool) -> bool: + res = validate_layout(data) + status = "PASS" if res.ok == expect_ok else "FAIL" + print(f"[{status}] {name}: ok={res.ok} widgets={res.widget_count}") + if res.ok != expect_ok: + for e in res.errors: + print(f" - {e}") + return False + if res.ok and res.perf_hint: + print(f" perf_hint: {res.perf_hint}") + return True + + +def main() -> int: + all_ok = True + + # 1) PRD 5.5 原始示例(氯化车间) + prd_example = { + "$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", "x": 6, "y": 2, "w": 3, "h": 2}, + {"type": "alarm_panel", "x": 0, "y": 4, "w": 12, "h": 3}, + ], + } + all_ok &= _check("PRD 5.5 示例(氯化车间)", prd_example, expect_ok=True) + + # 2) 现有树脂模板资产兼容(与 templates/resin/dashboard/cockpit.resin.yaml 等价) + resin_layout = { + "$schema": LAYOUT_SCHEMA_ID, + "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, + "description": "反应釜温度实时趋势"}, + {"type": "trend", "bind": "R-801.AGIT", "x": 6, "y": 4, "w": 6, "h": 2, + "description": "搅拌转速实时趋势"}, + {"type": "kpi_card", "metric": "resin_exchange_capacity", "label": "交换容量", + "x": 0, "y": 6, "w": 3, "h": 2, "description": "当批平均交换容量(mmol/g)"}, + {"type": "kpi_card", "metric": "resin_crosslink_degree", "label": "交联度", + "x": 3, "y": 6, "w": 3, "h": 2, "description": "当批平均交联度(%)"}, + {"type": "kpi_card", "metric": "batch_yield", "label": "批产率", + "x": 6, "y": 6, "w": 3, "h": 2, "description": "当批产率(%)"}, + {"type": "kpi_card", "metric": "energy_per_ton", "label": "单吨能耗", + "x": 9, "y": 6, "w": 3, "h": 2, "description": "单吨树脂综合能耗(kWh/t)"}, + {"type": "alarm_panel", "x": 0, "y": 8, "w": 9, "h": 3, + "description": "告警面板(温度/交联度/质量预测异常)"}, + {"type": "nl_query", "x": 9, "y": 8, "w": 3, "h": 3, + "description": "自然语言查询入口(配方/质量/能耗问答)"}, + ], + } + all_ok &= _check("树脂模板资产(向后兼容)", resin_layout, expect_ok=True) + + # 3) 负例:kpi_card 缺 metric,应被拒绝 + broken = { + "$schema": LAYOUT_SCHEMA_ID, + "title": "坏布局", + "theme": "dark", + "widgets": [ + {"type": "kpi_card", "x": 0, "y": 0, "w": 3, "h": 2}, # 缺 metric + ], + } + all_ok &= _check("负例(kpi_card 缺 metric)应被拒绝", broken, expect_ok=False) + + print() + print("全部通过 ✅" if all_ok else "存在未达标项 ❌") + return 0 if all_ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/core/cockpit/tests/__init__.py b/core/cockpit/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/cockpit/tests/test_layout.py b/core/cockpit/tests/test_layout.py new file mode 100644 index 0000000..b4ea83f --- /dev/null +++ b/core/cockpit/tests/test_layout.py @@ -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()