/** * useModuleSave 保存编排测试:守护跨表保存的关键不变量。 * 这些顺序约束此前只存在于注释中,被改动时不会有任何报警: * 1. 校验失败时不产生任何保存请求,且定位到对应分节; * 2. s_module 有修改走 updates、新建走 inserts; * 3. 临时 id 先分配真实主键,再跨表重映射 b_field_id(req 行与草稿行同引用); * 4. s_autocode 的 updates 剥离服务端维护的 b_currentperiod / b_currentvalue; * 5. i18n inserts 分配真实主键并回写面板,避免二次保存重复插入。 */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { ref } from 'vue' import { useModuleSave } from '@/views/module/module-management/composables/useModuleSave' import { saveObjectApi, nextIdApi } from '@/services/api' import { Message } from '@/components/ui' vi.mock('@/services/api', () => ({ saveObjectApi: vi.fn(), nextIdApi: vi.fn(), })) vi.mock('@/components/ui', () => ({ Message: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, })) let idSeq beforeEach(() => { vi.clearAllMocks() idSeq = 9000 nextIdApi.mockImplementation(async (count) => { const ids = [] for (let i = 0; i < count; i++) ids.push(String(++idSeq)) return { data: ids } }) }) function baseDrafts(over = {}) { const drafts = { // b_i18n 与 buildModuleI18nKey('data', 'demo') 派生值一致:saveModule 每次保存都会物化该键 module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data', b_i18n: 'module.demo' }, fields: [], groups: [], listConfig: [], editConfig: [], queryConfig: [], autoCode: [], powers: [], } return Object.assign(drafts, over) } function setup({ drafts, originals, basicPanel = null, autoCodePanel = null, i18nPanel = null }) { const unsavedModuleIds = new Set() const deps = { selectedModule: ref({ b_id: '1', b_code: 'demo', b_name: '演示模块' }), drafts: ref(drafts), originals: ref(originals), loadedModuleId: ref('1'), moduleSelectedId: ref('1'), unsavedModuleIds, configSaving: ref(false), busy: ref(false), activeSection: ref('basic'), moduleTreeData: ref([]), findModuleNode: () => null, basicPanelRef: ref(basicPanel), autoCodePanelRef: ref(autoCodePanel), i18nPanelRef: ref(i18nPanel), getFinalI18nItems: () => [{ key: 'module.demo', managed: true, id: 'module:demo' }], } return { saveModule: useModuleSave(deps).saveModule, unsavedModuleIds, activeSection: deps.activeSection } } function reqOf(table) { return saveObjectApi.mock.calls.at(-1)[0].find((req) => req.table === table) } describe('useModuleSave 保存编排', () => { it('无变更时不发请求并提示', async () => { const drafts = baseDrafts() const { saveModule } = setup({ drafts, originals: JSON.parse(JSON.stringify(drafts)) }) const ok = await saveModule() expect(ok).toBe(true) expect(saveObjectApi).not.toHaveBeenCalled() expect(Message.info).toHaveBeenCalledWith('没有需要保存的变更') }) it('基础面板校验失败:不发请求,定位到 basic 分节', async () => { const drafts = baseDrafts() const { saveModule, activeSection } = setup({ drafts, originals: JSON.parse(JSON.stringify(drafts)), basicPanel: { validate: async () => ['编码不能为空'] }, }) const ok = await saveModule() expect(ok).toBe(false) expect(saveObjectApi).not.toHaveBeenCalled() expect(activeSection.value).toBe('basic') expect(Message.warning).toHaveBeenCalled() }) it('s_module 修改走 updates,新建走 inserts', async () => { const drafts = baseDrafts({ module: { b_id: '1', b_code: 'demo', b_name: '改名后' } }) const originals = baseDrafts() const { saveModule } = setup({ drafts, originals }) await saveModule() expect(reqOf('s_module').updates[0].b_name).toBe('改名后') const { saveModule: saveNew, unsavedModuleIds } = setup({ drafts: baseDrafts(), originals: baseDrafts(), }) unsavedModuleIds.add('1') await saveNew() expect(reqOf('s_module').inserts[0].b_code).toBe('demo') }) it('临时 id 分配真实主键并跨表重映射 b_field_id', async () => { // 新字段(临时 id -1)+ 引用它的列表列:编辑行同引用,重映射必须同时落到请求与草稿 const listRow = { b_id: '100', b_module_id: '1', b_field_id: '-1', b_visible: 1, b_xh: 10 } const drafts = baseDrafts({ fields: [{ b_id: '-1', b_module_id: '1', b_field: 'name', b_name: '姓名' }], listConfig: [listRow], }) const originals = baseDrafts({ listConfig: [{ b_id: '100', b_module_id: '1', b_field_id: '9999', b_visible: 1, b_xh: 10 }], }) const { saveModule } = setup({ drafts, originals }) const ok = await saveModule() expect(ok).toBe(true) expect(nextIdApi).toHaveBeenCalled() const fieldReq = reqOf('s_field') expect(fieldReq.inserts[0].b_id).toBe('9001') // 列表列由旧字段改挂新字段:update 行内的临时外键必须已换成真实 id const viewReq = reqOf('s_field_view') expect(viewReq.updates[0].b_field_id).toBe('9001') // 草稿行与请求行同引用:重映射后草稿也持有真实 id expect(drafts.listConfig[0].b_field_id).toBe('9001') }) it('s_autocode updates 剥离服务端维护的期间/当前值', async () => { const rule = { b_id: '200', b_module_id: '1', b_field_id: '100', b_canuse: 1, b_currentperiod: 2026, b_currentvalue: 5, } const drafts = baseDrafts({ autoCode: [rule], module: { b_id: '1', b_code: 'demo', b_name: '演示模块', b_module_type: 'data', b_i18n: 'module.demo', b_savetable: 't_demo' }, }) const { saveModule } = setup({ drafts, // 原件行与草稿行存在差异(当前值不同)才会产生 update,从而走到剥离逻辑 originals: baseDrafts({ autoCode: [{ ...rule, b_currentvalue: 4 }] }), }) await saveModule() const update = reqOf('s_autocode').updates[0] expect(update).not.toHaveProperty('b_currentperiod') expect(update).not.toHaveProperty('b_currentvalue') expect(update.b_field_id).toBe('100') }) it('i18n inserts 分配真实主键并回写面板,事务内提交', async () => { const drafts = baseDrafts({ module: { b_id: '1', b_code: 'demo', b_name: '新名称' } }) const originals = baseDrafts() const i18nPanel = { reconcileManagedRows: vi.fn(), buildSaveRequest: vi.fn(() => ({ table: 's_i18n', key_field: 'b_id', inserts: [{ b_id: '-9', b_key: 'module.demo', b_locale: 'zh-CN', b_value: '新名称' }], })), applyInsertedIds: vi.fn(), markSaved: vi.fn(), } const { saveModule } = setup({ drafts, originals, i18nPanel }) const ok = await saveModule() expect(ok).toBe(true) // 受管键 reconcile 在构建请求前执行 expect(i18nPanel.reconcileManagedRows).toHaveBeenCalledWith([ { key: 'module.demo', managed: true, id: 'module:demo' }, ]) // 临时主键分配后回写面板,避免二次保存重复插入 expect(i18nPanel.applyInsertedIds).toHaveBeenCalledWith([ expect.objectContaining({ b_id: '9001', b_key: 'module.demo' }), ]) expect(reqOf('s_i18n').inserts[0].b_id).toBe('9001') expect(i18nPanel.markSaved).toHaveBeenCalled() }) it('跨面板校验前置:启用自动编码但未配置保存表则拦截', async () => { const rule = { b_id: '200', b_module_id: '1', b_field_id: '100', b_canuse: 1 } const drafts = baseDrafts({ autoCode: [rule] }) const { saveModule } = setup({ drafts, originals: baseDrafts() }) const ok = await saveModule() expect(ok).toBe(false) expect(saveObjectApi).not.toHaveBeenCalled() expect(Message.warning).toHaveBeenCalledWith('启用自动编码前请先配置保存表') }) })