Merge PR #130 (feat #62-67 模板配置台全链路:RBAC/点位导入/配置/预览/发布推送 + #55 Ti 布局模板改进)

This commit is contained in:
2026-08-05 08:27:01 +08:00
parent 52d986e6f0
commit 6036e5e151
17 changed files with 3243 additions and 0 deletions
+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()
+188
View File
@@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
"""配置预览渲染引擎测试(issue #65)。
覆盖:
1. 布局预览(widget 卡片 + 网格占用率 + 越界/重叠检测);
2. 告警预览(规则渲染 + 模拟触发评估);
3. NL 查询预览(模板 + 示例问句生成);
4. 从 ConfigStore 一键预览(含空配置的宽容处理);
5. PreviewResult 的 ok/to_dict。
"""
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 ConfigKind, ConfigStore # noqa: E402
from template_console.preview import ( # noqa: E402
AlarmRule,
GRID_COLUMNS,
NLQueryTemplate,
PreviewKind,
PreviewResult,
preview_from_store,
render_alarm_preview,
render_layout_preview,
render_nl_query_preview,
)
class LayoutPreviewTest(unittest.TestCase):
"""布局预览。"""
def test_basic_render(self):
widgets = [
{"type": "process_view", "src": "x.svg", "x": 0, "y": 0, "w": 12, "h": 4,
"description": "工艺流程"},
{"type": "trend", "bind": "R-801.TEMP", "x": 0, "y": 4, "w": 6, "h": 2},
]
r = render_layout_preview(widgets)
self.assertEqual(len(r.items), 2)
self.assertEqual(r.items[0]["type"], "process_view")
self.assertEqual(r.items[1]["bind"], "R-801.TEMP")
self.assertTrue(r.ok) # 无越界/重叠
self.assertTrue(any("网格占用率" in n for n in r.notes))
def test_overflow_warning(self):
# x+w 超过 12 列
widgets = [{"type": "trend", "bind": "p", "x": 8, "y": 0, "w": 6, "h": 2}]
r = render_layout_preview(widgets)
self.assertFalse(r.ok)
self.assertTrue(any("越界" in w for w in r.warnings))
def test_overlap_warning(self):
widgets = [
{"type": "trend", "bind": "a", "x": 0, "y": 0, "w": 6, "h": 2},
{"type": "kpi_card", "metric": "m", "label": "L", "x": 3, "y": 0, "w": 6, "h": 2},
]
r = render_layout_preview(widgets)
self.assertFalse(r.ok)
self.assertTrue(any("重叠" in w for w in r.warnings))
def test_bad_widget_type(self):
widgets = [{"type": "unknown", "x": 0, "y": 0, "w": 1, "h": 1}]
r = render_layout_preview(widgets)
self.assertFalse(r.ok)
self.assertTrue(any("非法类型" in w for w in r.warnings))
def test_kpi_card_carries_metric_and_label(self):
widgets = [{"type": "kpi_card", "metric": "yield", "label": "产率",
"x": 0, "y": 0, "w": 3, "h": 2}]
r = render_layout_preview(widgets)
self.assertEqual(r.items[0]["metric"], "yield")
self.assertEqual(r.items[0]["label"], "产率")
def test_empty_widgets(self):
r = render_layout_preview([])
self.assertEqual(r.items, [])
self.assertTrue(r.ok)
class AlarmPreviewTest(unittest.TestCase):
"""告警预览。"""
def test_render_rules(self):
rules = [AlarmRule("R-801.TEMP", "反应釜温度", ">", 120.0, "critical")]
r = render_alarm_preview(rules)
self.assertEqual(len(r.items), 1)
self.assertIn("critical", r.items[0]["text"])
self.assertTrue(r.ok)
def test_evaluate_triggered(self):
rules = [AlarmRule("R-801.TEMP", "温度", ">", 120.0, "critical")]
r = render_alarm_preview(rules, current_values={"R-801.TEMP": 130.0})
self.assertTrue(r.items[0]["triggered"])
self.assertEqual(r.items[0]["state"], "触发")
self.assertTrue(any("1/1" in n for n in r.notes))
def test_evaluate_not_triggered(self):
rules = [AlarmRule("P1", "温度", ">", 120.0, "warn")]
r = render_alarm_preview(rules, current_values={"P1": 100.0})
self.assertFalse(r.items[0]["triggered"])
self.assertEqual(r.items[0]["state"], "正常")
def test_operators(self):
for op, val, thr in [(">=", 120, 120), ("<", 50, 100), ("<=", 100, 100), ("==", 5, 5)]:
rule = AlarmRule("P", "m", op, thr, "warn")
self.assertTrue(rule.evaluate(val), f"{op} {val} {thr} 应触发")
def test_bad_level_and_operator(self):
rules = [AlarmRule("P", "m", "~", 1.0, level="boom")]
r = render_alarm_preview(rules)
self.assertFalse(r.ok)
self.assertTrue(any("非法级别" in w for w in r.warnings))
self.assertTrue(any("非法运算符" in w for w in r.warnings))
class NLQueryPreviewTest(unittest.TestCase):
"""NL 查询预览。"""
def test_render_with_examples(self):
t = NLQueryTemplate(
name="batch_query",
question_template="最近一批的{metric}是多少?",
slots={"metric": ["产率", "能耗"]},
data_source="tdengine",
answer_hint="返回当批聚合值",
)
r = render_nl_query_preview([t])
self.assertEqual(len(r.items), 1)
self.assertEqual(len(r.items[0]["examples"]), 2)
self.assertIn("产率", r.items[0]["examples"][0])
self.assertEqual(r.items[0]["data_source"], "tdengine")
def test_empty_question_warns(self):
t = NLQueryTemplate(name="x", question_template="", slots={})
r = render_nl_query_preview([t])
self.assertFalse(r.ok)
def test_no_slots_returns_template(self):
t = NLQueryTemplate(name="x", question_template="整体能耗?", slots={})
self.assertEqual(t.render_examples(), ["整体能耗?"])
class StorePreviewTest(unittest.TestCase):
"""从 ConfigStore 一键预览。"""
def setUp(self):
self._tmp = tempfile.mkdtemp()
self.store = ConfigStore(self._tmp)
def tearDown(self):
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
def test_layout_preview_from_store(self):
self.store.upsert(ConfigKind.LAYOUT, "dashboard",
[{"type": "trend", "bind": "p", "x": 0, "y": 0, "w": 6, "h": 2}])
r = preview_from_store(self.store, PreviewKind.LAYOUT)
self.assertEqual(len(r.items), 1)
self.assertEqual(r.items[0]["bind"], "p")
def test_empty_layout_is_graceful(self):
r = preview_from_store(self.store, PreviewKind.LAYOUT)
self.assertEqual(r.items, [])
self.assertTrue(any("未配置" in n for n in r.notes))
def test_alarm_preview_from_store(self):
self.store.upsert(ConfigKind.MODEL_PARAM, "alarm_temp",
{"point_id": "R-801.TEMP", "metric": "温度",
"operator": ">", "threshold": 120, "level": "critical"})
r = preview_from_store(self.store, PreviewKind.ALARM,
current_values={"R-801.TEMP": 130})
self.assertTrue(r.items[0]["triggered"])
def test_nl_query_preview_from_store(self):
self.store.upsert(ConfigKind.RAG_CONFIG, "nl_batch",
{"name": "批次查询", "question_template": "{m}多少?",
"slots": {"m": ["产率"]}, "data_source": "tdengine"})
r = preview_from_store(self.store, PreviewKind.NL_QUERY)
self.assertEqual(len(r.items), 1)
self.assertEqual(r.items[0]["name"], "批次查询")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,215 @@
# -*- coding: utf-8 -*-
"""配置推送契约测试(issue #67)。
覆盖:
1. manifest 构建(版本/快照/校验和/来源);
2. checksum 稳定性 + 完整性校验(verify);
3. 推送幂等(同版本不重复写文件、返回原 manifest);
4. force 强制重推;
5. retract 撤回 + 重新推送;
6. 模拟传输损坏(篡改 snapshot → verify 失败);
7. 推送日志与查询。
"""
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 ConfigKind, ConfigStore # noqa: E402
from template_console.push_channel import ( # noqa: E402
PushChannel,
PushManifest,
PushRecord,
checksum,
)
from template_console.release import Release, ReleaseManager # noqa: E402
def _make_release(version: str = "1.0.0") -> Release:
"""构造一个带快照的 Release(不走文件系统,直接内存构造)。"""
snap = {
"schema_version": 1,
"captured_at": "2026-01-01T00:00:00Z",
"kinds": {"model_param": [
{"key": "lr", "value": 0.001, "kind": "model_param",
"meaning": "学习率", "updated_by": "li", "reason": "init",
"updated_at": "2026-01-01T00:00:00Z"}]},
}
return Release(version=version, created_at="2026-01-01T00:00:00Z",
snapshot=snap, released_by="admin", changelog="t", reason="r")
class _Tmp:
def __init__(self):
self._tmp = tempfile.mkdtemp()
self.inbox = os.path.join(self._tmp, "inbox")
def cleanup(self):
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
class ChecksumTest(unittest.TestCase):
"""校验和稳定性 + 完整性。"""
def test_checksum_stable(self):
snap = {"kinds": {"a": [1, 2, 3]}}
self.assertEqual(checksum(snap), checksum(snap))
def test_checksum_key_order_independent(self):
# 键序不影响校验和(规范序列化)
a = checksum({"x": 1, "y": 2})
b = checksum({"y": 2, "x": 1})
self.assertEqual(a, b)
def test_checksum_changes_on_value_change(self):
self.assertNotEqual(checksum({"v": 1}), checksum({"v": 2}))
def test_checksum_is_sha256_hex(self):
cs = checksum({"v": 1})
self.assertEqual(len(cs), 64)
self.assertTrue(all(c in "0123456789abcdef" for c in cs))
class ManifestTest(unittest.TestCase):
"""manifest 构建。"""
def test_build_manifest_has_checksum(self):
ch = PushChannel(inbox=tempfile.mkdtemp())
rel = _make_release()
m = ch.build_manifest(rel)
self.assertEqual(m.version, "1.0.0")
self.assertTrue(m.checksum)
self.assertEqual(m.source, "template-console")
self.assertTrue(m.generated_at)
def test_manifest_roundtrip(self):
m = PushManifest(version="1.0.0", snapshot={"a": 1},
checksum=checksum({"a": 1}), generated_at="t")
text = m.to_json()
m2 = PushManifest.from_dict(json.loads(text))
self.assertEqual(m2.version, "1.0.0")
self.assertEqual(m2.checksum, m.checksum)
class PushIdempotencyTest(unittest.TestCase):
"""推送幂等。"""
def setUp(self):
self.ctx = _Tmp()
self.ch = PushChannel(inbox=self.ctx.inbox)
self.rel = _make_release()
def tearDown(self):
self.ctx.cleanup()
def test_push_writes_manifest_file(self):
self.ch.push(self.rel, pushed_by="admin")
path = os.path.join(self.ctx.inbox, "manifest-1.0.0.json")
self.assertTrue(os.path.isfile(path))
def test_push_is_idempotent(self):
m1 = self.ch.push(self.rel, pushed_by="a")
m2 = self.ch.push(self.rel, pushed_by="b") # 重复推送
# 同版本返回同一 manifest(校验和一致)
self.assertEqual(m1.checksum, m2.checksum)
# 推送日志只有一条记录
self.assertEqual(len(self.ch.pushed_versions()), 1)
def test_force_overrides_idempotency(self):
self.ch.push(self.rel, pushed_by="a")
before = self.ch.pushed_versions()[0]["pushed_at"]
# force 重推(时间戳可能更新)
self.ch.push(self.rel, pushed_by="b", force=True)
records = self.ch.pushed_versions()
self.assertEqual(len(records), 1)
self.assertEqual(records[0]["pushed_by"], "b")
def test_push_log_records_pushed_by(self):
self.ch.push(self.rel, pushed_by="admin_zhang")
rec = self.ch.pushed_versions()[0]
self.assertEqual(rec["pushed_by"], "admin_zhang")
self.assertEqual(rec["status"], "pushed")
class RetractTest(unittest.TestCase):
"""撤回 + 重新推送。"""
def setUp(self):
self.ctx = _Tmp()
self.ch = PushChannel(inbox=self.ctx.inbox)
self.rel = _make_release()
def tearDown(self):
self.ctx.cleanup()
def test_retract_marks_status(self):
self.ch.push(self.rel)
self.assertTrue(self.ch.retract("1.0.0", by="admin", reason="有问题"))
rec = self.ch.pushed_versions()[0]
self.assertEqual(rec["status"], "retracted")
def test_retract_unknown_returns_false(self):
self.assertFalse(self.ch.retract("9.9.9"))
def test_retract_allows_repush(self):
self.ch.push(self.rel)
self.ch.retract("1.0.0")
# 撤回后可重新推送(幂等解除)
m = self.ch.push(self.rel, pushed_by="admin2")
rec = self.ch.pushed_versions()[0]
self.assertEqual(rec["status"], "pushed")
self.assertEqual(rec["pushed_by"], "admin2")
class VerifyTest(unittest.TestCase):
"""完整性校验。"""
def test_verify_valid_manifest(self):
ch = PushChannel(inbox=tempfile.mkdtemp())
m = ch.build_manifest(_make_release())
self.assertTrue(PushChannel.verify(m))
def test_verify_tampered_snapshot_fails(self):
ch = PushChannel(inbox=tempfile.mkdtemp())
m = ch.build_manifest(_make_release())
# 篡改 snapshot 但不改 checksum → 校验失败
m.snapshot["kinds"]["model_param"][0]["value"] = 0.999
self.assertFalse(PushChannel.verify(m))
def test_verify_payload_dict(self):
ch = PushChannel(inbox=tempfile.mkdtemp())
m = ch.build_manifest(_make_release())
self.assertTrue(PushChannel.verify_payload(m.to_dict()))
def test_verify_payload_bad_dict(self):
self.assertFalse(PushChannel.verify_payload({"nope": 1}))
class IntegrationTest(unittest.TestCase):
"""端到端:store → publish → push → verify。"""
def test_store_publish_push_flow(self):
tmp = tempfile.mkdtemp()
try:
store = ConfigStore(tmp)
store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
rm = ReleaseManager(store)
rel = rm.publish("1.0.0", released_by="admin", changelog="首发")
inbox = os.path.join(tmp, "inbox")
ch = PushChannel(inbox=inbox)
m = ch.push(rel, pushed_by="admin")
# 内核侧校验通过
self.assertTrue(PushChannel.verify(m))
self.assertEqual(len(ch.pushed_versions()), 1)
finally:
import shutil
shutil.rmtree(tmp, ignore_errors=True)
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()
+193
View File
@@ -0,0 +1,193 @@
# -*- coding: utf-8 -*-
"""版本发布 + 回滚点测试(issue #66)。
覆盖:
1. semver 校验 / 比较 / 递增;
2. publish 发布(快照固化、单调递增、重复拒绝、空快照拒绝);
3. rollback 回滚(恢复快照、不删历史、回滚事件可追溯);
4. list/latest/get/history 查询;
5. 持久化(重开 manager 仍在)。
"""
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 ConfigKind, ConfigStore # noqa: E402
from template_console.release import ( # noqa: E402
Release,
ReleaseManager,
bump_patch,
is_valid_semver,
semver_gt,
semver_tuple,
)
class _Tmp:
def __init__(self):
self._tmp = tempfile.mkdtemp()
self.store = ConfigStore(self._tmp)
self.rm = ReleaseManager(self.store)
def cleanup(self):
import shutil
shutil.rmtree(self._tmp, ignore_errors=True)
class SemverTest(unittest.TestCase):
"""semver 工具。"""
def test_valid(self):
self.assertTrue(is_valid_semver("1.0.0"))
self.assertTrue(is_valid_semver("0.0.1"))
self.assertTrue(is_valid_semver("10.20.30"))
def test_invalid(self):
self.assertFalse(is_valid_semver("1.0"))
self.assertFalse(is_valid_semver("1.0.0.0"))
self.assertFalse(is_valid_semver("v1.0.0"))
self.assertFalse(is_valid_semver("1.0.0-rc"))
def test_tuple_and_gt(self):
self.assertEqual(semver_tuple("1.2.3"), (1, 2, 3))
self.assertTrue(semver_gt("1.0.1", "1.0.0"))
self.assertTrue(semver_gt("2.0.0", "1.9.9"))
self.assertFalse(semver_gt("1.0.0", "1.0.0"))
def test_bump_patch(self):
self.assertEqual(bump_patch("1.0.0"), "1.0.1")
self.assertEqual(bump_patch("0.9.9"), "0.9.10")
class PublishTest(unittest.TestCase):
"""发布。"""
def setUp(self):
self.ctx = _Tmp()
def tearDown(self):
self.ctx.cleanup()
def test_publish_requires_nonempty_store(self):
with self.assertRaises(ValueError):
self.ctx.rm.publish("1.0.0")
def test_publish_first_version(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
rel = self.ctx.rm.publish("1.0.0", released_by="admin", changelog="首次发布")
self.assertEqual(rel.version, "1.0.0")
self.assertEqual(rel.released_by, "admin")
self.assertIn("model_param", rel.snapshot["kinds"])
def test_publish_monotonic_increase(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
self.ctx.rm.publish("1.0.0")
# 较低版本号应被拒绝
with self.assertRaises(ValueError):
self.ctx.rm.publish("0.9.0")
# 相同版本号应被拒绝
with self.assertRaises(ValueError):
self.ctx.rm.publish("1.0.0")
# 更高版本 OK
self.ctx.rm.publish("1.0.1")
def test_publish_invalid_semver(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
with self.assertRaises(ValueError):
self.ctx.rm.publish("1.0")
def test_snapshot_captures_current_state(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
self.ctx.rm.publish("1.0.0")
# 发布后改配置,原版本快照不受影响
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.01)
rel1 = self.ctx.rm.get("1.0.0")
self.assertEqual(
rel1.snapshot["kinds"]["model_param"][0]["value"], 0.001)
class RollbackTest(unittest.TestCase):
"""回滚。"""
def setUp(self):
self.ctx = _Tmp()
def tearDown(self):
self.ctx.cleanup()
def test_rollback_restores_snapshot(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
self.ctx.rm.publish("1.0.0", changelog="v1 lr=0.001")
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.01)
self.ctx.rm.publish("1.1.0", changelog="v2 lr=0.01")
# 当前 store 的 lr 应是 0.01
self.assertEqual(self.ctx.store.get(ConfigKind.MODEL_PARAM, "lr").value, 0.01)
# 回滚到 1.0.0
target = self.ctx.rm.rollback("1.0.0", released_by="admin", reason="线上异常")
# store 恢复成 1.0.0 的快照
self.assertEqual(self.ctx.store.get(ConfigKind.MODEL_PARAM, "lr").value, 0.001)
self.assertEqual(target.version, "1.0.0")
def test_rollback_keeps_history(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
self.ctx.rm.publish("1.0.0")
self.ctx.rm.rollback("1.0.0")
# 回滚不删除任何版本
self.assertEqual(len(self.ctx.rm.list()), 1)
self.assertIsNotNone(self.ctx.rm.get("1.0.0"))
def test_rollback_records_event(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
self.ctx.rm.publish("1.0.0")
self.ctx.rm.rollback("1.0.0", released_by="admin", reason="紧急回滚")
rel = self.ctx.rm.get("1.0.0")
self.assertIn("回滚", rel.reason)
self.assertIn("紧急回滚", rel.reason)
def test_rollback_unknown_version(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
self.ctx.rm.publish("1.0.0")
with self.assertRaises(ValueError):
self.ctx.rm.rollback("9.9.9")
class QueryTest(unittest.TestCase):
"""查询 + 持久化。"""
def setUp(self):
self.ctx = _Tmp()
def tearDown(self):
self.ctx.cleanup()
def test_list_latest_history(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
self.ctx.rm.publish("1.0.0", changelog="c1")
self.ctx.rm.publish("1.1.0", changelog="c2")
self.assertEqual([r.version for r in self.ctx.rm.list()], ["1.0.0", "1.1.0"])
self.assertEqual(self.ctx.rm.latest().version, "1.1.0")
hist = self.ctx.rm.history()
self.assertEqual(len(hist), 2)
self.assertEqual(hist[1]["changelog"], "c2")
self.assertEqual(hist[1]["item_count"], 1)
def test_persistence_across_reopen(self):
self.ctx.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
self.ctx.rm.publish("1.0.0")
# 重开 manager(同一 store 目录)
store2 = ConfigStore(self.ctx._tmp)
rm2 = ReleaseManager(store2)
self.assertIsNotNone(rm2.get("1.0.0"))
self.assertEqual(rm2.latest().version, "1.0.0")
def test_get_nonexistent(self):
self.assertIsNone(self.ctx.rm.get("9.9.9"))
self.assertIsNone(self.ctx.rm.latest())
if __name__ == "__main__":
unittest.main()