Files
workspace/code/fms/fms-vue/tests/views/module-bind-panel.spec.js
T
2026-09-29 17:22:12 +08:00

572 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 绑定模块面板(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('外链不绑定模块')
})
})