tmagic-editor/packages/form/tests/unit/utils/validateValues.spec.ts
roymondchen a7999f50d5 feat(form): 用 FormContext 与 provide/inject 替代 extendState 钩子
统一表单业务上下文的注入方式,通过读穿 Proxy 保持 mForm 回调签名不变,并移除 extendFormState/extendState 等分散的扩展钩子。
2026-09-01 14:48:42 +08:00

783 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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('多条错误以 <br> 拼接', 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必填<br>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 拼接 proplazy 标签页也参与校验', () => {
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('tabdisplay 为假的标签页不参与校验', () => {
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('stepprop 基准被重置为 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();
});
});