Files
workspace/code/fms/fms-vue/tests/views/system-user.spec.js
T
2026-09-16 17:27:04 +08:00

292 lines
9.2 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.
/**
* 用户管理页(/system/user):左部门树 + 右用户列表的页面级业务规则。
*
* 本页自身只做三件事,测试也只锁这三件:
* 1. 选中部门 → 固定查询条件(含全部下级部门,按 b_parent_id 展开);
* 2. 新增用户时把选中部门补成默认值(只补新增、且表单未带该字段时);
* 3. 未选中部门时不加任何固定条件(显示全部用户)。
*
* 部门树的增删改与拖拽逻辑在本页内,但落库前的计算由 @/utils/tree 承担,
* 已由 fms-tree / utils 的既有用例覆盖,这里不重复。
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import SystemUser from '@/views/system/user/index.vue'
import { mountPage } from '../helpers/app.js'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
pageDataApi: vi.fn(() => Promise.resolve({ data: { rows: [], total: 0 } })),
saveObjectApi: vi.fn(() => Promise.resolve({ code: 0, success: true })),
nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })),
describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })),
}))
vi.mock('@/stores/permissions', () => ({
usePermissionStore: () => ({
load: () => Promise.resolve(),
canAccess: () => true,
canPower: () => true,
}),
}))
import { loadDataApi } from '@/services/api'
/** 造一条 b_dept 行;默认启用 */
function makeDept(over = {}) {
return {
b_id: 'd1',
b_parent_id: null,
b_depth: 0,
b_path: '/d1/',
b_name: 'D1',
b_xh: 10,
b_canuse: 1,
...over,
}
}
/**
* 固定夹具:
* d_root
* ├─ d_a
* │ └─ d_a1
* └─ d_b
* d_other(无父,与所选部门无关)
*/
const FIXTURE = [
makeDept({ b_id: 'd_root', b_parent_id: null, b_depth: 0, b_path: '/d_root/', b_name: '总部' }),
makeDept({
b_id: 'd_a',
b_parent_id: 'd_root',
b_depth: 1,
b_path: '/d_root/d_a/',
b_name: '华东',
}),
makeDept({
b_id: 'd_a1',
b_parent_id: 'd_a',
b_depth: 2,
b_path: '/d_root/d_a/d_a1/',
b_name: '上海',
}),
makeDept({
b_id: 'd_b',
b_parent_id: 'd_root',
b_depth: 1,
b_path: '/d_root/d_b/',
b_name: '华南',
}),
makeDept({ b_id: 'd_other', b_parent_id: null, b_depth: 0, b_path: '/d_other/', b_name: '独立' }),
]
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
loadDataApi.mockReset()
loadDataApi.mockImplementation(() =>
Promise.resolve({ data: FIXTURE.map((row) => ({ ...row })) }),
)
})
/** 只渲染默认插槽的直通壳,用于布局容器(否则插槽内容不渲染) */
const passthrough = {
template: '<div><slot /></div>',
}
async function mountUser() {
// 部门的加载在本页内完成(loadDepts 直接写 depts),测试只需让 loadDataApi 返回部门行,
// 页面即可拿到 depts,不依赖任何子组件 emit。
//
// 布局容器必须按「Vue 实际推断出的组件名」打桩。
// Splitter/SplitterPanel 两个 SFC 的文件名分别是 splitter.vue / panel.vue,
// 推断名因此是 'splitter' / 'panel'(不是 'SplitterPanel');写错键就匹配不上,
// 容器被 shallowMount 默认打成空壳,插槽内容不渲染,页面拿到的 depts 恒为空。
const { wrapper } = await mountPage(SystemUser, {
stubs: {
splitter: passthrough,
panel: passthrough,
},
})
await flushPromises()
return wrapper
}
describe('用户管理:选中部门 → 固定查询条件', () => {
it('未选中部门时不加任何固定条件', async () => {
const wrapper = await mountUser()
expect(wrapper.vm.deptFilterCondition).toBe('')
})
it('选中部门时条件含自身与全部下级', async () => {
const wrapper = await mountUser()
wrapper.vm.selectedDeptKey = 'd_root'
await flushPromises()
// IN 子句里 id 的顺序无意义,collectDescendantIds 按遍历序返回,这里只比对集合
const ids = [...wrapper.vm.deptFilterCondition.matchAll(/'([^']+)'/g)].map((m) => m[1])
expect(new Set(ids)).toEqual(new Set(['d_root', 'd_a', 'd_a1', 'd_b']))
expect(ids).toHaveLength(4)
})
it('选中中间层只含该分支,不含兄弟分支', async () => {
const wrapper = await mountUser()
wrapper.vm.selectedDeptKey = 'd_a'
await flushPromises()
const condition = wrapper.vm.deptFilterCondition
expect(condition).toContain("'d_a'")
expect(condition).toContain("'d_a1'")
expect(condition).not.toContain("'d_b'")
expect(condition).not.toContain("'d_root'")
})
it('选中无下级的部门时只匹配自身', async () => {
const wrapper = await mountUser()
wrapper.vm.selectedDeptKey = 'd_a1'
await flushPromises()
expect(wrapper.vm.deptFilterCondition).toBe("[b_dept_id] IN ('d_a1')")
})
it('b_parent_id 与 b_path 不一致时以 b_parent_id 为准(b_path 只是派生字段)', async () => {
loadDataApi.mockImplementation(() =>
Promise.resolve({
data: [
...FIXTURE.map((row) => ({ ...row })),
makeDept({
b_id: 'd_moved',
b_parent_id: 'd_a',
b_depth: 2,
b_path: '/stale/d_moved/',
b_name: '搬迁部门',
}),
],
}),
)
const wrapper = await mountUser()
wrapper.vm.selectedDeptKey = 'd_a'
await flushPromises()
expect(wrapper.vm.deptFilterCondition).toContain("'d_moved'")
})
})
describe('用户管理:新增时默认部门', () => {
it('选中部门时新增补上 b_dept_id', async () => {
const wrapper = await mountUser()
wrapper.vm.selectedDeptKey = 'd_a'
await flushPromises()
const row = {}
wrapper.vm.onBeforeSave(row, { isNew: true })
expect(row.b_dept_id).toBe('d_a')
})
it('编辑时不覆盖已有部门', async () => {
const wrapper = await mountUser()
wrapper.vm.selectedDeptKey = 'd_a'
await flushPromises()
const row = { b_id: 'u1', b_dept_id: 'd_b' }
wrapper.vm.onBeforeSave(row, { isNew: false })
expect(row.b_dept_id).toBe('d_b')
})
it('表单已填部门时不覆盖用户的输入', async () => {
const wrapper = await mountUser()
wrapper.vm.selectedDeptKey = 'd_a'
await flushPromises()
const row = { b_dept_id: 'd_b' }
wrapper.vm.onBeforeSave(row, { isNew: true })
expect(row.b_dept_id).toBe('d_b')
})
it('未选中部门时新增不做任何补充', async () => {
const wrapper = await mountUser()
const row = {}
wrapper.vm.onBeforeSave(row, { isNew: true })
expect(row.b_dept_id).toBeUndefined()
})
})
describe('用户管理:密码字段', () => {
/**
* 视图 v_b_user 刻意不含 b_password(避免全员口令随列表接口下发),
* 因此编辑态表单恒为空 —— 留空必须理解为「保持原密码」,
* 把字段摘掉再提交,否则会被 diff 当成「改成空」覆盖库中原值。
*/
it('编辑时密码留空 → 摘掉字段,不提交', async () => {
const wrapper = await mountUser()
const row = { b_id: 'u1', b_name: '张三', b_password: '' }
expect(wrapper.vm.onBeforeSave(row, { isNew: false })).toBe(true)
expect('b_password' in row).toBe(false)
})
it('编辑时填了新密码 → 原样保留待提交', async () => {
const wrapper = await mountUser()
const row = { b_id: 'u1', b_name: '张三', b_password: 'new-secret' }
expect(wrapper.vm.onBeforeSave(row, { isNew: false })).toBe(true)
expect(row.b_password).toBe('new-secret')
})
it('新增时密码为空 → 拦截保存', async () => {
const wrapper = await mountUser()
const row = { b_id: 'u1', b_name: '张三' }
expect(wrapper.vm.onBeforeSave(row, { isNew: true })).toBe(false)
})
it('新增时填了密码 → 通过,且不误删字段', async () => {
const wrapper = await mountUser()
const row = { b_id: 'u1', b_name: '张三', b_password: 'secret' }
expect(wrapper.vm.onBeforeSave(row, { isNew: true })).toBe(true)
expect(row.b_password).toBe('secret')
})
})
describe('用户管理:行操作', () => {
it('提供编辑操作,点开时把该行交给弹窗', async () => {
const wrapper = await mountUser()
const editAction = wrapper.vm.rowActions.find((action) => action.key === 'edit')
expect(editAction).toBeTruthy()
const row = { b_id: 'u1', b_name: '张三' }
editAction.onClick(row)
// 用 toStrictEqual 而非 toBe:editingUser 是 ref,读出来的是响应式代理,与传入对象不同一
expect(wrapper.vm.editingUser).toStrictEqual(row)
expect(wrapper.vm.editOpen).toBe(true)
})
it('有删除权限时提供删除操作', async () => {
const wrapper = await mountUser()
const keys = wrapper.vm.rowActions.map((action) => action.key)
expect(keys).toEqual(['edit', 'delete'])
})
it('新增时把编辑对象清空,避免带入上一次编辑的行', async () => {
const wrapper = await mountUser()
wrapper.vm.onEdit({ b_id: 'u1' })
expect(wrapper.vm.editingUser).toEqual({ b_id: 'u1' })
wrapper.vm.onCreate()
expect(wrapper.vm.editingUser).toBeNull()
expect(wrapper.vm.editOpen).toBe(true)
})
})