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