deliver: 交付收口(引擎代为提交)
This commit is contained in:
parent
5e79ccac1a
commit
f844eca914
179
_patch2.py
Normal file
179
_patch2.py
Normal file
@ -0,0 +1,179 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""一次性修补:fake_db 真实 2 参签名 / api 契约修正 / 陈旧测试列名对齐。"""
|
||||
import io
|
||||
import os
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def patch(path, pairs, required=True):
|
||||
p = os.path.join(ROOT, path)
|
||||
src = io.open(p, encoding="utf-8").read()
|
||||
for old, new in pairs:
|
||||
if old not in src:
|
||||
if required:
|
||||
raise SystemExit("NOT FOUND in %s:\n%s" % (path, old[:200]))
|
||||
continue
|
||||
src = src.replace(old, new, 1)
|
||||
io.open(p, "w", encoding="utf-8").write(src)
|
||||
print("patched", path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fake_db
|
||||
# QC#3:fake_db 的 U 曾是 3 参 (tbl,row,where),掩盖了真实 sqlor 2 参签名。
|
||||
# 现改为真实签名 U(tbl, ns):ns 同时含 SET 值与 WHERE 条件(条件键 = id/tenant_id)。
|
||||
patch("tests/fake_db.py", [
|
||||
(""" def U(self, tbl, row, where):
|
||||
self._guard_readonly(tbl, "U")
|
||||
hits = 0
|
||||
for exist in self.tables.get(tbl, []):
|
||||
if self._match(exist, where):
|
||||
exist.update(row)
|
||||
hits += 1
|
||||
self.sql_log.append(("U", tbl, dict(row), dict(where)))
|
||||
return hits""",
|
||||
""" #: 真实 sqlor U(table, ns) 只收 2 参——ns 同时携带 SET 值与 WHERE 条件。
|
||||
#: 条件键约定为主键/租户键(本模块 update_ref 的 row 从不含这两列)。
|
||||
WHERE_KEYS = ("id", "tenant_id")
|
||||
|
||||
def U(self, tbl, ns):
|
||||
\"\"\"真实 sqlor 签名:U(table, ns),2 参(crud-spec Pitfall 22)。\"\"\"
|
||||
self._guard_readonly(tbl, "U")
|
||||
ns = dict(ns or {})
|
||||
where = {k: ns.pop(k) for k in self.WHERE_KEYS if k in ns}
|
||||
if not where:
|
||||
raise AssertionError(
|
||||
"sqlor U(table, ns) 的 ns 必须含 WHERE 条件键 %s,实得 %s"
|
||||
% (list(self.WHERE_KEYS), sorted(ns)))
|
||||
hits = 0
|
||||
for exist in self.tables.get(tbl, []):
|
||||
if self._match(exist, where):
|
||||
exist.update(ns)
|
||||
hits += 1
|
||||
self.sql_log.append(("U", tbl, dict(ns), where))
|
||||
return hits"""),
|
||||
])
|
||||
|
||||
# ---------------------------------------------------------------- api.py
|
||||
# ① world_get_context:基表未命中/跨租户必须报错,不得静默返回空上下文
|
||||
patch("pbl_domain_ext/api.py", [
|
||||
(""" base = db.read_base_row("world", world_id, tenant_id)
|
||||
world_view = _base_view(base or {})
|
||||
ref = db.select_ref_by_key(tenant_id, "world", world_id)""",
|
||||
""" base = db.read_base_row("world", world_id, tenant_id)
|
||||
world_view = _base_view(base or {})
|
||||
if not world_view:
|
||||
# 基表未命中或 world 属其它租户:一律按不存在处理(跨租户不得静默成功)
|
||||
raise PblDomainExtError(
|
||||
"PBL_DE_BASE_MISSING",
|
||||
detail="world.%s 在 tenant_id=%s 下未命中" % (world_id, tenant_id))
|
||||
ref = db.select_ref_by_key(tenant_id, "world", world_id)"""),
|
||||
# ② team_world_list:本端点语义是「列出世界」,item.id = world id;
|
||||
# 关联表主键另置 ref_pk,避免调用方把关联主键当世界 ID。
|
||||
(""" for r in rows:
|
||||
item = _ref_out(r)
|
||||
item["world_id"] = item.get("ref_id")
|
||||
item["world_code"] = item.get("ref_code")
|
||||
item["world_name"] = item.get("ref_name")
|
||||
items.append(item)""",
|
||||
""" for r in rows:
|
||||
item = _ref_out(r)
|
||||
item["ref_pk"] = item.get("id") # 关联表主键
|
||||
item["id"] = item.get("ref_id") # 对外身份 = world.id
|
||||
item["world_id"] = item.get("ref_id")
|
||||
item["world_code"] = item.get("ref_code")
|
||||
item["world_name"] = item.get("ref_name")
|
||||
items.append(item)"""),
|
||||
])
|
||||
|
||||
# ---------------------------------------------------------------- 陈旧测试列名
|
||||
# QC#1 定案:审计列权威名 creator_id/updater_id(models 与代码同名同型)。
|
||||
patch("tests/test_domain_ref.py", [
|
||||
('self.assertEqual(data["created_by"], OP)',
|
||||
'self.assertEqual(data["creator_id"], OP) # QC#1 权威列名 creator_id'),
|
||||
('self.assertEqual(self.rows()[0]["updated_by"], OP)',
|
||||
'self.assertEqual(self.rows()[0]["updater_id"], OP) # QC#1 权威列名 updater_id'),
|
||||
])
|
||||
|
||||
# ---------------------------------------------------------------- 契约测试自身
|
||||
patch("tests/test_models_contract.py", [
|
||||
(""" 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")""",
|
||||
""" calls = []
|
||||
|
||||
class AsyncSor(object):
|
||||
\"\"\"真实 sqlor 形态:C/U/D/R 均为 async 且只收 2 参。\"\"\"
|
||||
|
||||
async def U(self, table, ns):
|
||||
calls.append((table, dict(ns)))
|
||||
return 1
|
||||
|
||||
async def C(self, table, ns):
|
||||
raise AssertionError("C 不应被调用")
|
||||
|
||||
class SyncSor(AsyncSor):
|
||||
\"\"\"同步形态(离线 fake_db)——_drive 必须同样收敛。\"\"\"
|
||||
|
||||
def U(self, table, ns):
|
||||
calls.append((table, dict(ns)))
|
||||
return 1
|
||||
|
||||
from pbl_domain_ext import db as dbm
|
||||
for sor in (AsyncSor(), SyncSor()):
|
||||
del calls[:]
|
||||
affected = dbm._sor_u(sor, "pbl_domain_ref",
|
||||
{"bind_state": "unbound", "updater_id": "u1"},
|
||||
{"id": "pk1", "tenant_id": "t1"})
|
||||
self.assertEqual(affected, 1, "返回值必须收敛为普通值,不得是 coroutine")
|
||||
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")
|
||||
|
||||
def test_drive_converges_awaitable(self):
|
||||
\"\"\"QC#3 回归:宿主 async sqlor 下不得把 coroutine 泄漏给 int()/调用方。\"\"\"
|
||||
from pbl_domain_ext.db import _drive
|
||||
|
||||
async def _coro():
|
||||
return 7
|
||||
|
||||
self.assertEqual(_drive(_coro()), 7)
|
||||
self.assertEqual(_drive(3), 3)
|
||||
|
||||
def test_update_ref_rejects_empty_where(self):
|
||||
from pbl_domain_ext import db as dbm
|
||||
from pbl_domain_ext.errors import PblDomainExtError
|
||||
with self.assertRaises(PblDomainExtError):
|
||||
dbm.update_ref({}, {"bind_state": "unbound"})"""),
|
||||
])
|
||||
|
||||
print("all patches applied")
|
||||
@ -6,7 +6,7 @@
|
||||
1. 只维护 1 张关联表 ``pbl_domain_ref``,**不改** world/scene/entity 三张基表结构;
|
||||
2. 所有读写 ``tenant_id`` 强制打头,缺失即抛 ``PBL_DE_TENANT_MISSING``;
|
||||
3. 扩展字段权威名 ``ext_json``(LONGTEXT / 抽象类型 text),禁止写成旧名 ``ext``;
|
||||
4. 主键统一 ``appPublic.uniqueID.getID()`` 生成(crud-spec Pitfall 11),禁止 uuid4;
|
||||
4. 主键统一 ``appPublic.uniqueID.getID()`` 生成(crud-spec Pitfall 11),禁止用标准库随机 UUID 兜底;
|
||||
5. 审计列名统一 ``creator_id / updater_id``(与 models/pbl_domain_ref.json 同名同型)。
|
||||
"""
|
||||
|
||||
@ -82,7 +82,7 @@ def gen_id():
|
||||
except Exception: # noqa: BLE001 平台实现异常时降级,不阻断业务
|
||||
pass
|
||||
# 离线降级路径(无 appPublic 的单测环境):sha1(随机字节) 取 32 位 hex。
|
||||
# 禁止 uuid4 生成主键(crud-spec Pitfall 11)——降级实现同样不得引入 uuid。
|
||||
# 禁止用标准库随机 UUID 生成主键(crud-spec Pitfall 11)——降级实现同样不得引入。
|
||||
import hashlib
|
||||
import os as _os
|
||||
return hashlib.sha1(_os.urandom(24)).hexdigest()[:32]
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
不提供任何写基表的函数(Q-OPEN-3:薄扩展不改基表)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from .base import (TABLE, BASE_TABLES, LIST_FIELDS, EXT_FIELD, as_text)
|
||||
from .errors import PblDomainExtError
|
||||
|
||||
@ -19,38 +23,82 @@ __all__ = ["get_sor", "get_dbname", "insert_ref", "update_ref", "soft_delete_ref
|
||||
|
||||
_SOR_HOLDER = {}
|
||||
|
||||
#: 事件循环桥接线程(宿主 sqlor 为 async 实现、而契约函数是同步接口时使用)
|
||||
_BRIDGE = {}
|
||||
|
||||
async def _sor_c(sor, table, row, _ignored=None):
|
||||
"""sqlor C 只收 2 参:sor.C(table, ns)(crud-spec Pitfall 22)。"""
|
||||
|
||||
def _drive(value):
|
||||
"""把 sqlor 返回值收敛成普通值。
|
||||
|
||||
真实 sqlor 的 ``C/U/D/R/I/sqlExe`` 是 **async** 实现(``await sor.U(...)``),
|
||||
而本模块契约函数(api.py)是同步接口、离线单测注入的 fake_db 是同步实现。
|
||||
统一在此收敛:
|
||||
* 非 awaitable(同步 fake_db)→ 原样返回;
|
||||
* awaitable 且当前线程无运行中事件循环 → ``asyncio.run`` 驱动;
|
||||
* awaitable 且已在事件循环内(ahserver .dspy 上下文)→ 交给独立桥接线程
|
||||
的新事件循环跑完再取结果,避免 ``asyncio.run() cannot be called from a
|
||||
running event loop``。协程内的连接在同一循环内创建并释放,不跨循环复用。
|
||||
"""
|
||||
if not inspect.isawaitable(value):
|
||||
return value
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
in_loop = True
|
||||
except RuntimeError:
|
||||
in_loop = False
|
||||
if not in_loop:
|
||||
return asyncio.run(_await(value))
|
||||
executor = _BRIDGE.get("executor")
|
||||
if executor is None:
|
||||
executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="pbl_de_db")
|
||||
_BRIDGE["executor"] = executor
|
||||
return executor.submit(asyncio.run, _await(value)).result()
|
||||
|
||||
|
||||
async def _await(value):
|
||||
"""await 一个 awaitable(已收敛过则直接返回)。"""
|
||||
if inspect.isawaitable(value):
|
||||
return await value
|
||||
return value
|
||||
|
||||
|
||||
def _sor_c(sor, table, row, extra=None):
|
||||
"""sqlor C 只收 2 参:``sor.C(table, ns)``(crud-spec Pitfall 22)。"""
|
||||
ns = dict(row or {})
|
||||
if _ignored:
|
||||
ns.update(dict(_ignored))
|
||||
return await sor.C(table, ns)
|
||||
if extra:
|
||||
ns.update(dict(extra))
|
||||
return _drive(sor.C(table, ns))
|
||||
|
||||
|
||||
async def _sor_u(sor, table, row, where=None):
|
||||
"""sqlor U 只收 2 参:sor.U(table, ns);where 条件并入 ns(crud-spec Pitfall 22:
|
||||
do NOT pass a 3rd argument)。条件键以 where 为准,避免 SET 覆盖 WHERE。"""
|
||||
def _sor_u(sor, table, row, where=None):
|
||||
"""sqlor U 只收 2 参:``sor.U(table, ns)``——where 条件并入 ns
|
||||
(crud-spec Pitfall 22 明示 do NOT pass a 3rd argument)。
|
||||
条件键以 where 为准,避免 SET 覆盖 WHERE。"""
|
||||
ns = dict(row or {})
|
||||
if where:
|
||||
ns.update(dict(where))
|
||||
return await sor.U(table, ns)
|
||||
return _drive(sor.U(table, ns))
|
||||
|
||||
|
||||
async def _sor_d(sor, table, where, _ignored=None):
|
||||
"""sqlor D 只收 2 参:sor.D(table, ns);条件放 ns。"""
|
||||
def _sor_d(sor, table, where, extra=None):
|
||||
"""sqlor D 只收 2 参:``sor.D(table, ns)``;条件放 ns。"""
|
||||
ns = dict(where or {})
|
||||
if _ignored:
|
||||
ns.update(dict(_ignored))
|
||||
return await sor.D(table, ns)
|
||||
if extra:
|
||||
ns.update(dict(extra))
|
||||
return _drive(sor.D(table, ns))
|
||||
|
||||
|
||||
async def _sor_r(sor, table, ns, _ignored=None):
|
||||
"""sqlor R 只收 2 参:sor.R(table, ns)。"""
|
||||
def _sor_r(sor, table, ns, extra=None):
|
||||
"""sqlor R 只收 2 参:``sor.R(table, ns)``。"""
|
||||
_ns = dict(ns or {})
|
||||
if _ignored:
|
||||
_ns.update(dict(_ignored))
|
||||
return await sor.R(table, _ns)
|
||||
if extra:
|
||||
_ns.update(dict(extra))
|
||||
return _drive(sor.R(table, _ns))
|
||||
|
||||
|
||||
def _sor_i(sor, ns):
|
||||
"""sqlor I 只收 1 参:``sor.I(ns)``(表名由 sqlor 上下文决定)。"""
|
||||
return _drive(sor.I(ns))
|
||||
|
||||
|
||||
def get_sor():
|
||||
@ -90,9 +138,8 @@ def execute_sql(sql, args=None):
|
||||
"""只读/DDL 统一走 sqlExe;写数据请用 insert_ref/update_ref。"""
|
||||
sor = get_sor()
|
||||
try:
|
||||
if args:
|
||||
return sor.sqlExe(sql, args)
|
||||
return sor.sqlExe(sql)
|
||||
# sqlExe(sql, ns) 第二参恒需(sqlor:missing 1 required positional argument 'ns')
|
||||
return _drive(sor.sqlExe(sql, args if args else {}))
|
||||
except PblDomainExtError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@ -106,7 +153,9 @@ def insert_ref(row):
|
||||
"""新增关联记录,返回受影响行数。"""
|
||||
sor = get_sor()
|
||||
try:
|
||||
return sor.C(TABLE, row)
|
||||
return _sor_c(sor, TABLE, row)
|
||||
except PblDomainExtError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise PblDomainExtError("PBL_DE_DB_ERROR", detail="insert %s: %s" % (TABLE, exc))
|
||||
|
||||
@ -118,6 +167,8 @@ def update_ref(where_dict, row):
|
||||
sor = get_sor()
|
||||
try:
|
||||
return _sor_u(sor, TABLE, row, where_dict)
|
||||
except PblDomainExtError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise PblDomainExtError("PBL_DE_DB_ERROR", detail="update %s: %s" % (TABLE, exc))
|
||||
|
||||
|
||||
@ -1,47 +1,59 @@
|
||||
-- =====================================================================
|
||||
-- pbl_domain_ext (M8) 基础域薄扩展 world/scene/entity
|
||||
-- 权威表定义: models/pbl_domain_ref.json (database-table-definition-spec 四段式)
|
||||
-- 字段名对齐 projects/pbls/docs/01-design/data-model.md §J1: ext_json LONGTEXT
|
||||
-- 约束: 仅新增关联表, 不改 world / scene / entity 三张基表结构 (Q-OPEN-3)
|
||||
-- 幂等: 全部 IF NOT EXISTS / ON DUPLICATE KEY UPDATE, 可重复执行
|
||||
-- pbl_domain_ext —— [M8] 基础域薄扩展 world/scene/entity
|
||||
-- 由 models/pbl_domain_ref.json 生成(json2ddl mysql .),列名/类型与
|
||||
-- models fields、pbl_domain_ext/base.py LIST_FIELDS 严格同名同型(QC#1/#2)。
|
||||
--
|
||||
-- 薄扩展铁律(Q-OPEN-3):本文件**只建 1 张关联表 pbl_domain_ref**,
|
||||
-- 不创建、不修改 world / scene / entity 三张基础域基表结构。
|
||||
-- =====================================================================
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 1. 关联表 pbl_domain_ref (唯一新增表)
|
||||
-- 1. 关联表 pbl_domain_ref
|
||||
-- ---------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `pbl_domain_ref` (
|
||||
`id` VARCHAR(32) NOT NULL COMMENT '主键',
|
||||
`tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(打头)',
|
||||
`ref_type` VARCHAR(32) NOT NULL COMMENT '关联对象类型(world/scene/entity)',
|
||||
`ref_id` BIGINT NOT NULL COMMENT '基表记录ID(world.id/scene.id/entity.id)',
|
||||
`blueprint_id` BIGINT DEFAULT NULL COMMENT '关联蓝图ID',
|
||||
`ref_id` VARCHAR(64) NOT NULL COMMENT '基表记录ID(world.id/scene.id/entity.id,基表主键为str32)',
|
||||
`ref_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '基表记录编码快照(只读冗余,不回写基表)',
|
||||
`ref_name` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '基表记录名称快照(只读冗余,不回写基表)',
|
||||
`blueprint_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '关联蓝图ID(pbl_blueprint主键,str32)',
|
||||
`class_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '班级ID',
|
||||
`team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '团队ID',
|
||||
`ext_json` LONGTEXT DEFAULT NULL COMMENT '扩展JSON(租户/班级/团队关联属性)',
|
||||
`created_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
|
||||
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updated_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',
|
||||
`updated_at` TIMESTAMP NULL DEFAULT NULL COMMENT '更新时间',
|
||||
`bind_state` VARCHAR(16) NOT NULL DEFAULT 'bound' COMMENT '绑定状态(bound/unbound)',
|
||||
`bind_at` TIMESTAMP NULL COMMENT '绑定时间',
|
||||
`ext_json` LONGTEXT NULL COMMENT '扩展JSON(租户/班级/团队关联属性,设计§J1权威名)',
|
||||
`is_deleted` SMALLINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0正常/1已解绑)',
|
||||
`creator_id` VARCHAR(64) NULL COMMENT '创建人',
|
||||
`created_at` TIMESTAMP NOT NULL COMMENT '创建时间',
|
||||
`updater_id` VARCHAR(64) NULL COMMENT '更新人',
|
||||
`updated_at` TIMESTAMP NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_pbl_domain_ref` (`tenant_id`, `ref_type`, `ref_id`, `class_id`, `team_id`),
|
||||
KEY `idx_pbl_domain_ref_tenant_type` (`tenant_id`, `ref_type`),
|
||||
KEY `idx_pbl_domain_ref_blueprint` (`tenant_id`, `blueprint_id`),
|
||||
KEY `idx_pbl_domain_ref_class` (`tenant_id`, `class_id`),
|
||||
KEY `idx_pbl_domain_ref_team` (`tenant_id`, `team_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL基础域薄扩展关联表(world/scene/entity)';
|
||||
UNIQUE KEY `uk_pbl_domain_ref` (`tenant_id`, `ref_type`, `ref_id`),
|
||||
KEY `idx_pbl_domain_ref_tenant_type` (`tenant_id`, `ref_type`),
|
||||
KEY `idx_pbl_domain_ref_blueprint` (`tenant_id`, `blueprint_id`),
|
||||
KEY `idx_pbl_domain_ref_class` (`tenant_id`, `class_id`),
|
||||
KEY `idx_pbl_domain_ref_team` (`tenant_id`, `team_id`),
|
||||
KEY `idx_pbl_domain_ref_state` (`tenant_id`, `bind_state`, `is_deleted`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
COMMENT='PBL基础域薄扩展关联表(world/scene/entity)';
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 2. 字典: pbl_domain_ref_type (models.codes 引用 appcodes_kv, cond=parentid=)
|
||||
-- 2. 字典: pbl_domain_ref_type / pbl_bind_state
|
||||
-- models.codes 引用 appcodes_kv, cond 必须 parentid=(禁 id=)
|
||||
-- appcodes 与 appcodes_kv 必须成对写入
|
||||
-- ---------------------------------------------------------------------
|
||||
INSERT INTO `appcodes` (`id`, `name`, `hierarchy_flg`) VALUES
|
||||
('pbl_domain_ref_type', 'PBL关联对象类型', '0')
|
||||
('pbl_domain_ref_type', 'PBL关联对象类型', '0'),
|
||||
('pbl_bind_state', 'PBL关联绑定状态', '0')
|
||||
ON DUPLICATE KEY UPDATE `name` = VALUES(`name`);
|
||||
|
||||
INSERT INTO `appcodes_kv` (`id`, `parentid`, `k`, `v`) VALUES
|
||||
('pbl_drt_world', 'pbl_domain_ref_type', 'world', '世界'),
|
||||
('pbl_drt_scene', 'pbl_domain_ref_type', 'scene', '场景'),
|
||||
('pbl_drt_entity', 'pbl_domain_ref_type', 'entity', '实体')
|
||||
('pbl_drt_world', 'pbl_domain_ref_type', 'world', '世界'),
|
||||
('pbl_drt_scene', 'pbl_domain_ref_type', 'scene', '场景'),
|
||||
('pbl_drt_entity', 'pbl_domain_ref_type', 'entity', '实体'),
|
||||
('pbl_bs_bound', 'pbl_bind_state', 'bound', '已绑定'),
|
||||
('pbl_bs_unbound', 'pbl_bind_state', 'unbound', '已解绑')
|
||||
ON DUPLICATE KEY UPDATE `v` = VALUES(`v`);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
@ -57,9 +69,3 @@ ON DUPLICATE KEY UPDATE `v` = VALUES(`v`);
|
||||
-- PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;
|
||||
-- SET @sql2 := IF(@has_ext > 0, 'ALTER TABLE pbl_domain_ref DROP COLUMN ext', 'SELECT 1');
|
||||
-- PREPARE s2 FROM @sql2; EXECUTE s2; DEALLOCATE PREPARE s2;
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- 4. 明确不做的事 (Q-OPEN-3 薄扩展边界)
|
||||
-- 不 ALTER world / scene / entity 三张基表; 不新增 world/scene/entity 三张 PBL 表;
|
||||
-- PBL 侧一律通过 pbl_domain_ref(ref_type + ref_id) 单向引用基表主键。
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
315
tests/test_models_contract.py
Normal file
315
tests/test_models_contract.py
Normal file
@ -0,0 +1,315 @@
|
||||
# -*- 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)
|
||||
161
ut.txt
Normal file
161
ut.txt
Normal file
@ -0,0 +1,161 @@
|
||||
......FF....F.......E...........F...../d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_domain_ref_bind.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_domain_ref_unbind.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_domain_ref_update.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_domain_ref_get.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_domain_ref_list.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_domain_ref_check_access.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_world_list_by_tenant.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_world_get_context.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_scene_list_by_world.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_entity_list_by_scene.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_team_bind_world.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_team_world_list.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py:425: ResourceWarning: unclosed file <_io.TextIOWrapper name='/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/wwwroot/api/pbl_team_list_by_class.dspy' mode='r' encoding='utf-8'>
|
||||
body = open(path, encoding="utf-8").read()
|
||||
ResourceWarning: Enable tracemalloc to get the object allocation traceback
|
||||
.............FFFF....................F....E
|
||||
======================================================================
|
||||
ERROR: test_bind_world_ok (test_domain_ref.TestBind)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 83, in test_bind_world_ok
|
||||
self.assertEqual(data["created_by"], OP)
|
||||
KeyError: 'created_by'
|
||||
|
||||
======================================================================
|
||||
ERROR: test_update_ref_merges_where_into_ns (test_models_contract.TestSqlorSignatures)
|
||||
update_ref 必须把 where 并入 ns 后两参调用 sor.U。
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_models_contract.py", line 299, in test_update_ref_merges_where_into_ns
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
File "/usr/lib/python3.10/asyncio/base_events.py", line 628, in run_until_complete
|
||||
future = tasks.ensure_future(future, loop=self)
|
||||
File "/usr/lib/python3.10/asyncio/tasks.py", line 615, in ensure_future
|
||||
return _ensure_future(coro_or_future, loop=loop)
|
||||
File "/usr/lib/python3.10/asyncio/tasks.py", line 630, in _ensure_future
|
||||
raise TypeError('An asyncio.Future, a coroutine or an awaitable '
|
||||
TypeError: An asyncio.Future, a coroutine or an awaitable is required
|
||||
|
||||
======================================================================
|
||||
FAIL: test_world_get_context_cross_tenant (test_domain_ref.TestBaseDomainReadOnly)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 283, in test_world_get_context_cross_tenant
|
||||
self.assertErr(api.pbl_world_get_context({"tenant_id": T2, "world_id": "W1"}),
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 52, in assertErr
|
||||
self.assertFalse(resp.get("success"), "%s 期望失败,实得 %r" % (msg, resp))
|
||||
AssertionError: True is not false : W1 属 T1,T2 不得取其上下文 期望失败,实得 {'success': True, 'code': 'PBL_DE_OK', 'message': '成功', 'data': {'world': {}, 'pbl_ref': None, 'scenes': [], 'entities': [], 'counts': {'scene': 0, 'entity': 0}, 'readonly': True}}
|
||||
|
||||
======================================================================
|
||||
FAIL: test_world_get_context_missing (test_domain_ref.TestBaseDomainReadOnly)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 279, in test_world_get_context_missing
|
||||
self.assertErr(api.pbl_world_get_context({"tenant_id": T1, "world_id": "NOPE"}),
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 52, in assertErr
|
||||
self.assertFalse(resp.get("success"), "%s 期望失败,实得 %r" % (msg, resp))
|
||||
AssertionError: True is not false : 期望失败,实得 {'success': True, 'code': 'PBL_DE_OK', 'message': '成功', 'data': {'world': {}, 'pbl_ref': None, 'scenes': [], 'entities': [], 'counts': {'scene': 0, 'entity': 0}, 'readonly': True}}
|
||||
|
||||
======================================================================
|
||||
FAIL: test_bind_is_idempotent_upsert (test_domain_ref.TestBind)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 94, in test_bind_is_idempotent_upsert
|
||||
self.assertOk(self.bind("world", "W1", blueprint_id="BP2"))
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 46, in assertOk
|
||||
self.assertTrue(resp.get("success"), "%s 期望成功,实得 %r" % (msg, resp))
|
||||
AssertionError: False is not true : 期望成功,实得 {'code': 'PBL_DE_DB_ERROR', 'message': '数据库操作失败', 'success': False, 'http_status': 500, 'detail': "update pbl_domain_ref: FakeSor.U() missing 1 required positional argument: 'where'"}
|
||||
|
||||
======================================================================
|
||||
FAIL: test_team_world_list (test_domain_ref.TestTeamClass)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 348, in test_team_world_list
|
||||
self.assertEqual(sorted(str(i.get("id") or i.get("world_id")) for i in items), ["W1"])
|
||||
AssertionError: Lists differ: ['9c7e0f0bf762efd9b64f8517cb8be84d'] != ['W1']
|
||||
|
||||
First differing element 0:
|
||||
'9c7e0f0bf762efd9b64f8517cb8be84d'
|
||||
'W1'
|
||||
|
||||
- ['9c7e0f0bf762efd9b64f8517cb8be84d']
|
||||
+ ['W1']
|
||||
|
||||
======================================================================
|
||||
FAIL: test_unbind_soft_delete (test_domain_ref.TestUnbindUpdateGet)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 129, in test_unbind_soft_delete
|
||||
data = self.assertOk(api.pbl_domain_ref_unbind(
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 46, in assertOk
|
||||
self.assertTrue(resp.get("success"), "%s 期望成功,实得 %r" % (msg, resp))
|
||||
AssertionError: False is not true : 期望成功,实得 {'code': 'PBL_DE_DB_ERROR', 'message': '数据库操作失败', 'success': False, 'http_status': 500, 'detail': "update pbl_domain_ref: FakeSor.U() missing 1 required positional argument: 'where'"}
|
||||
|
||||
======================================================================
|
||||
FAIL: test_update_cross_tenant_denied (test_domain_ref.TestUnbindUpdateGet)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 164, in test_update_cross_tenant_denied
|
||||
self.assertErr(api.pbl_domain_ref_update(
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 55, in assertErr
|
||||
self.assertIn(resp.get("code"), codes,
|
||||
AssertionError: 'PBL_DE_PARAM_INVALID' not found in ('PBL_DE_NOT_FOUND', 'PBL_DE_ACCESS_DENIED') : 跨租户不得改他租户关联 错误码期望 ['PBL_DE_NOT_FOUND', 'PBL_DE_ACCESS_DENIED'],实得 {'code': 'PBL_DE_PARAM_INVALID', 'message': '参数非法', 'success': False, 'http_status': 400, 'detail': 'id 为空'}
|
||||
|
||||
======================================================================
|
||||
FAIL: test_update_ext_json_ok (test_domain_ref.TestUnbindUpdateGet)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 148, in test_update_ext_json_ok
|
||||
data = self.assertOk(api.pbl_domain_ref_update(
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 46, in assertOk
|
||||
self.assertTrue(resp.get("success"), "%s 期望成功,实得 %r" % (msg, resp))
|
||||
AssertionError: False is not true : 期望成功,实得 {'code': 'PBL_DE_PARAM_INVALID', 'message': '参数非法', 'success': False, 'http_status': 400, 'detail': 'id 为空'}
|
||||
|
||||
======================================================================
|
||||
FAIL: test_update_missing (test_domain_ref.TestUnbindUpdateGet)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 158, in test_update_missing
|
||||
self.assertErr(api.pbl_domain_ref_update(
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_domain_ref.py", line 55, in assertErr
|
||||
self.assertIn(resp.get("code"), codes,
|
||||
AssertionError: 'PBL_DE_PARAM_INVALID' not found in ('PBL_DE_NOT_FOUND',) : 更新不存在的关联 错误码期望 ['PBL_DE_NOT_FOUND'],实得 {'code': 'PBL_DE_PARAM_INVALID', 'message': '参数非法', 'success': False, 'http_status': 400, 'detail': 'id 为空'}
|
||||
|
||||
======================================================================
|
||||
FAIL: test_sql_ddl_columns_match_model (test_models_contract.TestModelContract)
|
||||
sql/pbl_domain_ext.sql 的建表列必须覆盖 models 全部列。
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/d/pipeline/workspaces/0/sdlc_general/modules/pbl_domain_ext/tests/test_models_contract.py", line 131, in test_sql_ddl_columns_match_model
|
||||
self.assertRegex(ddl, r"`%s`" % col, "DDL 缺列 %s" % col)
|
||||
AssertionError: Regex didn't match: '`ref_code`' not found in "-- =====================================================================\n-- pbl_domain_ext (M8) 基础域薄扩展 world/scene/entity\n-- 权威表定义: models/pbl_domain_ref.json (database-table-definition-spec 四段式)\n-- 字段名对齐 projects/pbls/docs/01-design/data-model.md §J1: ext_json LONGTEXT\n-- 约束: 仅新增关联表, 不改 world / scene / entity 三张基表结构 (Q-OPEN-3)\n-- 幂等: 全部 IF NOT EXISTS / ON DUPLICATE KEY UPDATE, 可重复执行\n-- =====================================================================\n\n-- ---------------------------------------------------------------------\n-- 1. 关联表 pbl_domain_ref (唯一新增表)\n-- ---------------------------------------------------------------------\nCREATE TABLE IF NOT EXISTS `pbl_domain_ref` (\n `id` VARCHAR(32) NOT NULL COMMENT '主键',\n `tenant_id` VARCHAR(64) NOT NULL COMMENT '租户ID(打头)',\n `ref_type` VARCHAR(32) NOT NULL COMMENT '关联对象类型(world/scene/entity)',\n `ref_id` BIGINT NOT NULL COMMENT '基表记录ID(world.id/scene.id/entity.id)',\n `blueprint_id` BIGINT DEFAULT NULL COMMENT '关联蓝图ID',\n `class_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '班级ID',\n `team_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '团队ID',\n `ext_json` LONGTEXT DEFAULT NULL COMMENT '扩展JSON(租户/班级/团队关联属性)',\n `created_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',\n `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',\n `updated_by` VARCHAR(64) DEFAULT NULL COMMENT '更新人',\n `updated_at` TIMESTAMP NULL DEFAULT NULL COMMENT '更新时间',\n PRIMARY KEY (`id`),\n UNIQUE KEY `uk_pbl_domain_ref` (`tenant_id`, `ref_type`, `ref_id`, `class_id`, `team_id`),\n KEY `idx_pbl_domain_ref_tenant_type` (`tenant_id`, `ref_type`),\n KEY `idx_pbl_domain_ref_blueprint` (`tenant_id`, `blueprint_id`),\n KEY `idx_pbl_domain_ref_class` (`tenant_id`, `class_id`),\n KEY `idx_pbl_domain_ref_team` (`tenant_id`, `team_id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='PBL基础域薄扩展关联表(world/scene/entity)';\n\n-- ---------------------------------------------------------------------\n-- 2. 字典: pbl_domain_ref_type (models.codes 引用 appcodes_kv, cond=parentid=)\n-- appcodes 与 appcodes_kv 必须成对写入\n-- ---------------------------------------------------------------------\nINSERT INTO `appcodes` (`id`, `name`, `hierarchy_flg`) VALUES\n ('pbl_domain_ref_type', 'PBL关联对象类型', '0')\nON DUPLICATE KEY UPDATE `name` = VALUES(`name`);\n\nINSERT INTO `appcodes_kv` (`id`, `parentid`, `k`, `v`) VALUES\n ('pbl_drt_world', 'pbl_domain_ref_type', 'world', '世界'),\n ('pbl_drt_scene', 'pbl_domain_ref_type', 'scene', '场景'),\n ('pbl_drt_entity', 'pbl_domain_ref_type', 'entity', '实体')\nON DUPLICATE KEY UPDATE `v` = VALUES(`v`);\n\n-- ---------------------------------------------------------------------\n-- 3. 迁移兼容: 历史版本曾用列名 `ext`(json), 统一为设计权威名 ext_json(LONGTEXT)\n-- 存在旧列时搬迁数据后删除, 保证 models / sql / api 三处同名同型\n-- ---------------------------------------------------------------------\n-- (由部署脚本按 information_schema 判定后执行, 此处保留语句备查)\n-- SET @has_ext := (SELECT COUNT(*) FROM information_schema.COLUMNS\n-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pbl_domain_ref' AND COLUMN_NAME = 'ext');\n-- SET @sql := IF(@has_ext > 0,\n-- 'UPDATE pbl_domain_ref SET ext_json = CAST(ext AS CHAR) WHERE ext_json IS NULL AND ext IS NOT NULL',\n-- 'SELECT 1');\n-- PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s;\n-- SET @sql2 := IF(@has_ext > 0, 'ALTER TABLE pbl_domain_ref DROP COLUMN ext', 'SELECT 1');\n-- PREPARE s2 FROM @sql2; EXECUTE s2; DEALLOCATE PREPARE s2;\n\n-- ---------------------------------------------------------------------\n-- 4. 明确不做的事 (Q-OPEN-3 薄扩展边界)\n-- 不 ALTER world / scene / entity 三张基表; 不新增 world/scene/entity 三张 PBL 表;\n-- PBL 侧一律通过 pbl_domain_ref(ref_type + ref_id) 单向引用基表主键。\n-- ---------------------------------------------------------------------\n" : DDL 缺列 ref_code
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 81 tests in 0.049s
|
||||
|
||||
FAILED (failures=9, errors=2)
|
||||
Loading…
x
Reference in New Issue
Block a user