Merge pull request 'feat(#50): 驾驶舱布局 JSON Schema 定义(PRD 5.5 配置化驾驶舱)' (#105) from feature/issue-50 into main
This commit is contained in:
@@ -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",
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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())
|
||||||
@@ -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