20260919211702
This commit is contained in:
1 parent
f56e70b050
commit
f4bfc1ddfc
97 files changed
+6059
-1712
No files matched your search
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
saveObjectApi: vi.fn(),
|
||||
}))
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: () => ({ userInfo: { account: 'u001' } }),
|
||||
}))
|
||||
|
||||
const {
|
||||
PREF_SCHEMA_TYPES,
|
||||
columnSettingsDelta,
|
||||
isEmptyColumnDelta,
|
||||
mergeColumnPref,
|
||||
sameColumnSettings,
|
||||
} = await import('@/components/fms-module-list/useModulePref')
|
||||
|
||||
/** 通用栏位行:字段 + 宽度,数组顺序即通用顺序 */
|
||||
const base = [
|
||||
{ b_field: 'b_id', b_width: 120, b_visible: 1 },
|
||||
{ b_field: 'b_name', b_width: 200, b_visible: 1 },
|
||||
{ b_field: 'b_amount', b_width: 160, b_visible: 1 },
|
||||
]
|
||||
|
||||
/** 表格当前栏位设置(getColumnSettings 的形态) */
|
||||
const current = (...pairs) => pairs.map(([b_field, b_width]) => ({ b_field, b_width }))
|
||||
|
||||
describe('栏位设置:个人层合并', () => {
|
||||
it('无个人偏好时原样返回通用栏位', () => {
|
||||
expect(mergeColumnPref(base, null)).toBe(base)
|
||||
})
|
||||
|
||||
it('只覆盖宽度,保留通用行上的其它属性(b_visible 等)', () => {
|
||||
const merged = mergeColumnPref(base, { columns: { b_name: { width: 300 } } })
|
||||
const name = merged.find((row) => row.b_field === 'b_name')
|
||||
expect(name.b_width).toBe(300)
|
||||
expect(name.b_visible).toBe(1)
|
||||
// 未覆盖的字段保持通用宽度
|
||||
expect(merged.find((row) => row.b_field === 'b_id').b_width).toBe(120)
|
||||
})
|
||||
|
||||
it('按 order 重排并重编 b_xh(10/20/30)', () => {
|
||||
const merged = mergeColumnPref(base, { order: ['b_amount', 'b_id', 'b_name'] })
|
||||
expect(merged.map((row) => row.b_field)).toEqual(['b_amount', 'b_id', 'b_name'])
|
||||
expect(merged.map((row) => row.b_xh)).toEqual([10, 20, 30])
|
||||
})
|
||||
|
||||
it('order 未列出的字段保持原有相对顺序、追加在后', () => {
|
||||
const merged = mergeColumnPref(base, { order: ['b_amount'] })
|
||||
expect(merged.map((row) => row.b_field)).toEqual(['b_amount', 'b_id', 'b_name'])
|
||||
})
|
||||
|
||||
it('个人层不得新增字段:order 里的未知字段一律忽略', () => {
|
||||
const merged = mergeColumnPref(base, { order: ['b_hack', 'b_id'] })
|
||||
expect(merged.map((row) => row.b_field)).toEqual(['b_id', 'b_name', 'b_amount'])
|
||||
})
|
||||
|
||||
it('个人层不得给未知字段设宽度', () => {
|
||||
const merged = mergeColumnPref(base, { columns: { b_hack: { width: 999 } } })
|
||||
expect(merged.some((row) => row.b_field === 'b_hack')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位设置:增量计算', () => {
|
||||
it('完全一致时增量为空且不含 order(不把通用顺序固化)', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_id', 120], ['b_name', 200], ['b_amount', 160]),
|
||||
base,
|
||||
)
|
||||
expect(delta.columns).toEqual({})
|
||||
expect(delta.order).toBeUndefined()
|
||||
expect(isEmptyColumnDelta(delta)).toBe(true)
|
||||
})
|
||||
|
||||
it('只记改过的宽度,同宽字段不入增量(通用改宽仍能透传)', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_id', 120], ['b_name', 320], ['b_amount', 160]),
|
||||
base,
|
||||
)
|
||||
expect(delta.columns).toEqual({ b_name: { width: 320 } })
|
||||
expect(delta.order).toBeUndefined()
|
||||
})
|
||||
|
||||
it('顺序变化时整体记 order', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_amount', 160], ['b_id', 120], ['b_name', 200]),
|
||||
base,
|
||||
)
|
||||
expect(delta.order).toEqual(['b_amount', 'b_id', 'b_name'])
|
||||
expect(isEmptyColumnDelta(delta)).toBe(false)
|
||||
})
|
||||
|
||||
it('宽度与顺序都变了,两部分都记', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_name', 320], ['b_id', 120], ['b_amount', 160]),
|
||||
base,
|
||||
)
|
||||
expect(delta.columns).toEqual({ b_name: { width: 320 } })
|
||||
expect(delta.order).toEqual(['b_name', 'b_id', 'b_amount'])
|
||||
})
|
||||
|
||||
it('未知字段不产生宽度增量', () => {
|
||||
const delta = columnSettingsDelta(
|
||||
current(['b_id', 120], ['b_name', 200], ['b_hack', 300]),
|
||||
base,
|
||||
)
|
||||
expect(delta.columns).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位设置:与基线比较', () => {
|
||||
it('宽度相同且顺序相同 → 未调整', () => {
|
||||
expect(
|
||||
sameColumnSettings(current(['b_id', 120], ['b_name', 200], ['b_amount', 160]), base),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('宽度不同 → 已调整', () => {
|
||||
expect(
|
||||
sameColumnSettings(current(['b_id', 120], ['b_name', 260], ['b_amount', 160]), base),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('顺序不同 → 已调整(即使宽度全同)', () => {
|
||||
expect(
|
||||
sameColumnSettings(current(['b_name', 200], ['b_id', 120], ['b_amount', 160]), base),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('数量不同 → 已调整', () => {
|
||||
expect(sameColumnSettings(current(['b_id', 120]), base)).toBe(false)
|
||||
})
|
||||
|
||||
it('当前宽度为 null 而通用有宽度 → 视为已调整', () => {
|
||||
expect(
|
||||
sameColumnSettings(current(['b_id', null], ['b_name', 200], ['b_amount', 160]), base),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('两侧宽度都是 null/未设置时视为一致', () => {
|
||||
const noWidth = [{ b_field: 'b_id', b_width: null }, { b_field: 'b_name' }]
|
||||
expect(sameColumnSettings(current(['b_id', null], ['b_name', undefined]), noWidth)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('栏位设置:schema 类型常量', () => {
|
||||
it('个人层栏位用 view,与查询布局的 query 区分开', () => {
|
||||
expect(PREF_SCHEMA_TYPES.view).toBe('view')
|
||||
expect(PREF_SCHEMA_TYPES.query).toBe('query')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* 栏位设置(列宽/顺序)冒烟测试:模拟真实操作链路。
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. 未改动 → 操作条不出现(不占内容区);
|
||||
* 2. 拖列宽 → 操作条出现,撤销后消失且不落库;
|
||||
* 3. 保存为个人 → 只写 s_user_module_pref(b_schema_type='view'),且只存相对通用的增量;
|
||||
* 4. 保存为通用 → 写 s_module_schema(且只对管理员可见按钮);
|
||||
* 5. 个人层加载 → 合并进列配置,且不得引入通用没有的字段。
|
||||
*/
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||||
|
||||
registerFeature(useAreaSelection)
|
||||
|
||||
// vi.mock 工厂被提升到 import 之上,工厂内引用的变量必须同样提升
|
||||
const EMPTY = vi.hoisted(() => Object.freeze({}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => ({ path: '/module/i18n-type', meta: EMPTY }),
|
||||
}))
|
||||
|
||||
vi.mock('@/services/exportService', () => ({
|
||||
exportListApi: vi.fn(async () => 'data.xlsx'),
|
||||
loadTotalsApi: vi.fn(async () => ({ data: EMPTY })),
|
||||
countExportApi: vi.fn(async () => ({ data: EMPTY })),
|
||||
}))
|
||||
|
||||
/** 通用栏位:b_id 120 / b_name 180。测试按需注入个人层偏好。 */
|
||||
const GLOBAL_VIEW = {
|
||||
schemaVersion: 1,
|
||||
columns: [
|
||||
{ field: 'b_id', width: 120 },
|
||||
{ field: 'b_name', width: 180 },
|
||||
],
|
||||
}
|
||||
|
||||
let personalColumnPref = null
|
||||
let currentAccount = 'u001'
|
||||
|
||||
const loadDataApi = vi.fn(async (viewName) => {
|
||||
if (viewName === 's_module') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
b_id: 's_i18n_type',
|
||||
b_module_type: 'data',
|
||||
b_canuse: 1,
|
||||
b_view_table: 's_i18n_type',
|
||||
b_save_table: 's_i18n_type',
|
||||
b_key_field: 'b_id',
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_field') {
|
||||
return {
|
||||
data: [
|
||||
{ b_field: 'b_id', b_name: '语言编码', b_type: 'input', b_canuse: 1, b_i18n: null },
|
||||
{ b_field: 'b_name', b_name: '语言名称', b_type: 'input', b_canuse: 1, b_i18n: null },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_module_schema') {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'view',
|
||||
b_schema_json: JSON.stringify(GLOBAL_VIEW),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (viewName === 's_user_module_pref') {
|
||||
if (!personalColumnPref) return { data: [] }
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
b_user_id: currentAccount,
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'view',
|
||||
b_schema_json: JSON.stringify(personalColumnPref),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return { data: [] }
|
||||
})
|
||||
|
||||
const saveObjectApi = vi.fn(async () => ({}))
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: (...args) => loadDataApi(...args),
|
||||
saveObjectApi: (...args) => saveObjectApi(...args),
|
||||
pageDataApi: vi.fn(async () => ({ data: { rows: [{ b_id: 'zh-CN' }], total: 1 } })),
|
||||
nextIdApi: vi.fn(async () => ({ data: '1' })),
|
||||
loadDataBySqlApi: vi.fn(async () => ({ data: [] })),
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/auth', async () => {
|
||||
const actual = await vi.importActual('@/stores/auth')
|
||||
return {
|
||||
...actual,
|
||||
useAuthStore: () => ({
|
||||
userInfo: { account: currentAccount },
|
||||
loginInfo: { user: { account: currentAccount, b_id: currentAccount } },
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// 权限 store 决定「保存为通用」按钮是否渲染:默认按非管理员(普通用户)跑
|
||||
let isG3soft = false
|
||||
vi.mock('@/stores/permissions', () => ({
|
||||
usePermissionStore: () => ({
|
||||
get isG3soft() {
|
||||
return isG3soft
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import FmsModuleListPage from '@/components/fms-module-list/FmsModuleListPage.vue'
|
||||
import FmsModuleTable from '@/components/fms-module-list/FmsModuleTable.vue'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
loadDataApi.mockClear()
|
||||
saveObjectApi.mockClear()
|
||||
personalColumnPref = null
|
||||
currentAccount = 'u001'
|
||||
isG3soft = false
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
|
||||
configurable: true,
|
||||
get: () => 5000,
|
||||
})
|
||||
})
|
||||
|
||||
async function mountPage() {
|
||||
const wrapper = mount(FmsModuleListPage, {
|
||||
props: { dataCode: 's_i18n_type' },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟用户拖拽列宽:FmsTable 收到后会 emit('update:columns', 渲染列数组),
|
||||
* 其中每项保留 dataIndex/key,只有 width 变化——按这个真实形态构造,别只传 b_field/b_width,
|
||||
* 否则 FmsTable.onColumnsUpdate 按 key/dataIndex 匹配不到原始列,回传结果会变成空数组。
|
||||
*/
|
||||
async function resizeColumn(wrapper, dataIndex, width) {
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
const fmsTable = table.findComponent(FmsTable)
|
||||
const rendered = fmsTable
|
||||
.props('columns')
|
||||
.map((column) => (column.dataIndex === dataIndex ? { ...column, width } : { ...column }))
|
||||
fmsTable.vm.$emit('update:columns', rendered)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** 操作条内的按钮 */
|
||||
const barButton = (wrapper, text) =>
|
||||
wrapper.findAll('.list-column-bar button').find((button) => button.text().trim() === text)
|
||||
|
||||
describe('栏位设置冒烟', () => {
|
||||
it('未改动时不出现操作条,也不渲染栏位相关按钮', async () => {
|
||||
const wrapper = await mountPage()
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('下拉菜单不再包含栏位相关项(已迁到操作条)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
const menuText = wrapper.find('.list-toolbar__group--tools').text()
|
||||
expect(menuText).not.toContain('恢复栏位设置')
|
||||
expect(menuText).not.toContain('保存栏位设置')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('拖列宽后操作条出现;撤销后消失且不落库', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(true)
|
||||
expect(wrapper.find('.list-column-bar').text()).toContain('列宽或顺序已调整')
|
||||
|
||||
await barButton(wrapper, '撤销').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
expect(saveObjectApi).not.toHaveBeenCalled()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('普通用户看不到「保存为通用」,只看到个人与撤销', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
const bar = wrapper.find('.list-column-bar')
|
||||
expect(bar.text()).toContain('撤销')
|
||||
expect(bar.text()).toContain('保存为个人')
|
||||
expect(bar.text()).not.toContain('保存为通用')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('管理员能看到「保存为通用」', async () => {
|
||||
isG3soft = true
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
expect(wrapper.find('.list-column-bar').text()).toContain('保存为通用')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保存为个人:只写 s_user_module_pref,且只存改过的字段宽度', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveObjectApi).toHaveBeenCalledTimes(1)
|
||||
const requests = saveObjectApi.mock.calls[0][0]
|
||||
expect(requests).toHaveLength(1)
|
||||
const req = requests[0]
|
||||
expect(req.table).toBe('s_user_module_pref')
|
||||
expect(req.key_field).toBe('b_user_id,b_module_id,b_schema_type')
|
||||
|
||||
const row = req.inserts[0]
|
||||
expect(row.b_user_id).toBe('u001')
|
||||
expect(row.b_module_id).toBe('s_i18n_type')
|
||||
expect(row.b_schema_type).toBe('view')
|
||||
const delta = JSON.parse(row.b_schema_json)
|
||||
// 只有 b_name 改了宽度 → 增量里只应出现它,通用改 b_id 仍能透传
|
||||
expect(delta.columns).toEqual({ b_name: { width: 320 } })
|
||||
// 顺序未变 → 不应写 order(否则会把通用顺序固化)
|
||||
expect(delta.order).toBeUndefined()
|
||||
|
||||
// 保存成功后操作条消失(已成本新基线)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保存为个人不碰 s_module_schema(不污染通用)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
const tables = saveObjectApi.mock.calls.flatMap((call) => call[0].map((r) => r.table))
|
||||
expect(tables).not.toContain('s_module_schema')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保存为通用:写 s_module_schema 并回写宽度', async () => {
|
||||
isG3soft = true
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
await barButton(wrapper, '保存为通用').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const requests = saveObjectApi.mock.calls.at(-1)[0]
|
||||
const req = requests.find((r) => r.table === 's_module_schema')
|
||||
expect(req).toBeTruthy()
|
||||
expect(req.key_field).toBe('b_module_id,b_schema_type')
|
||||
const patch = req.updates ? req.updates[0] : req.inserts[0]
|
||||
expect(patch.b_schema_type).toBe('view')
|
||||
const schema = JSON.parse(patch.b_schema_json)
|
||||
const nameNode = schema.columns.find((node) => node.field === 'b_name')
|
||||
expect(nameNode.width).toBe(320)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('个人层加载:合并进列配置(宽度生效)', async () => {
|
||||
personalColumnPref = { schemaVersion: 1, columns: { b_name: { width: 260 } } }
|
||||
const wrapper = await mountPage()
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
const nameConfig = table.props('listConfig').find((item) => item.b_field === 'b_name')
|
||||
expect(nameConfig.b_width).toBe(260)
|
||||
// 已按个人层加载 → 与基线一致 → 不应立即显示「已调整」
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('个人层不得引入通用没有的字段(越权字段被忽略)', async () => {
|
||||
personalColumnPref = {
|
||||
schemaVersion: 1,
|
||||
columns: { b_hack: { width: 999 } },
|
||||
order: ['b_hack', 'b_id'],
|
||||
}
|
||||
const wrapper = await mountPage()
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
const fields = table.props('listConfig').map((item) => item.b_field)
|
||||
expect(fields).not.toContain('b_hack')
|
||||
expect(fields).toEqual(['b_id', 'b_name'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('个人层顺序生效,且不误报为「已调整」', async () => {
|
||||
personalColumnPref = { schemaVersion: 1, order: ['b_name', 'b_id'] }
|
||||
const wrapper = await mountPage()
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
expect(table.props('listConfig').map((item) => item.b_field)).toEqual(['b_name', 'b_id'])
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保留个人设置再次保存时走 updates 而非 inserts(避免主键冲突)', async () => {
|
||||
personalColumnPref = { schemaVersion: 1, columns: { b_name: { width: 260 } } }
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 300)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const req = saveObjectApi.mock.calls.at(-1)[0].find((r) => r.table === 's_user_module_pref')
|
||||
expect(req.updates).toBeTruthy()
|
||||
expect(req.inserts).toBeUndefined()
|
||||
// 增量是相对通用(180),不是相对个人上一次(260)
|
||||
expect(JSON.parse(req.updates[0].b_schema_json).columns).toEqual({ b_name: { width: 300 } })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('连续改动只累积一次落库,保存后操作条消失', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_id', 140)
|
||||
await resizeColumn(wrapper, 'b_id', 150)
|
||||
await resizeColumn(wrapper, 'b_name', 260)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(true)
|
||||
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveObjectApi).toHaveBeenCalledTimes(1)
|
||||
const delta = JSON.parse(saveObjectApi.mock.calls[0][0][0].inserts[0].b_schema_json)
|
||||
// 只记最终值(150 / 260),中间态不入增量
|
||||
expect(delta.columns).toEqual({ b_id: { width: 150 }, b_name: { width: 260 } })
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('保存后再次改动会重新出现操作条(基线已前移)', async () => {
|
||||
const wrapper = await mountPage()
|
||||
await resizeColumn(wrapper, 'b_name', 320)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
|
||||
// 保存后把宽度改回通用值 → 与「新基线 320」不同,仍应提示
|
||||
await resizeColumn(wrapper, 'b_name', 180)
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('改回通用值后保存:清空个人层而非留下空壳覆盖', async () => {
|
||||
personalColumnPref = { schemaVersion: 1, columns: { b_name: { width: 260 } } }
|
||||
const wrapper = await mountPage()
|
||||
// 改回与通用一致(b_name 通用宽度 180)
|
||||
await resizeColumn(wrapper, 'b_name', 180)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const req = saveObjectApi.mock.calls.at(-1)[0].find((r) => r.table === 's_user_module_pref')
|
||||
// 无增量可存 → 走删除,不写空 columns
|
||||
expect(req.deletes).toBeTruthy()
|
||||
expect(req.inserts).toBeUndefined()
|
||||
expect(req.updates).toBeUndefined()
|
||||
expect(req.deletes[0]).toMatchObject({
|
||||
b_user_id: 'u001',
|
||||
b_module_id: 's_i18n_type',
|
||||
b_schema_type: 'view',
|
||||
})
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('已有个人设置但本次改动恰好等于通用时,也走删除清理', async () => {
|
||||
// 个人层只改了顺序,本次把顺序也拖回通用顺序
|
||||
personalColumnPref = { schemaVersion: 1, order: ['b_name', 'b_id'] }
|
||||
const wrapper = await mountPage()
|
||||
const table = wrapper.findComponent(FmsModuleTable)
|
||||
const fmsTable = table.findComponent(FmsTable)
|
||||
// 拖回通用顺序 b_id, b_name
|
||||
const reordered = fmsTable.props('columns').toReversed()
|
||||
fmsTable.vm.$emit('update:columns', reordered)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.list-column-bar').exists()).toBe(true)
|
||||
await barButton(wrapper, '保存为个人').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const req = saveObjectApi.mock.calls.at(-1)[0].find((r) => r.table === 's_user_module_pref')
|
||||
expect(req.deletes).toBeTruthy()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -2,12 +2,13 @@
|
||||
* 模块关联面板(s_relation):
|
||||
* - 加载回填行只补本地行键(_rowKey),不得改动业务列——否则打开模块即提示「未保存」;
|
||||
* - 新增关联:追加「源模块 = 当前模块」的空行,主键四列留待用户填写;
|
||||
* 新建关系默认关联删除处理为 restrict(设计 §3.1);
|
||||
* - 换目标模块:原目标字段必然失效,需清空并按目标模块加载字段选项;
|
||||
* - 保存前校验:四列非空、关系类型合法、四列组合不重复(空列会撞行身份,落库即主键冲突)。
|
||||
* - 关联删除处理三值对多对一同样开放:类型切成多对一时不得重置 b_on_delete(设计 §3.2);
|
||||
* - 保存前校验:四列非空、关系类型合法、删除处置合法、四列组合不重复。
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import ModuleRelationPanel from '@/views/module/module-management/ModuleRelationPanel.vue'
|
||||
@@ -51,13 +52,13 @@ const sourceRow = {
|
||||
b_target_module_id: 'order_item',
|
||||
b_target_field: 'b_order_id',
|
||||
b_relation_type: 'one_to_many',
|
||||
b_on_delete: 'restrict',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
}
|
||||
|
||||
async function mountPanel(modelValue = [], { refRules = [] } = {}) {
|
||||
async function mountPanel(modelValue = []) {
|
||||
const pinia = createPinia()
|
||||
pinia.install({ provide() {}, config: { globalProperties: {} } })
|
||||
setActivePinia(pinia)
|
||||
const wrapper = mount(ModuleRelationPanel, {
|
||||
props: {
|
||||
@@ -67,7 +68,6 @@ async function mountPanel(modelValue = [], { refRules = [] } = {}) {
|
||||
reverseRows: [],
|
||||
loading: false,
|
||||
modelValue,
|
||||
refRules,
|
||||
},
|
||||
global: { plugins: [pinia] },
|
||||
attachTo: document.body,
|
||||
@@ -82,23 +82,6 @@ const lastRelations = (wrapper) => {
|
||||
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()
|
||||
@@ -119,7 +102,7 @@ describe('模块关联面板', () => {
|
||||
expect(stripInternalKeys(next[0])).toEqual(sourceRow)
|
||||
})
|
||||
|
||||
it('新增关联:追加源模块为当前模块的空行并进入编辑', async () => {
|
||||
it('新增关联:追加源模块为当前模块的空行,默认关联删除处理为 restrict', async () => {
|
||||
const wrapper = await mountPanel([{ ...sourceRow }])
|
||||
await wrapper.vm.addRow()
|
||||
await nextTick()
|
||||
@@ -132,6 +115,7 @@ describe('模块关联面板', () => {
|
||||
b_target_module_id: null,
|
||||
b_target_field: null,
|
||||
b_relation_type: 'many_to_one',
|
||||
b_on_delete: 'restrict',
|
||||
b_canuse: 1,
|
||||
b_xh: 20,
|
||||
})
|
||||
@@ -216,88 +200,31 @@ describe('模块关联面板', () => {
|
||||
expect(wrapper.vm.selectedRowKeys).toEqual([])
|
||||
})
|
||||
|
||||
it('删除连带:加载回填不置脏,多对一自动清回不连带', async () => {
|
||||
const cascadeRow = { ...sourceRow, b_on_delete: 'cascade' }
|
||||
it('关联删除处理:加载回填不置脏,多对一不再被重置(允许 cascade)', async () => {
|
||||
const cascadeRow = { ...sourceRow, b_relation_type: 'many_to_one', b_on_delete: 'cascade' }
|
||||
// 传入副本:面板会往行对象上补 _ 前缀派生列
|
||||
const wrapper = await mountPanel([{ ...cascadeRow }])
|
||||
const row = lastRelations(wrapper)[0]
|
||||
expect(stripInternalKeys(row)).toEqual(cascadeRow)
|
||||
|
||||
// 类型改成多对一:连带策略不再成立,自动清回 none 并提示
|
||||
// 类型在多对一与一对多之间切换:处置值必须原样保留,不再被硬编码清回 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(
|
||||
'多对一关系不支持连带删除/连带归档,已重置为不连带',
|
||||
)
|
||||
expect(row.b_on_delete).toBe('cascade')
|
||||
expect(Message.warning).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('校验:多对一上配连带删除/归档被拦,取值非法被拦', async () => {
|
||||
it('校验:多对一配 cascade 合法,仅取值非法被拦', 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)
|
||||
// 多对一 + cascade 是允许的场景(如「明细 → 主单」删主单级联删明细)
|
||||
expect(errors.some((item) => item.includes('第 1 行'))).toBe(false)
|
||||
expect(errors).toContain('第 2 行:关联删除处理取值不合法')
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,15 @@
|
||||
/**
|
||||
* 删除规则编辑弹窗(ModuleRuleEditModal):
|
||||
* - 勾条件 → predicate 存 {"kind":"dsl","expr":AST};写 SQL → predicate 存文本;
|
||||
* - 两种类型各自的必填校验(无条件 / 缺提示文案)不通过时不提交;
|
||||
* - 打开时按 predicate 三态回显类型与内容;标题按新增/编辑切换。
|
||||
* 删除 SQL 规则编辑弹窗(ModuleRuleEditModal):
|
||||
* - 只编辑 b_sql + b_message:SQL 必填、必须只读、必须含 :ids;提示文案必填;
|
||||
* - 校验不通过时不提交(不 emit ok);
|
||||
* - 打开时按当前行回显 SQL 与文案;标题按新增/编辑切换。
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import ModuleRuleEditModal from '@/views/module/module-management/ModuleRuleEditModal.vue'
|
||||
import { buildRuleQueryItems } from '@/views/module/module-management/ruleUtils'
|
||||
import { IDS_PLACEHOLDER } from '@/views/module/module-management/ruleUtils'
|
||||
|
||||
vi.mock('@/services/api', () => ({ loadDataApi: vi.fn() }))
|
||||
|
||||
@@ -28,32 +27,21 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const ITEMS = buildRuleQueryItems(
|
||||
[
|
||||
{ b_field: 'b_status', b_name: '状态', b_type: 'select', b_canuse: 1 },
|
||||
{ b_field: 'b_no', b_name: '单号', b_type: 'input', b_canuse: 1 },
|
||||
],
|
||||
{},
|
||||
)
|
||||
|
||||
const ruleRow = () => ({
|
||||
_rowKey: '-1',
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'module',
|
||||
b_scope_id: 'order',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: null,
|
||||
b_message: null,
|
||||
b_module_id: 'order',
|
||||
b_sql: `select b_id from bill where b_id in (${IDS_PLACEHOLDER})`,
|
||||
b_message: '单据已销账,不能删除',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
})
|
||||
|
||||
async function mountModal(row = ruleRow(), isNew = false) {
|
||||
const pinia = createPinia()
|
||||
pinia.install({ provide() {}, config: { globalProperties: {} } })
|
||||
setActivePinia(pinia)
|
||||
const wrapper = mount(ModuleRuleEditModal, {
|
||||
props: { open: true, row, isNew, items: ITEMS },
|
||||
props: { open: true, row, isNew },
|
||||
global: { plugins: [pinia] },
|
||||
attachTo: document.body,
|
||||
})
|
||||
@@ -66,57 +54,9 @@ beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('删除规则编辑弹窗', () => {
|
||||
it('勾条件:没有条件时不提交,选了条件后按 AST 组装 predicate', async () => {
|
||||
const row = ruleRow()
|
||||
const wrapper = await mountModal(row)
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.conditionError).toBe('勾条件规则至少要有一个条件')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
|
||||
wrapper.vm.conditionState = {
|
||||
nodes: [{ id: 'n1', at: 1, join: 'and', field: 'b_status', operator: 'eq', value: 'audit' }],
|
||||
groups: [],
|
||||
}
|
||||
await nextTick()
|
||||
expect(wrapper.vm.conditionSummary).toBe('状态 等于 audit')
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
const saved = wrapper.emitted('ok')?.[0]?.[0]
|
||||
expect(saved.b_hook).toBe('delete.pre')
|
||||
const predicate = JSON.parse(saved.b_predicate)
|
||||
expect(predicate.kind).toBe('dsl')
|
||||
expect(predicate.expr.children[0]).toMatchObject({
|
||||
type: 'condition',
|
||||
field: 'b_status',
|
||||
operator: 'eq',
|
||||
value: 'audit',
|
||||
})
|
||||
expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('写 SQL:缺提示文案不提交,填好后原样存文本', async () => {
|
||||
const row = ruleRow()
|
||||
const wrapper = await mountModal(row)
|
||||
wrapper.vm.ruleType = 'sql'
|
||||
wrapper.vm.sqlText = 'select b_id from bill where b_id in (:ids) and b_settled = 1'
|
||||
await nextTick()
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.sqlError).toBe('写 SQL 的规则必须填写提示文案')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
|
||||
row.b_message = '单据已销账,不能删除'
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.emitted('ok')?.[0]?.[0].b_predicate).toBe(
|
||||
'select b_id from bill where b_id in (:ids) and b_settled = 1',
|
||||
)
|
||||
})
|
||||
|
||||
it('写 SQL:SQL 为空时不提交', async () => {
|
||||
const wrapper = await mountModal(ruleRow())
|
||||
wrapper.vm.ruleType = 'sql'
|
||||
describe('删除 SQL 规则编辑弹窗', () => {
|
||||
it('SQL 为空时不提交', async () => {
|
||||
const wrapper = await mountModal()
|
||||
wrapper.vm.sqlText = ' '
|
||||
await nextTick()
|
||||
|
||||
@@ -125,15 +65,59 @@ describe('删除规则编辑弹窗', () => {
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('回显与标题:SQL 类型直接进 SQL 分支;新增/编辑标题不同', async () => {
|
||||
const row = { ...ruleRow(), b_predicate: 'select 1 as b_id' }
|
||||
const wrapper = await mountModal(row, true)
|
||||
it('SQL 非只读时不提交', async () => {
|
||||
const wrapper = await mountModal()
|
||||
wrapper.vm.sqlText = `delete from bill where b_id in (${IDS_PLACEHOLDER})`
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.ruleType).toBe('sql')
|
||||
expect(wrapper.vm.sqlText).toBe('select 1 as b_id')
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.sqlError).toBe('规则 SQL 必须是只读查询(以 select 或 with 开头)')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('SQL 缺少 :ids 标记时不提交', async () => {
|
||||
const wrapper = await mountModal()
|
||||
wrapper.vm.sqlText = 'select b_id from bill'
|
||||
await nextTick()
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.sqlError).toBe('规则 SQL 必须包含 :ids 标记,用于限定本次待删记录')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('缺提示文案时不提交,填好后原样存 SQL 与文案', async () => {
|
||||
const row = { ...ruleRow(), b_message: '' }
|
||||
const wrapper = await mountModal(row)
|
||||
wrapper.vm.sqlText = `select b_id from bill where b_id in (${IDS_PLACEHOLDER}) and b_settled = 1`
|
||||
await nextTick()
|
||||
|
||||
await wrapper.vm.onOk()
|
||||
expect(wrapper.vm.messageError).toBe('请填写提示文案')
|
||||
expect(wrapper.emitted('ok')).toBeFalsy()
|
||||
|
||||
wrapper.vm.messageText = '单据已销账,不能删除'
|
||||
await nextTick()
|
||||
await wrapper.vm.onOk()
|
||||
|
||||
const saved = wrapper.emitted('ok')?.[0]?.[0]
|
||||
expect(saved.b_sql).toBe(
|
||||
`select b_id from bill where b_id in (${IDS_PLACEHOLDER}) and b_settled = 1`,
|
||||
)
|
||||
expect(saved.b_message).toBe('单据已销账,不能删除')
|
||||
// 旧字段不再写入
|
||||
expect(saved.b_predicate).toBeUndefined()
|
||||
expect(saved.b_hook).toBeUndefined()
|
||||
expect(wrapper.emitted('update:open')?.at(-1)).toEqual([false])
|
||||
})
|
||||
|
||||
it('回显与标题:按当前行还原 SQL 与文案;新增/编辑标题不同', async () => {
|
||||
const wrapper = await mountModal({ ...ruleRow() }, true)
|
||||
|
||||
expect(wrapper.vm.sqlText).toBe(`select b_id from bill where b_id in (${IDS_PLACEHOLDER})`)
|
||||
expect(wrapper.vm.messageText).toBe('单据已销账,不能删除')
|
||||
expect(wrapper.vm.modalTitle).toBe('新增删除规则')
|
||||
|
||||
const editWrapper = await mountModal({ ...ruleRow() }, false)
|
||||
const editWrapper = await mountModal(ruleRow(), false)
|
||||
expect(editWrapper.vm.modalTitle).toBe('编辑删除规则')
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* 删除规则面板(module-management/ModuleRulePanel):
|
||||
* - 加载回填只补界面派生列(_rowKey / _typeText / _labelText),业务列不变(不置脏);
|
||||
* - 列表识别:勾条件显示条件中文摘要,写 SQL 显示提示文案;
|
||||
* - 保存前校验:勾条件要有条件、写 SQL 要有 SQL 与提示文案;
|
||||
* - 其他模块在本模块上配的引用规则只读展示,可跳转到源模块。
|
||||
* 删除 SQL 规则面板(module-management/ModuleRulePanel):
|
||||
* - 加载回填只补界面派生列(_rowKey / _labelText),业务列不变(不置脏);
|
||||
* - 列表识别:优先提示文案,缺文案退回 SQL 片段;
|
||||
* - 保存前校验:必须有主键、SQL 含 :ids 且为只读查询、必须填提示文案;
|
||||
* - 新增规则:取号后打开弹窗,确定时才落草稿。
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import ModuleRulePanel from '@/views/module/module-management/ModuleRulePanel.vue'
|
||||
import { stripInternalKeys } from '@/utils/dataChanges'
|
||||
import { IDS_PLACEHOLDER } from '@/views/module/module-management/ruleUtils'
|
||||
|
||||
vi.mock('@/services/api', () => ({
|
||||
loadDataApi: vi.fn(),
|
||||
@@ -33,47 +33,22 @@ beforeAll(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const FIELDS = [
|
||||
{ b_field: 'b_status', b_name: '状态', b_type: 'select', b_canuse: 1 },
|
||||
{ b_field: 'b_settled', b_name: '已销账', b_type: 'checkbox', b_canuse: 1 },
|
||||
]
|
||||
|
||||
const MODULES = [
|
||||
{ b_id: 'order', b_name: '订单', b_canuse: 1 },
|
||||
{ b_id: 'order_item', b_name: '订单明细', b_canuse: 1 },
|
||||
]
|
||||
|
||||
/** 库中已有的一条勾条件规则:状态等于 audit */
|
||||
const dslRow = {
|
||||
/** 库中已有的一条删除 SQL 规则 */
|
||||
const sqlRow = {
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'module',
|
||||
b_scope_id: 'order',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: JSON.stringify({
|
||||
kind: 'dsl',
|
||||
expr: {
|
||||
type: 'group',
|
||||
children: [
|
||||
{ type: 'condition', field: 'b_status', operator: 'eq', value: 'audit', join: 'and' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
b_message: null,
|
||||
b_module_id: 'order',
|
||||
b_sql: `select b_id from bill where b_id in (${IDS_PLACEHOLDER}) and b_settled = 1`,
|
||||
b_message: '单据已销账,不能删除',
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
}
|
||||
|
||||
async function mountPanel({ rules = [], refRulesReverse = [] } = {}) {
|
||||
async function mountPanel({ rules = [] } = {}) {
|
||||
const pinia = createPinia()
|
||||
pinia.install({ provide() {}, config: { globalProperties: {} } })
|
||||
setActivePinia(pinia)
|
||||
const wrapper = mount(ModuleRulePanel, {
|
||||
props: {
|
||||
moduleId: 'order',
|
||||
fields: FIELDS,
|
||||
querySchema: {},
|
||||
refRulesReverse,
|
||||
modules: MODULES,
|
||||
loading: false,
|
||||
modelValue: rules,
|
||||
},
|
||||
@@ -94,56 +69,47 @@ beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('删除规则面板', () => {
|
||||
describe('删除 SQL 规则面板', () => {
|
||||
it('加载回填只补界面派生列,业务列不变', async () => {
|
||||
const wrapper = await mountPanel({ rules: [{ ...dslRow }] })
|
||||
const wrapper = await mountPanel({ rules: [{ ...sqlRow }] })
|
||||
const next = lastRules(wrapper)
|
||||
|
||||
expect(next).toHaveLength(1)
|
||||
expect(next[0]._rowKey).toBeTruthy()
|
||||
expect(next[0]._typeText).toBe('条件')
|
||||
expect(next[0]._labelText).toBe('状态 等于 audit')
|
||||
expect(next[0]._labelText).toBe('单据已销账,不能删除')
|
||||
// 剥掉 _ 前缀的内部键后与库中行完全一致(不置脏)
|
||||
expect(stripInternalKeys(next[0])).toEqual(dslRow)
|
||||
expect(stripInternalKeys(next[0])).toEqual(sqlRow)
|
||||
})
|
||||
|
||||
it('识别文案:写 SQL 的规则用提示文案当列表文案', async () => {
|
||||
const sqlRow = {
|
||||
...dslRow,
|
||||
b_id: '1900000000000000002',
|
||||
b_predicate: 'select b_id from bill where b_settled = 1',
|
||||
b_message: '单据已销账,不能删除',
|
||||
}
|
||||
const wrapper = await mountPanel({ rules: [sqlRow] })
|
||||
it('识别文案:缺提示文案时退回 SQL 片段', async () => {
|
||||
const wrapper = await mountPanel({
|
||||
rules: [{ ...sqlRow, b_message: null }],
|
||||
})
|
||||
const next = lastRules(wrapper)
|
||||
|
||||
expect(next[0]._typeText).toBe('SQL')
|
||||
expect(next[0]._labelText).toBe('单据已销账,不能删除')
|
||||
expect(next[0]._labelText).toContain('select b_id from bill')
|
||||
})
|
||||
|
||||
it('校验:勾条件必须真有条件,写 SQL 必须有 SQL 与提示文案', async () => {
|
||||
const emptyDsl = {
|
||||
...dslRow,
|
||||
it('校验:主键、:ids 标记、只读查询、提示文案都要满足', async () => {
|
||||
const noId = { ...sqlRow, b_id: '' }
|
||||
const noPlaceholder = { ...sqlRow, b_id: '1900000000000000002', b_sql: 'select b_id from bill' }
|
||||
const notReadOnly = {
|
||||
...sqlRow,
|
||||
b_id: '1900000000000000003',
|
||||
b_predicate: JSON.stringify({ kind: 'dsl', expr: { type: 'group', children: [] } }),
|
||||
b_sql: `delete from bill where b_id in (${IDS_PLACEHOLDER})`,
|
||||
}
|
||||
const sqlWithoutMessage = {
|
||||
...dslRow,
|
||||
const noMessage = {
|
||||
...sqlRow,
|
||||
b_id: '1900000000000000004',
|
||||
b_predicate: 'select b_id from bill where b_id in (:ids)',
|
||||
b_message: '',
|
||||
}
|
||||
const wrapper = await mountPanel({ rules: [emptyDsl, sqlWithoutMessage] })
|
||||
const wrapper = await mountPanel({ rules: [noId, noPlaceholder, notReadOnly, noMessage] })
|
||||
const errors = wrapper.vm.validate()
|
||||
|
||||
expect(errors).toContain('第 1 条规则:至少配置一个判定条件')
|
||||
expect(errors).toContain('第 2 条规则:写 SQL 的规则必须填写提示文案')
|
||||
|
||||
// 引用规则不该出现在模块级列表里;没有主键的行也要拦住(新增取号失败的情况)
|
||||
const refWrapper = await mountPanel({ rules: [{ ...dslRow, b_predicate: null }] })
|
||||
expect(refWrapper.vm.validate()).toContain('第 1 条规则:引用规则请到「模块关联」面板维护')
|
||||
const noIdWrapper = await mountPanel({ rules: [{ ...dslRow, b_id: '' }] })
|
||||
expect(noIdWrapper.vm.validate()).toContain('第 1 条规则:缺少主键(请重新新增)')
|
||||
expect(errors).toContain('第 1 条规则:缺少主键(请重新新增)')
|
||||
expect(errors).toContain('第 2 条规则:规则 SQL 必须包含 :ids 标记')
|
||||
expect(errors).toContain('第 3 条规则:规则 SQL 必须是只读查询(以 select 或 with 开头)')
|
||||
expect(errors).toContain('第 4 条规则:请填写提示文案')
|
||||
})
|
||||
|
||||
it('新增规则:取号后打开弹窗,确定时才落草稿', async () => {
|
||||
@@ -153,39 +119,14 @@ describe('删除规则面板', () => {
|
||||
|
||||
expect(wrapper.vm.editingIsNew).toBe(true)
|
||||
expect(wrapper.vm.editingRow.b_id).toBe('1900000000000000009')
|
||||
expect(wrapper.vm.editingRow.b_module_id).toBe('order')
|
||||
expect(wrapper.vm.ruleModalOpen).toBe(true)
|
||||
// 未确定前不进列表
|
||||
expect(lastRules(wrapper)).toBe(null)
|
||||
|
||||
wrapper.vm.onRuleOk({ ...wrapper.vm.editingRow, b_predicate: 'select 1 as b_id' })
|
||||
wrapper.vm.onRuleOk({ ...wrapper.vm.editingRow, b_sql: 'select 1 as b_id' })
|
||||
await nextTick()
|
||||
expect(lastRules(wrapper)).toHaveLength(1)
|
||||
expect(lastRules(wrapper)[0].b_id).toBe('1900000000000000009')
|
||||
})
|
||||
|
||||
it('只读区:把引用规则还原成模块名/字段列,并支持跳转到源模块', async () => {
|
||||
const wrapper = await mountPanel({
|
||||
refRulesReverse: [
|
||||
{
|
||||
b_id: '1900000000000000005',
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: 'order_item|b_order_id|order|b_id',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: null,
|
||||
b_canuse: 1,
|
||||
b_xh: 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('其他模块在本模块上配置的引用规则')
|
||||
expect(wrapper.text()).toContain('订单明细(order_item)')
|
||||
expect(wrapper.text()).toContain('订单(order)')
|
||||
|
||||
const viewButton = wrapper.findAll('button').find((button) => button.text().includes('查看'))
|
||||
expect(viewButton, '只读区应有查看按钮').toBeTruthy()
|
||||
await viewButton.trigger('click')
|
||||
expect(wrapper.emitted('navigate')?.[0]).toEqual(['order_item'])
|
||||
})
|
||||
})
|
||||
@@ -1,156 +1,66 @@
|
||||
/**
|
||||
* 删除规则配置工具(module-management/ruleUtils):
|
||||
* 关系业务键拼串/还原、predicate 三态推断、识别文案、新增行构造、条件面板 items 构造。
|
||||
* 删除 SQL 规则配置工具(module-management/ruleUtils):
|
||||
* 新增行构造、列表识别文案、SQL 内容校验。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
REF_RULE_HINT,
|
||||
buildRuleQueryItems,
|
||||
createRefRuleRow,
|
||||
IDS_PLACEHOLDER,
|
||||
createRuleRow,
|
||||
isCompleteRelationKey,
|
||||
itemMapOf,
|
||||
parseRelationScopeId,
|
||||
parseRulePredicate,
|
||||
relationScopeId,
|
||||
ruleHasSql,
|
||||
ruleIsReadOnlySql,
|
||||
ruleLabel,
|
||||
ruleSummary,
|
||||
ruleTypeText,
|
||||
} from '@/views/module/module-management/ruleUtils'
|
||||
|
||||
const relation = {
|
||||
b_source_module_id: 'order',
|
||||
b_source_field: 'b_id',
|
||||
b_target_module_id: 'order_item',
|
||||
b_target_field: 'b_order_id',
|
||||
}
|
||||
|
||||
const fields = [
|
||||
{ b_field: 'b_status', b_name: '状态', b_type: 'select', b_canuse: 1 },
|
||||
{ b_field: 'b_amount', b_name: '金额', b_type: 'money', b_canuse: 1 },
|
||||
{ b_field: 'b_off', b_name: '停用字段', b_type: 'input', b_canuse: 0 },
|
||||
]
|
||||
|
||||
describe('删除规则工具', () => {
|
||||
it('关系业务键与四列互逆', () => {
|
||||
expect(relationScopeId(relation)).toBe('order|b_id|order_item|b_order_id')
|
||||
expect(parseRelationScopeId('order|b_id|order_item|b_order_id')).toEqual({
|
||||
sourceModuleId: 'order',
|
||||
sourceField: 'b_id',
|
||||
targetModuleId: 'order_item',
|
||||
targetField: 'b_order_id',
|
||||
})
|
||||
expect(isCompleteRelationKey(relation)).toBe(true)
|
||||
expect(isCompleteRelationKey({ ...relation, b_target_field: null })).toBe(false)
|
||||
})
|
||||
|
||||
it('predicate 三态:空=引用、JSON=勾条件、其余=SQL(非法 JSON 降级为 SQL)', () => {
|
||||
expect(parseRulePredicate({ b_predicate: null }).kind).toBe('reference')
|
||||
expect(parseRulePredicate({ b_predicate: ' ' }).kind).toBe('reference')
|
||||
|
||||
const dsl = parseRulePredicate({
|
||||
b_predicate: JSON.stringify({
|
||||
kind: 'dsl',
|
||||
expr: { type: 'group', children: [{ type: 'condition', field: 'b_status' }] },
|
||||
}),
|
||||
})
|
||||
expect(dsl.kind).toBe('dsl')
|
||||
expect(dsl.expr.children).toHaveLength(1)
|
||||
|
||||
expect(parseRulePredicate({ b_predicate: 'select 1 where b_id in (:ids)' })).toMatchObject({
|
||||
kind: 'sql',
|
||||
})
|
||||
expect(parseRulePredicate({ b_predicate: '{不是合法 JSON' })).toMatchObject({ kind: 'sql' })
|
||||
// JSON 合法但不是 dsl 形状:按 SQL 文本处理,内容不丢
|
||||
expect(parseRulePredicate({ b_predicate: '{"foo":1}' })).toMatchObject({ kind: 'sql' })
|
||||
})
|
||||
|
||||
it('类型文案与摘要:条件走中文描述、SQL 单行截断、引用固定说明', () => {
|
||||
const itemByField = itemMapOf(buildRuleQueryItems(fields, {}))
|
||||
|
||||
const dslRow = {
|
||||
b_predicate: JSON.stringify({
|
||||
kind: 'dsl',
|
||||
expr: {
|
||||
type: 'group',
|
||||
children: [{ type: 'condition', field: 'b_amount', operator: 'gt', value: 0 }],
|
||||
},
|
||||
}),
|
||||
}
|
||||
expect(ruleTypeText(dslRow)).toBe('条件')
|
||||
expect(ruleSummary(dslRow, itemByField)).toBe('金额 大于 0')
|
||||
|
||||
const sqlRow = { b_predicate: 'select\n b_id\nfrom bill where b_settled = 1' }
|
||||
expect(ruleTypeText(sqlRow)).toBe('SQL')
|
||||
expect(ruleSummary(sqlRow, itemByField)).toBe('select b_id from bill where b_settled = 1')
|
||||
|
||||
const refRow = { b_predicate: null }
|
||||
expect(ruleTypeText(refRow)).toBe('引用')
|
||||
expect(ruleSummary(refRow, itemByField)).toBe(REF_RULE_HINT)
|
||||
})
|
||||
|
||||
it('识别文案:勾条件显示条件摘要,写 SQL 显示提示文案(缺文案退回 SQL 片段)', () => {
|
||||
const itemByField = itemMapOf(buildRuleQueryItems(fields, {}))
|
||||
|
||||
const dslRow = {
|
||||
b_predicate: JSON.stringify({
|
||||
kind: 'dsl',
|
||||
expr: {
|
||||
type: 'group',
|
||||
children: [{ type: 'condition', field: 'b_status', operator: 'eq', value: 'audit' }],
|
||||
},
|
||||
}),
|
||||
}
|
||||
expect(ruleLabel(dslRow, itemByField)).toBe('状态 等于 audit')
|
||||
|
||||
const sqlRow = { b_predicate: 'select 1 as b_id', b_message: '单据已销账,不能删除' }
|
||||
expect(ruleLabel(sqlRow, itemByField)).toBe('单据已销账,不能删除')
|
||||
expect(ruleLabel({ ...sqlRow, b_message: null }, itemByField)).toBe('select 1 as b_id')
|
||||
|
||||
expect(ruleLabel({ b_predicate: null }, itemByField)).toBe(REF_RULE_HINT)
|
||||
})
|
||||
|
||||
it('新增行:主键由调用方取号传入,无名称/编码字段', () => {
|
||||
describe('删除 SQL 规则工具', () => {
|
||||
it('新增行:主键由调用方取号传入,模块列直接物化,无名称/编码字段', () => {
|
||||
const rule = createRuleRow('order', { id: '1900000000000000001', xh: 30 })
|
||||
expect(rule).toMatchObject({
|
||||
b_id: '1900000000000000001',
|
||||
b_scope_type: 'module',
|
||||
b_scope_id: 'order',
|
||||
b_hook: 'delete.pre',
|
||||
b_predicate: null,
|
||||
b_module_id: 'order',
|
||||
b_canuse: 1,
|
||||
b_xh: 30,
|
||||
})
|
||||
// 新增行的 SQL 预填 :ids 骨架,直接可改
|
||||
expect(rule.b_sql).toContain(IDS_PLACEHOLDER)
|
||||
expect(rule.b_code).toBeUndefined()
|
||||
expect(rule.b_name).toBeUndefined()
|
||||
|
||||
const ref = createRefRuleRow('order|b_id|order_item|b_order_id', '1900000000000000002')
|
||||
expect(ref).toMatchObject({
|
||||
b_id: '1900000000000000002',
|
||||
b_scope_type: 'relation',
|
||||
b_scope_id: 'order|b_id|order_item|b_order_id',
|
||||
b_predicate: null,
|
||||
b_hook: 'delete.pre',
|
||||
})
|
||||
// 旧设计的字段不再出现
|
||||
expect(rule.b_scope_type).toBeUndefined()
|
||||
expect(rule.b_scope_id).toBeUndefined()
|
||||
expect(rule.b_predicate).toBeUndefined()
|
||||
expect(rule.b_hook).toBeUndefined()
|
||||
})
|
||||
|
||||
it('items 构造:只列启用字段,类型映射控件,select 缺选项时降级为输入', () => {
|
||||
const items = buildRuleQueryItems(fields, {
|
||||
groups: [
|
||||
{
|
||||
conditions: [
|
||||
{ field: 'b_status', operator: 'in', component: 'select', options: [{ value: 'a' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
it('摘要:SQL 单行化并按长度截断,空 SQL 给可读提示', () => {
|
||||
const row = { b_sql: 'select\n b_id\nfrom bill where b_settled = 1' }
|
||||
expect(ruleSummary(row)).toBe('select b_id from bill where b_settled = 1')
|
||||
expect(ruleSummary({ b_sql: null })).toBe('未填写 SQL')
|
||||
|
||||
expect(items.map((item) => item.field.b_field)).toEqual(['b_status', 'b_amount'])
|
||||
// 查询配置里定义过:沿用其操作符/控件/选项
|
||||
expect(items[0].config).toMatchObject({ b_operator: 'in', b_component: 'select' })
|
||||
expect(items[0].options).toEqual([{ value: 'a', label: 'a' }])
|
||||
// 未定义过:按字段类型推导(金额 → 数字输入)
|
||||
expect(items[1].config.b_component).toBe('number')
|
||||
expect(items[1].config.b_operator).toBe('eq')
|
||||
const long = { b_sql: `select b_id from t where ${'x'.repeat(100)}` }
|
||||
const summary = ruleSummary(long)
|
||||
expect(summary.endsWith('…')).toBe(true)
|
||||
expect(summary.length).toBeLessThanOrEqual(81)
|
||||
})
|
||||
|
||||
it('识别文案:优先提示文案,缺文案退回 SQL 片段', () => {
|
||||
const row = {
|
||||
b_sql: 'select 1 as b_id',
|
||||
b_message: '单据已销账,不能删除',
|
||||
}
|
||||
expect(ruleLabel(row)).toBe('单据已销账,不能删除')
|
||||
expect(ruleLabel({ ...row, b_message: null })).toBe('select 1 as b_id')
|
||||
expect(ruleLabel({ ...row, b_message: ' ' })).toBe('select 1 as b_id')
|
||||
})
|
||||
|
||||
it('SQL 校验:必须非空、包含 :ids、且是只读查询', () => {
|
||||
expect(ruleHasSql({ b_sql: 'select b_id from t where b_id in (:ids)' })).toBe(true)
|
||||
expect(ruleHasSql({ b_sql: 'select b_id from t' })).toBe(false)
|
||||
expect(ruleHasSql({ b_sql: null })).toBe(false)
|
||||
|
||||
expect(ruleIsReadOnlySql({ b_sql: 'select b_id from t' })).toBe(true)
|
||||
expect(ruleIsReadOnlySql({ b_sql: ' WITH x AS (select 1) select * from x' })).toBe(true)
|
||||
expect(ruleIsReadOnlySql({ b_sql: 'delete from t' })).toBe(false)
|
||||
expect(ruleIsReadOnlySql({ b_sql: null })).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user