From d23dfae7768fddfd78d6d1395cb3084924e25035 Mon Sep 17 00:00:00 2001 From: bot_dev1 Date: Wed, 5 Aug 2026 01:26:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(#51):=20=E5=9B=BE=E8=A1=A8=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E9=85=8D=E7=BD=AE=E5=8C=96=E6=B8=B2=E6=9F=93=E5=BC=95?= =?UTF-8?q?=E6=93=8E=EF=BC=88CockpitLayout=E2=86=92RenderPlan=EF=BC=8CPRD?= =?UTF-8?q?=205.5=20=E9=85=8D=E7=BD=AE=E5=8C=96=E9=A9=BE=E9=A9=B6=E8=88=B1?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=B1=82=E9=80=82=E9=85=8D=E5=99=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 #50 布局 JSON Schema 之上实现渲染层适配器,把 CockpitLayout 编译为与前端框架无关的 RenderPlan: - 新增 core/cockpit/renderer.py:RenderPlan/WidgetRendererSpec/GridPlacement 渲染描述符,栅格整数坐标→CSS Grid 定位+百分比换算,widget type→前端组件映射(ProcessView/TrendChart/KpiCard/AlarmPanel/NlQuery)+ props 组装(四状态/趋势窗口/KPI标签兜底/告警订阅/NL占位),dark/light 两套主题 CSS 变量注入,组件数≥30 触发全局+逐组件性能策略(虚拟滚动/采样降频/WebWorker,PRD 5.5),render_layout 复用 #50 校验器防篡改。 - plan_to_dict/plan_to_json 序列化:切换行业模板=换 RenderPlan,前端组件注册表固定即可「切换模板零改码」。 - 28 个单元测试全通过 + verify_renderer.py sanity check 通过(氯化车间示例 + 树脂模板兼容 + 主题切换 + 序列化往返)。全 cockpit 模块 58 测试无回归。 --- core/cockpit/__init__.py | 33 ++- core/cockpit/renderer.py | 373 ++++++++++++++++++++++++ core/cockpit/scripts/verify_renderer.py | 151 ++++++++++ core/cockpit/tests/test_renderer.py | 317 ++++++++++++++++++++ 4 files changed, 872 insertions(+), 2 deletions(-) create mode 100644 core/cockpit/renderer.py create mode 100644 core/cockpit/scripts/verify_renderer.py create mode 100644 core/cockpit/tests/test_renderer.py diff --git a/core/cockpit/__init__.py b/core/cockpit/__init__.py index a835179..adc64d7 100644 --- a/core/cockpit/__init__.py +++ b/core/cockpit/__init__.py @@ -6,11 +6,15 @@ - ``CockpitLayout`` / ``Widget``:布局与组件的内存模型(dataclass)。 - ``validate_layout``:对一份布局资产做结构 + 语义校验,返回校验结果。 - ``load_layout``:从已解析的 dict 构造 ``CockpitLayout``(校验失败抛 ``LayoutValidationError``)。 + - ``render_layout`` / ``render_widget``:把布局/组件编译为渲染计划(#51, + PRD 5.5 渲染层适配器),输出与前端框架无关的 ``RenderPlan``。 + - ``RenderPlan`` / ``WidgetRendererSpec`` / ``GridPlacement``:渲染描述符。 + - ``THEME_TOKENS`` / ``WIDGET_COMPONENT``:主题调色与组件映射。 设计目标(PRD 5.5 验收口径): 切换行业模板后,驾驶舱按布局配置自动重排,**无需改前端代码**。 -本模块只负责"布局资产的定义 + 校验 + 解析",不依赖任何前端框架; -渲染层(Vue3 / 配置台)只需消费 ``CockpitLayout`` 的内存模型。 +本模块负责"布局资产的定义 + 校验 + 解析 + 渲染计划编译",不依赖任何前端框架; +渲染层(Vue3 / 配置台)只需消费 ``CockpitLayout`` 与 ``RenderPlan`` 的内存模型。 """ from __future__ import annotations @@ -29,6 +33,19 @@ from .layout import ( # noqa: F401 load_layout, validate_layout, ) +from .renderer import ( # noqa: F401 + THEME_TOKENS, + WIDGET_COMPONENT, + GridPlacement, + RenderError, + RenderPlan, + WidgetRendererSpec, + compute_grid_placement, + plan_to_dict, + plan_to_json, + render_layout, + render_widget, +) __all__ = [ "LAYOUT_SCHEMA_ID", @@ -44,4 +61,16 @@ __all__ = [ "LayoutValidationResult", "load_layout", "validate_layout", + # 渲染引擎(#51 / PRD 5.5 渲染层适配器) + "THEME_TOKENS", + "WIDGET_COMPONENT", + "RenderError", + "RenderPlan", + "WidgetRendererSpec", + "GridPlacement", + "render_layout", + "render_widget", + "compute_grid_placement", + "plan_to_dict", + "plan_to_json", ] diff --git a/core/cockpit/renderer.py b/core/cockpit/renderer.py new file mode 100644 index 0000000..ee3c200 --- /dev/null +++ b/core/cockpit/renderer.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- +"""图表组件配置化渲染引擎(issue #51 / PRD 5.5「⑤ 配置化驾驶舱」)。 + +本模块在 ``layout.py``(#50 布局 JSON Schema)之上实现**渲染层适配器**: +把一份 ``CockpitLayout`` 内存模型,编译成一份与前端框架无关的**渲染计划 +(``RenderPlan``)**——每个 widget 被翻译成一个 ``WidgetRendererSpec``, +描述它该用哪个前端组件、摆在栅格的哪个位置、绑定什么数据、套用哪套主题 +token、是否启用性能优化策略。 + +设计目标(PRD 5.5 验收口径「切换模板零改码」): + 前端只需一个固定的「组件注册表」+ 一个通用 ````, + 按 ``RenderPlan`` 逐个挂载组件;切换行业模板 = 换一份 ``RenderPlan``, + **前端代码零改动**。 + +本模块不依赖任何第三方库(与 #50 一致,避免引入运行时依赖),只消费 +``cockpit.layout`` 的内存模型,输出可 ``json.dumps`` 的纯数据结构。 +""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + +from .layout import ( # 复用 #50 已定义的布局模型与常量 + DEFAULT_GRID_COLUMNS, + LAYOUT_SCHEMA_ID, + PERF_WIDGET_THRESHOLD, + VALID_THEMES, + VALID_WIDGET_TYPES, + CockpitLayout, + Grid, + Widget, + validate_layout, +) + +# --------------------------------------------------------------------------- +# 主题 token(PRD 5.5「配置点:主题」dark / light 两套调色) +# --------------------------------------------------------------------------- +# 工业驾驶舱约定配色:dark 偏深蓝底 + 青绿强调(车间大屏常用); +# light 偏白底 + 蓝色强调(白天值班室)。前端按这些 CSS 变量渲染, +# 切换主题 = 切一套变量,组件代码不动。 +THEME_TOKENS: Dict[str, Dict[str, str]] = { + "dark": { + "--cockpit-bg": "#0b1220", + "--cockpit-surface": "#13203a", + "--cockpit-fg": "#e6edf6", + "--cockpit-fg-muted": "#8aa0bd", + "--cockpit-accent": "#18d3c8", # 青绿(KPI / 趋势主线) + "--cockpit-warn": "#f5a623", # 告警强调 + "--cockpit-grid-line": "rgba(255,255,255,0.06)", + }, + "light": { + "--cockpit-bg": "#f5f7fb", + "--cockpit-surface": "#ffffff", + "--cockpit-fg": "#1f2d3d", + "--cockpit-fg-muted": "#6b7c93", + "--cockpit-accent": "#2f7af2", # 蓝 + "--cockpit-warn": "#e08600", + "--cockpit-grid-line": "rgba(0,0,0,0.06)", + }, +} + +# --------------------------------------------------------------------------- +# widget type → 前端组件名映射(PRD 5.5 能力清单) +# --------------------------------------------------------------------------- +# 前端「组件注册表」的键;```` 据此选择具体组件实现。 +# 命名遵循 Vue3 大驼峰组件约定,便于 ``app.component()`` 注册。 +WIDGET_COMPONENT: Dict[str, str] = { + "process_view": "ProcessView", # 四状态工艺流程视图(SVG 资源) + "trend": "TrendChart", # 实时趋势曲线 + "kpi_card": "KpiCard", # KPI 卡片 + "alarm_panel": "AlarmPanel", # 告警面板 + "nl_query": "NlQuery", # 自然语言查询入口 +} + +# 各组件类型向渲染描述里注入的「绑定字段」:来自布局 widget 的哪个属性。 +# 例如 trend 组件的 series 绑定 = widget.bind(点位/指标 ID)。 +_WIDGET_BINDING_SOURCE: Dict[str, Tuple[str, ...]] = { + "process_view": ("src",), # 流程图资源 + "trend": ("bind",), # 趋势点位/指标 + "kpi_card": ("metric", "label"), # 指标键 + 展示标签 + "alarm_panel": (), # 无特有绑定,订阅告警流即可 + "nl_query": (), # 无特有绑定,固定入口 +} + +# --------------------------------------------------------------------------- +# 异常 +# --------------------------------------------------------------------------- + + +class RenderError(ValueError): + """渲染计划生成失败。``render_layout`` 在布局非法或组件缺失时抛出。""" + + +# --------------------------------------------------------------------------- +# 渲染描述符(dataclass) +# --------------------------------------------------------------------------- + + +@dataclass +class GridPlacement: + """单个 widget 在 CSS Grid 中的定位(``grid-column/grid-row`` 语法)。 + + PRD 5.5 栅格基线默认 12 列;这里把布局资产里的整数坐标 (x,y,w,h) + 换算成 CSS Grid 的 ``column-start / span`` / ``row-start / span``, + 以及占比百分比(便于非 Grid 容器/截图场景使用)。 + + 约定(与 #50 schema 一致):x/y/w/h 为栅格单位,x≥0、y≥0、w>0、h>0。 + """ + + column_start: int # CSS Grid 列起点的 1-based 编号(= x + 1) + column_span: int # 跨列数(= w) + row_start: int # CSS Grid 行起点的 1-based 编号(= y + 1) + row_span: int # 跨行数(= h) + width_pct: float # 占栅格总宽的百分比(= w / columns * 100,保留 4 位) + style: str # 直接可用的 ``grid-column/grid-row`` CSS 文本 + + def to_dict(self) -> Dict[str, Any]: + return { + "columnStart": self.column_start, + "columnSpan": self.column_span, + "rowStart": self.row_start, + "rowSpan": self.row_span, + "widthPct": self.width_pct, + "style": self.style, + } + + +@dataclass +class WidgetRendererSpec: + """单个驾驶舱组件的渲染描述符(```` 的单一挂载项)。 + + 前端渲染约定:: + + + """ + + id: str # 渲染层唯一标识(type + 序号),便于 diff / 虚拟滚动 key + component: str # 前端组件名(见 ``WIDGET_COMPONENT``) + type: str # 原始 widget type(调试/审计用) + grid: GridPlacement + description: Optional[str] = None + # 前端组件 props(绑定字段 + 展示参数),按组件类型组装 + props: Dict[str, Any] = field(default_factory=dict) + # 性能策略:是否对该组件启用虚拟滚动/采样降频/WebWorker(仅 perf 触发时 True) + perf: Dict[str, bool] = field(default_factory=lambda: { + "virtualScroll": False, + "downsample": False, + "worker": False, + }) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "component": self.component, + "type": self.type, + "grid": self.grid.to_dict(), + "description": self.description, + "props": dict(self.props), + "perf": dict(self.perf), + } + + +@dataclass +class RenderPlan: + """整份驾驶舱布局的渲染计划(```` 的总输入)。 + + 前端拿到本对象即可完成整屏渲染:``themeTokens`` 注入到根容器 CSS 变量, + ``specs`` 逐项挂载组件,``perfFlags`` 控制全局性能策略开关。 + """ + + schema: str # 继承自布局资产的 $schema(审计/版本对齐) + title: str + theme: str + theme_tokens: Dict[str, str] # 该主题下的 CSS 变量(来自 ``THEME_TOKENS``) + grid_columns: int # 栅格基线(用于前端百分比校验) + specs: List[WidgetRendererSpec] + widget_count: int + # 全局性能标志:组件数 ≥ PERF_WIDGET_THRESHOLD 时为 True(PRD 5.5 复杂仪表盘性能) + perf_flags: Dict[str, bool] = field(default_factory=lambda: { + "virtualScroll": False, + "downsample": False, + "worker": False, + }) + perf_hint: Optional[str] = None # 来自 #50 布局校验的人类可读提示(透传) + + def to_dict(self) -> Dict[str, Any]: + return { + "$schema": self.schema, + "title": self.title, + "theme": self.theme, + "themeTokens": dict(self.theme_tokens), + "grid": {"columns": self.grid_columns}, + "widgets": [s.to_dict() for s in self.specs], + "widgetCount": self.widget_count, + "perfFlags": dict(self.perf_flags), + "perfHint": self.perf_hint, + } + + +# --------------------------------------------------------------------------- +# 栅格换算 +# --------------------------------------------------------------------------- + + +def compute_grid_placement(widget: Widget, grid_columns: int) -> GridPlacement: + """把布局资产里的栅格整数坐标换算成 CSS Grid 定位 + 占比百分比。 + + - CSS Grid 的列/行起点是 1-based,布局资产里的 x/y 是 0-based, + 因此 ``column_start = x + 1``、``row_start = y + 1``。 + - 跨度直接等于 w/h(栅格单位)。 + - 宽度百分比 = w / grid_columns * 100(便于非 Grid 容器降级)。 + """ + if grid_columns <= 0: + raise RenderError(f"栅格列数必须 > 0,实际 {grid_columns}") + column_start = widget.x + 1 + row_start = widget.y + 1 + width_pct = round(widget.w / grid_columns * 100, 4) + style = ( + f"grid-column: {column_start} / span {widget.w}; " + f"grid-row: {row_start} / span {widget.h};" + ) + return GridPlacement( + column_start=column_start, + column_span=widget.w, + row_start=row_start, + row_span=widget.h, + width_pct=width_pct, + style=style, + ) + + +# --------------------------------------------------------------------------- +# 组件 props 组装 +# --------------------------------------------------------------------------- + + +def _build_props(widget: Widget) -> Dict[str, Any]: + """按 widget type 组装前端组件 props(绑定字段 + 展示参数)。 + + 绑定字段来源见 ``_WIDGET_BINDING_SOURCE``;缺字段视为该组件不依赖它 + (布局层 #50 已对必填字段做过校验,这里做防御性容错)。 + """ + props: Dict[str, Any] = {} + + if widget.type == "process_view": + # 流程图视图:资源名 + 四状态语义(前端按 SVG 内联渲染 + 状态着色) + props["src"] = widget.src or "" + props["states"] = ["running", "warning", "alarm", "offline"] # PRD 四状态 + elif widget.type == "trend": + # 实时趋势:绑定点位/指标 ID,前端订阅时序总线 + props["series"] = widget.bind or "" + props["window"] = "PT30M" # 默认 30 分钟滚动窗口(可由模板覆盖) + elif widget.type == "kpi_card": + # KPI 卡片:指标键 + 标签 + props["metric"] = widget.metric or "" + props["label"] = widget.label or widget.metric or "" + elif widget.type == "alarm_panel": + # 告警面板:固定订阅告警流,无特有绑定 + props["subscribe"] = "alarm_stream" + elif widget.type == "nl_query": + # NL 查询入口:固定能力,无特有绑定 + props["placeholder"] = "输入自然语言查询(配方 / 质量 / 能耗)" + + return props + + +# --------------------------------------------------------------------------- +# 渲染入口 +# --------------------------------------------------------------------------- + + +def render_widget( + widget: Widget, + index: int, + grid_columns: int, + perf_enabled: bool = False, +) -> WidgetRendererSpec: + """渲染单个 widget 为 ``WidgetRendererSpec``。 + + - ``index`` 用于生成稳定 id(type + 序号),作为虚拟滚动 key。 + - ``perf_enabled`` 为 True(全局性能策略开启)时,trend/alarm_panel 这类 + 高频刷新组件启用采样降频 + WebWorker(PRD 5.5)。 + """ + if widget.type not in WIDGET_COMPONENT: + # 布局层 #50 已挡住非法 type,这里再防御一次,避免渲染出未注册组件 + raise RenderError( + f"widget[{index}] type='{widget.type}' 无对应前端组件," + f"已注册 {list(WIDGET_COMPONENT)}" + ) + + spec = WidgetRendererSpec( + id=f"{widget.type}-{index}", + component=WIDGET_COMPONENT[widget.type], + type=widget.type, + grid=compute_grid_placement(widget, grid_columns), + description=widget.description, + props=_build_props(widget), + ) + + if perf_enabled: + # 高频刷新组件才需要逐组件性能策略:趋势曲线 / 告警面板 + if widget.type in ("trend", "alarm_panel"): + spec.perf["downsample"] = True + spec.perf["worker"] = True + # 大流程图(process_view)启用虚拟滚动分块 + if widget.type == "process_view": + spec.perf["virtualScroll"] = True + + return spec + + +def render_layout(layout: CockpitLayout) -> RenderPlan: + """把一份合法 ``CockpitLayout`` 编译为 ``RenderPlan``。 + + 先复用 #50 的 ``validate_layout`` 再校验一次(防御构造后被篡改), + 再逐 widget 渲染,最后汇总主题 token 与全局性能标志。 + + 校验失败抛 ``RenderError``(包装 #50 的错误清单),便于配置台定位。 + """ + # 1) 复用 #50 校验器,确保布局恒为合法资产 + result = validate_layout(layout.to_dict()) + if not result.ok: + raise RenderError(result.errors) + + # 2) 主题 token + if layout.theme not in THEME_TOKENS: + raise RenderError( + f"theme='{layout.theme}' 无调色方案,已支持 {list(THEME_TOKENS)}" + ) + theme_tokens = dict(THEME_TOKENS[layout.theme]) + + # 3) 全局性能标志(PRD 5.5:组件数 ≥ 阈值 → 虚拟滚动 + 采样降频 + WebWorker) + perf_on = result.widget_count >= PERF_WIDGET_THRESHOLD + perf_flags = { + "virtualScroll": perf_on, + "downsample": perf_on, + "worker": perf_on, + } + + # 4) 逐 widget 渲染 + specs: List[WidgetRendererSpec] = [ + render_widget(w, idx, layout.grid.columns, perf_enabled=perf_on) + for idx, w in enumerate(layout.widgets) + ] + + return RenderPlan( + schema=layout.schema, + title=layout.title, + theme=layout.theme, + theme_tokens=theme_tokens, + grid_columns=layout.grid.columns, + specs=specs, + widget_count=len(specs), + perf_flags=perf_flags, + perf_hint=result.perf_hint, + ) + + +# --------------------------------------------------------------------------- +# 序列化 +# --------------------------------------------------------------------------- + + +def plan_to_dict(plan: RenderPlan) -> Dict[str, Any]: + """渲染计划 → 可发布的 dict(前端直接消费 / 落盘缓存)。""" + return plan.to_dict() + + +def plan_to_json(plan: RenderPlan, indent: Optional[int] = 2) -> str: + """渲染计划 → JSON 字符串(确保 ASCII 安全,中文转义不影响前端解析)。""" + return json.dumps(plan.to_dict(), ensure_ascii=False, indent=indent) diff --git a/core/cockpit/scripts/verify_renderer.py b/core/cockpit/scripts/verify_renderer.py new file mode 100644 index 0000000..460e7d9 --- /dev/null +++ b/core/cockpit/scripts/verify_renderer.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +"""图表组件渲染引擎验证脚本(issue #51,PRD 5.5 渲染层适配器验收)。 + +验证四个能力点: +1. **PRD 5.5 原始示例**(氯化车间)经布局校验后可渲染为合法 ``RenderPlan``, + 且每类 widget 映射到正确前端组件 + props; +2. **现有树脂模板兼容**:与 ``templates/resin/dashboard/cockpit.resin.yaml`` + 等价的布局可渲染,且两个 trend 组件 id 唯一(虚拟滚动 key); +3. **主题 token 注入**:dark / light 两套 CSS 变量按主题切换; +4. **渲染计划可序列化**:``plan_to_json`` 输出可被 ``json.loads`` 还原, + 且字段结构对齐前端 ```` 约定。 + +用法(在 core/cockpit 目录下): + python scripts/verify_renderer.py +退出码:0 = 全部通过;1 = 存在未达标项。 + +说明:本脚本不依赖 PyYAML(与 #50 verify_layout_schema.py 一致,避免引入 +运行时依赖),使用等价 dict 复现树脂驾驶舱布局;若装了 PyYAML 可直接解析 +原 YAML 文件复现(见文末注释)。 +""" +from __future__ import annotations + +import os +import sys + +# 本脚本位于 core/cockpit/scripts/,需要把 core/ 加入 sys.path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from cockpit import ( # noqa: E402 + LAYOUT_SCHEMA_ID, + THEME_TOKENS, + WIDGET_COMPONENT, + plan_to_json, + render_layout, + validate_layout, +) +from cockpit.layout import load_layout # noqa: E402 + + +def _ti_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 的布局。""" + 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}, + {"type": "trend", "bind": "R-801.TEMP", "x": 0, "y": 4, "w": 6, "h": 2}, + {"type": "trend", "bind": "R-801.AGIT", "x": 6, "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}, + ], + } + + +def _check_render(name: str, data: dict) -> bool: + """校验 + 渲染一份布局,打印结果,返回是否通过。""" + res = validate_layout(data) + if not res.ok: + print(f"[FAIL] {name}: 布局校验未通过") + for e in res.errors: + print(f" - {e}") + return False + plan = render_layout(load_layout(data)) + print(f"[PASS] {name}: 渲染 {plan.widget_count} 个组件,主题={plan.theme}") + for spec in plan.specs: + print(f" - {spec.id} → {spec.component} " + f"({spec.grid.width_pct}%, {spec.props})") + return True + + +def _check_themes() -> bool: + """主题 token:dark / light 两套 CSS 变量都存在且不同。""" + ok = True + for theme in ("dark", "light"): + data = _ti_layout() + data["theme"] = theme + plan = render_layout(load_layout(data)) + tokens = plan.theme_tokens + match = tokens == THEME_TOKENS[theme] + print(f"[{'PASS' if match else 'FAIL'}] 主题 token({theme}): {len(tokens)} 个 CSS 变量") + ok = ok and match + if THEME_TOKENS["dark"]["--cockpit-bg"] == THEME_TOKENS["light"]["--cockpit-bg"]: + print("[FAIL] dark/light 底色不应相同") + ok = False + else: + print("[PASS] dark/light 底色不同(确为两套配色)") + return ok + + +def _check_serialization() -> bool: + """渲染计划可 JSON 序列化往返。""" + import json + plan = render_layout(load_layout(_resin_like_layout())) + text = plan_to_json(plan) + parsed = json.loads(text) + ok = parsed["title"] == "吸附树脂车间驾驶舱" and parsed["widgetCount"] == 6 + # 字段结构对齐前端 约定 + fields_ok = all({"component", "grid", "props", "perf"} <= set(w) for w in parsed["widgets"]) + print(f"[{'PASS' if ok and fields_ok else 'FAIL'}] 序列化往返: JSON 可解析、字段齐全") + return ok and fields_ok + + +def main() -> int: + print("=" * 70) + print("图表组件渲染引擎验证(issue #51 / PRD 5.5 渲染层适配器)") + print("=" * 70) + print(f"已注册前端组件映射: {WIDGET_COMPONENT}") + print(f"布局版本: {LAYOUT_SCHEMA_ID}") + print("-" * 70) + + all_ok = True + all_ok &= _check_render("PRD 5.5 氯化车间示例", _ti_layout()) + all_ok &= _check_render("树脂模板兼容", _resin_like_layout()) + all_ok &= _check_themes() + all_ok &= _check_serialization() + + print("-" * 70) + print(f"结果: {'全部通过 ✅' if all_ok else '存在未达标项 ❌'}") + return 0 if all_ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) + +# --------------------------------------------------------------------------- +# 附:若安装了 PyYAML,可直接解析树脂模板原文件复现渲染: +# import yaml +# with open("../../templates/resin/dashboard/cockpit.resin.yaml", encoding="utf-8") as f: +# data = yaml.safe_load(f) +# render_layout(load_layout(data)) +# 字段语义与本脚本的 _resin_like_layout() 完全一致(iAOP-cockpit-layout-v1)。 +# --------------------------------------------------------------------------- diff --git a/core/cockpit/tests/test_renderer.py b/core/cockpit/tests/test_renderer.py new file mode 100644 index 0000000..ecd0bb3 --- /dev/null +++ b/core/cockpit/tests/test_renderer.py @@ -0,0 +1,317 @@ +# -*- coding: utf-8 -*- +"""图表组件配置化渲染引擎测试(issue #51 / PRD 5.5)。 + +覆盖: +1. ``render_layout`` 把合法布局编译为合法 ``RenderPlan``(PRD 5.5 氯化车间示例); +2. 树脂模板兼容(与 #50 ``templates/resin/dashboard/cockpit.resin.yaml`` 等价布局); +3. 栅格换算正确:CSS Grid 1-based 起点与 span、宽度百分比; +4. 各 widget type → 前端组件映射 + props 组装正确(含四状态/趋势窗口/KPI 标签兜底); +5. 主题 token 注入(dark / light 两套 CSS 变量); +6. 性能策略:组件数 ≥ 30 触发全局 ``perf_flags`` 与高频组件逐项 perf 标记; +7. 非法布局 / 非法主题 / 未知 type 被拒绝(``RenderError``); +8. 序列化往返:``plan_to_dict`` / ``plan_to_json`` 与渲染计划一致、JSON 可解析。 +""" +from __future__ import annotations + +import copy +import json +import unittest + +from cockpit import ( # type: ignore[import-not-found] + LAYOUT_SCHEMA_ID, + PERF_WIDGET_THRESHOLD, + RenderError, + RenderPlan, + THEME_TOKENS, + WIDGET_COMPONENT, + WidgetRendererSpec, + compute_grid_placement, + load_layout, + plan_to_dict, + plan_to_json, + render_layout, + render_widget, +) +from cockpit.layout import Grid, Widget # type: ignore[import-not-found] + + +def _ti_layout() -> dict: + """PRD 5.5 氯化车间示例(与 #50 test_layout 等价)。""" + 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 的布局。""" + 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}, + {"type": "trend", "bind": "R-801.TEMP", "x": 0, "y": 4, "w": 6, "h": 2}, + {"type": "trend", "bind": "R-801.AGIT", "x": 6, "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 TestRenderLayoutHappyPath(unittest.TestCase): + """合法布局 → 合法 RenderPlan。""" + + def test_ti_layout_renders(self) -> None: + layout = load_layout(_ti_layout()) + plan = render_layout(layout) + self.assertIsInstance(plan, RenderPlan) + self.assertEqual(plan.schema, LAYOUT_SCHEMA_ID) + self.assertEqual(plan.title, "氯化车间驾驶舱") + self.assertEqual(plan.theme, "dark") + self.assertEqual(plan.widget_count, 5) + self.assertEqual(len(plan.specs), 5) + + def test_resin_template_compatible(self) -> None: + """切换到树脂模板同样可渲染(PRD 5.5「切换模板零改码」基础)。""" + plan = render_layout(load_layout(_resin_like_layout())) + self.assertEqual(plan.title, "吸附树脂车间驾驶舱") + self.assertEqual(plan.widget_count, 6) + # 两个 trend 组件共存,id 必须唯一(虚拟滚动 key) + ids = [s.id for s in plan.specs] + self.assertEqual(len(ids), len(set(ids))) + + +class TestGridPlacement(unittest.TestCase): + """栅格整数坐标 → CSS Grid 定位 + 百分比换算。""" + + def test_origin_top_left(self) -> None: + w = Widget(type="kpi_card", x=0, y=0, w=3, h=2, metric="m") + gp = compute_grid_placement(w, grid_columns=12) + self.assertEqual(gp.column_start, 1) + self.assertEqual(gp.row_start, 1) + self.assertEqual(gp.column_span, 3) + self.assertEqual(gp.row_span, 2) + self.assertEqual(gp.width_pct, 25.0) + self.assertIn("grid-column: 1 / span 3", gp.style) + self.assertIn("grid-row: 1 / span 2", gp.style) + + def test_offset_position(self) -> None: + w = Widget(type="kpi_card", x=6, y=2, w=6, h=2, metric="m") + gp = compute_grid_placement(w, grid_columns=12) + self.assertEqual(gp.column_start, 7) # 0-based 6 → 1-based 7 + self.assertEqual(gp.row_start, 3) + self.assertEqual(gp.width_pct, 50.0) + + def test_full_width(self) -> None: + w = Widget(type="alarm_panel", x=0, y=0, w=12, h=3) + gp = compute_grid_placement(w, grid_columns=12) + self.assertEqual(gp.column_span, 12) + self.assertEqual(gp.width_pct, 100.0) + + def test_non_default_grid_columns(self) -> None: + """栅格基线非 12 时百分比按实际列数换算。""" + w = Widget(type="kpi_card", x=0, y=0, w=1, h=1, metric="m") + gp = compute_grid_placement(w, grid_columns=24) + self.assertAlmostEqual(gp.width_pct, 4.1667, places=3) + + def test_invalid_columns_raises(self) -> None: + w = Widget(type="kpi_card", x=0, y=0, w=1, h=1, metric="m") + with self.assertRaises(RenderError): + compute_grid_placement(w, grid_columns=0) + + +class TestWidgetRendering(unittest.TestCase): + """单 widget 渲染:组件映射 + props 组装。""" + + def test_process_view_props(self) -> None: + w = Widget(type="process_view", x=0, y=0, w=12, h=4, src="ti_four_state.svg") + spec = render_widget(w, 0, 12) + self.assertEqual(spec.component, "ProcessView") + self.assertEqual(spec.props["src"], "ti_four_state.svg") + self.assertEqual(spec.props["states"], + ["running", "warning", "alarm", "offline"]) + self.assertEqual(spec.id, "process_view-0") + + def test_trend_props(self) -> None: + w = Widget(type="trend", x=0, y=0, w=6, h=2, bind="CLF-01.TEMP") + spec = render_widget(w, 1, 12) + self.assertEqual(spec.component, "TrendChart") + self.assertEqual(spec.props["series"], "CLF-01.TEMP") + self.assertEqual(spec.props["window"], "PT30M") + + def test_kpi_card_label_fallback(self) -> None: + """kpi_card 缺 label 时回退到 metric(PRD 展示容错)。""" + w = Widget(type="kpi_card", x=0, y=0, w=3, h=2, metric="Ti_purity") + spec = render_widget(w, 2, 12) + self.assertEqual(spec.component, "KpiCard") + self.assertEqual(spec.props["metric"], "Ti_purity") + self.assertEqual(spec.props["label"], "Ti_purity") # 兜底 + + def test_kpi_card_label_present(self) -> None: + w = Widget(type="kpi_card", x=0, y=0, w=3, h=2, metric="Ti_purity", label="Ti 纯度") + spec = render_widget(w, 2, 12) + self.assertEqual(spec.props["label"], "Ti 纯度") + + def test_alarm_panel_props(self) -> None: + w = Widget(type="alarm_panel", x=0, y=0, w=12, h=3) + spec = render_widget(w, 3, 12) + self.assertEqual(spec.component, "AlarmPanel") + self.assertEqual(spec.props["subscribe"], "alarm_stream") + + def test_nl_query_props(self) -> None: + w = Widget(type="nl_query", x=0, y=0, w=3, h=2) + spec = render_widget(w, 4, 12) + self.assertEqual(spec.component, "NlQuery") + self.assertIn("placeholder", spec.props) + + def test_all_widget_types_have_component(self) -> None: + """PRD 5.5 全部 widget type 都有前端组件映射。""" + for wtype in ("process_view", "trend", "kpi_card", "alarm_panel", "nl_query"): + self.assertIn(wtype, WIDGET_COMPONENT) + + def test_unknown_type_raises(self) -> None: + w = Widget(type="ghost", x=0, y=0, w=1, h=1) + with self.assertRaises(RenderError): + render_widget(w, 0, 12) + + def test_description_propagated(self) -> None: + w = Widget(type="kpi_card", x=0, y=0, w=3, h=2, metric="m", description="当批产率") + spec = render_widget(w, 0, 12) + self.assertEqual(spec.description, "当批产率") + + +class TestThemeTokens(unittest.TestCase): + """主题 → CSS 变量注入。""" + + def test_dark_theme_tokens(self) -> None: + plan = render_layout(load_layout(_ti_layout())) + self.assertEqual(plan.theme_tokens, THEME_TOKENS["dark"]) + self.assertIn("--cockpit-bg", plan.theme_tokens) + self.assertIn("--cockpit-accent", plan.theme_tokens) + + def test_light_theme_tokens(self) -> None: + data = copy.deepcopy(_ti_layout()) + data["theme"] = "light" + plan = render_layout(load_layout(data)) + self.assertEqual(plan.theme, "light") + self.assertEqual(plan.theme_tokens, THEME_TOKENS["light"]) + + def test_theme_tokens_cover_both_palettes(self) -> None: + self.assertEqual(set(THEME_TOKENS.keys()), {"dark", "light"}) + + +class TestPerformanceFlags(unittest.TestCase): + """组件数 ≥ 阈值触发全局 + 逐组件性能策略。""" + + def _many_widgets(self, n: int) -> dict: + widgets = [ + {"type": "trend", "bind": f"P-{i}", "x": i % 12, "y": (i // 12) * 2, "w": 1, "h": 2} + for i in range(n) + ] + return {"$schema": LAYOUT_SCHEMA_ID, "title": "大屏", "theme": "dark", "widgets": widgets} + + def test_under_threshold_no_perf(self) -> None: + plan = render_layout(load_layout(self._many_widgets(5))) + self.assertFalse(all(plan.perf_flags.values())) + + def test_over_threshold_enables_perf(self) -> None: + plan = render_layout(load_layout(self._many_widgets(PERF_WIDGET_THRESHOLD))) + self.assertTrue(plan.perf_flags["virtualScroll"]) + self.assertTrue(plan.perf_flags["downsample"]) + self.assertTrue(plan.perf_flags["worker"]) + # 高频刷新组件(trend)逐项启用采样降频 + worker + trend_specs = [s for s in plan.specs if s.type == "trend"] + self.assertTrue(len(trend_specs) > 0) + for s in trend_specs: + self.assertTrue(s.perf["downsample"]) + self.assertTrue(s.perf["worker"]) + # perfHint 透传自 #50 校验结果 + self.assertIsNotNone(plan.perf_hint) + + +class TestRenderErrorCases(unittest.TestCase): + """非法输入被拒绝。""" + + def test_invalid_theme_rejected_by_load_layout(self) -> None: + """非法主题在布局层 #50 即被拦(render_layout 入口前)。""" + from cockpit.layout import LayoutValidationError + bad = copy.deepcopy(_ti_layout()) + bad["theme"] = "purple" # 非法主题 + with self.assertRaises(LayoutValidationError): + load_layout(bad) + + def test_tampered_layout_re_validated(self) -> None: + """构造合法布局后篡改内存模型,render_layout 内部复校验应拒绝。 + + 直接改内存模型绕过 #50 构造期校验(模拟后续篡改), + ``render_layout`` 复用 ``validate_layout`` 应抛 ``RenderError``。 + """ + layout = load_layout(_ti_layout()) + layout.theme = "purple" # 篡改为非法主题 + with self.assertRaises(RenderError): + render_layout(layout) + + +class TestSerialization(unittest.TestCase): + """渲染计划序列化往返。""" + + def test_plan_to_dict_structure(self) -> None: + plan = render_layout(load_layout(_ti_layout())) + d = plan_to_dict(plan) + self.assertEqual(d["$schema"], LAYOUT_SCHEMA_ID) + self.assertEqual(d["title"], "氯化车间驾驶舱") + self.assertIn("themeTokens", d) + self.assertIn("widgets", d) + self.assertEqual(d["widgetCount"], 5) + self.assertIn("perfFlags", d) + # 每个 widget 都带渲染描述符字段 + for w in d["widgets"]: + self.assertIn("component", w) + self.assertIn("grid", w) + self.assertIn("props", w) + self.assertIn("perf", w) + self.assertIn("style", w["grid"]) + + def test_plan_to_json_parseable(self) -> None: + plan = render_layout(load_layout(_resin_like_layout())) + text = plan_to_json(plan) + parsed = json.loads(text) + self.assertEqual(parsed["title"], "吸附树脂车间驾驶舱") + self.assertEqual(parsed["widgetCount"], 6) + # 中文不应被转义(ensure_ascii=False) + self.assertIn("吸附树脂车间驾驶舱", text) + + def test_plan_to_json_indent(self) -> None: + plan = render_layout(load_layout(_ti_layout())) + text = plan_to_json(plan, indent=None) + self.assertNotIn("\n", text) # 紧凑模式无换行 + + def test_grid_placement_to_dict(self) -> None: + plan = render_layout(load_layout(_ti_layout())) + first = plan.specs[0] + gpd = first.grid.to_dict() + self.assertEqual(gpd["columnStart"], 1) + self.assertEqual(gpd["rowStart"], 1) + self.assertEqual(gpd["widthPct"], 50.0) + + def test_spec_to_dict_roundtrip_fields(self) -> None: + plan = render_layout(load_layout(_ti_layout())) + for spec in plan.specs: + sd = spec.to_dict() + self.assertEqual(set(sd.keys()), + {"id", "component", "type", "grid", "description", "props", "perf"}) + + +if __name__ == "__main__": + unittest.main()