feat(#55/#62/#63/#64/#65/#66/#67): ⑤.7 配置台 + ⑤ Ti 布局模板(7 子任务,123 测试通过) #129
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""⑤.7 模板配置台(Template Console)内核引擎 —— EPIC #9。
|
||||||
|
|
||||||
|
配置台是**跨模板通用的内核能力**:为实施工程师提供一个无代码的配置驱动
|
||||||
|
界面,把"模型超参 / RAG / 布局"三类配置 + 点位字典 + 版本发布统一编排,
|
||||||
|
并将发布的配置**推送给内核**(edge-gateway / rag-kb / model-framework)。
|
||||||
|
|
||||||
|
本包拆为 6 个子模块,对应 6 个 issue(同一 feature 分支承载,单 PR 关联):
|
||||||
|
|
||||||
|
- ``rbac`` (#62) 三级 RBAC 权限(admin / engineer / readonly);
|
||||||
|
- ``point_importer`` (#63) 点位字典 CSV 导入 + 自动校验页面(复用
|
||||||
|
``core/edge-gateway/point_dict`` 校验器,增加配置台
|
||||||
|
级结果聚合 + OPC 节点格式校验 + 模板选择);
|
||||||
|
- ``config_store`` (#64) 配置项 CRUD(模型超参 / RAG / 布局三类,文件系统
|
||||||
|
版本化 JSON 存储);
|
||||||
|
- ``preview`` (#65) 预览渲染引擎(布局/告警/查询 → 可预览结构化输出,
|
||||||
|
对齐 iAOP-cockpit-layout-v1 widget 类型);
|
||||||
|
- ``release`` (#66) 版本发布 + 回滚点(基于 config_store 快照,semver);
|
||||||
|
- ``push_channel`` (#67) 配置台↔内核配置推送契约(JSON manifest + 校验和 +
|
||||||
|
幂等性)。
|
||||||
|
|
||||||
|
设计原则(对齐 PRD「可解释可溯源」与既有内核范式):
|
||||||
|
- 纯标准库零运行时依赖(无 pyyaml/numpy/pandas),YAML 子集用内置解析器;
|
||||||
|
- dataclass + Enum + 类型注解 + 中文 docstring;
|
||||||
|
- 关键决策均带 ``meaning`` / ``reason`` 字段,便于审计与可解释性。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .rbac import (
|
||||||
|
Action,
|
||||||
|
Permission,
|
||||||
|
Role,
|
||||||
|
RoleKind,
|
||||||
|
User,
|
||||||
|
has_permission,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Action",
|
||||||
|
"Permission",
|
||||||
|
"Role",
|
||||||
|
"RoleKind",
|
||||||
|
"User",
|
||||||
|
"has_permission",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: 本包版本(对齐 EPIC #9 模板配置台交付节奏)
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""⑤.7 配置项 CRUD 存储引擎 —— issue #64 / PRD ⑤.7。
|
||||||
|
|
||||||
|
配置台要管理三类业务配置:**模型超参 / RAG / 布局**。这些配置是模板交付物的
|
||||||
|
"活"部分——实施工程师按现场调参,每次改动都要**可解释、可校验、可版本化**
|
||||||
|
(为 #66 发布/回滚提供快照源)。本模块提供基于文件系统的版本化 JSON 存储:
|
||||||
|
|
||||||
|
- 三类配置各对应一个 JSON 文件(``model_params.json`` / ``rag_configs.json`` /
|
||||||
|
``layout.json``),存放在一个 store 根目录下;
|
||||||
|
- 每条配置项是一个 ``ConfigItem``(key + value + 含义 + 校验规则);
|
||||||
|
- 提供 ``list / get / upsert / delete`` CRUD,所有写操作都先**校验**再落盘,
|
||||||
|
并记录 ``updated_by`` / ``reason``(对齐 PRD「可解释可溯源」);
|
||||||
|
- 校验规则按类别内置(模型超参的范围/类型、RAG 的来源数、布局的 widget 类型),
|
||||||
|
非法值在 upsert 阶段即被拒绝,避免坏数据进入版本快照。
|
||||||
|
|
||||||
|
存储格式(每类一个 JSON,内容为 ``{items: [ConfigItem, ...], schema_version}``)
|
||||||
|
刻意简单、人可读,便于实施工程师直接查看/备份。
|
||||||
|
|
||||||
|
零运行时依赖:仅用 json / dataclass / Enum / 标准库。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 配置类别
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConfigKind(str, Enum):
|
||||||
|
"""三类业务配置(对齐 #64 需求)。"""
|
||||||
|
|
||||||
|
MODEL_PARAM = "model_param" # 模型超参(学习率/迭代数/特征开关…)
|
||||||
|
RAG_CONFIG = "rag_config" # RAG 知识库配置(top_k/相似度阈值/来源…)
|
||||||
|
LAYOUT = "layout" # 驾驶舱布局(widget 列表)
|
||||||
|
|
||||||
|
|
||||||
|
#: 各类别对应的存储文件名
|
||||||
|
KIND_FILENAME: Dict[ConfigKind, str] = {
|
||||||
|
ConfigKind.MODEL_PARAM: "model_params.json",
|
||||||
|
ConfigKind.RAG_CONFIG: "rag_configs.json",
|
||||||
|
ConfigKind.LAYOUT: "layout.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 存储结构版本(schema 演进时升级,发布快照会带上)
|
||||||
|
STORE_SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
#: 驾驶舱布局允许的 widget 类型(对齐 iAOP-cockpit-layout-v1 / resin cockpit)
|
||||||
|
ALLOWED_WIDGET_TYPES = {"process_view", "trend", "kpi_card", "alarm_panel", "nl_query"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 配置项数据模型
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConfigItem:
|
||||||
|
"""一条配置项(可解释:带含义、更新人、原因)。"""
|
||||||
|
|
||||||
|
key: str # 配置键(类别内唯一,如 learning_rate)
|
||||||
|
value: Any # 配置值(标量或结构化)
|
||||||
|
kind: ConfigKind # 所属类别
|
||||||
|
meaning: str = "" # 业务含义(供配置台展示与审计)
|
||||||
|
updated_by: str = "system" # 最后修改人(对接 RBAC 用户名)
|
||||||
|
reason: str = "" # 本次修改原因(可解释可溯源)
|
||||||
|
updated_at: str = "" # ISO8601 时间戳
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
d = asdict(self)
|
||||||
|
d["kind"] = self.kind.value # 枚举序列化为字符串
|
||||||
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, raw: dict) -> "ConfigItem":
|
||||||
|
return cls(
|
||||||
|
key=raw["key"],
|
||||||
|
value=raw.get("value"),
|
||||||
|
kind=ConfigKind(raw.get("kind")),
|
||||||
|
meaning=raw.get("meaning", ""),
|
||||||
|
updated_by=raw.get("updated_by", "system"),
|
||||||
|
reason=raw.get("reason", ""),
|
||||||
|
updated_at=raw.get("updated_at", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_iso() -> str:
|
||||||
|
"""当前 UTC 时间 ISO8601(无时区歧义)。"""
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 校验(按类别内置规则)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationResult:
|
||||||
|
"""配置项校验结果。"""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
errors: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return self.ok
|
||||||
|
|
||||||
|
|
||||||
|
def validate_item(kind: ConfigKind, key: str, value: Any) -> ValidationResult:
|
||||||
|
"""按类别校验配置项的 key/value 合法性。
|
||||||
|
|
||||||
|
校验规则(配置台 upsert 前置门禁,防止坏数据进快照):
|
||||||
|
- 通用:key 非空、匹配 ``[a-z0-9_.-]+``;
|
||||||
|
- model_param:value 为标量(int/float/bool/str)或标量列表;
|
||||||
|
- rag_config:top_k 为 1~50 的正整数、similarity_threshold 为 0~1 浮点、
|
||||||
|
sources 为非空字符串列表;
|
||||||
|
- layout:value 为 widget 列表,每个 widget 有合法 type 与 x/y/w/h。
|
||||||
|
"""
|
||||||
|
errors: List[str] = []
|
||||||
|
if not key or not isinstance(key, str):
|
||||||
|
errors.append("key 不能为空")
|
||||||
|
elif not re.match(r"^[a-z0-9_.\-]+$", key):
|
||||||
|
errors.append(f"key '{key}' 仅允许小写字母/数字/._-")
|
||||||
|
|
||||||
|
if kind == ConfigKind.MODEL_PARAM:
|
||||||
|
if not isinstance(value, (int, float, bool, str, list)):
|
||||||
|
errors.append("model_param 的 value 必须为标量或标量列表")
|
||||||
|
elif isinstance(value, list) and any(
|
||||||
|
not isinstance(v, (int, float, bool, str)) for v in value):
|
||||||
|
errors.append("model_param 列表 value 仅允许标量元素")
|
||||||
|
# 常见超参范围提示(软约束,仅对已知键)
|
||||||
|
if key == "learning_rate" and isinstance(value, (int, float)):
|
||||||
|
if not (0 < value < 1):
|
||||||
|
errors.append("learning_rate 应在 (0, 1) 区间")
|
||||||
|
if key == "iterations" and isinstance(value, int):
|
||||||
|
if value <= 0:
|
||||||
|
errors.append("iterations 必须为正整数")
|
||||||
|
|
||||||
|
elif kind == ConfigKind.RAG_CONFIG:
|
||||||
|
if key == "top_k":
|
||||||
|
if not (isinstance(value, int) and 1 <= value <= 50):
|
||||||
|
errors.append("top_k 必须为 1~50 的整数")
|
||||||
|
elif key == "similarity_threshold":
|
||||||
|
if not (isinstance(value, (int, float)) and 0 <= value <= 1):
|
||||||
|
errors.append("similarity_threshold 必须为 0~1 的数")
|
||||||
|
elif key == "sources":
|
||||||
|
if not (isinstance(value, list) and value
|
||||||
|
and all(isinstance(s, str) and s for s in value)):
|
||||||
|
errors.append("sources 必须为非空字符串列表")
|
||||||
|
|
||||||
|
elif kind == ConfigKind.LAYOUT:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
errors.append("layout 的 value 必须为 widget 列表")
|
||||||
|
else:
|
||||||
|
for i, w in enumerate(value):
|
||||||
|
if not isinstance(w, dict):
|
||||||
|
errors.append(f"widget[{i}] 必须为对象")
|
||||||
|
continue
|
||||||
|
wt = w.get("type")
|
||||||
|
if wt not in ALLOWED_WIDGET_TYPES:
|
||||||
|
errors.append(
|
||||||
|
f"widget[{i}] 非法 type '{wt}'(合法:{sorted(ALLOWED_WIDGET_TYPES)})")
|
||||||
|
for coord in ("x", "y", "w", "h"):
|
||||||
|
if not isinstance(w.get(coord), int) or w.get(coord) < 0:
|
||||||
|
errors.append(f"widget[{i}] {coord} 必须为非负整数")
|
||||||
|
|
||||||
|
return ValidationResult(ok=not errors, errors=errors)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 存储引擎
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ConfigStore:
|
||||||
|
"""基于文件系统的版本化配置存储(三类配置各一 JSON)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
store = ConfigStore("/path/to/store")
|
||||||
|
store.upsert(ConfigKind.MODEL_PARAM, "learning_rate", 0.001,
|
||||||
|
meaning="学习率", updated_by="li", reason="首次标定")
|
||||||
|
items = store.list(ConfigKind.MODEL_PARAM)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, root: str) -> None:
|
||||||
|
self.root = root
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
|
||||||
|
# -- 路径 --
|
||||||
|
def _path(self, kind: ConfigKind) -> str:
|
||||||
|
return os.path.join(self.root, KIND_FILENAME[kind])
|
||||||
|
|
||||||
|
def _read(self, kind: ConfigKind) -> List[ConfigItem]:
|
||||||
|
path = self._path(kind)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return []
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
blob = json.load(fh)
|
||||||
|
return [ConfigItem.from_dict(r) for r in blob.get("items", [])]
|
||||||
|
|
||||||
|
def _write(self, kind: ConfigKind, items: List[ConfigItem]) -> None:
|
||||||
|
blob = {
|
||||||
|
"schema_version": STORE_SCHEMA_VERSION,
|
||||||
|
"kind": kind.value,
|
||||||
|
"items": [it.to_dict() for it in items],
|
||||||
|
}
|
||||||
|
path = self._path(kind)
|
||||||
|
# 先写临时文件再替换,避免写一半被读到(原子写)
|
||||||
|
tmp = path + ".tmp"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(blob, fh, ensure_ascii=False, indent=2)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
# -- 查询 --
|
||||||
|
def list(self, kind: ConfigKind) -> List[ConfigItem]:
|
||||||
|
"""列出某类全部配置项。"""
|
||||||
|
return self._read(kind)
|
||||||
|
|
||||||
|
def get(self, kind: ConfigKind, key: str) -> Optional[ConfigItem]:
|
||||||
|
"""取单条配置项(不存在返回 None)。"""
|
||||||
|
for it in self._read(kind):
|
||||||
|
if it.key == key:
|
||||||
|
return it
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- 写 --
|
||||||
|
def upsert(
|
||||||
|
self,
|
||||||
|
kind: ConfigKind,
|
||||||
|
key: str,
|
||||||
|
value: Any,
|
||||||
|
meaning: str = "",
|
||||||
|
updated_by: str = "system",
|
||||||
|
reason: str = "",
|
||||||
|
) -> ConfigItem:
|
||||||
|
"""新增或更新一条配置项(先校验,再落盘)。
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: 校验失败(带全部错误明细)。
|
||||||
|
"""
|
||||||
|
vr = validate_item(kind, key, value)
|
||||||
|
if not vr:
|
||||||
|
raise ValueError(f"配置项校验失败 [{kind.value}:{key}]:{'; '.join(vr.errors)}")
|
||||||
|
items = self._read(kind)
|
||||||
|
now = _now_iso()
|
||||||
|
existing_idx = next((i for i, it in enumerate(items) if it.key == key), None)
|
||||||
|
item = ConfigItem(
|
||||||
|
key=key, value=value, kind=kind, meaning=meaning,
|
||||||
|
updated_by=updated_by, reason=reason, updated_at=now,
|
||||||
|
)
|
||||||
|
if existing_idx is None:
|
||||||
|
items.append(item)
|
||||||
|
else:
|
||||||
|
items[existing_idx] = item
|
||||||
|
self._write(kind, items)
|
||||||
|
return item
|
||||||
|
|
||||||
|
def delete(self, kind: ConfigKind, key: str) -> bool:
|
||||||
|
"""删除一条配置项。返回是否实际删除。"""
|
||||||
|
items = self._read(kind)
|
||||||
|
new_items = [it for it in items if it.key != key]
|
||||||
|
if len(new_items) == len(items):
|
||||||
|
return False
|
||||||
|
self._write(kind, new_items)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# -- 快照(供 #66 release 使用) --
|
||||||
|
def snapshot(self) -> Dict[str, Any]:
|
||||||
|
"""全量配置快照(三类聚合,供发布版本固化)。"""
|
||||||
|
return {
|
||||||
|
"schema_version": STORE_SCHEMA_VERSION,
|
||||||
|
"captured_at": _now_iso(),
|
||||||
|
"kinds": {
|
||||||
|
kind.value: [it.to_dict() for it in self._read(kind)]
|
||||||
|
for kind in ConfigKind
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def restore(self, snapshot: Dict[str, Any]) -> None:
|
||||||
|
"""从快照恢复全部配置(#66 回滚入口)。"""
|
||||||
|
kinds = snapshot.get("kinds", {})
|
||||||
|
for kind in ConfigKind:
|
||||||
|
raw_items = kinds.get(kind.value, [])
|
||||||
|
items = [ConfigItem.from_dict(r) for r in raw_items]
|
||||||
|
self._write(kind, items)
|
||||||
|
|
||||||
|
def item_counts(self) -> Dict[str, int]:
|
||||||
|
"""各类配置项数量(配置台仪表盘用)。"""
|
||||||
|
return {kind.value: len(self._read(kind)) for kind in ConfigKind}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""⑤.7 点位字典 CSV 导入 + 自动校验页面 —— issue #63 / PRD ⑤.7。
|
||||||
|
|
||||||
|
配置台的"导入页面"要解决:实施工程师拿着 DCS 点表(Excel 转 CSV)粘进配置台,
|
||||||
|
**一次性看到所有问题**(表头错/量纲错/重复点号/采样率非正/协议非法/OPC 节点
|
||||||
|
格式错),而不是改一条报一条。本模块是导入页面的后端引擎。
|
||||||
|
|
||||||
|
**复用而非重造**:点位字典的 schema/加载/校验(量纲/数据类型/采样率/重复点号/
|
||||||
|
协议)已由内核 ``core/edge-gateway/point_dict``(loader/validator/schema)实现
|
||||||
|
并被边缘网关正式使用。本模块在其基础上增加**配置台专属**校验维度:
|
||||||
|
|
||||||
|
1. OPC 节点格式校验(OPC UA 节点须形如 ``ns=<数字>;s=<名>`` 或 PLC 寄存器
|
||||||
|
``holding:<数字>`` / ``coil:<数字>``,与 simulator/opcua 驱动约定一致);
|
||||||
|
2. 表头列顺序严格对齐(实施工程师照表填列,列序错位是高频错误);
|
||||||
|
3. 行级结果聚合为 ``ImportRowIssue``(行号 + 严重级别 + 问题 + 修复建议),
|
||||||
|
供配置台前端逐行渲染、按严重级别过滤;
|
||||||
|
4. 模板选择(resin/ti):不同行业模板的合法量纲集合不同(如树脂含 rpm/mmol·g⁻¹),
|
||||||
|
导入时按模板切换校验基线。
|
||||||
|
|
||||||
|
零运行时依赖:复用 ``point_dict`` 子包(纯标准库 csv/dataclass)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
# 复用内核 edge-gateway 的点位字典加载/校验(schema/validator/loader)
|
||||||
|
# 在 tests/_bootstrap.py 中已把 core/edge-gateway 加入 sys.path,
|
||||||
|
# 故此处以顶层包 point_dict 引用(与 edge-gateway 自身测试一致)。
|
||||||
|
from point_dict import ( # noqa: E402
|
||||||
|
CSV_HEADERS,
|
||||||
|
VALID_PROTOCOLS,
|
||||||
|
VALID_UNITS,
|
||||||
|
Point,
|
||||||
|
PointDict,
|
||||||
|
load_point_dict_csv,
|
||||||
|
)
|
||||||
|
from point_dict import schema as _pd_schema # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(str, Enum):
|
||||||
|
"""问题严重级别(配置台前端据此着色/过滤)。"""
|
||||||
|
|
||||||
|
ERROR = "error" # 阻断:不修复无法入库
|
||||||
|
WARN = "warn" # 警告:可入库但建议复核(如 OPC 节点为空)
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateKind(str, Enum):
|
||||||
|
"""行业模板(决定合法量纲等校验基线)。"""
|
||||||
|
|
||||||
|
TI = "ti" # 氯化/化工通用(templates/ti-cl4)
|
||||||
|
RESIN = "resin" # 吸附树脂(templates/resin,含 rpm / mmol/g)
|
||||||
|
|
||||||
|
|
||||||
|
# OPC 节点格式(与 simulator/opcua/s7 驱动约定一致):
|
||||||
|
# ns=2;s=CLF.Temp —— OPC UA 节点(namespace + 字符串 id)
|
||||||
|
# holding:40010 / coil:1 —— Modbus 寄存器(保持/线圈 + 地址)
|
||||||
|
_OPC_UA_RE = re.compile(r"^ns=\d+;s=[^\s,]+$")
|
||||||
|
_MODBUS_RE = re.compile(r"^(holding|coil|input|discrete):(\d+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_set(template: TemplateKind) -> set:
|
||||||
|
"""按模板返回合法量纲集合(树脂含 rpm/mmol·g⁻¹ 等扩展)。"""
|
||||||
|
base = set(VALID_UNITS)
|
||||||
|
if template == TemplateKind.RESIN:
|
||||||
|
# VALID_UNITS 已含树脂扩展(rpm/mmol/g),直接复用
|
||||||
|
return base
|
||||||
|
# ti 模板:移除树脂专属量纲,避免化工模板误用树脂量纲
|
||||||
|
base.discard("rpm")
|
||||||
|
base.discard("mmol/g")
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportRowIssue:
|
||||||
|
"""导入页一行的问题(行号 + 严重级别 + 问题 + 修复建议,可解释)。"""
|
||||||
|
|
||||||
|
row: int # CSV 行号(表头=1,数据从 2 起)
|
||||||
|
severity: Severity
|
||||||
|
code: str # 错误码(对齐 point_dict.validator 的 code + 本模块扩展)
|
||||||
|
message: str # 问题描述
|
||||||
|
suggestion: str = "" # 修复建议(供配置台"一键修复"提示)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"row": self.row, "severity": self.severity.value,
|
||||||
|
"code": self.code, "message": self.message,
|
||||||
|
"suggestion": self.suggestion,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportReport:
|
||||||
|
"""导入校验报告(配置台导入页面数据模型)。"""
|
||||||
|
|
||||||
|
template: TemplateKind
|
||||||
|
total_rows: int = 0 # 数据行数
|
||||||
|
issues: List[ImportRowIssue] = field(default_factory=list)
|
||||||
|
loaded_points: int = 0 # 成功加载的点数
|
||||||
|
file_path: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
"""无 ERROR 级问题即可入库(WARN 不阻断)。"""
|
||||||
|
return not any(i.severity == Severity.ERROR for i in self.issues)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error_count(self) -> int:
|
||||||
|
return sum(1 for i in self.issues if i.severity == Severity.ERROR)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def warn_count(self) -> int:
|
||||||
|
return sum(1 for i in self.issues if i.severity == Severity.WARN)
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
"""人类可读汇总(配置台导入结果横幅)。"""
|
||||||
|
status = "通过" if self.ok else "未通过"
|
||||||
|
return (f"导入校验{status}:{self.loaded_points} 点 / "
|
||||||
|
f"{self.total_rows} 行,错误 {self.error_count},警告 {self.warn_count}")
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"template": self.template.value,
|
||||||
|
"total_rows": self.total_rows,
|
||||||
|
"loaded_points": self.loaded_points,
|
||||||
|
"ok": self.ok,
|
||||||
|
"error_count": self.error_count,
|
||||||
|
"warn_count": self.warn_count,
|
||||||
|
"summary": self.summary(),
|
||||||
|
"issues": [i.to_dict() for i in self.issues],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 校验扩展
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _validate_opc_node(point: Point) -> List[ImportRowIssue]:
|
||||||
|
"""OPC 节点格式校验(配置台扩展维度)。
|
||||||
|
|
||||||
|
约定(与驱动注册表对齐):
|
||||||
|
- 协议 opcua:节点须匹配 ``ns=<数字>;s=<名>``;
|
||||||
|
- 协议 modbus:节点须匹配 ``holding/coil/input/discrete:<数字>``;
|
||||||
|
- 协议 simulator:节点可空,或任意上述格式(演示用,宽松);
|
||||||
|
- 节点为空:WARN(可入库但运行时无法采集,建议补全)。
|
||||||
|
"""
|
||||||
|
out: List[ImportRowIssue] = []
|
||||||
|
node = (point.opc_node or "").strip()
|
||||||
|
if not node:
|
||||||
|
out.append(ImportRowIssue(
|
||||||
|
row=point.row_number, severity=Severity.WARN, code="empty_opc_node",
|
||||||
|
message=f"第{point.row_number}行 opcNode 为空",
|
||||||
|
suggestion="运行时无法采集,建议补全 OPC UA 节点或 PLC 寄存器地址",
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
proto = (point.protocol or "").lower()
|
||||||
|
ok_ua = bool(_OPC_UA_RE.match(node))
|
||||||
|
ok_mb = bool(_MODBUS_RE.match(node))
|
||||||
|
if proto == "opcua" and not ok_ua:
|
||||||
|
out.append(ImportRowIssue(
|
||||||
|
row=point.row_number, severity=Severity.ERROR, code="bad_opc_node",
|
||||||
|
message=(f"第{point.row_number}行 opcNode '{node}' 不符合 OPC UA "
|
||||||
|
f"格式 ns=<ns>;s=<name>"),
|
||||||
|
suggestion="示例:ns=2;s=CLF.Temp",
|
||||||
|
))
|
||||||
|
elif proto == "modbus" and not ok_mb:
|
||||||
|
out.append(ImportRowIssue(
|
||||||
|
row=point.row_number, severity=Severity.ERROR, code="bad_opc_node",
|
||||||
|
message=(f"第{point.row_number}行 opcNode '{node}' 不符合 Modbus "
|
||||||
|
f"格式 holding/coil/input/discrete:<addr>"),
|
||||||
|
suggestion="示例:holding:40010",
|
||||||
|
))
|
||||||
|
elif proto not in ("opcua", "modbus") and not (ok_ua or ok_mb):
|
||||||
|
# simulator/s7/... 节点为空已 WARN;非空但格式都不符则 WARN(宽松)
|
||||||
|
out.append(ImportRowIssue(
|
||||||
|
row=point.row_number, severity=Severity.WARN, code="bad_opc_node",
|
||||||
|
message=(f"第{point.row_number}行 opcNode '{node}' 既非 OPC UA 也非 "
|
||||||
|
f"Modbus 格式"),
|
||||||
|
suggestion="确认节点格式或清空(演示协议可空)",
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_header_order(headers: List[str]) -> List[ImportRowIssue]:
|
||||||
|
"""表头列顺序严格对齐(列序错位是实施工程师高频错误)。"""
|
||||||
|
out: List[ImportRowIssue] = []
|
||||||
|
if not headers:
|
||||||
|
out.append(ImportRowIssue(
|
||||||
|
row=1, severity=Severity.ERROR, code="empty_header",
|
||||||
|
message="CSV 缺少表头行",
|
||||||
|
suggestion=f"表头应为:{','.join(CSV_HEADERS)}",
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
missing = [h for h in CSV_HEADERS if h not in headers]
|
||||||
|
for h in missing:
|
||||||
|
out.append(ImportRowIssue(
|
||||||
|
row=1, severity=Severity.ERROR, code="missing_column",
|
||||||
|
message=f"表头缺少必填列:{h}",
|
||||||
|
suggestion=f"补列 {h}(完整表头:{','.join(CSV_HEADERS)})",
|
||||||
|
))
|
||||||
|
if headers[: len(CSV_HEADERS)] != CSV_HEADERS and not missing:
|
||||||
|
out.append(ImportRowIssue(
|
||||||
|
row=1, severity=Severity.WARN, code="bad_column_order",
|
||||||
|
message=f"表头列顺序与标准不一致:{headers}",
|
||||||
|
suggestion=f"标准顺序:{','.join(CSV_HEADERS)}",
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_validator_issues(report: "object", severity_for: Dict[str, Severity]) -> List[ImportRowIssue]:
|
||||||
|
"""把内核 validator.ValidationReport.issues 转成 ImportRowIssue。"""
|
||||||
|
out: List[ImportRowIssue] = []
|
||||||
|
for it in getattr(report, "issues", []):
|
||||||
|
sev = severity_for.get(it.code, Severity.ERROR)
|
||||||
|
out.append(ImportRowIssue(
|
||||||
|
row=it.row, severity=sev, code=it.code, message=it.message,
|
||||||
|
))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# 内核 validator 错误码 → 严重级别映射
|
||||||
|
_SEVERITY_MAP: Dict[str, Severity] = {
|
||||||
|
"missing_column": Severity.ERROR,
|
||||||
|
"missing_field": Severity.ERROR,
|
||||||
|
"bad_unit": Severity.ERROR,
|
||||||
|
"bad_data_type": Severity.ERROR,
|
||||||
|
"bad_sample_rate": Severity.ERROR,
|
||||||
|
"bad_protocol": Severity.ERROR,
|
||||||
|
"dup_point": Severity.ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 导入入口
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def import_csv(
|
||||||
|
path: str,
|
||||||
|
template: TemplateKind = TemplateKind.TI,
|
||||||
|
extra_unit_check: bool = True,
|
||||||
|
) -> Tuple[PointDict, ImportReport]:
|
||||||
|
"""导入并校验点位字典 CSV(配置台导入页面后端入口)。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: CSV 文件路径(UTF-8,9 列表头);
|
||||||
|
template: 行业模板(决定合法量纲集合,resin/ti);
|
||||||
|
extra_unit_check: 是否按模板收窄量纲集合做额外校验。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(PointDict, ImportReport):加载的点位模型 + 校验报告。
|
||||||
|
报告 ``ok`` 为 True 即可入库;WARN 不阻断。
|
||||||
|
"""
|
||||||
|
report = ImportReport(template=template, file_path=path)
|
||||||
|
|
||||||
|
# 1) 表头校验(先读表头行)
|
||||||
|
import csv as _csv
|
||||||
|
with open(path, "r", encoding="utf-8-sig") as fh:
|
||||||
|
reader = _csv.reader(fh)
|
||||||
|
rows = list(reader)
|
||||||
|
headers = [c.strip() for c in rows[0]] if rows else []
|
||||||
|
report.issues.extend(_validate_header_order(headers))
|
||||||
|
|
||||||
|
# 2) 加载 + 内核校验(量纲/数据类型/采样率/重复点号/协议)
|
||||||
|
point_dict = load_point_dict_csv(path)
|
||||||
|
report.loaded_points = len(point_dict)
|
||||||
|
report.total_rows = len(point_dict.points)
|
||||||
|
|
||||||
|
from point_dict.validator import validate_point_dict
|
||||||
|
kernel_report = validate_point_dict(point_dict, headers)
|
||||||
|
report.issues.extend(_convert_validator_issues(kernel_report, _SEVERITY_MAP))
|
||||||
|
|
||||||
|
# 3) 模板级量纲收窄(resin 才允许 rpm/mmol·g⁻¹)
|
||||||
|
if extra_unit_check:
|
||||||
|
allowed_units = _unit_set(template)
|
||||||
|
for p in point_dict.points:
|
||||||
|
if p.unit and p.unit not in allowed_units:
|
||||||
|
# 内核 validator 已按全集校验过;这里只补充模板级差异提示
|
||||||
|
if p.unit in ("rpm", "mmol/g") and template == TemplateKind.TI:
|
||||||
|
report.issues.append(ImportRowIssue(
|
||||||
|
row=p.row_number, severity=Severity.ERROR,
|
||||||
|
code="template_unit_mismatch",
|
||||||
|
message=(f"第{p.row_number}行 量纲 '{p.unit}' 为树脂模板专属,"
|
||||||
|
f"当前导入的是 {template.value} 模板"),
|
||||||
|
suggestion="切换模板为 resin,或修正量纲",
|
||||||
|
))
|
||||||
|
|
||||||
|
# 4) OPC 节点格式校验(配置台扩展)
|
||||||
|
for p in point_dict.points:
|
||||||
|
report.issues.extend(_validate_opc_node(p))
|
||||||
|
|
||||||
|
# 行号排序,便于配置台逐行展示
|
||||||
|
report.issues.sort(key=lambda i: (i.row, i.code))
|
||||||
|
return point_dict, report
|
||||||
|
|
||||||
|
|
||||||
|
def import_csv_string(
|
||||||
|
content: str,
|
||||||
|
template: TemplateKind = TemplateKind.TI,
|
||||||
|
encoding: str = "utf-8",
|
||||||
|
) -> Tuple[PointDict, ImportReport]:
|
||||||
|
"""从 CSV 文本导入(配置台粘贴框场景,落临时文件后复用 import_csv)。"""
|
||||||
|
import tempfile
|
||||||
|
tmp = tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", encoding=encoding, suffix=".csv", delete=False)
|
||||||
|
try:
|
||||||
|
tmp.write(content)
|
||||||
|
tmp.flush()
|
||||||
|
tmp.close()
|
||||||
|
return import_csv(tmp.name, template=template)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""⑤.7 配置台三级 RBAC 权限模型 —— issue #62 / PRD ⑤.7。
|
||||||
|
|
||||||
|
配置台面向**多角色协作**:实施工程师配模板,行业工程师调参数,运维/管理者
|
||||||
|
发布上线。直接对所有人开放写权限会带来误改与不可溯源风险。本模块用三级
|
||||||
|
RBAC(基于角色的访问控制)锁定"谁能对哪类配置做什么",并把每次权限判定
|
||||||
|
的**理由**一并返回,对齐 PRD「可解释可溯源」。
|
||||||
|
|
||||||
|
三级角色(由低到高,后者继承前者全部权限):
|
||||||
|
|
||||||
|
- ``readonly`` (只读):查看配置 / 预览 / 历史版本,不可写;
|
||||||
|
- ``engineer`` (行业工程师):只读权限 + 编辑/校验/导入配置(模型超参 /
|
||||||
|
RAG / 布局 / 点位字典),但**不能发布与回滚**;
|
||||||
|
- ``admin`` (管理员):工程师权限 + 发布 / 回滚 / 推送内核 / 用户管理。
|
||||||
|
|
||||||
|
权限判定核心为 ``has_permission(user, resource, action)``,返回
|
||||||
|
``PermissionDecision``(allow + reason),便于配置台前端把"为什么拒绝"
|
||||||
|
直接展示给操作者,而不是一个干瘪的 403。
|
||||||
|
|
||||||
|
零运行时依赖:仅用 dataclass / Enum / 标准库。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Dict, List, Optional, Set
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 权限维度:资源 × 动作
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Resource(str, Enum):
|
||||||
|
"""配置台可管控的资源(对齐 #63~#67 子模块)。"""
|
||||||
|
|
||||||
|
POINT_DICT = "point_dict" # 点位字典(#63)
|
||||||
|
MODEL_PARAM = "model_param" # 模型超参配置(#64)
|
||||||
|
RAG_CONFIG = "rag_config" # RAG 知识库配置(#64)
|
||||||
|
LAYOUT = "layout" # 驾驶舱布局配置(#64/#65)
|
||||||
|
PREVIEW = "preview" # 预览(#65)
|
||||||
|
RELEASE = "release" # 版本发布/回滚(#66)
|
||||||
|
PUSH = "push" # 配置推送内核(#67)
|
||||||
|
USER = "user" # 用户/角色管理
|
||||||
|
|
||||||
|
|
||||||
|
class Action(str, Enum):
|
||||||
|
"""对资源可执行的动作。"""
|
||||||
|
|
||||||
|
VIEW = "view" # 查看 / 预览 / 列表
|
||||||
|
EDIT = "edit" # 新增 / 修改 / 删除 / 导入 / 校验
|
||||||
|
PUBLISH = "publish" # 发布版本 / 回滚 / 推送内核
|
||||||
|
MANAGE = "manage" # 用户与角色管理
|
||||||
|
|
||||||
|
|
||||||
|
class RoleKind(str, Enum):
|
||||||
|
"""三级角色枚举(值即配置资产中的角色标识)。"""
|
||||||
|
|
||||||
|
READONLY = "readonly"
|
||||||
|
ENGINEER = "engineer"
|
||||||
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
|
||||||
|
# 各资源的「写」动作等价集合:EDIT 含新增/修改/删除/导入/校验。
|
||||||
|
# PUBLISH 含发布/回滚/推送。这样配置台前端只需关心粗粒度动作。
|
||||||
|
_WRITE_ACTIONS: Set[Action] = {Action.EDIT, Action.PUBLISH, Action.MANAGE}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 权限模型
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Permission:
|
||||||
|
"""一条权限授予(角色 → 资源 → 动作)。
|
||||||
|
|
||||||
|
``meaning`` 解释该权限的业务含义,用于审计日志与配置台权限矩阵展示。
|
||||||
|
注意:权限**匹配**基于 ``resource:action``(资源×动作),与授予角色无关——
|
||||||
|
这正是角色继承能生效的关键(admin 继承 engineer 的 edit,匹配键相同)。
|
||||||
|
``role`` 仅作为审计元数据,记录"是谁授予的"。
|
||||||
|
"""
|
||||||
|
|
||||||
|
role: RoleKind
|
||||||
|
resource: Resource
|
||||||
|
action: Action
|
||||||
|
meaning: str = ""
|
||||||
|
|
||||||
|
def key(self) -> str:
|
||||||
|
"""权限匹配键(资源:动作)—— 角色继承据此累计。"""
|
||||||
|
return f"{self.resource.value}:{self.action.value}"
|
||||||
|
|
||||||
|
def audit_key(self) -> str:
|
||||||
|
"""审计唯一键(角色/资源/动作三元组,含授予者)。"""
|
||||||
|
return f"{self.role.value}:{self.resource.value}:{self.action.value}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Role:
|
||||||
|
"""一个角色:权限集合 + 继承的父角色。"""
|
||||||
|
|
||||||
|
kind: RoleKind
|
||||||
|
label: str # 中文展示名
|
||||||
|
permissions: List[Permission] = field(default_factory=list)
|
||||||
|
inherits: Optional[RoleKind] = None # 继承的低一级角色
|
||||||
|
description: str = "" # 角色职责说明(可解释性)
|
||||||
|
|
||||||
|
def permission_keys(self) -> Set[str]:
|
||||||
|
"""本角色直接授予的权限键集合。"""
|
||||||
|
return {p.key() for p in self.permissions}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
"""配置台用户。"""
|
||||||
|
|
||||||
|
username: str
|
||||||
|
role: RoleKind
|
||||||
|
display_name: str = ""
|
||||||
|
# 可选资源级收窄:即便角色允许,列表中的资源也会被额外限制为只读。
|
||||||
|
# 用于"只允许工程师改某几类配置"的细粒度场景。
|
||||||
|
restricted_to_readonly: List[Resource] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PermissionDecision:
|
||||||
|
"""``has_permission`` 的判定结果(带理由,可解释)。"""
|
||||||
|
|
||||||
|
allow: bool
|
||||||
|
reason: str # 人类可读的判定理由(允许/拒绝原因)
|
||||||
|
role: RoleKind
|
||||||
|
resource: Resource
|
||||||
|
action: Action
|
||||||
|
source: str = "explicit" # explicit(本角色直接授予)/ inherited(继承自父角色)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 角色注册表:三级权限矩阵(对齐 PRD ⑤.7「三级 RBAC」)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _build_role_registry() -> Dict[RoleKind, Role]:
|
||||||
|
"""构建三级角色及其权限矩阵。
|
||||||
|
|
||||||
|
权限设计依据(PRD ⑤.7):
|
||||||
|
- readonly:可查看所有配置/预览/历史,但不能改、不能发;
|
||||||
|
- engineer:在 readonly 基础上,可编辑/校验/导入四类业务配置,
|
||||||
|
但**发布/回滚/推送/用户管理仍归 admin**(避免未经评审上线);
|
||||||
|
- admin:在 engineer 基础上,可发布/回滚/推送 + 管理用户角色。
|
||||||
|
"""
|
||||||
|
ro = Role(
|
||||||
|
kind=RoleKind.READONLY,
|
||||||
|
label="只读",
|
||||||
|
description="实施/运维只读角色:查看配置、预览、历史版本,不可写。",
|
||||||
|
permissions=[
|
||||||
|
Permission(RoleKind.READONLY, Resource.POINT_DICT, Action.VIEW,
|
||||||
|
"查看点位字典与校验报告"),
|
||||||
|
Permission(RoleKind.READONLY, Resource.MODEL_PARAM, Action.VIEW,
|
||||||
|
"查看模型超参配置"),
|
||||||
|
Permission(RoleKind.READONLY, Resource.RAG_CONFIG, Action.VIEW,
|
||||||
|
"查看 RAG 知识库配置"),
|
||||||
|
Permission(RoleKind.READONLY, Resource.LAYOUT, Action.VIEW,
|
||||||
|
"查看驾驶舱布局配置"),
|
||||||
|
Permission(RoleKind.READONLY, Resource.PREVIEW, Action.VIEW,
|
||||||
|
"查看配置预览"),
|
||||||
|
Permission(RoleKind.READONLY, Resource.RELEASE, Action.VIEW,
|
||||||
|
"查看历史发布版本"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
engineer = Role(
|
||||||
|
kind=RoleKind.ENGINEER,
|
||||||
|
label="行业工程师",
|
||||||
|
inherits=RoleKind.READONLY,
|
||||||
|
description="行业工程师:编辑/校验/导入业务配置,但不能发布与推送。",
|
||||||
|
permissions=[
|
||||||
|
Permission(RoleKind.ENGINEER, Resource.POINT_DICT, Action.EDIT,
|
||||||
|
"导入/编辑/校验点位字典 CSV"),
|
||||||
|
Permission(RoleKind.ENGINEER, Resource.MODEL_PARAM, Action.EDIT,
|
||||||
|
"调整模型超参配置"),
|
||||||
|
Permission(RoleKind.ENGINEER, Resource.RAG_CONFIG, Action.EDIT,
|
||||||
|
"编辑 RAG 知识库配置"),
|
||||||
|
Permission(RoleKind.ENGINEER, Resource.LAYOUT, Action.EDIT,
|
||||||
|
"编辑驾驶舱布局配置"),
|
||||||
|
Permission(RoleKind.ENGINEER, Resource.PREVIEW, Action.VIEW,
|
||||||
|
"预览配置效果(编辑后必看)"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
admin = Role(
|
||||||
|
kind=RoleKind.ADMIN,
|
||||||
|
label="管理员",
|
||||||
|
inherits=RoleKind.ENGINEER,
|
||||||
|
description="管理员:在工程师基础上负责发布/回滚/推送与用户管理。",
|
||||||
|
permissions=[
|
||||||
|
Permission(RoleKind.ADMIN, Resource.RELEASE, Action.PUBLISH,
|
||||||
|
"发布新版本与回滚到历史版本"),
|
||||||
|
Permission(RoleKind.ADMIN, Resource.PUSH, Action.PUBLISH,
|
||||||
|
"把已发布配置推送给内核"),
|
||||||
|
Permission(RoleKind.ADMIN, Resource.USER, Action.MANAGE,
|
||||||
|
"管理用户与角色分配"),
|
||||||
|
Permission(RoleKind.ADMIN, Resource.POINT_DICT, Action.PUBLISH,
|
||||||
|
"确认点位字典上线(审批环节)"),
|
||||||
|
Permission(RoleKind.ADMIN, Resource.MODEL_PARAM, Action.PUBLISH,
|
||||||
|
"确认模型超参上线"),
|
||||||
|
Permission(RoleKind.ADMIN, Resource.LAYOUT, Action.PUBLISH,
|
||||||
|
"确认布局上线"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return {RoleKind.READONLY: ro, RoleKind.ENGINEER: engineer, RoleKind.ADMIN: admin}
|
||||||
|
|
||||||
|
|
||||||
|
_ROLES: Dict[RoleKind, Role] = _build_role_registry()
|
||||||
|
|
||||||
|
|
||||||
|
def get_role(kind: RoleKind) -> Role:
|
||||||
|
"""获取角色定义。"""
|
||||||
|
return _ROLES[kind]
|
||||||
|
|
||||||
|
|
||||||
|
def all_roles() -> List[Role]:
|
||||||
|
"""全部角色(按权限由低到高)。"""
|
||||||
|
return [_ROLES[RoleKind.READONLY], _ROLES[RoleKind.ENGINEER], _ROLES[RoleKind.ADMIN]]
|
||||||
|
|
||||||
|
|
||||||
|
def effective_permissions(kind: RoleKind) -> Set[str]:
|
||||||
|
"""角色有效权限键(含继承链)。
|
||||||
|
|
||||||
|
继承解析:admin 继承 engineer 继承 readonly,递归向上累计权限键。
|
||||||
|
"""
|
||||||
|
role = _ROLES[kind]
|
||||||
|
keys: Set[str] = set(role.permission_keys())
|
||||||
|
if role.inherits is not None:
|
||||||
|
keys |= effective_permissions(role.inherits)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 判定 API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def has_permission(
|
||||||
|
user: User,
|
||||||
|
resource: Resource,
|
||||||
|
action: Action,
|
||||||
|
) -> PermissionDecision:
|
||||||
|
"""判定用户对某资源执行某动作是否被允许(带理由)。
|
||||||
|
|
||||||
|
判定顺序:
|
||||||
|
1. 计算角色有效权限(含继承),命中即允许并标注来源(本角色/继承);
|
||||||
|
2. 命中后若该资源在用户 ``restricted_to_readonly`` 列表且动作是写动作,
|
||||||
|
则降级拒绝(细粒度收窄);
|
||||||
|
3. 未命中则拒绝,理由标注缺失的权限三元组。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user: 配置台用户;
|
||||||
|
resource: 目标资源;
|
||||||
|
action: 目标动作。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PermissionDecision:allow + reason(可直接展示给操作者)。
|
||||||
|
"""
|
||||||
|
target = f"{resource.value}:{action.value}"
|
||||||
|
eff = effective_permissions(user.role)
|
||||||
|
|
||||||
|
# 细粒度收窄:即便角色允许,特定资源也被限制为只读
|
||||||
|
if resource in user.restricted_to_readonly and action in _WRITE_ACTIONS:
|
||||||
|
return PermissionDecision(
|
||||||
|
allow=False,
|
||||||
|
reason=(f"用户 '{user.username}' 对资源 '{resource.value}' 被收窄为只读,"
|
||||||
|
f"禁止执行 '{action.value}' 动作"),
|
||||||
|
role=user.role, resource=resource, action=action, source="restricted",
|
||||||
|
)
|
||||||
|
|
||||||
|
if target in eff:
|
||||||
|
# 判定来源:本角色直接授予 or 继承自父角色
|
||||||
|
own = get_role(user.role).permission_keys()
|
||||||
|
source = "explicit" if target in own else "inherited"
|
||||||
|
src_label = "本角色直接授予" if source == "explicit" else "继承自低级角色"
|
||||||
|
return PermissionDecision(
|
||||||
|
allow=True,
|
||||||
|
reason=(f"用户 '{user.username}'({get_role(user.role).label})"
|
||||||
|
f"允许对 '{resource.value}' 执行 '{action.value}'({src_label})"),
|
||||||
|
role=user.role, resource=resource, action=action, source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
return PermissionDecision(
|
||||||
|
allow=False,
|
||||||
|
reason=(f"用户 '{user.username}'({get_role(user.role).label})缺少权限 "
|
||||||
|
f"{user.role.value}:{resource.value}:{action.value};"
|
||||||
|
f"该动作需更高角色或审批"),
|
||||||
|
role=user.role, resource=resource, action=action, source="denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def can_publish(user: User) -> bool:
|
||||||
|
"""便捷判定:用户是否具备发布(发布/回滚/推送)能力。"""
|
||||||
|
return has_permission(user, Resource.RELEASE, Action.PUBLISH).allow
|
||||||
|
|
||||||
|
|
||||||
|
def user_summary(user: User) -> Dict[str, object]:
|
||||||
|
"""用户权限概览(供配置台用户卡片/审计日志展示)。"""
|
||||||
|
role = get_role(user.role)
|
||||||
|
return {
|
||||||
|
"username": user.username,
|
||||||
|
"display_name": user.display_name or user.username,
|
||||||
|
"role": user.role.value,
|
||||||
|
"role_label": role.label,
|
||||||
|
"description": role.description,
|
||||||
|
"effective_permission_count": len(effective_permissions(user.role)),
|
||||||
|
"restricted_to_readonly": [r.value for r in user.restricted_to_readonly],
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""测试引导:把 `core/template-console` 以包名 `template_console` 挂载到 sys.modules。
|
||||||
|
|
||||||
|
目录名 `template-console` 含连字符,无法直接以包名 import;挂载后模块内
|
||||||
|
相对导入(`from .rbac import ...`)在 unittest 发现机制下可正常解析。
|
||||||
|
|
||||||
|
同时把兄弟内核目录 `core/edge-gateway` 加入 sys.path,使 point_importer
|
||||||
|
可复用其 `point_dict` 子包(loader/validator/schema),避免重复造轮子。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
# 1) 挂载 core/template-console 为 template_console 包
|
||||||
|
CONSOLE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
sys.path.insert(0, CONSOLE_DIR)
|
||||||
|
if "template_console" not in sys.modules:
|
||||||
|
pkg = types.ModuleType("template_console")
|
||||||
|
pkg.__path__ = [CONSOLE_DIR]
|
||||||
|
sys.modules["template_console"] = pkg
|
||||||
|
|
||||||
|
# 2) 暴露兄弟内核 edge-gateway/point_dict(#63 复用其校验器)
|
||||||
|
CORE_DIR = os.path.dirname(CONSOLE_DIR)
|
||||||
|
EDGE_GW_DIR = os.path.join(CORE_DIR, "edge-gateway")
|
||||||
|
if os.path.isdir(EDGE_GW_DIR) and EDGE_GW_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, EDGE_GW_DIR)
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""配置项 CRUD 存储引擎测试(issue #64)。
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
1. 三类配置 CRUD(list/get/upsert/delete);
|
||||||
|
2. 原子写 + 持久化(重开 store 仍在);
|
||||||
|
3. 校验规则(model_param/rag_config/layout,非法值拒绝);
|
||||||
|
4. 快照 snapshot/restore(为 #66 提供基础);
|
||||||
|
5. 可解释字段(meaning/reason/updated_by/updated_at 落盘)。
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import _bootstrap # noqa: F401
|
||||||
|
|
||||||
|
from template_console.config_store import ( # noqa: E402
|
||||||
|
ALLOWED_WIDGET_TYPES,
|
||||||
|
ConfigItem,
|
||||||
|
ConfigKind,
|
||||||
|
ConfigStore,
|
||||||
|
ValidationResult,
|
||||||
|
validate_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _TmpStore:
|
||||||
|
def __init__(self):
|
||||||
|
self._tmp = tempfile.mkdtemp()
|
||||||
|
self.store = ConfigStore(self._tmp)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationTest(unittest.TestCase):
|
||||||
|
"""校验规则。"""
|
||||||
|
|
||||||
|
def test_model_param_scalar_ok(self):
|
||||||
|
self.assertTrue(validate_item(ConfigKind.MODEL_PARAM, "learning_rate", 0.001))
|
||||||
|
|
||||||
|
def test_model_param_learning_rate_range(self):
|
||||||
|
vr = validate_item(ConfigKind.MODEL_PARAM, "learning_rate", 1.5)
|
||||||
|
self.assertFalse(vr)
|
||||||
|
self.assertTrue(any("learning_rate" in e for e in vr.errors))
|
||||||
|
|
||||||
|
def test_model_param_bad_key(self):
|
||||||
|
vr = validate_item(ConfigKind.MODEL_PARAM, "Bad Key!", 1)
|
||||||
|
self.assertFalse(vr)
|
||||||
|
|
||||||
|
def test_rag_top_k_bounds(self):
|
||||||
|
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "top_k", 0))
|
||||||
|
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "top_k", 51))
|
||||||
|
self.assertTrue(validate_item(ConfigKind.RAG_CONFIG, "top_k", 10))
|
||||||
|
|
||||||
|
def test_rag_similarity_threshold(self):
|
||||||
|
self.assertTrue(validate_item(ConfigKind.RAG_CONFIG, "similarity_threshold", 0.5))
|
||||||
|
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "similarity_threshold", 1.5))
|
||||||
|
|
||||||
|
def test_rag_sources_must_be_nonempty_list(self):
|
||||||
|
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "sources", []))
|
||||||
|
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "sources", ["", "x"]))
|
||||||
|
self.assertTrue(validate_item(ConfigKind.RAG_CONFIG, "sources", ["sop", "gb"]))
|
||||||
|
|
||||||
|
def test_layout_widget_type(self):
|
||||||
|
bad = [{"type": "unknown", "x": 0, "y": 0, "w": 1, "h": 1}]
|
||||||
|
self.assertFalse(validate_item(ConfigKind.LAYOUT, "dashboard", bad))
|
||||||
|
good = [{"type": "trend", "x": 0, "y": 0, "w": 6, "h": 2}]
|
||||||
|
self.assertTrue(validate_item(ConfigKind.LAYOUT, "dashboard", good))
|
||||||
|
|
||||||
|
def test_layout_widget_coords_nonneg_int(self):
|
||||||
|
bad = [{"type": "trend", "x": -1, "y": 0, "w": 1, "h": 1}]
|
||||||
|
vr = validate_item(ConfigKind.LAYOUT, "dashboard", bad)
|
||||||
|
self.assertFalse(vr)
|
||||||
|
|
||||||
|
|
||||||
|
class CrudTest(unittest.TestCase):
|
||||||
|
"""CRUD + 持久化。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.ctx = _TmpStore()
|
||||||
|
self.store = self.ctx.store
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.ctx.cleanup()
|
||||||
|
|
||||||
|
def test_upsert_and_get(self):
|
||||||
|
self.store.upsert(ConfigKind.MODEL_PARAM, "iterations", 100,
|
||||||
|
meaning="迭代数", updated_by="li", reason="标定")
|
||||||
|
it = self.store.get(ConfigKind.MODEL_PARAM, "iterations")
|
||||||
|
self.assertIsNotNone(it)
|
||||||
|
self.assertEqual(it.value, 100)
|
||||||
|
self.assertEqual(it.updated_by, "li")
|
||||||
|
self.assertEqual(it.reason, "标定")
|
||||||
|
self.assertTrue(it.updated_at) # 时间戳已写
|
||||||
|
|
||||||
|
def test_upsert_rejects_invalid(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 999)
|
||||||
|
|
||||||
|
def test_upsert_overwrites(self):
|
||||||
|
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.1)
|
||||||
|
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.01, reason="调小")
|
||||||
|
it = self.store.get(ConfigKind.MODEL_PARAM, "lr")
|
||||||
|
self.assertEqual(it.value, 0.01)
|
||||||
|
self.assertEqual(it.reason, "调小")
|
||||||
|
|
||||||
|
def test_list_and_delete(self):
|
||||||
|
self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 5)
|
||||||
|
self.store.upsert(ConfigKind.RAG_CONFIG, "similarity_threshold", 0.6)
|
||||||
|
self.assertEqual(len(self.store.list(ConfigKind.RAG_CONFIG)), 2)
|
||||||
|
self.assertTrue(self.store.delete(ConfigKind.RAG_CONFIG, "top_k"))
|
||||||
|
self.assertIsNone(self.store.get(ConfigKind.RAG_CONFIG, "top_k"))
|
||||||
|
self.assertFalse(self.store.delete(ConfigKind.RAG_CONFIG, "nope"))
|
||||||
|
|
||||||
|
def test_persistence_across_reopen(self):
|
||||||
|
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
|
||||||
|
# 重开一个指向同一目录的 store
|
||||||
|
store2 = ConfigStore(self.ctx._tmp)
|
||||||
|
it = store2.get(ConfigKind.MODEL_PARAM, "lr")
|
||||||
|
self.assertIsNotNone(it)
|
||||||
|
self.assertEqual(it.value, 0.001)
|
||||||
|
|
||||||
|
def test_json_file_is_human_readable(self):
|
||||||
|
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001, meaning="学习率")
|
||||||
|
path = os.path.join(self.ctx._tmp, "model_params.json")
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
blob = json.load(fh)
|
||||||
|
self.assertEqual(blob["schema_version"], 1)
|
||||||
|
self.assertEqual(blob["kind"], "model_param")
|
||||||
|
self.assertEqual(blob["items"][0]["meaning"], "学习率")
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotTest(unittest.TestCase):
|
||||||
|
"""快照与恢复(#66 基础)。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.ctx = _TmpStore()
|
||||||
|
self.store = self.ctx.store
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.ctx.cleanup()
|
||||||
|
|
||||||
|
def test_snapshot_captures_all_kinds(self):
|
||||||
|
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
|
||||||
|
self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 8)
|
||||||
|
snap = self.store.snapshot()
|
||||||
|
self.assertIn("captured_at", snap)
|
||||||
|
self.assertEqual(set(snap["kinds"].keys()),
|
||||||
|
{"model_param", "rag_config", "layout"})
|
||||||
|
self.assertEqual(len(snap["kinds"]["model_param"]), 1)
|
||||||
|
|
||||||
|
def test_restore_replicates_state(self):
|
||||||
|
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
|
||||||
|
self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 8)
|
||||||
|
snap = self.store.snapshot()
|
||||||
|
# 清空再恢复
|
||||||
|
self.store.delete(ConfigKind.MODEL_PARAM, "lr")
|
||||||
|
self.store.delete(ConfigKind.RAG_CONFIG, "top_k")
|
||||||
|
self.store.restore(snap)
|
||||||
|
self.assertEqual(self.store.get(ConfigKind.MODEL_PARAM, "lr").value, 0.001)
|
||||||
|
self.assertEqual(self.store.get(ConfigKind.RAG_CONFIG, "top_k").value, 8)
|
||||||
|
|
||||||
|
def test_item_counts(self):
|
||||||
|
self.store.upsert(ConfigKind.LAYOUT, "dashboard",
|
||||||
|
[{"type": "trend", "x": 0, "y": 0, "w": 6, "h": 2}])
|
||||||
|
counts = self.store.item_counts()
|
||||||
|
self.assertEqual(counts["layout"], 1)
|
||||||
|
self.assertEqual(counts["model_param"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigItemSerializationTest(unittest.TestCase):
|
||||||
|
"""ConfigItem 序列化往返。"""
|
||||||
|
|
||||||
|
def test_roundtrip(self):
|
||||||
|
it = ConfigItem(key="lr", value=0.1, kind=ConfigKind.MODEL_PARAM,
|
||||||
|
meaning="学习率", updated_by="li", reason="init",
|
||||||
|
updated_at="2026-01-01T00:00:00Z")
|
||||||
|
d = it.to_dict()
|
||||||
|
self.assertEqual(d["kind"], "model_param")
|
||||||
|
it2 = ConfigItem.from_dict(d)
|
||||||
|
self.assertEqual(it2.value, 0.1)
|
||||||
|
self.assertEqual(it2.kind, ConfigKind.MODEL_PARAM)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""点位字典 CSV 导入 + 自动校验测试(issue #63)。
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
1. 合法 CSV 导入通过(ti / resin 两套模板);
|
||||||
|
2. 表头校验(缺失列 / 列序错位);
|
||||||
|
3. 内核校验复用(量纲/数据类型/采样率/重复点号/协议);
|
||||||
|
4. OPC 节点格式校验(opcua/modbus/空);
|
||||||
|
5. 模板级量纲收窄(rpm 仅 resin 允许);
|
||||||
|
6. 报告 ok/汇总/字典化 + 粘贴框入口。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import _bootstrap # noqa: F401
|
||||||
|
|
||||||
|
from template_console.point_importer import ( # noqa: E402
|
||||||
|
ImportReport,
|
||||||
|
ImportRowIssue,
|
||||||
|
Severity,
|
||||||
|
TemplateKind,
|
||||||
|
import_csv,
|
||||||
|
import_csv_string,
|
||||||
|
)
|
||||||
|
|
||||||
|
GOOD_TI = """device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol
|
||||||
|
CLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp,opcua
|
||||||
|
CLF-01,CLF-01.PRES,炉压,kPa,float,1000,true,ns=2;s=CLF.Pres,opcua
|
||||||
|
"""
|
||||||
|
|
||||||
|
GOOD_RESIN = """device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol
|
||||||
|
R-801,R-801.TEMP,反应釜温度,℃,float,1000,true,ns=2;s=R801.Temp,opcua
|
||||||
|
R-801,R-801.AGIT,搅拌转速,rpm,float,1000,true,ns=2;s=R801.Agit,opcua
|
||||||
|
"""
|
||||||
|
|
||||||
|
BAD_MULTI = """device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol
|
||||||
|
CLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,badnode,opcua
|
||||||
|
CLF-01,CLF-01.TEMP,炉压,kPa,badtype,0,true,ns=2;s=CLF.Pres,opcua
|
||||||
|
CLF-01,CLF-01.PRES,炉压,kPa,float,500,true,holding:40010,modbus
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class _TmpCsv:
|
||||||
|
"""临时 CSV 文件助手。"""
|
||||||
|
|
||||||
|
def __init__(self, content):
|
||||||
|
self._tmp = tempfile.mkdtemp()
|
||||||
|
self.path = os.path.join(self._tmp, "points.csv")
|
||||||
|
with open(self.path, "w", encoding="utf-8") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
import shutil
|
||||||
|
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
class GoodImportTest(unittest.TestCase):
|
||||||
|
"""合法 CSV 导入。"""
|
||||||
|
|
||||||
|
def test_good_ti_imports_ok(self):
|
||||||
|
f = _TmpCsv(GOOD_TI)
|
||||||
|
try:
|
||||||
|
pd, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
self.assertTrue(rep.ok, rep.summary())
|
||||||
|
self.assertEqual(rep.loaded_points, 2)
|
||||||
|
self.assertEqual(rep.error_count, 0)
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
def test_good_resin_imports_ok_with_rpm(self):
|
||||||
|
f = _TmpCsv(GOOD_RESIN)
|
||||||
|
try:
|
||||||
|
pd, rep = import_csv(f.path, template=TemplateKind.RESIN)
|
||||||
|
self.assertTrue(rep.ok, rep.summary())
|
||||||
|
# rpm 在 resin 模板合法
|
||||||
|
self.assertEqual(rep.error_count, 0)
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
def test_report_summary_and_dict(self):
|
||||||
|
f = _TmpCsv(GOOD_TI)
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
self.assertIn("通过", rep.summary())
|
||||||
|
d = rep.to_dict()
|
||||||
|
self.assertTrue(d["ok"])
|
||||||
|
self.assertEqual(d["template"], "ti")
|
||||||
|
self.assertEqual(d["loaded_points"], 2)
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class HeaderValidationTest(unittest.TestCase):
|
||||||
|
"""表头校验。"""
|
||||||
|
|
||||||
|
def test_missing_column_is_error(self):
|
||||||
|
bad = "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp\n"
|
||||||
|
f = _TmpCsv(bad)
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
self.assertFalse(rep.ok)
|
||||||
|
codes = [i.code for i in rep.issues if i.row == 1]
|
||||||
|
self.assertIn("missing_column", codes)
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
def test_wrong_column_order_is_warn(self):
|
||||||
|
# 列齐全但顺序错(name 提前)→ WARN,不阻断
|
||||||
|
bad = "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,protocol,opcNode\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,opcua,ns=2;s=CLF.Temp\n"
|
||||||
|
f = _TmpCsv(bad)
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
self.assertIn("bad_column_order", [i.code for i in rep.issues])
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class KernelValidationTest(unittest.TestCase):
|
||||||
|
"""复用内核校验(量纲/数据类型/采样率/重复点号)。"""
|
||||||
|
|
||||||
|
def test_dup_point_detected(self):
|
||||||
|
bad = GOOD_TI + "CLF-01,CLF-01.TEMP,炉温2,℃,float,1000,true,ns=2;s=CLF.Temp2,opcua\n"
|
||||||
|
f = _TmpCsv(bad)
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
self.assertFalse(rep.ok)
|
||||||
|
self.assertIn("dup_point", [i.code for i in rep.issues])
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
def test_bad_data_type_and_sample_rate(self):
|
||||||
|
f = _TmpCsv(BAD_MULTI)
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
codes = [i.code for i in rep.issues]
|
||||||
|
self.assertIn("bad_data_type", codes)
|
||||||
|
self.assertIn("bad_sample_rate", codes)
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
def test_bad_protocol_detected(self):
|
||||||
|
bad = "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp,unknownproto\n"
|
||||||
|
f = _TmpCsv(bad)
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
self.assertIn("bad_protocol", [i.code for i in rep.issues])
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class OpcNodeValidationTest(unittest.TestCase):
|
||||||
|
"""OPC 节点格式校验(配置台扩展维度)。"""
|
||||||
|
|
||||||
|
def test_bad_opcua_node_is_error(self):
|
||||||
|
# BAD_MULTI 第1行 opcNode=badnode 协议 opcua → ERROR
|
||||||
|
f = _TmpCsv(BAD_MULTI)
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
opc_issues = [i for i in rep.issues if i.code == "bad_opc_node"]
|
||||||
|
self.assertTrue(any(i.severity == Severity.ERROR for i in opc_issues))
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
def test_valid_modbus_node_ok(self):
|
||||||
|
# BAD_MULTI 第3行 holding:40010 modbus → 不报 bad_opc_node
|
||||||
|
f = _TmpCsv("device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\nCLF-01,CLF-01.PRES,炉压,kPa,float,500,true,holding:40010,modbus\n")
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
self.assertNotIn("bad_opc_node", [i.code for i in rep.issues
|
||||||
|
if i.severity == Severity.ERROR])
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
def test_empty_opc_node_is_warn(self):
|
||||||
|
f = _TmpCsv("device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,,simulator\n")
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
empties = [i for i in rep.issues if i.code == "empty_opc_node"]
|
||||||
|
self.assertEqual(len(empties), 1)
|
||||||
|
self.assertEqual(empties[0].severity, Severity.WARN)
|
||||||
|
# 警告不阻断
|
||||||
|
self.assertTrue(rep.ok)
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateUnitTest(unittest.TestCase):
|
||||||
|
"""模板级量纲收窄。"""
|
||||||
|
|
||||||
|
def test_rpm_rejected_in_ti_template(self):
|
||||||
|
f = _TmpCsv(GOOD_RESIN)
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||||
|
# rpm 是树脂专属,ti 模板应报 template_unit_mismatch
|
||||||
|
self.assertIn("template_unit_mismatch", [i.code for i in rep.issues])
|
||||||
|
self.assertFalse(rep.ok)
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
def test_rpm_allowed_in_resin_template(self):
|
||||||
|
f = _TmpCsv(GOOD_RESIN)
|
||||||
|
try:
|
||||||
|
_, rep = import_csv(f.path, template=TemplateKind.RESIN)
|
||||||
|
self.assertNotIn("template_unit_mismatch", [i.code for i in rep.issues])
|
||||||
|
self.assertTrue(rep.ok, rep.summary())
|
||||||
|
finally:
|
||||||
|
f.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class ImportStringTest(unittest.TestCase):
|
||||||
|
"""粘贴框入口(import_csv_string)。"""
|
||||||
|
|
||||||
|
def test_import_from_string(self):
|
||||||
|
pd, rep = import_csv_string(GOOD_TI, template=TemplateKind.TI)
|
||||||
|
self.assertTrue(rep.ok)
|
||||||
|
self.assertEqual(len(pd), 2)
|
||||||
|
|
||||||
|
def test_import_string_bad_csv(self):
|
||||||
|
bad = "device_id,point_id\nCLF-01,CLF-01.TEMP\n" # 缺列
|
||||||
|
_, rep = import_csv_string(bad, template=TemplateKind.TI)
|
||||||
|
self.assertFalse(rep.ok)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""三级 RBAC 权限模型测试(issue #62)。
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
1. 三级角色权限矩阵正确(readonly/engineer/admin);
|
||||||
|
2. 角色继承(admin 继承 engineer 继承 readonly);
|
||||||
|
3. has_permission 允许/拒绝判定 + 理由可解释;
|
||||||
|
4. 细粒度收窄(restricted_to_readonly 把写动作降级拒绝);
|
||||||
|
5. 便捷判定 can_publish / 用户概览。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import _bootstrap # noqa: F401
|
||||||
|
|
||||||
|
from template_console.rbac import ( # noqa: E402
|
||||||
|
Action,
|
||||||
|
Permission,
|
||||||
|
Resource,
|
||||||
|
Role,
|
||||||
|
RoleKind,
|
||||||
|
User,
|
||||||
|
all_roles,
|
||||||
|
can_publish,
|
||||||
|
effective_permissions,
|
||||||
|
get_role,
|
||||||
|
has_permission,
|
||||||
|
user_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleRegistryTest(unittest.TestCase):
|
||||||
|
"""三级角色注册表。"""
|
||||||
|
|
||||||
|
def test_three_roles_present(self):
|
||||||
|
roles = {r.kind for r in all_roles()}
|
||||||
|
self.assertEqual(roles, {RoleKind.READONLY, RoleKind.ENGINEER, RoleKind.ADMIN})
|
||||||
|
|
||||||
|
def test_role_labels_in_chinese(self):
|
||||||
|
self.assertEqual(get_role(RoleKind.READONLY).label, "只读")
|
||||||
|
self.assertEqual(get_role(RoleKind.ENGINEER).label, "行业工程师")
|
||||||
|
self.assertEqual(get_role(RoleKind.ADMIN).label, "管理员")
|
||||||
|
|
||||||
|
def test_role_descriptions_explainable(self):
|
||||||
|
# 可解释性:每个角色都有职责说明
|
||||||
|
for role in all_roles():
|
||||||
|
self.assertTrue(role.description, f"{role.kind} 缺少 description")
|
||||||
|
|
||||||
|
def test_inheritance_chain(self):
|
||||||
|
self.assertEqual(get_role(RoleKind.ADMIN).inherits, RoleKind.ENGINEER)
|
||||||
|
self.assertEqual(get_role(RoleKind.ENGINEER).inherits, RoleKind.READONLY)
|
||||||
|
self.assertIsNone(get_role(RoleKind.READONLY).inherits)
|
||||||
|
|
||||||
|
def test_permission_key_format(self):
|
||||||
|
p = Permission(RoleKind.ADMIN, Resource.USER, Action.MANAGE)
|
||||||
|
# 匹配键为资源:动作(角色无关,便于继承);审计键含授予角色
|
||||||
|
self.assertEqual(p.key(), "user:manage")
|
||||||
|
self.assertEqual(p.audit_key(), "admin:user:manage")
|
||||||
|
|
||||||
|
|
||||||
|
class EffectivePermissionTest(unittest.TestCase):
|
||||||
|
"""继承后的有效权限集合。"""
|
||||||
|
|
||||||
|
def test_admin_inherits_engineer_and_readonly(self):
|
||||||
|
eff = effective_permissions(RoleKind.ADMIN)
|
||||||
|
# 匹配键为 resource:action:admin 拥有自身的 user:manage,
|
||||||
|
# 也继承 engineer 的 model_param:edit 与 readonly 的 layout:view
|
||||||
|
self.assertIn("user:manage", eff)
|
||||||
|
self.assertIn("model_param:edit", eff)
|
||||||
|
self.assertIn("layout:view", eff)
|
||||||
|
|
||||||
|
def test_engineer_cannot_publish(self):
|
||||||
|
eff = effective_permissions(RoleKind.ENGINEER)
|
||||||
|
# 工程师不能发布/推送/管用户
|
||||||
|
self.assertNotIn("release:publish", eff)
|
||||||
|
self.assertNotIn("push:publish", eff)
|
||||||
|
self.assertNotIn("user:manage", eff)
|
||||||
|
|
||||||
|
def test_readonly_has_no_write(self):
|
||||||
|
eff = effective_permissions(RoleKind.READONLY)
|
||||||
|
for key in eff:
|
||||||
|
# 只读权限只能以 :view 结尾
|
||||||
|
self.assertTrue(key.endswith(":view"), f"readonly 不应有写/发布权限: {key}")
|
||||||
|
|
||||||
|
|
||||||
|
class HasPermissionTest(unittest.TestCase):
|
||||||
|
"""has_permission 判定 + 理由。"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.ro = User("viewer", RoleKind.READONLY, "查看员")
|
||||||
|
self.eng = User("li_engineer", RoleKind.ENGINEER, "李工")
|
||||||
|
self.admin = User("root_admin", RoleKind.ADMIN, "管理员甲")
|
||||||
|
|
||||||
|
def test_readonly_view_allowed(self):
|
||||||
|
d = has_permission(self.ro, Resource.LAYOUT, Action.VIEW)
|
||||||
|
self.assertTrue(d.allow)
|
||||||
|
self.assertEqual(d.source, "explicit")
|
||||||
|
|
||||||
|
def test_readonly_edit_denied(self):
|
||||||
|
d = has_permission(self.ro, Resource.LAYOUT, Action.EDIT)
|
||||||
|
self.assertFalse(d.allow)
|
||||||
|
self.assertIn("缺少权限", d.reason)
|
||||||
|
|
||||||
|
def test_engineer_edit_allowed_inherited_view(self):
|
||||||
|
# 工程师编辑是本角色权限(explicit)
|
||||||
|
d_edit = has_permission(self.eng, Resource.LAYOUT, Action.EDIT)
|
||||||
|
self.assertTrue(d_edit.allow)
|
||||||
|
self.assertEqual(d_edit.source, "explicit")
|
||||||
|
# 工程师查看布局是继承自 readonly(inherited)
|
||||||
|
d_view = has_permission(self.eng, Resource.LAYOUT, Action.VIEW)
|
||||||
|
self.assertTrue(d_view.allow)
|
||||||
|
self.assertEqual(d_view.source, "inherited")
|
||||||
|
|
||||||
|
def test_engineer_publish_denied(self):
|
||||||
|
d = has_permission(self.eng, Resource.RELEASE, Action.PUBLISH)
|
||||||
|
self.assertFalse(d.allow)
|
||||||
|
|
||||||
|
def test_admin_publish_allowed(self):
|
||||||
|
d = has_permission(self.admin, Resource.RELEASE, Action.PUBLISH)
|
||||||
|
self.assertTrue(d.allow)
|
||||||
|
self.assertEqual(d.source, "explicit")
|
||||||
|
|
||||||
|
def test_admin_inherited_engineer_edit(self):
|
||||||
|
d = has_permission(self.admin, Resource.MODEL_PARAM, Action.EDIT)
|
||||||
|
self.assertTrue(d.allow)
|
||||||
|
self.assertEqual(d.source, "inherited")
|
||||||
|
|
||||||
|
def test_decision_carries_reason(self):
|
||||||
|
# 可解释性:无论允许/拒绝,reason 非空且含用户名与资源
|
||||||
|
for user in (self.ro, self.eng, self.admin):
|
||||||
|
d = has_permission(user, Resource.PUSH, Action.PUBLISH)
|
||||||
|
self.assertIn(user.username, d.reason)
|
||||||
|
self.assertIn(Resource.PUSH.value, d.reason)
|
||||||
|
|
||||||
|
|
||||||
|
class RestrictedUserTest(unittest.TestCase):
|
||||||
|
"""细粒度收窄:restricted_to_readonly。"""
|
||||||
|
|
||||||
|
def test_restricted_engineer_cannot_edit_that_resource(self):
|
||||||
|
# 工程师本可编辑布局,但被收窄为只读后应拒绝
|
||||||
|
u = User("limited", RoleKind.ENGINEER, "受限工程师",
|
||||||
|
restricted_to_readonly=[Resource.LAYOUT])
|
||||||
|
d = has_permission(u, Resource.LAYOUT, Action.EDIT)
|
||||||
|
self.assertFalse(d.allow)
|
||||||
|
self.assertEqual(d.source, "restricted")
|
||||||
|
|
||||||
|
def test_restricted_engineer_can_still_view(self):
|
||||||
|
u = User("limited", RoleKind.ENGINEER, "受限工程师",
|
||||||
|
restricted_to_readonly=[Resource.LAYOUT])
|
||||||
|
d = has_permission(u, Resource.LAYOUT, Action.VIEW)
|
||||||
|
self.assertTrue(d.allow)
|
||||||
|
|
||||||
|
def test_restricted_only_affects_named_resource(self):
|
||||||
|
u = User("limited", RoleKind.ENGINEER, "受限工程师",
|
||||||
|
restricted_to_readonly=[Resource.LAYOUT])
|
||||||
|
# 模型超参未被收窄,仍可编辑
|
||||||
|
d = has_permission(u, Resource.MODEL_PARAM, Action.EDIT)
|
||||||
|
self.assertTrue(d.allow)
|
||||||
|
|
||||||
|
|
||||||
|
class ConvenienceTest(unittest.TestCase):
|
||||||
|
"""便捷判定与用户概览。"""
|
||||||
|
|
||||||
|
def test_can_publish(self):
|
||||||
|
self.assertFalse(can_publish(User("v", RoleKind.READONLY)))
|
||||||
|
self.assertFalse(can_publish(User("e", RoleKind.ENGINEER)))
|
||||||
|
self.assertTrue(can_publish(User("a", RoleKind.ADMIN)))
|
||||||
|
|
||||||
|
def test_user_summary(self):
|
||||||
|
s = user_summary(User("li", RoleKind.ENGINEER, "李工"))
|
||||||
|
self.assertEqual(s["username"], "li")
|
||||||
|
self.assertEqual(s["role"], "engineer")
|
||||||
|
self.assertEqual(s["role_label"], "行业工程师")
|
||||||
|
self.assertGreater(s["effective_permission_count"], 0)
|
||||||
|
self.assertEqual(s["restricted_to_readonly"], [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# 海绵钛驾驶舱布局资产 + 校验器(Issue #55 / PRD 5.5)
|
||||||
|
|
||||||
|
> 父 Issue「⑤ Ti 行业布局模板(四状态流程视图)· 0.5d」
|
||||||
|
|
||||||
|
把海绵钛车间驾驶舱布局落为**对齐 iAOP-cockpit-layout-v1 的资产 + 可校验的纯标准库
|
||||||
|
校验器**(无 node/前端构建环境,零运行时依赖)。
|
||||||
|
|
||||||
|
## 资产:`cockpit.ti.yaml`
|
||||||
|
|
||||||
|
四状态工艺流程(PRD 4.2 海绵钛:**氯化 → 精制 → 还原 → 蒸馏**):
|
||||||
|
|
||||||
|
- `process_view` 主视图(首屏立即加载),`stages` 声明四状态覆盖;
|
||||||
|
- `trend` 实时趋势(氯化炉温度 `CLF-01.TEMP` / 氯气流量 `CLF-01.CL2`);
|
||||||
|
- `kpi_card` KPI(TiCl₄纯度 `RF-01.PURITY` / 杂质 `RF-01.IMP` / 电耗 `E-01.KWH` / 蒸汽 `ST-01.STEAM`);
|
||||||
|
- `alarm_panel` 告警面板 + `nl_query` NL 查询入口。
|
||||||
|
|
||||||
|
所有 `bind` 的 `point_id` 对齐 `templates/ti-cl4/point-dict/point_dict.default.csv`。
|
||||||
|
|
||||||
|
## 校验器:`layout_validator.py`
|
||||||
|
|
||||||
|
`LayoutValidator(layout_yaml, point_dict_csv).validate()` → `LayoutReport`,校验:
|
||||||
|
|
||||||
|
1. **widget 类型合法**:在 `iAOP-cockpit-layout-v1` 允许集合内(process_view/trend/kpi_card/alarm_panel/nl_query);
|
||||||
|
2. **12 列网格不越界**:`0 ≤ x`、`x + w ≤ 12`、`y ≥ 0`、`w/h > 0`;
|
||||||
|
3. **bind point_id 在点位字典内**:防模板漂移(trend/kpi_card 的 bind 必须命中点字典);
|
||||||
|
4. **四状态覆盖完整**:process_view 的 stages 必须覆盖氯化/精制/还原/蒸馏,order 单调递增、id 唯一。
|
||||||
|
|
||||||
|
零依赖 YAML 子集解析(复制 impurity-forecast 的 `_parse_yaml_subset`,无 pyyaml)。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m unittest discover -s templates/ti-cl4/dashboard/tests -p "test_*.py" -v
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖正常 + 边界 + 错误(18 用例):真实资产端到端通过、非法类型、网格越界/负坐标/零宽、
|
||||||
|
bind 漂移、缺 process_view、缺必需状态、order 非单调、stage 重复、$schema 头、CSV 加载、空布局。
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""海绵钛驾驶舱布局资产 + 校验器包(Issue #55)。
|
||||||
|
|
||||||
|
对齐 PRD 5.5「⑤ 配置化驾驶舱」布局 JSON Schema(iAOP-cockpit-layout-v1):
|
||||||
|
四状态工艺流程(氯化 → 精制 → 还原 → 蒸馏)。
|
||||||
|
"""
|
||||||
|
from .layout_validator import (
|
||||||
|
LayoutError,
|
||||||
|
LayoutIssue,
|
||||||
|
LayoutReport,
|
||||||
|
LayoutValidator,
|
||||||
|
WidgetSpec,
|
||||||
|
ALLOWED_WIDGET_TYPES,
|
||||||
|
GRID_COLUMNS,
|
||||||
|
REQUIRED_STAGES,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LayoutError",
|
||||||
|
"LayoutIssue",
|
||||||
|
"LayoutReport",
|
||||||
|
"LayoutValidator",
|
||||||
|
"WidgetSpec",
|
||||||
|
"ALLOWED_WIDGET_TYPES",
|
||||||
|
"GRID_COLUMNS",
|
||||||
|
"REQUIRED_STAGES",
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""海绵钛驾驶舱布局冒烟脚本(Issue #55)。
|
||||||
|
|
||||||
|
直接运行 ``python _sanity_check.py`` 验证:cockpit.ti.yaml + point_dict.default.csv
|
||||||
|
端到端校验通过(widget 类型/网格/bind/四状态全覆盖)。零第三方依赖。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, HERE)
|
||||||
|
|
||||||
|
from layout_validator import LayoutValidator # noqa: E402
|
||||||
|
|
||||||
|
LAYOUT_YAML = os.path.join(HERE, "cockpit.ti.yaml")
|
||||||
|
POINT_DICT_CSV = os.path.join(
|
||||||
|
HERE, os.pardir, "point-dict", "point_dict.default.csv")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
report = LayoutValidator(LAYOUT_YAML, POINT_DICT_CSV).validate()
|
||||||
|
if not report.passed:
|
||||||
|
print("FAIL")
|
||||||
|
for issue in report.errors:
|
||||||
|
print(f" - [{issue.widget_id}] {issue.field}: {issue.reason}")
|
||||||
|
return 1
|
||||||
|
print(f"OK: {report.widget_count} widgets,"
|
||||||
|
f"类型/网格/bind/四状态校验通过")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# 海绵钛(Ti)车间驾驶舱布局资产(iAOP-Template-Ti,EPIC #12)。
|
||||||
|
#
|
||||||
|
# 对齐 PRD 5.5「⑤ 配置化驾驶舱」布局 JSON Schema(iAOP-cockpit-layout-v1):
|
||||||
|
# 切换行业模板后,驾驶舱按本布局自动重排,无需改前端代码。
|
||||||
|
# widget 类型:process_view(工艺流程视图)/ trend(实时趋势)/ kpi_card(KPI卡片)/
|
||||||
|
# alarm_panel(告警面板)/ nl_query(NL查询入口)。
|
||||||
|
#
|
||||||
|
# 四状态工艺流程(PRD 4.2 海绵钛:氯化 → 精制 → 还原 → 蒸馏):
|
||||||
|
# - 氯化 (CLF-01):TiO₂ + Cl₂ + C → TiCl₄(沸腾氯化炉)
|
||||||
|
# - 精制 :粗 TiCl₄ → 精 TiCl₄(除钒/除硅,常压精馏)
|
||||||
|
# - 还原 (RF-01):TiCl₄ + Mg → 海绵钛(真空还原,Kroll 法)
|
||||||
|
# - 蒸馏 :海绵钛 + 残余 Mg/MgCl₂ 分离(真空蒸馏)
|
||||||
|
# process_view 的 stages 字段声明四状态覆盖,layout_validator 校验完整性。
|
||||||
|
#
|
||||||
|
# bind 的 point_id 对齐 templates/ti-cl4/point-dict/point_dict.default.csv。
|
||||||
|
$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: 氯化
|
||||||
|
device: CLF-01
|
||||||
|
order: 1
|
||||||
|
- id: purification
|
||||||
|
name: 精制
|
||||||
|
order: 2
|
||||||
|
- id: reduction
|
||||||
|
name: 还原
|
||||||
|
device: RF-01
|
||||||
|
order: 3
|
||||||
|
- id: distillation
|
||||||
|
name: 蒸馏
|
||||||
|
order: 4
|
||||||
|
# ---- 实时趋势:氯化炉温度/氯气流量(工艺核心监控) -------------------
|
||||||
|
- type: trend
|
||||||
|
bind: CLF-01.TEMP
|
||||||
|
x: 0
|
||||||
|
y: 4
|
||||||
|
w: 6
|
||||||
|
h: 2
|
||||||
|
description: 氯化炉温度实时趋势(沸腾氯化炉温 850±50℃)
|
||||||
|
- type: trend
|
||||||
|
bind: CLF-01.CL2
|
||||||
|
x: 6
|
||||||
|
y: 4
|
||||||
|
w: 6
|
||||||
|
h: 2
|
||||||
|
description: 氯气流量实时趋势(流态化监控)
|
||||||
|
# ---- KPI 卡片:还原质量/能耗(海绵钛核心指标) -----------------------
|
||||||
|
- type: kpi_card
|
||||||
|
metric: ticl4_purity
|
||||||
|
bind: RF-01.PURITY
|
||||||
|
label: TiCl₄纯度
|
||||||
|
x: 0
|
||||||
|
y: 6
|
||||||
|
w: 3
|
||||||
|
h: 2
|
||||||
|
description: 还原 TiCl₄ 纯度(%,工艺 ≥ 99.9%)
|
||||||
|
- type: kpi_card
|
||||||
|
metric: ticl4_impurity
|
||||||
|
bind: RF-01.IMP
|
||||||
|
label: 杂质含量
|
||||||
|
x: 3
|
||||||
|
y: 6
|
||||||
|
w: 3
|
||||||
|
h: 2
|
||||||
|
description: 还原杂质含量(%,越低越好)
|
||||||
|
- type: kpi_card
|
||||||
|
metric: energy_per_ton
|
||||||
|
bind: E-01.KWH
|
||||||
|
label: 累计电耗
|
||||||
|
x: 6
|
||||||
|
y: 6
|
||||||
|
w: 3
|
||||||
|
h: 2
|
||||||
|
description: 车间累计电耗(kWh,单吨海绵钛综合能耗输入)
|
||||||
|
- type: kpi_card
|
||||||
|
metric: steam_flow
|
||||||
|
bind: ST-01.STEAM
|
||||||
|
label: 蒸汽流量
|
||||||
|
x: 9
|
||||||
|
y: 6
|
||||||
|
w: 3
|
||||||
|
h: 2
|
||||||
|
description: 蒸汽流量(t/h,公用工程监控)
|
||||||
|
# ---- 告警面板 + NL 查询入口 ------------------------------------------
|
||||||
|
- type: alarm_panel
|
||||||
|
x: 0
|
||||||
|
y: 8
|
||||||
|
w: 9
|
||||||
|
h: 3
|
||||||
|
description: 告警面板(氯化炉温/还原真空度/纯度/杂质异常)
|
||||||
|
- type: nl_query
|
||||||
|
x: 9
|
||||||
|
y: 8
|
||||||
|
w: 3
|
||||||
|
h: 3
|
||||||
|
description: 自然语言查询入口(工艺/质量/能耗问答)
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""海绵钛驾驶舱布局校验器(Issue #55 / PRD 5.5「⑤ 配置化驾驶舱」)。
|
||||||
|
|
||||||
|
PRD 5.5:切换行业模板后,驾驶舱按布局资产自动重排,无需改前端代码。
|
||||||
|
本模块把布局资产(``cockpit.ti.yaml``)落为**可校验的纯标准库资产 + 校验器**——
|
||||||
|
给定布局 YAML + 点位字典 CSV,校验:
|
||||||
|
|
||||||
|
1. **widget 类型合法**:在 PRD 5.5 ``iAOP-cockpit-layout-v1`` 允许集合内
|
||||||
|
(process_view/trend/kpi_card/alarm_panel/nl_query)。
|
||||||
|
2. **12 列网格不越界**:每个 widget ``0 ≤ x`` 且 ``x + w ≤ 12``,``y ≥ 0``、
|
||||||
|
``h > 0``;坐标为非负整数,w/h 正整数(网格对齐)。
|
||||||
|
3. **bind 的 point_id 在点位字典内**:trend/kpi_card 的 ``bind`` 必须命中
|
||||||
|
``point_dict.default.csv`` 的 ``point_id`` 列(防模板漂移)。
|
||||||
|
4. **四状态视图覆盖完整**:process_view 的 ``stages`` 必须覆盖工艺全流程
|
||||||
|
(氯化/精制/还原/蒸馏),且 order 单调递增、id 唯一。
|
||||||
|
|
||||||
|
校验产出 :class:`LayoutReport`(PASS/FAIL + 逐条 :class:`LayoutIssue`,
|
||||||
|
每条 issue 带 ``reason`` 可解释)。
|
||||||
|
|
||||||
|
设计要点
|
||||||
|
--------
|
||||||
|
- **零依赖 YAML 子集解析**:复制 impurity-forecast features.py 的
|
||||||
|
``_parse_yaml_subset``(无 pyyaml),支持 map/list/标量/行内 flow map。
|
||||||
|
- **纯标准库**:CSV 用标准库 csv,无 numpy/pyyaml 依赖。
|
||||||
|
- **换行业只改资产**:校验器对任何对齐 ``iAOP-cockpit-layout-v1`` 的布局都适用。
|
||||||
|
|
||||||
|
用法::
|
||||||
|
|
||||||
|
report = LayoutValidator(layout_yaml, point_dict_csv).validate()
|
||||||
|
if not report.passed:
|
||||||
|
for issue in report.issues:
|
||||||
|
print(issue.severity, issue.widget_id, issue.reason)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
#: iAOP-cockpit-layout-v1 允许的 widget 类型集合(对齐 resin _sanity_check)。
|
||||||
|
ALLOWED_WIDGET_TYPES = frozenset({
|
||||||
|
"process_view", "trend", "kpi_card", "alarm_panel", "nl_query",
|
||||||
|
})
|
||||||
|
|
||||||
|
#: 12 列网格(主流前端栅格标准,对齐 cockpit layout v1)。
|
||||||
|
GRID_COLUMNS = 12
|
||||||
|
|
||||||
|
#: 海绵钛四状态工艺流程(PRD 4.2:氯化 → 精制 → 还原 → 蒸馏)。
|
||||||
|
#: process_view 的 stages 必须覆盖这四个 id。
|
||||||
|
REQUIRED_STAGES = ("chlorination", "purification", "reduction", "distillation")
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutError(ValueError):
|
||||||
|
"""布局资产解析/声明错误(YAML 格式错、表头缺字段等)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class Severity(str, Enum):
|
||||||
|
"""问题严重度。"""
|
||||||
|
|
||||||
|
ERROR = "error" # 阻断:布局不可用(类型非法/越界/bind 缺失/状态缺失)
|
||||||
|
WARN = "warn" # 告警:可运行但不规范(重复/顺序乱)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LayoutIssue:
|
||||||
|
"""单条布局校验问题(含 reason 可解释)。"""
|
||||||
|
|
||||||
|
severity: Severity
|
||||||
|
reason: str
|
||||||
|
widget_id: str = "" # 关联 widget(index 或 src/metric)
|
||||||
|
field: str = "" # 关联字段(type/x/bind/stages ...)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_error(self) -> bool:
|
||||||
|
return self.severity is Severity.ERROR
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LayoutReport:
|
||||||
|
"""布局校验报告。"""
|
||||||
|
|
||||||
|
issues: List[LayoutIssue] = field(default_factory=list)
|
||||||
|
widget_count: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def errors(self) -> List[LayoutIssue]:
|
||||||
|
return [i for i in self.issues if i.is_error]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def passed(self) -> bool:
|
||||||
|
"""通过 = 无 ERROR(WARN 不阻断)。"""
|
||||||
|
return not any(i.is_error for i in self.issues)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"passed": self.passed,
|
||||||
|
"widget_count": self.widget_count,
|
||||||
|
"error_count": len(self.errors),
|
||||||
|
"warn_count": len(self.issues) - len(self.errors),
|
||||||
|
"issues": [
|
||||||
|
{"severity": i.severity.value, "widget_id": i.widget_id,
|
||||||
|
"field": i.field, "reason": i.reason}
|
||||||
|
for i in self.issues
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WidgetSpec:
|
||||||
|
"""单个 widget 的内存模型(从 YAML 解析)。"""
|
||||||
|
|
||||||
|
index: int # 在 widgets 列表中的位置(0 起)
|
||||||
|
type: str
|
||||||
|
x: int = 0
|
||||||
|
y: int = 0
|
||||||
|
w: int = 1
|
||||||
|
h: int = 1
|
||||||
|
bind: str = "" # trend/kpi_card 绑定的 point_id
|
||||||
|
src: str = "" # process_view 的 SVG
|
||||||
|
metric: str = "" # kpi_card 的 metric
|
||||||
|
label: str = ""
|
||||||
|
description: str = ""
|
||||||
|
stages: List[Dict[str, object]] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 零依赖 YAML 子集解析(复制自 impurity-forecast features.py,对齐 data-bus)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_scalar(text: str) -> str:
|
||||||
|
"""去掉标量两侧引号与行内注释。"""
|
||||||
|
t = text.split(" #", 1)[0].strip()
|
||||||
|
if len(t) >= 2 and t[0] == t[-1] and t[0] in ("'", '"'):
|
||||||
|
return t[1:-1]
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_flow_value(text: str):
|
||||||
|
"""解析 ``key: value`` 右侧值,支持行内 flow map ``{k: v, k: v}``。"""
|
||||||
|
t = text.split(" #", 1)[0].strip()
|
||||||
|
if t.startswith("{") and t.endswith("}"):
|
||||||
|
inner = t[1:-1].strip()
|
||||||
|
out: Dict[str, object] = {}
|
||||||
|
if not inner:
|
||||||
|
return out
|
||||||
|
for part in inner.split(","):
|
||||||
|
if ":" not in part:
|
||||||
|
raise LayoutError(f"flow map 项不是键值对:{part!r}")
|
||||||
|
k, _, v = part.partition(":")
|
||||||
|
out[k.strip()] = _parse_scalar(v)
|
||||||
|
return out
|
||||||
|
return _parse_scalar(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_comments(lines: List[str]) -> List[Tuple[str, int]]:
|
||||||
|
out: List[Tuple[str, int]] = []
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
s = ln.strip()
|
||||||
|
if not s or s.startswith("#"):
|
||||||
|
continue
|
||||||
|
out.append((ln, i + 1))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_node(lines: List[Tuple[str, int]], i: int, indent: int):
|
||||||
|
"""递归解析 YAML 节点(map / list / scalar)。返回 (value, next_i)。"""
|
||||||
|
text, _ = lines[i]
|
||||||
|
# ---- list 节点 ----
|
||||||
|
if text.lstrip(" ").startswith("- "):
|
||||||
|
items: List[object] = []
|
||||||
|
while i < len(lines):
|
||||||
|
t, no = lines[i]
|
||||||
|
stripped = t.lstrip(" ")
|
||||||
|
if not stripped.startswith("- "):
|
||||||
|
break
|
||||||
|
lead_j = len(t) - len(t.lstrip(" "))
|
||||||
|
if lead_j != indent:
|
||||||
|
break
|
||||||
|
item_text = stripped[2:].strip()
|
||||||
|
if not item_text:
|
||||||
|
raise LayoutError(f"cockpit.yaml 第 {no} 行:list 项为空")
|
||||||
|
if ":" in item_text:
|
||||||
|
map_indent = len(t) - len(t.lstrip(" ")) + 2
|
||||||
|
lines[i] = (" " * map_indent + item_text, no)
|
||||||
|
v, i = _parse_node(lines, i, map_indent)
|
||||||
|
items.append(v)
|
||||||
|
else:
|
||||||
|
items.append(_parse_flow_value(item_text))
|
||||||
|
i += 1
|
||||||
|
return items, i
|
||||||
|
# ---- map 节点 ----
|
||||||
|
result: Dict[str, object] = {}
|
||||||
|
while i < len(lines):
|
||||||
|
t, no = lines[i]
|
||||||
|
lead_j = len(t) - len(t.lstrip(" "))
|
||||||
|
if lead_j < indent or t.lstrip(" ").startswith("- "):
|
||||||
|
break
|
||||||
|
if lead_j > indent:
|
||||||
|
raise LayoutError(
|
||||||
|
f"cockpit.yaml 第 {no} 行缩进异常(期望 {indent},实际 {lead_j})")
|
||||||
|
if ":" not in t:
|
||||||
|
raise LayoutError(f"cockpit.yaml 第 {no} 行不是合法键值对:{t!r}")
|
||||||
|
key, _, rest = t.partition(":")
|
||||||
|
key = key.strip()
|
||||||
|
rest = rest.strip()
|
||||||
|
if rest:
|
||||||
|
result[key] = _parse_flow_value(rest)
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if i + 1 >= len(lines):
|
||||||
|
raise LayoutError(f"cockpit.yaml 第 {no} 行 {key!r} 缺少值")
|
||||||
|
sub_indent = len(lines[i + 1][0]) - len(lines[i + 1][0].lstrip(" "))
|
||||||
|
if sub_indent <= indent:
|
||||||
|
raise LayoutError(f"cockpit.yaml 第 {no} 行 {key!r} 缺少值(无嵌套)")
|
||||||
|
v, i = _parse_node(lines, i + 1, sub_indent)
|
||||||
|
result[key] = v
|
||||||
|
return result, i
|
||||||
|
|
||||||
|
|
||||||
|
def _load_yaml_text(text: str) -> Dict[str, object]:
|
||||||
|
"""解析 YAML 文本为 dict(顶层必须是 map)。"""
|
||||||
|
lines = _strip_comments(text.splitlines())
|
||||||
|
if not lines:
|
||||||
|
return {}
|
||||||
|
top_indent = len(lines[0][0]) - len(lines[0][0].lstrip(" "))
|
||||||
|
value, next_i = _parse_node(lines, 0, top_indent)
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise LayoutError("cockpit.yaml 顶层必须是 map")
|
||||||
|
if next_i < len(lines):
|
||||||
|
raise LayoutError(
|
||||||
|
f"cockpit.yaml 第 {lines[next_i][1]} 行:顶层存在多个节点")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 点位字典加载(CSV → point_id 集合)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_point_ids(csv_path: str) -> List[str]:
|
||||||
|
"""从点位字典 CSV 加载全部 point_id(保序,对齐 CSV point_id 列)。
|
||||||
|
|
||||||
|
CSV 表头对齐 core/edge-gateway point_dict schema(第二列 point_id)。
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(csv_path):
|
||||||
|
raise LayoutError(f"点位字典 CSV 不存在:{csv_path}")
|
||||||
|
with open(csv_path, "r", encoding="utf-8") as fh:
|
||||||
|
rows = list(csv.reader(fh))
|
||||||
|
if not rows:
|
||||||
|
raise LayoutError(f"点位字典 CSV 为空:{csv_path}")
|
||||||
|
header = [c.strip() for c in rows[0]]
|
||||||
|
if "point_id" not in header:
|
||||||
|
raise LayoutError(
|
||||||
|
f"点位字典 CSV 表头缺 point_id 列:{header}")
|
||||||
|
col = header.index("point_id")
|
||||||
|
ids: List[str] = []
|
||||||
|
for i, row in enumerate(rows[1:], 2):
|
||||||
|
if len(row) <= col:
|
||||||
|
continue
|
||||||
|
pid = row[col].strip()
|
||||||
|
if pid:
|
||||||
|
ids.append(pid)
|
||||||
|
if not ids:
|
||||||
|
raise LayoutError(f"点位字典 CSV 无 point_id 数据行:{csv_path}")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 校验器
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LayoutValidator:
|
||||||
|
"""海绵钛驾驶舱布局校验器。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
layout_yaml_path: 布局资产路径(cockpit.ti.yaml)。
|
||||||
|
point_dict_csv_path: 点位字典 CSV 路径(point_dict.default.csv)。
|
||||||
|
grid_columns: 网格列数(默认 12,对齐 cockpit layout v1)。
|
||||||
|
required_stages: process_view 必须覆盖的 stage id(默认海绵钛四状态)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
layout_yaml_path: str,
|
||||||
|
point_dict_csv_path: Optional[str] = None,
|
||||||
|
grid_columns: int = GRID_COLUMNS,
|
||||||
|
required_stages: Tuple[str, ...] = REQUIRED_STAGES,
|
||||||
|
) -> None:
|
||||||
|
if grid_columns <= 0:
|
||||||
|
raise LayoutError(f"grid_columns 必须 > 0,实际 {grid_columns}")
|
||||||
|
self.layout_path = layout_yaml_path
|
||||||
|
self.point_dict_path = point_dict_csv_path
|
||||||
|
self.grid_columns = int(grid_columns)
|
||||||
|
self.required_stages = tuple(required_stages)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def validate(self) -> LayoutReport:
|
||||||
|
"""执行全部校验,返回报告。"""
|
||||||
|
report = LayoutReport()
|
||||||
|
# 1) 解析布局 YAML
|
||||||
|
try:
|
||||||
|
with open(self.layout_path, "r", encoding="utf-8") as fh:
|
||||||
|
data = _load_yaml_text(fh.read())
|
||||||
|
except LayoutError:
|
||||||
|
raise
|
||||||
|
except OSError as exc:
|
||||||
|
raise LayoutError(f"布局 YAML 读取失败:{self.layout_path} ({exc})") from exc
|
||||||
|
|
||||||
|
# schema 头校验
|
||||||
|
schema = str(data.get("$schema", "")).strip()
|
||||||
|
if schema != "iAOP-cockpit-layout-v1":
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR,
|
||||||
|
field="$schema",
|
||||||
|
reason=f"$schema 应为 'iAOP-cockpit-layout-v1',实际 {schema!r}",
|
||||||
|
))
|
||||||
|
|
||||||
|
# 2) 解析 widgets
|
||||||
|
raw_widgets = data.get("widgets") or []
|
||||||
|
if not isinstance(raw_widgets, list):
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, field="widgets",
|
||||||
|
reason=f"widgets 必须是 list,实际 {type(raw_widgets).__name__}"))
|
||||||
|
return report
|
||||||
|
widgets = self._parse_widgets(raw_widgets, report)
|
||||||
|
report.widget_count = len(widgets)
|
||||||
|
|
||||||
|
if not widgets:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, field="widgets",
|
||||||
|
reason="布局无任何 widget"))
|
||||||
|
return report
|
||||||
|
|
||||||
|
# 3) 加载点位字典(bind 校验需要)
|
||||||
|
point_ids: Optional[set] = None
|
||||||
|
if self.point_dict_path:
|
||||||
|
try:
|
||||||
|
point_ids = set(load_point_ids(self.point_dict_path))
|
||||||
|
except LayoutError as exc:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, field="point_dict",
|
||||||
|
reason=str(exc)))
|
||||||
|
|
||||||
|
# 4) 逐 widget 校验
|
||||||
|
for w in widgets:
|
||||||
|
self._check_widget(w, point_ids, report)
|
||||||
|
|
||||||
|
# 5) process_view 四状态覆盖
|
||||||
|
self._check_process_views(widgets, report)
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _parse_widgets(self, raw_widgets: List[object],
|
||||||
|
report: LayoutReport) -> List[WidgetSpec]:
|
||||||
|
widgets: List[WidgetSpec] = []
|
||||||
|
for idx, item in enumerate(raw_widgets):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=f"[{idx}]",
|
||||||
|
field="widgets",
|
||||||
|
reason=f"widgets[{idx}] 必须是 map,实际 {type(item).__name__}"))
|
||||||
|
continue
|
||||||
|
wtype = str(item.get("type", "")).strip()
|
||||||
|
widgets.append(WidgetSpec(
|
||||||
|
index=idx,
|
||||||
|
type=wtype,
|
||||||
|
x=_to_int(item.get("x"), 0),
|
||||||
|
y=_to_int(item.get("y"), 0),
|
||||||
|
w=_to_int(item.get("w"), 1),
|
||||||
|
h=_to_int(item.get("h"), 1),
|
||||||
|
bind=str(item.get("bind", "")).strip(),
|
||||||
|
src=str(item.get("src", "")).strip(),
|
||||||
|
metric=str(item.get("metric", "")).strip(),
|
||||||
|
label=str(item.get("label", "")).strip(),
|
||||||
|
description=str(item.get("description", "")).strip(),
|
||||||
|
stages=_as_list_of_dict(item.get("stages")),
|
||||||
|
))
|
||||||
|
return widgets
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _check_widget(self, w: WidgetSpec, point_ids: Optional[set],
|
||||||
|
report: LayoutReport) -> None:
|
||||||
|
wid = f"[{w.index}]({w.type})"
|
||||||
|
# 4a) widget 类型合法
|
||||||
|
if w.type not in ALLOWED_WIDGET_TYPES:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=wid, field="type",
|
||||||
|
reason=f"非法 widget 类型 {w.type!r}(允许 {sorted(ALLOWED_WIDGET_TYPES)})"))
|
||||||
|
|
||||||
|
# 4b) 12 列网格不越界(坐标非负整数、x+w ≤ columns、h>0)
|
||||||
|
if w.x < 0 or w.y < 0:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=wid, field="grid",
|
||||||
|
reason=f"坐标不能为负:x={w.x} y={w.y}"))
|
||||||
|
if w.w <= 0 or w.h <= 0:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=wid, field="grid",
|
||||||
|
reason=f"w/h 必须为正整数:w={w.w} h={w.h}"))
|
||||||
|
if w.x + w.w > self.grid_columns:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=wid, field="grid",
|
||||||
|
reason=f"越出 {self.grid_columns} 列网格:x={w.x}+w={w.w}"
|
||||||
|
f"={w.x + w.w} > {self.grid_columns}"))
|
||||||
|
|
||||||
|
# 4c) bind 的 point_id 必须在点位字典内(trend/kpi_card)
|
||||||
|
if w.bind:
|
||||||
|
if point_ids is not None and w.bind not in point_ids:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=wid, field="bind",
|
||||||
|
reason=f"bind point_id {w.bind!r} 不在点位字典内"
|
||||||
|
f"(防模板漂移,对齐 point_dict.default.csv)"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _check_process_views(self, widgets: List[WidgetSpec],
|
||||||
|
report: LayoutReport) -> None:
|
||||||
|
"""校验 process_view 的四状态覆盖完整。"""
|
||||||
|
pv = [w for w in widgets if w.type == "process_view"]
|
||||||
|
if not pv:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, field="process_view",
|
||||||
|
reason="布局缺少 process_view(四状态工艺流程主视图必需)"))
|
||||||
|
return
|
||||||
|
|
||||||
|
covered: Dict[str, WidgetSpec] = {} # stage_id → widget
|
||||||
|
for w in pv:
|
||||||
|
wid = f"[{w.index}](process_view)"
|
||||||
|
if not w.stages:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=wid, field="stages",
|
||||||
|
reason="process_view 缺少 stages 声明(四状态覆盖必需)"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
stage_ids: List[str] = []
|
||||||
|
orders: List[int] = []
|
||||||
|
seen: set = set()
|
||||||
|
for st in w.stages:
|
||||||
|
sid = str(st.get("id", "")).strip()
|
||||||
|
sname = str(st.get("name", "")).strip()
|
||||||
|
if not sid:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=wid, field="stages",
|
||||||
|
reason=f"stage 缺少 id(name={sname!r})"))
|
||||||
|
continue
|
||||||
|
if sid in seen:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.WARN, widget_id=wid, field="stages",
|
||||||
|
reason=f"stage id 重复:{sid!r}"))
|
||||||
|
continue
|
||||||
|
seen.add(sid)
|
||||||
|
stage_ids.append(sid)
|
||||||
|
covered.setdefault(sid, w)
|
||||||
|
order = st.get("order")
|
||||||
|
if order is not None:
|
||||||
|
try:
|
||||||
|
orders.append(int(order))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=wid, field="stages",
|
||||||
|
reason=f"stage {sid!r} order 非整数:{order!r}"))
|
||||||
|
|
||||||
|
# order 单调递增校验
|
||||||
|
if orders and len(orders) == len(stage_ids):
|
||||||
|
if orders != sorted(orders):
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, widget_id=wid, field="stages",
|
||||||
|
reason=f"stage order 非单调递增:{orders}"))
|
||||||
|
|
||||||
|
# 必需四状态全覆盖
|
||||||
|
missing = [s for s in self.required_stages if s not in covered]
|
||||||
|
if missing:
|
||||||
|
report.issues.append(LayoutIssue(
|
||||||
|
severity=Severity.ERROR, field="stages",
|
||||||
|
reason=f"process_view stages 未覆盖必需四状态:{missing}"
|
||||||
|
f"(氯化/精制/还原/蒸馏)"))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 辅助
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _to_int(value: object, default: int) -> int:
|
||||||
|
"""把 YAML 解析出的值(可能是 str/int)转为 int;失败返回 default。"""
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise LayoutError(f"坐标值不是整数:{value!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list_of_dict(value: object) -> List[Dict[str, object]]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
out: List[Dict[str, object]] = []
|
||||||
|
for item in value:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
out.append(item)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""测试引导:把 dashboard 测试根目录加入 sys.path,使 layout_validator 可导入。
|
||||||
|
|
||||||
|
dashboard 目录名是合法 Python 标识符,直接作为包导入;本引导把父目录
|
||||||
|
(templates/ti-cl4/dashboard)挂到 sys.path,使 ``from layout_validator import ...``
|
||||||
|
在 unittest 发现机制下可解析(与 core 模块测试引导同款)。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
if PKG_DIR not in sys.path:
|
||||||
|
sys.path.insert(0, PKG_DIR)
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
# -*- 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 sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user