# -*- 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)