/** * 模块关联面板(s_relation): * - 加载回填行只补本地行键(_rowKey),不得改动业务列——否则打开模块即提示「未保存」; * - 新增关联:追加「源模块 = 当前模块」的空行,主键四列留待用户填写; * - 换目标模块:原目标字段必然失效,需清空并按目标模块加载字段选项; * - 保存前校验:四列非空、关系类型合法、四列组合不重复(空列会撞行身份,落库即主键冲突)。 */ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { flushPromises } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' import ModuleRelationPanel from '@/views/module/module-management/ModuleRelationPanel.vue' import { stripInternalKeys } from '@/utils/dataChanges' vi.mock('@/services/api', () => ({ loadDataApi: vi.fn(), nextIdApi: vi.fn(() => Promise.resolve({ data: '1900000000000000009', code: 0 })), })) 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 { loadDataApi } from '@/services/api' import { Message } from '@/components/ui/message/message-manager.js' // jsdom 无布局:StkTable 在 clientWidth 为 0 时启用虚拟列裁剪,补成宽容器 beforeAll(() => { Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get() { return 5000 }, }) }) const MODULES = [ { b_id: 'order', b_name: '订单', b_canuse: 1 }, { b_id: 'order_item', b_name: '订单明细', b_canuse: 1 }, { b_id: 'legacy', b_name: '历史模块', b_canuse: 0 }, ] const FIELDS = [{ b_field: 'b_id', b_name: '主键', b_canuse: 1 }] /** 库中已有的一行:源侧(当前模块 order)→ 目标模块 order_item */ const sourceRow = { b_source_module_id: 'order', b_source_field: 'b_id', b_target_module_id: 'order_item', b_target_field: 'b_order_id', b_relation_type: 'one_to_many', b_canuse: 1, b_xh: 10, } async function mountPanel(modelValue = [], { refRules = [] } = {}) { const pinia = createPinia() pinia.install({ provide() {}, config: { globalProperties: {} } }) setActivePinia(pinia) const wrapper = mount(ModuleRelationPanel, { props: { moduleId: 'order', fields: FIELDS, modules: MODULES, reverseRows: [], loading: false, modelValue, refRules, }, global: { plugins: [pinia] }, attachTo: document.body, }) await flushPromises() await nextTick() return wrapper } const lastRelations = (wrapper) => { const events = wrapper.emitted('update:modelValue') || [] return events.length ? events[events.length - 1][0] : null } const lastRefRules = (wrapper) => { const events = wrapper.emitted('update:refRules') || [] return events.length ? events[events.length - 1][0] : null } /** 已勾选引用规则的关系行(refRules 里挂着对应的 relation 规则) */ const REF_SCOPE_ID = 'order|b_id|order_item|b_order_id' const refRuleRow = { b_id: '1900000000000000001', b_scope_type: 'relation', b_scope_id: REF_SCOPE_ID, b_hook: 'delete.pre', b_predicate: null, b_canuse: 1, b_xh: 10, } beforeEach(() => { document.body.innerHTML = '' loadDataApi.mockReset() loadDataApi.mockResolvedValue({ data: [] }) Message.warning.mockClear() Message.info.mockClear() Message.success.mockClear() }) describe('模块关联面板', () => { it('加载回填只补本地行键,业务列不变', async () => { const wrapper = await mountPanel([{ ...sourceRow }]) const next = lastRelations(wrapper) expect(next).toHaveLength(1) expect(next[0]._rowKey).toBeTruthy() // 剥掉 _ 前缀的内部键后应与库中行完全一致(不置脏) expect(stripInternalKeys(next[0])).toEqual(sourceRow) }) it('新增关联:追加源模块为当前模块的空行并进入编辑', async () => { const wrapper = await mountPanel([{ ...sourceRow }]) await wrapper.vm.addRow() await nextTick() const next = lastRelations(wrapper) expect(next).toHaveLength(2) expect(next[1]).toMatchObject({ b_source_module_id: 'order', b_source_field: null, b_target_module_id: null, b_target_field: null, b_relation_type: 'many_to_one', b_canuse: 1, b_xh: 20, }) }) it('换目标模块:清空原目标字段并按新模块加载字段选项', async () => { loadDataApi.mockResolvedValue({ data: [{ b_field: 'b_item_id', b_name: '明细主键', b_canuse: 1 }], }) const wrapper = await mountPanel([{ ...sourceRow }]) const row = wrapper.vm.relations[0] wrapper.vm.onCellChange(row, { dataIndex: 'b_target_module_id' }) expect(row.b_target_field).toBe(null) await flushPromises() expect(loadDataApi).toHaveBeenCalledWith( 's_field', "b_module_id = N'order_item'", 'b_xh ASC, b_field ASC', ) expect(wrapper.vm.fieldOptionsOf('order_item')).toEqual([ { label: '明细主键(b_item_id)', value: 'b_item_id' }, ]) }) it('停用模块与历史字段降级为裸编码,不丢行', async () => { const wrapper = await mountPanel([ { ...sourceRow, b_target_module_id: 'legacy', b_target_field: 'b_gone' }, ]) // 停用模块不在可选项里,但名称解析仍可用(不阻断展示) expect(wrapper.vm.moduleOptions.map((item) => item.value)).toEqual(['order', 'order_item']) expect(wrapper.vm.moduleText('legacy')).toBe('历史模块(legacy)') expect(wrapper.vm.moduleText('removed')).toBe('removed') expect(wrapper.vm.fieldText('order', 'b_id')).toBe('主键(b_id)') expect(wrapper.vm.fieldText('order', 'b_gone')).toBe('b_gone') }) it('校验:空列与四列组合重复都被拦截', async () => { const wrapper = await mountPanel([{ ...sourceRow }]) wrapper.vm.relations = [ { ...sourceRow, _rowKey: 'r1' }, { ...sourceRow, _rowKey: 'r2' }, ] await nextTick() expect(wrapper.vm.validate()).toContain('第 2 行:与前面的关联重复') wrapper.vm.relations = [ { _rowKey: 'r1', b_source_module_id: 'order', b_source_field: null, b_target_module_id: null, b_target_field: null, b_relation_type: 'many_to_one', b_canuse: 1, }, ] await nextTick() const errors = wrapper.vm.validate() expect(errors).toContain('第 1 行:请选择源字段') expect(errors).toContain('第 1 行:请选择目标模块') expect(errors).toContain('第 1 行:请选择目标字段') // 四列都空时行身份相同:只有一条时不算重复 expect(errors.some((item) => item.includes('重复'))).toBe(false) }) it('批量删除:移除选中行并清空选择', async () => { const wrapper = await mountPanel([{ ...sourceRow }]) await wrapper.vm.addRow() await nextTick() const next = lastRelations(wrapper) wrapper.vm.selectedRowKeys = [String(next[1]._rowKey)] wrapper.vm.batchDelete() await nextTick() expect(lastRelations(wrapper)).toHaveLength(1) expect(wrapper.vm.selectedRowKeys).toEqual([]) }) it('删除连带:加载回填不置脏,多对一自动清回不连带', async () => { const cascadeRow = { ...sourceRow, b_on_delete: 'cascade' } // 传入副本:面板会往行对象上补 _ 前缀派生列 const wrapper = await mountPanel([{ ...cascadeRow }]) const row = lastRelations(wrapper)[0] expect(stripInternalKeys(row)).toEqual(cascadeRow) // 类型改成多对一:连带策略不再成立,自动清回 none 并提示 row.b_relation_type = 'many_to_one' wrapper.vm.onCellChange(row, { dataIndex: 'b_relation_type' }) await nextTick() expect(row.b_on_delete).toBe('none') expect(Message.warning).toHaveBeenCalledWith( '多对一关系不支持连带删除/连带归档,已重置为不连带', ) }) it('校验:多对一上配连带删除/归档被拦,取值非法被拦', async () => { const wrapper = await mountPanel([ { ...sourceRow, b_relation_type: 'many_to_one', b_on_delete: 'cascade' }, { ...sourceRow, b_target_field: 'b_other', b_on_delete: 'remove' }, ]) const errors = wrapper.vm.validate() expect(errors).toContain('第 1 行:多对一关系不支持连带删除/连带归档') expect(errors).toContain('第 2 行:删除连带取值不合法') }) it('引用规则:勾选取号建行、取消删行,回填按业务键还原勾选态', async () => { const wrapper = await mountPanel([{ ...sourceRow }], { refRules: [] }) const row = wrapper.vm.relations[0] row._hasRefRule = 1 wrapper.vm.onCellChange(row, { dataIndex: '_hasRefRule' }) await flushPromises() await nextTick() const added = lastRefRules(wrapper) expect(added).toHaveLength(1) expect(added[0]).toMatchObject({ b_id: '1900000000000000009', b_scope_type: 'relation', b_scope_id: REF_SCOPE_ID, b_predicate: null, }) // 已有规则时加载:勾选态由业务键还原(不置脏) const withRule = await mountPanel([{ ...sourceRow }], { refRules: [{ ...refRuleRow }] }) expect(stripInternalKeys(withRule.vm.relations[0])).toEqual(sourceRow) expect(withRule.vm.relations[0]._hasRefRule).toBe(1) // 取消勾选:规则行被移除 const target = withRule.vm.relations[0] target._hasRefRule = 0 withRule.vm.onCellChange(target, { dataIndex: '_hasRefRule' }) await nextTick() expect(lastRefRules(withRule)).toEqual([]) }) it('改关系主键:自动取消引用保护并删掉旧键规则,避免库中残留孤儿规则', async () => { const wrapper = await mountPanel([{ ...sourceRow }], { refRules: [{ ...refRuleRow }] }) const row = wrapper.vm.relations[0] expect(row._hasRefRule).toBe(1) row.b_target_module_id = 'legacy' wrapper.vm.onCellChange(row, { dataIndex: 'b_target_module_id' }) await nextTick() expect(row._hasRefRule).toBe(0) expect(lastRefRules(wrapper)).toEqual([]) expect(Message.info).toHaveBeenCalledWith( '关系主键已变化,「有引用时拒绝删除」已取消,请重新勾选', ) }) it('删掉关系行时,挂在它上面的引用规则一并移除', async () => { const wrapper = await mountPanel([{ ...sourceRow }], { refRules: [{ ...refRuleRow }] }) wrapper.vm.removeRow(wrapper.vm.relations[0]) await nextTick() expect(lastRefRules(wrapper)).toEqual([]) expect(lastRelations(wrapper)).toHaveLength(0) }) })