280 lines
11 KiB
JavaScript
280 lines
11 KiB
JavaScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
astToSql,
|
|
buildFilterStateAst,
|
|
buildKeywordAst,
|
|
buildKeywordPlaceholder,
|
|
buildQuickAst,
|
|
buildViewAst,
|
|
combineAst,
|
|
defaultOperator,
|
|
describeConditionText,
|
|
emptyConditionValue,
|
|
hasConditionValue,
|
|
isDatePresetValue,
|
|
operatorsForField,
|
|
resolveDatePreset,
|
|
resolveDatePresetValue,
|
|
} from '@/components/fms-module-common/queryUtils'
|
|
|
|
const fields = new Map([
|
|
['name', { b_field: 'name', b_type: 'input' }],
|
|
['amount', { b_field: 'amount', b_type: 'money' }],
|
|
['created', { b_field: 'created', b_type: 'datetime' }],
|
|
['status', { b_field: 'status', b_type: 'select' }],
|
|
])
|
|
|
|
const pad2 = (number) => String(number).padStart(2, '0')
|
|
|
|
/**
|
|
* 本地墙钟串 → UTC 墙钟串。
|
|
* datetime 条件拼 SQL 前会折算成 UTC(库中存 UTC,见后端 TimeUtils),
|
|
* 断言用同一折算、不依赖运行环境时区。
|
|
*/
|
|
function toUtcWall(localText) {
|
|
const date = new Date(String(localText).replace(' ', 'T'))
|
|
return `${date.getUTCFullYear()}-${pad2(date.getUTCMonth() + 1)}-${pad2(date.getUTCDate())} ${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}:${pad2(date.getUTCSeconds())}`
|
|
}
|
|
|
|
describe('查询条件 AST 构造', () => {
|
|
it('按字段类型给出默认操作符:文本 like、日期 between、其余 eq', () => {
|
|
expect(defaultOperator({ b_type: 'input' })).toBe('like')
|
|
expect(defaultOperator({ b_type: 'datetime' })).toBe('between')
|
|
expect(defaultOperator({ b_type: 'select' })).toBe('eq')
|
|
expect(operatorsForField({ b_type: 'money' })).toContain('between')
|
|
})
|
|
|
|
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%'")
|
|
expect(
|
|
astToSql({ type: 'condition', field: 'status', operator: 'ne', value: 'done' }, fields),
|
|
).toBe("[status] <> N'done'")
|
|
expect(
|
|
astToSql({ type: 'condition', field: 'name', operator: 'is_null', value: '' }, fields),
|
|
).toBe('[name] IS NULL')
|
|
})
|
|
|
|
it('区间条件可退化为单边比较,日期单边取对应端点', () => {
|
|
expect(
|
|
astToSql(
|
|
{ type: 'condition', field: 'amount', operator: 'between', value: ['10', ''] },
|
|
fields,
|
|
),
|
|
).toBe('[amount] >= 10')
|
|
expect(
|
|
astToSql(
|
|
{ type: 'condition', field: 'created', operator: 'ge', value: ['2026-01-01', ''] },
|
|
fields,
|
|
),
|
|
).toBe(`[created] >= N'${toUtcWall('2026-01-01 00:00:00')}'`)
|
|
expect(
|
|
astToSql(
|
|
{ type: 'condition', field: 'created', operator: 'le', value: ['', '2026-01-31'] },
|
|
fields,
|
|
),
|
|
).toBe(`[created] <= N'${toUtcWall('2026-01-31 23:59:59')}'`)
|
|
})
|
|
|
|
it('嵌套条件组按各自逻辑加括号,空组不产生条件', () => {
|
|
const ast = {
|
|
type: 'group',
|
|
logic: 'and',
|
|
children: [
|
|
{ type: 'condition', field: 'status', operator: 'eq', value: 'new' },
|
|
{
|
|
type: 'group',
|
|
logic: 'or',
|
|
children: [
|
|
{ type: 'condition', field: 'name', operator: 'like', value: 'a' },
|
|
{ type: 'condition', field: 'name', operator: 'like', value: '' },
|
|
],
|
|
},
|
|
],
|
|
}
|
|
expect(astToSql(ast, fields)).toBe("([status] = N'new' AND [name] LIKE N'%a%')")
|
|
expect(astToSql({ type: 'group', logic: 'and', children: [] }, fields)).toBe('')
|
|
})
|
|
|
|
it('区间 / 多选只要有一项即算已填写,空值按操作符重置', () => {
|
|
expect(hasConditionValue(['', '20'])).toBe(true)
|
|
expect(hasConditionValue(['', ''])).toBe(false)
|
|
expect(hasConditionValue(undefined)).toBe(false)
|
|
expect(emptyConditionValue({ b_type: 'money' }, 'between')).toEqual(['', ''])
|
|
expect(emptyConditionValue({ b_type: 'select' }, 'eq')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
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('语义值 { preset } 只在组装 AST 时解析,配置不写死日期', () => {
|
|
expect(isDatePresetValue({ preset: 'today' })).toBe(true)
|
|
expect(isDatePresetValue(['2026-09-23'])).toBe(false)
|
|
expect(resolveDatePresetValue('2026-09-23')).toBe('2026-09-23')
|
|
// 不写死基准日:按实现同源解析「今天」,避免测试随日期流逝变成时间炸弹。
|
|
// preset 由组装 AST 的入口(buildViewAst / buildQuickAst / buildFilterStateAst)解析,
|
|
// astToSql 只认已解析的区间数组,所以这里走页签条件入口。
|
|
const [start, end] = resolveDatePresetValue({ preset: 'today' })
|
|
expect(start).toBe(end)
|
|
expect(start).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
|
expect(
|
|
astToSql(
|
|
buildViewAst(
|
|
{
|
|
code: 'today',
|
|
conditions: [
|
|
{ field: 'created', operator: 'between', value: { preset: 'today' } },
|
|
],
|
|
},
|
|
fields,
|
|
),
|
|
fields,
|
|
),
|
|
).toBe(
|
|
`[created] BETWEEN N'${toUtcWall(`${start} 00:00:00`)}' AND N'${toUtcWall(`${end} 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 ast = buildViewAst(view, fields)
|
|
const [start, end] = resolveDatePresetValue({ preset: 'today' })
|
|
expect(astToSql(ast, fields)).toBe(
|
|
`([status] = N'失败' AND [created] BETWEEN N'${toUtcWall(`${start} 00:00:00`)}' AND N'${toUtcWall(`${end} 23:59:59`)}')`,
|
|
)
|
|
expect(buildViewAst({ code: 'all' }, fields)).toBeNull()
|
|
})
|
|
|
|
it('页签条件为 SQL 片段时整段成组,优先于旧结构 conditions', () => {
|
|
const view = {
|
|
code: 'pending',
|
|
conditionSql: "b_status = N'1'",
|
|
conditions: [{ field: 'status', operator: 'eq', value: '失败' }],
|
|
}
|
|
expect(astToSql(buildViewAst(view, fields), fields)).toBe("(b_status = N'1')")
|
|
expect(buildViewAst({ code: 'all', conditionSql: ' ' }, fields)).toBeNull()
|
|
})
|
|
|
|
it('页签 SQL 与其他来源按「且」合并', () => {
|
|
const ast = combineAst(
|
|
buildViewAst({ code: 'pending', conditionSql: "b_status = N'1'" }, fields),
|
|
buildKeywordAst('东方', [{ field: fields.get('name'), config: {} }]),
|
|
)
|
|
expect(astToSql(ast, fields)).toBe(
|
|
"((b_status = N'1') AND ([name] LIKE N'%东方%'))",
|
|
)
|
|
})
|
|
|
|
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('快捷筛选跳过空值,操作符取模块配置,日期预设被解析', () => {
|
|
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' },
|
|
})
|
|
const [monthStart, monthEnd] = resolveDatePresetValue({ preset: 'this_month' })
|
|
expect(ast.children).toHaveLength(2)
|
|
expect(astToSql(ast, fields)).toBe(
|
|
`([name] LIKE N'%abc%' AND [created] BETWEEN N'${toUtcWall(`${monthStart} 00:00:00`)}' AND N'${toUtcWall(`${monthEnd} 23:59:59`)}')`,
|
|
)
|
|
})
|
|
|
|
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('combineAst 把多个来源按「且」合并,空 AST 跳过', () => {
|
|
const ast = combineAst(
|
|
buildKeywordAst('东方', [{ field: fields.get('name'), config: {} }]),
|
|
{ type: 'group', children: [] },
|
|
{ type: 'condition', field: 'status', operator: 'eq', value: 'new' },
|
|
)
|
|
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('')
|
|
})
|
|
|
|
it('关键词占位文案按字段名生成:多于 5 个只列前 5 个加「等」', () => {
|
|
const makeItems = (labels) =>
|
|
labels.map((label, index) => ({
|
|
field: { b_field: `f${index}`, b_name: label, b_type: 'input' },
|
|
}))
|
|
expect(buildKeywordPlaceholder(makeItems(['单号', '客户']))).toBe('搜索 单号、客户')
|
|
expect(buildKeywordPlaceholder([])).toBe('')
|
|
expect(
|
|
buildKeywordPlaceholder(makeItems(['a', 'b', 'c', 'd', 'e', 'f'])),
|
|
).toBe('搜索 a、b、c、d、e 等')
|
|
// 传入翻译函数时前缀与「等」走多语言
|
|
expect(
|
|
buildKeywordPlaceholder(makeItems(['Order No']), (key, fallback) =>
|
|
key === 'common.keywordSearch' ? 'Search' : fallback,
|
|
),
|
|
).toBe('Search Order No')
|
|
})
|
|
})
|