This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""测试引导:把 `core/template-console` 以包名 `template_console` 挂载到 sys.modules。
|
||||
|
||||
目录名 `template-console` 含连字符,无法直接以包名 import;挂载后模块内
|
||||
相对导入(`from .rbac import ...`)在 unittest 发现机制下可正常解析。
|
||||
|
||||
同时把兄弟内核目录 `core/edge-gateway` 加入 sys.path,使 point_importer
|
||||
可复用其 `point_dict` 子包(loader/validator/schema),避免重复造轮子。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
# 1) 挂载 core/template-console 为 template_console 包
|
||||
CONSOLE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, CONSOLE_DIR)
|
||||
if "template_console" not in sys.modules:
|
||||
pkg = types.ModuleType("template_console")
|
||||
pkg.__path__ = [CONSOLE_DIR]
|
||||
sys.modules["template_console"] = pkg
|
||||
|
||||
# 2) 暴露兄弟内核 edge-gateway/point_dict(#63 复用其校验器)
|
||||
CORE_DIR = os.path.dirname(CONSOLE_DIR)
|
||||
EDGE_GW_DIR = os.path.join(CORE_DIR, "edge-gateway")
|
||||
if os.path.isdir(EDGE_GW_DIR) and EDGE_GW_DIR not in sys.path:
|
||||
sys.path.insert(0, EDGE_GW_DIR)
|
||||
@@ -0,0 +1,191 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""配置项 CRUD 存储引擎测试(issue #64)。
|
||||
|
||||
覆盖:
|
||||
1. 三类配置 CRUD(list/get/upsert/delete);
|
||||
2. 原子写 + 持久化(重开 store 仍在);
|
||||
3. 校验规则(model_param/rag_config/layout,非法值拒绝);
|
||||
4. 快照 snapshot/restore(为 #66 提供基础);
|
||||
5. 可解释字段(meaning/reason/updated_by/updated_at 落盘)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
from template_console.config_store import ( # noqa: E402
|
||||
ALLOWED_WIDGET_TYPES,
|
||||
ConfigItem,
|
||||
ConfigKind,
|
||||
ConfigStore,
|
||||
ValidationResult,
|
||||
validate_item,
|
||||
)
|
||||
|
||||
|
||||
class _TmpStore:
|
||||
def __init__(self):
|
||||
self._tmp = tempfile.mkdtemp()
|
||||
self.store = ConfigStore(self._tmp)
|
||||
|
||||
def cleanup(self):
|
||||
import shutil
|
||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||
|
||||
|
||||
class ValidationTest(unittest.TestCase):
|
||||
"""校验规则。"""
|
||||
|
||||
def test_model_param_scalar_ok(self):
|
||||
self.assertTrue(validate_item(ConfigKind.MODEL_PARAM, "learning_rate", 0.001))
|
||||
|
||||
def test_model_param_learning_rate_range(self):
|
||||
vr = validate_item(ConfigKind.MODEL_PARAM, "learning_rate", 1.5)
|
||||
self.assertFalse(vr)
|
||||
self.assertTrue(any("learning_rate" in e for e in vr.errors))
|
||||
|
||||
def test_model_param_bad_key(self):
|
||||
vr = validate_item(ConfigKind.MODEL_PARAM, "Bad Key!", 1)
|
||||
self.assertFalse(vr)
|
||||
|
||||
def test_rag_top_k_bounds(self):
|
||||
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "top_k", 0))
|
||||
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "top_k", 51))
|
||||
self.assertTrue(validate_item(ConfigKind.RAG_CONFIG, "top_k", 10))
|
||||
|
||||
def test_rag_similarity_threshold(self):
|
||||
self.assertTrue(validate_item(ConfigKind.RAG_CONFIG, "similarity_threshold", 0.5))
|
||||
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "similarity_threshold", 1.5))
|
||||
|
||||
def test_rag_sources_must_be_nonempty_list(self):
|
||||
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "sources", []))
|
||||
self.assertFalse(validate_item(ConfigKind.RAG_CONFIG, "sources", ["", "x"]))
|
||||
self.assertTrue(validate_item(ConfigKind.RAG_CONFIG, "sources", ["sop", "gb"]))
|
||||
|
||||
def test_layout_widget_type(self):
|
||||
bad = [{"type": "unknown", "x": 0, "y": 0, "w": 1, "h": 1}]
|
||||
self.assertFalse(validate_item(ConfigKind.LAYOUT, "dashboard", bad))
|
||||
good = [{"type": "trend", "x": 0, "y": 0, "w": 6, "h": 2}]
|
||||
self.assertTrue(validate_item(ConfigKind.LAYOUT, "dashboard", good))
|
||||
|
||||
def test_layout_widget_coords_nonneg_int(self):
|
||||
bad = [{"type": "trend", "x": -1, "y": 0, "w": 1, "h": 1}]
|
||||
vr = validate_item(ConfigKind.LAYOUT, "dashboard", bad)
|
||||
self.assertFalse(vr)
|
||||
|
||||
|
||||
class CrudTest(unittest.TestCase):
|
||||
"""CRUD + 持久化。"""
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _TmpStore()
|
||||
self.store = self.ctx.store
|
||||
|
||||
def tearDown(self):
|
||||
self.ctx.cleanup()
|
||||
|
||||
def test_upsert_and_get(self):
|
||||
self.store.upsert(ConfigKind.MODEL_PARAM, "iterations", 100,
|
||||
meaning="迭代数", updated_by="li", reason="标定")
|
||||
it = self.store.get(ConfigKind.MODEL_PARAM, "iterations")
|
||||
self.assertIsNotNone(it)
|
||||
self.assertEqual(it.value, 100)
|
||||
self.assertEqual(it.updated_by, "li")
|
||||
self.assertEqual(it.reason, "标定")
|
||||
self.assertTrue(it.updated_at) # 时间戳已写
|
||||
|
||||
def test_upsert_rejects_invalid(self):
|
||||
with self.assertRaises(ValueError):
|
||||
self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 999)
|
||||
|
||||
def test_upsert_overwrites(self):
|
||||
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.1)
|
||||
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.01, reason="调小")
|
||||
it = self.store.get(ConfigKind.MODEL_PARAM, "lr")
|
||||
self.assertEqual(it.value, 0.01)
|
||||
self.assertEqual(it.reason, "调小")
|
||||
|
||||
def test_list_and_delete(self):
|
||||
self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 5)
|
||||
self.store.upsert(ConfigKind.RAG_CONFIG, "similarity_threshold", 0.6)
|
||||
self.assertEqual(len(self.store.list(ConfigKind.RAG_CONFIG)), 2)
|
||||
self.assertTrue(self.store.delete(ConfigKind.RAG_CONFIG, "top_k"))
|
||||
self.assertIsNone(self.store.get(ConfigKind.RAG_CONFIG, "top_k"))
|
||||
self.assertFalse(self.store.delete(ConfigKind.RAG_CONFIG, "nope"))
|
||||
|
||||
def test_persistence_across_reopen(self):
|
||||
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
|
||||
# 重开一个指向同一目录的 store
|
||||
store2 = ConfigStore(self.ctx._tmp)
|
||||
it = store2.get(ConfigKind.MODEL_PARAM, "lr")
|
||||
self.assertIsNotNone(it)
|
||||
self.assertEqual(it.value, 0.001)
|
||||
|
||||
def test_json_file_is_human_readable(self):
|
||||
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001, meaning="学习率")
|
||||
path = os.path.join(self.ctx._tmp, "model_params.json")
|
||||
with open(path, encoding="utf-8") as fh:
|
||||
blob = json.load(fh)
|
||||
self.assertEqual(blob["schema_version"], 1)
|
||||
self.assertEqual(blob["kind"], "model_param")
|
||||
self.assertEqual(blob["items"][0]["meaning"], "学习率")
|
||||
|
||||
|
||||
class SnapshotTest(unittest.TestCase):
|
||||
"""快照与恢复(#66 基础)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.ctx = _TmpStore()
|
||||
self.store = self.ctx.store
|
||||
|
||||
def tearDown(self):
|
||||
self.ctx.cleanup()
|
||||
|
||||
def test_snapshot_captures_all_kinds(self):
|
||||
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
|
||||
self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 8)
|
||||
snap = self.store.snapshot()
|
||||
self.assertIn("captured_at", snap)
|
||||
self.assertEqual(set(snap["kinds"].keys()),
|
||||
{"model_param", "rag_config", "layout"})
|
||||
self.assertEqual(len(snap["kinds"]["model_param"]), 1)
|
||||
|
||||
def test_restore_replicates_state(self):
|
||||
self.store.upsert(ConfigKind.MODEL_PARAM, "lr", 0.001)
|
||||
self.store.upsert(ConfigKind.RAG_CONFIG, "top_k", 8)
|
||||
snap = self.store.snapshot()
|
||||
# 清空再恢复
|
||||
self.store.delete(ConfigKind.MODEL_PARAM, "lr")
|
||||
self.store.delete(ConfigKind.RAG_CONFIG, "top_k")
|
||||
self.store.restore(snap)
|
||||
self.assertEqual(self.store.get(ConfigKind.MODEL_PARAM, "lr").value, 0.001)
|
||||
self.assertEqual(self.store.get(ConfigKind.RAG_CONFIG, "top_k").value, 8)
|
||||
|
||||
def test_item_counts(self):
|
||||
self.store.upsert(ConfigKind.LAYOUT, "dashboard",
|
||||
[{"type": "trend", "x": 0, "y": 0, "w": 6, "h": 2}])
|
||||
counts = self.store.item_counts()
|
||||
self.assertEqual(counts["layout"], 1)
|
||||
self.assertEqual(counts["model_param"], 0)
|
||||
|
||||
|
||||
class ConfigItemSerializationTest(unittest.TestCase):
|
||||
"""ConfigItem 序列化往返。"""
|
||||
|
||||
def test_roundtrip(self):
|
||||
it = ConfigItem(key="lr", value=0.1, kind=ConfigKind.MODEL_PARAM,
|
||||
meaning="学习率", updated_by="li", reason="init",
|
||||
updated_at="2026-01-01T00:00:00Z")
|
||||
d = it.to_dict()
|
||||
self.assertEqual(d["kind"], "model_param")
|
||||
it2 = ConfigItem.from_dict(d)
|
||||
self.assertEqual(it2.value, 0.1)
|
||||
self.assertEqual(it2.kind, ConfigKind.MODEL_PARAM)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,228 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""点位字典 CSV 导入 + 自动校验测试(issue #63)。
|
||||
|
||||
覆盖:
|
||||
1. 合法 CSV 导入通过(ti / resin 两套模板);
|
||||
2. 表头校验(缺失列 / 列序错位);
|
||||
3. 内核校验复用(量纲/数据类型/采样率/重复点号/协议);
|
||||
4. OPC 节点格式校验(opcua/modbus/空);
|
||||
5. 模板级量纲收窄(rpm 仅 resin 允许);
|
||||
6. 报告 ok/汇总/字典化 + 粘贴框入口。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
from template_console.point_importer import ( # noqa: E402
|
||||
ImportReport,
|
||||
ImportRowIssue,
|
||||
Severity,
|
||||
TemplateKind,
|
||||
import_csv,
|
||||
import_csv_string,
|
||||
)
|
||||
|
||||
GOOD_TI = """device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol
|
||||
CLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp,opcua
|
||||
CLF-01,CLF-01.PRES,炉压,kPa,float,1000,true,ns=2;s=CLF.Pres,opcua
|
||||
"""
|
||||
|
||||
GOOD_RESIN = """device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol
|
||||
R-801,R-801.TEMP,反应釜温度,℃,float,1000,true,ns=2;s=R801.Temp,opcua
|
||||
R-801,R-801.AGIT,搅拌转速,rpm,float,1000,true,ns=2;s=R801.Agit,opcua
|
||||
"""
|
||||
|
||||
BAD_MULTI = """device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol
|
||||
CLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,badnode,opcua
|
||||
CLF-01,CLF-01.TEMP,炉压,kPa,badtype,0,true,ns=2;s=CLF.Pres,opcua
|
||||
CLF-01,CLF-01.PRES,炉压,kPa,float,500,true,holding:40010,modbus
|
||||
"""
|
||||
|
||||
|
||||
class _TmpCsv:
|
||||
"""临时 CSV 文件助手。"""
|
||||
|
||||
def __init__(self, content):
|
||||
self._tmp = tempfile.mkdtemp()
|
||||
self.path = os.path.join(self._tmp, "points.csv")
|
||||
with open(self.path, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
|
||||
def cleanup(self):
|
||||
import shutil
|
||||
shutil.rmtree(self._tmp, ignore_errors=True)
|
||||
|
||||
|
||||
class GoodImportTest(unittest.TestCase):
|
||||
"""合法 CSV 导入。"""
|
||||
|
||||
def test_good_ti_imports_ok(self):
|
||||
f = _TmpCsv(GOOD_TI)
|
||||
try:
|
||||
pd, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
self.assertTrue(rep.ok, rep.summary())
|
||||
self.assertEqual(rep.loaded_points, 2)
|
||||
self.assertEqual(rep.error_count, 0)
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
def test_good_resin_imports_ok_with_rpm(self):
|
||||
f = _TmpCsv(GOOD_RESIN)
|
||||
try:
|
||||
pd, rep = import_csv(f.path, template=TemplateKind.RESIN)
|
||||
self.assertTrue(rep.ok, rep.summary())
|
||||
# rpm 在 resin 模板合法
|
||||
self.assertEqual(rep.error_count, 0)
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
def test_report_summary_and_dict(self):
|
||||
f = _TmpCsv(GOOD_TI)
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
self.assertIn("通过", rep.summary())
|
||||
d = rep.to_dict()
|
||||
self.assertTrue(d["ok"])
|
||||
self.assertEqual(d["template"], "ti")
|
||||
self.assertEqual(d["loaded_points"], 2)
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
|
||||
class HeaderValidationTest(unittest.TestCase):
|
||||
"""表头校验。"""
|
||||
|
||||
def test_missing_column_is_error(self):
|
||||
bad = "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp\n"
|
||||
f = _TmpCsv(bad)
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
self.assertFalse(rep.ok)
|
||||
codes = [i.code for i in rep.issues if i.row == 1]
|
||||
self.assertIn("missing_column", codes)
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
def test_wrong_column_order_is_warn(self):
|
||||
# 列齐全但顺序错(name 提前)→ WARN,不阻断
|
||||
bad = "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,protocol,opcNode\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,opcua,ns=2;s=CLF.Temp\n"
|
||||
f = _TmpCsv(bad)
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
self.assertIn("bad_column_order", [i.code for i in rep.issues])
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
|
||||
class KernelValidationTest(unittest.TestCase):
|
||||
"""复用内核校验(量纲/数据类型/采样率/重复点号)。"""
|
||||
|
||||
def test_dup_point_detected(self):
|
||||
bad = GOOD_TI + "CLF-01,CLF-01.TEMP,炉温2,℃,float,1000,true,ns=2;s=CLF.Temp2,opcua\n"
|
||||
f = _TmpCsv(bad)
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
self.assertFalse(rep.ok)
|
||||
self.assertIn("dup_point", [i.code for i in rep.issues])
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
def test_bad_data_type_and_sample_rate(self):
|
||||
f = _TmpCsv(BAD_MULTI)
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
codes = [i.code for i in rep.issues]
|
||||
self.assertIn("bad_data_type", codes)
|
||||
self.assertIn("bad_sample_rate", codes)
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
def test_bad_protocol_detected(self):
|
||||
bad = "device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,ns=2;s=CLF.Temp,unknownproto\n"
|
||||
f = _TmpCsv(bad)
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
self.assertIn("bad_protocol", [i.code for i in rep.issues])
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
|
||||
class OpcNodeValidationTest(unittest.TestCase):
|
||||
"""OPC 节点格式校验(配置台扩展维度)。"""
|
||||
|
||||
def test_bad_opcua_node_is_error(self):
|
||||
# BAD_MULTI 第1行 opcNode=badnode 协议 opcua → ERROR
|
||||
f = _TmpCsv(BAD_MULTI)
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
opc_issues = [i for i in rep.issues if i.code == "bad_opc_node"]
|
||||
self.assertTrue(any(i.severity == Severity.ERROR for i in opc_issues))
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
def test_valid_modbus_node_ok(self):
|
||||
# BAD_MULTI 第3行 holding:40010 modbus → 不报 bad_opc_node
|
||||
f = _TmpCsv("device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\nCLF-01,CLF-01.PRES,炉压,kPa,float,500,true,holding:40010,modbus\n")
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
self.assertNotIn("bad_opc_node", [i.code for i in rep.issues
|
||||
if i.severity == Severity.ERROR])
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
def test_empty_opc_node_is_warn(self):
|
||||
f = _TmpCsv("device_id,point_id,name,unit,dataType,sampleRate,qualityCode,opcNode,protocol\nCLF-01,CLF-01.TEMP,炉温,℃,float,1000,true,,simulator\n")
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
empties = [i for i in rep.issues if i.code == "empty_opc_node"]
|
||||
self.assertEqual(len(empties), 1)
|
||||
self.assertEqual(empties[0].severity, Severity.WARN)
|
||||
# 警告不阻断
|
||||
self.assertTrue(rep.ok)
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
|
||||
class TemplateUnitTest(unittest.TestCase):
|
||||
"""模板级量纲收窄。"""
|
||||
|
||||
def test_rpm_rejected_in_ti_template(self):
|
||||
f = _TmpCsv(GOOD_RESIN)
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.TI)
|
||||
# rpm 是树脂专属,ti 模板应报 template_unit_mismatch
|
||||
self.assertIn("template_unit_mismatch", [i.code for i in rep.issues])
|
||||
self.assertFalse(rep.ok)
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
def test_rpm_allowed_in_resin_template(self):
|
||||
f = _TmpCsv(GOOD_RESIN)
|
||||
try:
|
||||
_, rep = import_csv(f.path, template=TemplateKind.RESIN)
|
||||
self.assertNotIn("template_unit_mismatch", [i.code for i in rep.issues])
|
||||
self.assertTrue(rep.ok, rep.summary())
|
||||
finally:
|
||||
f.cleanup()
|
||||
|
||||
|
||||
class ImportStringTest(unittest.TestCase):
|
||||
"""粘贴框入口(import_csv_string)。"""
|
||||
|
||||
def test_import_from_string(self):
|
||||
pd, rep = import_csv_string(GOOD_TI, template=TemplateKind.TI)
|
||||
self.assertTrue(rep.ok)
|
||||
self.assertEqual(len(pd), 2)
|
||||
|
||||
def test_import_string_bad_csv(self):
|
||||
bad = "device_id,point_id\nCLF-01,CLF-01.TEMP\n" # 缺列
|
||||
_, rep = import_csv_string(bad, template=TemplateKind.TI)
|
||||
self.assertFalse(rep.ok)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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()
|
||||
@@ -0,0 +1,181 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""三级 RBAC 权限模型测试(issue #62)。
|
||||
|
||||
覆盖:
|
||||
1. 三级角色权限矩阵正确(readonly/engineer/admin);
|
||||
2. 角色继承(admin 继承 engineer 继承 readonly);
|
||||
3. has_permission 允许/拒绝判定 + 理由可解释;
|
||||
4. 细粒度收窄(restricted_to_readonly 把写动作降级拒绝);
|
||||
5. 便捷判定 can_publish / 用户概览。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
from template_console.rbac import ( # noqa: E402
|
||||
Action,
|
||||
Permission,
|
||||
Resource,
|
||||
Role,
|
||||
RoleKind,
|
||||
User,
|
||||
all_roles,
|
||||
can_publish,
|
||||
effective_permissions,
|
||||
get_role,
|
||||
has_permission,
|
||||
user_summary,
|
||||
)
|
||||
|
||||
|
||||
class RoleRegistryTest(unittest.TestCase):
|
||||
"""三级角色注册表。"""
|
||||
|
||||
def test_three_roles_present(self):
|
||||
roles = {r.kind for r in all_roles()}
|
||||
self.assertEqual(roles, {RoleKind.READONLY, RoleKind.ENGINEER, RoleKind.ADMIN})
|
||||
|
||||
def test_role_labels_in_chinese(self):
|
||||
self.assertEqual(get_role(RoleKind.READONLY).label, "只读")
|
||||
self.assertEqual(get_role(RoleKind.ENGINEER).label, "行业工程师")
|
||||
self.assertEqual(get_role(RoleKind.ADMIN).label, "管理员")
|
||||
|
||||
def test_role_descriptions_explainable(self):
|
||||
# 可解释性:每个角色都有职责说明
|
||||
for role in all_roles():
|
||||
self.assertTrue(role.description, f"{role.kind} 缺少 description")
|
||||
|
||||
def test_inheritance_chain(self):
|
||||
self.assertEqual(get_role(RoleKind.ADMIN).inherits, RoleKind.ENGINEER)
|
||||
self.assertEqual(get_role(RoleKind.ENGINEER).inherits, RoleKind.READONLY)
|
||||
self.assertIsNone(get_role(RoleKind.READONLY).inherits)
|
||||
|
||||
def test_permission_key_format(self):
|
||||
p = Permission(RoleKind.ADMIN, Resource.USER, Action.MANAGE)
|
||||
# 匹配键为资源:动作(角色无关,便于继承);审计键含授予角色
|
||||
self.assertEqual(p.key(), "user:manage")
|
||||
self.assertEqual(p.audit_key(), "admin:user:manage")
|
||||
|
||||
|
||||
class EffectivePermissionTest(unittest.TestCase):
|
||||
"""继承后的有效权限集合。"""
|
||||
|
||||
def test_admin_inherits_engineer_and_readonly(self):
|
||||
eff = effective_permissions(RoleKind.ADMIN)
|
||||
# 匹配键为 resource:action:admin 拥有自身的 user:manage,
|
||||
# 也继承 engineer 的 model_param:edit 与 readonly 的 layout:view
|
||||
self.assertIn("user:manage", eff)
|
||||
self.assertIn("model_param:edit", eff)
|
||||
self.assertIn("layout:view", eff)
|
||||
|
||||
def test_engineer_cannot_publish(self):
|
||||
eff = effective_permissions(RoleKind.ENGINEER)
|
||||
# 工程师不能发布/推送/管用户
|
||||
self.assertNotIn("release:publish", eff)
|
||||
self.assertNotIn("push:publish", eff)
|
||||
self.assertNotIn("user:manage", eff)
|
||||
|
||||
def test_readonly_has_no_write(self):
|
||||
eff = effective_permissions(RoleKind.READONLY)
|
||||
for key in eff:
|
||||
# 只读权限只能以 :view 结尾
|
||||
self.assertTrue(key.endswith(":view"), f"readonly 不应有写/发布权限: {key}")
|
||||
|
||||
|
||||
class HasPermissionTest(unittest.TestCase):
|
||||
"""has_permission 判定 + 理由。"""
|
||||
|
||||
def setUp(self):
|
||||
self.ro = User("viewer", RoleKind.READONLY, "查看员")
|
||||
self.eng = User("li_engineer", RoleKind.ENGINEER, "李工")
|
||||
self.admin = User("root_admin", RoleKind.ADMIN, "管理员甲")
|
||||
|
||||
def test_readonly_view_allowed(self):
|
||||
d = has_permission(self.ro, Resource.LAYOUT, Action.VIEW)
|
||||
self.assertTrue(d.allow)
|
||||
self.assertEqual(d.source, "explicit")
|
||||
|
||||
def test_readonly_edit_denied(self):
|
||||
d = has_permission(self.ro, Resource.LAYOUT, Action.EDIT)
|
||||
self.assertFalse(d.allow)
|
||||
self.assertIn("缺少权限", d.reason)
|
||||
|
||||
def test_engineer_edit_allowed_inherited_view(self):
|
||||
# 工程师编辑是本角色权限(explicit)
|
||||
d_edit = has_permission(self.eng, Resource.LAYOUT, Action.EDIT)
|
||||
self.assertTrue(d_edit.allow)
|
||||
self.assertEqual(d_edit.source, "explicit")
|
||||
# 工程师查看布局是继承自 readonly(inherited)
|
||||
d_view = has_permission(self.eng, Resource.LAYOUT, Action.VIEW)
|
||||
self.assertTrue(d_view.allow)
|
||||
self.assertEqual(d_view.source, "inherited")
|
||||
|
||||
def test_engineer_publish_denied(self):
|
||||
d = has_permission(self.eng, Resource.RELEASE, Action.PUBLISH)
|
||||
self.assertFalse(d.allow)
|
||||
|
||||
def test_admin_publish_allowed(self):
|
||||
d = has_permission(self.admin, Resource.RELEASE, Action.PUBLISH)
|
||||
self.assertTrue(d.allow)
|
||||
self.assertEqual(d.source, "explicit")
|
||||
|
||||
def test_admin_inherited_engineer_edit(self):
|
||||
d = has_permission(self.admin, Resource.MODEL_PARAM, Action.EDIT)
|
||||
self.assertTrue(d.allow)
|
||||
self.assertEqual(d.source, "inherited")
|
||||
|
||||
def test_decision_carries_reason(self):
|
||||
# 可解释性:无论允许/拒绝,reason 非空且含用户名与资源
|
||||
for user in (self.ro, self.eng, self.admin):
|
||||
d = has_permission(user, Resource.PUSH, Action.PUBLISH)
|
||||
self.assertIn(user.username, d.reason)
|
||||
self.assertIn(Resource.PUSH.value, d.reason)
|
||||
|
||||
|
||||
class RestrictedUserTest(unittest.TestCase):
|
||||
"""细粒度收窄:restricted_to_readonly。"""
|
||||
|
||||
def test_restricted_engineer_cannot_edit_that_resource(self):
|
||||
# 工程师本可编辑布局,但被收窄为只读后应拒绝
|
||||
u = User("limited", RoleKind.ENGINEER, "受限工程师",
|
||||
restricted_to_readonly=[Resource.LAYOUT])
|
||||
d = has_permission(u, Resource.LAYOUT, Action.EDIT)
|
||||
self.assertFalse(d.allow)
|
||||
self.assertEqual(d.source, "restricted")
|
||||
|
||||
def test_restricted_engineer_can_still_view(self):
|
||||
u = User("limited", RoleKind.ENGINEER, "受限工程师",
|
||||
restricted_to_readonly=[Resource.LAYOUT])
|
||||
d = has_permission(u, Resource.LAYOUT, Action.VIEW)
|
||||
self.assertTrue(d.allow)
|
||||
|
||||
def test_restricted_only_affects_named_resource(self):
|
||||
u = User("limited", RoleKind.ENGINEER, "受限工程师",
|
||||
restricted_to_readonly=[Resource.LAYOUT])
|
||||
# 模型超参未被收窄,仍可编辑
|
||||
d = has_permission(u, Resource.MODEL_PARAM, Action.EDIT)
|
||||
self.assertTrue(d.allow)
|
||||
|
||||
|
||||
class ConvenienceTest(unittest.TestCase):
|
||||
"""便捷判定与用户概览。"""
|
||||
|
||||
def test_can_publish(self):
|
||||
self.assertFalse(can_publish(User("v", RoleKind.READONLY)))
|
||||
self.assertFalse(can_publish(User("e", RoleKind.ENGINEER)))
|
||||
self.assertTrue(can_publish(User("a", RoleKind.ADMIN)))
|
||||
|
||||
def test_user_summary(self):
|
||||
s = user_summary(User("li", RoleKind.ENGINEER, "李工"))
|
||||
self.assertEqual(s["username"], "li")
|
||||
self.assertEqual(s["role"], "engineer")
|
||||
self.assertEqual(s["role_label"], "行业工程师")
|
||||
self.assertGreater(s["effective_permission_count"], 0)
|
||||
self.assertEqual(s["restricted_to_readonly"], [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,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()
|
||||
Reference in New Issue
Block a user