fix(kpi): 阈值红黄绿读写/填写规则修复 — 统一解析器+红灯优先+建议可解析+前端校验+存量修正 (project-20260911-threshold-fix)
① 新建 app/utils/threshold_parser.py 统一解析/判定(单一事实源):
- 支持负数(>=-100000)、闭区间 A~B、多条件 C1|C2;空串=未设置
- 判定顺序 red→green→yellow:红灯优先(消除宽黄灯遮蔽红灯),
green 先于 yellow(green 是主条件,yellow 是更宽兜底带)
② kpis.py / alert_generator.py 双实现收敛到统一解析器(消除重复维护)
③ thresholds.py suggest 输出可解析表达式(原黄色/适中型生成 `52.33~107.67`、`偏离55%` 等
区间/文字 → 用户点"自动建议"填入后静默失效;另修复大数目标生成 1.6e+06 科学计数法)
④ KPIDetail.vue 三个阈值输入加 placeholder/tooltip/帮助文案 + 保存前格式校验(非法即拒)
⑤ 存量 4 个不可解析表达式修正(id=46 C_REBATE_RATE / id=327 BH_SALES_10),
改前 mysqldump 备份 backend/scripts/backups/kpi_definitions.bak-20260911.sql +
threshold_rows_before_20260911.json(可回滚);不可解析 5→0
⑥ 单测 97 条 + 红黄绿判定回归 367 判定点(旧→新差异 14 条,含差异说明)
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
统一阈值解析器 / 建议格式 / 红灯优先判定 测试
|
||||
(project-20260911-threshold-fix,2026-09-11)
|
||||
|
||||
覆盖 4 类缺陷:
|
||||
① suggest 生成格式必须可解析(原生成区间/文字 → 保存后静默失效)
|
||||
② 前端/接口格式校验(非法表达式被拒)
|
||||
③ 解析器支持负数(>=-100000)
|
||||
⑤ 红灯优先(宽黄灯不得遮蔽红灯)
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.utils.threshold_parser import (
|
||||
LEVEL_PRIORITY,
|
||||
calc_level,
|
||||
calc_level_from_exprs,
|
||||
eval_threshold,
|
||||
is_valid_threshold,
|
||||
parse_threshold,
|
||||
)
|
||||
from app.api.thresholds import build_suggestion
|
||||
from app.api.kpis import validate_threshold_format
|
||||
from tests.conftest import create_test_user, get_token_for_user, auth_header, create_test_kpi
|
||||
|
||||
|
||||
class TestParseThreshold:
|
||||
"""解析器:操作符 / 负数 / 区间 / 多条件 / 非法格式"""
|
||||
|
||||
def test_scalar_ops(self):
|
||||
assert parse_threshold(">=32000000") == (">=", 32000000.0)
|
||||
assert parse_threshold("<20") == ("<", 20.0)
|
||||
assert parse_threshold("<=55") == ("<=", 55.0)
|
||||
assert parse_threshold(">60") == (">", 60.0)
|
||||
assert parse_threshold("=100") == ("=", 100.0)
|
||||
assert parse_threshold("!=0") == ("!=", 0.0)
|
||||
|
||||
def test_negative_numbers(self):
|
||||
"""缺陷3:原正则 [\\d.]+ 不支持负号 → 现金流类阈值静默失效"""
|
||||
assert parse_threshold(">=-100000") == (">=", -100000.0)
|
||||
assert parse_threshold("<-100000") == ("<", -100000.0)
|
||||
assert eval_threshold(-200000, "<-100000") is True
|
||||
assert eval_threshold(-50000, "<-100000") is False
|
||||
|
||||
def test_whitespace_tolerated(self):
|
||||
assert parse_threshold(" >= 800 ") == (">=", 800.0)
|
||||
|
||||
def test_range(self):
|
||||
assert parse_threshold("52.33~107.67") == ("~", (52.33, 107.67))
|
||||
assert eval_threshold(80, "52.33~107.67") is True
|
||||
assert eval_threshold(52.33, "52.33~107.67") is True # 含端点
|
||||
assert eval_threshold(120, "52.33~107.67") is False
|
||||
|
||||
def test_or_clauses(self):
|
||||
assert eval_threshold(130, "<=36|>=124") is True
|
||||
assert eval_threshold(20, "<=36|>=124") is True
|
||||
assert eval_threshold(80, "<=36|>=124") is False
|
||||
|
||||
@pytest.mark.parametrize("bad", ["偏离55%", "偏离>55%", "80%", ">=abc", "52.33~", ">=", ">=800%"])
|
||||
def test_invalid(self, bad):
|
||||
assert is_valid_threshold(bad) is False
|
||||
assert parse_threshold(bad) == (None, None)
|
||||
assert eval_threshold(100, bad) is False
|
||||
|
||||
def test_blank_is_valid_means_unset(self):
|
||||
assert is_valid_threshold("") is True
|
||||
assert is_valid_threshold(None) is True
|
||||
assert eval_threshold(100, "") is False
|
||||
|
||||
def test_partial_or_clause_is_invalid(self):
|
||||
"""多条件中任一子句非法 → 整体非法(防半截表达式静默生效)"""
|
||||
assert is_valid_threshold("<=36|abc") is False
|
||||
|
||||
|
||||
class TestCalcLevel:
|
||||
def test_red_priority_over_yellow(self):
|
||||
"""缺陷5:id=6 实测 Y='>76' R='>91',值 95 原判黄(红灯不可达)→ 必须判红"""
|
||||
assert calc_level_from_exprs(95, "<=76", ">76", ">91") == "red"
|
||||
assert calc_level_from_exprs(152.62, "<=76", ">76", ">91") == "red"
|
||||
|
||||
def test_green_priority_over_yellow(self):
|
||||
"""green 是主条件、yellow 是更宽兜底带(id=1 G='>=800' Y='>=700'):
|
||||
值 900 同时满足两条,用户意图是绿,不能被黄截胡"""
|
||||
assert calc_level_from_exprs(900, ">=800", ">=700", "<600") == "green"
|
||||
assert calc_level_from_exprs(750, ">=800", ">=700", "<600") == "yellow"
|
||||
assert calc_level_from_exprs(500, ">=800", ">=700", "<600") == "red"
|
||||
|
||||
def test_lower_better(self):
|
||||
assert calc_level_from_exprs(20, "<=25", "<=30", ">30") == "green"
|
||||
assert calc_level_from_exprs(28, "<=25", "<=30", ">30") == "yellow"
|
||||
assert calc_level_from_exprs(35, "<=25", "<=30", ">30") == "red"
|
||||
|
||||
def test_negative_cashflow(self):
|
||||
"""缺陷3 场景:F_EVA G='>=0' Y='>=-100000' R='<-100000'"""
|
||||
assert calc_level_from_exprs(-200000, ">=0", ">=-100000", "<-100000") == "red"
|
||||
assert calc_level_from_exprs(-50000, ">=0", ">=-100000", "<-100000") == "yellow"
|
||||
assert calc_level_from_exprs(100, ">=0", ">=-100000", "<-100000") == "green"
|
||||
|
||||
def test_moderate_band(self):
|
||||
"""适中型:绿=内区间、黄=环带、红=外区间"""
|
||||
g, y, r = "72~88", "<72|>88", "<=64|>=96"
|
||||
assert calc_level_from_exprs(80, g, y, r) == "green"
|
||||
assert calc_level_from_exprs(70, g, y, r) == "yellow"
|
||||
assert calc_level_from_exprs(98, g, y, r) == "red"
|
||||
|
||||
def test_priority_constant(self):
|
||||
assert LEVEL_PRIORITY == ("red", "green", "yellow")
|
||||
|
||||
def test_no_threshold_is_info(self):
|
||||
assert calc_level_from_exprs(100, None, None, None) == "info"
|
||||
assert calc_level_from_exprs(None, ">=1", None, None) == "info"
|
||||
|
||||
def test_calc_level_with_kpi_object(self):
|
||||
class K:
|
||||
threshold_green = "<=76"
|
||||
threshold_yellow = ">76"
|
||||
threshold_red = ">91"
|
||||
assert calc_level(95, K()) == "red"
|
||||
assert calc_level(70, K()) == "green"
|
||||
|
||||
|
||||
class TestSuggestFormat:
|
||||
"""缺陷1:suggest 生成值必须全部可解析(点“自动建议”填入后不再静默失效)"""
|
||||
|
||||
@pytest.mark.parametrize("kpi_type,target", [
|
||||
("higher_better", 1200.0), ("lower_better", 80.0),
|
||||
("middle_best", 80.0),
|
||||
])
|
||||
def test_all_labels_parseable(self, kpi_type, target):
|
||||
s = build_suggestion(kpi_type, target, tolerance=10.0)
|
||||
for level in ("green", "yellow", "red"):
|
||||
label = s[level]["label"]
|
||||
assert label, f"{kpi_type}.{level} 缺 label"
|
||||
assert is_valid_threshold(label), f"{kpi_type}.{level} 不可解析: {label}"
|
||||
|
||||
def test_higher_better_labels(self):
|
||||
s = build_suggestion("higher_better", 1000.0)
|
||||
assert s["green"]["label"] == ">=800"
|
||||
assert s["yellow"]["label"] == ">=500"
|
||||
assert s["red"]["label"] == "<500"
|
||||
# 三档构成完整划分
|
||||
assert calc_level_from_exprs(900, ">=800", ">=500", "<500") == "green"
|
||||
assert calc_level_from_exprs(600, ">=800", ">=500", "<500") == "yellow"
|
||||
assert calc_level_from_exprs(400, ">=800", ">=500", "<500") == "red"
|
||||
|
||||
def test_lower_better_labels(self):
|
||||
s = build_suggestion("lower_better", 100.0)
|
||||
assert s["green"]["label"] == "<=120"
|
||||
assert s["yellow"]["label"] == "<=200"
|
||||
assert s["red"]["label"] == ">200"
|
||||
assert calc_level_from_exprs(100, "<=120", "<=200", ">200") == "green"
|
||||
assert calc_level_from_exprs(150, "<=120", "<=200", ">200") == "yellow"
|
||||
assert calc_level_from_exprs(250, "<=120", "<=200", ">200") == "red"
|
||||
|
||||
def test_middle_best_labels_partition(self):
|
||||
s = build_suggestion("middle_best", 100.0, tolerance=10.0)
|
||||
assert s["green"]["label"] == "90~110"
|
||||
# 黄 = 外环带 T±2tol(green ⊂ yellow);因判定顺序 red→green→yellow,绿先命中,
|
||||
# 等价于严格分区:绿=90~110 / 黄=80~120 / 红=之外
|
||||
assert s["yellow"]["label"] == "80~120"
|
||||
assert s["red"]["label"] == "<80|>120"
|
||||
g = s["green"]["label"]
|
||||
y = s["yellow"]["label"]
|
||||
r = s["red"]["label"]
|
||||
assert calc_level_from_exprs(100, g, y, r) == "green"
|
||||
assert calc_level_from_exprs(95, g, y, r) == "green" # 90~110 内 = 绿
|
||||
assert calc_level_from_exprs(85, g, y, r) == "yellow" # 环带
|
||||
assert calc_level_from_exprs(119, g, y, r) == "yellow" # 环带
|
||||
# 边界归属与 higher_better/lower_better 保持一致:恰好落在 ±2tol 边界 → 黄(红为开区间)
|
||||
assert calc_level_from_exprs(80, g, y, r) == "yellow"
|
||||
assert calc_level_from_exprs(120, g, y, r) == "yellow"
|
||||
assert calc_level_from_exprs(79, g, y, r) == "red"
|
||||
assert calc_level_from_exprs(121, g, y, r) == "red"
|
||||
|
||||
def test_labels_avoid_scientific_notation(self):
|
||||
"""大数目标(>=1e6)不得生成 1.6e+06 这类科学计数法(解析器不认)。"""
|
||||
s = build_suggestion("higher_better", 2_000_000.0)
|
||||
assert s["green"]["label"] == ">=1600000"
|
||||
assert s["yellow"]["label"] == ">=1000000"
|
||||
assert s["red"]["label"] == "<1000000"
|
||||
for level in ("green", "yellow", "red"):
|
||||
assert is_valid_threshold(s[level]["label"]), s[level]["label"]
|
||||
s2 = build_suggestion("middle_best", 1_500_000.0, tolerance=100_000.0)
|
||||
for level in ("green", "yellow", "red"):
|
||||
assert is_valid_threshold(s2[level]["label"]), s2[level]["label"]
|
||||
|
||||
def test_middle_best_zero_tolerance_guard(self):
|
||||
"""容忍度退化时不得生成 A~A 之类不可用表达式"""
|
||||
s = build_suggestion("middle_best", 0.0, tolerance=0.0)
|
||||
for level in ("green", "yellow", "red"):
|
||||
assert is_valid_threshold(s[level]["label"])
|
||||
|
||||
|
||||
class TestThresholdValidation:
|
||||
"""缺陷2:非法格式必须被拒(接口层 422),不再静默入库"""
|
||||
|
||||
def test_validate_helper(self):
|
||||
assert validate_threshold_format({"threshold_green": ">=800"}) == []
|
||||
assert validate_threshold_format({"threshold_yellow": ""}) == []
|
||||
assert validate_threshold_format({"threshold_red": None}) == []
|
||||
errs = validate_threshold_format({"threshold_yellow": "偏离55%"})
|
||||
assert len(errs) == 1 and "阈值格式非法" in errs[0]
|
||||
|
||||
def test_update_kpi_rejects_bad_threshold(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
resp = client.put(f"/api/cma/kpis/{kpi.id}", headers=auth_header(token),
|
||||
json={"threshold_yellow": "偏离55%"})
|
||||
assert resp.status_code == 422
|
||||
assert "阈值格式非法" in str(resp.json())
|
||||
|
||||
def test_update_kpi_accepts_valid_thresholds(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db)
|
||||
for good in (">=800", ">=-100000", "52.33~107.67", "<=36|>=124", ""):
|
||||
resp = client.put(f"/api/cma/kpis/{kpi.id}", headers=auth_header(token),
|
||||
json={"threshold_green": good})
|
||||
assert resp.status_code == 200, f"{good} 应被接受"
|
||||
|
||||
|
||||
class TestSuggestApi:
|
||||
"""接口级:suggest 返回的 label 可解析 + 挂 format_hint"""
|
||||
|
||||
def test_suggest_by_type_parseable(self, client: TestClient, db: Session):
|
||||
create_test_user(db)
|
||||
token = get_token_for_user(client)
|
||||
kpi = create_test_kpi(db, kpi_code="F_REVENUE", target_value=1000)
|
||||
resp = client.get(f"/api/cma/thresholds/suggest/{kpi.id}", headers=auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
labels = [body["suggestion"][lv]["label"] for lv in ("green", "yellow", "red")]
|
||||
for lb in labels:
|
||||
assert is_valid_threshold(lb), f"建议值不可解析: {lb}"
|
||||
assert body.get("format_hint")
|
||||
@@ -0,0 +1,263 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""统一阈值解析器 + 红黄绿判定顺序(红灯优先)单测
|
||||
|
||||
任务:project-20260911-threshold-fix
|
||||
覆盖:解析器(正/负/小数/区间/多条件/非法)、判定顺序、双调用方接线、存量表达式回归。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
BACKEND = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if BACKEND not in sys.path:
|
||||
sys.path.insert(0, BACKEND)
|
||||
|
||||
from app.utils.threshold_parser import ( # noqa: E402
|
||||
LEVEL_PRIORITY,
|
||||
calc_level,
|
||||
calc_level_from_exprs,
|
||||
describe_level,
|
||||
eval_threshold,
|
||||
is_valid_threshold,
|
||||
parse_clauses,
|
||||
parse_threshold,
|
||||
)
|
||||
|
||||
|
||||
class _Kpi:
|
||||
"""轻量替身:只需要 3 个阈值属性"""
|
||||
|
||||
def __init__(self, g=None, y=None, r=None):
|
||||
self.threshold_green = g
|
||||
self.threshold_yellow = y
|
||||
self.threshold_red = r
|
||||
|
||||
|
||||
# ───────────────────────── ① 解析器 ─────────────────────────
|
||||
|
||||
@pytest.mark.parametrize("expr,expected", [
|
||||
(">=800", (">=", 800.0)),
|
||||
("<=25", ("<=", 25.0)),
|
||||
(">60", (">", 60.0)),
|
||||
("<20", ("<", 20.0)),
|
||||
("=100", ("=", 100.0)),
|
||||
("!=0", ("!=", 0.0)),
|
||||
(">=-100000", (">=", -100000.0)), # 缺陷3:负数
|
||||
("<-100000", ("<", -100000.0)),
|
||||
("<-1.5", ("<", -1.5)),
|
||||
(">=1.0", (">=", 1.0)),
|
||||
(">= 800", (">=", 800.0)), # 允许空白
|
||||
(" >=800 ", (">=", 800.0)),
|
||||
("52.33~107.67", ("~", (52.33, 107.67))), # 区间
|
||||
("-182678~183078", ("~", (-182678.0, 183078.0))),
|
||||
])
|
||||
def test_parse_threshold_ok(expr, expected):
|
||||
assert parse_threshold(expr) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expr", ["偏离55%", "偏离>55%", "80%", ">=abc", "~", ">=", "52.33~",
|
||||
">=1>=2", "abc", ">=800元", ">=- 100"])
|
||||
def test_parse_threshold_invalid(expr):
|
||||
assert parse_threshold(expr) == (None, None)
|
||||
assert is_valid_threshold(expr) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expr", [None, "", " "])
|
||||
def test_blank_is_valid_but_no_clause(expr):
|
||||
"""空串 = 不设置阈值:视为合法,但无子句。"""
|
||||
assert is_valid_threshold(expr) is True
|
||||
assert parse_clauses(expr) == []
|
||||
assert parse_threshold(expr) == (None, None)
|
||||
|
||||
|
||||
def test_multi_clause():
|
||||
assert is_valid_threshold("<=36|>=124") is True
|
||||
assert is_valid_threshold("<=36 | >=124") is True
|
||||
assert is_valid_threshold("<=36|>=124") is True # 全角竖线
|
||||
assert is_valid_threshold("<=36|abc") is False
|
||||
assert parse_clauses("<=36|>=124") == [("<=", 36.0), (">=", 124.0)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("actual,expr,expected", [
|
||||
(800, ">=800", True), (799.99, ">=800", False),
|
||||
(25, "<=25", True), (25.01, "<=25", False),
|
||||
(61, ">60", True), (60, ">60", False),
|
||||
(19, "<20", True), (20, "<20", False),
|
||||
(-100000, ">=-100000", True), (-100001, ">=-100000", False),
|
||||
(-100001, "<-100000", True),
|
||||
(80, "52.33~107.67", True), (52.33, "52.33~107.67", True),
|
||||
(107.67, "52.33~107.67", True), (107.68, "52.33~107.67", False),
|
||||
(200, "<24.66|>135.34", True), (10, "<24.66|>135.34", True),
|
||||
(80, "<24.66|>135.34", False),
|
||||
(None, ">=0", False),
|
||||
(5, "偏离55%", False), # 非法表达式 → 不命中(不静默乱判)
|
||||
(5, "", False),
|
||||
])
|
||||
def test_eval_threshold(actual, expr, expected):
|
||||
assert eval_threshold(actual, expr) is expected
|
||||
|
||||
|
||||
# ─────────────────── ② 判定顺序:红灯优先 ───────────────────
|
||||
|
||||
def test_level_priority_is_red_first():
|
||||
assert LEVEL_PRIORITY == ("red", "green", "yellow")
|
||||
|
||||
|
||||
def test_defect5_yellow_no_longer_masks_red():
|
||||
"""缺陷5 实测:id=6 G='<=76' Y='>76' R='>91',值 95 必须判红(原来判黄)。"""
|
||||
kpi = _Kpi(g="<=76", y=">76", r=">91")
|
||||
assert calc_level(95, kpi) == "red" # 关键用例
|
||||
assert calc_level(92, kpi) == "red"
|
||||
assert calc_level(91, kpi) == "yellow" # 未过 >91 → 黄
|
||||
assert calc_level(80, kpi) == "yellow"
|
||||
assert calc_level(76, kpi) == "green" # <=76 → 绿
|
||||
assert calc_level(70, kpi) == "green"
|
||||
|
||||
|
||||
def test_green_beats_wide_yellow():
|
||||
"""id=1 实测:G='>=800' Y='>=700',值 900 不能判黄。"""
|
||||
kpi = _Kpi(g=">=800", y=">=700", r="<600")
|
||||
assert calc_level(900, kpi) == "green"
|
||||
assert calc_level(750, kpi) == "yellow"
|
||||
assert calc_level(500, kpi) == "red"
|
||||
assert calc_level(650, kpi) == "info" # 600~700 无命中
|
||||
|
||||
|
||||
def test_negative_thresholds():
|
||||
"""id=32:Y='>=-100000' R='<-100000' 存量负数表达式(缺陷3)。"""
|
||||
kpi = _Kpi(g=">=0", y=">=-100000", r="<-100000")
|
||||
assert calc_level(100, kpi) == "green"
|
||||
assert calc_level(-50000, kpi) == "yellow"
|
||||
assert calc_level(-200000, kpi) == "red"
|
||||
|
||||
|
||||
def test_moderate_range_semantics():
|
||||
"""区间型(middle_best):绿内区间 → 黄外环 → 红区间外。"""
|
||||
kpi = _Kpi(g="52.33~107.67", y="24.66~135.34", r="<24.66|>135.34")
|
||||
assert calc_level(80, kpi) == "green"
|
||||
assert calc_level(52.8, kpi) == "green"
|
||||
assert calc_level(124.1, kpi) == "yellow"
|
||||
assert calc_level(30, kpi) == "yellow"
|
||||
assert calc_level(200, kpi) == "red"
|
||||
assert calc_level(10, kpi) == "red"
|
||||
|
||||
|
||||
def test_no_threshold_and_none_actual():
|
||||
assert calc_level(100, _Kpi()) == "info"
|
||||
assert calc_level(None, _Kpi(g=">=0", y=">=0", r=">=0")) == "info"
|
||||
assert calc_level_from_exprs(50, green=None, yellow=None, red=None) == "info"
|
||||
|
||||
|
||||
def test_describe_level():
|
||||
kpi = _Kpi(g="<=76", y=">76", r=">91")
|
||||
assert describe_level(95, kpi) == ("red", ">91")
|
||||
assert describe_level(80, kpi) == ("yellow", ">76")
|
||||
assert describe_level(70, kpi) == ("green", "<=76")
|
||||
assert describe_level(0, kpi) == ("green", "<=76")
|
||||
assert describe_level(None, kpi) == ("info", None)
|
||||
|
||||
|
||||
# ─────────────────── ③ 双调用方接线(消除双实现) ───────────────────
|
||||
|
||||
def test_alert_generator_uses_unified_parser():
|
||||
"""alert_generator 必须委托统一解析器(负数/区间/多条件都生效)。"""
|
||||
from scripts import alert_generator as ag
|
||||
|
||||
assert ag._parse_threshold(">=-100000") == (">=", -100000.0)
|
||||
assert ag._parse_threshold("52.33~107.67") == ("~", (52.33, 107.67))
|
||||
|
||||
trig, msg = ag._check_threshold(95, ">91", "red")
|
||||
assert trig is True and ">91" in msg
|
||||
assert ag._check_threshold(95, ">91x", "red") == (False, "")
|
||||
assert ag._check_threshold(None, ">91", "red") == (False, "")
|
||||
# 区间 + 多条件(旧实现做不到)
|
||||
assert ag._check_threshold(124.1, "24.66~135.34", "yellow")[0] is True
|
||||
assert ag._check_threshold(10, "<24.66|>135.34", "red")[0] is True
|
||||
|
||||
|
||||
def test_alert_generator_loop_order_is_red_first():
|
||||
"""判定循环顺序必须与 LEVEL_PRIORITY 一致(红灯优先)。"""
|
||||
import inspect
|
||||
from scripts import alert_generator as ag
|
||||
|
||||
src = inspect.getsource(ag.run_alert_check)
|
||||
assert "for level in LEVEL_PRIORITY" in src
|
||||
assert 'for level in ["green", "yellow", "red"]' not in src
|
||||
|
||||
|
||||
def test_kpis_static_threshold_level_uses_unified_parser():
|
||||
from app.api.kpis import _static_threshold_level
|
||||
|
||||
kpi = _Kpi(g=">=0", y=">=-100000", r="<-100000")
|
||||
assert _static_threshold_level(-200000, kpi) == "red"
|
||||
assert _static_threshold_level(-50000, kpi) == "yellow"
|
||||
assert _static_threshold_level(100, kpi) == "green"
|
||||
|
||||
|
||||
def test_kpis_validate_threshold_format():
|
||||
from app.api.kpis import validate_threshold_format
|
||||
|
||||
assert validate_threshold_format({}) == []
|
||||
assert validate_threshold_format({"threshold_green": ">=800", "threshold_yellow": "",
|
||||
"threshold_red": None}) == []
|
||||
errs = validate_threshold_format({"threshold_yellow": "偏离55%"})
|
||||
assert len(errs) == 1 and "threshold_yellow" in errs[0]
|
||||
errs2 = validate_threshold_format({"threshold_green": "80%", "threshold_red": "偏离>55%"})
|
||||
assert len(errs2) == 2
|
||||
|
||||
|
||||
def test_suggest_labels_are_parseable():
|
||||
"""suggest 生成的 label 必须可解析(缺陷1 根因)。"""
|
||||
from app.api import thresholds as th
|
||||
|
||||
def _walk(obj, acc):
|
||||
if isinstance(obj, dict):
|
||||
if "label" in obj and obj.get("label") is not None:
|
||||
acc.append(obj["label"])
|
||||
for v in obj.values():
|
||||
_walk(v, acc)
|
||||
elif isinstance(obj, list):
|
||||
for v in obj:
|
||||
_walk(v, acc)
|
||||
|
||||
class _K:
|
||||
def __init__(self, code, target):
|
||||
self.id = 1
|
||||
self.kpi_code = code
|
||||
self.kpi_name = code
|
||||
self.target_value = target
|
||||
|
||||
for code in ("SALES_TOTAL", "COST_CONTROL_RATE", "TOP5_CUSTOMER_RATIO", "MADE_UP_CODE"):
|
||||
out = th.suggest_by_type(_K(code, 80.0))
|
||||
labels = []
|
||||
_walk(out, labels)
|
||||
assert labels, code
|
||||
for lb in labels:
|
||||
assert is_valid_threshold(lb), f"{code} 生成了不可解析 label: {lb!r}"
|
||||
|
||||
|
||||
def test_existing_266_expressions_still_parse():
|
||||
"""回归:库里现存的表达式语法必须全部可解析(除已修正的存量 4 条外为 0 不可解析)。"""
|
||||
from app.database import get_session_local
|
||||
from app.models import KPIDefinition
|
||||
|
||||
try:
|
||||
db = get_session_local()()
|
||||
except Exception as e: # 无DB环境(CI)时跳过
|
||||
pytest.skip(f"DB 不可用: {e}")
|
||||
try:
|
||||
rows = db.query(KPIDefinition).filter(
|
||||
(KPIDefinition.threshold_green.isnot(None)) |
|
||||
(KPIDefinition.threshold_yellow.isnot(None)) |
|
||||
(KPIDefinition.threshold_red.isnot(None))
|
||||
).all()
|
||||
bad = []
|
||||
for k in rows:
|
||||
for f in ("threshold_green", "threshold_yellow", "threshold_red"):
|
||||
e = getattr(k, f)
|
||||
if e and str(e).strip() and not is_valid_threshold(e):
|
||||
bad.append((k.id, k.kpi_code, f, str(e)))
|
||||
assert bad == [], f"仍存在不可解析阈值: {bad}"
|
||||
finally:
|
||||
db.close()
|
||||
Reference in New Issue
Block a user