/** * 菜单管理:菜单入口权限点(s_power)派生 + 新增/删除时的落库请求。 * * 这两块是保存编排里最容易出错、也最难靠肉眼验证的部分: * - 权限点行完全由菜单行派生(menu.{编码}),名称 / 顺序 / 启用态跟随菜单, * 目录不产生权限点; * - 删除菜单要连带清掉多语言资源、模块绑定与入口权限点, * 且权限点删除必须带 dependent_deletes(授权行随权限点删除)。 */ import { describe, expect, it } from 'vitest' import { buildMenuI18nKey, buildMenuPowerCode, buildMenuPowerRow, normalizeMenuPowerRow, menuRowOf, MENU_NON_LEAF_TYPES, MENU_TYPE_ICON_MAP, MENU_TYPE_OPTIONS, type MenuNode, } from '@/views/module/menu-management/utils' import { diffRow, diffRows, stripInternalKeys, cloneData } from '@/utils/dataChanges' describe('菜单类型与图标', () => { it('三种菜单类型都有图标映射,且只有目录能承载子节点', () => { for (const option of MENU_TYPE_OPTIONS) { expect(MENU_TYPE_ICON_MAP[option.value]).toBeTruthy() } expect(MENU_NON_LEAF_TYPES).toEqual(['directory']) }) }) describe('菜单入口权限点派生', () => { it('页面菜单派生 menu.{编码} 行,字段跟随菜单', () => { const row = buildMenuPowerRow({ b_id: 'base_contact', b_name: '联系人', b_menu_type: 'page', b_canuse: 1, b_xh: 30, }) expect(row).toEqual({ b_id: 'menu.base_contact', b_name: '联系人', b_i18n: 'menu.base_contact', b_type: 'menu', b_object_type: 'menu', b_object_id: 'base_contact', b_action: 'access', b_canuse: 1, b_xh: 30, }) }) it('外链菜单同样派生权限点(外链也是「能否进入」的入口)', () => { const row = buildMenuPowerRow({ b_id: 'ext_docs', b_name: '外部文档', b_menu_type: 'external', b_canuse: 1, }) expect(row?.b_id).toBe('menu.ext_docs') }) it('目录不派生权限点(目录只是导航分组)', () => { const row = buildMenuPowerRow({ b_id: 'group_base', b_name: '基础资料', b_menu_type: 'directory', }) expect(row).toBeNull() }) it('编码为空时返回 null(避免派生出 menu. 这种空编码权限点)', () => { expect(buildMenuPowerRow({ b_id: '', b_menu_type: 'page' })).toBeNull() expect(buildMenuPowerRow(null)).toBeNull() }) it('停用菜单派生出停用的权限点(b_canuse 跟随)', () => { const row = buildMenuPowerRow({ b_id: 'base_contact', b_menu_type: 'page', b_canuse: 0, }) expect(row?.b_canuse).toBe(0) }) it('b_canuse 缺省时按启用处理(不把历史数据的空值当停用)', () => { const row = buildMenuPowerRow({ b_id: 'base_contact', b_menu_type: 'page' }) expect(row?.b_canuse).toBe(1) }) }) describe('权限点基线归一化', () => { it('剔除 b_bz 等不参与同步的列,数字列统一为 number', () => { const normalized = normalizeMenuPowerRow({ b_id: 'menu.base_contact', b_name: '联系人', b_bz: '备注不该参与比较', b_canuse: '1', b_xh: '30', }) expect(normalized).not.toHaveProperty('b_bz') expect(normalized?.b_canuse).toBe(1) expect(normalized?.b_xh).toBe(30) }) it('空基线返回 null', () => { expect(normalizeMenuPowerRow(null)).toBeNull() }) it('归一化后与派生行比较:仅备注不同不算变更(避免「打开即脏」)', () => { // 库里那行带了 b_bz,而派生行没有;归一化会把 b_bz 摘掉,两者应判为无差异 const org = normalizeMenuPowerRow({ b_id: 'menu.base_contact', b_name: '联系人', b_i18n: 'menu.base_contact', b_type: 'menu', b_object_type: 'menu', b_object_id: 'base_contact', b_action: 'access', b_canuse: 1, b_xh: 30, b_bz: '人工写的备注', }) const target = buildMenuPowerRow({ b_id: 'base_contact', b_name: '联系人', b_menu_type: 'page', b_canuse: 1, b_xh: 30, }) const changes = diffRows([target!], [org!], { keyFields: ['b_id'] }) expect(changes.inserts).toHaveLength(0) expect(changes.updates).toHaveLength(0) expect(changes.deletes).toHaveLength(0) }) }) describe('权限点随菜单变化同步', () => { const org = buildMenuPowerRow({ b_id: 'base_contact', b_name: '联系人', b_menu_type: 'page', b_canuse: 1, b_xh: 30, })! it('菜单改名 → 权限点 update', () => { const target = buildMenuPowerRow({ b_id: 'base_contact', b_name: '联系人(新)', b_menu_type: 'page', b_canuse: 1, b_xh: 30, })! const changes = diffRows([target], [org], { keyFields: ['b_id'] }) expect(changes.updates).toHaveLength(1) expect(changes.updates[0]?.b_name).toBe('联系人(新)') }) it('菜单由页面改成目录 → 权限点 delete(目录没有入口权限)', () => { const target = buildMenuPowerRow({ b_id: 'base_contact', b_menu_type: 'directory', }) const changes = diffRows(target ? [target] : [], [org], { keyFields: ['b_id'] }) expect(changes.deletes).toEqual([{ b_id: 'menu.base_contact' }]) }) it('菜单由目录改成页面 → 权限点 insert', () => { const target = buildMenuPowerRow({ b_id: 'group_base', b_name: '基础资料', b_menu_type: 'page', b_canuse: 1, })! const changes = diffRows([target], [], { keyFields: ['b_id'] }) expect(changes.inserts).toHaveLength(1) expect(changes.inserts[0]?.b_id).toBe('menu.group_base') }) it('无任何变化 → 不产生请求(保存时提示「没有需要保存的变更」)', () => { const target = buildMenuPowerRow({ b_id: 'base_contact', b_name: '联系人', b_menu_type: 'page', b_canuse: 1, b_xh: 30, })! const changes = diffRows([target], [org], { keyFields: ['b_id'] }) expect( changes.inserts.length || changes.updates.length || changes.deletes.length, ).toBeFalsy() }) }) describe('多语言键与权限编码', () => { it('多语言键与权限编码同形但语义不同(一个进 s_i18n,一个进 s_power)', () => { expect(buildMenuI18nKey('base_contact')).toBe('menu.base_contact') expect(buildMenuPowerCode('base_contact')).toBe('menu.base_contact') }) it('编码为空时两者都返回 null', () => { expect(buildMenuI18nKey('')).toBeNull() expect(buildMenuPowerCode('')).toBeNull() }) }) describe('menuRowOf:树节点 → 主表行', () => { it('剔除 children / isLeaf / icon 等树注入字段', () => { const node = { b_id: 'base_contact', b_parent_id: 'group_base', b_name: '联系人', b_menu_type: 'page', b_route: '/base/contact', b_xh: 10, b_canuse: 1, children: [{ b_id: 'x' }], isLeaf: false, icon: 'file-text', } as unknown as MenuNode const row = menuRowOf(node) expect(row).not.toHaveProperty('children') expect(row).not.toHaveProperty('isLeaf') expect(row).not.toHaveProperty('icon') expect(row.b_id).toBe('base_contact') }) it('b_path 缺省时退化为空串(非空列,不能为 null)', () => { const row = menuRowOf({ b_id: 'a', b_menu_type: 'page' }) expect(row.b_path).toBe('') expect(row.b_depth).toBe(0) }) it('b_parent_id 空值归一成 null', () => { const row = menuRowOf({ b_id: 'a', b_parent_id: '', b_menu_type: 'page' }) expect(row.b_parent_id).toBeNull() }) }) describe('菜单主表 diff', () => { it('新建(无基线)→ 整行 insert', () => { const change = diffRow({ current: { b_id: 'new_menu', b_name: '新菜单', b_menu_type: 'page' }, original: null, }) expect(change.hasChanges).toBe(true) expect(change.inserts).toHaveLength(1) expect(change.inserts[0]?.b_id).toBe('new_menu') }) it('字段级 update 只携带变化列 + 主键', () => { const change = diffRow({ current: { b_id: 'a', b_name: '新名', b_route: '/x', b_xh: 10 }, original: { b_id: 'a', b_name: '旧名', b_route: '/x', b_xh: 10 }, }) expect(change.updates).toHaveLength(1) expect(change.updates[0]).toEqual({ b_id: 'a', b_name: '新名' }) expect(change.updates[0]).not.toHaveProperty('b_route') }) it('派生字段(_org / v_*)不参与比较', () => { const change = diffRow({ current: { b_id: 'a', b_name: '同名', _org: { x: 1 } }, original: { b_id: 'a', b_name: '同名', _org: { x: 2 } }, }) expect(change.hasChanges).toBe(false) }) it('回环序列化:cloneData 的基线与草稿修改前不产生差异', () => { const org = { b_id: 'a', b_name: '联系人', b_xh: 10 } const draft = cloneData(org) expect(diffRow({ current: draft, original: org }).hasChanges).toBe(false) draft.b_name = '改了' expect(diffRow({ current: draft, original: org }).hasChanges).toBe(true) }) it('stripInternalKeys 递归剔除 _ / v_ 前缀(数组逐元素)', () => { const stripped = stripInternalKeys([ { b_id: 'a', _rowKey: 'k', v_count: 3, b_name: 'n' }, ]) expect(stripped[0]).toEqual({ b_id: 'a', b_name: 'n' }) }) })