#!/usr/bin/env python3 # -*- coding: utf-8 -*- """M1b 扩展字段 + 关联判定测试(真实执行,非 py_compile)。 覆盖 QC 退回意见 #6(7 类子对象泛化契约可验收证据)与 #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 域恒为 stub(M1b 不解析外部系统)。""" 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)