505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
/**
|
|
* querySchema:查询配置的解析 / 派生 / 自检。
|
|
*
|
|
* 重点覆盖三块最容易回归的逻辑:
|
|
* 1. 宽容解析——库里存着 12 条空壳与一批旧结构(groups / quick),不认识就按空处理,
|
|
* 不能抛错、也不能把两种结构混着渲染;
|
|
* 2. 「更多查询」派生——这是唯一**不落库**的部分,规则错了配置界面与列表页会对不上;
|
|
* 3. 保存前自检——这些是「运行时一定拼不出合法 SQL」的硬错误。
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
createEmptyQuerySchema,
|
|
defaultOperatorOf,
|
|
deriveMoreEntries,
|
|
inferComponent,
|
|
needsOptions,
|
|
operatorOptionsOf,
|
|
parseQuerySchema,
|
|
pinnedFieldSet,
|
|
TAGS_OPERATOR,
|
|
validateQuerySchema,
|
|
} from '@/utils/querySchema'
|
|
|
|
describe('parseQuerySchema 宽容解析', () => {
|
|
it('空值 / 非法 JSON 回退到空配置', () => {
|
|
for (const raw of [null, undefined, '', ' ', '{不是json}', '[]', '123']) {
|
|
const schema = parseQuerySchema(raw)
|
|
expect(schema.keyword.enabled).toBe(true)
|
|
expect(schema.keyword.fields).toEqual([])
|
|
expect(schema.filters).toEqual([])
|
|
expect(schema.customs).toEqual([])
|
|
}
|
|
})
|
|
|
|
it('旧结构(groups / quick)不被识别,解析成空配置', () => {
|
|
// 库里确实存在这种行(b_city 等 12 条),刻意不做兼容
|
|
const schema = parseQuerySchema({
|
|
schemaVersion: 1,
|
|
quick: ['b_name'],
|
|
groups: [{ conditions: [{ field: 'b_name', operator: 'like' }] }],
|
|
})
|
|
|
|
expect(schema.keyword.fields).toEqual([])
|
|
expect(schema.filters).toEqual([])
|
|
})
|
|
|
|
it('完整结构能原样读回来', () => {
|
|
const schema = parseQuerySchema({
|
|
keyword: { enabled: false, fields: ['b_name', 'b_id'] },
|
|
filters: [
|
|
{ field: 'b_name', pinned: true, component: 'input', operator: 'like' },
|
|
{ field: 'b_city', pinned: false },
|
|
],
|
|
customs: [
|
|
{ key: 'q1', label: '仅看逾期', component: 'checkbox', condition: 'b_x < 1', pinned: true },
|
|
],
|
|
})
|
|
|
|
expect(schema.keyword).toEqual({
|
|
enabled: false,
|
|
fields: ['b_name', 'b_id'],
|
|
})
|
|
// 旧结构里的 placeholder 不再解析(配置界面已不提供该项)
|
|
expect('placeholder' in schema.keyword).toBe(false)
|
|
expect(schema.filters).toHaveLength(2)
|
|
expect(schema.filters[0]).toEqual({
|
|
field: 'b_name',
|
|
pinned: true,
|
|
component: 'input',
|
|
operator: 'like',
|
|
})
|
|
// 未写 pinned 的项保持 undefined(不擅自补 false,避免与「显式收入更多」混淆)
|
|
expect(schema.filters[1].pinned).toBe(false)
|
|
expect(schema.customs[0].condition).toBe('b_x < 1')
|
|
})
|
|
|
|
it('缺 field 的 filter、缺 key/label 的 custom 被丢弃', () => {
|
|
const schema = parseQuerySchema({
|
|
filters: [{ pinned: true }, { field: 'ok' }],
|
|
customs: [
|
|
{ key: 'q1', component: 'checkbox' }, // 缺 label
|
|
{ label: '无 key', component: 'checkbox' }, // 缺 key
|
|
{ key: 'q2', label: '合法项', component: 'checkbox' },
|
|
],
|
|
})
|
|
|
|
expect(schema.filters.map((item) => item.field)).toEqual(['ok'])
|
|
expect(schema.customs.map((item) => item.key)).toEqual(['q2'])
|
|
})
|
|
|
|
it('选项来源两种形态都能解析,非法 source 丢弃', () => {
|
|
const schema = parseQuerySchema({
|
|
filters: [
|
|
{
|
|
field: 'a',
|
|
options: { source: 'static', items: [{ label: '已核销', value: '1' }] },
|
|
},
|
|
{ field: 'b', options: { source: 'sql', sql: 'select 1' } },
|
|
{ field: 'c', options: { source: '未知', sql: 'x' } },
|
|
],
|
|
})
|
|
|
|
expect(schema.filters[0].options).toEqual({
|
|
source: 'static',
|
|
items: [{ label: '已核销', value: '1' }],
|
|
})
|
|
expect(schema.filters[1].options).toEqual({ source: 'sql', sql: 'select 1' })
|
|
expect(schema.filters[2].options).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('控件与操作符', () => {
|
|
it('按字段类型推断控件,未知类型回退 input', () => {
|
|
expect(inferComponent('textarea')).toBe('textarea')
|
|
expect(inferComponent('number')).toBe('number')
|
|
expect(inferComponent('money')).toBe('money')
|
|
expect(inferComponent('date')).toBe('date')
|
|
expect(inferComponent('datetime')).toBe('datetime')
|
|
expect(inferComponent('checkbox')).toBe('checkbox')
|
|
expect(inferComponent('select')).toBe('select')
|
|
expect(inferComponent('input')).toBe('input')
|
|
expect(inferComponent(null)).toBe('input')
|
|
expect(inferComponent('没见过的类型')).toBe('input')
|
|
})
|
|
|
|
it('各控件只给出类型上说得通的操作符', () => {
|
|
// 文本没有「大于」
|
|
expect(operatorOptionsOf('input').map((o) => o.value)).toEqual(['like', 'eq', 'ne'])
|
|
// 数字没有「包含」
|
|
expect(operatorOptionsOf('number').map((o) => o.value)).not.toContain('like')
|
|
// 复选框只有等值
|
|
expect(operatorOptionsOf('checkbox').map((o) => o.value)).toEqual(['eq'])
|
|
// tags 已从控件体系移除:它走独立的 QueryTag 组,固定用 TAGS_OPERATOR
|
|
expect(operatorOptionsOf('tags').map((o) => o.value)).toEqual(['like', 'eq', 'ne'])
|
|
expect(TAGS_OPERATOR).toBe('in')
|
|
})
|
|
|
|
it('默认操作符取各自可用列表的第一项', () => {
|
|
expect(defaultOperatorOf('input')).toBe('like')
|
|
expect(defaultOperatorOf('number')).toBe('eq')
|
|
expect(defaultOperatorOf('datetime')).toBe('eq')
|
|
})
|
|
|
|
it('只有 select 需要配选项(tags 是独立结构,必然自带选项)', () => {
|
|
expect(needsOptions('select')).toBe(true)
|
|
expect(needsOptions('tags')).toBe(false)
|
|
expect(needsOptions('input')).toBe(false)
|
|
expect(needsOptions('checkbox')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('「更多查询」派生(不落库)', () => {
|
|
const FIELDS = new Map<string, { b_name?: unknown; b_type?: unknown }>([
|
|
['b_name', { b_name: '名称', b_type: 'input' }],
|
|
['b_city', { b_name: '城市', b_type: 'select' }],
|
|
['b_created_at', { b_name: '创建时间', b_type: 'datetime' }],
|
|
['b_remark', { b_name: '备注', b_type: 'input' }],
|
|
])
|
|
|
|
it('列表列里未被普通查询占用的列,自动进更多查询', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.filters = [{ field: 'b_name', pinned: true }]
|
|
const columns = [{ field: 'b_name' }, { field: 'b_city' }, { field: 'b_created_at' }]
|
|
|
|
const entries = deriveMoreEntries(schema, columns, FIELDS)
|
|
|
|
// b_name 已在普通查询 → 排除;其余两列衍生出来
|
|
expect(entries.map((e) => e.field)).toEqual(['b_city', 'b_created_at'])
|
|
expect(entries.every((e) => e.from === 'column')).toBe(true)
|
|
// 名称与控件按字段元数据带出来
|
|
expect(entries[0]).toMatchObject({ label: '城市', component: 'select' })
|
|
expect(entries[1]).toMatchObject({ label: '创建时间', component: 'datetime' })
|
|
})
|
|
|
|
it('列表列里没有、但显式收进更多查询的条件也会出现', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.filters = [{ field: 'b_remark', pinned: false }]
|
|
|
|
const entries = deriveMoreEntries(schema, [{ field: 'b_name' }], FIELDS)
|
|
|
|
expect(entries.map((e) => [e.field, e.from])).toEqual([
|
|
['b_name', 'column'],
|
|
['b_remark', 'filter'],
|
|
])
|
|
})
|
|
|
|
it('自定义项按 key 出现在更多查询里', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.customs = [
|
|
{ key: 'q1', label: '仅看逾期', component: 'checkbox', pinned: false },
|
|
]
|
|
|
|
const entries = deriveMoreEntries(schema, [], FIELDS)
|
|
|
|
expect(entries).toEqual([
|
|
{ field: 'q1', label: '仅看逾期', component: 'checkbox', from: 'custom' },
|
|
])
|
|
})
|
|
|
|
it('同一字段既在列表列又在 filters 里,只出现一次', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.filters = [{ field: 'b_city', pinned: false }]
|
|
|
|
const entries = deriveMoreEntries(schema, [{ field: 'b_city' }], FIELDS)
|
|
|
|
expect(entries).toHaveLength(1)
|
|
expect(entries[0].from).toBe('column')
|
|
})
|
|
|
|
it('没有字段元数据时用字段名本身作标签', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
|
|
const entries = deriveMoreEntries(schema, [{ field: 'unknown_col' }])
|
|
|
|
expect(entries[0]).toMatchObject({ field: 'unknown_col', label: 'unknown_col', component: 'input' })
|
|
})
|
|
|
|
it('列里缺 field 的项被跳过(不产生空条目)', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
|
|
const entries = deriveMoreEntries(schema, [{}, { field: '' }, { field: 'b_name' }])
|
|
|
|
expect(entries.map((e) => e.field)).toEqual(['b_name'])
|
|
})
|
|
|
|
it('pinnedFieldSet 汇总普通查询占用的字段,含自定义项的 field', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.filters = [
|
|
{ field: 'a', pinned: true },
|
|
{ field: 'b', pinned: false },
|
|
]
|
|
schema.customs = [
|
|
{ key: 'q1', label: '固定条件', component: 'checkbox', pinned: true },
|
|
{ key: 'q2', label: '虚拟字段', component: 'number', field: 'c', pinned: true },
|
|
{ key: 'q3', label: '收起来的', component: 'number', field: 'd', pinned: false },
|
|
]
|
|
|
|
expect([...pinnedFieldSet(schema)].sort()).toEqual(['a', 'c'])
|
|
})
|
|
})
|
|
|
|
describe('validateQuerySchema 自检', () => {
|
|
it('空配置无问题', () => {
|
|
expect(validateQuerySchema(createEmptyQuerySchema())).toEqual([])
|
|
})
|
|
|
|
it('同一字段在 filters 里重复出现会报错', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.filters = [{ field: 'a', pinned: true }, { field: 'a', pinned: false }]
|
|
|
|
expect(validateQuerySchema(schema).join()).toContain('重复出现')
|
|
})
|
|
|
|
it('自定义项 key 重复会报错', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.customs = [
|
|
{ key: 'q1', label: 'A', component: 'checkbox', condition: 'x = 1' },
|
|
{ key: 'q1', label: 'B', component: 'checkbox', condition: 'y = 1' },
|
|
]
|
|
|
|
expect(validateQuerySchema(schema).join()).toContain('重复')
|
|
})
|
|
|
|
it('固定条件形态缺 condition 会报错;虚拟字段形态缺 field 也会', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.customs = [
|
|
{ key: 'q1', label: '缺条件', component: 'checkbox' },
|
|
{ key: 'q2', label: '缺字段', component: 'number' },
|
|
]
|
|
|
|
const issues = validateQuerySchema(schema)
|
|
expect(issues.join()).toContain('必须填写条件片段')
|
|
expect(issues.join()).toContain('缺少字段或字段表达式')
|
|
})
|
|
|
|
it('缺名称会报错', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.customs = [{ key: 'q1', label: '', component: 'checkbox', condition: 'x = 1' }]
|
|
|
|
expect(validateQuerySchema(schema).join()).toContain('缺少名称')
|
|
})
|
|
|
|
it('select 缺选项会报错,配了选项则不报', () => {
|
|
const bad = createEmptyQuerySchema()
|
|
bad.filters = [{ field: 'a', component: 'select' }]
|
|
expect(validateQuerySchema(bad).join()).toContain('需要配置选项')
|
|
|
|
const good = createEmptyQuerySchema()
|
|
good.filters = [
|
|
{ field: 'a', component: 'select', options: { source: 'sql', sql: 'select 1 as value' } },
|
|
]
|
|
expect(validateQuerySchema(good)).toEqual([])
|
|
})
|
|
|
|
it('静态选项为空数组视为没配', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.filters = [{ field: 'a', component: 'select', options: { source: 'static', items: [] } }]
|
|
|
|
expect(validateQuerySchema(schema).join()).toContain('需要配置选项')
|
|
})
|
|
})
|
|
|
|
/* ============================================================================
|
|
* 多标签搜索(tags 组)
|
|
* ========================================================================== */
|
|
|
|
describe('多标签搜索', () => {
|
|
it('完整结构能解析(含 SQL 选项、数量、默认选中)', () => {
|
|
const schema = parseQuerySchema({
|
|
tags: [
|
|
{
|
|
key: 't1',
|
|
label: '核销状态',
|
|
field: 'b_writeoff_status',
|
|
pinned: true,
|
|
showCount: true,
|
|
defaultValues: ['0', '1'],
|
|
options: {
|
|
source: 'sql',
|
|
sql: 'select b_id as value, b_name as label, cnt as count from x',
|
|
},
|
|
},
|
|
],
|
|
})
|
|
|
|
expect(schema.tags).toHaveLength(1)
|
|
expect(schema.tags[0]).toMatchObject({
|
|
key: 't1',
|
|
label: '核销状态',
|
|
field: 'b_writeoff_status',
|
|
pinned: true,
|
|
showCount: true,
|
|
defaultValues: ['0', '1'],
|
|
})
|
|
expect(schema.tags[0].options?.source).toBe('sql')
|
|
})
|
|
|
|
it('缺 key / label / field 的组被丢弃', () => {
|
|
const schema = parseQuerySchema({
|
|
tags: [
|
|
{ label: '缺key', field: 'a' },
|
|
{ key: 't1', field: 'a' },
|
|
{ key: 't2', label: '缺field' },
|
|
{ key: 't3', label: '合法', field: 'a' },
|
|
],
|
|
})
|
|
expect(schema.tags.map((t) => t.key)).toEqual(['t3'])
|
|
})
|
|
|
|
it('静态选项的数量只有确实写了才保留(空串不落成 NaN)', () => {
|
|
const schema = parseQuerySchema({
|
|
tags: [
|
|
{
|
|
key: 't1',
|
|
label: '状态',
|
|
field: 'st',
|
|
options: {
|
|
source: 'static',
|
|
items: [
|
|
{ label: '待核销', value: '0', count: 12 },
|
|
{ label: '已核销', value: '1', count: '' },
|
|
{ label: '其它', value: '2', count: 'abc' },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
})
|
|
|
|
const items = schema.tags[0].options?.items ?? []
|
|
expect(items[0].count).toBe(12)
|
|
expect(items[1].count).toBeUndefined()
|
|
expect(items[2].count).toBeUndefined()
|
|
})
|
|
|
|
it('tags 组参与「更多查询」派生(按 key 去重,不与字段名混)', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.tags = [
|
|
{ key: 't1', label: '核销状态', field: 'b_st', pinned: false },
|
|
{ key: 't2', label: '审核状态', field: 'b_audit', pinned: true },
|
|
]
|
|
|
|
const entries = deriveMoreEntries(schema, [{ field: 'b_name' }])
|
|
|
|
// t2 在普通查询里 → 不出现在更多;t1 出现且来源标为 tag
|
|
expect(entries.map((e) => [e.field, e.from])).toEqual([
|
|
['b_name', 'column'],
|
|
['t1', 'tag'],
|
|
])
|
|
})
|
|
|
|
it('普通查询里的 tags 组,其字段从更多查询里排除', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.tags = [{ key: 't1', label: '核销状态', field: 'b_st', pinned: true }]
|
|
|
|
// b_st 这一列已经在普通查询(通过 tags 组)→ 不该再作为列衍生项出现
|
|
expect([...pinnedFieldSet(schema)]).toEqual(['b_st'])
|
|
expect(deriveMoreEntries(schema, [{ field: 'b_st' }])).toEqual([])
|
|
})
|
|
|
|
it('缺选项的 tags 组会报错', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.tags = [{ key: 't1', label: '核销状态', field: 'b_st' }]
|
|
|
|
expect(validateQuerySchema(schema).join()).toContain('需要配置选项')
|
|
})
|
|
|
|
it('刚新增、还什么都没填的 tags 组不报错(那是「还没配」,不是「配错了」)', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
// 「新增标签组」刚点下去的形态:有 key 与默认字段,但组名空、选项空
|
|
schema.tags = [
|
|
{ key: 't1', label: '', field: 'b_st', options: { source: 'sql', sql: '', items: [] } },
|
|
{ key: 't2', label: '', field: 'b_st', options: { source: 'static', items: [] } },
|
|
]
|
|
|
|
expect(validateQuerySchema(schema)).toEqual([])
|
|
})
|
|
|
|
it('只要填了组名或配了选项,就进入校验', () => {
|
|
// 填了组名 → 开始校验选项
|
|
const withLabel = createEmptyQuerySchema()
|
|
withLabel.tags = [
|
|
{ key: 't1', label: '审核状态', field: 'b_st', options: { source: 'sql', sql: '' } },
|
|
]
|
|
expect(validateQuerySchema(withLabel).join()).toContain('需要配置选项')
|
|
|
|
// 配了选项 → 开始校验组名
|
|
const withOptions = createEmptyQuerySchema()
|
|
withOptions.tags = [
|
|
{ key: 't1', label: '', field: 'b_st', options: { source: 'sql', sql: 'select 1' } },
|
|
]
|
|
expect(validateQuerySchema(withOptions).join()).toContain('缺少名称')
|
|
})
|
|
|
|
it('缺字段的 tags 组会报错', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.tags = [
|
|
{ key: 't1', label: '核销状态', field: '', options: { source: 'sql', sql: 'select 1' } },
|
|
]
|
|
|
|
expect(validateQuerySchema(schema).join()).toContain('缺少筛选字段')
|
|
})
|
|
|
|
it('tags 标识重复会报错', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
const options = { source: 'sql' as const, sql: 'select 1' }
|
|
schema.tags = [
|
|
{ key: 't1', label: 'A', field: 'a', options },
|
|
{ key: 't1', label: 'B', field: 'b', options },
|
|
]
|
|
|
|
expect(validateQuerySchema(schema).join()).toContain('重复')
|
|
})
|
|
|
|
it('开了数量但静态选项都没写数量,会报错', () => {
|
|
const schema = createEmptyQuerySchema()
|
|
schema.tags = [
|
|
{
|
|
key: 't1',
|
|
label: '核销状态',
|
|
field: 'b_st',
|
|
showCount: true,
|
|
options: {
|
|
source: 'static',
|
|
items: [
|
|
{ label: '待核销', value: '0' },
|
|
{ label: '已核销', value: '1' },
|
|
],
|
|
},
|
|
},
|
|
]
|
|
|
|
expect(validateQuerySchema(schema).join()).toContain('都没写数量')
|
|
})
|
|
|
|
it('开了数量且写了数量则不报;用 SQL 选项也不报', () => {
|
|
const withCounts = createEmptyQuerySchema()
|
|
withCounts.tags = [
|
|
{
|
|
key: 't1',
|
|
label: '核销状态',
|
|
field: 'b_st',
|
|
showCount: true,
|
|
options: {
|
|
source: 'static',
|
|
items: [{ label: '待核销', value: '0', count: 3 }],
|
|
},
|
|
},
|
|
]
|
|
expect(validateQuerySchema(withCounts)).toEqual([])
|
|
|
|
const withSql = createEmptyQuerySchema()
|
|
withSql.tags = [
|
|
{
|
|
key: 't1',
|
|
label: '核销状态',
|
|
field: 'b_st',
|
|
showCount: true,
|
|
options: { source: 'sql', sql: 'select 1 as value, 2 as label, 3 as count' },
|
|
},
|
|
]
|
|
expect(validateQuerySchema(withSql)).toEqual([])
|
|
})
|
|
})
|