Files
workspace/code/fms/fms-vue/tests/views/user-power-panel.spec.js
T
2026-09-20 22:05:20 +08:00

390 lines
15 KiB
JavaScript

/**
* 功能权限面板(用户管理 → 分配权限 → 功能权限)。
*
* 数据加载与保存由父级 UserPowerDrawer 负责,面板自身只做这几件事,测试只锁这几件:
* 1. 打开时的初始状态:只展开「全部分类」虚拟根,业务目录收起、不选中任何菜单;
* 2. 菜单选中语义:重复点击已选中的菜单不取消选中,只有点「全部分类」才清空;
* 3. 右侧两个分区:菜单访问(入口开关)+ 模块权限
* (模块名 | 模块使用开关 | 操作权限 chip | 行尾全选与计数)。
* 目录不渲染入口开关、没配权限点的模块不渲染,都不出空态文案;
* 4. 面板顶部工具栏的「全授权 / 全禁用」作用于该账号的全部功能权限(s_power 全量),
* 与左侧选中哪个菜单无关;
* 5. 勾选结果经 v-model 回传,三层是「保护性单向联动」:
* 勾动作补模块使用与入口,勾模块补入口,关模块清该模块动作,
* 关入口收回该菜单下的全部授权。
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import UserPowerPanel from '@/views/system/user/UserPowerPanel.vue'
import { clearMessages } from '@/stores/i18n'
/** 菜单树夹具:顺序模拟 s_menu 的 b_depth / b_xh 排序(父目录在前) */
const MENU_TREE = [
{ b_id: 'base', b_parent_id: null, b_name: '基础资料', b_menu_type: 'directory', b_xh: 10 },
{ b_id: 'system', b_parent_id: null, b_name: '系统管理', b_menu_type: 'directory', b_xh: 20 },
{ b_id: 'partner', b_parent_id: 'base', b_name: '合作伙伴', b_menu_type: 'page', b_xh: 10 },
// 外链菜单:不绑模块,入口就是唯一的授权单元
{ b_id: 'portal', b_parent_id: 'base', b_name: '外部系统', b_menu_type: 'external', b_xh: 20 },
{ b_id: 'user', b_parent_id: 'system', b_name: '用户管理', b_menu_type: 'page', b_xh: 10 },
]
const MENU_MODULES = {
partner: [{ b_menu_id: 'partner', b_module_id: 'v_b_othercompany', b_xh: 10 }],
// 用户管理绑两个模块,其中币种(b_money)没配权限点 → 验证「未配置权限点的模块不渲染」
user: [
{ b_menu_id: 'user', b_module_id: 'v_b_user', b_xh: 10 },
{ b_menu_id: 'user', b_module_id: 'b_money', b_xh: 20 },
],
}
const MODULE_BY_ID = new Map([
['v_b_othercompany', { b_id: 'v_b_othercompany', b_name: '合作伙伴' }],
['v_b_user', { b_id: 'v_b_user', b_name: '用户管理' }],
['b_money', { b_id: 'b_money', b_name: '币种' }],
])
const POWER_BY_MODULE = {
v_b_othercompany: [
{ b_id: 'module.v_b_othercompany', b_name: '模块使用', b_action: 'access' },
{ b_id: 'action.v_b_othercompany.create', b_name: '新增', b_action: 'create' },
],
v_b_user: [{ b_id: 'module.v_b_user', b_name: '模块使用', b_action: 'access' }],
}
/** s_power 全量启用编码(含 menu.*),父级加载后传给面板,供「全授权」使用 */
const ALL_POWER_IDS = [
'menu.partner',
'menu.portal',
'menu.user',
'module.v_b_othercompany',
'action.v_b_othercompany.create',
'module.v_b_user',
]
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
clearMessages()
})
function mountPanel(props = {}) {
return mount(UserPowerPanel, {
props: {
modelValue: [],
menuTree: MENU_TREE,
menuModules: MENU_MODULES,
moduleById: MODULE_BY_ID,
powerByModule: POWER_BY_MODULE,
allPowerIds: ALL_POWER_IDS,
...props,
},
})
}
/** 选中一个菜单(打开时不再默认选中,多数用例只需把右侧切到目标菜单) */
async function mountWithMenu(menuId, props = {}) {
const wrapper = mountPanel(props)
wrapper.vm.selectedMenuId = menuId
await nextTick()
return wrapper
}
/** 按标题文本找树行(每次重新查询,避免拿着已过期的 DOM 包装器做断言) */
function findRow(wrapper, text) {
return wrapper.findAll('.tree-node').find((row) => row.text().includes(text))
}
/** 分区标题清单 */
function sectionTitles(wrapper) {
return wrapper.findAll('.power-section__title').map((el) => el.text())
}
/** 模块权限区的行名清单(跳过「菜单访问」分区的那一行) */
function moduleNames(wrapper) {
const section = wrapper.findAll('.power-section')[1]
return section ? section.findAll('.power-row__name').map((el) => el.text()) : []
}
/** 「菜单访问」分区的入口开关(目录没有入口权限点时返回 undefined) */
function entrySwitch(wrapper) {
return wrapper.findAll('.power-section')[0]?.find('[role="switch"]')
}
/** 模块权限区的模块使用开关(第 index 行) */
function moduleSwitch(wrapper, index = 0) {
const section = wrapper.findAll('.power-section')[1]
return section ? section.findAll('[role="switch"]')[index] : undefined
}
/** 面板顶部工具栏按钮:0 = 全授权,1 = 全禁用 */
function toolbarButtons(wrapper) {
return wrapper.findAll('.power-toolbar__actions button')
}
/** 回传的勾选结果(取最后一次 emit) */
function lastEmitted(wrapper) {
const events = wrapper.emitted('update:modelValue')
return events[events.length - 1][0]
}
describe('功能权限面板:打开时的初始状态', () => {
it('只展开「全部分类」,业务目录收起、不选中任何菜单', async () => {
const wrapper = mountPanel({ menuTree: [] })
await nextTick()
await wrapper.setProps({ menuTree: MENU_TREE })
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('')
// 业务目录的展开键不由面板补写:虚拟根的展开由 FmsTree 承担
expect(wrapper.vm.expandedKeys).toEqual([])
expect(findRow(wrapper, '全部分类')).toBeTruthy()
expect(findRow(wrapper, '基础资料')).toBeTruthy()
// 目录收起 → 其下的页面菜单不可见
expect(findRow(wrapper, '合作伙伴')).toBeUndefined()
expect(wrapper.find('.power-panel__main').text()).toContain('请选择左侧菜单')
})
})
describe('功能权限面板:菜单选中语义', () => {
it('重复点击已选中的菜单:保持选中,不跳回「全部分类」', async () => {
const wrapper = mountPanel()
await nextTick()
// 展开目录 → 选中「合作伙伴」→ 再点一次(ui/Tree 会传出空 keys,即「取消选中」)
await findRow(wrapper, '基础资料').find('.tree-node-switcher').trigger('click')
await nextTick()
await findRow(wrapper, '合作伙伴').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('partner')
await findRow(wrapper, '合作伙伴').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('partner')
expect(findRow(wrapper, '合作伙伴').classes()).toContain('is-selected')
expect(findRow(wrapper, '全部分类').classes()).not.toContain('is-selected')
})
it('点「全部分类」虚拟根:清空选中,右侧回到空态提示', async () => {
const wrapper = mountPanel()
await nextTick()
await findRow(wrapper, '基础资料').find('.tree-node-switcher').trigger('click')
await nextTick()
await findRow(wrapper, '合作伙伴').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('partner')
await findRow(wrapper, '全部分类').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('')
expect(wrapper.find('.power-panel__main').text()).toContain('请选择左侧菜单')
})
it('点另一个菜单:选中切到该菜单', async () => {
const wrapper = mountPanel()
await nextTick()
// 「用户管理」在收起的「系统管理」下:先展开目录再点
await findRow(wrapper, '系统管理').find('.tree-node-switcher').trigger('click')
await nextTick()
await findRow(wrapper, '用户管理').find('.tree-node-content').trigger('click')
await nextTick()
expect(wrapper.vm.selectedMenuId).toBe('user')
expect(moduleNames(wrapper)).toEqual(['用户管理'])
})
})
describe('功能权限面板:右侧结构', () => {
it('两个分区:菜单访问 + 模块权限;入口与模块使用各一个开关', async () => {
const wrapper = await mountWithMenu('partner')
expect(sectionTitles(wrapper)).toEqual(['菜单访问', '模块权限'])
expect(wrapper.findAll('.power-section')[0].find('.power-row__name').text()).toBe('合作伙伴')
expect(moduleNames(wrapper)).toEqual(['合作伙伴'])
// 开关只有两处:菜单访问 + 模块使用
expect(wrapper.findAll('[role="switch"]')).toHaveLength(2)
// chip 只剩业务动作,模块使用不再混在里面
expect(wrapper.findAll('.power-chip').map((el) => el.text())).toEqual(['新增'])
})
it('已授权的入口渲染为打开态', async () => {
const wrapper = await mountWithMenu('partner', { modelValue: ['menu.partner'] })
expect(wrapper.vm.menuPowerCode).toBe('menu.partner')
expect(entrySwitch(wrapper).attributes('aria-checked')).toBe('true')
})
it('目录:不渲染入口开关(没有 menu.* 权限点),只给一行说明', async () => {
const wrapper = await mountWithMenu('base')
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
expect(wrapper.findAll('[role="switch"]')).toHaveLength(0)
expect(wrapper.find('.power-panel__hint').text()).toContain('目录只是分组节点')
})
it('外链菜单:没有可授权模块,入口开关独立可用', async () => {
const wrapper = await mountWithMenu('portal')
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
expect(moduleNames(wrapper)).toEqual([])
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.portal'])
})
it('页面没绑模块(自定义页):只剩入口开关,勾选即全部权限', async () => {
const wrapper = await mountWithMenu('partner', { menuModules: {} })
expect(sectionTitles(wrapper)).toEqual(['菜单访问'])
expect(wrapper.findAll('[role="switch"]')).toHaveLength(1)
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
})
it('未配置权限点的模块不渲染,也不显示空态文案', async () => {
const wrapper = await mountWithMenu('user')
// 「币种」绑定了但没配权限点:不渲染成行,也不出任何提示
expect(moduleNames(wrapper)).toEqual(['用户管理'])
expect(wrapper.find('.power-panel__main').text()).not.toContain('未配置权限点')
})
})
describe('功能权限面板:全授权 / 全禁用', () => {
it('工具栏在面板顶部:说明 + 两个按钮', async () => {
const wrapper = mountPanel()
await nextTick()
expect(wrapper.find('.power-toolbar__label').text()).toBe('该账号的全部功能权限')
expect(toolbarButtons(wrapper).map((el) => el.text())).toEqual(['全授权', '全禁用'])
})
it('全授权:一次勾满 s_power 的全部权限点', async () => {
const wrapper = await mountWithMenu('partner')
await toolbarButtons(wrapper)[0].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(ALL_POWER_IDS)
})
it('全禁用:清空该账号的全部功能权限', async () => {
const wrapper = await mountWithMenu('partner', { modelValue: ALL_POWER_IDS })
await toolbarButtons(wrapper)[1].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([])
})
it('工具栏不依赖选中菜单:未选中菜单时同样可用', async () => {
const wrapper = mountPanel()
await nextTick()
expect(wrapper.find('.power-panel__main').text()).toContain('请选择左侧菜单')
await toolbarButtons(wrapper)[0].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(ALL_POWER_IDS)
})
})
describe('功能权限面板:三层联动', () => {
it('勾模块使用:自动补上菜单入口', async () => {
const wrapper = await mountWithMenu('partner')
await moduleSwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['module.v_b_othercompany', 'menu.partner'])
})
it('勾操作权限:自动补上模块使用与菜单入口', async () => {
const wrapper = await mountWithMenu('partner')
await wrapper.findAll('.power-chip')[0].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([
'action.v_b_othercompany.create',
'module.v_b_othercompany',
'menu.partner',
])
})
it('关模块使用:只清该模块的操作权限,不动菜单入口', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'],
})
await moduleSwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
})
it('开菜单入口:只补入口本身,不给任何模块权限', async () => {
const wrapper = await mountWithMenu('partner')
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner'])
})
it('关菜单入口:连带收回该菜单下的全部模块与动作授权', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'],
})
await entrySwitch(wrapper).trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([])
})
it('取消单个操作权限:不动模块使用与入口', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['menu.partner', 'module.v_b_othercompany', 'action.v_b_othercompany.create'],
})
await wrapper.findAll('.power-chip')[0].trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual(['menu.partner', 'module.v_b_othercompany'])
})
it('勾选态回显:操作权限 chip 与模块使用开关各管各的', async () => {
const wrapper = await mountWithMenu('partner', {
modelValue: ['action.v_b_othercompany.create'],
})
expect(wrapper.findAll('.power-chip')[0].classes()).toContain('is-checked')
expect(wrapper.findAll('.power-chip')[0].attributes('aria-checked')).toBe('true')
// 模块使用未授权 → 开关是关的
expect(moduleSwitch(wrapper).attributes('aria-checked')).toBe('false')
})
it('行尾「全选」:补齐该模块的权限点并联动入口', async () => {
const wrapper = await mountWithMenu('partner')
await wrapper.find('.power-row__toggle').trigger('click')
await nextTick()
expect(lastEmitted(wrapper)).toEqual([
'module.v_b_othercompany',
'action.v_b_othercompany.create',
'menu.partner',
])
})
})