# -*- coding: utf-8 -*- """M1b 测试:模板平台公共部分 / 子对象扩展 / 关联表判定。 运行:cd modules/pbl_blueprint && python -m pytest tests/test_m1b_ext_ref.py -q (无 pytest 时可直接 python tests/test_m1b_ext_ref.py) """ import os import sys import unittest import _m1b_loader # noqa: F401 (空壳包引导,绕开 M1a 挂载链 eager import) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from pbl_blueprint.m1b_common import ( # noqa: E402 IS_NULL, TenantRequiredError, PermissionDeniedError, ValidationError, NotFoundError, ConflictError, ) from pbl_blueprint import m1b_template as T # noqa: E402 from pbl_blueprint import m1b_subobject as S # noqa: E402 from pbl_blueprint import m1b_ref as R # noqa: E402 from pbl_blueprint import m1b_api as A # noqa: E402 from pbl_blueprint import m1b_init as I # noqa: E402 class FakeDB(object): """最小内存 DB:支持 query/insert/update/delete(与 m1b_common 适配器契约一致)。""" def __init__(self): self.tables = {} def query(self, table, conds=None, order_by=None, limit=None): return list(self.tables.get(table, [])) def insert(self, table, row): self.tables.setdefault(table, []).append(dict(row)) return row.get("id") def update(self, table, conds, values): n = 0 for r in self.tables.get(table, []): if all((r.get(k) in (None, "") if isinstance(v, type(IS_NULL)) else r.get(k) == v) for k, v in (conds or {}).items()): r.update(values) n += 1 return n def delete(self, table, conds): rows = self.tables.get(table, []) keep = [r for r in rows if not all(r.get(k) == v for k, v in (conds or {}).items())] self.tables[table] = keep return len(rows) - len(keep) TENANT = "t-001" OTHER = "t-002" class TestPlatformTemplate(unittest.TestCase): def setUp(self): self.db = FakeDB() def test_create_platform_template_requires_admin(self): with self.assertRaises(PermissionDeniedError): T.create_template(self.db, None, {"code": "p1", "name": "平台模板"}, actor="u1", is_platform_admin=False) def test_create_platform_template_ok_and_tenant_isolated(self): r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform", "content": {"a": 1}}, actor="admin", is_platform_admin=True) self.assertTrue(r["ok"]) self.assertTrue(r["is_platform"]) self.assertIsNone(r["template"]["tenant_id"]) # 租户可见平台公共模板 lst = T.list_templates(self.db, TENANT) self.assertEqual(lst["total"], 1) self.assertTrue(lst["items"][0]["is_platform"]) self.assertFalse(lst["items"][0]["editable"]) # 其他租户同样可见(公共) self.assertEqual(T.list_templates(self.db, OTHER)["total"], 1) def test_tenant_cannot_write_platform_template(self): T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform"}, actor="admin", is_platform_admin=True) lst = T.list_templates(self.db, TENANT) tid = lst["items"][0]["id"] with self.assertRaises(PermissionDeniedError): T.update_template(self.db, TENANT, tid, {"name": "改名"}, actor="u1", is_platform_admin=False) with self.assertRaises(PermissionDeniedError): T.delete_template(self.db, TENANT, tid, actor="u1", is_platform_admin=False) def test_tenant_template_not_visible_to_others(self): T.create_template(self.db, TENANT, {"code": "t1", "name": "租户模板"}, actor="u1") self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1) self.assertEqual(T.list_templates(self.db, OTHER)["total"], 0) tid = T.list_templates(self.db, TENANT)["items"][0]["id"] with self.assertRaises(NotFoundError): T.get_template(self.db, OTHER, tid) def test_missing_tenant_fail_closed(self): with self.assertRaises(TenantRequiredError): T.list_templates(self.db, None) with self.assertRaises(TenantRequiredError): T.list_templates(self.db, " ") def test_code_unique_per_scope(self): T.create_template(self.db, None, {"code": "same", "name": "平台", "scope": "platform"}, actor="admin", is_platform_admin=True) # 同 code 在不同租户域可共存 T.create_template(self.db, TENANT, {"code": "same", "name": "租户"}, actor="u1") with self.assertRaises(ConflictError): T.create_template(self.db, TENANT, {"code": "same", "name": "重复"}, actor="u1") with self.assertRaises(ConflictError): T.create_template(self.db, None, {"code": "same", "name": "重复平台", "scope": "platform"}, actor="admin", is_platform_admin=True) def test_fork_platform_to_tenant(self): r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform", "content": {"missions": [{"id": "m1"}]}, "ext_schema": {"mission": {"difficulty": "hard"}}}, actor="admin", is_platform_admin=True) f = T.fork_template(self.db, TENANT, r["id"], actor="u1", new_code="p1_local") self.assertTrue(f["ok"]) self.assertEqual(f["template"]["tenant_id"], TENANT) self.assertEqual(f["template"]["source"], "fork") self.assertEqual(f["template"]["source_template_id"], r["id"]) # 派生副本可写 T.update_template(self.db, TENANT, f["id"], {"name": "本地改名"}, actor="u1") # 平台模板未受影响 self.assertEqual(T.get_template(self.db, TENANT, r["id"])["name"], "平台模板") def test_builtin_template_cannot_be_deleted(self): r = T.create_template(self.db, None, {"code": "b1", "name": "内置", "scope": "platform", "is_builtin": 1, "content": {"x": 1}}, actor="admin", is_platform_admin=True) with self.assertRaises(PermissionDeniedError): T.delete_template(self.db, TENANT, r["id"], actor="admin", is_platform_admin=True) self.assertTrue(T.deprecate_template(self.db, TENANT, r["id"], actor="admin", is_platform_admin=True)["ok"]) def test_publish_requires_content(self): r = T.create_template(self.db, TENANT, {"code": "t1", "name": "空模板"}, actor="u1") with self.assertRaises(ValidationError): T.publish_template(self.db, TENANT, r["id"], actor="u1") T.update_template(self.db, TENANT, r["id"], {"content": {"missions": []}}, actor="u1") self.assertEqual(T.publish_template(self.db, TENANT, r["id"], actor="u1")["status"], "published") def test_instantiate_platform_template_sets_tenant(self): r = T.create_template(self.db, None, {"code": "p1", "name": "平台模板", "scope": "platform", "status": "published", "content": {"missions": [{"id": "m1"}]}, "subobject_kinds": ["mission"]}, actor="admin", is_platform_admin=True) captured = {} def fake_create(db, tenant_id, payload, actor=None): captured["tenant_id"] = tenant_id captured["payload"] = payload return {"ok": True, "id": "bp-1"} res = T.instantiate_template(self.db, TENANT, r["id"], actor="u1", create_blueprint=fake_create) self.assertTrue(res["ok"]) self.assertTrue(res["is_platform_template"]) self.assertEqual(captured["tenant_id"], TENANT) # 实例绝不继承 NULL self.assertEqual(captured["payload"]["tenant_id"], TENANT) self.assertEqual(captured["payload"]["template_id"], r["id"]) # usage_count +1 self.assertEqual(int(T.get_template(self.db, TENANT, r["id"])["usage_count"]), 1) def test_instantiate_deprecated_rejected(self): r = T.create_template(self.db, TENANT, {"code": "t1", "name": "x", "content": {"a": 1}}, actor="u1") T.deprecate_template(self.db, TENANT, r["id"], actor="u1") with self.assertRaises(ValidationError): T.instantiate_template(self.db, TENANT, r["id"], actor="u1") def test_seed_idempotent(self): a = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子", "content": {"a": 1}}], actor="system") self.assertEqual(a["created_count"], 1) b = T.ensure_platform_seed(self.db, seed_rows=[{"code": "s1", "name": "种子", "content": {"a": 1}}], actor="system") self.assertEqual(b["created_count"], 0) self.assertEqual(b["skipped_count"], 1) self.assertEqual(T.list_templates(self.db, TENANT)["total"], 1) class TestSubobjectExt(unittest.TestCase): def setUp(self): self.db = FakeDB() self.bp = "bp-1" S.create_ext_field_def(self.db, None, { "scope": "platform", "subobject_kind": "mission", "ext_key": "difficulty", "label": "难度", "value_type": "enum", "enum_options": ["easy", "medium", "hard"], "required": 1}, actor="admin", is_platform_admin=True) S.create_ext_field_def(self.db, None, { "scope": "platform", "subobject_kind": "mission", "ext_key": "estimated_minutes", "label": "耗时", "value_type": "int", "constraints": {"min": 1, "max": 600}}, actor="admin", is_platform_admin=True) def test_undefined_ext_key_rejected(self): with self.assertRaises(ValidationError): S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "not_defined", "x", actor="u1") def test_invalid_kind_rejected(self): with self.assertRaises(ValidationError): S.set_ext(self.db, TENANT, self.bp, "not_a_kind", "m1", "difficulty", "easy", actor="u1") def test_enum_and_int_validation(self): with self.assertRaises(ValidationError): S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "impossible", actor="u1") with self.assertRaises(ValidationError): S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "estimated_minutes", 9999, actor="u1") r = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "estimated_minutes", "90", actor="u1") self.assertEqual(r["value"], 90) # 字符串按定义转 int self.assertEqual(r["value_type"], "int") def test_upsert_idempotent(self): a = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1") self.assertTrue(a["created"]) b = S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "hard", actor="u1") self.assertFalse(b["created"]) self.assertEqual(b["id"], a["id"]) self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty"), "hard") self.assertEqual(len(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["items"]), 1) def test_tenant_isolation_on_ext(self): S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1") self.assertEqual(S.get_ext(self.db, OTHER, self.bp, "mission", "m1", "difficulty"), None) self.assertEqual(S.list_ext(self.db, OTHER, blueprint_id=self.bp)["total"], 0) with self.assertRaises(TenantRequiredError): S.list_ext(self.db, None, blueprint_id=self.bp) def test_tenant_def_overrides_platform(self): S.create_ext_field_def(self.db, TENANT, { "subobject_kind": "mission", "ext_key": "difficulty", "label": "本地难度", "value_type": "enum", "enum_options": ["L1", "L2"]}, actor="u1") d = S.resolve_ext_field_def(self.db, TENANT, "mission", "difficulty") self.assertFalse(d["is_platform"]) self.assertEqual(d["label"], "本地难度") # 平台枚举值在租户覆盖后失效 with self.assertRaises(ValidationError): S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1") S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "L1", actor="u1") # 其他租户仍用平台定义 d2 = S.resolve_ext_field_def(self.db, OTHER, "mission", "difficulty") self.assertTrue(d2["is_platform"]) def test_any_kind_def_visible(self): S.create_ext_field_def(self.db, None, {"scope": "platform", "subobject_kind": "any", "ext_key": "tags", "label": "标签", "value_type": "json"}, actor="admin", is_platform_admin=True) r = S.set_ext(self.db, TENANT, self.bp, "role", "r1", "tags", ["a", "b"], actor="u1") self.assertEqual(r["value"], ["a", "b"]) def test_bulk_set_all_or_nothing(self): with self.assertRaises(ValidationError) as cm: S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1", {"difficulty": "easy", "estimated_minutes": 99999}, actor="u1") self.assertTrue(cm.exception.detail.get("errors")) self.assertEqual(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["total"], 0) ok = S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1", {"difficulty": "easy", "estimated_minutes": 60}, actor="u1") self.assertEqual(ok["count"], 2) def test_apply_template_ext_schema_no_overwrite(self): S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1") res = S.apply_template_ext_schema( self.db, TENANT, self.bp, {"mission": {"difficulty": "hard", "estimated_minutes": 45}}, {"mission": ["m1", "m2"]}, actor="u1", source_template_id="tpl-x") self.assertEqual(res["applied_count"], 2) # m1.estimated_minutes + m2.estimated_minutes self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty"), "easy") # 已填不覆盖 self.assertEqual(S.get_ext(self.db, TENANT, self.bp, "mission", "m2", "difficulty"), "hard") rows = S.list_ext(self.db, TENANT, blueprint_id=self.bp)["items"] self.assertTrue(all(r["source"] in ("manual", "template_instantiate") for r in rows)) self.assertTrue(any(r["source_template_id"] == "tpl-x" for r in rows)) def test_subobject_tree_ext(self): S.bulk_set_ext(self.db, TENANT, self.bp, "mission", "m1", {"difficulty": "easy", "estimated_minutes": 30}, actor="u1") tree = S.subobject_tree_ext(self.db, TENANT, self.bp)["ext"] self.assertEqual(tree["mission"]["m1"]["difficulty"], "easy") self.assertEqual(tree["mission"]["m1"]["estimated_minutes"], 30) def test_delete_ext(self): S.set_ext(self.db, TENANT, self.bp, "mission", "m1", "difficulty", "easy", actor="u1") self.assertEqual(S.delete_ext(self.db, TENANT, self.bp, "mission", "m1", ext_key="difficulty", actor="u1")["deleted_count"], 1) self.assertEqual(S.list_ext(self.db, TENANT, blueprint_id=self.bp)["total"], 0) def test_ext_def_delete_blocked_when_in_use(self): d = S.create_ext_field_def(self.db, TENANT, { "subobject_kind": "role", "ext_key": "min_players", "label": "最少人数", "value_type": "int"}, actor="u1") S.set_ext(self.db, TENANT, self.bp, "role", "r1", "min_players", 2, actor="u1") with self.assertRaises(ConflictError): S.delete_ext_field_def(self.db, TENANT, d["id"], actor="u1") def test_platform_ext_def_write_requires_admin(self): with self.assertRaises(PermissionDeniedError): S.create_ext_field_def(self.db, None, {"scope": "platform", "subobject_kind": "role", "ext_key": "x", "label": "x"}, actor="u1", is_platform_admin=False) class TestRefTable(unittest.TestCase): def setUp(self): self.db = FakeDB() self.bp = "bp-1" def test_classify_ref_whitelist(self): self.assertEqual(R.classify_ref("world", "world")[0], True) self.assertEqual(R.classify_ref("world", "employee")[0], False) # 域-表不匹配 self.assertEqual(R.classify_ref("unknown_domain", "x")[0], False) with self.assertRaises(ValidationError): R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "secret_tbl", "ref_id": "w1"}, actor="u1") def test_add_ref_and_idempotent(self): a = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world", "ref_id": "w1", "rel_type": "binds", "required": 1}, actor="u1") self.assertTrue(a["created"]) b = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world", "ref_id": "w1", "rel_type": "binds", "required": 0}, actor="u1") self.assertFalse(b["created"]) self.assertEqual(b["id"], a["id"]) self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 1) def test_ref_tenant_isolation(self): R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world", "ref_id": "w1"}, actor="u1") self.assertEqual(R.list_refs(self.db, OTHER, blueprint_id=self.bp)["total"], 0) with self.assertRaises(TenantRequiredError): R.list_refs(self.db, None, blueprint_id=self.bp) with self.assertRaises(NotFoundError): R.get_ref(self.db, OTHER, R.list_refs(self.db, TENANT, blueprint_id=self.bp)["items"][0]["id"]) def test_src_kind_validation(self): with self.assertRaises(ValidationError): R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "scene", "ref_table": "scene", "ref_id": "s1", "src_kind": "mission"}, actor="u1") # 缺 src_id ok = R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "scene", "ref_table": "scene", "ref_id": "s1", "src_kind": "mission", "src_id": "m1"}, actor="u1") self.assertTrue(ok["ok"]) def test_bulk_all_or_nothing(self): with self.assertRaises(ValidationError): R.bulk_add_refs(self.db, TENANT, self.bp, [ {"ref_domain": "world", "ref_table": "world", "ref_id": "w1"}, {"ref_domain": "bad", "ref_table": "bad", "ref_id": "x"}], actor="u1") self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 0) def test_resolve_refs_with_reader(self): R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world", "ref_id": "w-exist", "required": 1}, actor="u1") R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world", "ref_id": "w-gone", "required": 1}, actor="u1") def reader(db, table, rid): return {"id": rid} if rid == "w-exist" else None res = R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1", reader=reader) self.assertEqual(res["resolved_count"], 1) self.assertEqual(res["missing_count"], 1) self.assertEqual(len(res["blocking"]), 1) rows = R.list_refs(self.db, TENANT, blueprint_id=self.bp)["items"] st = {r["ref_id"]: r["resolve_status"] for r in rows} self.assertEqual(st["w-exist"], "resolved") self.assertEqual(st["w-gone"], "missing") def test_resolve_without_reader_stays_unknown(self): R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world", "ref_id": "w1"}, actor="u1") res = R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1") self.assertEqual(res["unknown_count"], 1) self.assertEqual(res["missing_count"], 0) # 不臆断 missing def test_impact_of_reverse_lookup(self): R.add_ref(self.db, TENANT, "bp-1", {"ref_domain": "world", "ref_table": "world", "ref_id": "w1", "required": 1}, actor="u1") R.add_ref(self.db, TENANT, "bp-2", {"ref_domain": "world", "ref_table": "world", "ref_id": "w1"}, actor="u1") R.add_ref(self.db, OTHER, "bp-9", {"ref_domain": "world", "ref_table": "world", "ref_id": "w1"}, actor="u2") imp = R.impact_of(self.db, TENANT, "world", "world", "w1") self.assertEqual(imp["blueprint_count"], 2) # 跨租户不泄露 self.assertEqual(sorted(imp["blueprint_ids"]), ["bp-1", "bp-2"]) self.assertFalse(imp["safe_to_delete"]) self.assertTrue(imp["warning"]) R.remove_ref(self.db, TENANT, R.list_refs(self.db, TENANT, blueprint_id="bp-1")["items"][0]["id"], actor="u1") self.assertTrue(R.impact_of(self.db, TENANT, "world", "world", "w1")["safe_to_delete"]) def test_refs_from_content_extraction(self): content = { "world_id": "w-100", "missions": [{"id": "m1", "kind": "mission", "scene_id": "sc-1"}, {"id": "m2", "kind": "mission", "scene_id": "sc-1"}], "project": {"id": "pj1", "kind": "project", "game_id": "g-1"}, } refs = R.refs_from_content(content) keys = {(r["ref_domain"], r["ref_id"]) for r in refs} self.assertIn(("world", "w-100"), keys) self.assertIn(("scene", "sc-1"), keys) self.assertIn(("scense_game", "g-1"), keys) self.assertEqual(len(refs), len(keys)) # 去重生效 def test_sync_refs_from_content_idempotent(self): content = {"world_id": "w-1", "missions": [{"id": "m1", "kind": "mission", "scene_id": "sc-1"}]} a = R.sync_refs_from_content(self.db, TENANT, self.bp, content, actor="u1") self.assertEqual(a["added_count"], 2) b = R.sync_refs_from_content(self.db, TENANT, self.bp, content, actor="u1") self.assertEqual(b["added_count"], 0) self.assertEqual(b["removed_count"], 0) # content 去掉 world 引用 → 关联边同步软删 c = R.sync_refs_from_content(self.db, TENANT, self.bp, {"missions": [{"id": "m1", "kind": "mission", "scene_id": "sc-1"}]}, actor="u1") self.assertEqual(c["removed_count"], 1) self.assertEqual(R.list_refs(self.db, TENANT, blueprint_id=self.bp)["total"], 1) def test_world_table_untouched(self): """Q-OPEN-3:关联操作绝不写 world 基表。""" R.add_ref(self.db, TENANT, self.bp, {"ref_domain": "world", "ref_table": "world", "ref_id": "w1"}, actor="u1") R.sync_refs_from_content(self.db, TENANT, self.bp, {"world_id": "w2"}, actor="u1") R.resolve_refs(self.db, TENANT, blueprint_id=self.bp, actor="u1", reader=lambda db, t, i: {"id": i}) self.assertNotIn("world", self.db.tables) self.assertNotIn("scene", self.db.tables) self.assertIn("pbl_blueprint_ref", self.db.tables) class TestApiDispatch(unittest.TestCase): def setUp(self): self.db = FakeDB() def test_route_dispatch_platform_template(self): r = A.dispatch(self.db, "POST", "/pbl/templates", {"tenant_id": None, "is_platform_admin": True, "actor": "admin", "code": "p1", "name": "平台模板", "scope": "platform", "content": {"a": 1}}) self.assertTrue(r["ok"], r) lst = A.dispatch(self.db, "GET", "/pbl/templates", {"tenant_id": TENANT}) self.assertEqual(lst["total"], 1) tid = lst["items"][0]["id"] det = A.dispatch(self.db, "GET", "/pbl/templates/%s" % tid, {"tenant_id": TENANT}) self.assertTrue(det["template"]["is_platform"]) bad = A.dispatch(self.db, "PUT", "/pbl/templates/%s" % tid, {"tenant_id": TENANT, "actor": "u1", "name": "x"}) self.assertFalse(bad["ok"]) self.assertEqual(bad["code"], "PBL_PLATFORM_ADMIN_REQUIRED") self.assertEqual(bad["http_status"], 403) def test_route_missing_tenant_fail_closed(self): r = A.dispatch(self.db, "GET", "/pbl/templates", {}) self.assertFalse(r["ok"]) self.assertEqual(r["code"], "PBL_TENANT_REQUIRED") def test_route_unknown(self): r = A.dispatch(self.db, "GET", "/pbl/nope", {"tenant_id": TENANT}) self.assertEqual(r["code"], "PBL_ROUTE_NOT_FOUND") def test_route_ext_and_refs(self): A.dispatch(self.db, "POST", "/pbl/ext-defs", {"tenant_id": None, "is_platform_admin": True, "actor": "admin", "scope": "platform", "subobject_kind": "mission", "ext_key": "difficulty", "label": "难度", "value_type": "enum", "enum_options": ["easy", "hard"]}) r = A.dispatch(self.db, "PUT", "/pbl/blueprints/bp-1/ext", {"tenant_id": TENANT, "actor": "u1", "kind": "mission", "subobject_id": "m1", "values": {"difficulty": "easy"}}) self.assertTrue(r["ok"], r) g = A.dispatch(self.db, "GET", "/pbl/blueprints/bp-1/ext", {"tenant_id": TENANT}) self.assertEqual(g["ext"]["mission"]["m1"]["difficulty"], "easy") add = A.dispatch(self.db, "POST", "/pbl/blueprints/bp-1/refs", {"tenant_id": TENANT, "actor": "u1", "ref_domain": "world", "ref_table": "world", "ref_id": "w1"}) self.assertTrue(add["ok"], add) imp = A.dispatch(self.db, "GET", "/pbl/refs/impact", {"tenant_id": TENANT, "ref_domain": "world", "ref_table": "world", "ref_id": "w1"}) self.assertEqual(imp["blueprint_count"], 1) class TestInitSeed(unittest.TestCase): def test_init_m1b_seeds_platform_data(self): db = FakeDB() res = I.init_m1b(db=db, seed=True, actor="system") self.assertTrue(res["ok"]) self.assertEqual(res["q_open_3"], "world/scene/entity 基表零改动") self.assertNotIn("world", db.tables) tpls = T.list_templates(db, TENANT) self.assertGreaterEqual(tpls["total"], 3) self.assertTrue(all(t["is_platform"] for t in tpls["items"])) defs = S.list_ext_field_defs(db, TENANT) self.assertGreaterEqual(defs["total"], 20) self.assertTrue(all(d["is_platform"] for d in defs["items"])) # 幂等 again = I.seed_platform_data(db, actor="system") self.assertEqual(again["templates"]["created_count"], 0) self.assertEqual(again["ext_defs"]["created_count"], 0) self.assertEqual(T.list_templates(db, TENANT)["total"], tpls["total"]) def test_platform_seed_usable_end_to_end(self): db = FakeDB() I.init_m1b(db=db, seed=True) tpl = [t for t in T.list_templates(db, TENANT)["items"] if t["code"] == "pbl-stem-water-quality"][0] full = T.get_template(db, TENANT, tpl["id"]) inst = T.instantiate_template(db, TENANT, tpl["id"], actor="u1") self.assertTrue(inst["result"]["deferred"]) self.assertEqual(inst["result"]["payload"]["tenant_id"], TENANT) # 模板 ext_schema 默认值可落到子对象扩展 res = S.apply_template_ext_schema( db, TENANT, "bp-new", full["ext_schema"], {"mission": ["ms-1", "ms-2"], "role": ["rl-1"], "project": ["pj-1"], "driving_question": ["dq-1"], "artifact_def": ["af-1"]}, actor="u1", source_template_id=tpl["id"]) self.assertGreater(res["applied_count"], 5) self.assertEqual(S.get_ext(db, TENANT, "bp-new", "mission", "ms-1", "difficulty"), "medium") self.assertEqual(S.get_ext(db, TENANT, "bp-new", "project", "pj-1", "assessment_mode"), "rubric") # content 中的跨域引用可抽取(本模板无 world_id → 0 条,不报错) sync = R.sync_refs_from_content(db, TENANT, "bp-new", full["content"], actor="u1") self.assertTrue(sync["ok"]) if __name__ == "__main__": unittest.main(verbosity=2)