/* * Tencent is pleased to support the open source community by making TMagicEditor available. * * Copyright (C) 2025 Tencent. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest'; import { createApp, defineComponent } from 'vue'; import MagicForm, { builtInFields, clearFields, collectValidatableFields, createHeadlessFormState, getTypeMatchRule, isFieldInnerConfigError, isLeafFieldType, registerBuiltInFields, registerField, registerFields, unregisterField, validateValues, } from '@form/index'; import { required } from '../helpers/formValidation'; /** plugin.ts 注册的内置字段 type */ const BUILT_IN_FIELD_TYPES = [ 'text', 'img-upload', 'number', 'number-range', 'textarea', 'hidden', 'date', 'datetime', 'daterange', 'timerange', 'time', 'checkbox', 'switch', 'color-picker', 'checkbox-group', 'radio-group', 'display', 'link', 'select', 'cascader', 'dynamic-field', 'component', ]; /** 收集字段 prop 列表,用于断言「哪些字段参与了校验」 */ const collectProps = (config: any, values: any) => { const formState = createHeadlessFormState({ config, initValues: values }); formState.values = values; const fields = collectValidatableFields(formState, config, values); return { props: fields.map((f) => f.prop) }; }; beforeAll(() => { registerBuiltInFields(builtInFields); }); afterEach(() => { clearFields(); }); describe('validateValues —— 基础校验', () => { test('required 规则生效,错误文案使用字段 text', async () => { const { error, invalidFields } = await validateValues({ config: [{ type: 'text', name: 'name', text: '名称', rules: required() }] as any, initValues: { name: '' }, }); expect(error).toBe('名称 -> 必填'); expect(invalidFields).toHaveProperty('name'); }); test('校验通过时 error 为空字符串', async () => { const { error, invalidFields } = await validateValues({ config: [{ type: 'text', name: 'name', text: '名称', rules: required() }] as any, initValues: { name: 'a' }, }); expect(error).toBe(''); expect(invalidFields).toEqual({}); }); test('useFieldTextInError=false 时用字段 name 作为前缀', async () => { const { error } = await validateValues({ config: [{ type: 'text', name: 'name', text: '名称', rules: required() }] as any, initValues: { name: '' }, useFieldTextInError: false, }); expect(error).toBe('name -> 必填'); }); test('多条错误以
拼接', async () => { const { error } = await validateValues({ config: [ { type: 'text', name: 'a', text: 'A', rules: required('A必填') }, { type: 'text', name: 'b', text: 'B', rules: required('B必填') }, ] as any, initValues: { a: '', b: '' }, }); expect(error).toBe('A -> A必填
B -> B必填'); }); test('无 rules 的字段不参与校验', async () => { const { props } = collectProps([{ type: 'text', name: 'a', text: 'A' }], { a: '' }); expect(props).toEqual([]); }); test('trigger 只是 FormItem 的元信息,不影响规则匹配方式', async () => { // 若把 trigger 一起交给 async-validator,{ required: true } 会从 required 校验器 // 退化为 string 校验器,行为随之改变,这里断言两者结果一致 const withTrigger = await validateValues({ config: [ { type: 'text', name: 'a', text: 'A', rules: [{ required: true, message: '必填', trigger: 'blur' }] }, ] as any, initValues: { a: '' }, }); const withoutTrigger = await validateValues({ config: [{ type: 'text', name: 'a', text: 'A', rules: required() }] as any, initValues: { a: '' }, }); expect(withTrigger.error).toBe(withoutTrigger.error); }); test('自定义 validator 可拿到 mForm 与 model 上下文', async () => { const validator = vi.fn(({ value, callback }: any, ctx: any, mForm: any) => { expect(ctx.model).toMatchObject({ a: 'x' }); expect(mForm.initValues).toMatchObject({ a: 'x' }); callback(value === 'x' ? new Error('不能是 x') : undefined); }); const { error } = await validateValues({ config: [{ type: 'text', name: 'a', text: 'A', rules: [{ validator }] }] as any, initValues: { a: 'x' }, }); expect(validator).toHaveBeenCalled(); expect(error).toBe('A -> 不能是 x'); }); test('typeMatchValid=true 时自动注入类型匹配校验', async () => { const { error } = await validateValues({ config: [{ type: 'number', name: 'n', text: '数字' }] as any, initValues: { n: 'not-a-number' }, typeMatchValid: true, }); expect(error).not.toBe(''); expect(error).toContain('数字'); }); test('context 注入的字段可被 mForm 读穿', async () => { const { error } = await validateValues({ config: [ { type: 'text', name: 'a', text: 'A', rules: required(), display: (mForm: any) => mForm?.custom === 'on', }, ] as any, initValues: { a: '' }, context: { custom: 'on' }, }); expect(error).toBe('A -> 必填'); }); test('context 不能覆盖 formState 内置字段', async () => { const { values } = await validateValues({ config: [{ type: 'text', name: 'a', text: 'A' }] as any, initValues: { a: 'origin' }, context: { keyProp: 'hacked', initValues: { a: 'hacked' } } as any, }); expect(values).toEqual({ a: 'origin' }); }); test('context 注入的字段可被 display 通过 mForm 读到', async () => { const { error } = await validateValues({ config: [ { type: 'text', name: 'a', text: 'A', rules: required(), display: (mForm: any) => mForm?.custom === 'on', }, ] as any, initValues: { a: '' }, context: { custom: 'on' }, }); expect(error).toBe('A -> 必填'); }); test('同一次校验中各回调从 mForm 读到同一份 context', async () => { const seen: any[] = []; const display = (mForm: any) => { seen.push({ username: mForm?.username, env: mForm?.env }); return true; }; await validateValues({ config: [ { type: 'text', name: 'a', text: 'A', display }, { type: 'text', name: 'b', text: 'B', display }, ] as any, initValues: { a: '1', b: '2' }, context: { username: 'alice', env: 'prod' }, }); expect(seen.length).toBeGreaterThan(1); expect(seen.every((c) => c.username === 'alice' && c.env === 'prod')).toBe(true); }); }); describe('validateValues —— display 判定', () => { test('display: false 的字段不参与校验', async () => { const { props } = collectProps([{ type: 'text', name: 'a', text: 'A', display: false, rules: required() }], { a: '', }); expect(props).toEqual([]); }); test('display 函数返回 false 的字段不参与校验', async () => { const { props } = collectProps([{ type: 'text', name: 'a', text: 'A', display: () => false, rules: required() }], { a: '', }); expect(props).toEqual([]); }); test("display: 'expand' 的字段参与校验(展开与否是交互状态)", async () => { const { props } = collectProps([{ type: 'text', name: 'a', text: 'A', display: 'expand', rules: required() }], { a: '', }); expect(props).toEqual(['a']); }); test('hidden 字段不看 display,始终参与校验', async () => { const { props } = collectProps([{ type: 'hidden', name: 'a', display: false, rules: required() }], { a: '' }); expect(props).toEqual(['a']); }); }); describe('validateValues —— 容器的 prop 路径', () => { test('无 type 的嵌套 items 按 name 下钻', () => { const { props } = collectProps( [{ name: 'obj', items: [{ type: 'text', name: 'inner', text: 'I', rules: required() }] }], { obj: { inner: '' } }, ); expect(props).toEqual(['obj.inner']); }); test('无 name 的嵌套 items 不增加层级', () => { const { props } = collectProps([{ items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] }], { a: '' }); expect(props).toEqual(['a']); }); test('row / flex-layout 透传 prop', () => { const row = collectProps([{ type: 'row', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] }], { a: '', }); expect(row.props).toEqual(['a']); const flex = collectProps( [{ type: 'flexLayout', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] }], { a: '' }, ); expect(flex.props).toEqual(['a']); }); test('panel 折叠不影响校验', () => { const { props } = collectProps( [ { type: 'panel', name: 'p', expand: false, items: [{ type: 'text', name: 'a', text: 'A', rules: required() }], }, ], { p: { a: '' } }, ); expect(props).toEqual(['p.a']); }); test('tab:按 tab.name 拼接 prop,lazy 标签页也参与校验', () => { const { props } = collectProps( [ { type: 'tab', items: [ { title: 'T1', name: 't1', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] }, { title: 'T2', name: 't2', lazy: true, items: [{ type: 'text', name: 'b', text: 'B', rules: required() }] }, ], }, ], { t1: { a: '' }, t2: { b: '' } }, ); expect(props).toEqual(['t1.a', 't2.b']); }); test('tab:display 为假的标签页不参与校验', () => { const { props } = collectProps( [ { type: 'tab', items: [ { title: 'T1', name: 't1', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] }, { title: 'T2', name: 't2', display: () => false, items: [{ type: 'text', name: 'b', text: 'B', rules: required() }], }, ], }, ], { t1: { a: '' }, t2: { b: '' } }, ); expect(props).toEqual(['t1.a']); }); test('dynamic tab:按下标拼接 prop', () => { const { props } = collectProps( [ { type: 'tab', dynamic: true, name: 'tabs', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }], }, ], { tabs: [{ a: '' }, { a: '' }] }, ); expect(props).toEqual(['tabs.0.a', 'tabs.1.a']); }); test('fieldset:勾选框关闭时子项不参与校验', () => { const config = [ { type: 'fieldset', name: 'fs', expand: true, checkbox: true, items: [{ type: 'text', name: 'a', text: 'A', rules: required() }], }, ]; expect(collectProps(config, { fs: { value: 0, a: '' } }).props).toEqual([]); expect(collectProps(config, { fs: { value: 1, a: '' } }).props).toEqual(['fs.a']); }); test('step:prop 基准被重置为 step.name', () => { const { props } = collectProps( [ { type: 'step', items: [ { title: 'S1', name: 's1', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }] }, { title: 'S2', name: 's2', items: [{ type: 'text', name: 'b', text: 'B', rules: required() }] }, ], }, ], { s1: { a: '' }, s2: { b: '' } }, ); expect(props).toEqual(['s1.a', 's2.b']); }); test('group-list:每一行都参与校验,包括默认折叠的行', () => { const rows = Array.from({ length: 9 }, (_, i) => ({ title: `${i}` })); const { props } = collectProps( [ { type: 'group-list', name: 'list', items: [{ type: 'text', name: 'title', text: '标题', rules: required() }], }, ], { list: rows }, ); // defaultExpandQuantity 默认为 7,渲染式校验会漏掉第 8 行起的字段 expect(props).toHaveLength(9); expect(props[8]).toBe('list.8.title'); }); test('table:逐行逐列拼接 prop,跳过 hidden 列与 display 为假的列', () => { const { props } = collectProps( [ { type: 'table', name: 'rows', items: [ { type: 'text', name: 'a', label: 'A', rules: required() }, { type: 'hidden', name: 'h', rules: required() }, { type: 'text', name: 'c', label: 'C', display: () => false, rules: required() }, ], }, ], { rows: [ { a: '', h: '', c: '' }, { a: '', h: '', c: '' }, ], }, ); expect(props).toEqual(['rows.0.a', 'rows.1.a']); }); test('table:分页配置不会截断校验范围', () => { const rows = Array.from({ length: 12 }, () => ({ a: '' })); const { props } = collectProps( [ { type: 'table', name: 'rows', pagination: true, items: [{ type: 'text', name: 'a', label: 'A', rules: required() }], }, ], { rows }, ); // 渲染式校验只会覆盖当前页(默认 10 条) expect(props).toHaveLength(12); }); test('带 text 的容器:容器自身与子项都参与校验', () => { const { props } = collectProps( [ { type: 'group-list', name: 'list', text: '列表', rules: required('列表必填'), items: [{ type: 'text', name: 'title', text: '标题', rules: required() }], }, ], { list: [{ title: '' }] }, ); expect(props).toEqual(['list', 'list.0.title']); }); }); describe('validateValues —— 挂载副作用复刻', () => { test('display 字段的 initValue 会写入表单值', async () => { const { values, error } = await validateValues({ config: [{ type: 'display', name: 'status', text: '状态', initValue: 'ready', rules: required() }] as any, initValues: {}, }); expect(values.status).toBe('ready'); expect(error).toBe(''); }); test('number-range 字段的非数组值被修正为空数组', async () => { const { values } = await validateValues({ config: [{ type: 'number-range', name: 'range', text: '区间' }] as any, initValues: { range: 'not-an-array' }, }); expect(values.range).toEqual([]); }); test('checkbox-group 字段的空值被初始化为空数组', async () => { const { values, error } = await validateValues({ config: [{ type: 'checkbox-group', name: 'tags', text: '标签', rules: required() }] as any, initValues: {}, }); // 与渲染式一致:初始化成空数组后 required 仍然不通过 expect(values.tags).toEqual([]); expect(error).toBe('标签 -> 必填'); }); test('date 字段按 valueFormat 归一化', async () => { const { values } = await validateValues({ config: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }] as any, initValues: { start: '2021/07/17 15:37:00' }, }); expect(values.start).toBe('2021-07-17'); }); test('datetime 字段的非法值被归一化为空字符串', async () => { const { values, error } = await validateValues({ config: [{ type: 'datetime', name: 'start', text: '开始', rules: required() }] as any, initValues: { start: new Date('nonsense') }, }); expect(values.start).toBe(''); expect(error).toBe('开始 -> 必填'); }); test('datetime 字段按默认 valueFormat 归一化', async () => { const { values } = await validateValues({ config: [{ type: 'datetime', name: 'start', text: '开始' }] as any, initValues: { start: '2021/07/17 15:37:00' }, }); expect(values.start).toBe('2021/07/17 15:37:00'); }); }); describe('validateValues —— 未登记 type 与扩展登记', () => { test('未登记的字段 type 只要有 rules 就校验自身,不必登记为叶子', () => { const { props } = collectProps([{ type: 'my-custom', name: 'a', text: 'A', rules: required() }], { a: '' }); expect(props).toEqual(['a']); }); test('未登记且没有 rules / items 的 type 不抛错,也不收集字段', () => { const { props } = collectProps( [ { type: 'my-custom', name: 'a', text: 'A' }, { type: 'text', name: 'ok', text: 'OK', rules: required() }, ], { a: '', ok: '' }, ); expect(props).toEqual(['ok']); }); test('未登记的 type 若配置了 items,会下钻校验子项', () => { const { props } = collectProps( [ { type: 'my-layout', items: [{ type: 'text', name: 'a', text: 'A', rules: required() }], }, ], { a: '' }, ); expect(props).toEqual(['a']); }); test('多个未登记 type 不阻塞遍历,只收集带 rules 的字段', () => { const { props } = collectProps( [ { type: 'alpha-unknown', name: 'a', text: 'A' }, { type: 'text', name: 'ok', text: 'OK', rules: required() }, { type: 'beta-unknown', name: 'b', text: 'B' }, { type: 'panel', name: 'wrap', items: [{ type: 'gamma-unknown', name: 'c', text: 'C', rules: required() }], }, ], { a: '', ok: '', b: '', wrap: { c: '' } }, ); expect(props).toEqual(['ok', 'wrap.c']); }); test('内置 type: component 视为叶子:收集自身 rules,不下钻 items', () => { const { props } = collectProps( [ { type: 'component', name: 'a', text: 'A', rules: required(), items: [{ type: 'text', name: 'nested', text: 'Nested', rules: required() }], }, ], { a: '', nested: '' }, ); expect(props).toEqual(['a']); }); test('clearFields 不会清掉内置叶子字段', () => { registerFields({ 'temp-leaf': {} }); clearFields(); expect(isLeafFieldType('temp-leaf')).toBe(false); expect(isLeafFieldType('text')).toBe(true); expect(isLeafFieldType('tab')).toBe(false); }); test('内置叶子字段都不抛错', () => { for (const type of BUILT_IN_FIELD_TYPES) { expect(() => collectProps([{ type, name: 'a', text: 'A' }], { a: '' }), type).not.toThrow(); } }); test('内置叶子字段表覆盖 plugin 注册的全部 m-fields-* 组件', () => { const app = createApp(defineComponent({ render: () => null })); app.use(MagicForm); const registeredTypes = Object.keys((app._context as any).components) .filter((name) => name.startsWith('m-fields-')) .map((name) => name.replace('m-fields-', '')); expect(registeredTypes.length).toBeGreaterThan(0); for (const type of registeredTypes) { expect(isLeafFieldType(type), `${type} 未登记为叶子字段`).toBe(true); } }); test('registerFields 登记为叶子后仍收集自身 rules,且不下钻 items', () => { registerFields({ 'my-custom': {} }); const { props } = collectProps( [ { type: 'my-custom', name: 'a', text: 'A', rules: required(), items: [{ type: 'text', name: 'nested', text: 'Nested', rules: required() }], }, ], { a: '', nested: '' }, ); expect(props).toEqual(['a']); }); test('registerField 带 effect 时会复刻写入', async () => { registerField('my-status', { effect: ({ config, model }) => { model[(config as any).name] = 'from-effect'; }, }); const { values, error } = await validateValues({ config: [{ type: 'my-status', name: 'status', text: '状态', rules: required() }] as any, initValues: {}, }); expect(values.status).toBe('from-effect'); expect(error).toBe(''); }); test('叶子字段 type 名支持驼峰与中划线互通', () => { registerField('myStatus'); const { props } = collectProps( [ { type: 'my-status', name: 'a', items: [{ type: 'text', name: 'nested', text: 'Nested', rules: required() }], }, ], { a: { nested: '' } }, ); expect(props).toEqual([]); }); test('登记的叶子字段可覆盖内置字段的 mount effect', async () => { registerField('display', { effect: ({ config, model }) => { model[(config as any).name] = 'overridden'; }, }); const { values } = await validateValues({ config: [{ type: 'display', name: 'status', text: '状态', initValue: 'ready' }] as any, initValues: {}, }); expect(values.status).toBe('overridden'); }); test('unregisterField 后不再锁死子树,配置里的 items 会下钻', () => { registerFields({ 'my-custom': {} }); unregisterField('my-custom'); const { props } = collectProps( [ { type: 'my-custom', name: 'wrap', items: [{ type: 'text', name: 'inner', text: 'Inner', rules: required() }], }, ], { wrap: { inner: '' } }, ); expect(props).toEqual(['wrap.inner']); }); test('registerField innerConfig 可遍历复合字段的内部配置', async () => { registerField('my-composite', { innerConfig: ({ config, model }) => ({ config: { type: 'text', name: 'inner', text: '内部', rules: required('内部必填') }, model: model[(config as any).name], }), }); const { error } = await validateValues({ config: [{ type: 'my-composite', name: 'wrap', text: '包裹' }] as any, initValues: { wrap: { inner: '' } }, }); // 内部配置不在调用方传入的 config 树上,getTextByName 找不到 text,回退为 prop 路径 expect(error).toBe('wrap.inner -> 内部必填'); }); test('innerConfig 返回 null 表示该字段没有内部字段', () => { registerField('my-composite', { innerConfig: () => null }); expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).not.toThrow(); }); test('innerConfig 抛错时把失败原因带出去', () => { registerField('my-composite', { innerConfig: () => { throw new Error('boom'); }, }); expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).toThrow( /\[MForm\] innerConfig for "my-composite" at "a" failed: boom/, ); }); test('innerConfig 抛错时抛出 FieldInnerConfigError,可按 code 判别', () => { registerField('my-composite', { innerConfig: () => { throw new Error('boom'); }, }); try { collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' }); expect.unreachable('should throw'); } catch (e) { expect(isFieldInnerConfigError(e)).toBe(true); expect((e as { code?: string }).code).toBe('FIELD_INNER_CONFIG'); expect((e as { type?: string; prop?: string }).type).toBe('my-composite'); expect((e as { type?: string; prop?: string }).prop).toBe('a'); } }); test('innerConfig 的 type 名支持驼峰与中划线互通', () => { registerField('myComposite', { innerConfig: () => null }); expect(() => collectProps([{ type: 'my-composite', name: 'a', text: 'A' }], { a: '' })).not.toThrow(); }); test('同时传 innerConfig 与 effect 时不告警,两者并存', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); registerField('my-both', { innerConfig: () => null, effect: () => undefined }); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); }); test('后一次 registerField 覆盖前一次,不告警', () => { const spy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); registerField('my-both', { effect: () => undefined }); registerField('my-both', { innerConfig: () => null }); expect(spy).not.toHaveBeenCalled(); spy.mockRestore(); }); test('registerField typeMatch 会写入规则表,unregisterField 后清除', () => { registerField('my-custom', { typeMatch: (value) => (value === 'ok' ? undefined : 'not ok'), }); expect(getTypeMatchRule('my-custom')).toBeTypeOf('function'); unregisterField('my-custom'); expect(getTypeMatchRule('my-custom')).toBeUndefined(); }); });