Merge PR #115 (feat #52 报警看板配置化:阈值/规则外置 + 布局/渲染整合)

This commit is contained in:
2026-08-05 08:26:40 +08:00
parent 8a407fc0ad
commit 52d986e6f0
6 changed files with 1219 additions and 0 deletions
+338
View File
@@ -0,0 +1,338 @@
# -*- coding: utf-8 -*-
"""报警看板配置化单元测试(issue #52 / PRD 5.5「⑤ 配置化驾驶舱」)。
覆盖:
1. 合法配置(Ti 模板)→ 校验通过、可解析为 ``AlarmPanelConfig``、序列化往返一致;
2. 默认值与字段别名(snake_case / camelCase 都接受)、thresholds_source 选填;
3. ``render_alarm_panel_props`` 正确编译 severity 样式 / 规则源 / 确认策略;
4. ``filter_alarms_by_mute`` 静默下限 + severity 降序排序;
5. 各类非法情况:非对象、错误 $schema、severityColors 缺级/重复/非法、
rulesSource 缺失/asset 缺 ref/inline 缺 data、布尔/整数/枚举/区间非法;
6. ``load_alarm_config`` 校验失败抛 ``AlarmConfigError`` 并携带全部错误。
"""
from __future__ import annotations
import copy
import unittest
from cockpit import ( # type: ignore[import-not-found]
ALARM_CONFIG_SCHEMA_ID,
DEFAULT_ACK_TIMEOUT_S,
DEFAULT_MAX_ITEMS,
AlarmConfigError,
AlarmPanelConfig,
filter_alarms_by_mute,
load_alarm_config,
render_alarm_panel_props,
validate_alarm_config,
)
def _valid_config() -> dict:
"""返回一份合法的告警面板配置(Ti 模板风格)。"""
return {
"$schema": ALARM_CONFIG_SCHEMA_ID,
"severityColors": [
{"severity": "P0", "fg": "#ff3b30", "bg": "rgba(255,59,48,0.12)",
"icon": "alert-octagon", "border": "#ff3b30"},
{"severity": "P1", "fg": "#f5a623", "bg": "rgba(245,166,35,0.12)"},
{"severity": "P2", "fg": "#3aa0ff", "bg": "rgba(58,160,255,0.10)"},
],
"rulesSource": {"kind": "asset", "ref": "ti-cl4/cfg/alert_rules.yaml"},
"showSop": True,
"requireAck": True,
"ackTimeoutS": 300,
"muteLower": "P2",
"groupBy": "severity",
"sortBy": "severity",
"maxItems": 50,
}
class ValidateTest(unittest.TestCase):
def test_valid_config_passes(self):
res = validate_alarm_config(_valid_config())
self.assertTrue(res.ok, msg=res.errors)
self.assertEqual(res.errors, [])
norm = res.normalized
assert norm is not None
# 规范化:severityColors 按 P0/P1/P2 顺序回填
self.assertEqual([c["severity"] for c in norm["severityColors"]], ["P0", "P1", "P2"])
# P0 的可选 icon/border 保留
self.assertEqual(norm["severityColors"][0]["icon"], "alert-octagon")
def test_non_dict_rejected(self):
res = validate_alarm_config(["not", "a", "dict"])
self.assertFalse(res.ok)
self.assertIn("对象(dict)", res.errors[0])
def test_wrong_schema_rejected(self):
cfg = _valid_config()
cfg["$schema"] = "something-else"
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("$schema" in e for e in res.errors))
def test_snake_case_aliases_accepted(self):
# snake_case 字段名同样接受(向后兼容手写 YAML)
cfg = {
"severity_colors": [
{"severity": "P0", "fg": "#f", "bg": "#0"},
{"severity": "P1", "fg": "#f", "bg": "#0"},
{"severity": "P2", "fg": "#f", "bg": "#0"},
],
"rules_source": {"kind": "asset", "ref": "a.yaml"},
"show_sop": False,
"require_ack": False,
"mute_lower": "P2",
"group_by": "severity",
"sort_by": "severity",
"max_items": 10,
}
res = validate_alarm_config(cfg)
self.assertTrue(res.ok, msg=res.errors)
def test_missing_severity_level_rejected(self):
cfg = _valid_config()
cfg["severityColors"] = cfg["severityColors"][:2] # 只留 P0/P1
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("缺少 P2" in e for e in res.errors))
def test_duplicate_and_invalid_severity_rejected(self):
cfg = _valid_config()
cfg["severityColors"] = [
{"severity": "P0", "fg": "#f", "bg": "#0"},
{"severity": "P0", "fg": "#f", "bg": "#0"}, # 重复
{"severity": "P9", "fg": "#f", "bg": "#0"}, # 非法
]
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
joined = "; ".join(res.errors)
self.assertIn("重复", joined)
self.assertIn("P9", joined)
def test_missing_required_color_key_rejected(self):
cfg = _valid_config()
cfg["severityColors"][0] = {"severity": "P0", "fg": "#f"} # 缺 bg
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("bg" in e for e in res.errors))
def test_missing_rules_source_rejected(self):
cfg = _valid_config()
del cfg["rulesSource"]
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("rulesSource" in e for e in res.errors))
def test_asset_source_without_ref_rejected(self):
cfg = _valid_config()
cfg["rulesSource"] = {"kind": "asset"} # 缺 ref
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("ref" in e for e in res.errors))
def test_inline_source_without_data_rejected(self):
cfg = _valid_config()
cfg["rulesSource"] = {"kind": "inline"} # 缺 data
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("data" in e for e in res.errors))
def test_inline_source_item_without_id_rejected(self):
cfg = _valid_config()
cfg["rulesSource"] = {"kind": "inline", "data": [{"severity": "P0"}]} # 缺 id
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("id" in e for e in res.errors))
def test_invalid_source_kind_rejected(self):
cfg = _valid_config()
cfg["rulesSource"] = {"kind": "graphql", "ref": "x"}
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("kind" in e for e in res.errors))
def test_bool_fields_type_checked(self):
for field in ("showSop", "requireAck"):
cfg = _valid_config()
cfg[field] = "yes"
res = validate_alarm_config(cfg)
self.assertFalse(res.ok, msg=field)
self.assertTrue(any(field in e for e in res.errors), msg=field)
def test_ack_timeout_must_be_positive_int(self):
cfg = _valid_config()
cfg["ackTimeoutS"] = 0
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
self.assertTrue(any("ackTimeoutS" in e for e in res.errors))
# bool 不应被当成 int 接受
cfg["ackTimeoutS"] = True
res = validate_alarm_config(cfg)
self.assertFalse(res.ok)
def test_enum_fields_validated(self):
for field, valid, invalid in [
("muteLower", ["P0", "P1", "P2"], "P9"),
("groupBy", ["severity", "rule", "process"], "color"),
("sortBy", ["severity", "time"], "color"),
]:
for v in valid:
cfg = _valid_config()
cfg[field] = v
# muteLower=P0 合法但会过滤掉 P1/P2,不影响校验合法性
self.assertTrue(validate_alarm_config(cfg).ok, msg=f"{field}={v}")
cfg = _valid_config()
cfg[field] = invalid
res = validate_alarm_config(cfg)
self.assertFalse(res.ok, msg=f"{field}={invalid}")
self.assertTrue(any(field in e for e in res.errors), msg=field)
def test_max_items_range(self):
cfg = _valid_config()
cfg["maxItems"] = 0
self.assertFalse(validate_alarm_config(cfg).ok)
cfg["maxItems"] = 501
self.assertFalse(validate_alarm_config(cfg).ok)
cfg["maxItems"] = 500
self.assertTrue(validate_alarm_config(cfg).ok)
cfg["maxItems"] = True # bool 不被当成 int
self.assertFalse(validate_alarm_config(cfg).ok)
def test_defaults_applied_when_optional_omitted(self):
cfg = {
"severityColors": [
{"severity": "P0", "fg": "#f", "bg": "#0"},
{"severity": "P1", "fg": "#f", "bg": "#0"},
{"severity": "P2", "fg": "#f", "bg": "#0"},
],
"rulesSource": {"kind": "asset", "ref": "a.yaml"},
}
res = validate_alarm_config(cfg)
self.assertTrue(res.ok, msg=res.errors)
assert res.normalized is not None
self.assertEqual(res.normalized["showSop"], True)
self.assertEqual(res.normalized["requireAck"], False)
self.assertEqual(res.normalized["ackTimeoutS"], DEFAULT_ACK_TIMEOUT_S)
self.assertEqual(res.normalized["muteLower"], "P2")
self.assertEqual(res.normalized["groupBy"], "severity")
self.assertEqual(res.normalized["sortBy"], "severity")
self.assertEqual(res.normalized["maxItems"], DEFAULT_MAX_ITEMS)
# thresholds_source 选填,未给出则 normalized 不含它
self.assertNotIn("thresholdsSource", res.normalized)
def test_multiple_errors_aggregated(self):
# 一次性破坏多个字段,校验器应聚合多条错误而非首条即返
bad = {
"severityColors": [{"severity": "P0", "fg": "#f", "bg": "#0"}], # 缺 P1/P2
"rulesSource": {"kind": "asset"}, # 缺 ref
"ackTimeoutS": -1,
"sortBy": "color",
}
res = validate_alarm_config(bad)
self.assertFalse(res.ok)
self.assertGreaterEqual(len(res.errors), 4)
class LoadTest(unittest.TestCase):
def test_load_valid_returns_model(self):
cfg = load_alarm_config(_valid_config())
self.assertIsInstance(cfg, AlarmPanelConfig)
self.assertEqual(cfg.schema, ALARM_CONFIG_SCHEMA_ID)
self.assertEqual(len(cfg.severity_colors), 3)
self.assertEqual(cfg.rules_source.kind, "asset")
self.assertEqual(cfg.rules_source.ref, "ti-cl4/cfg/alert_rules.yaml")
self.assertTrue(cfg.require_ack)
def test_load_roundtrip(self):
original = _valid_config()
cfg = load_alarm_config(original)
# 内存模型序列化后再次校验仍合法(往返一致)
res = validate_alarm_config(cfg.to_dict())
self.assertTrue(res.ok, msg=res.errors)
def test_load_invalid_raises_with_errors(self):
bad = copy.deepcopy(_valid_config())
del bad["rulesSource"]
with self.assertRaises(AlarmConfigError) as cm:
load_alarm_config(bad)
# 异常携带全部错误清单
self.assertGreaterEqual(len(cm.exception.errors), 1)
self.assertTrue(any("rulesSource" in e for e in cm.exception.errors))
class RenderPropsTest(unittest.TestCase):
def test_props_contain_severity_styles_and_sources(self):
cfg = load_alarm_config(_valid_config())
props = render_alarm_panel_props(cfg)
# 保留 #51 的订阅语义(向后兼容)
self.assertEqual(props["subscribe"], "alarm_stream")
# severity 样式:P0 红色 + 可选 border
self.assertEqual(props["severityStyles"]["P0"]["fg"], "#ff3b30")
self.assertEqual(props["severityStyles"]["P0"]["border"], "#ff3b30")
# asset 规则源透传 ref
self.assertEqual(props["rulesSource"]["kind"], "asset")
self.assertTrue(props["rulesSource"]["ref"].endswith("alert_rules.yaml"))
# require_ack=True 携带 ackTimeoutS
self.assertEqual(props["ackTimeoutS"], 300)
self.assertTrue(props["showSop"])
def test_props_ack_omitted_when_not_required(self):
cfg = _valid_config()
cfg["requireAck"] = False
props = render_alarm_panel_props(load_alarm_config(cfg))
self.assertNotIn("ackTimeoutS", props)
self.assertFalse(props["requireAck"])
def test_props_inline_source_data_passed_through(self):
cfg = _valid_config()
cfg["rulesSource"] = {
"kind": "inline",
"data": [{"id": "r1", "severity": "P0"}, {"id": "r2", "severity": "P1"}],
}
props = render_alarm_panel_props(load_alarm_config(cfg))
self.assertEqual(props["rulesSource"]["kind"], "inline")
self.assertEqual(len(props["rulesSource"]["data"]), 2)
def test_props_thresholds_source_optional(self):
# 未声明 thresholds_source → props 不含该键
props = render_alarm_panel_props(load_alarm_config(_valid_config()))
self.assertNotIn("thresholdsSource", props)
# 声明后 → 透传
cfg = _valid_config()
cfg["thresholdsSource"] = {"kind": "asset", "ref": "thr.yaml"}
props = render_alarm_panel_props(load_alarm_config(cfg))
self.assertEqual(props["thresholdsSource"]["ref"], "thr.yaml")
class FilterMuteTest(unittest.TestCase):
def test_filter_keeps_above_floor_and_sorts_desc(self):
alarms = [
{"severity": "P2", "id": "a"},
{"severity": "P0", "id": "b"},
{"severity": "P1", "id": "c"},
]
# mute=P2:全保留,按严重度降序
kept = filter_alarms_by_mute(alarms, "P2")
self.assertEqual([a["id"] for a in kept], ["b", "c", "a"])
# mute=P1:滤掉 P2
kept = filter_alarms_by_mute(alarms, "P1")
self.assertEqual([a["id"] for a in kept], ["b", "c"])
# mute=P0:只留 P0
kept = filter_alarms_by_mute(alarms, "P0")
self.assertEqual([a["id"] for a in kept], ["b"])
def test_filter_ignores_unknown_severity(self):
alarms = [{"severity": "P9", "id": "x"}, {"severity": "P0", "id": "y"}]
kept = filter_alarms_by_mute(alarms, "P2")
self.assertEqual([a["id"] for a in kept], ["y"])
def test_filter_invalid_mute_raises(self):
with self.assertRaises(AlarmConfigError):
filter_alarms_by_mute([], "P9")
if __name__ == "__main__":
unittest.main()