修复业务菜单图标并固化菌种编码模糊查询
This commit is contained in:
@@ -0,0 +1,137 @@
|
|||||||
|
-- 本文件是人工执行的 MySQL 前向补丁,未自动执行。
|
||||||
|
-- 前置条件:必须先成功执行 20260727_promote_business_menus_mysql.sql,将三个业务目录提升为一级菜单。
|
||||||
|
-- 应用本补丁后禁止逆序重跑旧补丁;所有补丁只允许按日期顺序前向执行。
|
||||||
|
-- 执行前备份数据库,并在维护窗口使用独立连接运行标准 mysql batch。
|
||||||
|
-- 旧图标和目标图标状态均可通过写前校验,因此本补丁可幂等重跑。
|
||||||
|
-- 标准 mysql batch 遇错后必须退出并关闭连接,使未提交事务回滚;禁止使用 mysql --force。
|
||||||
|
|
||||||
|
drop temporary table if exists business_menu_icon_expected;
|
||||||
|
create temporary table business_menu_icon_expected (
|
||||||
|
menu_id bigint not null,
|
||||||
|
menu_name varchar(50) not null,
|
||||||
|
path varchar(200),
|
||||||
|
component varchar(255),
|
||||||
|
query varchar(255),
|
||||||
|
route_name varchar(50),
|
||||||
|
is_frame int,
|
||||||
|
is_cache int,
|
||||||
|
menu_type char(1),
|
||||||
|
visible char(1),
|
||||||
|
status char(1),
|
||||||
|
perms varchar(100),
|
||||||
|
parent_id bigint not null,
|
||||||
|
order_num int not null,
|
||||||
|
old_icon varchar(100) not null,
|
||||||
|
new_icon varchar(100) not null,
|
||||||
|
primary key (menu_id)
|
||||||
|
) engine=innodb;
|
||||||
|
|
||||||
|
insert into business_menu_icon_expected (
|
||||||
|
menu_id, menu_name, path, component, query, route_name,
|
||||||
|
is_frame, is_cache, menu_type, visible, status, perms,
|
||||||
|
parent_id, order_num, old_icon, new_icon
|
||||||
|
) values
|
||||||
|
(121, '基础资料维护', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', 0, 1, '#', 'dict'),
|
||||||
|
(122, '业务管理', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', 0, 2, '#', 'form'),
|
||||||
|
(123, '报表查询', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', 0, 3, '#', 'chart');
|
||||||
|
|
||||||
|
drop temporary table if exists business_menu_icon_guard;
|
||||||
|
create temporary table business_menu_icon_guard (
|
||||||
|
guard_token varchar(64) not null,
|
||||||
|
primary key (guard_token)
|
||||||
|
) engine=innodb;
|
||||||
|
|
||||||
|
start transaction;
|
||||||
|
|
||||||
|
select menu_id
|
||||||
|
from sys_menu
|
||||||
|
where menu_id in (121, 122, 123)
|
||||||
|
order by menu_id
|
||||||
|
for update;
|
||||||
|
|
||||||
|
-- 写前只接受三行全部使用旧占位图标或全部使用目标图标。
|
||||||
|
insert into business_menu_icon_guard (guard_token)
|
||||||
|
select guard_token
|
||||||
|
from (
|
||||||
|
select 'business-menu-icon-precondition-conflict' as guard_token
|
||||||
|
union all
|
||||||
|
select if(
|
||||||
|
count(actual.menu_id) = 3
|
||||||
|
and sum(
|
||||||
|
(binary actual.menu_name <=> binary expected.menu_name)
|
||||||
|
and (binary actual.path <=> binary expected.path)
|
||||||
|
and (binary actual.component <=> binary expected.component)
|
||||||
|
and (binary actual.query <=> binary expected.query)
|
||||||
|
and (binary actual.route_name <=> binary expected.route_name)
|
||||||
|
and (actual.is_frame <=> expected.is_frame)
|
||||||
|
and (actual.is_cache <=> expected.is_cache)
|
||||||
|
and (binary actual.menu_type <=> binary expected.menu_type)
|
||||||
|
and (binary actual.visible <=> binary expected.visible)
|
||||||
|
and (binary actual.status <=> binary expected.status)
|
||||||
|
and (binary actual.perms <=> binary expected.perms)
|
||||||
|
and (actual.parent_id <=> expected.parent_id)
|
||||||
|
and (actual.order_num <=> expected.order_num)
|
||||||
|
) = 3
|
||||||
|
and (
|
||||||
|
sum((binary actual.icon <=> binary expected.old_icon)) = 3
|
||||||
|
or sum((binary actual.icon <=> binary expected.new_icon)) = 3
|
||||||
|
),
|
||||||
|
'business-menu-icon-precondition-verified',
|
||||||
|
'business-menu-icon-precondition-conflict'
|
||||||
|
) as guard_token
|
||||||
|
from business_menu_icon_expected as expected
|
||||||
|
left join sys_menu as actual
|
||||||
|
on actual.menu_id = expected.menu_id
|
||||||
|
) as precondition_guard_rows;
|
||||||
|
|
||||||
|
-- 唯一永久 DML:只更新三个业务一级目录的图标。
|
||||||
|
update sys_menu
|
||||||
|
set icon = case menu_id
|
||||||
|
when 121 then 'dict'
|
||||||
|
when 122 then 'form'
|
||||||
|
when 123 then 'chart'
|
||||||
|
else icon
|
||||||
|
end
|
||||||
|
where menu_id in (121, 122, 123);
|
||||||
|
|
||||||
|
-- 提交前再次核对三行稳定身份和目标图标。
|
||||||
|
insert into business_menu_icon_guard (guard_token)
|
||||||
|
select guard_token
|
||||||
|
from (
|
||||||
|
select 'business-menu-icon-postcondition-conflict' as guard_token
|
||||||
|
union all
|
||||||
|
select if(
|
||||||
|
count(actual.menu_id) = 3
|
||||||
|
and sum(
|
||||||
|
(binary actual.menu_name <=> binary expected.menu_name)
|
||||||
|
and (binary actual.path <=> binary expected.path)
|
||||||
|
and (binary actual.component <=> binary expected.component)
|
||||||
|
and (binary actual.query <=> binary expected.query)
|
||||||
|
and (binary actual.route_name <=> binary expected.route_name)
|
||||||
|
and (actual.is_frame <=> expected.is_frame)
|
||||||
|
and (actual.is_cache <=> expected.is_cache)
|
||||||
|
and (binary actual.menu_type <=> binary expected.menu_type)
|
||||||
|
and (binary actual.visible <=> binary expected.visible)
|
||||||
|
and (binary actual.status <=> binary expected.status)
|
||||||
|
and (binary actual.perms <=> binary expected.perms)
|
||||||
|
and (actual.parent_id <=> expected.parent_id)
|
||||||
|
and (actual.order_num <=> expected.order_num)
|
||||||
|
) = 3
|
||||||
|
and sum((binary actual.icon <=> binary expected.new_icon)) = 3,
|
||||||
|
'business-menu-icon-postcondition-verified',
|
||||||
|
'business-menu-icon-postcondition-conflict'
|
||||||
|
) as guard_token
|
||||||
|
from business_menu_icon_expected as expected
|
||||||
|
left join sys_menu as actual
|
||||||
|
on actual.menu_id = expected.menu_id
|
||||||
|
) as postcondition_guard_rows;
|
||||||
|
|
||||||
|
commit;
|
||||||
|
|
||||||
|
drop temporary table business_menu_icon_guard;
|
||||||
|
drop temporary table business_menu_icon_expected;
|
||||||
|
|
||||||
|
select menu_id, menu_name, parent_id, order_num, path, icon
|
||||||
|
from sys_menu
|
||||||
|
where menu_id in (121, 122, 123)
|
||||||
|
order by order_num, menu_id;
|
||||||
@@ -1449,9 +1449,9 @@ create table biz_pallet_inventory (
|
|||||||
-- 初始化-托盘业务一级菜单和权限
|
-- 初始化-托盘业务一级菜单和权限
|
||||||
-- ----------------------------
|
-- ----------------------------
|
||||||
insert into sys_menu values
|
insert into sys_menu values
|
||||||
(121, '基础资料维护', 0, '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
|
(121, '基础资料维护', 0, '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', 'dict', 'admin', current_timestamp, '', null, ''),
|
||||||
(122, '业务管理', 0, '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
|
(122, '业务管理', 0, '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', 'form', 'admin', current_timestamp, '', null, ''),
|
||||||
(123, '报表查询', 0, '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''),
|
(123, '报表查询', 0, '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', 'chart', 'admin', current_timestamp, '', null, ''),
|
||||||
(502, '菌种资料', 121, '1', 'strain', 'master/strain/index', '', '', 1, 0, 'C', '0', '0', 'master:strain:list', '#', 'admin', current_timestamp, '', null, ''),
|
(502, '菌种资料', 121, '1', 'strain', 'master/strain/index', '', '', 1, 0, 'C', '0', '0', 'master:strain:list', '#', 'admin', current_timestamp, '', null, ''),
|
||||||
(503, '客户资料', 121, '2', 'customer', 'master/customer/index', '', '', 1, 0, 'C', '0', '0', 'master:customer:list', '#', 'admin', current_timestamp, '', null, ''),
|
(503, '客户资料', 121, '2', 'customer', 'master/customer/index', '', '', 1, 0, 'C', '0', '0', 'master:customer:list', '#', 'admin', current_timestamp, '', null, ''),
|
||||||
(504, '客户现场', 121, '3', 'site', 'master/site/index', '', '', 1, 0, 'C', '0', '0', 'master:site:list', '#', 'admin', current_timestamp, '', null, ''),
|
(504, '客户现场', 121, '3', 'site', 'master/site/index', '', '', 1, 0, 'C', '0', '0', 'master:site:list', '#', 'admin', current_timestamp, '', null, ''),
|
||||||
|
|||||||
@@ -283,9 +283,9 @@ insert into sys_menu values('1059', '预览代码', '116', '5', '#', '', '', '',
|
|||||||
insert into sys_menu values('1060', '生成代码', '116', '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:code', '#', 'admin', sysdate(), '', null, '');
|
insert into sys_menu values('1060', '生成代码', '116', '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'tool:gen:code', '#', 'admin', sysdate(), '', null, '');
|
||||||
|
|
||||||
-- 托盘业务一级目录
|
-- 托盘业务一级目录
|
||||||
insert into sys_menu values('121', '基础资料维护', '0', '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '基础资料维护目录');
|
insert into sys_menu values('121', '基础资料维护', '0', '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', 'dict', 'admin', sysdate(), '', null, '基础资料维护目录');
|
||||||
insert into sys_menu values('122', '业务管理', '0', '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '业务管理目录');
|
insert into sys_menu values('122', '业务管理', '0', '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', 'form', 'admin', sysdate(), '', null, '业务管理目录');
|
||||||
insert into sys_menu values('123', '报表查询', '0', '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', sysdate(), '', null, '报表查询目录');
|
insert into sys_menu values('123', '报表查询', '0', '3', 'businessReport', null, '', '', 1, 0, 'M', '0', '0', '', 'chart', 'admin', sysdate(), '', null, '报表查询目录');
|
||||||
|
|
||||||
-- 托盘业务页面
|
-- 托盘业务页面
|
||||||
insert into sys_menu values('502', '菌种资料', '121', '1', 'strain', 'master/strain/index', '', '', 1, 0, 'C', '0', '0', 'master:strain:list', '#', 'admin', sysdate(), '', null, '菌种资料菜单');
|
insert into sys_menu values('502', '菌种资料', '121', '1', 'strain', 'master/strain/index', '', '', 1, 0, 'C', '0', '0', 'master:strain:list', '#', 'admin', sysdate(), '', null, '菌种资料菜单');
|
||||||
|
|||||||
@@ -570,6 +570,23 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn(f'ORDER BY {statement.get_final_froms()[0].name}.{expected_tokens[-1]}', sql)
|
self.assertIn(f'ORDER BY {statement.get_final_froms()[0].name}.{expected_tokens[-1]}', sql)
|
||||||
self.assertEqual((2, 5, True), paginate_mock.await_args.args[2:])
|
self.assertEqual((2, 5, True), paginate_mock.await_args.args[2:])
|
||||||
|
|
||||||
|
async def test_strain_code_search_keeps_partial_matching_for_leading_zero_codes(self) -> None:
|
||||||
|
page = PageModel(rows=[], pageNum=1, pageSize=10, total=0, hasNext=False)
|
||||||
|
for strain_code in ('01', '1'):
|
||||||
|
with (
|
||||||
|
self.subTest(strain_code=strain_code),
|
||||||
|
patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=page)) as paginate_mock,
|
||||||
|
):
|
||||||
|
result = await MasterDao.list_strain(
|
||||||
|
self.db,
|
||||||
|
StrainPageQueryModel(strainCode=strain_code, pageNum=1, pageSize=10),
|
||||||
|
is_page=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
statement = paginate_mock.await_args.args[1]
|
||||||
|
self.assertIs(page, result)
|
||||||
|
self.assertIn(f"biz_strain.strain_code LIKE '%{strain_code}%'", compile_sql(statement))
|
||||||
|
|
||||||
async def test_get_queries_use_expected_table_and_primary_key(self) -> None:
|
async def test_get_queries_use_expected_table_and_primary_key(self) -> None:
|
||||||
cases = (
|
cases = (
|
||||||
('strain', 'biz_strain', 'strain_id'),
|
('strain', 'biz_strain', 'strain_id'),
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql'
|
|||||||
INITIALIZERS = ('shierfeng-fastapi.sql', 'shierfeng-fastapi-pg.sql')
|
INITIALIZERS = ('shierfeng-fastapi.sql', 'shierfeng-fastapi-pg.sql')
|
||||||
PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
|
PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_mysql.sql'
|
||||||
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
|
MENU_PROMOTION_PATCH_PATH = SQL_ROOT / 'patches/20260727_promote_business_menus_mysql.sql'
|
||||||
|
BUSINESS_MENU_ICON_PATCH_PATH = SQL_ROOT / 'patches/20260728_add_business_menu_icons_mysql.sql'
|
||||||
SYS_MENU_VALUE_COUNT = 20
|
SYS_MENU_VALUE_COUNT = 20
|
||||||
SYS_MENU_PERMS_INDEX = 13
|
SYS_MENU_PERMS_INDEX = 13
|
||||||
|
SYS_MENU_ICON_INDEX = 14
|
||||||
REMOVED_AI_DICT_CODES = set(range(33, 70))
|
REMOVED_AI_DICT_CODES = set(range(33, 70))
|
||||||
|
|
||||||
EXPECTED_TABLES = {
|
EXPECTED_TABLES = {
|
||||||
@@ -591,10 +593,15 @@ EXPECTED_TOP_LEVEL_MENU_POSITIONS = {
|
|||||||
2: ('系统监控', 0, 5),
|
2: ('系统监控', 0, 5),
|
||||||
3: ('系统工具', 0, 6),
|
3: ('系统工具', 0, 6),
|
||||||
}
|
}
|
||||||
|
EXPECTED_BUSINESS_ROOT_ICONS = {
|
||||||
|
121: 'dict',
|
||||||
|
122: 'form',
|
||||||
|
123: 'chart',
|
||||||
|
}
|
||||||
EXPECTED_TOP_LEVEL_MENU_STABLE_FIELDS = {
|
EXPECTED_TOP_LEVEL_MENU_STABLE_FIELDS = {
|
||||||
121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', '#'),
|
121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', 'dict'),
|
||||||
122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '#'),
|
122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', 'form'),
|
||||||
123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '#'),
|
123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', 'chart'),
|
||||||
1: ('系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system'),
|
1: ('系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system'),
|
||||||
2: ('系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor'),
|
2: ('系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor'),
|
||||||
3: ('系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool'),
|
3: ('系统工具', 'tool', None, '', '', '1', '0', 'M', '0', '0', '', 'tool'),
|
||||||
@@ -1044,6 +1051,82 @@ def _assert_menu_promotion_transaction_body(test_case: unittest.TestCase, sql: s
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_business_menu_icon_guard_primary_key(test_case: unittest.TestCase, sql: str) -> None:
|
||||||
|
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)]
|
||||||
|
guard_creates = [
|
||||||
|
statement
|
||||||
|
for statement in normalized
|
||||||
|
if statement.startswith('create temporary table business_menu_icon_guard ')
|
||||||
|
]
|
||||||
|
test_case.assertEqual(1, len(guard_creates))
|
||||||
|
test_case.assertIn('primary key (guard_token)', guard_creates[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_business_menu_icon_guard_structure(test_case: unittest.TestCase, sql: str) -> tuple[str, str]:
|
||||||
|
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(sql)]
|
||||||
|
guards = [
|
||||||
|
statement for statement in normalized if statement.startswith('insert into business_menu_icon_guard ')
|
||||||
|
]
|
||||||
|
test_case.assertEqual(2, len(guards))
|
||||||
|
pre_guard, post_guard = guards
|
||||||
|
identity_fields = (
|
||||||
|
('menu_name', True),
|
||||||
|
('path', True),
|
||||||
|
('component', True),
|
||||||
|
('query', True),
|
||||||
|
('route_name', True),
|
||||||
|
('is_frame', False),
|
||||||
|
('is_cache', False),
|
||||||
|
('menu_type', True),
|
||||||
|
('visible', True),
|
||||||
|
('status', True),
|
||||||
|
('perms', True),
|
||||||
|
('parent_id', False),
|
||||||
|
('order_num', False),
|
||||||
|
)
|
||||||
|
identity_comparisons = [
|
||||||
|
(
|
||||||
|
rf'\(binary\s+actual\.{field}\s*<=>\s*binary\s+expected\.{field}\)'
|
||||||
|
if binary
|
||||||
|
else rf'\(actual\.{field}\s*<=>\s*expected\.{field}\)'
|
||||||
|
)
|
||||||
|
for field, binary in identity_fields
|
||||||
|
]
|
||||||
|
identity_aggregate = r'sum\s*\(\s*' + r'\s+and\s+'.join(identity_comparisons) + r'\s*\)\s*=\s*3'
|
||||||
|
for guard, phase in ((pre_guard, 'precondition'), (post_guard, 'postcondition')):
|
||||||
|
conflict_token = f"'business-menu-icon-{phase}-conflict'"
|
||||||
|
test_case.assertEqual(2, guard.count(conflict_token))
|
||||||
|
test_case.assertIn(f"'business-menu-icon-{phase}-verified'", guard)
|
||||||
|
duplicate_key_structure = (
|
||||||
|
r'^insert into business_menu_icon_guard \(guard_token\) '
|
||||||
|
r'select guard_token from \( select '
|
||||||
|
rf'{re.escape(conflict_token)} as guard_token union all select if\('
|
||||||
|
)
|
||||||
|
test_case.assertRegex(guard, duplicate_key_structure)
|
||||||
|
test_case.assertEqual(1, len(re.findall(r'\bbusiness_menu_icon_expected\b', guard)))
|
||||||
|
test_case.assertIn('count(actual.menu_id) = 3', guard)
|
||||||
|
test_case.assertIn(
|
||||||
|
'left join sys_menu as actual on actual.menu_id = expected.menu_id',
|
||||||
|
guard,
|
||||||
|
)
|
||||||
|
test_case.assertRegex(guard, identity_aggregate)
|
||||||
|
for field in (
|
||||||
|
'menu_name',
|
||||||
|
'path',
|
||||||
|
'component',
|
||||||
|
'query',
|
||||||
|
'route_name',
|
||||||
|
'menu_type',
|
||||||
|
'visible',
|
||||||
|
'status',
|
||||||
|
'perms',
|
||||||
|
):
|
||||||
|
test_case.assertIn(f'binary actual.{field} <=> binary expected.{field}', guard)
|
||||||
|
for field in ('is_frame', 'is_cache', 'parent_id', 'order_num'):
|
||||||
|
test_case.assertIn(f'actual.{field} <=> expected.{field}', guard)
|
||||||
|
return pre_guard, post_guard
|
||||||
|
|
||||||
|
|
||||||
class SqlContractTest(unittest.TestCase):
|
class SqlContractTest(unittest.TestCase):
|
||||||
def test_initializers_define_exact_business_table_set(self) -> None:
|
def test_initializers_define_exact_business_table_set(self) -> None:
|
||||||
for filename in INITIALIZERS:
|
for filename in INITIALIZERS:
|
||||||
@@ -1142,10 +1225,121 @@ class SqlContractTest(unittest.TestCase):
|
|||||||
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
|
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
|
||||||
_assert_top_level_menu_fields(self, sql)
|
_assert_top_level_menu_fields(self, sql)
|
||||||
|
|
||||||
|
def test_initializers_define_renderable_business_root_icons(self) -> None:
|
||||||
|
icon_root = SQL_ROOT.parent.parent / 'shierfeng-fastapi-frontend' / 'src' / 'assets' / 'icons' / 'svg'
|
||||||
|
for icon in EXPECTED_BUSINESS_ROOT_ICONS.values():
|
||||||
|
with self.subTest(icon=icon):
|
||||||
|
self.assertTrue((icon_root / f'{icon}.svg').is_file())
|
||||||
|
|
||||||
|
for filename in INITIALIZERS:
|
||||||
|
with self.subTest(filename=filename):
|
||||||
|
rows = _menu_rows((SQL_ROOT / filename).read_text(encoding='utf-8'), set(EXPECTED_BUSINESS_ROOT_ICONS))
|
||||||
|
actual = {menu_id: row[SYS_MENU_ICON_INDEX] for menu_id, row in rows.items()}
|
||||||
|
self.assertEqual(EXPECTED_BUSINESS_ROOT_ICONS, actual)
|
||||||
|
|
||||||
|
def test_business_menu_icon_patch_is_fail_closed_atomic_and_exact(self) -> None:
|
||||||
|
self.assertTrue(BUSINESS_MENU_ICON_PATCH_PATH.is_file(), f'missing patch: {BUSINESS_MENU_ICON_PATCH_PATH}')
|
||||||
|
raw = BUSINESS_MENU_ICON_PATCH_PATH.read_text(encoding='utf-8')
|
||||||
|
executable = _strip_sql_comments(raw).replace('`', '')
|
||||||
|
normalized = [_normalized_sql_statement(statement) for statement in _sql_statements(raw)]
|
||||||
|
_assert_business_menu_icon_guard_primary_key(self, raw)
|
||||||
|
pre_guard, post_guard = _assert_business_menu_icon_guard_structure(self, raw)
|
||||||
|
|
||||||
|
for required_note in (
|
||||||
|
'未自动执行',
|
||||||
|
'执行前备份',
|
||||||
|
'可幂等重跑',
|
||||||
|
'禁止使用 mysql --force',
|
||||||
|
'独立连接',
|
||||||
|
'20260727_promote_business_menus_mysql.sql',
|
||||||
|
'禁止逆序重跑旧补丁',
|
||||||
|
):
|
||||||
|
self.assertIn(required_note, raw)
|
||||||
|
self.assertNotIn('--force', executable.lower())
|
||||||
|
self.assertNotRegex(executable.lower(), r'\bsys_role_menu\b')
|
||||||
|
|
||||||
|
expected_rows = [
|
||||||
|
('121', '基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', '0', '1', '#', 'dict'),
|
||||||
|
('122', '业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '0', '2', '#', 'form'),
|
||||||
|
('123', '报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '0', '3', '#', 'chart'),
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
expected_rows,
|
||||||
|
[tuple(row) for row in _insert_rows(raw, 'business_menu_icon_expected')],
|
||||||
|
)
|
||||||
|
|
||||||
|
start_index = normalized.index('start transaction')
|
||||||
|
commit_index = normalized.index('commit')
|
||||||
|
self.assertEqual(1, normalized.count('start transaction'))
|
||||||
|
self.assertEqual(1, normalized.count('commit'))
|
||||||
|
transaction_body = normalized[start_index + 1 : commit_index]
|
||||||
|
lock_statement = (
|
||||||
|
'select menu_id from sys_menu where menu_id in (121, 122, 123) order by menu_id for update'
|
||||||
|
)
|
||||||
|
expected_update = (
|
||||||
|
"update sys_menu set icon = case menu_id when 121 then 'dict' when 122 then 'form' "
|
||||||
|
"when 123 then 'chart' else icon end where menu_id in (121, 122, 123)"
|
||||||
|
)
|
||||||
|
self.assertEqual(lock_statement, transaction_body[0])
|
||||||
|
self.assertEqual(expected_update, transaction_body[2])
|
||||||
|
self.assertEqual(4, len(transaction_body))
|
||||||
|
|
||||||
|
self.assertRegex(
|
||||||
|
pre_guard,
|
||||||
|
r'sum\(\(binary actual\.icon <=> binary expected\.old_icon\)\) = 3 or '
|
||||||
|
r'sum\(\(binary actual\.icon <=> binary expected\.new_icon\)\) = 3',
|
||||||
|
)
|
||||||
|
self.assertNotIn('old_icon', post_guard)
|
||||||
|
self.assertIn('sum((binary actual.icon <=> binary expected.new_icon)) = 3', post_guard)
|
||||||
|
|
||||||
|
permanent_dml = [
|
||||||
|
statement
|
||||||
|
for statement in normalized
|
||||||
|
if re.match(r'^(?:insert|update|delete|replace|truncate)\b', statement)
|
||||||
|
and not statement.startswith('insert into business_menu_icon_expected ')
|
||||||
|
and not statement.startswith('insert into business_menu_icon_guard ')
|
||||||
|
]
|
||||||
|
self.assertEqual([expected_update], permanent_dml)
|
||||||
|
self.assertEqual(
|
||||||
|
'select menu_id, menu_name, parent_id, order_num, path, icon from sys_menu '
|
||||||
|
'where menu_id in (121, 122, 123) order by order_num, menu_id',
|
||||||
|
normalized[-1],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_business_menu_icon_patch_rejects_guard_primary_key_removal(self) -> None:
|
||||||
|
raw = BUSINESS_MENU_ICON_PATCH_PATH.read_text(encoding='utf-8')
|
||||||
|
mutated, replacements = re.subn(
|
||||||
|
r',\s*primary\s+key\s*\(guard_token\)',
|
||||||
|
'',
|
||||||
|
raw,
|
||||||
|
count=1,
|
||||||
|
flags=re.I,
|
||||||
|
)
|
||||||
|
self.assertEqual(1, replacements)
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
_assert_business_menu_icon_guard_primary_key(self, mutated)
|
||||||
|
|
||||||
|
def test_business_menu_icon_guard_contract_rejects_structural_mutations(self) -> None:
|
||||||
|
raw = BUSINESS_MENU_ICON_PATCH_PATH.read_text(encoding='utf-8')
|
||||||
|
mutation_specs = (
|
||||||
|
('union_distinct', r'\bunion\s+all\b', 'union distinct'),
|
||||||
|
(
|
||||||
|
'identity_threshold',
|
||||||
|
r'(\(actual\.order_num\s*<=>\s*expected\.order_num\)\s*\)\s*)=\s*3',
|
||||||
|
r'\1>= 0',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for name, pattern, replacement in mutation_specs:
|
||||||
|
with self.subTest(mutation=name):
|
||||||
|
mutated, replacements = re.subn(pattern, replacement, raw, count=1, flags=re.I)
|
||||||
|
self.assertEqual(1, replacements)
|
||||||
|
with self.assertRaises(AssertionError):
|
||||||
|
_assert_business_menu_icon_guard_structure(self, mutated)
|
||||||
|
|
||||||
def test_top_level_menu_contract_rejects_same_stable_field_drift_in_both_initializers(self) -> None:
|
def test_top_level_menu_contract_rejects_same_stable_field_drift_in_both_initializers(self) -> None:
|
||||||
mutations = (
|
mutations = (
|
||||||
(1, '系统管理', 'system', 'driftSystemIcon'),
|
(1, '系统管理', 'system', 'driftSystemIcon'),
|
||||||
(121, '基础资料维护', '#', 'driftBusinessIcon'),
|
(121, '基础资料维护', 'dict', 'driftBusinessIcon'),
|
||||||
)
|
)
|
||||||
for filename in INITIALIZERS:
|
for filename in INITIALIZERS:
|
||||||
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
|
sql = (SQL_ROOT / filename).read_text(encoding='utf-8')
|
||||||
@@ -1580,8 +1774,17 @@ class SqlContractTest(unittest.TestCase):
|
|||||||
patch_rows = _business_menu_rows(raw)
|
patch_rows = _business_menu_rows(raw)
|
||||||
mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8'))
|
mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8'))
|
||||||
compared_indexes = (0, 1, *range(4, 15))
|
compared_indexes = (0, 1, *range(4, 15))
|
||||||
|
expected_legacy_rows = {
|
||||||
|
menu_id: tuple(
|
||||||
|
'#'
|
||||||
|
if index == SYS_MENU_ICON_INDEX and menu_id in EXPECTED_BUSINESS_ROOT_ICONS
|
||||||
|
else row[index]
|
||||||
|
for index in compared_indexes
|
||||||
|
)
|
||||||
|
for menu_id, row in mysql_rows.items()
|
||||||
|
}
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{menu_id: tuple(row[index] for index in compared_indexes) for menu_id, row in mysql_rows.items()},
|
expected_legacy_rows,
|
||||||
{menu_id: tuple(row[index] for index in compared_indexes) for menu_id, row in patch_rows.items()},
|
{menu_id: tuple(row[index] for index in compared_indexes) for menu_id, row in patch_rows.items()},
|
||||||
)
|
)
|
||||||
self.assertRegex(lower, r'\bon\s+duplicate\s+key\s+update\b')
|
self.assertRegex(lower, r'\bon\s+duplicate\s+key\s+update\b')
|
||||||
|
|||||||
Reference in New Issue
Block a user