pbl_domain_ext/tests/test_domain_ref.py

529 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""tests/test_domain_ref.py — M8 薄扩展 13 个契约接口的真实断言测试(离线 sqlite
覆盖:
§3.1 bind_ref / unbind_ref / get_ref / list_refs / update_ref
§3.2 list_worlds_by_tenant / list_scenes_by_world / list_entities_by_scene
/ get_world_with_pbl_context / check_ref_access
§3.3 list_teams_by_class / bind_team_to_world / get_team_worlds
+ 薄扩展铁律:不改基表(基表行数/结构前后一致、跨租户隔离US-21
悬挂引用过滤、表总账OWN_TABLES == models/*.json == 1 张 pbl_domain_ref
运行python3 tests/test_domain_ref.py
"""
import asyncio
import json
import os
import sys
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, ROOT)
sys.path.insert(0, HERE)
import fake_db # noqa: E402
from pbl_domain_ext import api # noqa: E402
from pbl_domain_ext import base as base_mod # noqa: E402
from pbl_domain_ext import init as init_mod # noqa: E402
from pbl_domain_ext.errors import (E_DUPLICATE, E_FORBIDDEN, E_NOT_FOUND, # noqa: E402
E_VALIDATION, PblError)
T1 = 'T1'
T2 = 'T2'
def run(coro):
return asyncio.get_event_loop().run_until_complete(coro) if False else asyncio.run(coro)
class BaseCase(unittest.TestCase):
def setUp(self):
self.conn, self.adapter = fake_db.setup()
def tearDown(self):
fake_db.teardown()
self.conn.close()
def assertPblError(self, code, coro):
with self.assertRaises(Exception) as ctx:
run(coro)
exc = ctx.exception
self.assertEqual(getattr(exc, 'code', None), code,
'期望 %s,实际 %s(%s)' % (code, getattr(exc, 'code', None), exc))
return exc
class TestBindRef(BaseCase):
"""接口 1bind_ref"""
def test_bind_world_ok(self):
ref = run(api.bind_ref('world', 1, blueprint_id='BP-1', class_id='CLS-1',
team_id='TEAM_A', ext_json={'stage': 2}, tenant_id=T1))
self.assertIsNotNone(ref)
self.assertEqual(ref['tenant_id'], T1)
self.assertEqual(ref['ref_type'], 'world')
self.assertEqual(ref['ref_id'], 1)
self.assertEqual(ref['blueprint_id'], 'BP-1')
self.assertEqual(ref['class_id'], 'CLS-1')
self.assertEqual(ref['team_id'], 'TEAM_A')
self.assertEqual(ref['ext_json'], {'stage': 2})
self.assertEqual(ref['is_deleted'], 0)
def test_bind_duplicate_raises(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.assertPblError(E_DUPLICATE, api.bind_ref('world', 1, tenant_id=T1))
def test_bind_same_ref_other_tenant_ok(self):
"""同一基表记录可被不同租户各自绑定UNIQUE 含 tenant_id"""
run(api.bind_ref('world', 1, tenant_id=T1))
ref2 = run(api.bind_ref('world', 1, tenant_id=T2))
self.assertEqual(ref2['tenant_id'], T2)
def test_bind_base_missing_raises_not_found(self):
self.assertPblError(E_NOT_FOUND, api.bind_ref('world', 999, tenant_id=T1))
def test_bind_invalid_ref_type(self):
self.assertPblError(E_VALIDATION, api.bind_ref('script', 1, tenant_id=T1))
def test_bind_invalid_ref_id(self):
self.assertPblError(E_VALIDATION, api.bind_ref('world', 'abc', tenant_id=T1))
self.assertPblError(E_VALIDATION, api.bind_ref('world', 0, tenant_id=T1))
def test_bind_missing_tenant(self):
self.assertPblError(E_VALIDATION, api.bind_ref('world', 1))
def test_rebind_after_unbind_revives(self):
run(api.bind_ref('scene', 11, class_id='CLS-1', tenant_id=T1))
run(api.unbind_ref('scene', 11, tenant_id=T1))
ref = run(api.bind_ref('scene', 11, class_id='CLS-2', tenant_id=T1))
self.assertEqual(ref['class_id'], 'CLS-2')
self.assertEqual(ref['is_deleted'], 0)
def test_bind_ext_invalid_json_string(self):
self.assertPblError(E_VALIDATION,
api.bind_ref('world', 1, ext_json='{bad json', tenant_id=T1))
class TestUnbindGetUpdate(BaseCase):
"""接口 2/3/5unbind_ref / get_ref / update_ref"""
def test_unbind_soft_delete(self):
run(api.bind_ref('entity', 101, tenant_id=T1))
self.assertTrue(run(api.unbind_ref('entity', 101, tenant_id=T1)))
flag = fake_db.scalar(self.conn,
"SELECT is_deleted FROM pbl_domain_ref WHERE ref_id=101")
self.assertEqual(flag, 1, 'unbind 必须软删而非物理删除')
self.assertPblError(E_NOT_FOUND, api.get_ref('entity', 101, tenant_id=T1))
def test_unbind_missing(self):
self.assertPblError(E_NOT_FOUND, api.unbind_ref('world', 1, tenant_id=T1))
def test_get_ref_ok(self):
run(api.bind_ref('world', 2, blueprint_id='BP-9', tenant_id=T1))
ref = run(api.get_ref('world', 2, tenant_id=T1))
self.assertEqual(ref['blueprint_id'], 'BP-9')
def test_get_ref_cross_tenant_not_found(self):
run(api.bind_ref('world', 2, tenant_id=T1))
self.assertPblError(E_NOT_FOUND, api.get_ref('world', 2, tenant_id=T2))
def test_update_ref_ok(self):
run(api.bind_ref('world', 1, class_id='CLS-1', tenant_id=T1))
ref = run(api.update_ref('world', 1, {'class_id': 'CLS-2', 'team_id': 'TEAM_B',
'ext_json': {'k': 'v'}}, tenant_id=T1))
self.assertEqual(ref['class_id'], 'CLS-2')
self.assertEqual(ref['team_id'], 'TEAM_B')
self.assertEqual(ref['ext_json'], {'k': 'v'})
def test_update_ref_rejects_key_fields(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.assertPblError(E_VALIDATION,
api.update_ref('world', 1, {'ref_id': 2}, tenant_id=T1))
self.assertPblError(E_VALIDATION,
api.update_ref('world', 1, {'tenant_id': T2}, tenant_id=T1))
def test_update_ref_missing(self):
self.assertPblError(E_NOT_FOUND,
api.update_ref('world', 1, {'class_id': 'X'}, tenant_id=T1))
class TestListRefs(BaseCase):
"""接口 4list_refs过滤 + 分页 + 悬挂引用标记)"""
def _seed(self):
run(api.bind_ref('world', 1, blueprint_id='BP-1', class_id='CLS-1',
team_id='TEAM_A', tenant_id=T1))
run(api.bind_ref('world', 2, blueprint_id='BP-1', class_id='CLS-1',
team_id='TEAM_B', tenant_id=T1))
run(api.bind_ref('scene', 11, blueprint_id='BP-2', class_id='CLS-1',
tenant_id=T1))
run(api.bind_ref('entity', 101, class_id='CLS-2', tenant_id=T1))
run(api.bind_ref('world', 1, class_id='CLS-9', tenant_id=T2))
def test_list_all_tenant_scoped(self):
self._seed()
res = run(api.list_refs({}, 1, 20, tenant_id=T1))
self.assertEqual(res['total'], 4, 'T1 只应看到自己的 4 条关联')
self.assertTrue(all(i['tenant_id'] == T1 for i in res['items']))
def test_list_filter_by_type_and_class(self):
self._seed()
res = run(api.list_refs({'ref_type': 'world'}, 1, 20, tenant_id=T1))
self.assertEqual(res['total'], 2)
res2 = run(api.list_refs({'class_id': 'CLS-2'}, 1, 20, tenant_id=T1))
self.assertEqual(res2['total'], 1)
self.assertEqual(res2['items'][0]['ref_type'], 'entity')
res3 = run(api.list_refs({'blueprint_id': 'BP-1'}, 1, 20, tenant_id=T1))
self.assertEqual(res3['total'], 2)
res4 = run(api.list_refs({'team_id': 'TEAM_B'}, 1, 20, tenant_id=T1))
self.assertEqual(res4['total'], 1)
def test_list_pagination(self):
self._seed()
page1 = run(api.list_refs({}, 1, 2, tenant_id=T1))
page2 = run(api.list_refs({}, 2, 2, tenant_id=T1))
self.assertEqual(len(page1['items']), 2)
self.assertEqual(len(page2['items']), 2)
self.assertEqual(page1['total'], 4)
ids1 = {i['id'] for i in page1['items']}
ids2 = {i['id'] for i in page2['items']}
self.assertFalse(ids1 & ids2, '分页结果不得重叠')
def test_list_page_size_capped(self):
self._seed()
res = run(api.list_refs({}, 1, 9999, tenant_id=T1))
self.assertEqual(res['size'], api.MAX_PAGE_SIZE)
def test_list_invalid_ref_type_filter(self):
self.assertPblError(E_VALIDATION,
api.list_refs({'ref_type': 'bogus'}, 1, 20, tenant_id=T1))
def test_list_dangling_flag(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.conn.execute("DELETE FROM world WHERE id=1") # 复用模块删除基表记录
self.conn.commit()
res = run(api.list_refs({'ref_type': 'world'}, 1, 20, tenant_id=T1))
self.assertEqual(res['total'], 1)
self.assertTrue(res['items'][0]['dangling'], '基表已删的 ref 必须标记 dangling')
self.assertEqual(res['valid_total'], 0)
class TestTenantIsolationQueries(BaseCase):
"""接口 6~9租户隔离查询封装基表 + 扩展联合)"""
def test_list_worlds_by_tenant_only_bound(self):
run(api.bind_ref('world', 1, class_id='CLS-1', tenant_id=T1))
worlds = run(api.list_worlds_by_tenant(T1))
self.assertEqual(len(worlds), 1)
self.assertEqual(worlds[0]['id'], 1)
self.assertEqual(worlds[0]['name'], '世界A-火星基地')
self.assertEqual(worlds[0]['class_id'], 'CLS-1')
self.assertIsNotNone(worlds[0]['pbl_ref'])
# 未绑定的 world 2 对 T1 不可见
self.assertNotIn(2, [w['id'] for w in worlds])
def test_list_worlds_by_tenant_empty_when_no_binding(self):
self.assertEqual(run(api.list_worlds_by_tenant(T2)), [])
def test_list_worlds_by_class_filter(self):
run(api.bind_ref('world', 1, class_id='CLS-1', tenant_id=T1))
run(api.bind_ref('world', 2, class_id='CLS-2', tenant_id=T1))
got = run(api.list_worlds_by_tenant(T1, class_id='CLS-2'))
self.assertEqual([w['id'] for w in got], [2])
def test_list_worlds_filters_dangling(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.conn.execute("DELETE FROM world WHERE id=1")
self.conn.commit()
self.assertEqual(run(api.list_worlds_by_tenant(T1)), [],
'悬挂引用必须从联合结果中过滤')
def test_list_scenes_by_world_ok(self):
run(api.bind_ref('world', 1, tenant_id=T1))
run(api.bind_ref('scene', 11, tenant_id=T1))
run(api.bind_ref('scene', 12, tenant_id=T1))
scenes = run(api.list_scenes_by_world(1, T1))
self.assertEqual(sorted(s['id'] for s in scenes), [11, 12])
self.assertEqual(scenes[0]['world_id'], 1)
def test_list_scenes_only_bound_visible(self):
run(api.bind_ref('world', 1, tenant_id=T1))
run(api.bind_ref('scene', 11, tenant_id=T1)) # 12 未绑定
scenes = run(api.list_scenes_by_world(1, T1))
self.assertEqual([s['id'] for s in scenes], [11])
def test_list_scenes_cross_tenant_forbidden(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.assertPblError(E_FORBIDDEN, api.list_scenes_by_world(1, T2))
def test_list_entities_by_scene_ok(self):
run(api.bind_ref('scene', 11, tenant_id=T1))
run(api.bind_ref('entity', 101, tenant_id=T1))
run(api.bind_ref('entity', 102, tenant_id=T1))
ents = run(api.list_entities_by_scene(11, T1))
self.assertEqual(sorted(e['id'] for e in ents), [101, 102])
self.assertEqual(ents[0]['kind'], 'vehicle')
def test_list_entities_cross_tenant_forbidden(self):
run(api.bind_ref('scene', 11, tenant_id=T1))
self.assertPblError(E_FORBIDDEN, api.list_entities_by_scene(11, T2))
def test_get_world_with_pbl_context_ok(self):
run(api.bind_ref('world', 1, blueprint_id='BP-1', class_id='CLS-1',
team_id='TEAM_A', ext_json={'mode': 'coop'}, tenant_id=T1))
ctx = run(api.get_world_with_pbl_context(1, T1))
self.assertEqual(ctx['name'], '世界A-火星基地')
self.assertEqual(ctx['pbl_context']['blueprint_id'], 'BP-1')
self.assertEqual(ctx['pbl_context']['class_id'], 'CLS-1')
self.assertEqual(ctx['pbl_context']['team_id'], 'TEAM_A')
self.assertEqual(ctx['pbl_context']['ext_json'], {'mode': 'coop'})
self.assertEqual(ctx['pbl_context']['tenant_id'], T1)
def test_get_world_context_cross_tenant_forbidden(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.assertPblError(E_FORBIDDEN, api.get_world_with_pbl_context(1, T2))
def test_get_world_context_base_missing_not_found(self):
run(api.bind_ref('world', 1, tenant_id=T1))
self.conn.execute("DELETE FROM world WHERE id=1")
self.conn.commit()
self.assertPblError(E_NOT_FOUND, api.get_world_with_pbl_context(1, T1))
class TestCheckRefAccess(BaseCase):
"""接口 10check_ref_access租户+班级+团队三重匹配)"""
def test_access_granted(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
self.assertTrue(run(api.check_ref_access('world', 1, T1)))
self.assertTrue(run(api.check_ref_access('world', 1, T1,
class_id='CLS-1', team_id='TEAM_A')))
def test_access_denied_cross_tenant(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
self.assertFalse(run(api.check_ref_access('world', 1, T2)))
def test_access_denied_wrong_class_or_team(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
self.assertFalse(run(api.check_ref_access('world', 1, T1, class_id='CLS-X')))
self.assertFalse(run(api.check_ref_access('world', 1, T1, team_id='TEAM-X')))
def test_access_denied_unbound(self):
self.assertFalse(run(api.check_ref_access('world', 2, T1)))
def test_access_denied_after_unbind(self):
run(api.bind_ref('scene', 11, tenant_id=T1))
run(api.unbind_ref('scene', 11, tenant_id=T1))
self.assertFalse(run(api.check_ref_access('scene', 11, T1)))
def test_access_denied_invalid_input_no_raise(self):
self.assertFalse(run(api.check_ref_access('bogus', 1, T1)))
self.assertFalse(run(api.check_ref_access('world', 'x', T1)))
self.assertFalse(run(api.check_ref_access('world', 1, None)))
def test_access_denied_dangling_base(self):
run(api.bind_ref('entity', 101, tenant_id=T1))
self.conn.execute("DELETE FROM entity WHERE id=101")
self.conn.commit()
self.assertFalse(run(api.check_ref_access('entity', 101, T1)))
class TestTeamClassDimension(BaseCase):
"""接口 11~13团队/班级维度US-13 共享世界支撑)"""
def test_list_teams_by_class_groups(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
run(api.bind_ref('scene', 11, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
run(api.bind_ref('world', 2, class_id='CLS-1', team_id='TEAM_B', tenant_id=T1))
teams = run(api.list_teams_by_class('CLS-1', T1))
self.assertEqual(len(teams), 2)
by_id = {t['team_id']: t for t in teams}
self.assertEqual(sorted(by_id['TEAM_A']['world_ids']), [1])
self.assertEqual(sorted(by_id['TEAM_A']['scene_ids']), [11])
self.assertEqual(by_id['TEAM_A']['entity_ids'], [])
self.assertEqual(len(by_id['TEAM_A']['ref_ids']), 2)
self.assertEqual(sorted(by_id['TEAM_B']['world_ids']), [2])
def test_list_teams_by_class_members_from_governance(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
teams = run(api.list_teams_by_class('CLS-1', T1))
members = teams[0]['members']
self.assertEqual(len(members), 2, 'members 应只读取自 pbl_governance.pbl_team_member')
self.assertEqual({m['user_id'] for m in members}, {'stu01', 'stu02'})
def test_list_teams_by_class_tenant_scoped(self):
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
self.assertEqual(run(api.list_teams_by_class('CLS-1', T2)), [])
def test_list_teams_by_class_requires_class_id(self):
self.assertPblError(E_VALIDATION, api.list_teams_by_class(None, T1))
def test_list_teams_without_governance_table_degrades(self):
"""治理模块未部署(无 pbl_team_member 表)时降级为空成员列表,不报错。"""
fake_db.teardown()
self.conn.close()
self.conn, self.adapter = fake_db.setup(with_team_member=False)
run(api.bind_ref('world', 1, class_id='CLS-1', team_id='TEAM_A', tenant_id=T1))
teams = run(api.list_teams_by_class('CLS-1', T1))
self.assertEqual(len(teams), 1)
self.assertEqual(teams[0]['members'], [])
def test_bind_team_to_world_creates_ref(self):
ref = run(api.bind_team_to_world(1, 'TEAM_A', T1, class_id='CLS-1',
blueprint_id='BP-1'))
self.assertEqual(ref['ref_type'], 'world')
self.assertEqual(ref['ref_id'], 1)
self.assertEqual(ref['team_id'], 'TEAM_A')
self.assertEqual(ref['class_id'], 'CLS-1')
def test_bind_team_to_world_updates_existing(self):
run(api.bind_ref('world', 1, team_id='TEAM_A', tenant_id=T1))
ref = run(api.bind_team_to_world(1, 'TEAM_B', T1))
self.assertEqual(ref['team_id'], 'TEAM_B', '已有关联应幂等更新 team_id')
cnt = fake_db.scalar(
self.conn,
"SELECT COUNT(*) FROM pbl_domain_ref WHERE ref_type='world' AND ref_id=1")
self.assertEqual(cnt, 1, '不得产生重复关联行')
def test_bind_team_to_world_duplicate_same_team(self):
run(api.bind_team_to_world(1, 'TEAM_A', T1))
self.assertPblError(E_DUPLICATE, api.bind_team_to_world(1, 'TEAM_A', T1))
def test_bind_team_to_world_base_missing(self):
self.assertPblError(E_NOT_FOUND, api.bind_team_to_world(999, 'TEAM_A', T1))
def test_bind_team_to_world_requires_team_id(self):
self.assertPblError(E_VALIDATION, api.bind_team_to_world(1, None, T1))
def test_get_team_worlds(self):
run(api.bind_team_to_world(1, 'TEAM_A', T1))
run(api.bind_team_to_world(2, 'TEAM_B', T1))
worlds = run(api.get_team_worlds('TEAM_A', T1))
self.assertEqual([w['id'] for w in worlds], [1])
self.assertEqual(worlds[0]['team_id'], 'TEAM_A')
self.assertEqual(run(api.get_team_worlds('TEAM_A', T2)), [],
'跨租户不得返回他租户团队世界')
def test_get_team_worlds_requires_team_id(self):
self.assertPblError(E_VALIDATION, api.get_team_worlds('', T1))
class TestThinExtensionInvariants(BaseCase):
"""薄扩展铁律:不改基表 + 表总账 + 契约齐备"""
def test_base_tables_untouched(self):
before = {t: fake_db.scalar(self.conn, "SELECT COUNT(*) FROM %s" % t)
for t in ('world', 'scene', 'entity')}
run(api.bind_ref('world', 1, class_id='C', team_id='T', tenant_id=T1))
run(api.bind_ref('scene', 11, tenant_id=T1))
run(api.bind_ref('entity', 101, tenant_id=T1))
run(api.update_ref('world', 1, {'class_id': 'C2'}, tenant_id=T1))
run(api.bind_team_to_world(2, 'TEAM_B', T1))
# 读路径(联合查询)——必须在解绑前跑,解绑后 scene 11 对本租户即 403
run(api.list_worlds_by_tenant(T1))
run(api.list_scenes_by_world(1, T1))
run(api.list_entities_by_scene(11, T1))
run(api.get_world_with_pbl_context(1, T1))
run(api.check_ref_access('world', 1, T1))
run(api.list_teams_by_class('C', T1))
run(api.get_team_worlds('T', T1))
run(api.list_refs({}, 1, 20, tenant_id=T1))
# 写路径收尾:解绑(软删,只动自有表)
run(api.unbind_ref('scene', 11, tenant_id=T1))
run(api.unbind_ref('entity', 101, tenant_id=T1))
after = {t: fake_db.scalar(self.conn, "SELECT COUNT(*) FROM %s" % t)
for t in ('world', 'scene', 'entity')}
self.assertEqual(before, after, '基表行数不得变化(零写入)')
# 基表结构零 ALTER列集合不变
for table in ('world', 'scene', 'entity'):
cols = [r[1] for r in fake_db.raw_sql(
self.conn, "PRAGMA table_info(%s)" % table)]
self.assertNotIn('tenant_id', cols, '禁止给基表加 tenant_id 列')
def test_own_tables_single_and_matches_models(self):
self.assertEqual(init_mod.OWN_TABLES, ['pbl_domain_ref'])
models_dir = os.path.join(ROOT, 'models')
files = sorted(f for f in os.listdir(models_dir) if f.endswith('.json'))
self.assertEqual(files, ['pbl_domain_ref.json'],
'models/ 必须与 OWN_TABLES 一一对应QC #4')
def test_model_json_four_sections(self):
path = os.path.join(ROOT, 'models', 'pbl_domain_ref.json')
model = json.load(open(path, encoding='utf-8'))
for key in ('summary', 'fields', 'indexes', 'codes'):
self.assertIn(key, model, '表定义四段式缺 %s' % key)
self.assertIsInstance(model['summary'], list, 'summary 必须是数组QC #3')
self.assertTrue(all(isinstance(s, str) for s in model['summary']))
self.assertEqual(model['summary'][0], 'pbl_domain_ref')
primaries = [f for f in model['fields'] if f.get('primary')]
self.assertEqual(len(primaries), 1)
self.assertEqual(primaries[0]['name'], 'id')
uniq = [i for i in model['indexes'] if i.get('unique') and not i.get('primary')]
self.assertIn(['tenant_id', 'ref_type', 'ref_id'],
[i['fields'] for i in uniq],
'必须有 UNIQUE(tenant_id,ref_type,ref_id)(设计 §2')
def test_contract_13_interfaces_all_callable(self):
self.assertEqual(len(api.CONTRACT_INTERFACES), 13)
for name in api.CONTRACT_INTERFACES:
self.assertTrue(callable(getattr(api, name, None)), '缺实现:%s' % name)
def test_contract_map_covers_dspy_files(self):
cmap = init_mod.get_contract_map()
self.assertEqual(len(cmap), 13)
api_dir = os.path.join(ROOT, 'wwwroot', 'api')
existing = set(os.listdir(api_dir))
for name, (impl, dspy) in cmap.items():
self.assertTrue(callable(getattr(api, name, None)), '缺实现:%s' % name)
self.assertIn(os.path.basename(dspy), existing,
'契约 %s 缺 dspy 端点 %sQC #5' % (name, dspy))
self.assertTrue(impl.startswith('pbl_domain_ext/api.py:'))
def test_load_path_registers_all_dspy(self):
sys.path.insert(0, os.path.join(ROOT, 'scripts'))
import load_path as lp
self.assertEqual(lp.selfcheck(), [], 'load_path 自检必须无问题QC #6')
registered = set(lp.API_PATHS) | set(lp.UI_PATHS)
api_dir = os.path.join(ROOT, 'wwwroot', 'api')
for fname in os.listdir(api_dir):
if fname.endswith('.dspy'):
self.assertIn('/pbl_domain_ext/api/%s' % fname, registered,
'端点 %s 未在 load_path.py 注册 → 上线 403' % fname)
self.assertIn('/pbl_domain_ext/index.ui', registered)
self.assertEqual(len(lp.API_PATHS), 13)
for path in lp.ALL_PATHS:
self.assertNotIn('*', path, '禁止通配符注册')
def test_load_module_registers_env_functions(self):
env = init_mod.load_pbl_domain_ext()
for name in api.CONTRACT_INTERFACES:
self.assertTrue(callable(getattr(env, 'pbl_%s' % name, None)),
'env.pbl_%s 未注册' % name)
self.assertTrue(callable(getattr(env, name, None)),
'env.%s(设计原名)未注册' % name)
info = env.pbl_domain_ext_module_info
self.assertEqual(info['own_tables'], ['pbl_domain_ref'])
self.assertFalse(info['base_table_altered'])
self.assertEqual(len(info['contracts']), 13)
def test_package_exports_match_init(self):
import pbl_domain_ext as pkg
for name in api.CONTRACT_INTERFACES:
self.assertTrue(callable(getattr(pkg, name, None)),
'__init__.py 未导出 %s(三处同步注册之 ②)' % name)
self.assertTrue(callable(pkg.load_pbl_domain_ext))
def test_base_layer_readonly_projection(self):
self.assertTrue(run(base_mod.base_exists('world', 1)))
self.assertFalse(run(base_mod.base_exists('world', 999)))
rows = run(base_mod.fetch_children('scene', 1))
self.assertEqual(sorted(r['id'] for r in rows), [11, 12])
col = run(base_mod.resolve_column('scene', base_mod.SCENE_PARENT_CANDIDATES))
self.assertEqual(col, 'world_id')
if __name__ == '__main__':
unittest.main(verbosity=2)