pbl_validation/tests/test_validation_engine.py
2026-09-17 23:45:03 +08:00

548 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""pbl_validation 引擎自测(纯函数层,不依赖 DB / 平台)。
运行python3 modules/pbl_validation/tests/test_validation_engine.py
或: cd modules/pbl_validation && python3 -m pytest tests -q
"""
from __future__ import annotations
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pbl_validation.constants import ( # noqa: E402
DEFAULT_THRESHOLDS,
QUALITY_BASIC,
QUALITY_DRAFT,
QUALITY_EXCELLENT,
QUALITY_GOOD,
QUALITY_INCOMPLETE,
QUALITY_STATES,
resolve_thresholds,
)
from pbl_validation.dimensions import ( # noqa: E402
DIM_COUNT,
DIMENSION_REGISTRY,
SUB_OBJECT_KINDS,
dimension_meta,
get_checker,
)
from pbl_validation.engine import ( # noqa: E402
ValidationError,
decide_quality_state,
is_publishable,
normalize_payload,
payload_fingerprint,
report_to_row,
run_validation,
)
PASSED = []
FAILED = []
def check(name, cond, detail=""):
if cond:
PASSED.append(name)
print(" PASS %s" % name)
else:
FAILED.append((name, detail))
print(" FAIL %s %s" % (name, detail))
def good_blueprint():
"""构造一个应判 Q4_EXCELLENT 的完整蓝图。"""
objects = []
objects.append({"kind": "objective", "id": "OBJ1", "name": "掌握供需建模",
"description": "学生能够建立并求解供需平衡模型"})
objects.append({"kind": "objective", "id": "OBJ2", "name": "团队协作决策",
"description": "学生能够在多角色协作中做出权衡决策"})
objects.append({"kind": "role", "id": "R1", "name": "生产经理",
"description": "负责产能规划与排产决策"})
objects.append({"kind": "role", "id": "R2", "name": "销售总监",
"description": "负责订单承接与价格策略"})
objects.append({"kind": "scene", "id": "S1", "name": "第一季度经营会议",
"description": "季度经营决策场景"})
objects.append({"kind": "entity", "id": "E1", "name": "产品",
"attributes": [{"name": "单价", "type": "number"},
{"name": "库存", "type": "number"}]})
objects.append({"kind": "entity", "id": "E2", "name": "订单",
"attributes": [{"name": "数量", "type": "number"}]})
tasks = [
{"kind": "task", "id": "T1", "name": "需求预测", "objective_id": "OBJ1",
"role_id": "R1", "scene_id": "S1", "difficulty": 2, "duration_minutes": 20,
"evidence": [{"type": "report", "name": "预测报告"}], "next": "T2"},
{"kind": "task", "id": "T2", "name": "产能决策", "objective_id": "OBJ1",
"role_id": "R1", "scene_id": "S1", "difficulty": 3, "duration_minutes": 25,
"evidence": [{"type": "decision", "name": "排产方案"}],
"branches": [{"next": "T3"}, {"next": "T4"}]},
{"kind": "task", "id": "T3", "name": "扩产投资", "objective_id": "OBJ2",
"role_id": "R2", "scene_id": "S1", "difficulty": 4, "duration_minutes": 30,
"evidence": [{"type": "plan", "name": "投资计划"}], "next": "T5"},
{"kind": "task", "id": "T4", "name": "外包协作", "objective_id": "OBJ2",
"role_id": "R2", "scene_id": "S1", "difficulty": 3, "duration_minutes": 25,
"evidence": [{"type": "contract", "name": "外包合同"}], "next": "T5"},
{"kind": "task", "id": "T5", "name": "复盘汇报", "objective_id": "OBJ2",
"role_id": "R1", "scene_id": "S1", "difficulty": 2, "duration_minutes": 15,
"evidence": [{"type": "presentation", "name": "复盘PPT"}]},
]
objects.extend(tasks)
objects.append({"kind": "rule", "id": "RU1", "name": "库存下限约束",
"expression": "stock >= 100", "refs": ["E1", "T2"]})
objects.append({"kind": "assessment", "id": "A1", "name": "经营绩效量规",
"objective_id": "OBJ1",
"criteria": [{"name": "模型正确性", "weight": 0.5},
{"name": "决策合理性", "weight": 0.3},
{"name": "协作表现", "weight": 0.2}]})
return {
"root": {"name": "供应链经营沙盘", "driving_question": "如何在需求波动下实现利润最大化?",
"duration_minutes": 115},
"objects": objects,
}
def test_constants():
print("\n[1] 常量与阈值配置化")
check("mcRatio 默认 0.7Q-OPEN-8", DEFAULT_THRESHOLDS["mcRatio"] == 0.7,
str(DEFAULT_THRESHOLDS.get("mcRatio")))
check("5 级质量状态齐备", len(QUALITY_STATES) == 5, str(QUALITY_STATES))
check("质量状态顺序正确",
QUALITY_STATES == (QUALITY_DRAFT, QUALITY_INCOMPLETE, QUALITY_BASIC,
QUALITY_GOOD, QUALITY_EXCELLENT))
th = resolve_thresholds({"mcRatio": 0.85})
check("阈值覆盖生效", th["mcRatio"] == 0.85, str(th["mcRatio"]))
check("阈值覆盖不污染默认表", DEFAULT_THRESHOLDS["mcRatio"] == 0.7)
bad = resolve_thresholds({"mcRatio": 1.7, "unknownKey": 1, "minTasks": -3})
check("非法阈值被忽略并记录",
len(bad["_ignored"]) == 3 and bad["mcRatio"] == 0.7,
json.dumps(bad["_ignored"], ensure_ascii=False))
def test_dimensions_registry():
print("\n[2] 14 维注册表")
check("维度数 = 14", DIM_COUNT == 14, str(DIM_COUNT))
check("注册表长度 = 14", len(DIMENSION_REGISTRY) == 14)
dims = [d["dim"] for d in DIMENSION_REGISTRY]
check("维度编码 D01~D14 连续",
dims == ["D%02d" % i for i in range(1, 15)], str(dims))
check("7 类子对象定义齐备", len(SUB_OBJECT_KINDS) == 7, str(SUB_OBJECT_KINDS))
check("每个维度都有可调用 checker",
all(callable(d["checker"]) for d in DIMENSION_REGISTRY))
check("get_checker 支持 dim 与 code",
get_checker("D08") is not None and get_checker("task_chain") is not None)
check("get_checker 非法返回 None", get_checker("D99") is None)
meta = dimension_meta()
check("dimension_meta 不含函数对象",
len(meta) == 14 and all("checker" not in m for m in meta))
check("权重均为正数", all(d["weight"] > 0 for d in DIMENSION_REGISTRY))
def test_normalize():
print("\n[3] 载荷归一化与 fail-closed")
bp = good_blueprint()
check("dict 原样通过", normalize_payload(bp)["root"]["name"] == "供应链经营沙盘")
check("JSON 字符串可解析",
normalize_payload(json.dumps(bp, ensure_ascii=False))["root"]["name"]
== "供应链经营沙盘")
check("list 载荷转 objects", len(normalize_payload(bp["objects"])["objects"]) == 14)
for bad, label in ((None, "None"), ("", "空串"), ("{bad json", "非法JSON"),
(123, "数字")):
try:
normalize_payload(bad)
check("非法载荷拒绝:%s" % label, False, "未抛异常")
except ValidationError as exc:
check("非法载荷拒绝:%s" % label, exc.code.startswith("V-INPUT"), exc.code)
try:
run_validation(bp, tenant_id="")
check("tenant_id 缺失 fail-closed", False, "未抛异常")
except ValidationError as exc:
check("tenant_id 缺失 fail-closed", exc.code == "V-TENANT-001", exc.code)
def test_excellent():
print("\n[4] 完整蓝图 → Q4_EXCELLENT")
rep = run_validation(good_blueprint(), tenant_id="t1", blueprint_id="BP1", version="v1")
check("契约版本正确", rep["contract"] == "pbl.validation.report/1.0", rep["contract"])
check("执行 14 维", rep["dim_count"] == 14, str(rep["dim_count"]))
check("质量状态 Q4", rep["quality_state"] == QUALITY_EXCELLENT,
"%s / %s" % (rep["quality_state"], rep["quality_reasons"]))
check("无 fail 维度", rep["state_summary"]["fail"] == 0, str(rep["state_summary"]))
check("无 warn 维度", rep["state_summary"]["warn"] == 0, str(rep["state_summary"]))
check("无 blocker", rep["severity_summary"]["blocker"] == 0)
check("总分 >= 0.9", rep["total_score"] >= 0.9, str(rep["total_score"]))
check("passed=True", rep["passed"] is True)
check("发布门禁通过", is_publishable(rep)[0] is True)
d08 = [d for d in rep["dimensions"] if d["dim"] == "D08"][0]
check("D08 mcRatio = 1.0", d08["metrics"]["mcRatio"] == 1.0,
str(d08["metrics"]))
check("D08 阈值取 0.7", d08["metrics"]["mcRatio_threshold"] == 0.7)
check("D08 识别 2 条完整路径T2 双分支)", d08["metrics"]["path_count"] == 2,
str(d08["metrics"]["path_count"]))
check("D08 分支出口总数=2", d08["metrics"]["branch_exits_total"] == 2,
str(d08["metrics"]["branch_exits_total"]))
check("报告含 14 维元信息", len(rep["dimensions_meta"]) == 14)
check("确定性:同载荷两次指纹一致",
payload_fingerprint(good_blueprint(), rep["thresholds"])
== payload_fingerprint(good_blueprint(), rep["thresholds"]))
def test_mc_ratio_gate():
print("\n[5] mcRatio 阈值门禁Q-OPEN-8")
bp = good_blueprint()
# 把 T2 的一个分支指向不存在的任务 → 该分支出口未被路径覆盖
for o in bp["objects"]:
if o.get("id") == "T2":
o["branches"] = [{"next": "T3"}, {"next": "T_GHOST"}]
rep = run_validation(bp, tenant_id="t1", blueprint_id="BP2")
d08 = [d for d in rep["dimensions"] if d["dim"] == "D08"][0]
check("mcRatio 降到 0.5", d08["metrics"]["mcRatio"] == 0.5,
str(d08["metrics"]["mcRatio"]))
check("D08 判 fail", d08["state"] == "fail", d08["state"])
check("命中 V-TASK-004",
any(i["code"] == "V-TASK-004" for i in d08["issues"]),
json.dumps(d08["issues"], ensure_ascii=False))
check("命中悬空引用 V-TASK-003",
any(i["code"] == "V-TASK-003" for i in d08["issues"]))
check("质量状态被压到 Q1", rep["quality_state"] == QUALITY_INCOMPLETE,
rep["quality_state"])
# 阈值放宽到 0.5 → mcRatio 门禁不再触发(证明阈值配置化生效)
rep2 = run_validation(bp, tenant_id="t1", blueprint_id="BP2",
threshold_overrides={"mcRatio": 0.5})
d08b = [d for d in rep2["dimensions"] if d["dim"] == "D08"][0]
check("阈值放宽后不再命中 V-TASK-004",
not any(i["code"] == "V-TASK-004" for i in d08b["issues"]))
check("阈值放宽后 D08 阈值=0.5", d08b["metrics"]["mcRatio_threshold"] == 0.5)
check("报告回显生效阈值 mcRatio=0.5",
rep2["thresholds"]["mcRatio"] == 0.5)
def test_rule_json_override():
print("\n[6] rule_json 阈值覆盖优先级")
bp = good_blueprint()
rep = run_validation(bp, tenant_id="t1",
threshold_overrides={"mcRatio": 0.95},
rule_json={"thresholds": {"mcRatio": 0.6},
"weights": {"D08": 2.0},
"disabled_dims": ["D13"]})
check("rule_json 覆盖入参阈值", rep["thresholds"]["mcRatio"] == 0.6,
str(rep["thresholds"]["mcRatio"]))
d13 = [d for d in rep["dimensions"] if d["dim"] == "D13"][0]
check("D13 被禁用 → skip", d13["state"] == "skip", d13["state"])
check("skip 维度不计入权重和",
abs(rep["weight_sum"] - sum(d["weight"] for d in rep["dimensions"]
if d["state"] != "skip")) < 1e-6)
d08 = [d for d in rep["dimensions"] if d["dim"] == "D08"][0]
check("D08 权重被规则改为 2.0", d08["weight"] == 2.0, str(d08["weight"]))
def test_empty_blueprint():
print("\n[7] 空蓝图 → Q1_INCOMPLETE")
rep = run_validation({"root": {}}, tenant_id="t1", blueprint_id="BP_EMPTY")
check("空蓝图判 Q1", rep["quality_state"] == QUALITY_INCOMPLETE,
"%s %s" % (rep["quality_state"], rep["quality_reasons"]))
check("存在 blocker 问题", rep["severity_summary"]["blocker"] > 0,
str(rep["severity_summary"]))
check("passed=False", rep["passed"] is False)
check("发布门禁不通过", is_publishable(rep)[0] is False)
check("D01 fail", [d for d in rep["dimensions"] if d["dim"] == "D01"][0]["state"]
== "fail")
check("D10 缺量规 blocker",
any(i["code"] == "V-RUB-001"
for i in [d for d in rep["dimensions"] if d["dim"] == "D10"][0]["issues"]))
check("总分低于 Q2 线", rep["total_score"] < 0.4, str(rep["total_score"]))
def test_partial_blueprint():
print("\n[8] 部分完整蓝图 → Q2/Q3")
bp = good_blueprint()
# 去掉一半任务的 evidence → D11 覆盖率 0.4 < 0.8 判 fail非核心维度
n = 0
for o in bp["objects"]:
if o.get("kind") == "task":
n += 1
if n % 2 == 0:
o.pop("evidence", None)
rep = run_validation(bp, tenant_id="t1", blueprint_id="BP3")
d11 = [d for d in rep["dimensions"] if d["dim"] == "D11"][0]
check("D11 覆盖率 0.6", d11["metrics"]["coverage"] == 0.6,
str(d11["metrics"]))
check("D11 判 fail低于 0.8 阈值)", d11["state"] == "fail", d11["state"])
check("非核心维度 fail 不触发 R3",
not any(r.startswith("R3") for r in rep["quality_reasons"]),
str(rep["quality_reasons"]))
check("质量状态落在 Q2/Q3",
rep["quality_state"] in (QUALITY_BASIC, QUALITY_GOOD),
"%s %s" % (rep["quality_state"], rep["quality_reasons"]))
def test_safety():
print("\n[9] D14 安全合规扫描")
bp = good_blueprint()
bp["objects"].append({"kind": "rule", "id": "RU_BAD", "name": "危险规则",
"expression": "DROP TABLE pbl_blueprint;"})
rep = run_validation(bp, tenant_id="t1", blueprint_id="BP_SEC")
d14 = [d for d in rep["dimensions"] if d["dim"] == "D14"][0]
check("命中破坏性 SQL", any(i["code"] == "V-SEC-001" for i in d14["issues"]),
json.dumps(d14["issues"], ensure_ascii=False)[:200])
check("D14 判 fail", d14["state"] == "fail")
check("blocker 触发 R2 → Q1",
rep["quality_state"] == QUALITY_INCOMPLETE
and any(r.startswith("R2") for r in rep["quality_reasons"]),
str(rep["quality_reasons"]))
bp2 = good_blueprint()
bp2["root"]["note"] = "apikey = 'sk-abcdef123456'"
rep2 = run_validation(bp2, tenant_id="t1")
check("命中硬编码凭据",
any(i["code"] == "V-SEC-003"
for i in [d for d in rep2["dimensions"] if d["dim"] == "D14"][0]["issues"]))
def test_rubric_weight():
print("\n[10] D10 量规权重和=1")
bp = good_blueprint()
for o in bp["objects"]:
if o.get("kind") == "assessment":
o["criteria"] = [{"name": "A", "weight": 0.5}, {"name": "B", "weight": 0.3}]
rep = run_validation(bp, tenant_id="t1")
d10 = [d for d in rep["dimensions"] if d["dim"] == "D10"][0]
check("权重和 0.8 != 1 命中 V-RUB-004",
any(i["code"] == "V-RUB-004" for i in d10["issues"]),
json.dumps(d10["issues"], ensure_ascii=False))
check("D10 判 fail", d10["state"] == "fail")
check("核心维度 fail → R3 → Q1",
rep["quality_state"] == QUALITY_INCOMPLETE
and any(r.startswith("R3") for r in rep["quality_reasons"]),
str(rep["quality_reasons"]))
def test_cycle_and_dangling():
print("\n[11] 任务链环检测")
bp = good_blueprint()
for o in bp["objects"]:
if o.get("id") == "T5":
o["next"] = "T1" # 造环
rep = run_validation(bp, tenant_id="t1")
d08 = [d for d in rep["dimensions"] if d["dim"] == "D08"][0]
check("检出环形路径", d08["metrics"]["cyclic_paths"] > 0,
str(d08["metrics"]))
check("命中 V-TASK-005",
any(i["code"] == "V-TASK-005" for i in d08["issues"]))
check("D08 判 fail", d08["state"] == "fail")
def test_quality_rules():
print("\n[12] 5 级质量状态规则单测")
from pbl_validation.dimensions import DimResult, Issue
th = resolve_thresholds()
check("R1 无维度 → Q0",
decide_quality_state([], th, 0.0)[0] == QUALITY_DRAFT)
check("R1 全 skip → Q0",
decide_quality_state([DimResult("D01", "x", "skip")], th, 0.0)[0]
== QUALITY_DRAFT)
blocker_res = [DimResult("D02", "x", "warn", 0.9,
issues=[Issue("D02", "V-X", "blocker", "b")])]
st, why = decide_quality_state(blocker_res, th, 0.99)
check("R2 blocker 优先于高分 → Q1",
st == QUALITY_INCOMPLETE and why[0].startswith("R2"), str(why))
core_fail = [DimResult("D08", "x", "fail", 0.0), DimResult("D02", "y", "pass", 1.0)]
st, why = decide_quality_state(core_fail, th, 0.95)
check("R3 核心维度 fail → Q1",
st == QUALITY_INCOMPLETE and why[0].startswith("R3"), str(why))
noncore_fail = [DimResult("D02", "y", "pass", 1.0), DimResult("D12", "x", "fail", 0.0)]
st, why = decide_quality_state(noncore_fail, th, 0.30)
check("R4 总分 < 0.40 → Q1",
st == QUALITY_INCOMPLETE and why[0].startswith("R4"), str(why))
all_pass = [DimResult("D01", "a", "pass"), DimResult("D03", "b", "pass"),
DimResult("D08", "c", "pass"), DimResult("D10", "d", "pass"),
DimResult("D14", "e", "pass"), DimResult("D02", "f", "pass")]
st, why = decide_quality_state(all_pass, th, 0.95)
check("R5 高分无 warn → Q4",
st == QUALITY_EXCELLENT and why[0].startswith("R5"), str(why))
with_warn = all_pass + [DimResult("D12", "g", "warn", 0.6)]
st, why = decide_quality_state(with_warn, th, 0.92)
check("R5 有 warn 不给 Q4落 Q3",
st == QUALITY_GOOD and why[0].startswith("R6"), str(why))
st, why = decide_quality_state(with_warn, th, 0.55)
check("R7 中等分 → Q2",
st == QUALITY_BASIC and why[0].startswith("R7"), str(why))
core_warn = [DimResult("D01", "a", "warn", 0.6), DimResult("D03", "b", "pass"),
DimResult("D08", "c", "pass"), DimResult("D10", "d", "pass"),
DimResult("D14", "e", "pass")]
st, why = decide_quality_state(core_warn, th, 0.80)
check("R6 核心维度非全 pass 不给 Q3 → Q2",
st == QUALITY_BASIC and why[0].startswith("R7"), str(why))
def test_report_contract():
print("\n[13] 报告契约字段完备性")
rep = run_validation(good_blueprint(), tenant_id="t1", blueprint_id="BP1", version="v2")
required = ["contract", "engine_version", "report_id", "tenant_id", "blueprint_id",
"version", "ruleset", "created_at", "elapsed_ms", "fingerprint",
"quality_state", "quality_label", "quality_order", "quality_reasons",
"total_score", "weighted_sum", "weight_sum", "dim_count", "dim_total",
"state_summary", "severity_summary", "issue_count", "passed",
"dimensions", "issues", "thresholds", "engine_errors",
"dimensions_meta"]
missing = [k for k in required if k not in rep]
check("报告含全部契约字段", not missing, str(missing))
check("tenant_id 回显", rep["tenant_id"] == "t1")
check("blueprint_id/version 回显",
rep["blueprint_id"] == "BP1" and rep["version"] == "v2")
check("dim_total = 14", rep["dim_total"] == 14)
check("总分 = 加权和/权重和",
abs(rep["total_score"] - rep["weighted_sum"] / rep["weight_sum"]) < 1e-3,
"%s vs %s" % (rep["total_score"],
rep["weighted_sum"] / rep["weight_sum"]))
check("报告可 JSON 序列化", isinstance(json.dumps(rep, ensure_ascii=False), str))
d = rep["dimensions"][0]
check("维度项字段齐备",
all(k in d for k in ("dim", "name", "state", "score", "weight",
"weighted_score", "metrics", "issues")))
row = report_to_row(rep, operator="tester")
check("结果行 tenant_id 打头", row["tenant_id"] == "t1")
check("结果行 quality_state 一致", row["quality_state"] == rep["quality_state"])
check("结果行 dim_scores 为 JSON 串",
isinstance(json.loads(row["dim_scores"]), dict))
check("结果行 report_json 可反解",
json.loads(row["report_json"])["report_id"] == rep["report_id"])
check("结果行 state=done", row["state"] == "done")
def test_dims_subset():
print("\n[14] 指定维度子集执行")
rep = run_validation(good_blueprint(), tenant_id="t1", dims=["D08", "task_chain"])
check("去重后只跑 D08", rep["dim_count"] == 1, str(rep["dim_count"]))
check("维度为 D08", rep["dimensions"][0]["dim"] == "D08")
rep2 = run_validation(good_blueprint(), tenant_id="t1", dims=["D01"])
check("D01 单维可跑", rep2["dim_count"] == 1 and rep2["dimensions"][0]["dim"] == "D01")
def test_payload_shapes():
print("\n[15] 多种载荷形态兼容")
bp = good_blueprint()
kinds = {}
for o in bp["objects"]:
kinds.setdefault(o["kind"], []).append(o)
shape_b = {"root": bp["root"], "kinds": kinds}
rep_b = run_validation(shape_b, tenant_id="t1")
check("形态B(kinds) 判 Q4", rep_b["quality_state"] == QUALITY_EXCELLENT,
"%s %s" % (rep_b["quality_state"], rep_b["quality_reasons"]))
shape_c = {"name": "供应链经营沙盘",
"driving_question": "如何在需求波动下实现利润最大化?",
"duration_minutes": 115,
"objectives": [o for o in bp["objects"] if o["kind"] == "objective"],
"roles": [o for o in bp["objects"] if o["kind"] == "role"],
"scenes": [o for o in bp["objects"] if o["kind"] == "scene"],
"entities": [o for o in bp["objects"] if o["kind"] == "entity"],
"tasks": [o for o in bp["objects"] if o["kind"] == "task"],
"rules": [o for o in bp["objects"] if o["kind"] == "rule"],
"assessments": [o for o in bp["objects"] if o["kind"] == "assessment"]}
rep_c = run_validation(shape_c, tenant_id="t1")
check("形态C(顶层复数键) 判 Q4",
rep_c["quality_state"] == QUALITY_EXCELLENT,
"%s %s" % (rep_c["quality_state"], rep_c["quality_reasons"]))
wrapped = {"blueprint": bp}
rep_w = run_validation(wrapped, tenant_id="t1")
check("形态D({blueprint:...}) 判 Q4",
rep_w["quality_state"] == QUALITY_EXCELLENT,
"%s %s" % (rep_w["quality_state"], rep_w["quality_reasons"]))
def test_api_layer():
print("\n[16] 契约接口层(无 DB 环境 fail-closed / 离线兜底)")
from pbl_validation import api as vapi
check("API_REGISTRY 含 5 个契约", len(vapi.API_REGISTRY) == 5,
str(sorted(vapi.API_REGISTRY)))
expect = {"pbl_validation_run", "pbl_validation_get", "pbl_validation_list",
"pbl_validation_rule_list", "pbl_validation_rule_save"}
check("契约名与规格一致", set(vapi.API_REGISTRY) == expect,
str(set(vapi.API_REGISTRY) ^ expect))
check("内置规则 14 条", len(vapi.builtin_rules()) == 14)
check("内置规则 script_type=1",
all(r["script_type"] == 1 for r in vapi.builtin_rules()))
check("内置规则 rule_json 可解析",
all(isinstance(json.loads(r["rule_json"]), dict)
for r in vapi.builtin_rules()))
# tenant_id 缺失 → fail-closed不抛异常返回错误结构
res = vapi.pbl_validation_run({"blueprint": good_blueprint()})
check("run 缺 tenant_id 返回错误",
res["success"] is False and res["code"] in ("V-TENANT-001", "V-DB-001",
"V-DB-002"),
str(res.get("code")))
res2 = vapi.pbl_validation_run({"tenant_id": "t1"})
check("run 缺 blueprint 返回 V-INPUT-001",
res2["success"] is False and res2["code"] == "V-INPUT-001", str(res2))
res3 = vapi.pbl_validation_rule_save({"tenant_id": "t1", "dim": "D99"})
check("rule_save 非法 dim 被拒",
res3["success"] is False and res3["code"] == "V-INPUT-021", str(res3.get("code")))
res4 = vapi.pbl_validation_rule_save({"tenant_id": "t1", "dim": "D08",
"thresholds": {"mcRatio": 5}})
check("rule_save 非法阈值被拒fail-closed",
res4["success"] is False and res4["code"] == "V-INPUT-022", str(res4.get("code")))
res5 = vapi.pbl_validation_list({"tenant_id": "t1", "quality_state": "Q9"})
check("list 非法 quality_state 被拒",
res5["success"] is False and res5["code"] == "V-INPUT-011", str(res5.get("code")))
res6 = vapi.pbl_validation_get({"tenant_id": "t1"})
check("get 缺 id 被拒",
res6["success"] is False and res6["code"] == "V-INPUT-010", str(res6.get("code")))
# 无 DB 环境下 run 应离线兜底(内置规则)或明确报 DB 错误,不允许静默成功
res7 = vapi.pbl_validation_run({"tenant_id": "t1", "blueprint": good_blueprint(),
"persist": 0})
if res7["success"]:
check("离线兜底run 成功且用内置规则",
res7["data"]["rules_from_builtin"] is True
and res7["data"]["rule_count"] == 14
and res7["data"]["quality_state"] == QUALITY_EXCELLENT,
json.dumps({k: res7["data"][k] for k in
("rules_from_builtin", "rule_count", "quality_state")},
ensure_ascii=False))
check("离线兜底publishable=True", res7["data"]["publishable"] is True)
else:
check("无 DB 时明确报错(不静默)",
res7["code"] in ("V-DB-001", "V-DB-002"), str(res7))
def main():
print("=" * 72)
print("pbl_validation 引擎自测14 维 + 5 级质量状态 + 阈值配置化)")
print("=" * 72)
for fn in (test_constants, test_dimensions_registry, test_normalize,
test_excellent, test_mc_ratio_gate, test_rule_json_override,
test_empty_blueprint, test_partial_blueprint, test_safety,
test_rubric_weight, test_cycle_and_dangling, test_quality_rules,
test_report_contract, test_dims_subset, test_payload_shapes,
test_api_layer):
try:
fn()
except Exception as exc:
import traceback
traceback.print_exc()
FAILED.append((fn.__name__, "%s: %s" % (type(exc).__name__, exc)))
print("\n" + "=" * 72)
print("PASSED: %d FAILED: %d" % (len(PASSED), len(FAILED)))
if FAILED:
for name, detail in FAILED:
print(" - %s : %s" % (name, detail))
print("=" * 72)
return 1 if FAILED else 0
if __name__ == "__main__":
sys.exit(main())