pbl_blueprint/tests/test_m1b_ext_ref.py
2026-09-16 19:36:42 +08:00

553 lines
29 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 -*-
"""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)