20260923165454

This commit is contained in:
oneao committed 2026-09-23 16:54:54 +08:00
1 parent 0abe3706c8
commit e6935b8fa4
173 files changed
+14975 -2295

No files matched your search

@@ -2,15 +2,16 @@ import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
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 FmsQuickFilters from '@/components/fms-module-list/FmsQuickFilters.vue'
import FmsKeywordSearch from '@/components/fms-module-list/FmsKeywordSearch.vue'
import FmsActiveFilterPills from '@/components/fms-module-list/FmsActiveFilterPills.vue'
import FmsField from '@/components/fms-field/FmsField.vue'
import { resetUIConfig, setUIConfig } from '@/components/ui'
/** 构造一个查询条件项(字段 + 配置),供工具栏布局用例复用 */
const queryItem = (field) => ({
field: { b_field: field, b_type: 'input' },
config: { b_operator: 'like', b_component: 'input' },
/** 构造一个查询条件项(字段 + 配置),供用例复用 */
const queryItem = (field, overrides = {}) => ({
field: { b_field: field, b_name: field, b_type: 'input' },
config: { b_operator: 'like', b_component: 'input', ...overrides },
})
/** 构造一个区间条件项:字段类型决定两端控件的形态 */
@@ -36,24 +37,18 @@ describe('fms-module-list components', () => {
expect(wrapper.find('input').exists()).toBe(true)
})
it('mounts an empty toolbar without legacy Ant components', () => {
const wrapper = mount(FmsQueryToolbar)
expect(wrapper.find('.fms-query-toolbar').exists()).toBe(true)
expect(wrapper.html()).not.toContain('<a-')
})
it('条件字段用 FormItem 渲染,label 排版跟随全局布局配置', () => {
const mountToolbar = () =>
mount(FmsQueryToolbar, {
it('快捷筛选条件字段用 FormItem 渲染,label 排版跟随全局布局配置', () => {
const mountFilters = () =>
mount(FmsQuickFilters, {
props: {
queryItems: [queryItem('b_name')],
queryLayout: { area: { b_name: 'quick' }, order: { b_name: 10 } },
items: [queryItem('b_name')],
modelValue: {},
},
})
// 中文(默认):横排,label 在左
const zh = mountToolbar()
const item = zh.find('.query-field')
const zh = mountFilters()
const item = zh.find('.quick-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')
@@ -63,74 +58,64 @@ describe('fms-module-list components', () => {
// 切到非中文:竖排,label 在上
setUIConfig({ form: { layout: 'vertical' } })
const en = mountToolbar()
expect(en.find('.query-field').classes()).toContain('fms-form-item-vertical')
const en = mountFilters()
expect(en.find('.quick-field').classes()).toContain('fms-form-item-vertical')
en.unmount()
})
it('查询条件网格列数按条件数收缩(只有一个条件时不占 4 列宽)', () => {
const one = mount(FmsQueryToolbar, {
props: {
queryItems: [queryItem('b_name')],
queryLayout: { area: { b_name: 'quick' }, order: { b_name: 10 } },
},
it('快捷筛选网格列数按条件数收缩(只有一个条件时不占 4 列宽)', () => {
const one = mount(FmsQuickFilters, {
props: { items: [queryItem('b_name')], modelValue: {} },
})
expect(one.find('.query-grid').element.style.gridTemplateColumns).toBe(
expect(one.find('.quick-grid').element.style.gridTemplateColumns).toBe(
'repeat(1, minmax(0, 288px))',
)
one.unmount()
const four = mount(FmsQueryToolbar, {
const four = mount(FmsQuickFilters, {
props: {
queryItems: [queryItem('a'), queryItem('b'), queryItem('c'), queryItem('d')],
queryLayout: {
area: { a: 'quick', b: 'quick', c: 'quick', d: 'quick' },
order: {},
},
items: [queryItem('a'), queryItem('b'), queryItem('c'), queryItem('d')],
modelValue: {},
},
})
expect(four.find('.query-grid').element.style.gridTemplateColumns).toBe(
expect(four.find('.quick-grid').element.style.gridTemplateColumns).toBe(
'repeat(4, minmax(0, 288px))',
)
four.unmount()
})
it('高级查询行间连接符表达行与行的连接:≥2 项才出现,点击切换后一项的 join', async () => {
const state = {
nodes: [
{ id: 'n1', join: 'and', field: 'name', operator: 'like', value: 'a' },
{ id: 'n2', join: 'and', field: 'name', operator: 'like', value: 'b' },
],
groups: [],
}
const wrapper = mount(FmsAdvancedQueryPanel, {
props: { open: true, items: [], modelValue: state },
it('快捷筛选「查询」提交草稿、「重置」交给页面处理', async () => {
const wrapper = mount(FmsQuickFilters, {
props: { items: [queryItem('b_name')], modelValue: { b_name: 'abc' } },
})
const pill = wrapper.find('.filter-joiner .joiner-pill')
expect(pill.exists()).toBe(true)
expect(pill.text()).toBe('且')
await pill.trigger('click')
expect(state.nodes[1].join).toBe('or')
expect(wrapper.find('.filter-joiner .joiner-pill').text()).toBe('或')
await wrapper.find('button[type="button"]').trigger('click')
const emitted = wrapper.emitted('search')
expect(emitted).toHaveLength(1)
await wrapper.findAll('button').find((b) => b.text().includes('重置')).trigger('click')
expect(wrapper.emitted('reset')).toHaveLength(1)
wrapper.unmount()
})
it('只有 1 个条件时不显示行间连接符(没有连接对象),条件行直接可见', () => {
const wrapper = mount(FmsAdvancedQueryPanel, {
props: {
open: true,
items: [],
modelValue: {
logic: 'and',
nodes: [{ id: 'n1', field: 'name', operator: 'like', value: '' }],
groups: [],
},
},
it('日期字段开启预设后先选语义区间,选「自定义」才落到两端控件', async () => {
const wrapper = mount(FmsQueryControl, {
props: { item: rangeQueryItem('date'), modelValue: { preset: 'this_month' }, datePresets: true },
})
expect(wrapper.find('.filter-joiner').exists()).toBe(false)
expect(wrapper.find('.filter-row').exists()).toBe(true)
const fields = wrapper.findAllComponents(FmsField)
// 预设态:只有一个下拉,没有两端日期
expect(fields).toHaveLength(1)
const presetField = fields[0]
expect(presetField.props('controlProps').options.map((o) => o.value)).toEqual([
'today',
'this_week',
'this_month',
'recent_3_months',
'custom',
])
// 切到自定义:值变区间、两端日期控件出现
presetField.vm.$emit('update:modelValue', 'custom')
expect(wrapper.emitted('update:modelValue').at(-1)[0]).toEqual(['', ''])
await wrapper.setProps({ modelValue: ['', ''] })
expect(wrapper.findAllComponents(FmsField)).toHaveLength(3)
wrapper.unmount()
})
@@ -200,4 +185,56 @@ describe('fms-module-list components', () => {
expect(wrapper.find('.fms-query-control__separator').exists()).toBe(true)
wrapper.unmount()
})
it('关键词:回车立即查询,清空立即查询,防抖自动查询', async () => {
vi.useFakeTimers()
try {
const wrapper = mount(FmsKeywordSearch, {
props: { modelValue: '', placeholder: '搜索' },
})
const input = wrapper.find('input')
// 输入触发防抖
await input.setValue('东方')
expect(wrapper.emitted('search')).toBeUndefined()
await vi.advanceTimersByTimeAsync(400)
expect(wrapper.emitted('search').at(-1)[0]).toBe('东方')
// 回车立即查询(不等防抖)
await input.setValue('东方国际')
await input.trigger('keydown.enter')
expect(wrapper.emitted('search').at(-1)[0]).toBe('东方国际')
// 清空立即查询
await input.setValue('')
expect(wrapper.emitted('search').at(-1)[0]).toBe('')
wrapper.unmount()
} finally {
vi.useRealTimers()
}
})
it('条件回显:逐条展示并上抛删除与清除全部', async () => {
const wrapper = mount(FmsActiveFilterPills, {
props: {
pills: [
{ key: 'keyword', text: '关键词 东方' },
{ key: 'quick:b_status', text: '状态 属于 待处理' },
],
},
})
const pills = wrapper.findAll('.active-filter-pill')
expect(pills).toHaveLength(2)
expect(pills[0].text()).toContain('关键词 东方')
await pills[1].find('.active-filter-pill__remove').trigger('click')
expect(wrapper.emitted('remove')[0][0]).toBe('quick:b_status')
await wrapper.find('.active-filter-clear').trigger('click')
expect(wrapper.emitted('clear')).toHaveLength(1)
wrapper.unmount()
})
it('无条件时条件回显区不渲染', () => {
const wrapper = mount(FmsActiveFilterPills, { props: { pills: [] } })
expect(wrapper.find('.fms-active-filters').exists()).toBe(false)
wrapper.unmount()
})
})
+16 -61
View File
@@ -13,23 +13,15 @@ vi.mock('@/stores/auth', () => ({
}))
const {
buildDefaultQueryLayout,
currentUserId,
isEmptyDelta,
layoutDelta,
loadFieldPower,
loadModulePref,
mergeQueryLayout,
mergeDefaultViewCode,
removeModulePref,
saveModulePref,
viewCodeDelta,
} = await import('@/components/fms-module-list/useModulePref')
const items = [
{ field: { b_field: 'b_no' }, config: { b_operator: 'like' } },
{ field: { b_field: 'b_name' }, config: { b_operator: 'like' } },
{ field: { b_field: 'b_bz' }, config: { b_operator: 'like' } },
]
describe('用户模块偏好', () => {
beforeEach(() => {
loadDataApi.mockReset()
@@ -42,60 +34,23 @@ describe('用户模块偏好', () => {
expect(currentUserId()).toBe('u001')
})
it('默认布局:quick 列表中的字段进常用区并靠前排序', () => {
const layout = buildDefaultQueryLayout(items, ['b_name', 'b_no'])
expect(layout.area).toEqual({ b_name: 'quick', b_no: 'quick', b_bz: 'advanced' })
expect(layout.order.b_name).toBeLessThan(layout.order.b_no)
})
it('未配置 quick 列表时,配置了的查询条件全部进常用区(配置了就要能看到)', () => {
const layout = buildDefaultQueryLayout(items, [])
expect(layout.area).toEqual({ b_no: 'quick', b_name: 'quick', b_bz: 'quick' })
})
it('查询 JSON 节点上的 area 显式配置优先', () => {
const configured = [
{ field: { b_field: 'b_no' }, node: { area: 'hidden' } },
{ field: { b_field: 'b_name' }, node: { area: 'advanced' } },
]
const layout = buildDefaultQueryLayout(configured, [])
expect(layout.area).toEqual({ b_no: 'hidden', b_name: 'advanced' })
})
it('偏好只覆盖布局,无权限字段不得因偏好复活', () => {
const defaultLayout = buildDefaultQueryLayout(items, ['b_no'])
const pref = {
it('个人默认页签:与系统默认一致时不产生覆盖', () => {
expect(viewCodeDelta('all', 'all')).toBeNull()
expect(viewCodeDelta('', 'all')).toBeNull()
expect(viewCodeDelta('pending', 'all')).toEqual({
schemaVersion: 1,
area: { b_bz: 'quick', b_status: 'quick' },
order: { b_bz: 1 },
}
const merged = mergeQueryLayout(defaultLayout, pref, new Set(['b_no', 'b_name', 'b_bz']))
expect(merged.area.b_bz).toBe('quick')
expect(merged.area.b_status).toBeUndefined()
expect(merged.order.b_status).toBeUndefined()
defaultViewCode: 'pending',
})
})
it('三层合并:企业层盖系统默认,个人层再盖企业层', () => {
const sysDefault = buildDefaultQueryLayout(items, ['b_no']) // b_no 常用,其余靠后
const globalPref = { schemaVersion: 1, area: { b_name: 'quick' } }
const enterprise = mergeQueryLayout(sysDefault, globalPref, new Set(['b_no', 'b_name', 'b_bz']))
expect(enterprise.area.b_name).toBe('quick')
// 个人层在企业层之上进一步调整
const personalPref = { schemaVersion: 1, area: { b_name: 'hidden' } }
const merged = mergeQueryLayout(enterprise, personalPref, new Set(['b_no', 'b_name', 'b_bz']))
expect(merged.area.b_name).toBe('hidden')
// 企业层没动的字段保持系统默认
expect(merged.area.b_bz).toBe(sysDefault.area.b_bz)
})
it('增量覆盖:只保留与基线不同的字段', () => {
const base = { area: { a: 'quick', b: 'advanced' }, order: { a: 10, b: 20 } }
const current = { area: { a: 'quick', b: 'quick' }, order: { a: 30, b: 20 } }
const delta = layoutDelta(current, base)
expect(delta.area).toEqual({ b: 'quick' })
expect(delta.order).toEqual({ a: 30 })
expect(isEmptyDelta(delta)).toBe(false)
expect(isEmptyDelta(layoutDelta(base, base))).toBe(true)
it('个人默认页签必须仍是当前可用页签,否则回落系统默认', () => {
expect(mergeDefaultViewCode('all', { defaultViewCode: 'pending' }, ['all', 'pending'])).toBe(
'pending',
)
expect(mergeDefaultViewCode('all', { defaultViewCode: 'deleted' }, ['all', 'pending'])).toBe(
'all',
)
expect(mergeDefaultViewCode('all', null, ['all'])).toBe('all')
})
it('读取个人偏好时按用户/模块/schema 类型过滤,解析失败返回 null', async () => {
+113 -134
View File
@@ -1,13 +1,19 @@
import { describe, expect, it } from 'vitest'
import {
advancedQueryFromAst,
astToSql,
buildAdvancedAst,
buildFilterStateAst,
buildKeywordAst,
buildQuickAst,
buildViewAst,
combineAst,
defaultOperator,
describeConditionText,
emptyConditionValue,
hasConditionValue,
isDatePresetValue,
operatorsForField,
resolveDatePreset,
resolveDatePresetValue,
} from '@/components/fms-module-common/queryUtils'
const fields = new Map([
@@ -25,10 +31,13 @@ describe('查询条件 AST 构造', () => {
expect(operatorsForField({ b_type: 'money' })).toContain('between')
})
it('生成转义后的 like / 不等 / 空值条件', () => {
it('生成转义后的 like / 开头是 / 不等 / 空值条件', () => {
expect(
astToSql({ type: 'condition', field: 'name', operator: 'like', value: "O'Reilly" }, fields),
).toBe("[name] LIKE N'%O''Reilly%'")
expect(
astToSql({ type: 'condition', field: 'name', operator: 'starts_with', value: 'a' }, fields),
).toBe("[name] LIKE N'a%'")
expect(
astToSql({ type: 'condition', field: 'name', operator: 'not_like', value: 'a' }, fields),
).toBe("[name] NOT LIKE N'%a%'")
@@ -81,66 +90,6 @@ describe('查询条件 AST 构造', () => {
expect(astToSql({ type: 'group', logic: 'and', children: [] }, fields)).toBe('')
})
it('常用条件跳过空值,操作符取模块配置', () => {
const items = [
{ field: fields.get('name'), config: { b_operator: 'like' } },
{ field: fields.get('status'), config: { b_operator: 'eq' } },
]
const ast = buildQuickAst(items, { name: 'abc', status: '' })
expect(ast.children).toHaveLength(1)
expect(astToSql(ast, fields)).toBe("[name] LIKE N'%abc%'")
})
it('高级条件按每项 join 生成 AST,单条件的组不再套一层', () => {
const ast = buildAdvancedAst({
nodes: [
{ join: 'or', field: 'name', operator: 'like', value: 'a' },
{ join: 'or', field: 'name', operator: 'like', value: 'b' },
],
groups: [
{
id: 'g1',
join: 'or',
nodes: [{ join: 'and', field: 'status', operator: 'eq', value: 'new' }],
},
],
})
// join 决定每项与上一相邻项的连接;首项 join 忽略;单条件的组等价于一个普通条件
expect(astToSql(ast, fields)).toBe(
"([name] LIKE N'%a%' OR [name] LIKE N'%b%' OR [status] = N'new')",
)
})
it('支持 (A 且 B) 或 C:组行内且、组与前一行或', () => {
const ast = buildAdvancedAst({
nodes: [{ join: 'or', field: 'name', operator: 'eq', value: '张三' }],
groups: [
{
id: 'g1',
join: 'or',
nodes: [
{ join: 'and', field: 'amount', operator: 'gt', value: 100 },
{ join: 'and', field: 'amount', operator: 'gt', value: 1000 },
],
},
],
})
expect(astToSql(ast, fields)).toBe("([name] = N'张三' OR ([amount] > 100 AND [amount] > 1000))")
})
it('同一层可混用 且/或:(a 且 b) 或 c 从左到右加括号', () => {
const ast = buildAdvancedAst({
nodes: [
{ join: 'and', field: 'name', operator: 'like', value: 'a' },
{ join: 'and', field: 'name', operator: 'like', value: 'b' },
{ join: 'or', field: 'name', operator: 'like', value: 'c' },
],
})
expect(astToSql(ast, fields)).toBe(
"(([name] LIKE N'%a%' AND [name] LIKE N'%b%') OR [name] LIKE N'%c%')",
)
})
it('区间 / 多选只要有一项即算已填写,空值按操作符重置', () => {
expect(hasConditionValue(['', '20'])).toBe(true)
expect(hasConditionValue(['', ''])).toBe(false)
@@ -150,88 +99,118 @@ describe('查询条件 AST 构造', () => {
})
})
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,
})
describe('日期预设区间', () => {
it('预设解析为具体日期区间,周以周一为起点', () => {
// 2026-09-23 是周三:本周为 09-21(周一)~ 09-27(周日)
expect(resolveDatePreset('today', '2026-09-23')).toEqual(['2026-09-23', '2026-09-23'])
expect(resolveDatePreset('this_week', '2026-09-23')).toEqual(['2026-09-21', '2026-09-27'])
expect(resolveDatePreset('this_month', '2026-09-23')).toEqual(['2026-09-01', '2026-09-30'])
expect(resolveDatePreset('recent_3_months', '2026-09-23')).toEqual(['2026-06-24', '2026-09-23'])
expect(resolveDatePreset('nope', '2026-09-23')).toBeNull()
})
it('单层「行 + 组」往返:还原后再构造语义等价', () => {
const ast = buildAdvancedAst({
nodes: [
{ join: 'and', field: 'name', operator: 'like', value: 'a' },
{ join: 'or', field: 'name', operator: 'like', value: 'b' },
],
groups: [
it('语义值 { preset } 只在组装 AST 时解析,配置不写死日期', () => {
expect(isDatePresetValue({ preset: 'today' })).toBe(true)
expect(isDatePresetValue(['2026-09-23'])).toBe(false)
expect(resolveDatePresetValue({ preset: 'today' })).toEqual(['2026-09-23', '2026-09-23'])
expect(resolveDatePresetValue('2026-09-23')).toBe('2026-09-23')
expect(
astToSql(
{
join: 'and',
nodes: [
{ join: 'and', field: 'amount', operator: 'gt', value: 100 },
{ join: 'or', field: 'status', operator: 'eq', value: 'done' },
],
type: 'condition',
field: 'created',
operator: 'between',
value: { preset: 'today' },
},
fields,
),
).toBe("[created] BETWEEN N'2026-09-23 00:00:00' AND N'2026-09-23 23:59:59'")
})
})
describe('查询入口 → AST(页签 / 关键词 / 快捷筛选 / 更多筛选)', () => {
it('页签条件生成 AST:未知字段丢弃、语义值解析', () => {
const view = {
code: 'failed',
conditions: [
{ field: 'status', operator: 'eq', value: '失败' },
{ field: 'ghost', operator: 'eq', value: 'x' },
{ field: 'created', operator: 'between', value: { preset: 'today' } },
],
})
const state = advancedQueryFromAst(ast, { idFactory })
}
const ast = buildViewAst(view, fields)
expect(astToSql(ast, fields)).toBe(
"([status] = N'失败' AND [created] BETWEEN N'2026-09-23 00:00:00' AND N'2026-09-23 23:59:59')",
)
expect(buildViewAst({ code: 'all' }, fields)).toBeNull()
})
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('关键词跨字段取「或」,空关键词不产生条件', () => {
const items = [
{ field: fields.get('name'), config: {} },
{ field: fields.get('status'), config: {} },
]
const ast = buildKeywordAst('东方', items)
expect(astToSql(ast, fields)).toBe("([name] LIKE N'%东方%' OR [status] LIKE N'%东方%')")
expect(buildKeywordAst(' ', items)).toBeNull()
expect(buildKeywordAst('东方', [])).toBeNull()
})
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 })
it('快捷筛选跳过空值,操作符取模块配置,日期预设被解析', () => {
const items = [
{ field: fields.get('name'), config: { b_operator: 'like' } },
{ field: fields.get('status'), config: { b_operator: 'eq' } },
{ field: fields.get('created'), config: { b_operator: 'between' } },
]
const ast = buildQuickAst(items, {
name: 'abc',
status: '',
created: { preset: 'this_month' },
})
expect(ast.children).toHaveLength(2)
expect(astToSql(ast, fields)).toBe(
"([name] LIKE N'%abc%' AND [created] BETWEEN N'2026-09-01 00:00:00' AND N'2026-09-30 23:59:59')",
)
})
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('更多筛选草稿只收已填值,未授权字段被忽略', () => {
const items = new Map([['name', { field: fields.get('name'), config: {} }]])
const ast = buildFilterStateAst(
{
name: { operator: 'like', value: 'a' },
status: { operator: 'eq', value: 'new' },
amount: { operator: 'eq', value: '' },
},
items,
)
expect(astToSql(ast, fields)).toBe("[name] LIKE N'%a%'")
expect(buildFilterStateAst({}, items)).toBeNull()
})
it('单条件 AST(非 group)也能还原成一行', () => {
const state = advancedQueryFromAst(
{ type: 'condition', field: 'status', operator: 'eq', value: 'draft' },
{ idFactory },
it('combineAst 把多个来源按「且」合并,空 AST 跳过', () => {
const ast = combineAst(
buildKeywordAst('东方', [{ field: fields.get('name'), config: {} }]),
{ type: 'group', children: [] },
{ type: 'condition', field: 'status', operator: 'eq', value: 'new' },
)
expect(state.nodes).toHaveLength(1)
expect(state.nodes[0]).toMatchObject({ field: 'status', operator: 'eq', value: 'draft' })
expect(astToSql(ast, fields)).toBe(
"(([name] LIKE N'%东方%') AND [status] = N'new')",
)
expect(combineAst(null, { type: 'group', children: [] })).toBeNull()
})
it('条件回显文案:字段名 + 操作符 + 值,未填值返回空串', () => {
const items = new Map([
['name', { field: { b_field: 'name', b_name: '姓名', b_type: 'input' } }],
['status', { field: { b_field: 'status', b_name: '状态', b_type: 'select' } }],
])
expect(
describeConditionText({ field: 'name', operator: 'like', value: '东方' }, items),
).toBe('姓名 包含 东方')
expect(
describeConditionText({ field: 'status', operator: 'is_null', value: '' }, items),
).toBe('状态 为空')
expect(describeConditionText({ field: 'name', operator: 'like', value: '' }, items)).toBe('')
expect(describeConditionText({ field: 'ghost', operator: 'eq', value: 'x' }, items)).toBe('')
})
})
@@ -1,13 +1,12 @@
/**
* 界面配置 JSON → 运行时渲染配置 适配层测试(fms-module-common/schemaRender)。
* 覆盖 view→列表列、edit→分组/编辑行、query→查询条件,以及栏位设置回写 JSON。
* 覆盖 view→列表列、edit→分组/编辑行、query→查询入口,以及栏位设置回写 JSON。
*/
import { describe, expect, it } from 'vitest'
import {
buildListConfig,
buildEditRender,
buildQueryRender,
orderQueryConditions,
applyColumnSettings,
} from '@/components/fms-module-common/schemaRender'
import { parseOptions } from '@/components/fms-module-common/fieldUtils'
@@ -75,32 +74,78 @@ describe('buildEditRender(edit JSON → 分组 + 编辑行)', () => {
})
})
describe('buildQueryRender / orderQueryConditions(query JSON → 查询条件)', () => {
describe('buildQueryRender(query JSON → 查询入口)', () => {
const fields = [
{ b_field: 'b_no', b_name: '单号', b_type: 'input' },
{ b_field: 'b_status', b_name: '状态', b_type: 'select' },
{ b_field: 'b_created', b_name: '创建时间', b_type: 'datetime' },
{ b_field: 'b_denied', b_name: '无权限字段', b_type: 'input' },
]
const query = {
schemaVersion: 1,
quick: ['b_status'],
groups: [
{
logic: 'and',
conditions: [
{ field: 'b_no', operator: 'like' },
{ field: 'b_status', operator: 'eq', component: 'select', defaultValue: '1' },
],
},
schemaVersion: 2,
defaultViewCode: 'pending',
views: [
{ code: 'all', label: '全部', order: 20 },
{ code: 'pending', label: '待处理', order: 10, showCount: true,
conditions: [{ field: 'b_status', operator: 'eq', value: '1' }] },
{ code: 'ghost', label: '幽灵页签', order: 30,
conditions: [{ field: 'b_denied', operator: 'eq', value: 'x' }] },
],
keywordSearch: { placeholder: '搜索', trigger: 'enter_or_debounce', fields: ['b_no', 'b_denied'] },
quickFilters: [
{ field: 'b_created', order: 20 },
{ field: 'b_status', order: 10 },
{ field: 'b_denied', order: 30 },
],
filterGroups: [
{ code: 'basic', label: '基础', order: 10, fields: ['b_no', 'b_status', 'b_denied'] },
{ code: 'time', label: '时间', order: 20, fields: ['b_created'] },
],
queryFields: {
b_status: { component: 'select', operator: 'eq' },
b_created: { component: 'datetime', operator: 'between' },
},
}
it('展开条件并透出 quick', () => {
const { quick, conditions } = buildQueryRender(query)
expect(quick).toEqual(['b_status'])
expect(conditions.map((c) => c.field)).toEqual(['b_no', 'b_status'])
expect(conditions[1]).toMatchObject({ operator: 'eq', component: 'select', defaultValue: '1' })
it('产出页签 / 关键词 / 快捷筛选 / 分组,条目按 order 排序', () => {
const render = buildQueryRender(query, fields)
expect(render.views.map((v) => [v.code, v.showCount])).toEqual([
['pending', true],
['all', false],
['ghost', false],
])
expect(render.defaultViewCode).toBe('pending')
expect(render.quickFilters.map((item) => item.field.b_field)).toEqual(['b_status', 'b_created'])
expect(render.filterGroups.map((g) => g.code)).toEqual(['basic', 'time'])
expect(render.keywordSearch.items.map((item) => item.field.b_field)).toEqual(['b_no'])
})
it('quick 字段优先排序', () => {
const { quick, conditions } = buildQueryRender(query)
const ordered = orderQueryConditions(conditions, quick)
expect(ordered.map((c) => c.field)).toEqual(['b_status', 'b_no'])
it('字段项统一为 FmsQueryControl 消费的形状,算子缺省按字段类型推导', () => {
const render = buildQueryRender(query, fields)
const statusItem = render.quickFilters[0]
expect(statusItem.config).toMatchObject({ b_field: 'b_status', b_component: 'select', b_operator: 'eq' })
const createdItem = render.filterGroups[1].items[0]
expect(createdItem.config.b_operator).toBe('between')
// queryFields 未登记的字段按 b_type 推导:input → like
const noItem = render.filterGroups[0].items[0]
expect(noItem.config.b_operator).toBe('like')
})
it('无权限字段不进任何入口;引用它的页签整条丢弃', () => {
const render = buildQueryRender(query, fields, new Set(['b_no', 'b_status', 'b_created']))
expect(render.views.map((v) => v.code)).toEqual(['pending', 'all'])
expect(render.quickFilters.map((item) => item.field.b_field)).toEqual(['b_status', 'b_created'])
expect(render.keywordSearch).toBeNull()
expect(render.filterGroups[0].items.map((item) => item.field.b_field)).toEqual(['b_no', 'b_status'])
})
it('未配置查询时返回空结构', () => {
const render = buildQueryRender(null, fields)
expect(render.views).toEqual([])
expect(render.defaultViewCode).toBe('')
expect(render.keywordSearch).toBeNull()
expect(render.quickFilters).toEqual([])
expect(render.filterGroups).toEqual([])
})
})
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { nextTick } from 'vue'
import { mount } from '@vue/test-utils'
import WorkflowCanvas from '@/views/system/workflow/designer/WorkflowCanvas.vue'
// jsdom 不实现 SVG 测量,LogicFlow 的节点文本(autoWrap)会用到
if (typeof SVGElement !== 'undefined' && !SVGElement.prototype.getBBox) {
SVGElement.prototype.getBBox = () => ({ x: 0, y: 0, width: 0, height: 0 })
}
/** 一个坐标明显重叠的节点 */
function stackedNode(nodeKey, nodeType, name) {
return { nodeKey, nodeType, name, x: 300, y: 300, config: {}, actorRules: [] }
}
/** 三个节点全叠在同一个坐标上,布局后应该被拉开成纵向流程 */
function messyDraft() {
return {
schemaVersion: 1,
definitionId: 'expense_approval',
name: '费用报销审批',
nodes: [
stackedNode('start', 'start', '开始'),
stackedNode('user_task_1', 'user_task', '部门负责人审批'),
stackedNode('end', 'end', '结束'),
],
edges: [
{ edgeKey: 'edge_1', from: 'start', to: 'user_task_1', name: '', condition: null },
{ edgeKey: 'edge_2', from: 'user_task_1', to: 'end', name: '', condition: null },
],
}
}
describe('画布一键整理(@logicflow/layout 的 dagre 插件)', () => {
it('把重叠节点按纵向流程拉开,并把新坐标回报给上层', async () => {
const wrapper = mount(WorkflowCanvas, {
props: { draft: messyDraft() },
attachTo: document.body,
})
await nextTick()
const changed = wrapper.vm.autoLayout()
expect(changed).toBe(true)
// 布局结果必须回报给上层,否则草稿里的 x/y 还是旧的
const emitted = wrapper.emitted('graph-change')
expect(emitted).toBeTruthy()
const graph = emitted[emitted.length - 1][0]
const byId = Object.fromEntries(graph.nodes.map((item) => [item.id, item]))
// 纵向(rankdir: TB):开始在上、结束在下,且三个节点不再重叠
expect(byId.start.y).toBeLessThan(byId.user_task_1.y)
expect(byId.user_task_1.y).toBeLessThan(byId.end.y)
expect(new Set(graph.nodes.map((item) => `${item.x},${item.y}`)).size).toBe(3)
wrapper.unmount()
})
it('布局只改坐标:节点与连线的编码、拓扑原样保留', async () => {
const wrapper = mount(WorkflowCanvas, {
props: { draft: messyDraft() },
attachTo: document.body,
})
await nextTick()
wrapper.vm.autoLayout()
const emitted = wrapper.emitted('graph-change')
const graph = emitted[emitted.length - 1][0]
// 编码稳定,上层才能按 nodeKey / edgeKey 回填审批人与条件
expect(graph.nodes.map((item) => item.id).toSorted()).toEqual(['end', 'start', 'user_task_1'])
expect(graph.edges.map((item) => item.id).toSorted()).toEqual(['edge_1', 'edge_2'])
expect(graph.edges.map((item) => [item.sourceNodeId, item.targetNodeId])).toEqual([
['start', 'user_task_1'],
['user_task_1', 'end'],
])
wrapper.unmount()
})
})