/** * 回归用例:菜单管理 - 随菜单保存同步的两份子表数据。 * * 1) 绑定模块(s_menu_module) * s_menu 表没有模块列:绑定关系存 s_menu_module,行身份 = (b_menu_id, b_module_id), * 只登记 data / virtual 模块(module 只是模块树上的分类,不参与权限)。 * 保存时与加载基线做行级 diff,只提交增删行;目录 / 外链菜单不绑定模块。 * * 2) 菜单入口权限点(s_power, b_object_type = 'menu') * 页面 / 外链菜单各派生一行 menu.{菜单编码}(b_action = 'access',名称/顺序/启用跟随菜单), * 目录没有;保存时按菜单行与该菜单的加载基线 diff;删除菜单时只删权限点行, * 引用它的授权行用 dependent_deletes 声明,由保存层按同一条件查出编码再删(设计 §14.6)。 */ import { describe, it, expect, beforeEach, vi } from 'vitest' import { flushPromises } from '@vue/test-utils' import MenuManagement from '@/views/module/menu-management/index.vue' import { mountPage } from '../helpers/app.js' vi.mock('@/services/api', () => ({ loadDataApi: vi.fn(), saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })), nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })), describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })), })) vi.mock('@/components/ui/message/message-manager.js', () => { const m = { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() } return { Message: m, default: m } }) import { loadDataApi, saveObjectApi } from '@/services/api' const directoryMenu = { b_id: 'base', b_parent_id: null, b_depth: 0, b_path: '/base/', b_name: '基础资料', b_i18n: 'menu.base', b_menu_type: 'directory', b_route: null, b_icon: null, b_xh: 10, b_canuse: 1, } const pageMenu = { b_id: 'otherdata', b_parent_id: 'base', b_depth: 1, b_path: '/base/otherdata/', b_name: '其他数据', b_i18n: 'menu.otherdata', b_menu_type: 'page', b_route: '/base/otherdata', b_icon: null, b_xh: 10, b_canuse: 1, } /** 模块清单:base / base_otherdata 为分类节点,b_port / b_money 为数据模块 */ const modules = [ { b_id: 'base', b_parent_id: null, b_depth: 0, b_path: '/base/', b_module_type: 'module', b_name: '基础资料', b_canuse: 1, b_xh: 10, }, { b_id: 'base_otherdata', b_parent_id: 'base', b_depth: 1, b_path: '/base/base_otherdata/', b_module_type: 'module', b_name: '其他数据', b_canuse: 1, b_xh: 30, }, { b_id: 'b_port', b_parent_id: 'base_otherdata', b_depth: 2, b_path: '/base/base_otherdata/b_port/', b_module_type: 'data', b_name: '港口', b_canuse: 1, b_xh: 10, }, { b_id: 'b_money', b_parent_id: 'base_otherdata', b_depth: 2, b_path: '/base/base_otherdata/b_money/', b_module_type: 'data', b_name: '币种', b_canuse: 1, b_xh: 20, }, ] /** 库中 s_menu_module 的现有行(每个用例前重置) */ let boundRows = [] /** 库中该菜单的 s_power(menu.*) 行(每个用例前重置) */ let powerRows = [] /** 从查询条件里取 N'xxx' 字面量(mock 按 id 过滤用;'1 = 1' 等条件返回 null) */ function literalOf(condition) { return /N'([^']*)'/.exec(String(condition || ''))?.[1] ?? null } beforeEach(() => { boundRows = [{ b_menu_id: 'otherdata', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }] powerRows = [] loadDataApi.mockReset() saveObjectApi.mockClear() loadDataApi.mockImplementation((view, condition) => { if (view === 's_menu') { const id = literalOf(condition) const rows = [directoryMenu, pageMenu].filter((row) => !id || row.b_id === id) return Promise.resolve({ data: rows }) } if (view === 's_module') return Promise.resolve({ data: modules }) if (view === 's_menu_module') { const id = literalOf(condition) return Promise.resolve({ data: boundRows.filter((row) => !id || row.b_menu_id === id), }) } if (view === 's_power') { const id = literalOf(condition) return Promise.resolve({ data: powerRows.filter((row) => !id || row.b_object_id === id), }) } return Promise.resolve({ data: [] }) }) }) /** 取出最近一次保存请求里的指定表请求 */ function lastRequestOf(table) { const requests = saveObjectApi.mock.calls.at(-1)[0] return requests.find((request) => request.table === table) } describe('菜单管理:绑定模块(s_menu_module)', () => { it('选中菜单时按 b_menu_id 加载绑定模块', async () => { const { wrapper } = await mountPage(MenuManagement) await flushPromises() wrapper.vm.menuSelectedId = 'otherdata' await flushPromises() expect(loadDataApi).toHaveBeenCalledWith( 's_menu_module', "b_menu_id = N'otherdata'", 'b_xh ASC, b_module_id ASC', ) expect(wrapper.vm.menuModules).toEqual(['b_port']) }) it('新增绑定:只提交 s_menu_module 的 insert 行', async () => { const { wrapper } = await mountPage(MenuManagement) await flushPromises() wrapper.vm.menuSelectedId = 'otherdata' await flushPromises() wrapper.vm.menuModules = ['b_port', 'b_money'] await wrapper.vm.handleSave() const request = lastRequestOf('s_menu_module') expect(request.key_field).toBe('b_menu_id,b_module_id') expect(request.inserts).toEqual([ { b_menu_id: 'otherdata', b_module_id: 'b_money', b_xh: 20, b_canuse: 1 }, ]) expect(request.deletes).toBeUndefined() }) it('移除绑定:只提交 s_menu_module 的 delete 主键行', async () => { const { wrapper } = await mountPage(MenuManagement) await flushPromises() wrapper.vm.menuSelectedId = 'otherdata' await flushPromises() wrapper.vm.menuModules = [] await wrapper.vm.handleSave() const request = lastRequestOf('s_menu_module') expect(request.deletes).toEqual([{ b_menu_id: 'otherdata', b_module_id: 'b_port' }]) expect(request.inserts).toBeUndefined() }) it('目录菜单不绑定模块:保存时清空并删除既有关系行', async () => { boundRows = [{ b_menu_id: 'base', b_module_id: 'b_port', b_xh: 10, b_canuse: 1 }] const { wrapper } = await mountPage(MenuManagement) await flushPromises() wrapper.vm.menuSelectedId = 'base' await flushPromises() expect(wrapper.vm.menuModules).toEqual(['b_port']) await wrapper.vm.handleSave() const request = lastRequestOf('s_menu_module') expect(request.deletes).toEqual([{ b_menu_id: 'base', b_module_id: 'b_port' }]) }) }) describe('菜单管理:入口权限点(s_power, menu.*)', () => { /** 库中该菜单的入口权限点行(名称故意与菜单不同,用于验证保存会同步) */ function makePowerRow(over = {}) { return { b_id: 'menu.otherdata', b_name: '其他数据', b_i18n: 'menu.otherdata', b_type: 'menu', b_object_type: 'menu', b_object_id: 'otherdata', b_action: 'access', b_canuse: 1, b_xh: 10, ...over, } } it('选中页面菜单时按 b_object_id 加载入口权限点基线', async () => { powerRows = [makePowerRow()] const { wrapper } = await mountPage(MenuManagement) await flushPromises() wrapper.vm.menuSelectedId = 'otherdata' await flushPromises() expect(loadDataApi).toHaveBeenCalledWith( 's_power', "b_object_type = 'menu' and b_object_id = N'otherdata'", 'b_id ASC', ) expect(wrapper.vm.menuPower_org?.b_id).toBe('menu.otherdata') }) it('新增页面菜单:自动派生 menu.{编码} 权限点行', async () => { const { wrapper } = await mountPage(MenuManagement) await flushPromises() wrapper.vm.menuSelectedId = 'otherdata' await flushPromises() // 改菜单名,保证有可提交的差异 wrapper.vm.maindata.b_name = '其他数据(新)' await wrapper.vm.handleSave() const request = lastRequestOf('s_power') expect(request.key_field).toBe('b_id') expect(request.inserts).toHaveLength(1) expect(request.inserts[0]).toMatchObject({ b_id: 'menu.otherdata', b_name: '其他数据(新)', b_i18n: 'menu.otherdata', b_type: 'menu', b_object_type: 'menu', b_object_id: 'otherdata', b_action: 'access', b_canuse: 1, b_xh: 10, }) }) it('保存页面菜单:入口权限点的名称跟随菜单同步更新', async () => { powerRows = [makePowerRow({ b_name: '旧名称' })] const { wrapper } = await mountPage(MenuManagement) await flushPromises() wrapper.vm.menuSelectedId = 'otherdata' await flushPromises() expect(wrapper.vm.menuPower_org?.b_name).toBe('旧名称') // 菜单本身没有改动,只有权限点名称与菜单不一致 → 也应同步 await wrapper.vm.handleSave() const request = lastRequestOf('s_power') expect(request.updates).toHaveLength(1) expect(request.updates[0]).toMatchObject({ b_id: 'menu.otherdata', b_name: '其他数据' }) }) it('目录菜单没有入口权限点:无差异时不提交任何保存请求', async () => { const { wrapper } = await mountPage(MenuManagement) await flushPromises() wrapper.vm.menuSelectedId = 'base' await flushPromises() await wrapper.vm.handleSave() // 目录不派生权限点;菜单 / 绑定 / 多语言都无差异 → 不调用保存接口 expect(saveObjectApi).not.toHaveBeenCalled() }) it('删除页面菜单:删掉入口权限点(授权行由保存层连带清理)', async () => { const { wrapper } = await mountPage(MenuManagement) await flushPromises() wrapper.vm.deleteMenu('otherdata') await flushPromises() await wrapper.vm.confirmState.onOk() await flushPromises() const requests = saveObjectApi.mock.calls.at(-1)[0] const powerReq = requests.find((request) => request.table === 's_power') expect(powerReq.deletes).toEqual([{ b_id: 'menu.otherdata' }]) // 授权行不在这里拼:用 dependent_deletes 声明依赖表 / 键列 / 取值列, // 由保存层按同一条件查出权限编码再删(前端不查询、不逐个拼授权行) expect(powerReq.dependent_deletes).toEqual([ { table: 's_user_power', key_field: 'b_power_id', source_field: 'b_id' }, ]) // 菜单主表最后删 expect(requests.at(-1)).toMatchObject({ table: 's_menu' }) }) })