Files
iAOP/templates/ti-cl4/dashboard/tests/test_layout_validator.py
T
bot_dev1 1290a8bc54 feat(#55): Ti 行业布局模板(四状态流程视图)+ layout_validator
纯标准库实现,对齐 PRD 5.5「⑤ 配置化驾驶舱」iAOP-cockpit-layout-v1:

- cockpit.ti.yaml:海绵钛四状态工艺流程(氯化→精制→还原→蒸馏),
  process_view 主视图声明 stages 覆盖,trend/kpi_card bind 对齐
  point_dict.default.csv 的 point_id(CLF-01/RF-01/E-01/ST-01)。
- layout_validator.py:LayoutValidator 校验 widget 类型合法、12 列网格
  不越界、bind point_id 在点位字典内、四状态覆盖完整(order 单调/id 唯一);
  零依赖 YAML 子集解析(复制 impurity-forecast _parse_yaml_subset)。
- tests:18 用例覆盖真实资产端到端、非法类型、网格越界/负坐标/零宽、
  bind 漂移、缺 process_view、缺必需状态、order 非单调、stage 重复、
  $schema 头、CSV 加载、空布局。
- _sanity_check.py:冒烟验证 9 widgets 全校验通过。
2026-08-05 05:32:23 +08:00

308 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 unittest
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()