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,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' })
})
})