/** * 回归用例:模块管理 - 修改基础信息 → 保存 → 切到其它树节点仍提示「未保存」。 * * 成因:查询配置面板(ModuleQueryConfigPanel)的「行草稿 → query JSON」回写在配置 * 重载时被触发。保存走 afterSave → loadModuleConfig(同一模块重载,面板不卸载), * schema ref 被换成新对象 → 面板重新解析出行草稿 → 回写监听发现 * rowsToQuerySchema(rows) 与当前 schema 文本不等(补齐 quick/groups、给条件补默认 * component 等「规范化」差异),于是把规范化后的 query JSON 写回草稿: * - 草稿与库中基线文本不一致 → hasUnsavedChanges() 恒为真,切节点必弹确认框; * - 编辑器未建模的属性(运行时「保存为通用」写入的 condition.area 等)被静默改写。 * 修复:回写前先与「当前配置的规范化结果」比较,仅用户真实改动才回写 * (与 TableDesignEditor / FormDesignEditor 的回写策略一致)。 */ import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import ModuleManagement from '@/views/module/module-management/index.vue' import { createTestPinia, createTestRouter } from '../helpers/app.js' import { createDefaultSchema, schemaToJsonText } from '@/utils/schemaJson' vi.mock('@/services/api', () => ({ loadDataApi: vi.fn(), saveObjectApi: vi.fn(), nextIdApi: vi.fn(() => Promise.resolve({ data: 9001, code: 0, success: true })), describeApi: vi.fn(() => Promise.resolve({ data: [], code: 0, success: true })), })) 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, saveObjectApi } from '@/services/api' import { Message } from '@/components/ui/message/message-manager.js' /** 库中的一条关系:demo.b_name → other.b_name(一对多,源侧即当前模块) */ function relationRow() { return { b_source_module_id: 'demo', b_source_field: 'b_name', b_target_module_id: 'other', b_target_field: 'b_name', b_relation_type: 'one_to_many', b_on_delete: 'none', b_canuse: 1, b_xh: 10, } } function moduleRow(id, name) { return { b_id: id, b_parent_id: null, b_depth: 0, b_path: `/${id}/`, b_name: name, b_i18n: `module.${id}`, b_module_type: 'data', b_view_table: `v_${id}`, b_save_table: `t_${id}`, b_key_field: 'b_id', b_order_sql: null, b_query_sql: null, b_config_json: null, b_canuse: 1, b_xh: 10, b_bz: null, } } /** 库中历史查询配置:缺 quick 键、条件未显式写 component,且带运行时写入的 area 标记 */ const legacyQuerySchema = { schemaVersion: 1, groups: { logic: 'and', conditions: [{ field: 'b_name', operator: 'like', area: 'hidden' }], }, } function schemaRow(moduleId, type, json) { return { b_module_id: moduleId, b_schema_type: type, b_schema_json: schemaToJsonText(type, json), b_canuse: 1, } } let db let savedRequests function buildDb() { const fields = ['demo', 'other'].map((id) => ({ b_module_id: id, b_field: 'b_name', b_name: '名称', b_i18n: `field.${id}.b_name`, b_xh: 10, b_canuse: 1, })) const schemas = [ schemaRow('demo', 'view', createDefaultSchema('view')), schemaRow('demo', 'edit', createDefaultSchema('edit')), schemaRow('demo', 'query', { schemaVersion: 1, groups: [legacyQuerySchema.groups] }), ] return { s_module: [moduleRow('demo', '示例模块'), moduleRow('other', '另一个模块')], s_field: fields, s_module_schema: schemas, s_autocode: [], s_power: [], s_relation: [], s_delete_rule: [], s_i18n: [{ b_key: 'module.demo', b_locale: 'en-US', b_value: 'Demo module' }], } } const rowsOf = (view) => (db[view] || []).map((row) => ({ ...row })) beforeEach(() => { db = buildDb() savedRequests = [] loadDataApi.mockReset() loadDataApi.mockImplementation((view, condition) => { if (view === 's_i18n_type') return Promise.resolve({ data: [ { b_id: 'zh-CN', b_name: '简体中文', b_default: 1 }, { b_id: 'en-US', b_name: 'English', b_default: 0 }, ], }) return Promise.resolve({ data: rowsOf(view) }) }) saveObjectApi.mockReset() saveObjectApi.mockImplementation((requests) => { savedRequests.push(...(requests || [])) for (const request of requests || []) { const table = request.table if (!db[table]) continue for (const row of request.updates || []) { const keyFields = String(request.key_field || 'b_id').split(',') const target = db[table].find((item) => keyFields.every((field) => String(item[field]) === String(row[field])), ) if (target) Object.assign(target, row) } for (const row of request.inserts || []) db[table].push({ ...row }) } return Promise.resolve({ code: 0, success: true }) }) }) /** * 保存后的重载(afterSave → loadModuleConfig)不是 await 的,断言前先等它把新基线装载完, * 避免并发跑用例时时序抖动 */ async function settleConfigReload(wrapper) { for (let i = 0; i < 50 && wrapper.vm.configLoading; i += 1) { await flushPromises() } await flushPromises() } async function mountManagement() { const pinia = createTestPinia() const router = createTestRouter('/') await router.isReady() return mount(ModuleManagement, { global: { plugins: [pinia, router] } }) } describe('模块管理:保存后的未保存判定', () => { it('保存基础信息后不应再判定为未保存,切换树节点不再弹确认框', async () => { const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() // 打开即干净:加载时的配置规范化不得算作改动 expect(wrapper.vm.hasUnsavedChanges()).toBe(false) wrapper.vm.maindata.b_name = '改名后' expect(wrapper.vm.hasUnsavedChanges()).toBe(true) await wrapper.vm.handleSave() await settleConfigReload(wrapper) expect(wrapper.vm.maindata.b_name).toBe('改名后') expect(wrapper.vm.hasUnsavedChanges()).toBe(false) // 切到其它节点:直接切换,不弹「未保存」确认框 wrapper.vm.onModuleSelect('other') await flushPromises() expect(wrapper.vm.switchConfirm).toBe(null) expect(String(wrapper.vm.moduleSelectedId)).toBe('other') }) it('未被用户编辑的查询配置不应随保存写回(不静默改写 area 等属性)', async () => { const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() wrapper.vm.maindata.b_name = '改名后' await wrapper.vm.handleSave() await settleConfigReload(wrapper) // 只提交主表变更;查询配置(s_module_schema.query)不参与 expect(savedRequests.some((request) => request.table === 's_module')).toBe(true) expect(savedRequests.some((request) => request.table === 's_module_schema')).toBe(false) // 库中 query JSON 保持原样(含 area 标记) const queryRow = db.s_module_schema.find((row) => row.b_schema_type === 'query') expect(JSON.parse(queryRow.b_schema_json).groups[0].conditions[0].area).toBe('hidden') // 草稿里的查询配置也不得被「规范化」改写(否则下一次保存就会把它落库) expect(wrapper.vm.schemaCurrent.query.value).toEqual({ schemaVersion: 1, groups: [ { logic: 'and', conditions: [{ field: 'b_name', operator: 'like', area: 'hidden' }] }, ], }) }) it('多语言的人工翻译应计入未保存,保存后不再提示', async () => { const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() const row = wrapper.vm.i18n.find((item) => item.b_key === 'module.demo') expect(row, '库中的模块名多语言行应加载到草稿').toBeTruthy() expect(wrapper.vm.hasUnsavedChanges()).toBe(false) row['en-US'] = 'Demo module v2' expect(wrapper.vm.hasUnsavedChanges()).toBe(true) await wrapper.vm.handleSave() await settleConfigReload(wrapper) expect(wrapper.vm.hasUnsavedChanges()).toBe(false) expect( db.s_i18n.find((item) => item.b_key === 'module.demo' && item.b_locale === 'en-US')?.b_value, ).toBe('Demo module v2') }) it('权限面板:库中历史派生列不算未保存改动,仅在保存前物化', async () => { db.s_power = [ { b_id: 'module.demo.access', b_object_type: 'module', b_object_id: 'demo', b_action: 'access', b_type: 'action', b_name: '模块使用', b_canuse: 1, b_xh: 100, }, ] const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() // 打开即干净:加载阶段不得把库里的派生列改写进草稿 expect(wrapper.vm.hasUnsavedChanges()).toBe(false) expect(wrapper.vm.powers[0].b_id).toBe('module.demo.access') expect(wrapper.vm.powers[0].b_xh).toBe(100) wrapper.vm.powers[0].b_action = 'audit' await wrapper.vm.handleSave() await settleConfigReload(wrapper) const powerReq = savedRequests.find((request) => request.table === 's_power') expect(powerReq, '动作变化应提交权限点变更').toBeTruthy() // 权限编码即主键:动作变化会换编码,落库表现为「删旧码 + 插新码」 expect(powerReq.deletes).toEqual([{ b_id: 'module.demo.access' }]) expect(powerReq.inserts[0]).toMatchObject({ b_id: 'action.demo.audit', b_object_type: 'module', b_object_id: 'demo', b_type: 'action', b_xh: 10, }) }) it('保存当前模块不得丢弃其它未保存的新建草稿节点', async () => { const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() await wrapper.vm.addModule(null, 'module') await flushPromises() const draftId = String(wrapper.vm.moduleSelectedId) expect(draftId.startsWith('-'), '新建模块用本地临时负数 id 占位').toBe(true) wrapper.vm.onModuleSelect('demo') await flushPromises() await flushPromises() expect(String(wrapper.vm.moduleSelectedId)).toBe('demo') wrapper.vm.maindata.b_name = '改名后' await wrapper.vm.handleSave() await settleConfigReload(wrapper) expect(wrapper.vm.unsavedModuleIds.has(draftId), '草稿仍应标记为未保存').toBe(true) expect( wrapper.vm.moduleTreeData.find((node) => String(node.b_id) === draftId), '草稿节点应保留在树中', ).toBeTruthy() expect(wrapper.vm.hasUnsavedChanges()).toBe(false) }) it('删除其它节点不得丢掉当前未保存的草稿', async () => { const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() wrapper.vm.maindata.b_name = '改名后' expect(wrapper.vm.hasUnsavedChanges()).toBe(true) wrapper.vm.deleteModule('other') await flushPromises() await wrapper.vm.onConfirmOk() await flushPromises() expect(String(wrapper.vm.moduleSelectedId)).toBe('demo') expect(wrapper.vm.maindata.b_name).toBe('改名后') expect(wrapper.vm.hasUnsavedChanges()).toBe(true) }) it('父模块尚未保存时不允许先保存子模块(避免插入指向临时 id 的孤儿行)', async () => { const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() await wrapper.vm.addModule(null, 'module') await flushPromises() const parentDraftId = String(wrapper.vm.moduleSelectedId) await wrapper.vm.addModule(parentDraftId, 'module') await flushPromises() wrapper.vm.maindata.b_id = 'sub_module' wrapper.vm.maindata.b_name = '子模块' const ok = await wrapper.vm.handleSave() await flushPromises() expect(ok).toBe(false) expect(Message.warning).toHaveBeenCalledWith('父模块尚未保存,请先保存父模块') expect(savedRequests).toEqual([]) }) it('删除模块应一并清理菜单绑定(s_menu_module)', async () => { const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() wrapper.vm.deleteModule('demo') await flushPromises() expect(wrapper.vm.confirmVisible).toBe(true) await wrapper.vm.onConfirmOk() await flushPromises() const menuReq = savedRequests.find((request) => request.table === 's_menu_module') expect(menuReq, '模块删除必须清理 s_menu_module 绑定行').toBeTruthy() expect(menuReq.key_field).toBe('b_module_id') expect(menuReq.deletes).toEqual([{ b_module_id: 'demo' }]) expect( savedRequests.some( (request) => request.table === 's_module' && request.deletes?.[0]?.b_id === 'demo', ), ).toBe(true) }) it('模块关联:加载拆分源/目标两侧,保存只提交源侧行(四列复合主键)', async () => { db.s_relation = [ { b_source_module_id: 'demo', b_source_field: 'b_name', b_target_module_id: 'other', b_target_field: 'b_name', b_relation_type: 'one_to_many', b_canuse: 1, b_xh: 10, }, { b_source_module_id: 'other', b_source_field: 'b_name', b_target_module_id: 'demo', b_target_field: 'b_name', b_relation_type: 'many_to_one', b_canuse: 1, b_xh: 20, }, ] const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() // 源侧进可编辑草稿,目标侧只读展示;打开即干净(本地行键不得算作改动) expect(wrapper.vm.relations).toHaveLength(1) expect(wrapper.vm.reverseRelations).toHaveLength(1) expect(String(wrapper.vm.reverseRelations[0].b_source_module_id)).toBe('other') expect(wrapper.vm.hasUnsavedChanges()).toBe(false) wrapper.vm.relations[0].b_canuse = 0 expect(wrapper.vm.hasUnsavedChanges()).toBe(true) await wrapper.vm.handleSave() await settleConfigReload(wrapper) const relationReq = savedRequests.find((request) => request.table === 's_relation') expect(relationReq, '关联变更应提交 s_relation').toBeTruthy() expect(relationReq.key_field).toBe( 'b_source_module_id,b_source_field,b_target_module_id,b_target_field', ) expect(relationReq.updates).toEqual([ { b_source_module_id: 'demo', b_source_field: 'b_name', b_target_module_id: 'other', b_target_field: 'b_name', b_relation_type: 'one_to_many', b_canuse: 0, b_xh: 10, }, ]) // 反向行不属于本模块草稿,保存不得把它算作删除 expect(relationReq.deletes ?? []).toEqual([]) expect(wrapper.vm.hasUnsavedChanges()).toBe(false) }) it('删除模块应一并清理双向模块关联(s_relation)', async () => { const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() wrapper.vm.deleteModule('demo') await flushPromises() await wrapper.vm.onConfirmOk() await flushPromises() const relationReqs = savedRequests.filter((request) => request.table === 's_relation') expect(relationReqs).toHaveLength(2) expect(relationReqs).toEqual([ { table: 's_relation', key_field: 'b_source_module_id', deletes: [{ b_source_module_id: 'demo' }], }, { table: 's_relation', key_field: 'b_target_module_id', deletes: [{ b_target_module_id: 'demo' }], }, ]) }) it('删除 SQL 规则:加载不脏,只提交变更(雪花主键)', async () => { db.s_relation = [relationRow()] db.s_delete_rule = [ { b_id: '1900000000000000001', b_module_id: 'demo', b_sql: 'select b_id from demo where b_id in (:ids) and b_name like N\'a\'', b_message: '示例模块记录已锁定,不能删除', b_canuse: 1, b_xh: 10, }, ] const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() // 规则进 rules;打开即干净 expect(wrapper.vm.rules).toHaveLength(1) expect(wrapper.vm.hasUnsavedChanges()).toBe(false) wrapper.vm.rules[0].b_canuse = 0 expect(wrapper.vm.hasUnsavedChanges()).toBe(true) await wrapper.vm.handleSave() await settleConfigReload(wrapper) const ruleReq = savedRequests.find((request) => request.table === 's_delete_rule') expect(ruleReq, '规则变更应提交 s_delete_rule').toBeTruthy() expect(ruleReq.key_field).toBe('b_id') expect(ruleReq.updates?.[0]).toMatchObject({ b_id: '1900000000000000001', b_module_id: 'demo', b_canuse: 0, }) // 关系未变:不产生 s_relation 请求 expect(savedRequests.some((request) => request.table === 's_relation')).toBe(false) expect(wrapper.vm.hasUnsavedChanges()).toBe(false) }) it('关联删除处理:改 b_on_delete 随 s_relation 一起提交,不产生规则行', async () => { db.s_relation = [relationRow()] const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() expect(wrapper.vm.rules).toEqual([]) wrapper.vm.relations[0].b_on_delete = 'cascade' expect(wrapper.vm.hasUnsavedChanges()).toBe(true) await wrapper.vm.handleSave() await settleConfigReload(wrapper) const relationReq = savedRequests.find((request) => request.table === 's_relation') expect(relationReq?.updates?.[0]).toMatchObject({ b_on_delete: 'cascade' }) // 关联处置收敛到 s_relation,不再生成任何规则表请求 expect(savedRequests.some((request) => request.table === 's_delete_rule')).toBe(false) expect(wrapper.vm.hasUnsavedChanges()).toBe(false) }) it('删除模块应一并清理删除 SQL 规则(s_delete_rule)', async () => { db.s_relation = [relationRow()] db.s_delete_rule = [ { b_id: '1900000000000000001', b_module_id: 'demo', b_sql: 'select 1 as b_id', b_message: '不能删除', }, { b_id: '1900000000000000002', b_module_id: 'other', b_sql: 'select 1 as b_id', b_message: '不能删除', }, ] const wrapper = await mountManagement() await flushPromises() wrapper.vm.moduleSelectedId = 'demo' await flushPromises() await flushPromises() wrapper.vm.deleteModule('demo') await flushPromises() await wrapper.vm.onConfirmOk() await flushPromises() const ruleReqs = savedRequests.filter((request) => request.table === 's_delete_rule') const moduleReq = ruleReqs.find((request) => request.deletes?.[0]?.b_module_id === 'demo') expect(moduleReq?.deletes).toEqual([{ b_module_id: 'demo' }]) // 只按模块列清理,不误伤其他模块的规则 expect(ruleReqs.every((request) => request.deletes.every((row) => row.b_module_id === 'demo'))).toBe( true, ) }) })