/** * 绑定模块面板(ModuleBindPanel):菜单配置右侧的常驻面板。 * * 回归点: * 1) 树默认全部收起(不设 default-expanded-keys),子节点由用户按需逐层点开; * 折叠态下子树不渲染(tree-node 用 v-if 而非 v-show),因此可直接数 treeitem。 * 2) 勾分类节点级联勾选其下全部模块,但回写给父级的只有 data / virtual 编码 —— * module 类型只是模块树上的分类,不参与权限,写进 s_menu_module 会留下脏关系行。 * 3) 目录 / 外链菜单不绑定模块(不渲染可勾的树,只留一句说明)。 * 4) 外部值变化(切换菜单 / 重载)时,树上的勾选跟随重置。 * 5) 权限点:点模块名按 b_object_id 查 s_power 并用表格列出;分类节点与 virtual * 模块不挂权限点,不发查询、只给说明;切换菜单时连草稿一起清空。 * 6) 就地增删(同「模块管理 → 权限」):新增先在草稿里加一行空动作行再进单元格编辑, * 填完动作按共用口径(@/utils/powerCodes)派生编码 / 性质 / 资源键 / 默认名, * 编码撞车时清掉编码并提示;启用与删除只改草稿(回写给父级),落库由菜单的保存统一提交。 */ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import { nextTick } from 'vue' import { registerFeature, useAreaSelection } from 'stk-table-vue' import ModuleBindPanel from '@/views/module/menu-management/ModuleBindPanel.vue' import { clearMessages } from '@/stores/i18n' import { createTestPinia } from '../helpers/app.js' // 与 main.js 一致:表格可编辑(单元格点击进编辑)依赖 stk-table 的 area-selection feature registerFeature(useAreaSelection) // 权限点 / 权限库都直接取数(面板自带的查询),mock 这一个接缝即可 vi.mock('@/services/api', () => ({ loadDataApi: vi.fn(), loadDataBySqlApi: vi.fn() })) // 命令式 Message 在 jsdom 下会建 DOM 容器,按测试约定打桩 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 { buildPowerLibrarySql } from '@/utils/powerCodes' import { loadDataApi, loadDataBySqlApi } from '@/services/api' import { Message } from '@/components/ui/message/message-manager.js' // 权限点表是 FmsTable(stk-table):jsdom 无布局,容器 clientWidth 为 0 时库会 // 启用虚拟列裁剪只渲染首列——patch 成宽容器,让全部列真实渲染(同 module-fields-panel 用例) beforeAll(() => { Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get() { return 5000 }, }) }) const TREE = [ { b_id: 'base', b_module_type: 'module', b_name: '基础资料', children: [ { b_id: 'base_otherdata', b_module_type: 'module', b_name: '其他数据', children: [{ b_id: 'b_port', b_module_type: 'data', b_name: '港口' }], }, ], }, { b_id: 'system', b_module_type: 'module', b_name: '系统', children: [{ b_id: 'b_user', b_module_type: 'data', b_name: '用户' }], }, ] const MODULE_TYPES = { base: 'module', base_otherdata: 'module', b_port: 'data', system: 'module', b_user: 'data', } /** 库中 b_port 的权限点:模块使用(启用)+ 审核(停用) */ const POWERS = [ { b_id: 'module.b_port', b_name: '模块使用', b_i18n: 'module.b_port', b_action: 'access', b_type: 'module', b_object_type: 'module', b_object_id: 'b_port', b_canuse: 1, b_xh: 10, }, { b_id: 'action.b_port.audit', b_name: '审核', b_i18n: 'action.audit', b_action: 'audit', b_type: 'action', b_object_type: 'module', b_object_id: 'b_port', b_canuse: 0, b_xh: 20, }, ] /** 权限库里的动作(跨模块去重后只留动作与一份名称) */ const LIBRARY = [ { b_action: 'audit', b_name: '审核' }, { b_action: 'print', b_name: '打印' }, ] function mountPanel(props = {}) { // attachTo:FmsTable(stk-table)要真实 DOM 尺寸,脱离文档流时量不到宽高 const wrapper = mount(ModuleBindPanel, { props: { treeData: TREE, moduleTypes: MODULE_TYPES, menuType: 'page', modelValue: [], // 权限点草稿走 v-model:power-drafts:回写值要真的回到 prop,面板才继续拿得到草稿 powerDrafts: {}, 'onUpdate:powerDrafts': (value) => wrapper.setProps({ powerDrafts: value }), ...props, }, attachTo: document.body, }) return wrapper } /** 按文案取按钮(工具栏按钮没有更稳定的属性可用) */ function buttonByText(wrapper, text) { return wrapper.findAll('button').find((btn) => btn.text().includes(text)) } /** * 左权限点表的启用勾选框(树上的勾选框是 ui/Checkbox,类名不同)。 * 按区块取:右栏权限库的选择列(含表头那枚)也是 .fms-checkbox。 */ function powerCheckboxes(wrapper) { return wrapper.findAll('.mm-bind-panel__powers .fms-checkbox input') } /** 取最后一次回写给父级的权限点草稿 */ function lastDrafts(wrapper) { const events = wrapper.emitted('update:powerDrafts') return events[events.length - 1][0] } /** 驱动 ui/Checkbox 内部的原生 input:勾上 / 取消 */ function setCheck(wrapper, index, checked = true) { return wrapper.findAll('input.checkbox-input')[index].setValue(checked) } /** 可勾选行的勾选态(顺序与 [role="treeitem"] 一致) */ function checkedStates(wrapper) { return wrapper.findAll('input.checkbox-input').map((box) => box.element.checked) } /** 回写给父级的模块编码(取最后一次 emit) */ function lastEmitted(wrapper) { const events = wrapper.emitted('update:modelValue') return events[events.length - 1][0] } /** 权限库区块的行文本(左权限点表也有表格,按区块取) */ function libraryRowTexts(wrapper) { return wrapper .findAll('.mm-bind-panel__powers-lib .stk-tbody-main tr') .map((row) => row.text()) } /** 权限库区块的行复选框(选择列的表头也有一枚,取 tbody 里的才是行) */ function libraryCheckboxes(wrapper) { return wrapper.findAll('.mm-bind-panel__powers-lib .stk-tbody-main .fms-checkbox input') } /** 按节点名取树行([role="treeitem"]) */ function nodeRow(wrapper, name) { return wrapper.findAll('[role="treeitem"]').find((row) => row.text().includes(name)) } /** 展开非叶子节点(箭头是行内唯一的 role="button") */ async function expandNode(wrapper, name) { await nodeRow(wrapper, name).find('[role="button"]').trigger('click') await nextTick() } /** 点节点标题:选中即列出该模块的权限点 */ async function clickNode(wrapper, name) { await nodeRow(wrapper, name).find('.tree-node-content').trigger('click') await nextTick() } beforeEach(() => { localStorage.clear() sessionStorage.clear() clearMessages() // FmsTable 内取 appStore(深浅色主题);用 createTestPinia 保证 persist 插件真实注册 createTestPinia() loadDataApi.mockReset() loadDataApi.mockResolvedValue({ data: [] }) loadDataBySqlApi.mockReset() loadDataBySqlApi.mockResolvedValue({ data: [] }) Message.success.mockClear() Message.info.mockClear() Message.warning.mockClear() }) describe('绑定模块面板:树默认收起', () => { it('初始只渲染根节点,没有任何节点处于展开态', () => { const wrapper = mountPanel() const items = wrapper.findAll('[role="treeitem"]') expect(items).toHaveLength(TREE.length) expect(items.map((item) => item.attributes('aria-expanded'))).toEqual(['false', 'false']) }) it('折叠态下子节点不渲染', () => { const wrapper = mountPanel() expect(wrapper.text()).toContain('基础资料') expect(wrapper.text()).not.toContain('其他数据') expect(wrapper.text()).not.toContain('港口') }) }) describe('绑定模块面板:勾选与回写', () => { it('勾分类节点级联整棵子树,回写只保留 data 模块编码', async () => { const wrapper = mountPanel() await setCheck(wrapper, 0) await nextTick() // 分类节点(base / base_otherdata)不落库,只回写 data 模块 expect(lastEmitted(wrapper)).toEqual(['b_port']) }) it('子模块全勾时分类节点呈全选态,点它取消整棵子树并回写空数组', async () => { const wrapper = mountPanel({ modelValue: ['b_port'] }) await setCheck(wrapper, 0, false) await nextTick() expect(lastEmitted(wrapper)).toEqual([]) // 再点回来:级联勾选整棵子树,回写仍只有 data 模块 await setCheck(wrapper, 0) await nextTick() expect(lastEmitted(wrapper)).toEqual(['b_port']) }) it('外部值变化(切换菜单)时树上的勾选跟随重置', async () => { const wrapper = mountPanel() // 展开「系统」露出子节点「用户」:此时只有未勾选的根节点 await expandNode(wrapper, '系统') expect(checkedStates(wrapper)).toEqual([false, false, false]) await wrapper.setProps({ modelValue: ['b_user'] }) await nextTick() expect(checkedStates(wrapper)).toEqual([false, true, true]) }) }) /** 展开到「港口」这一层(b_port 是唯一的 data 叶子) */ async function openPort(wrapper) { await expandNode(wrapper, '基础资料') await expandNode(wrapper, '其他数据') await clickNode(wrapper, '港口') await flushPromises() } /** 点「新增权限」:草稿里多一行空动作行 */ async function addPowerRow(wrapper) { await buttonByText(wrapper, '新增权限').trigger('click') await nextTick() } /** * 驱动单元格变更:jsdom 不实现布局,单元格编辑器挂载与命中不可靠, * 直接触发组件真实监听的 change(row, col, value)——同 module-fields-panel 用例的做法。 */ function changeCell(wrapper, row, dataIndex, value) { wrapper.findComponent({ name: 'FmsTable' }).vm.$emit('change', row, { dataIndex }, value) } describe('绑定模块面板:权限点(点模块名查看)', () => { it('按模块编码查权限点,用表格列出(启停态反映到勾选框)', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) const wrapper = mountPanel() await openPort(wrapper) expect(loadDataApi).toHaveBeenCalledWith( 's_power', "b_object_type = 'module' AND b_object_id = N'b_port'", 'b_xh ASC, b_id ASC', ) expect(wrapper.findAll('.stk-tbody-main tr')).toHaveLength(2) expect(wrapper.text()).toContain('权限名称') expect(wrapper.text()).toContain('模块使用') expect(wrapper.text()).toContain('action.b_port.audit') expect(powerCheckboxes(wrapper).map((el) => el.element.checked)).toEqual([true, false]) }) it('点分类节点:不发查询,只提示分类节点没有权限点', async () => { const wrapper = mountPanel() await clickNode(wrapper, '基础资料') expect(loadDataApi).not.toHaveBeenCalled() expect(wrapper.text()).toContain('分类节点没有权限点') }) it('同一模块来回点只查一次(草稿即缓存)', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) const wrapper = mountPanel() await openPort(wrapper) await clickNode(wrapper, '基础资料') await clickNode(wrapper, '港口') await flushPromises() expect(loadDataApi).toHaveBeenCalledTimes(1) expect(wrapper.text()).toContain('module.b_port') }) it('切换菜单:清掉选中的模块与权限点草稿', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) const wrapper = mountPanel({ menuId: 'otherdata' }) await openPort(wrapper) expect(wrapper.text()).toContain('module.b_port') await wrapper.setProps({ menuId: 'base' }) await nextTick() expect(wrapper.text()).not.toContain('module.b_port') expect(wrapper.text()).toContain('点击左侧模块') expect(lastDrafts(wrapper)).toEqual({}) }) }) describe('绑定模块面板:权限点就地增删', () => { it('模块没有权限点时也能新增:先加一行空动作行,等填动作', async () => { const wrapper = mountPanel() await openPort(wrapper) expect(wrapper.findAll('.fms-action-btn')).toHaveLength(0) await addPowerRow(wrapper) const rows = lastDrafts(wrapper).b_port.rows expect(rows).toHaveLength(1) expect(rows[0]).toMatchObject({ b_id: '', b_action: '', b_type: 'action', b_object_type: 'module', b_object_id: 'b_port', b_canuse: 1, }) expect(rows[0]._rowKey).toBeTruthy() expect(wrapper.findAll('.stk-tbody-main tr')).toHaveLength(1) // 未保存标记:改动只在草稿里,由菜单的「保存」统一落库 expect(wrapper.text()).toContain('未保存') }) it('填动作:按共用口径派生编码 / 性质 / 资源键 / 默认名', async () => { const wrapper = mountPanel() await openPort(wrapper) await addPowerRow(wrapper) changeCell(wrapper, lastDrafts(wrapper).b_port.rows[0], 'b_action', 'audit') await nextTick() expect(lastDrafts(wrapper).b_port.rows[0]).toMatchObject({ b_id: 'action.b_port.audit', b_name: '审核', b_i18n: 'action.audit', b_action: 'audit', b_type: 'action', b_object_id: 'b_port', }) expect(wrapper.text()).toContain('action.b_port.audit') }) it('动作编码撞车:清掉该行编码(不落库)并提示', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) const wrapper = mountPanel() await openPort(wrapper) await addPowerRow(wrapper) // 新增行(最后一行)填一个库里已存在的动作 → 编码撞车 changeCell(wrapper, lastDrafts(wrapper).b_port.rows.at(-1), 'b_action', 'audit') await nextTick() expect(Message.warning).toHaveBeenCalledWith('该动作已存在,重复行不会保存') expect(lastDrafts(wrapper).b_port.rows.at(-1)).toMatchObject({ b_action: 'audit', b_id: '' }) }) it('启用勾选框:按 _rowKey 把勾选结果写回草稿(不就地改显示副本)', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) const wrapper = mountPanel() await openPort(wrapper) await powerCheckboxes(wrapper)[1].setValue(true) await nextTick() const rows = lastDrafts(wrapper).b_port.rows expect(rows.find((row) => row.b_id === 'action.b_port.audit').b_canuse).toBe(1) expect(rows.find((row) => row.b_id === 'module.b_port').b_canuse).toBe(1) expect(wrapper.text()).toContain('未保存') }) it('删除:文字按钮只从草稿里去掉,不回库', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) const wrapper = mountPanel() await openPort(wrapper) // 操作列是文字按钮;删掉第二行(审核),第一行「模块使用」保留 await wrapper.findAll('.fms-action-btn')[1].trigger('click') await nextTick() expect(lastDrafts(wrapper).b_port.rows.map((row) => row.b_id)).toEqual(['module.b_port']) expect(wrapper.findAll('.stk-tbody-main tr')).toHaveLength(1) expect(wrapper.text()).toContain('未保存') }) }) describe('绑定模块面板:权限区表头与表宽', () => { it('不再渲染「权限点 / 模块名 / 条数」表头行', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) const wrapper = mountPanel() await openPort(wrapper) expect(wrapper.find('.mm-bind-panel__powers-head').exists()).toBe(false) // 权限区只剩工具栏(新增权限)+ 表格:模块名由左侧树写明了,这里不重复 expect(wrapper.find('.mm-bind-panel__powers').text()).not.toContain('港口') expect(wrapper.find('.mm-bind-panel__powers').text()).toContain('新增权限') }) it('权限点表按列宽定宽:列宽收紧到 <500,且允许拖拽回写(自适应)', async () => { const wrapper = mountPanel() await openPort(wrapper) const totalWidth = wrapper.vm.powerColumns.reduce((sum, col) => sum + (col.width || 0), 0) expect(totalWidth).toBeLessThan(500) // colResizable + v-model:columns:表格按列宽定宽(不自铺满整栏),拖过的列宽可回写 const powerTable = wrapper.findAllComponents({ name: 'FmsTable' })[0] expect(powerTable.props('colResizable')).toMatchObject({ disabled: expect.any(Function) }) }) }) describe('绑定模块面板:权限库(历史动作导入)', () => { it('选中 data 模块后按去重口径取库,列出名称与动作', async () => { loadDataBySqlApi.mockResolvedValue({ data: LIBRARY }) const wrapper = mountPanel() await openPort(wrapper) expect(loadDataBySqlApi).toHaveBeenCalledWith(buildPowerLibrarySql()) // 名称固定一档窄宽,动作(最后一列)不指定宽度:由它自适应吃剩余 const [nameCol, actionCol] = wrapper.vm.libraryColumns expect(nameCol.width).toBe(88) expect(actionCol.width).toBeUndefined() const rows = libraryRowTexts(wrapper) expect(rows).toHaveLength(2) expect(rows[0]).toContain('审核') expect(rows[0]).toContain('audit') expect(rows[1]).toContain('打印') expect(rows[1]).toContain('print') }) it('没选模块 / 选中分类节点时不出权限库,也不发查询', async () => { const wrapper = mountPanel() // 未选模块:权限点区只有一句说明,右栏整块不渲染 expect(wrapper.find('.mm-bind-panel__powers-lib').exists()).toBe(false) await clickNode(wrapper, '基础资料') expect(wrapper.find('.mm-bind-panel__powers-lib').exists()).toBe(false) expect(loadDataBySqlApi).not.toHaveBeenCalled() }) it('已在本模块的动作置灰,未用过的可勾选', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) loadDataBySqlApi.mockResolvedValue({ data: LIBRARY }) const wrapper = mountPanel() await openPort(wrapper) // audit 已在 b_port 的权限点里(POWERS),print 没有 expect(libraryCheckboxes(wrapper).map((box) => box.element.disabled)).toEqual([true, false]) }) it('导入选中动作:按共用口径派生行追进草稿,清空选中并提示', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) loadDataBySqlApi.mockResolvedValue({ data: LIBRARY }) const wrapper = mountPanel() await openPort(wrapper) await libraryCheckboxes(wrapper)[1].setValue(true) await nextTick() expect(wrapper.vm.librarySelectedKeys).toEqual(['action:print']) wrapper.vm.importSelectedActions() await nextTick() const rows = lastDrafts(wrapper).b_port.rows expect(rows).toHaveLength(3) expect(rows[2]).toMatchObject({ b_id: 'action.b_port.print', b_name: '打印', b_i18n: 'action.print', b_action: 'print', b_type: 'action', b_object_type: 'module', b_object_id: 'b_port', b_canuse: 1, // 顺序号接着草稿末尾排(原有 10 / 20) b_xh: 30, }) expect(wrapper.vm.librarySelectedKeys).toEqual([]) expect(Message.success).toHaveBeenCalledWith('已导入 1 个权限点,随菜单保存一起落库') }) it('选中的动作本模块都已存在:不写回草稿,只提示一句', async () => { loadDataApi.mockResolvedValue({ data: POWERS }) loadDataBySqlApi.mockResolvedValue({ data: [LIBRARY[0]] }) const wrapper = mountPanel() await openPort(wrapper) const drafts = wrapper.emitted('update:powerDrafts') || [] // 库里该行是禁用态(勾不上),直接驱动内部状态覆盖兜底分支 wrapper.vm.librarySelectedKeys = ['action:audit'] wrapper.vm.importSelectedActions() await nextTick() expect(wrapper.emitted('update:powerDrafts') || []).toHaveLength(drafts.length) expect(Message.info).toHaveBeenCalledWith('选中的动作本模块都已存在') expect(Message.success).not.toHaveBeenCalled() }) it('换模块:库重取一次,选中清空', async () => { loadDataBySqlApi.mockResolvedValue({ data: LIBRARY }) const wrapper = mountPanel() await openPort(wrapper) await libraryCheckboxes(wrapper)[1].setValue(true) await nextTick() expect(wrapper.vm.librarySelectedKeys).toEqual(['action:print']) // 换到另一个 data 模块(系统 / 用户) await expandNode(wrapper, '系统') await clickNode(wrapper, '用户') expect(loadDataBySqlApi).toHaveBeenCalledTimes(2) expect(wrapper.vm.librarySelectedKeys).toEqual([]) }) }) describe('绑定模块面板:非页面菜单', () => { it('目录菜单不渲染可勾的树,只给一句说明', () => { const wrapper = mountPanel({ menuType: 'directory' }) expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(0) expect(wrapper.text()).toContain('目录不绑定模块') }) it('外链菜单不渲染可勾的树,只给一句说明', () => { const wrapper = mountPanel({ menuType: 'external' }) expect(wrapper.findAll('[role="treeitem"]')).toHaveLength(0) expect(wrapper.text()).toContain('外链不绑定模块') }) })