feat(#55/#62/#63/#64/#65/#66/#67): ⑤.7 配置台 + ⑤ Ti 布局模板(7 子任务,123 测试通过) #129

Closed
bot_dev1 wants to merge 5 commits from feature/issue-62 into main
15 changed files with 2620 additions and 0 deletions
+48
View File
@@ -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"
+291
View File
@@ -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}
+318
View File
@@ -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
+310
View File
@@ -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],
}
+26
View File
@@ -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()
+181
View File
@@ -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()
+37
View File
@@ -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 加载、空布局。
+27
View File
@@ -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())
+108
View File
@@ -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()