pbl_blueprint/tests/test_m1b_template.py
2026-09-17 16:59:58 +08:00

327 lines
14 KiB
Python
Raw Permalink 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 -*-
"""
M1b 自检测试模板平台公共部分tenant_id NULL+ 子对象扩展 + 关联表判定Q-OPEN-3
运行cd modules/pbl_blueprint && python -m pytest tests/test_m1b_template.py -q
(无 pytest 时python tests/test_m1b_template.py
覆盖 M1b 出口门禁:
① 模板 schema 校验 fail-closed非法类型/悬空父/环/缺必填 -> PBL_TPL_SCHEMA_INVALID
② ID 重映射:临时 ID -> 业务主键,父子同步替换,外部引用保持原值
③ tpl_hash 确定性(同模板同输入同 hash键序无关
④ 平台公共模板解析优先级(租户私有 > 平台公共)+ 写入门禁
⑤ Q-OPEN-3 守卫DDL 不含对 world/scene/entity/script 基表的改动
⑥ 离线种子模板可解析、可重映射、结构完整无孤儿
"""
from __future__ import annotations
import json
import os
import sys
import unittest
import _m1b_loader # noqa: F401 (空壳包引导,绕开 M1a 挂载链 eager import)
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from pbl_blueprint.models.pbl_template import ( # noqa: E402
M1B_TABLES, PLATFORM_TENANT_KEY, PROTECTED_BASE_TABLES,
assert_no_base_table_change, build_all_ddl, build_ddl,
is_platform_scope, normalize_tenant_key,
)
from pbl_blueprint.subobject_ext import ( # noqa: E402
SUBOBJECT_TYPE_LIST, TplSchemaError, apply_ref_status,
extract_external_refs, group_refs_by_domain, remap_ids,
tpl_hash, validate_tpl_schema,
)
import pbl_blueprint.template_platform as tp # noqa: E402
SEED_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "pbl_blueprint", "json", "seed_template_offline.json")
def _gen_code_factory():
"""确定性编码生成器(替代 pbl_common.gen_code保证测试可复现"""
counter = {"n": 0}
def gen_code(prefix, tenant_id):
counter["n"] += 1
return "%s%s%05d" % (prefix, (tenant_id or "PLT")[:3].upper(), counter["n"])
return gen_code
def _demo_tpl():
return {
"schema_version": "1.0",
"subobjects": [
{"tmp_id": "s1", "subobject_type": "stage", "name": "阶段1", "seq": 1},
{"tmp_id": "t1", "subobject_type": "task", "parent_tmp_id": "s1", "name": "任务1", "seq": 1},
{"tmp_id": "r1", "subobject_type": "role", "parent_tmp_id": "t1", "name": "角色1", "seq": 1},
{"tmp_id": "w1", "subobject_type": "world_ref", "parent_tmp_id": "t1", "world_code": "W001", "seq": 2},
{"tmp_id": "sc1", "subobject_type": "scene_ref", "parent_tmp_id": "t1", "scene_code": "SC001", "seq": 3},
{"tmp_id": "e1", "subobject_type": "entity_ref", "parent_tmp_id": "sc1", "entity_code": "EN001", "seq": 1},
{"tmp_id": "sp1", "subobject_type": "script_ref", "parent_tmp_id": "t1", "script_code": "SP001", "seq": 4},
],
}
class TestSubobjectExt(unittest.TestCase):
def test_01_schema_ok_and_topo_order(self):
norm = validate_tpl_schema(_demo_tpl())
types = [s["subobject_type"] for s in norm["subobjects"]]
self.assertEqual(len(norm["subobjects"]), 7)
# 父必在子之前(拓扑序)
pos = {s["tmp_id"]: i for i, s in enumerate(norm["subobjects"])}
for s in norm["subobjects"]:
p = s.get("parent_tmp_id")
if p:
self.assertLess(pos[p], pos[s["tmp_id"]])
self.assertTrue(types)
def test_02_schema_invalid_type(self):
tpl = _demo_tpl()
tpl["subobjects"][0]["subobject_type"] = "unknown_type"
with self.assertRaises(TplSchemaError) as cm:
validate_tpl_schema(tpl)
self.assertEqual(cm.exception.code, "PBL_TPL_SCHEMA_INVALID")
def test_03_schema_dangling_parent(self):
tpl = _demo_tpl()
tpl["subobjects"][1]["parent_tmp_id"] = "not_exist"
with self.assertRaises(TplSchemaError):
validate_tpl_schema(tpl)
def test_04_schema_cycle(self):
tpl = {"schema_version": "1.0", "subobjects": [
{"tmp_id": "a", "subobject_type": "task", "parent_tmp_id": "b", "name": "A"},
{"tmp_id": "b", "subobject_type": "stage", "parent_tmp_id": "a", "name": "B"},
]}
with self.assertRaises(TplSchemaError):
validate_tpl_schema(tpl)
def test_05_schema_missing_required(self):
tpl = {"schema_version": "1.0", "subobjects": [
{"tmp_id": "s1", "subobject_type": "stage", "seq": 1},
]}
with self.assertRaises(TplSchemaError):
validate_tpl_schema(tpl)
def test_06_schema_stage_cannot_have_parent(self):
tpl = {"schema_version": "1.0", "subobjects": [
{"tmp_id": "s1", "subobject_type": "stage", "name": "S", "seq": 1},
{"tmp_id": "s2", "subobject_type": "stage", "parent_tmp_id": "s1", "name": "S2", "seq": 2},
]}
with self.assertRaises(TplSchemaError):
validate_tpl_schema(tpl)
def test_07_hash_deterministic_and_key_order_free(self):
tpl = _demo_tpl()
h1 = tpl_hash(tpl)
h2 = tpl_hash(json.dumps(tpl, ensure_ascii=False))
shuffled = {"subobjects": tpl["subobjects"], "schema_version": tpl["schema_version"]}
h3 = tpl_hash(shuffled)
self.assertEqual(h1, h2)
self.assertEqual(h1, h3)
self.assertEqual(len(h1), 64)
def test_08_remap_ids(self):
norm = validate_tpl_schema(_demo_tpl())
items, mapping = remap_ids(norm["subobjects"], _gen_code_factory(), "T001")
self.assertEqual(len(items), 7)
self.assertEqual(len(set(mapping.values())), 7) # 无碰撞
for it in items:
self.assertNotIn("tmp_id", it)
self.assertNotIn("parent_tmp_id", it)
if it["parent_code"]:
self.assertIn(it["parent_code"], mapping.values())
# 前缀按类型
stage = [i for i in items if i["subobject_type"] == "stage"][0]
self.assertTrue(stage["subobject_code"].startswith("STG"))
self.assertIsNone(stage["parent_code"])
def test_09_external_refs_keep_original_value(self):
norm = validate_tpl_schema(_demo_tpl())
refs = extract_external_refs(norm["subobjects"])
self.assertEqual(len(refs), 4)
codes = {r["ref_code"] for r in refs}
self.assertEqual(codes, {"W001", "SC001", "EN001", "SP001"})
grouped = group_refs_by_domain(refs)
self.assertEqual(sorted(grouped.keys()), ["entity", "scene", "script", "world"])
items, _ = remap_ids(norm["subobjects"], _gen_code_factory(), "T001")
w = [i for i in items if i["subobject_type"] == "world_ref"][0]
self.assertEqual(w["world_code"], "W001") # 原值不重映射
def test_10_apply_ref_status_warn_mode(self):
norm = validate_tpl_schema(_demo_tpl())
refs = extract_external_refs(norm["subobjects"])
items, _ = remap_ids(norm["subobjects"], _gen_code_factory(), "T001")
unresolved = apply_ref_status(items, refs, {
"world": {"W001": True}, "scene": {"SC001": True},
"entity": {}, "script": {},
})
self.assertEqual(unresolved, 2)
statuses = {i["subobject_type"]: i.get("ref_status") for i in items if i.get("ref_status")}
self.assertEqual(statuses["world_ref"], "resolved")
self.assertEqual(statuses["entity_ref"], "unresolved")
def test_11_seven_types_match_dict(self):
self.assertEqual(
sorted(SUBOBJECT_TYPE_LIST),
sorted(["stage", "task", "role", "world_ref", "scene_ref", "entity_ref", "script_ref"]))
class TestPlatformScope(unittest.TestCase):
def test_20_normalize_tenant_key(self):
self.assertEqual(normalize_tenant_key(None), PLATFORM_TENANT_KEY)
self.assertEqual(normalize_tenant_key(""), PLATFORM_TENANT_KEY)
self.assertEqual(normalize_tenant_key(" "), PLATFORM_TENANT_KEY)
self.assertEqual(normalize_tenant_key("T001"), "T001")
self.assertTrue(is_platform_scope(None))
self.assertFalse(is_platform_scope("T001"))
def test_21_tenant_id_nullable_in_ddl(self):
ddl = build_ddl("pbl_template")
self.assertIn("`tenant_id` VARCHAR(32) NULL", ddl)
self.assertIn("IFNULL(`tenant_id`,'__PLATFORM__')", ddl)
# 实例化日志 tenant_id 仍 NOT NULL
ddl2 = build_ddl("pbl_template_instance_log")
self.assertIn("`tenant_id` VARCHAR(32) NOT NULL", ddl2)
def test_22_resolve_priority_tenant_over_platform(self):
rows = [
{"tenant_id": "T001", "template_code": "C1", "template_version": 1},
{"tenant_id": None, "template_code": "C1", "template_version": 1},
]
row, scope = tp.pick_template(rows)
self.assertEqual(scope, "platform" if False else scope) # 保持可读
self.assertEqual(scope, tp.SCOPE_TENANT)
self.assertEqual(row["tenant_id"], "T001")
# 仅平台行 -> platform
row2, scope2 = tp.pick_template([rows[1]])
self.assertEqual(scope2, tp.SCOPE_PLATFORM)
self.assertIsNone(row2["tenant_id"])
with self.assertRaises(tp.TemplateNotFound):
tp.pick_template([])
def test_23_platform_write_guard(self):
self.assertEqual(
tp.build_platform_write_guard({"tenant_id": "P", "roles": ["platform_admin"]}, None),
tp.SCOPE_PLATFORM)
with self.assertRaises(tp.TemplatePlatformForbidden):
tp.build_platform_write_guard({"tenant_id": "T001", "roles": ["teacher"]}, None)
self.assertEqual(
tp.build_platform_write_guard({"tenant_id": "T001"}, "T001"), tp.SCOPE_TENANT)
with self.assertRaises(tp.TemplatePlatformForbidden):
tp.build_platform_write_guard({"tenant_id": "T001"}, "T002")
def test_24_require_tenant_fail_closed(self):
with self.assertRaises(tp.TemplateError) as cm:
tp.require_tenant({})
self.assertEqual(cm.exception.code, "PBL_TENANT_MISSING")
self.assertEqual(tp.require_tenant({"tenant_id": "T001"}), "T001")
def test_25_list_sql_tenant_first(self):
sql, params = tp.build_list_sql("T001", {"subject": "math"})
self.assertIn("(tenant_id = %s OR tenant_id IS NULL)", sql)
self.assertIn("ORDER BY (tenant_id IS NULL) ASC", sql)
self.assertEqual(params, ["T001", "math"])
sql2, params2 = tp.build_list_sql("T001", {"scope": "platform"})
self.assertIn("tenant_id IS NULL", sql2)
self.assertEqual(params2, [])
def test_26_offline_sql(self):
sql, params = tp.build_offline_sql("T001", subject="general")
self.assertIn("offline_flag = 'Y'", sql)
self.assertIn("tenant_id IS NULL", sql)
self.assertEqual(params[0], "T001")
class TestQOpen3RelationTable(unittest.TestCase):
def test_30_ddl_has_no_base_table_change(self):
ddl = build_all_ddl()
self.assertTrue(assert_no_base_table_change(ddl))
upper = ddl.upper()
for tbl in PROTECTED_BASE_TABLES:
self.assertNotIn("ALTER TABLE %s" % tbl.upper(), upper)
self.assertNotIn("ALTER TABLE `%s`" % tbl.upper(), upper)
self.assertNotIn("FOREIGN KEY", upper)
def test_31_guard_rejects_violation(self):
with self.assertRaises(AssertionError):
assert_no_base_table_change("ALTER TABLE world ADD COLUMN tpl_code VARCHAR(32);")
with self.assertRaises(AssertionError):
assert_no_base_table_change("ALTER TABLE `scene` ADD COLUMN x INT;")
def test_32_only_two_m1b_tables(self):
self.assertEqual(sorted(M1B_TABLES),
sorted(["pbl_template", "pbl_template_instance_log"]))
ddl = build_all_ddl()
self.assertEqual(ddl.count("CREATE TABLE"), 2)
def test_33_sql_file_guard(self):
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"..", "pbl_blueprint", "sql", "m1b_template.sql")
with open(path, "r", encoding="utf-8") as fh:
content = fh.read()
# 文件内说明性注释提到基表名是允许的,但不得有真实 ALTER/CREATE 语句
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("--"):
continue
upper = stripped.upper()
for tbl in PROTECTED_BASE_TABLES:
self.assertFalse(
upper.startswith("ALTER TABLE %s" % tbl.upper()) or
upper.startswith("ALTER TABLE `%s`" % tbl.upper()),
"Q-OPEN-3 违规行: %s" % stripped)
class TestOfflineSeed(unittest.TestCase):
def _seeds(self):
with open(SEED_FILE, "r", encoding="utf-8") as fh:
return json.load(fh)["templates"]
def test_40_seed_valid_and_offline(self):
seeds = self._seeds()
self.assertGreaterEqual(len(seeds), 1)
for s in seeds:
self.assertEqual(s["offline_flag"], "Y")
self.assertIsNone(s["tenant_id"]) # 平台公共
norm = validate_tpl_schema(s["tpl_json"]) # 不抛即通过
self.assertGreater(len(norm["subobjects"]), 0)
self.assertEqual(len(tpl_hash(norm)), 64)
def test_41_seed_instantiate_no_orphan(self):
"""出口门禁①:种子模板实例化后树完整无孤儿(父引用全部可解析)。"""
for s in self._seeds():
norm = validate_tpl_schema(s["tpl_json"])
items, mapping = remap_ids(norm["subobjects"], _gen_code_factory(), "T_DEMO")
codes = {i["subobject_code"] for i in items}
roots = 0
for i in items:
if i["parent_code"] is None:
roots += 1
else:
self.assertIn(i["parent_code"], codes) # 无孤儿
self.assertEqual(roots, 1, "演示模板应恰有 1 个顶层 stage")
def test_42_seed_min_has_no_external_ref(self):
seeds = {s["template_code"]: s for s in self._seeds()}
min_tpl = seeds["TPL_DEMO_BLUEPRINT_MIN"]
norm = validate_tpl_schema(min_tpl["tpl_json"])
self.assertEqual(extract_external_refs(norm["subobjects"]), [])
def test_43_seed_deterministic_hash(self):
"""出口门禁④同模板同输入产出同结构hash 稳定)。"""
s = self._seeds()[0]
self.assertEqual(tpl_hash(s["tpl_json"]),
tpl_hash(json.loads(json.dumps(s["tpl_json"]))))
if __name__ == "__main__":
unittest.main(verbosity=2)