From b4822fef12f88c47e2941289845c3bf15d7ff2fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Tue, 28 Jul 2026 08:43:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=9A=E5=8A=A1=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E5=9B=BE=E6=A0=87=E5=B9=B6=E5=9B=BA=E5=8C=96=E8=8F=8C?= =?UTF-8?q?=E7=A7=8D=E7=BC=96=E7=A0=81=E6=A8=A1=E7=B3=8A=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...20260728_add_business_menu_icons_mysql.sql | 137 +++++++++++ .../sql/shierfeng-fastapi-pg.sql | 6 +- .../sql/shierfeng-fastapi.sql | 6 +- .../module_business/test_master_service.py | 17 ++ .../module_business/test_sql_contract.py | 213 +++++++++++++++++- 5 files changed, 368 insertions(+), 11 deletions(-) create mode 100644 shierfeng-fastapi-backend/sql/patches/20260728_add_business_menu_icons_mysql.sql diff --git a/shierfeng-fastapi-backend/sql/patches/20260728_add_business_menu_icons_mysql.sql b/shierfeng-fastapi-backend/sql/patches/20260728_add_business_menu_icons_mysql.sql new file mode 100644 index 0000000..1dc5f5b --- /dev/null +++ b/shierfeng-fastapi-backend/sql/patches/20260728_add_business_menu_icons_mysql.sql @@ -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; diff --git a/shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql b/shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql index 7c9dd66..2c38e85 100644 --- a/shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql +++ b/shierfeng-fastapi-backend/sql/shierfeng-fastapi-pg.sql @@ -1449,9 +1449,9 @@ create table biz_pallet_inventory ( -- 初始化-托盘业务一级菜单和权限 -- ---------------------------- insert into sys_menu values -(121, '基础资料维护', 0, '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''), -(122, '业务管理', 0, '2', 'businessOperation', null, '', '', 1, 0, 'M', '0', '0', '', '#', 'admin', current_timestamp, '', null, ''), -(123, '报表查询', 0, '3', 'businessReport', 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', '', 'form', '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, ''), (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, ''), diff --git a/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql b/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql index 3ca0995..4a29a89 100644 --- a/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql +++ b/shierfeng-fastapi-backend/sql/shierfeng-fastapi.sql @@ -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('121', '基础资料维护', '0', '1', 'businessMaster', null, '', '', 1, 0, 'M', '0', '0', '', '#', '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('123', '报表查询', '0', '3', 'businessReport', 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', '', 'form', '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, '菌种资料菜单'); diff --git a/shierfeng-fastapi-backend/tests/module_business/test_master_service.py b/shierfeng-fastapi-backend/tests/module_business/test_master_service.py index a4ae7e6..c846003 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_master_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_master_service.py @@ -570,6 +570,23 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase): 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:]) + 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: cases = ( ('strain', 'biz_strain', 'strain_id'), diff --git a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py index 300e8d5..e751da6 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_sql_contract.py @@ -8,8 +8,10 @@ SQL_ROOT = Path(__file__).resolve().parents[2] / 'sql' INITIALIZERS = ('shierfeng-fastapi.sql', 'shierfeng-fastapi-pg.sql') PATCH_PATH = SQL_ROOT / 'patches/20260724_pallet_workflow_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_PERMS_INDEX = 13 +SYS_MENU_ICON_INDEX = 14 REMOVED_AI_DICT_CODES = set(range(33, 70)) EXPECTED_TABLES = { @@ -591,10 +593,15 @@ EXPECTED_TOP_LEVEL_MENU_POSITIONS = { 2: ('系统监控', 0, 5), 3: ('系统工具', 0, 6), } +EXPECTED_BUSINESS_ROOT_ICONS = { + 121: 'dict', + 122: 'form', + 123: 'chart', +} EXPECTED_TOP_LEVEL_MENU_STABLE_FIELDS = { - 121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', '#'), - 122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', '#'), - 123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', '#'), + 121: ('基础资料维护', 'businessMaster', None, '', '', '1', '0', 'M', '0', '0', '', 'dict'), + 122: ('业务管理', 'businessOperation', None, '', '', '1', '0', 'M', '0', '0', '', 'form'), + 123: ('报表查询', 'businessReport', None, '', '', '1', '0', 'M', '0', '0', '', 'chart'), 1: ('系统管理', 'system', None, '', '', '1', '0', 'M', '0', '0', '', 'system'), 2: ('系统监控', 'monitor', None, '', '', '1', '0', 'M', '0', '0', '', 'monitor'), 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): def test_initializers_define_exact_business_table_set(self) -> None: for filename in INITIALIZERS: @@ -1142,10 +1225,121 @@ class SqlContractTest(unittest.TestCase): sql = (SQL_ROOT / filename).read_text(encoding='utf-8') _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: mutations = ( (1, '系统管理', 'system', 'driftSystemIcon'), - (121, '基础资料维护', '#', 'driftBusinessIcon'), + (121, '基础资料维护', 'dict', 'driftBusinessIcon'), ) for filename in INITIALIZERS: sql = (SQL_ROOT / filename).read_text(encoding='utf-8') @@ -1580,8 +1774,17 @@ class SqlContractTest(unittest.TestCase): patch_rows = _business_menu_rows(raw) mysql_rows = _business_menu_rows((SQL_ROOT / 'shierfeng-fastapi.sql').read_text(encoding='utf-8')) 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( - {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()}, ) self.assertRegex(lower, r'\bon\s+duplicate\s+key\s+update\b')