20260917165839

This commit is contained in:
oneao committed 2026-09-17 16:58:40 +08:00
1 parent 182ed86966
commit 38e1e66c3b
108 files changed
+2497 -1215

No files matched your search

@@ -0,0 +1,370 @@
/**
* 回归用例:模块管理 - 修改基础信息 → 保存 → 切到其它树节点仍提示「未保存」。
*
* 成因:查询配置面板(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'
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_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) => {
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)
})
})
@@ -167,6 +167,24 @@ describe('往来单位:删除分类的两条守卫', () => {
expect(saveObjectApi).not.toHaveBeenCalled()
})
it('守卫查询带 order_by:/data/page 缺排序字段后端直接报「order_by 不能为空」', async () => {
const wrapper = await mountOthercompany()
await wrapper.vm.onCategoryDelete({ b_id: 'child1' })
expect(pageDataApi).toHaveBeenCalledTimes(1)
expect(pageDataApi.mock.calls[0][0]).toMatchObject({
viewName: 'v_b_othercompany',
orderBy: 'b_id ASC',
searchCondition: "[b_cate_id] = 'child1'",
searchColumns: ['b_id'],
pageNo: 1,
pageSize: 1,
})
// 带上排序字段后守卫能正常通过,不再走错误分支
expect(Message.error).not.toHaveBeenCalled()
})
it('分类下还有往来单位时拦下,不发删除请求', async () => {
const wrapper = await mountOthercompany()
pageDataApi.mockResolvedValueOnce({ data: { rows: [{ b_id: 'OC001' }], total: 1 } })