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

428 lines
22 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.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""M1b 扩展字段 + 关联判定测试(真实执行,非 py_compile
覆盖 QC 退回意见 #67 类子对象泛化契约可验收证据)与
#5关联表判定/字段映射证据):
- 7 类子对象走同一组函数签名(泛化),逐类断言 CRUD + 扩展值读写
- 扩展字段校验(必填/枚举/数值区间/未声明字段)
- 关联判定 4 种状态resolved / unresolved / stub / external
- Q-OPEN-3 守门:基表写 SQL 被 assert_base_table_immutable 拦截
- 租户隔离:跨租户读不到彼此的子对象与关联
运行:
python3 modules/pbl_blueprint/tests/test_m1b_ext_ref.py -v
"""
import os
import shutil
import sys
# --- M1b sys.path bootstrap: modules/ 下各包互为兄弟仓库,需逐个入 path ---
_M1B_MOD_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
_M1B_MODULES_DIR = os.path.abspath(os.path.join(_M1B_MOD_ROOT, ".."))
_M1B_CANDIDATES = [_M1B_MOD_ROOT, _M1B_MODULES_DIR]
try:
for _d in sorted(os.listdir(_M1B_MODULES_DIR)):
_sub = os.path.join(_M1B_MODULES_DIR, _d)
if os.path.isdir(_sub) and not _d.startswith("."):
_M1B_CANDIDATES.append(_sub)
except OSError:
pass
for _p in _M1B_CANDIDATES:
if _p not in sys.path:
sys.path.insert(0, _p)
# --- end bootstrap ---
import tempfile
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
MOD_ROOT = os.path.dirname(HERE)
REPO_ROOT = os.path.abspath(os.path.join(MOD_ROOT, "..", ".."))
for p in (REPO_ROOT, MOD_ROOT):
if p not in sys.path:
sys.path.insert(0, p)
import pbl_blueprint.m1b.init as m1b_init # noqa: E402
from pbl_blueprint.m1b.dbutil import ( # noqa: E402
get_conn, reset_conn, sql_exec, sql_rows, table_exists,
)
from pbl_blueprint.m1b.errors import ( # noqa: E402
PblForbidden, PblNotFound, PblValidationError, TenantMissingError,
)
from pbl_blueprint.m1b.ref import ( # noqa: E402
assert_base_table_immutable, list_refs, ref_contract,
ref_summary, resolve_ref,
)
from pbl_blueprint.m1b.subobject import ( # noqa: E402
SUBOBJECT_SPECS, create_subobject, delete_subobject, get_ext,
get_subobject, list_subobjects, set_ext, subobject_contract,
subobject_tree, update_subobject, validate_ext,
)
from pbl_blueprint.m1b.tables import REF_KINDS, SUBOBJECT_TYPES # noqa: E402
TENANT = "T_M1B_EXTREF_001"
OTHER = "T_M1B_EXTREF_002"
BP_ID = "BP_M1B_EXTREF_0001"
#: 每类子对象的最小合法载荷(证明 7 类都能通过同一契约创建)
SAMPLES = {
"driving_question": {"question": "如何让校园垃圾分类真正落地?", "sort_no": 1},
"learning_goal": {"goal": "能设计并执行一次校园垃圾调研", "sort_no": 1},
"mission": {"name": "M1 现状调研", "sort_no": 1},
"problem": {"title": "分类标识不清导致误投", "sort_no": 1},
"role": {"name": "调研组长", "sort_no": 1},
"learner": {"name": "张三", "sort_no": 1},
"artifact_def": {"name": "调研报告", "sort_no": 1},
}
#: 每类子对象的扩展字段值(对应 EXT_FIELD_SEED 中声明的字段键)
EXT_SAMPLES = {
"driving_question": {"cognitive_level": "分析", "weight": 0.5},
"learning_goal": {"bloom_level": "L4", "assessable": True},
"mission": {"estimated_minutes": 60, "unlock_condition": "完成 M0"},
"problem": {"difficulty": "", "hint_text": "先统计误投率"},
"role": {"team_size": 4, "responsibility": "统筹调研"},
"learner": {"grade": "高一", "group_no": 2},
"artifact_def": {"artifact_type": "文档", "rubric_ref": "rubric_report_v1"},
}
class M1bExtRefTestCase(unittest.TestCase):
"""扩展字段 + 关联判定真实库测试。"""
@classmethod
def setUpClass(cls):
cls.tmpdir = tempfile.mkdtemp(prefix="m1b_extref_")
cls.db_path = os.path.join(cls.tmpdir, "pbl_m1b_extref.sqlite3")
os.environ["PBL_M1B_DB"] = cls.db_path
os.environ["PBL_AUDIT_DIR"] = os.path.join(cls.tmpdir, "audit")
reset_conn()
cls.conn = get_conn(cls.db_path)
m1b_init.load_m1b(conn=cls.conn, actor_id="test_m1b_extref")
# 建 7 类子对象基表(最小列集),使泛化 CRUD 可真实落库
for st, spec in SUBOBJECT_SPECS.items():
# 列集去重text_field 可能就叫 name如 goal.name
# 直接字符串拼接会产生 "duplicate column name: name"sqlite 建表失败)
cols, seen = [], set()
for col in ("id TEXT PRIMARY KEY", "tenant_key TEXT", "tenant_id TEXT",
"blueprint_id TEXT", "%s TEXT" % spec["text_field"],
"sort_no INTEGER", "name TEXT", "status TEXT",
"created_at TEXT", "updated_at TEXT"):
key = col.split()[0].strip("`").lower()
if key in seen:
continue
seen.add(key)
cols.append(col)
sql_exec(
"CREATE TABLE IF NOT EXISTS %s (%s)" % (spec["table"], ", ".join(cols)),
conn=cls.conn)
sql_exec("CREATE TABLE IF NOT EXISTS pbl_blueprint ("
"id TEXT PRIMARY KEY, tenant_key TEXT, tenant_id TEXT, "
"name TEXT, code TEXT, status TEXT, template_id TEXT, "
"template_code TEXT, template_version TEXT, category TEXT, "
"created_at TEXT, updated_at TEXT)", conn=cls.conn)
@classmethod
def tearDownClass(cls):
reset_conn()
shutil.rmtree(cls.tmpdir, ignore_errors=True)
os.environ.pop("PBL_M1B_DB", None)
os.environ.pop("PBL_AUDIT_DIR", None)
# ---------- 1. 泛化契约QC #6 ----------
def test_01_contract_covers_exactly_seven_types(self):
"""契约声明恰好 7 类,且每类都有表/主键/必填/文本字段声明。"""
c = subobject_contract()
self.assertEqual(c["type_count"], 7)
self.assertEqual(sorted(c["types"]), sorted(SUBOBJECT_TYPES))
self.assertEqual(len(SUBOBJECT_SPECS), 7)
for st, spec in SUBOBJECT_SPECS.items():
self.assertTrue(spec["table"].startswith("pbl_"))
self.assertEqual(spec["pk"], "id")
self.assertIn("blueprint_id", spec["required"])
self.assertTrue(spec["text_field"])
# 9 个统一方法签名对 7 类一致(泛化)
self.assertEqual(len(c["unified_methods"]), 9)
def test_02_unknown_subobject_type_rejected(self):
"""非法类型 fail-closed不允许静默通过"""
for bad in ("pbl_project", "unknown", "", None):
with self.assertRaises(PblValidationError):
create_subobject(TENANT, bad, BP_ID, data={"name": "x"},
conn=self.conn)
def test_03_all_seven_types_crud_through_same_api(self):
"""7 类子对象全部通过同一组函数完成 增/查/改/删(泛化实证)。"""
created = {}
for st in SUBOBJECT_TYPES:
row = create_subobject(TENANT, st, BP_ID, data=dict(SAMPLES[st]),
actor_id="tester", conn=self.conn)
self.assertEqual(row["tenant_id"], TENANT)
self.assertEqual(row["blueprint_id"], BP_ID)
self.assertTrue(row["id"])
created[st] = row["id"]
# 读
got = get_subobject(TENANT, st, row["id"], conn=self.conn)
self.assertEqual(got["id"], row["id"])
listed = list_subobjects(TENANT, BP_ID, st, conn=self.conn)
self.assertGreaterEqual(len(listed), 1)
# 改
upd = update_subobject(TENANT, st, row["id"],
data={"sort_no": 9}, conn=self.conn)
self.assertEqual(int(upd["sort_no"]), 9)
self.assertEqual(len(created), 7)
# 删(并级联清理扩展值)
for st, oid in created.items():
n = delete_subobject(TENANT, st, oid, conn=self.conn)
self.assertEqual(int(n), 1)
with self.assertRaises(PblNotFound):
get_subobject(TENANT, st, oid, conn=self.conn)
def test_04_missing_required_field_rejected(self):
"""必填字段缺失 -> PblValidationError且不落库。"""
before = sql_rows("SELECT COUNT(*) AS n FROM pbl_mission", conn=self.conn)
with self.assertRaises(PblValidationError):
create_subobject(TENANT, "mission", BP_ID, data={"sort_no": 1},
conn=self.conn)
after = sql_rows("SELECT COUNT(*) AS n FROM pbl_mission", conn=self.conn)
self.assertEqual(before[0]["n"], after[0]["n"])
def test_05_tenant_isolation_on_subobjects(self):
"""跨租户读不到彼此子对象(租户打头强制隔离)。"""
row = create_subobject(TENANT, "role", BP_ID, data=dict(SAMPLES["role"]),
conn=self.conn)
with self.assertRaises(PblNotFound):
get_subobject(OTHER, "role", row["id"], conn=self.conn)
self.assertEqual(list_subobjects(OTHER, BP_ID, "role", conn=self.conn), [])
with self.assertRaises(TenantMissingError):
list_subobjects(None, BP_ID, "role", conn=self.conn)
# ---------- 2. 扩展字段QC #6 核心) ----------
def test_06_set_and_get_ext_for_all_seven_types(self):
"""7 类子对象都能写/读扩展字段值(泛化扩展接口实证)。"""
for st in SUBOBJECT_TYPES:
row = create_subobject(TENANT, st, BP_ID, data=dict(SAMPLES[st]),
conn=self.conn)
res = set_ext(TENANT, st, row["id"], EXT_SAMPLES[st],
source="manual", blueprint_id=BP_ID, conn=self.conn)
self.assertEqual(res["written"], len(EXT_SAMPLES[st]))
self.assertEqual(res["problems"], [])
got = get_ext(TENANT, st, row["id"], conn=self.conn)
for k, v in EXT_SAMPLES[st].items():
self.assertEqual(got.get(k), v, "%s.%s 扩展值不一致" % (st, k))
def test_07_set_ext_is_idempotent(self):
"""重复写同一扩展字段不产生重复行(唯一键幂等)。"""
row = create_subobject(TENANT, "problem", BP_ID,
data=dict(SAMPLES["problem"]), conn=self.conn)
set_ext(TENANT, "problem", row["id"], {"difficulty": ""}, conn=self.conn)
set_ext(TENANT, "problem", row["id"], {"difficulty": ""}, conn=self.conn)
rows = sql_rows(
"SELECT COUNT(*) AS n FROM pbl_subobject_ext WHERE subobject_id = ? "
"AND field_key = 'difficulty'", [row["id"]], conn=self.conn)
self.assertEqual(int(rows[0]["n"]), 1)
self.assertEqual(get_ext(TENANT, "problem", row["id"],
conn=self.conn)["difficulty"], "")
def test_08_ext_validation_enum_and_range(self):
"""枚举越界 / 数值越界 / 类型错误 / 未声明字段 均被校验拦截。"""
# 枚举越界
p = validate_ext(TENANT, "driving_question", {"cognitive_level": "不存在级"},
conn=self.conn)
self.assertTrue(any(x["code"] == "ENUM" for x in p), p)
# 数值越界weight max=1
p = validate_ext(TENANT, "driving_question", {"weight": 5}, conn=self.conn)
self.assertTrue(any(x["code"] == "MAX" for x in p), p)
# 类型错误
p = validate_ext(TENANT, "mission", {"estimated_minutes": "很多"},
conn=self.conn)
self.assertTrue(any(x["code"] == "TYPE" for x in p), p)
# 未声明字段
p = validate_ext(TENANT, "role", {"not_declared_field": 1}, conn=self.conn)
self.assertTrue(any(x["code"] == "UNDECLARED" for x in p), p)
# 合法值通过
self.assertEqual(validate_ext(TENANT, "learner",
{"grade": "高二", "group_no": 3},
conn=self.conn), [])
def test_09_set_ext_rejects_invalid_values(self):
"""set_ext 校验失败即抛错且不落库。"""
row = create_subobject(TENANT, "artifact_def", BP_ID,
data=dict(SAMPLES["artifact_def"]), conn=self.conn)
before = sql_rows("SELECT COUNT(*) AS n FROM pbl_subobject_ext "
"WHERE subobject_id = ?", [row["id"]], conn=self.conn)
with self.assertRaises(PblValidationError):
set_ext(TENANT, "artifact_def", row["id"],
{"artifact_type": "不存在的类型"}, conn=self.conn)
after = sql_rows("SELECT COUNT(*) AS n FROM pbl_subobject_ext "
"WHERE subobject_id = ?", [row["id"]], conn=self.conn)
self.assertEqual(before[0]["n"], after[0]["n"])
def test_10_create_with_inline_ext(self):
"""创建子对象时可内联 ext一次调用完成基础行+扩展值。"""
row = create_subobject(
TENANT, "learning_goal", BP_ID,
data=dict(SAMPLES["learning_goal"],
ext={"bloom_level": "L5", "assessable": True}),
conn=self.conn)
self.assertEqual(row["ext"]["bloom_level"], "L5")
self.assertEqual(get_ext(TENANT, "learning_goal", row["id"],
conn=self.conn)["assessable"], True)
def test_11_delete_cascades_ext_values(self):
"""删除子对象级联清理扩展值(应用层级联,无外键)。"""
row = create_subobject(TENANT, "mission", BP_ID,
data=dict(SAMPLES["mission"]), conn=self.conn)
set_ext(TENANT, "mission", row["id"], {"estimated_minutes": 30},
conn=self.conn)
self.assertGreater(len(get_ext(TENANT, "mission", row["id"],
conn=self.conn)), 0)
delete_subobject(TENANT, "mission", row["id"], conn=self.conn)
left = sql_rows("SELECT COUNT(*) AS n FROM pbl_subobject_ext "
"WHERE subobject_id = ?", [row["id"]], conn=self.conn)
self.assertEqual(int(left[0]["n"]), 0)
def test_12_subobject_tree_aggregates_seven_types(self):
"""subobject_tree 一次取全 7 类 + 扩展值。"""
bp = "BP_TREE_%s" % os.getpid()
for st in SUBOBJECT_TYPES:
create_subobject(TENANT, st, bp, data=dict(SAMPLES[st]),
conn=self.conn)
oid = list_subobjects(TENANT, bp, st, conn=self.conn)[0]["id"]
set_ext(TENANT, st, oid, EXT_SAMPLES[st], conn=self.conn,
blueprint_id=bp)
tree = subobject_tree(TENANT, bp, conn=self.conn)
self.assertEqual(sorted(tree["subobjects"].keys()),
sorted(SUBOBJECT_TYPES))
self.assertEqual(tree["counts"]["__total__"], 7)
for st in SUBOBJECT_TYPES:
self.assertEqual(len(tree["subobjects"][st]), 1)
self.assertTrue(tree["subobjects"][st][0]["ext"])
# ---------- 3. 关联表判定QC #5 + Q-OPEN-3 ----------
def test_13_ref_contract_declares_readonly_and_no_fk(self):
"""关联契约声明4 域只读、无外键、基表零写入。"""
c = ref_contract()
self.assertEqual(sorted(c["ref_kinds"]), sorted(REF_KINDS))
self.assertEqual(c["ownership"], "read_only")
self.assertTrue(c["q_open_3"]["no_foreign_key"])
self.assertEqual(c["q_open_3"]["base_table_writes"], 0)
self.assertEqual(sorted(c["base_tables_readonly"].keys()),
["entity", "scene", "script", "world"])
def test_14_ref_stub_when_base_table_unavailable(self):
"""基表不可达 -> resolve_status='stub'不报错、不建表Q-OPEN-3"""
row = resolve_ref(TENANT, BP_ID, "world", "W_NOT_EXIST_1",
actor_id="tester", conn=self.conn)
self.assertEqual(row["resolve_status"], "stub")
self.assertEqual(row["ownership"], "read_only")
self.assertIn("not available", row["resolve_note"])
self.assertFalse(table_exists("world", conn=self.conn),
"判定过程不得创建 world 基表")
def test_15_ref_resolved_with_readonly_snapshot(self):
"""基表可达且行存在 -> resolved + 只读快照。"""
sql_exec("CREATE TABLE IF NOT EXISTS world (id TEXT PRIMARY KEY, "
"name TEXT, code TEXT, status TEXT)", conn=self.conn)
sql_exec("INSERT INTO world (id, name, code, status) VALUES (?,?,?,?)",
["W_M1B_1", "M1b 测试世界", "world_m1b_1", "published"],
conn=self.conn)
row = resolve_ref(TENANT, BP_ID, "world", "W_M1B_1", conn=self.conn)
self.assertEqual(row["resolve_status"], "resolved")
self.assertEqual(row["ref_table"], "world")
self.assertEqual(row["ref_code"], "world_m1b_1")
self.assertTrue(row["ref_snapshot_json"])
# 快照是只读副本:改基表不影响已落快照
sql_exec("UPDATE world SET name = '改名后' WHERE id = 'W_M1B_1'",
conn=self.conn)
stored = [r for r in list_refs(TENANT, blueprint_id=BP_ID, conn=self.conn)
if r["ref_id"] == "W_M1B_1"][0]
self.assertEqual(stored["ref_snapshot"]["name"], "M1b 测试世界")
def test_16_ref_unresolved_when_row_missing(self):
"""基表可达但行不存在 -> unresolved。"""
row = resolve_ref(TENANT, BP_ID, "world", "W_MISSING_ROW", conn=self.conn)
self.assertEqual(row["resolve_status"], "unresolved")
self.assertIn("not found", row["resolve_note"])
def test_17_ref_external_is_stub(self):
"""external 域恒为 stubM1b 不解析外部系统)。"""
row = resolve_ref(TENANT, BP_ID, "external", "EXT_1", conn=self.conn)
self.assertEqual(row["resolve_status"], "stub")
self.assertEqual(row["ref_table"], "")
def test_18_ref_invalid_kind_rejected(self):
"""非法 ref_kind / 缺 ref_id -> PblValidationError。"""
with self.assertRaises(PblValidationError):
resolve_ref(TENANT, BP_ID, "not_a_kind", "X1", conn=self.conn)
with self.assertRaises(PblValidationError):
resolve_ref(TENANT, BP_ID, "world", "", conn=self.conn)
with self.assertRaises(TenantMissingError):
resolve_ref(None, BP_ID, "world", "X1", conn=self.conn)
def test_19_ref_idempotent_and_subobject_scoped(self):
"""同目标+同发起方重复判定不增行;子对象级关联可区分。"""
r1 = resolve_ref(TENANT, BP_ID, "scene", "S_M1B_1", conn=self.conn)
r2 = resolve_ref(TENANT, BP_ID, "scene", "S_M1B_1", conn=self.conn)
self.assertEqual(r1["id"], r2["id"])
n = sql_rows("SELECT COUNT(*) AS c FROM pbl_blueprint_ref "
"WHERE ref_id = 'S_M1B_1' AND subobject_type = 'blueprint'",
conn=self.conn)
self.assertEqual(int(n[0]["c"]), 1)
row = create_subobject(TENANT, "mission", BP_ID,
data=dict(SAMPLES["mission"]), conn=self.conn)
resolve_ref(TENANT, BP_ID, "scene", "S_M1B_1", subobject_type="mission",
subobject_id=row["id"], conn=self.conn)
n2 = sql_rows("SELECT COUNT(*) AS c FROM pbl_blueprint_ref "
"WHERE ref_id = 'S_M1B_1'", conn=self.conn)
self.assertEqual(int(n2[0]["c"]), 2, "子对象级关联应独立成行")
def test_20_ref_summary_and_tenant_isolation(self):
"""汇总报表按 kind×status 计数;跨租户不可见。"""
resolve_ref(OTHER, BP_ID, "entity", "E_OTHER_1", conn=self.conn)
s = ref_summary(TENANT, blueprint_id=BP_ID, conn=self.conn)
self.assertEqual(s["ownership"], "read_only")
self.assertEqual(s["base_tables_modified"], 0)
self.assertGreaterEqual(s["total"], 3)
self.assertIn("world", s["by_kind"])
self.assertEqual(s["by_status"].get("resolved", 0), 1)
rows = list_refs(OTHER, blueprint_id=BP_ID, conn=self.conn)
self.assertTrue(all(r["tenant_id"] == OTHER for r in rows))
self.assertFalse(any(r["ref_id"] == "E_OTHER_1"
for r in list_refs(TENANT, conn=self.conn)))
def test_21_base_table_write_sql_is_blocked(self):
"""Q-OPEN-3 守门:对基表的写 SQL 被静态拦截。"""
bad = [
"UPDATE world SET name = 'x' WHERE id = '1'",
"DELETE FROM scene WHERE id = '1'",
"INSERT INTO entity (id) VALUES ('1')",
"ALTER TABLE script ADD COLUMN tenant_id VARCHAR(64)",
]
for sql in bad:
with self.assertRaises(PblForbidden):
assert_base_table_immutable(sql)
# 只读与自有表写入放行
self.assertTrue(assert_base_table_immutable(
"SELECT * FROM world WHERE id = '1'"))
self.assertTrue(assert_base_table_immutable(
"INSERT INTO pbl_blueprint_ref (id) VALUES ('1')"))
def test_22_generated_ddl_contains_no_fk_and_no_base_table(self):
"""生成的 sqlite/mariadb DDL 均无 FOREIGN KEY、不含基表建表语句。"""
ddl = m1b_init.SQLITE_DDL.upper()
self.assertNotIn("FOREIGN KEY", ddl)
for base in ("CREATE TABLE IF NOT EXISTS WORLD",
"CREATE TABLE IF NOT EXISTS SCENE",
"CREATE TABLE IF NOT EXISTS ENTITY",
"CREATE TABLE IF NOT EXISTS SCRIPT"):
self.assertNotIn(base, ddl)
for t in TABLES:
self.assertIn("CREATE TABLE IF NOT EXISTS %s" % t["name"].upper(), ddl)
if __name__ == "__main__":
unittest.main(verbosity=2)