Files
workspace/code/fms/fms-vue/tests/views/module-unsaved-after-save.spec.js
T
2026-09-17 21:29:08 +08:00

623 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 回归用例:模块管理 - 修改基础信息 → 保存 → 切到其它树节点仍提示「未保存」。
*
* 成因:查询配置面板(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'
import { createRefRuleRow } from '@/views/module/module-management/ruleUtils'
/** 库中的一条关系: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_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 },
],
})
// s_rule 的 scope 条件是两列(模块级 / 关系级)拼在同一个查询条件里,
// 这里按条件过滤,模拟后端 search_condition 的真实行为
if (view === 's_rule' && typeof condition === 'string') {
if (condition.includes("b_scope_type = 'module'")) {
return Promise.resolve({
data: rowsOf(view).filter((row) => row.b_scope_type === 'module'),
})
}
if (condition.includes("b_scope_type = 'relation'")) {
return Promise.resolve({
data: rowsOf(view).filter((row) => row.b_scope_type === 'relation'),
})
}
}
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('删除规则:模块级规则加载不脏,只提交变更(雪花主键)', async () => {
db.s_relation = [relationRow()]
db.s_rule = [
{
b_id: '1900000000000000001',
b_scope_type: 'module',
b_scope_id: 'demo',
b_hook: 'delete.pre',
b_predicate: JSON.stringify({
kind: 'dsl',
expr: {
type: 'group',
children: [
{ type: 'condition', field: 'b_name', operator: 'like', value: 'a', join: 'and' },
],
},
}),
b_message: null,
b_canuse: 1,
b_xh: 10,
},
{
b_id: '1900000000000000002',
b_scope_type: 'relation',
b_scope_id: 'demo|b_name|other|b_name',
b_hook: 'delete.pre',
b_predicate: null,
b_message: null,
b_canuse: 1,
b_xh: 10,
},
]
const wrapper = await mountManagement()
await flushPromises()
wrapper.vm.moduleSelectedId = 'demo'
await flushPromises()
await flushPromises()
// 模块级规则进 rules,关系级进 refRules;打开即干净
expect(wrapper.vm.rules).toHaveLength(1)
expect(wrapper.vm.refRules).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_rule')
expect(ruleReq, '规则变更应提交 s_rule').toBeTruthy()
expect(ruleReq.key_field).toBe('b_id')
expect(ruleReq.updates?.[0]).toMatchObject({
b_id: '1900000000000000001',
b_scope_type: 'module',
b_scope_id: 'demo',
b_canuse: 0,
})
// 关系未变:不产生 s_relation 请求
expect(savedRequests.some((request) => request.table === 's_relation')).toBe(false)
expect(wrapper.vm.hasUnsavedChanges()).toBe(false)
})
it('关联面板勾选引用保护:保存时新增一条 relation 范围的规则', async () => {
db.s_relation = [relationRow()]
const wrapper = await mountManagement()
await flushPromises()
wrapper.vm.moduleSelectedId = 'demo'
await flushPromises()
await flushPromises()
expect(wrapper.vm.refRules).toEqual([])
wrapper.vm.refRules = [createRefRuleRow('demo|b_name|other|b_name', '1900000000000000003')]
expect(wrapper.vm.hasUnsavedChanges()).toBe(true)
await wrapper.vm.handleSave()
await settleConfigReload(wrapper)
const refReq = savedRequests.find((request) => request.table === 's_rule')
expect(refReq?.key_field).toBe('b_id')
expect(refReq?.inserts?.[0]).toMatchObject({
b_id: '1900000000000000003',
b_scope_type: 'relation',
b_scope_id: 'demo|b_name|other|b_name',
b_predicate: null,
b_hook: 'delete.pre',
})
expect(wrapper.vm.hasUnsavedChanges()).toBe(false)
})
it('删除模块应一并清理模块级与关系级删除规则(s_rule)', async () => {
db.s_relation = [relationRow()]
db.s_rule = [
{
b_id: '1900000000000000001',
b_scope_type: 'module',
b_scope_id: 'demo',
b_hook: 'delete.pre',
b_predicate: 'select 1 as b_id',
},
{
b_id: '1900000000000000002',
b_scope_type: 'relation',
b_scope_id: 'demo|b_name|other|b_name',
b_hook: 'delete.pre',
b_predicate: null,
},
]
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_rule')
const moduleReq = ruleReqs.find(
(request) => request.deletes?.[0]?.b_scope_type === 'module' && request.deletes.length === 1,
)
expect(moduleReq?.deletes).toEqual([{ b_scope_type: 'module', b_scope_id: 'demo' }])
const relationReq = ruleReqs.find((request) =>
request.deletes?.some((row) => row.b_scope_type === 'relation'),
)
expect(relationReq?.deletes).toEqual([
{ b_scope_type: 'relation', b_scope_id: 'demo|b_name|other|b_name' },
])
})
})