/** * 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([ ['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([]) }) })