/**
* 用户管理页(/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: '
',
}
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)
})
})