import { describe, expect, it } from 'vitest' import { mount } from '@vue/test-utils' import { nextTick } from 'vue' import ModuleBindPanel from '@/views/module/menu-management/ModuleBindPanel.vue' import { buildMenuModuleRows, filterBindableModules, isBindableModule, moduleBindSignature, normalizeMenuModulesByType, } from '@/views/module/menu-management/utils' /** * 菜单「绑定模块」(s_menu_module)的纯逻辑与面板行为。 * * 关键口径:只有页面(page)菜单绑定模块;落库只登记 data / virtual —— * module 类型只是模块树上的分类节点,不参与绑定。 */ const MODULE_TREE = [ { b_id: 'sales', b_name: '销售', children: [ { b_id: 'v_order', b_name: '订单' }, { b_id: 'v_customer', b_name: '客户' }, ], }, { b_id: 'v_other', b_name: '其他' }, ] /** 编码 → 类型:sales 是分类,其余是可绑定的数据模块 */ const TYPES: Record = { sales: 'module', v_order: 'data', v_customer: 'virtual', v_other: 'data', } /** 挂面板;menuType 决定渲染树还是给一句说明,model 是已绑模块编码 */ function mountPanel(props: Record = {}, model: string[] = []) { return mount(ModuleBindPanel, { props: { treeData: MODULE_TREE, moduleTypes: TYPES, menuType: 'page', menuId: 'm1', modelValue: model, 'onUpdate:modelValue': () => {}, ...props, }, }) } describe('isBindableModule', () => { it('data / virtual 可绑定,module 不可', () => { expect(isBindableModule('data')).toBe(true) expect(isBindableModule('virtual')).toBe(true) expect(isBindableModule('module')).toBe(false) }) it('类型未知时按可绑定处理(避免误删历史绑定)', () => { // 模块清单没加载全时,宁可保留关系行也不当成脏数据删掉 expect(isBindableModule(undefined)).toBe(true) expect(isBindableModule(null)).toBe(true) expect(isBindableModule('')).toBe(true) }) }) describe('filterBindableModules', () => { it('剔除分类节点,保留可绑定模块与原顺序', () => { expect(filterBindableModules(['v_order', 'sales', 'v_customer'], TYPES)).toEqual([ 'v_order', 'v_customer', ]) }) it('勾父级带回的子树键里,只有可绑定的留下', () => { // 非严格模式点「销售」会把 sales / v_order / v_customer 一起回传 expect(filterBindableModules(['sales', 'v_order', 'v_customer'], TYPES)).toEqual([ 'v_order', 'v_customer', ]) }) }) describe('moduleBindSignature', () => { it('顺序无关:同集合不同顺序签名相同', () => { expect(moduleBindSignature(['a', 'b'])).toBe(moduleBindSignature(['b', 'a'])) }) it('集合不同则签名不同', () => { expect(moduleBindSignature(['a'])).not.toBe(moduleBindSignature(['a', 'b'])) }) it('数字与字符串编码视为同一个', () => { expect(moduleBindSignature([1, 2])).toBe(moduleBindSignature(['1', '2'])) }) }) describe('buildMenuModuleRows', () => { it('按勾选顺序生成关系行,b_xh 从 10 起步', () => { expect(buildMenuModuleRows('m1', ['v_order', 'v_customer'])).toEqual([ { b_menu_id: 'm1', b_module_id: 'v_order', b_xh: 10, b_canuse: 1 }, { b_menu_id: 'm1', b_module_id: 'v_customer', b_xh: 20, b_canuse: 1 }, ]) }) it('菜单编码为空时返回空数组(不产出悬空关系行)', () => { expect(buildMenuModuleRows('', ['v_order'])).toEqual([]) }) it('空绑定返回空数组', () => { expect(buildMenuModuleRows('m1', [])).toEqual([]) }) }) describe('normalizeMenuModulesByType', () => { it('页面菜单保留绑定', () => { expect(normalizeMenuModulesByType('page', ['v_order'])).toEqual(['v_order']) }) it('目录 / 外链一律清空(非页面不绑定模块)', () => { expect(normalizeMenuModulesByType('directory', ['v_order'])).toEqual([]) expect(normalizeMenuModulesByType('external', ['v_order'])).toEqual([]) }) }) describe('ModuleBindPanel', () => { it('页面菜单渲染可勾选的模块树', () => { const wrapper = mountPanel() expect(wrapper.find('.biz-tree').exists()).toBe(true) // 树默认收起(只渲染顶层节点),所以这里只断言顶层两个节点各有复选框 const boxes = wrapper.findAllComponents({ name: 'G3Checkbox' }) expect(boxes.length).toBe(2) expect(wrapper.text()).toContain('销售') expect(wrapper.text()).toContain('其他') }) it('目录 / 外链不渲染树,各给一句说明', () => { const dir = mountPanel({ menuType: 'directory' }) expect(dir.find('.biz-tree').exists()).toBe(false) expect(dir.text()).toContain('目录不绑定模块') const ext = mountPanel({ menuType: 'external' }) expect(ext.find('.biz-tree').exists()).toBe(false) expect(ext.text()).toContain('外链不绑定模块') }) it('初始已绑模块反映在顶层复选框上', async () => { // 「其他」是顶层节点:默认收起的树里只有顶层可见,用它验证已绑态回填 const wrapper = mountPanel({}, ['v_other']) await nextTick() const boxes = wrapper.findAllComponents({ name: 'G3Checkbox' }) // 顶层顺序与 treeData 一致:sales、v_other expect(boxes[0].props('modelValue')).toBe(false) expect(boxes[1].props('modelValue')).toBe(true) }) it('切换菜单清掉勾选草稿', async () => { const wrapper = mountPanel({}, ['v_other']) await nextTick() expect(wrapper.findAllComponents({ name: 'G3Checkbox' })[1].props('modelValue')).toBe(true) // 切到另一个菜单:父级尚未回填新绑定时,草稿必须清空而不是留着上一个菜单的勾 await wrapper.setProps({ menuId: 'm2', modelValue: [] }) await nextTick() expect(wrapper.findAllComponents({ name: 'G3Checkbox' })[1].props('modelValue')).toBe(false) }) it('勾选后回写模型时剔除分类节点', async () => { const updates: string[][] = [] const wrapper = mountPanel({ 'onUpdate:modelValue': (v: string[]) => updates.push(v), }) await nextTick() // 点「销售」分类 → 级联勾选整棵子树(含分类自身) const boxes = wrapper.findAllComponents({ name: 'G3Checkbox' }) await boxes[0].vm.$emit('change', true) await nextTick() const last = updates.at(-1) ?? [] expect(last).not.toContain('sales') expect(last.toSorted()).toEqual(['v_customer', 'v_order']) }) it('数量标记只算可绑定的模块', async () => { const wrapper = mountPanel({}, ['v_order', 'v_customer']) await nextTick() expect(wrapper.find('.g3-tag').text()).toBe('2') }) it('未绑定时不显示数量标记', () => { const wrapper = mountPanel({}, []) expect(wrapper.find('.g3-tag').exists()).toBe(false) }) })