20260917212907

This commit is contained in:
oneao committed 2026-09-17 21:29:08 +08:00
1 parent 6fa3d8535e
commit 5cbf745bbf
115 files changed
+4104 -85

No files matched your search

@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import { createTestPinia, createTestRouter } from '../helpers/app'
import AppTopbar from '@/layouts/components/AppTopbar.vue'
import { useI18nStore } from '@/stores/i18n'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn().mockResolvedValue({ data: [] }),
}))
async function mountTopbar() {
const pinia = createTestPinia()
const router = createTestRouter('/dashboard')
await router.isReady()
const wrapper = shallowMount(AppTopbar, {
global: { plugins: [pinia, router] },
})
return { wrapper, i18n: useI18nStore() }
}
/** 顶栏语言下拉:组件库 Dropdown 被 shallow 打桩,桩名取文件名推断的 'dropdown' */
function languageDropdown(wrapper) {
return wrapper
.findAllComponents({ name: 'dropdown' })
.find((item) => Array.isArray(item.props('items')) && item.props('items').some((i) => i.shortcut))
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
})
describe('顶栏语言切换', () => {
it('语种来自 s_i18n_type 语言清单,当前语言带勾选标记', async () => {
const { wrapper, i18n } = await mountTopbar()
i18n.languages = [
{ b_id: 'zh-CN', b_name: '简体中文', b_canuse: 1, b_default: 1, b_xh: 10 },
{ b_id: 'en-US', b_name: 'English', b_canuse: 1, b_default: 0, b_xh: 20 },
]
i18n.setLocale('zh-CN')
await wrapper.vm.$nextTick()
const items = languageDropdown(wrapper).props('items')
expect(items.map((item) => item.key)).toEqual(['zh-CN', 'en-US'])
expect(items.map((item) => item.label)).toEqual(['简体中文', 'English'])
expect(items[0].shortcut).toBe('✓')
expect(items[1].shortcut).toBe('')
})
it('点击语种即切换(不再硬编码 zh-CN / en-US 互切)', async () => {
const { wrapper, i18n } = await mountTopbar()
i18n.languages = [
{ b_id: 'zh-CN', b_name: '简体中文', b_canuse: 1, b_default: 1, b_xh: 10 },
{ b_id: 'en-US', b_name: 'English', b_canuse: 1, b_default: 0, b_xh: 20 },
]
i18n.setLocale('zh-CN')
await wrapper.vm.$nextTick()
languageDropdown(wrapper).vm.$emit('click', { key: 'en-US' })
expect(i18n.locale).toBe('en-US')
// 重复点当前语种不产生变化(无副作用)
languageDropdown(wrapper).vm.$emit('click', { key: 'en-US' })
expect(i18n.locale).toBe('en-US')
})
it('语言清单未加载时退化成只显示当前语种', async () => {
const { wrapper, i18n } = await mountTopbar()
i18n.languages = []
i18n.setLocale('zh-CN')
await wrapper.vm.$nextTick()
const items = languageDropdown(wrapper).props('items')
expect(items).toEqual([{ key: 'zh-CN', label: 'zh-CN', shortcut: '✓' }])
})
})
+56 -1
View File
@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest'
import { afterEach, describe, it, expect } from 'vitest'
import { h, nextTick, ref } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import Form from '@/components/ui/form/form.vue'
import FormItem from '@/components/ui/form/form-item.vue'
import { resetUIConfig, setUIConfig, uiConfig } from '@/components/ui/config'
const model = () =>
ref({
@@ -72,6 +73,60 @@ describe('Form 多列网格布局', () => {
})
})
describe('Form 全局配置(uiConfig)', () => {
afterEach(() => resetUIConfig())
it('未传 layout 时取全局配置', () => {
setUIConfig({ form: { layout: 'vertical' } })
const wrapper = mountForm()
expect(wrapper.classes()).toContain('fms-form-vertical')
expect(wrapper.findComponent(FormItem).classes()).toContain('fms-form-item-vertical')
})
it('显式 layout 覆盖全局配置', () => {
setUIConfig({ form: { layout: 'vertical' } })
const wrapper = mountForm(1, { layout: 'horizontal' })
expect(wrapper.classes()).toContain('fms-form-horizontal')
expect(wrapper.findComponent(FormItem).classes()).not.toContain('fms-form-item-vertical')
})
it('setUIConfig 局部合并,未提及的键保持原值', () => {
setUIConfig({ form: { labelAlign: 'left' } })
expect(uiConfig.form.labelAlign).toBe('left')
expect(uiConfig.form.layout).toBe('horizontal')
setUIConfig({ form: { layout: 'vertical' } })
expect(uiConfig.form.labelAlign).toBe('left')
})
it('全局 validateTrigger 生效:change 改为 blur 后值变化不立即报红', async () => {
setUIConfig({ form: { validateTrigger: 'blur' } })
const model = ref({ code: '' })
const wrapper = mount(Form, {
props: { model: model.value, rules: { code: [{ required: true, message: '必填' }] } },
slots: {
default: () => [
h(FormItem, { name: 'code', label: '编码' }, { default: () => h('input') }),
],
},
})
model.value.code = 'a'
await nextTick()
model.value.code = ''
await nextTick()
await flushPromises()
expect(wrapper.findComponent(FormItem).classes()).not.toContain('fms-form-item-error')
})
it('独立使用的 FormItem(无 Form 包裹)也读全局 layout', () => {
setUIConfig({ form: { layout: 'vertical' } })
const wrapper = mount(FormItem, {
props: { name: 'code', label: '编码' },
slots: { default: () => h('input') },
})
expect(wrapper.classes()).toContain('fms-form-item-vertical')
})
})
describe('Form 必填校验与 FormItem 错误展示', () => {
function mountRequiredForm(rules, modelValue = { code: '' }) {
const model = ref(modelValue)
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestPinia } from '../helpers/app'
import { useI18nStore } from '@/stores/i18n'
import { BUILTIN_MESSAGES } from '@/i18n/builtin-messages'
const loadDataApi = vi.fn()
vi.mock('@/services/api', () => ({
loadDataApi: (...args) => loadDataApi(...args),
}))
/** 按 s_i18n 行结构构造返回值 */
function rows(locale, entries) {
return {
data: entries.map(([b_key, b_value]) => ({ b_key, b_locale: locale, b_value, b_canuse: 1 })),
}
}
beforeEach(() => {
loadDataApi.mockReset()
createTestPinia()
const i18n = useI18nStore()
i18n.setLocale('zh-CN')
i18n.clearMessages()
})
describe('i18n store · 译文加载(s_i18n)', () => {
it('加载当前语言译文并写入运行时(t 生效)', async () => {
loadDataApi.mockResolvedValue(rows('zh-CN', [['field.x.b_name', '客户名称']]))
const i18n = useI18nStore()
await i18n.loadMessages()
expect(loadDataApi).toHaveBeenCalledTimes(1)
const [viewName, condition] = loadDataApi.mock.calls[0]
expect(viewName).toBe('s_i18n')
expect(condition).toContain("b_locale = N'zh-CN'")
expect(condition).toContain('b_canuse = 1')
expect(i18n.t('field.x.b_name', '名称')).toBe('客户名称')
})
it('英文模式:内置 UI 文案兜底,库表译文覆盖同名 key', async () => {
loadDataApi.mockResolvedValue(rows('en-US', [['common.cancel', 'Cancel!!']]))
const i18n = useI18nStore()
i18n.setLocale('en-US')
await i18n.loadMessages()
// 库里有的以库为准
expect(i18n.t('common.cancel', '取消')).toBe('Cancel!!')
// 库里没有的走前端内置英文
expect(i18n.t('common.applyQuery', '应用')).toBe(BUILTIN_MESSAGES['en-US']['common.applyQuery'])
expect(i18n.t('common.applyQuery', '应用')).toBe('Apply')
})
it('切换语言后重新加载,且缓存命中不再发请求', async () => {
loadDataApi.mockResolvedValue(rows('zh-CN', [['k', '中文']]))
const i18n = useI18nStore()
await i18n.loadMessages()
expect(loadDataApi).toHaveBeenCalledTimes(1)
// 切到英文:新语言未缓存 → 发请求
loadDataApi.mockResolvedValue(rows('en-US', [['k', 'EN']]))
i18n.setLocale('en-US')
await i18n.loadMessages('en-US')
expect(loadDataApi).toHaveBeenCalledTimes(2)
expect(i18n.t('k', 'x')).toBe('EN')
// 切回中文:命中缓存 → 不再请求,但译文要切回来
i18n.setLocale('zh-CN')
await i18n.loadMessages()
expect(loadDataApi).toHaveBeenCalledTimes(2)
expect(i18n.t('k', 'x')).toBe('中文')
})
it('并发同语言只发一个请求', async () => {
loadDataApi.mockResolvedValue(rows('zh-CN', [['k', 'v']]))
const i18n = useI18nStore()
await Promise.all([i18n.loadMessages(), i18n.loadMessages(), i18n.loadMessages()])
expect(loadDataApi).toHaveBeenCalledTimes(1)
})
it('接口失败不抛出,界面回落兜底文案', async () => {
loadDataApi.mockRejectedValue(new Error('boom'))
const i18n = useI18nStore()
await expect(i18n.loadMessages()).resolves.toEqual({})
expect(i18n.t('common.cancel', '取消')).toBe('取消')
expect(i18n.error).toBe('boom')
})
it('b_locale 走转义,单引号不会拼坏 SQL', async () => {
loadDataApi.mockResolvedValue({ data: [] })
const i18n = useI18nStore()
await i18n.loadMessages("zh'; drop table s_i18n --")
expect(loadDataApi.mock.calls[0][1]).toContain("N'zh''; drop table s_i18n --'")
})
it('请求回来时语言已被切走则不覆盖当前译文', async () => {
let resolveQuery
loadDataApi.mockReturnValue(new Promise((r) => (resolveQuery = r)))
const i18n = useI18nStore()
const pending = i18n.loadMessages()
i18n.setLocale('en-US')
resolveQuery({ data: [{ b_key: 'k', b_value: '中文值' }] })
await pending
// 当前是英文,中文译文不应写进运行时(英文下 k 无译文 → 回落 fallback)
expect(i18n.t('k', 'fallback')).toBe('fallback')
})
})
@@ -0,0 +1,57 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createTestPinia, createTestRouter } from '../helpers/app'
import App from '@/App.vue'
import { useI18nStore } from '@/stores/i18n'
import { uiConfig } from '@/components/ui/config'
import { resetUIConfig } from '@/components/ui'
const loadDataApi = vi.fn()
vi.mock('@/services/api', () => ({
loadDataApi: (...args) => loadDataApi(...args),
}))
async function mountApp() {
const pinia = createTestPinia()
const router = createTestRouter('/dashboard')
await router.isReady()
const wrapper = mount(App, {
global: { plugins: [pinia, router], stubs: { FpsMonitor: true } },
})
await wrapper.vm.$nextTick()
return { wrapper, i18n: useI18nStore() }
}
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
loadDataApi.mockReset()
loadDataApi.mockResolvedValue({ data: [] })
resetUIConfig()
})
describe('App 语言联动(表单布局 + 译文)', () => {
it('中文 → 表单横排,并按当前语言加载译文', async () => {
const { i18n } = await mountApp()
i18n.setLocale('zh-CN')
await Promise.resolve()
expect(uiConfig.form.layout).toBe('horizontal')
expect(loadDataApi.mock.calls.some(([, cond]) => String(cond).includes("b_locale = N'zh-CN'"))).toBe(
true,
)
})
it('切到英文 → 表单竖排,并重新拉取 en-US 译文', async () => {
const { i18n } = await mountApp()
i18n.setLocale('en-US')
await Promise.resolve()
await Promise.resolve()
expect(uiConfig.form.layout).toBe('vertical')
expect(loadDataApi.mock.calls.some(([, cond]) => String(cond).includes("b_locale = N'en-US'"))).toBe(
true,
)
})
})
@@ -262,7 +262,8 @@ describe('FmsModuleListPage 冒烟', () => {
// 模块只配置了 b_name 一个查询条件,未写 quick 列表时它必须出现在常用区
expect(wrapper.findAll('.query-field')).toHaveLength(1)
expect(wrapper.find('.query-field__label').text()).toBe('语言名称')
// 条件字段已改用 FormItem 渲染:label 由组件库输出
expect(wrapper.find('.query-field .fms-form-item-label').text()).toBe('语言名称')
querySchema.quick = original
wrapper.unmount()
})
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import ModuleAutoCodePanel from '@/views/module/module-management/ModuleAutoCodePanel.vue'
import Switch from '@/components/ui/switch/switch.vue'
const fields = [
{ b_field: 'b_id', b_name: '编码', b_type: 'input', b_canuse: 1 },
{ b_field: 'b_no', b_name: '业务单号', b_type: 'input', b_canuse: 1 },
{ b_field: 'b_bz', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
]
/** 模拟父级 v-model:emit 回来要显式写回 prop,否则复现不了「立刻回读旧值」的问题 */
function mountPanel() {
let wrapper
wrapper = mount(ModuleAutoCodePanel, {
props: {
moduleId: 'm1',
fields,
modelValue: [],
'onUpdate:modelValue': (value) => wrapper.setProps({ modelValue: value }),
},
})
return wrapper
}
describe('自动编码面板', () => {
it('首次拨「启用」时惰性建行并写回 b_canuse(defineModel 不能写完立刻回读)', async () => {
const wrapper = mountPanel()
// 模块从未配置自动编码:草稿为空,此时拨开关会走 ensureRow 建行
wrapper.findComponent(Switch).vm.$emit('update:modelValue', true)
await wrapper.vm.$nextTick()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeTruthy()
const row = emitted[0][0][0]
expect(row.b_canuse).toBe(1)
expect(row.b_module_id).toBe('m1')
// 业务编号字段自动取第一个可用文本字段(排除 b_id)
expect(row.b_field).toBe('b_no')
})
it('已有一行时就地改,不再新建也不重新 emit 整个数组', async () => {
let wrapper
wrapper = mount(ModuleAutoCodePanel, {
props: {
moduleId: 'm1',
fields,
modelValue: [{ b_module_id: 'm1', b_field: 'b_no', b_canuse: 0, b_prefix: 'A' }],
'onUpdate:modelValue': (value) => wrapper.setProps({ modelValue: value }),
},
})
wrapper.findComponent(Switch).vm.$emit('update:modelValue', true)
await wrapper.vm.$nextTick()
// 已落草稿:直接改这一行的属性(父级 ref 是深度响应式,改得见)
expect(wrapper.emitted('update:modelValue')).toBeUndefined()
const row = wrapper.props('modelValue')[0]
expect(row.b_canuse).toBe(1)
expect(row.b_prefix).toBe('A') // 沿用原行,未被占位行覆盖
})
it('紧凑网格固定竖排,不跟随全局横排配置', () => {
const wrapper = mountPanel()
expect(wrapper.find('form').classes()).toContain('fms-form-vertical')
})
})
@@ -1,10 +1,11 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import FmsQueryControl from '@/components/fms-module-list/FmsQueryControl.vue'
import FmsQueryToolbar from '@/components/fms-module-list/FmsQueryToolbar.vue'
import FmsAdvancedQueryPanel from '@/components/fms-module-list/FmsAdvancedQueryPanel.vue'
import FmsField from '@/components/fms-field/FmsField.vue'
import { resetUIConfig, setUIConfig } from '@/components/ui'
/** 构造一个查询条件项(字段 + 配置),供工具栏布局用例复用 */
const queryItem = (field) => ({
@@ -20,6 +21,7 @@ const rangeQueryItem = (bType) => ({
describe('fms-module-list components', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => resetUIConfig())
it('mounts a text query control with the new UI kit', () => {
const wrapper = mount(FmsQueryControl, {
@@ -40,6 +42,32 @@ describe('fms-module-list components', () => {
expect(wrapper.html()).not.toContain('<a-')
})
it('条件字段用 FormItem 渲染,label 排版跟随全局布局配置', () => {
const mountToolbar = () =>
mount(FmsQueryToolbar, {
props: {
queryItems: [queryItem('b_name')],
queryLayout: { area: { b_name: 'quick' }, order: { b_name: 10 } },
},
})
// 中文(默认):横排,label 在左
const zh = mountToolbar()
const item = zh.find('.query-field')
expect(item.classes()).toContain('fms-form-item')
expect(item.classes()).not.toContain('fms-form-item-vertical')
expect(item.find('.fms-form-item-label').text()).toBe('b_name')
// 定宽标签会在短标签后拖出一段空白,查询区按内容收缩
expect(item.find('.fms-form-item-label').attributes('style')).toContain('width: auto')
zh.unmount()
// 切到非中文:竖排,label 在上
setUIConfig({ form: { layout: 'vertical' } })
const en = mountToolbar()
expect(en.find('.query-field').classes()).toContain('fms-form-item-vertical')
en.unmount()
})
it('查询条件网格列数按条件数收缩(只有一个条件时不占 4 列宽)', () => {
const one = mount(FmsQueryToolbar, {
props: {
@@ -0,0 +1,303 @@
/**
* 模块关联面板(s_relation):
* - 加载回填行只补本地行键(_rowKey),不得改动业务列——否则打开模块即提示「未保存」;
* - 新增关联:追加「源模块 = 当前模块」的空行,主键四列留待用户填写;
* - 换目标模块:原目标字段必然失效,需清空并按目标模块加载字段选项;
* - 保存前校验:四列非空、关系类型合法、四列组合不重复(空列会撞行身份,落库即主键冲突)。
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import ModuleRelationPanel from '@/views/module/module-management/ModuleRelationPanel.vue'
import { stripInternalKeys } from '@/utils/dataChanges'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
nextIdApi: vi.fn(() => Promise.resolve({ data: '1900000000000000009', code: 0 })),
}))
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 } from '@/services/api'
import { Message } from '@/components/ui/message/message-manager.js'
// jsdom 无布局:StkTable 在 clientWidth 为 0 时启用虚拟列裁剪,补成宽容器
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get() {
return 5000
},
})
})
const MODULES = [
{ b_id: 'order', b_name: '订单', b_canuse: 1 },
{ b_id: 'order_item', b_name: '订单明细', b_canuse: 1 },
{ b_id: 'legacy', b_name: '历史模块', b_canuse: 0 },
]
const FIELDS = [{ b_field: 'b_id', b_name: '主键', b_canuse: 1 }]
/** 库中已有的一行:源侧(当前模块 order)→ 目标模块 order_item */
const sourceRow = {
b_source_module_id: 'order',
b_source_field: 'b_id',
b_target_module_id: 'order_item',
b_target_field: 'b_order_id',
b_relation_type: 'one_to_many',
b_canuse: 1,
b_xh: 10,
}
async function mountPanel(modelValue = [], { refRules = [] } = {}) {
const pinia = createPinia()
pinia.install({ provide() {}, config: { globalProperties: {} } })
setActivePinia(pinia)
const wrapper = mount(ModuleRelationPanel, {
props: {
moduleId: 'order',
fields: FIELDS,
modules: MODULES,
reverseRows: [],
loading: false,
modelValue,
refRules,
},
global: { plugins: [pinia] },
attachTo: document.body,
})
await flushPromises()
await nextTick()
return wrapper
}
const lastRelations = (wrapper) => {
const events = wrapper.emitted('update:modelValue') || []
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()
loadDataApi.mockResolvedValue({ data: [] })
Message.warning.mockClear()
Message.info.mockClear()
Message.success.mockClear()
})
describe('模块关联面板', () => {
it('加载回填只补本地行键,业务列不变', async () => {
const wrapper = await mountPanel([{ ...sourceRow }])
const next = lastRelations(wrapper)
expect(next).toHaveLength(1)
expect(next[0]._rowKey).toBeTruthy()
// 剥掉 _ 前缀的内部键后应与库中行完全一致(不置脏)
expect(stripInternalKeys(next[0])).toEqual(sourceRow)
})
it('新增关联:追加源模块为当前模块的空行并进入编辑', async () => {
const wrapper = await mountPanel([{ ...sourceRow }])
await wrapper.vm.addRow()
await nextTick()
const next = lastRelations(wrapper)
expect(next).toHaveLength(2)
expect(next[1]).toMatchObject({
b_source_module_id: 'order',
b_source_field: null,
b_target_module_id: null,
b_target_field: null,
b_relation_type: 'many_to_one',
b_canuse: 1,
b_xh: 20,
})
})
it('换目标模块:清空原目标字段并按新模块加载字段选项', async () => {
loadDataApi.mockResolvedValue({
data: [{ b_field: 'b_item_id', b_name: '明细主键', b_canuse: 1 }],
})
const wrapper = await mountPanel([{ ...sourceRow }])
const row = wrapper.vm.relations[0]
wrapper.vm.onCellChange(row, { dataIndex: 'b_target_module_id' })
expect(row.b_target_field).toBe(null)
await flushPromises()
expect(loadDataApi).toHaveBeenCalledWith(
's_field',
"b_module_id = N'order_item'",
'b_xh ASC, b_field ASC',
)
expect(wrapper.vm.fieldOptionsOf('order_item')).toEqual([
{ label: '明细主键(b_item_id)', value: 'b_item_id' },
])
})
it('停用模块与历史字段降级为裸编码,不丢行', async () => {
const wrapper = await mountPanel([
{ ...sourceRow, b_target_module_id: 'legacy', b_target_field: 'b_gone' },
])
// 停用模块不在可选项里,但名称解析仍可用(不阻断展示)
expect(wrapper.vm.moduleOptions.map((item) => item.value)).toEqual(['order', 'order_item'])
expect(wrapper.vm.moduleText('legacy')).toBe('历史模块(legacy)')
expect(wrapper.vm.moduleText('removed')).toBe('removed')
expect(wrapper.vm.fieldText('order', 'b_id')).toBe('主键(b_id)')
expect(wrapper.vm.fieldText('order', 'b_gone')).toBe('b_gone')
})
it('校验:空列与四列组合重复都被拦截', async () => {
const wrapper = await mountPanel([{ ...sourceRow }])
wrapper.vm.relations = [
{ ...sourceRow, _rowKey: 'r1' },
{ ...sourceRow, _rowKey: 'r2' },
]
await nextTick()
expect(wrapper.vm.validate()).toContain('第 2 行:与前面的关联重复')
wrapper.vm.relations = [
{
_rowKey: 'r1',
b_source_module_id: 'order',
b_source_field: null,
b_target_module_id: null,
b_target_field: null,
b_relation_type: 'many_to_one',
b_canuse: 1,
},
]
await nextTick()
const errors = wrapper.vm.validate()
expect(errors).toContain('第 1 行:请选择源字段')
expect(errors).toContain('第 1 行:请选择目标模块')
expect(errors).toContain('第 1 行:请选择目标字段')
// 四列都空时行身份相同:只有一条时不算重复
expect(errors.some((item) => item.includes('重复'))).toBe(false)
})
it('批量删除:移除选中行并清空选择', async () => {
const wrapper = await mountPanel([{ ...sourceRow }])
await wrapper.vm.addRow()
await nextTick()
const next = lastRelations(wrapper)
wrapper.vm.selectedRowKeys = [String(next[1]._rowKey)]
wrapper.vm.batchDelete()
await nextTick()
expect(lastRelations(wrapper)).toHaveLength(1)
expect(wrapper.vm.selectedRowKeys).toEqual([])
})
it('删除连带:加载回填不置脏,多对一自动清回不连带', async () => {
const cascadeRow = { ...sourceRow, b_on_delete: 'cascade' }
// 传入副本:面板会往行对象上补 _ 前缀派生列
const wrapper = await mountPanel([{ ...cascadeRow }])
const row = lastRelations(wrapper)[0]
expect(stripInternalKeys(row)).toEqual(cascadeRow)
// 类型改成多对一:连带策略不再成立,自动清回 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(
'多对一关系不支持连带删除/连带归档,已重置为不连带',
)
})
it('校验:多对一上配连带删除/归档被拦,取值非法被拦', 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)
})
})
@@ -0,0 +1,139 @@
/**
* 删除规则编辑弹窗(ModuleRuleEditModal):
* - 勾条件 → predicate 存 {"kind":"dsl","expr":AST};写 SQL → predicate 存文本;
* - 两种类型各自的必填校验(无条件 / 缺提示文案)不通过时不提交;
* - 打开时按 predicate 三态回显类型与内容;标题按新增/编辑切换。
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { 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'
vi.mock('@/services/api', () => ({ loadDataApi: vi.fn() }))
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 }
})
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get() {
return 5000
},
})
})
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_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 },
global: { plugins: [pinia] },
attachTo: document.body,
})
await flushPromises()
await nextTick()
return wrapper
}
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'
wrapper.vm.sqlText = ' '
await nextTick()
await wrapper.vm.onOk()
expect(wrapper.vm.sqlError).toBe('请填写规则 SQL')
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)
expect(wrapper.vm.ruleType).toBe('sql')
expect(wrapper.vm.sqlText).toBe('select 1 as b_id')
expect(wrapper.vm.modalTitle).toBe('新增删除规则')
const editWrapper = await mountModal({ ...ruleRow() }, false)
expect(editWrapper.vm.modalTitle).toBe('编辑删除规则')
})
})
@@ -0,0 +1,191 @@
/**
* 删除规则面板(module-management/ModuleRulePanel):
* - 加载回填只补界面派生列(_rowKey / _typeText / _labelText),业务列不变(不置脏);
* - 列表识别:勾条件显示条件中文摘要,写 SQL 显示提示文案;
* - 保存前校验:勾条件要有条件、写 SQL 要有 SQL 与提示文案;
* - 其他模块在本模块上配的引用规则只读展示,可跳转到源模块。
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { 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'
vi.mock('@/services/api', () => ({
loadDataApi: vi.fn(),
nextIdApi: vi.fn(() => Promise.resolve({ data: '1900000000000000009', code: 0 })),
}))
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 }
})
// jsdom 无布局:StkTable 在 clientWidth 为 0 时启用虚拟列裁剪,补成宽容器
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get() {
return 5000
},
})
})
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 = {
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_canuse: 1,
b_xh: 10,
}
async function mountPanel({ rules = [], refRulesReverse = [] } = {}) {
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,
},
global: { plugins: [pinia] },
attachTo: document.body,
})
await flushPromises()
await nextTick()
return wrapper
}
const lastRules = (wrapper) => {
const events = wrapper.emitted('update:modelValue') || []
return events.length ? events[events.length - 1][0] : null
}
beforeEach(() => {
document.body.innerHTML = ''
})
describe('删除规则面板', () => {
it('加载回填只补界面派生列,业务列不变', async () => {
const wrapper = await mountPanel({ rules: [{ ...dslRow }] })
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(stripInternalKeys(next[0])).toEqual(dslRow)
})
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] })
const next = lastRules(wrapper)
expect(next[0]._typeText).toBe('SQL')
expect(next[0]._labelText).toBe('单据已销账,不能删除')
})
it('校验:勾条件必须真有条件,写 SQL 必须有 SQL 与提示文案', async () => {
const emptyDsl = {
...dslRow,
b_id: '1900000000000000003',
b_predicate: JSON.stringify({ kind: 'dsl', expr: { type: 'group', children: [] } }),
}
const sqlWithoutMessage = {
...dslRow,
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 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 条规则:缺少主键(请重新新增)')
})
it('新增规则:取号后打开弹窗,确定时才落草稿', async () => {
const wrapper = await mountPanel()
await wrapper.vm.openRuleModal(null)
await flushPromises()
expect(wrapper.vm.editingIsNew).toBe(true)
expect(wrapper.vm.editingRow.b_id).toBe('1900000000000000009')
expect(wrapper.vm.ruleModalOpen).toBe(true)
// 未确定前不进列表
expect(lastRules(wrapper)).toBe(null)
wrapper.vm.onRuleOk({ ...wrapper.vm.editingRow, b_predicate: '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'])
})
})
@@ -0,0 +1,156 @@
/**
* 删除规则配置工具(module-management/ruleUtils):
* 关系业务键拼串/还原、predicate 三态推断、识别文案、新增行构造、条件面板 items 构造。
*/
import { describe, expect, it } from 'vitest'
import {
REF_RULE_HINT,
buildRuleQueryItems,
createRefRuleRow,
createRuleRow,
isCompleteRelationKey,
itemMapOf,
parseRelationScopeId,
parseRulePredicate,
relationScopeId,
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('新增行:主键由调用方取号传入,无名称/编码字段', () => {
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_canuse: 1,
b_xh: 30,
})
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',
})
})
it('items 构造:只列启用字段,类型映射控件,select 缺选项时降级为输入', () => {
const items = buildRuleQueryItems(fields, {
groups: [
{
conditions: [
{ field: 'b_status', operator: 'in', component: 'select', options: [{ value: 'a' }] },
],
},
],
})
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')
})
})
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
advancedQueryFromAst,
astToSql,
buildAdvancedAst,
buildQuickAst,
@@ -148,3 +149,89 @@ describe('查询条件 AST 构造', () => {
expect(emptyConditionValue({ b_type: 'select' }, 'eq')).toBeUndefined()
})
})
describe('高级条件 AST → 界面状态(advancedQueryFromAst)', () => {
let seq = 0
const idFactory = () => `n${(seq += 1)}`
it('空 AST / 空组都给空结构且不降级', () => {
expect(advancedQueryFromAst(null, { idFactory })).toEqual({
nodes: [],
groups: [],
degraded: false,
})
expect(advancedQueryFromAst({ type: 'group', children: [] }, { idFactory })).toEqual({
nodes: [],
groups: [],
degraded: false,
})
})
it('单层「行 + 组」往返:还原后再构造语义等价', () => {
const ast = buildAdvancedAst({
nodes: [
{ join: 'and', field: 'name', operator: 'like', value: 'a' },
{ join: 'or', field: 'name', operator: 'like', value: 'b' },
],
groups: [
{
join: 'and',
nodes: [
{ join: 'and', field: 'amount', operator: 'gt', value: 100 },
{ join: 'or', field: 'status', operator: 'eq', value: 'done' },
],
},
],
})
const state = advancedQueryFromAst(ast, { idFactory })
expect(state.degraded).toBe(false)
expect(state.nodes.map((node) => [node.field, node.join])).toEqual([
['name', 'and'],
['name', 'or'],
])
expect(state.groups).toHaveLength(1)
expect(state.groups[0].nodes.map((node) => [node.field, node.join])).toEqual([
['amount', 'and'],
['status', 'or'],
])
expect(astToSql(buildAdvancedAst(state), fields)).toBe(astToSql(ast, fields))
})
it('组内再嵌套组:拍平成单层并置 degraded(根层的组不算降级)', () => {
const ast = {
type: 'group',
children: [
{ type: 'condition', field: 'name', operator: 'like', value: 'a', join: 'and' },
{
type: 'group',
join: 'or',
children: [
{ type: 'condition', field: 'amount', operator: 'gt', value: 1, join: 'and' },
{
type: 'group',
join: 'and',
children: [{ type: 'condition', field: 'amount', operator: 'lt', value: 9 }],
},
],
},
],
}
const state = advancedQueryFromAst(ast, { idFactory })
expect(state.degraded).toBe(true)
expect(state.nodes.map((node) => node.field)).toEqual(['name'])
expect(state.groups).toHaveLength(1)
expect(state.groups[0].nodes.map((node) => node.field)).toEqual(['amount', 'amount'])
expect(state.groups[0].join).toBe('or')
})
it('单条件 AST(非 group)也能还原成一行', () => {
const state = advancedQueryFromAst(
{ type: 'condition', field: 'status', operator: 'eq', value: 'draft' },
{ idFactory },
)
expect(state.nodes).toHaveLength(1)
expect(state.nodes[0]).toMatchObject({ field: 'status', operator: 'eq', value: 'draft' })
})
})
@@ -31,6 +31,21 @@ vi.mock('@/components/ui/message/message-manager.js', () => {
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 {
@@ -94,6 +109,8 @@ function buildDb() {
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' }],
}
}
@@ -105,7 +122,7 @@ beforeEach(() => {
savedRequests = []
loadDataApi.mockReset()
loadDataApi.mockImplementation((view) => {
loadDataApi.mockImplementation((view, condition) => {
if (view === 's_i18n_type')
return Promise.resolve({
data: [
@@ -113,6 +130,20 @@ beforeEach(() => {
{ 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) })
})
@@ -367,4 +398,225 @@ describe('模块管理:保存后的未保存判定', () => {
),
).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' },
])
})
})