# -*- coding: utf-8 -*- """机械契约测试:models/pbl_domain_ref.json ↔ pbl_domain_ext 代码列名/类型一致性。 覆盖 QC 退回意见 #1/#2/#5/#6 的回归防护: #1 代码读写的 7 个列(ref_code/ref_name/bind_state/bind_at/is_deleted/creator_id/updater_id) 必须全部出现在 models fields 中,且审计列名不得写成 created_by/updated_by; #2 ref_id/blueprint_id 必须是 str 类型(与基表 world/scene/entity 主键 str(32) 一致), 禁止 long/bigint; #5 init/data.json 必须是合法 JSON,且按 Format B 注入 appcodes 两组 (pbl_domain_ref_type / pbl_bind_state),parentid 与 models codes cond 对得上; #6 主键生成必须走 appPublic.uniqueID.getID(禁止 uuid4)。 """ import json import os import re import unittest ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) MODEL_PATH = os.path.join(ROOT, "models", "pbl_domain_ref.json") CRUD_PATH = os.path.join(ROOT, "json", "pbl_domain_ref.json") INIT_DATA_PATH = os.path.join(ROOT, "init", "data.json") SQL_PATH = os.path.join(ROOT, "sql", "pbl_domain_ext.sql") #: api.py / db.py / base.py 实际读写的列(QC#1 清单) CODE_USED_COLUMNS = [ "id", "tenant_id", "ref_type", "ref_id", "ref_code", "ref_name", "blueprint_id", "class_id", "team_id", "bind_state", "bind_at", "ext_json", "is_deleted", "creator_id", "created_at", "updater_id", "updated_at", ] FORBIDDEN_AUDIT_NAMES = ["created_by", "updated_by", "creator", "updater", "ext"] def _load(path): with open(path, "r", encoding="utf-8") as fh: return json.load(fh) class TestModelContract(unittest.TestCase): """models/pbl_domain_ref.json 与代码契约同名同型。""" @classmethod def setUpClass(cls): cls.model = _load(MODEL_PATH) cls.fields = {f["name"]: f for f in cls.model["fields"]} def test_summary_shape(self): """summary 必须恰一条、primary 必须是数组 ["id"](table-definition-spec)。""" self.assertEqual(len(self.model["summary"]), 1) s = self.model["summary"][0] self.assertEqual(s["name"], "pbl_domain_ref") self.assertIsInstance(s["primary"], list) self.assertEqual(s["primary"], ["id"]) def test_only_one_table(self): """Q-OPEN-3 薄扩展:models/ 下只允许 pbl_domain_ref 一张表, 禁止把 world/scene/entity 基表重新定义进来。""" names = sorted(f for f in os.listdir(os.path.join(ROOT, "models")) if f.endswith(".json")) self.assertEqual(names, ["pbl_domain_ref.json"]) def test_code_columns_all_declared(self): """QC#1:代码读写的列必须全部在 fields 里声明。""" missing = [c for c in CODE_USED_COLUMNS if c not in self.fields] self.assertEqual(missing, [], "models 缺列: %s" % missing) def test_no_forbidden_column_names(self): """QC#1:审计列名统一 creator_id/updater_id,扩展列统一 ext_json。""" bad = [n for n in self.fields if n in FORBIDDEN_AUDIT_NAMES] self.assertEqual(bad, [], "存在旧名/禁用列名: %s" % bad) def test_ref_id_and_blueprint_id_are_str(self): """QC#2:ref_id / blueprint_id 必须 str,长度 >= 32,禁止 long/bigint。""" for col, minlen in (("ref_id", 32), ("blueprint_id", 32), ("id", 32)): f = self.fields[col] self.assertEqual(f["type"], "str", "%s 类型应为 str" % col) self.assertIsInstance(f["length"], int) self.assertGreaterEqual(f["length"], minlen) for col in self.fields: self.assertNotIn(self.fields[col]["type"], ("long", "bigint"), "%s 不得使用 bigint" % col) def test_str_fields_have_int_length(self): """str/char 必须带正整数 length(禁止 "15,2" 之类字符串写法)。""" for name, f in self.fields.items(): if f["type"] in ("str", "char"): self.assertIsInstance(f.get("length"), int, "%s 缺 int length" % name) self.assertGreater(f["length"], 0) if f["type"] in ("float", "double", "ddouble", "decimal"): self.assertIsInstance(f.get("length"), int) self.assertIsInstance(f.get("dec"), int) def test_indexes_shape(self): """索引必须用 idxfields 数组;唯一键 = (tenant_id, ref_type, ref_id)。""" names = set() for idx in self.model["indexes"]: self.assertIn("idxtype", idx) self.assertIsInstance(idx["idxfields"], list) self.assertNotIn(idx["name"], names) names.add(idx["name"]) uniq = [i for i in self.model["indexes"] if i["idxtype"] == "unique"] self.assertEqual(len(uniq), 1) self.assertEqual(uniq[0]["idxfields"], ["tenant_id", "ref_type", "ref_id"]) def test_codes_use_parentid(self): """codes 引用 appcodes_kv 必须 cond parentid=,禁止 id=;禁止 module.table 点号。""" for c in self.model["codes"]: self.assertNotIn(".", c["table"]) if c["table"] == "appcodes_kv": self.assertTrue(c["cond"].startswith("parentid="), "codes cond 必须 parentid= : %s" % c) fields = [c["field"] for c in self.model["codes"]] self.assertEqual(len(fields), len(set(fields)), "codes 存在重复 field") def test_list_fields_match_model(self): """base.LIST_FIELDS 必须与 models fields 完全一致(无多无少)。""" from pbl_domain_ext.base import LIST_FIELDS, ALL_COLUMNS, AUDIT_FIELDS self.assertEqual(sorted(LIST_FIELDS), sorted(self.fields.keys())) self.assertEqual(sorted(ALL_COLUMNS), sorted(self.fields.keys())) for a in AUDIT_FIELDS: self.assertIn(a, self.fields) def test_sql_ddl_columns_match_model(self): """sql/pbl_domain_ext.sql 的建表列必须覆盖 models 全部列。""" if not os.path.exists(SQL_PATH): self.skipTest("sql/pbl_domain_ext.sql 不存在(由 json2ddl 生成)") with open(SQL_PATH, "r", encoding="utf-8") as fh: ddl = fh.read() for col in self.fields: self.assertRegex(ddl, r"`%s`" % col, "DDL 缺列 %s" % col) for bad in FORBIDDEN_AUDIT_NAMES: self.assertNotRegex(ddl, r"`%s`" % bad, "DDL 含禁用旧列名 %s" % bad) class TestCrudJsonContract(unittest.TestCase): """json/pbl_domain_ref.json 与 models 字段名一致(QC#1 同步项)。""" @classmethod def setUpClass(cls): cls.crud = _load(CRUD_PATH) cls.model_fields = {f["name"] for f in _load(MODEL_PATH)["fields"]} def test_tblname_and_editable(self): self.assertEqual(self.crud["tblname"], "pbl_domain_ref") params = self.crud["params"] for key in ("new_data_url", "update_data_url", "delete_data_url"): self.assertIn(key, params, "params 顶层缺 %s" % key) self.assertIn("entire_url", params[key]) self.assertIn("editable", params) def test_browserfields_subset_of_model(self): bf = set(self.crud["params"]["browserfields"].keys()) self.assertEqual(bf - self.model_fields, set(), "CRUD 出现 models 未声明的字段: %s" % (bf - self.model_fields)) def test_editexclouded_known_columns(self): for col in self.crud["params"].get("editexclouded", []): self.assertIn(col, self.model_fields) def test_no_new_data_url_query_params(self): """new_data_url 不得带 query 参数(避免与 editexclouded 合并成 list)。""" url = self.crud["params"]["new_data_url"] self.assertNotIn("?", url) class TestInitDataContract(unittest.TestCase): """QC#5:init/data.json Format B 种子必须真实存在且与 codes cond 对齐。""" @classmethod def setUpClass(cls): cls.data = _load(INIT_DATA_PATH) cls.codes = _load(MODEL_PATH)["codes"] def test_is_valid_json_format_b(self): self.assertIn("appcodes", self.data) self.assertIsInstance(self.data["appcodes"], list) self.assertGreaterEqual(len(self.data["appcodes"]), 2) def test_parentids_cover_codes_cond(self): parents = {g["parentid"] for g in self.data["appcodes"]} for c in self.codes: if c["table"] != "appcodes_kv": continue m = re.search(r"parentid='([^']+)'", c["cond"]) self.assertIsNotNone(m, "codes cond 解析失败: %s" % c) self.assertIn(m.group(1), parents, "codes 引用的编码组 %s 未在 init/data.json 注入" % m.group(1)) def test_items_and_id_length(self): """每组必须有 items(k/v);parentid+k 生成的 id 不得超 VARCHAR(32)。""" for g in self.data["appcodes"]: self.assertLessEqual(len(g["parentid"]), 22, "parentid 过长会导致 appcodes_kv.id 超 32: %s" % g["parentid"]) self.assertTrue(g.get("items"), "%s 无 items" % g["parentid"]) for it in g["items"]: self.assertTrue(it.get("k") and it.get("v")) self.assertLessEqual(len("%s_%s" % (g["parentid"], it["k"])), 32) def test_ref_type_items_match_code_enum(self): """world/scene/entity 三值必须齐(与 base.REF_TYPES 一致)。""" from pbl_domain_ext.base import REF_TYPES, BIND_STATES groups = {g["parentid"]: {it["k"] for it in g["items"]} for g in self.data["appcodes"]} self.assertEqual(groups["pbl_domain_ref_type"], set(REF_TYPES)) self.assertEqual(groups["pbl_bind_state"], set(BIND_STATES)) class TestIdGeneration(unittest.TestCase): """QC#6:主键必须走 appPublic.uniqueID.getID,禁止 uuid4。""" def test_no_uuid_in_package(self): pkg = os.path.join(ROOT, "pbl_domain_ext") for fn in sorted(os.listdir(pkg)): if not fn.endswith(".py"): continue with open(os.path.join(pkg, fn), "r", encoding="utf-8") as fh: src = fh.read() self.assertNotIn("uuid", src, "%s 不得使用 uuid 生成主键" % fn) def test_base_uses_platform_getid(self): with open(os.path.join(ROOT, "pbl_domain_ext", "base.py"), "r", encoding="utf-8") as fh: src = fh.read() self.assertIn("from appPublic.uniqueID import getID", src) def test_gen_id_shape(self): from pbl_domain_ext.base import gen_id a, b = gen_id(), gen_id() self.assertEqual(len(a), 32) self.assertNotEqual(a, b) class TestHostAgnosticImports(unittest.TestCase): """QC#4:模块宿主无关——禁止 import 具体宿主应用(sage/pipeline_app 等)。""" FORBIDDEN_HOSTS = ("sage", "pipeline_app", "hrs6", "hrs7") ALLOWED_ROOTS = ("ahserver", "sqlor", "appPublic", "apppublic", "pbl_domain_ext", "json", "os", "re", "sys", "time", "hashlib", "unittest") def test_no_host_import(self): pkg = os.path.join(ROOT, "pbl_domain_ext") pat = re.compile(r"^\s*(?:from|import)\s+([A-Za-z_][\w\.]*)", re.M) for fn in sorted(os.listdir(pkg)): if not fn.endswith(".py"): continue with open(os.path.join(pkg, fn), "r", encoding="utf-8") as fh: src = fh.read() for mod in pat.findall(src): top = mod.split(".")[0] self.assertNotIn(top, self.FORBIDDEN_HOSTS, "%s 违反宿主无关铁律: import %s" % (fn, mod)) def test_serverenv_from_ahserver(self): with open(os.path.join(ROOT, "pbl_domain_ext", "db.py"), "r", encoding="utf-8") as fh: src = fh.read() self.assertIn("from ahserver.serverenv import ServerEnv", src) self.assertNotIn("from sage", src) class TestSqlorSignatures(unittest.TestCase): """QC#3:sor.C/U/D/R 只收 2 参——真实签名断言(不再被 fake_db 掩盖)。""" def test_no_three_arg_crud_calls(self): pkg = os.path.join(ROOT, "pbl_domain_ext") bad = re.compile(r"sor\.[CUDR]\(\s*[^()]*?,\s*[^()]*?,\s*[^()]*?\)") for fn in sorted(os.listdir(pkg)): if not fn.endswith(".py"): continue with open(os.path.join(pkg, fn), "r", encoding="utf-8") as fh: src = fh.read() self.assertIsNone(bad.search(src), "%s 存在 3 参 sor.C/U/D/R 调用" % fn) def test_sor_I_single_arg(self): pkg = os.path.join(ROOT, "pbl_domain_ext") bad = re.compile(r"sor\.I\(\s*[^()]*?,") for fn in sorted(os.listdir(pkg)): if not fn.endswith(".py"): continue with open(os.path.join(pkg, fn), "r", encoding="utf-8") as fh: src = fh.read() self.assertIsNone(bad.search(src), "%s sor.I 只能 1 参" % fn) def test_update_ref_merges_where_into_ns(self): """update_ref 必须把 where 并入 ns 后两参调用 sor.U。""" calls = [] class FakeSor(object): async def U(self, table, ns): calls.append((table, dict(ns))) return 1 def C(self, *a, **k): raise AssertionError("C 不应被调用") import asyncio from pbl_domain_ext import db as dbm old = dbm._SOR_HOLDER.get("sor") dbm.set_sor(FakeSor()) try: asyncio.get_event_loop().run_until_complete( dbm._sor_u(FakeSor(), "pbl_domain_ref", {"bind_state": "unbound", "updater_id": "u1"}, {"id": "pk1", "tenant_id": "t1"})) finally: dbm._SOR_HOLDER["sor"] = old self.assertEqual(len(calls), 1) table, ns = calls[0] self.assertEqual(table, "pbl_domain_ref") # where 条件必须在 ns 内,且不被 SET 覆盖 self.assertEqual(ns["id"], "pk1") self.assertEqual(ns["tenant_id"], "t1") self.assertEqual(ns["bind_state"], "unbound") if __name__ == "__main__": unittest.main(verbosity=2)