mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-08-01 10:55:49 +00:00
feat(form): 支持 typeMatch 异步校验与 validator 返回值约定
This commit is contained in:
parent
e4d4a11240
commit
a123747e9d
@ -69,6 +69,8 @@
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`validator` 除了调用 `callback` 外,也兼容 async-validator 的返回值约定:返回 `false` 或 `Error` / 错误数组表示失败,返回 `true` 表示通过,返回 `Promise` 则以 resolve / reject 为结果,内部抛出的异常会转成校验失败。同步与异步 `typeMatch` 下这些约定行为一致,且只会上报一次结果(既调 `callback` 又返回 `Promise` 时以先到的为准)。
|
||||||
|
|
||||||
## 扩展自定义 type 规则
|
## 扩展自定义 type 规则
|
||||||
|
|
||||||
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
|
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
|
||||||
@ -107,7 +109,26 @@ deleteTypeMatchRule('foo');
|
|||||||
clearTypeMatchRules();
|
clearTypeMatchRules();
|
||||||
```
|
```
|
||||||
|
|
||||||
自定义校验器签名:`(value, context) => string | undefined`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType`、`mForm`、`props`、`message`。
|
自定义校验器签名:`(value, context) => string | undefined | Promise<string | undefined>`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType`、`mForm`、`props`、`message`。
|
||||||
|
|
||||||
|
### 异步校验
|
||||||
|
|
||||||
|
自定义校验器可以返回 `Promise`,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。内置规则均为同步。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
registerTypeMatchRule('mod-select', async (value, { message }) => {
|
||||||
|
const exists = await checkModExists(value);
|
||||||
|
if (!exists) {
|
||||||
|
return message || `模块(${value})不存在`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
异步校验器通过后才会执行同一条 rule 上的自定义 `validator`。需要注意几点:
|
||||||
|
|
||||||
|
- **校验器自身失败不算校验失败。** Promise 被 reject(如接口异常)时只打印错误、该字段按通过处理,避免网络故障阻塞操作。需要把失败暴露给用户时请在校验器内部 catch 并返回错误文案。
|
||||||
|
- **建议自行缓存请求结果。** 每次校验都会执行校验器,无缓存会导致逐次输入都发请求;也可以给 rule 配 `trigger: 'blur'` 降低触发频率。
|
||||||
|
- **只有最新一轮校验的结论会被采用。** 新一轮校验开始后,上一轮针对旧值、尚未返回的校验不再使用自己的结论,而是等最新一轮出结论后一起结算,因此旧结论不会晚到覆盖新结论,`form.validate()` 也不会对一个尚未校验完的值返回成功。
|
||||||
|
|
||||||
### 安装时注册
|
### 安装时注册
|
||||||
|
|
||||||
|
|||||||
@ -60,13 +60,15 @@ export const adaptFormValidator = (validator: AsyncValidatorFn): AsyncValidatorF
|
|||||||
const value = arg1;
|
const value = arg1;
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const callback = (error?: Error | string) => {
|
const callback = (error?: Error | string | (Error | string)[]) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
if (error) {
|
// async-validator 约定 callback 也可接收错误数组,TDesign 只能展示一条,取首条
|
||||||
|
const first = Array.isArray(error) ? error[0] : error;
|
||||||
|
if (first) {
|
||||||
resolve({
|
resolve({
|
||||||
result: false,
|
result: false,
|
||||||
message: typeof error === 'string' ? error : error.message,
|
message: typeof first === 'string' ? first : first.message,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
resolve(true);
|
resolve(true);
|
||||||
@ -74,8 +76,9 @@ export const adaptFormValidator = (validator: AsyncValidatorFn): AsyncValidatorF
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 异步 validator 会先返回 undefined,稍后再调 callback,这里不能当成 thenable 取值
|
||||||
const result = validator(undefined, value, callback);
|
const result = validator(undefined, value, callback);
|
||||||
if (result !== null && typeof (result as PromiseLike<unknown>).then === 'function') {
|
if (result && typeof (result as PromiseLike<unknown>).then === 'function') {
|
||||||
Promise.resolve(result).then(
|
Promise.resolve(result).then(
|
||||||
() => {
|
() => {
|
||||||
if (!settled) callback();
|
if (!settled) callback();
|
||||||
|
|||||||
@ -38,8 +38,15 @@ export interface TypeMatchValidateContext {
|
|||||||
// #endregion TypeMatchValidateContext
|
// #endregion TypeMatchValidateContext
|
||||||
|
|
||||||
// #region TypeMatchValidator
|
// #region TypeMatchValidator
|
||||||
/** 自定义 type 校验器:返回错误文案;通过则返回 undefined */
|
/**
|
||||||
export type TypeMatchValidator = (value: any, context: TypeMatchValidateContext) => string | undefined;
|
* 自定义 type 校验器:返回错误文案;通过则返回 undefined。
|
||||||
|
*
|
||||||
|
* 支持返回 Promise,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。
|
||||||
|
*/
|
||||||
|
export type TypeMatchValidator = (
|
||||||
|
value: any,
|
||||||
|
context: TypeMatchValidateContext,
|
||||||
|
) => string | undefined | Promise<string | undefined>;
|
||||||
// #endregion TypeMatchValidator
|
// #endregion TypeMatchValidator
|
||||||
|
|
||||||
const typeMatchRuleRegistry = new Map<string, TypeMatchValidator>();
|
const typeMatchRuleRegistry = new Map<string, TypeMatchValidator>();
|
||||||
@ -692,12 +699,17 @@ const validateBuiltinTypeMatch = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验取值与字段 type 是否匹配;通过返回 undefined,否则返回错误文案。
|
||||||
|
*
|
||||||
|
* 命中的自定义规则是异步校验器时返回 Promise,内置规则始终同步返回。
|
||||||
|
*/
|
||||||
export const validateTypeMatch = (
|
export const validateTypeMatch = (
|
||||||
value: any,
|
value: any,
|
||||||
mForm: FormState | undefined,
|
mForm: FormState | undefined,
|
||||||
props: any,
|
props: any,
|
||||||
message?: string,
|
message?: string,
|
||||||
): string | undefined => {
|
): string | undefined | Promise<string | undefined> => {
|
||||||
if (isEmptyValue(value) || isEmptyArray(value)) {
|
if (isEmptyValue(value) || isEmptyArray(value)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -724,43 +736,135 @@ export const validateTypeMatch = (
|
|||||||
return validateBuiltinTypeMatch(value, fieldType, mForm, props, message);
|
return validateBuiltinTypeMatch(value, fieldType, mForm, props, message);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toError = (error: any): Error => (error instanceof Error ? error : new Error(`${error}`));
|
||||||
|
|
||||||
export const createTypeMatchValidator = (mForm: FormState | undefined, props: any, rule: Rule) => {
|
export const createTypeMatchValidator = (mForm: FormState | undefined, props: any, rule: Rule) => {
|
||||||
const originalValidator = typeof rule.validator === 'function' ? rule.validator : undefined;
|
const originalValidator = typeof rule.validator === 'function' ? rule.validator : undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每次校验取一个自增序号,只有最新一轮的结论会被采用。
|
||||||
|
*
|
||||||
|
* 旧值的在途校验不能用自己的结论结算:它可能晚于新校验结束,用过期结论覆盖新结论
|
||||||
|
* (表单取最后结束的那次结果);也不能在新校验开始时无条件按通过结算,否则
|
||||||
|
* `form.validate()` 会对一个还没校验过的值返回成功。因此所有尚未结算的调用都登记在
|
||||||
|
* pendingSettlers 中,等最新一轮出结论后用同一个结论一起结算。
|
||||||
|
*
|
||||||
|
* 不比较取值本身:配了 names 或取值为对象/数组时拿到的是同一个引用,就地修改后比较不出变化。
|
||||||
|
*/
|
||||||
|
let generation = 0;
|
||||||
|
let pendingSettlers: ((args: any[]) => void)[] = [];
|
||||||
|
|
||||||
return (asyncValidatorRule: any, value: any, callback: Function, source: any, options: any) => {
|
return (asyncValidatorRule: any, value: any, callback: Function, source: any, options: any) => {
|
||||||
const actualValue = props.config?.names ? props.model : value;
|
const actualValue = props.config?.names ? props.model : value;
|
||||||
try {
|
|
||||||
const error = validateTypeMatch(actualValue, mForm, props, rule.message);
|
|
||||||
|
|
||||||
if (error) {
|
generation += 1;
|
||||||
callback(new Error(error));
|
const currentGeneration = generation;
|
||||||
|
/** 已有更新的一轮校验开始,本轮结论作废 */
|
||||||
|
const isStale = () => currentGeneration !== generation;
|
||||||
|
|
||||||
|
pendingSettlers.push((args: any[]) => callback(...args));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结算本轮与被本轮取代的所有在途校验。
|
||||||
|
*
|
||||||
|
* async-validator 的 callback 不幂等:重复调用会让它的内部计数提前满足、错误信息重复;
|
||||||
|
* 原始 validator 写成 async 时很容易既调 callback 又返回 Promise,故所有回调都收敛到这里,
|
||||||
|
* 由「取出即出队」保证每个调用的 callback 只会被调用一次。
|
||||||
|
*/
|
||||||
|
const conclude = (...args: any[]) => {
|
||||||
|
if (isStale()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
const settlers = pendingSettlers;
|
||||||
|
pendingSettlers = [];
|
||||||
|
for (const settle of settlers) {
|
||||||
|
settle(args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行原始 validator 并把结果统一转成 callback。
|
||||||
|
*
|
||||||
|
* async-validator 只解析同步返回给它的返回值(true / false / Error / 错误数组 / Promise,
|
||||||
|
* 抛错转成错误信息),异步路径已脱离它的调用栈,这些约定会失效导致校验永不结束,故复刻一份。
|
||||||
|
*/
|
||||||
|
const settleWithOriginalValidator = () => {
|
||||||
|
// 本轮结论已作废,不必再跑一遍原始 validator 触发副作用
|
||||||
|
if (isStale()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!originalValidator) {
|
||||||
|
conclude();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: any;
|
||||||
|
try {
|
||||||
|
result = originalValidator(
|
||||||
|
{ rule: asyncValidatorRule, value: actualValue, callback: conclude, source, options },
|
||||||
|
{
|
||||||
|
values: mForm?.initValues || {},
|
||||||
|
model: props.model,
|
||||||
|
parent: mForm?.parentValues || {},
|
||||||
|
formValue: mForm?.values || props.model,
|
||||||
|
prop: props.prop,
|
||||||
|
config: props.config,
|
||||||
|
},
|
||||||
|
mForm,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
conclude(toError(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPromise(result)) {
|
||||||
|
result.then(
|
||||||
|
() => conclude(),
|
||||||
|
(err: any) => conclude(toError(err)),
|
||||||
|
);
|
||||||
|
} else if (result === true) {
|
||||||
|
conclude();
|
||||||
|
} else if (result === false) {
|
||||||
|
const field = asyncValidatorRule?.fullField || asyncValidatorRule?.field || props.prop;
|
||||||
|
conclude(new Error(rule.message || `${field} fails`));
|
||||||
|
} else if (result instanceof Error || Array.isArray(result)) {
|
||||||
|
conclude(result);
|
||||||
|
}
|
||||||
|
// 其余返回值(undefined / void)按约定由 validator 自行调用 callback
|
||||||
|
};
|
||||||
|
|
||||||
|
// 校验器自身失败(如接口异常)不应阻塞用户,记录后按通过处理
|
||||||
|
const skipFailedTypeMatch = (err: any) => {
|
||||||
|
console.error(err);
|
||||||
|
settleWithOriginalValidator();
|
||||||
|
};
|
||||||
|
|
||||||
|
let error: string | undefined | Promise<string | undefined>;
|
||||||
|
try {
|
||||||
|
error = validateTypeMatch(actualValue, mForm, props, rule.message);
|
||||||
|
} catch (err) {
|
||||||
|
skipFailedTypeMatch(err);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (originalValidator) {
|
if (isPromise(error)) {
|
||||||
return originalValidator(
|
error.then((asyncMessage) => {
|
||||||
{
|
if (asyncMessage) {
|
||||||
rule: asyncValidatorRule,
|
conclude(new Error(asyncMessage));
|
||||||
value: actualValue,
|
return;
|
||||||
callback,
|
}
|
||||||
source,
|
settleWithOriginalValidator();
|
||||||
options,
|
}, skipFailedTypeMatch);
|
||||||
},
|
return;
|
||||||
{
|
|
||||||
values: mForm?.initValues || {},
|
|
||||||
model: props.model,
|
|
||||||
parent: mForm?.parentValues || {},
|
|
||||||
formValue: mForm?.values || props.model,
|
|
||||||
prop: props.prop,
|
|
||||||
config: props.config,
|
|
||||||
},
|
|
||||||
mForm,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
callback();
|
if (error) {
|
||||||
|
conclude(new Error(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settleWithOriginalValidator();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -625,6 +625,88 @@ describe('getRules tdesign validator', () => {
|
|||||||
});
|
});
|
||||||
await expect(newRules[0].validator(1)).resolves.toBe(true);
|
await expect(newRules[0].validator(1)).resolves.toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('异步 typeMatch 校验器的结果能正确适配', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async (value) => (value === 'ok' ? undefined : '异步校验失败'));
|
||||||
|
|
||||||
|
const rules: any = [{ typeMatch: true }];
|
||||||
|
const newRules: any = getRules(mForm, rules, propsOf({ type: 'async-type' }));
|
||||||
|
|
||||||
|
await expect(newRules[0].validator('ok')).resolves.toBe(true);
|
||||||
|
await expect(newRules[0].validator('bad')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: '异步校验失败',
|
||||||
|
});
|
||||||
|
|
||||||
|
deleteTypeMatchRule('async-type');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自定义 validator 返回 Promise 时按 Promise 结果适配', async () => {
|
||||||
|
const rules: any = [
|
||||||
|
{ validator: () => Promise.resolve() },
|
||||||
|
{ validator: () => Promise.reject(new Error('异步失败')) },
|
||||||
|
{ validator: () => Promise.reject('非 Error') },
|
||||||
|
];
|
||||||
|
const newRules: any = getRules(mForm, rules, { config: {} });
|
||||||
|
|
||||||
|
await expect(newRules[0].validator('ok')).resolves.toBe(true);
|
||||||
|
await expect(newRules[1].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: '异步失败',
|
||||||
|
});
|
||||||
|
await expect(newRules[2].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: '非 Error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自定义 validator 同步抛错时转成 CustomValidateObj', async () => {
|
||||||
|
const rules: any = [
|
||||||
|
{
|
||||||
|
validator: () => {
|
||||||
|
throw new Error('validator 内部抛错');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: () => {
|
||||||
|
throw 'string error';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const newRules: any = getRules(mForm, rules, { config: {} });
|
||||||
|
|
||||||
|
await expect(newRules[0].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: 'validator 内部抛错',
|
||||||
|
});
|
||||||
|
await expect(newRules[1].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: 'string error',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('callback 收到错误数组时取首条展示', async () => {
|
||||||
|
const rules: any = [
|
||||||
|
{
|
||||||
|
validator: ({ callback }: any) => {
|
||||||
|
callback([new Error('错误一'), new Error('错误二')]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: ({ callback }: any) => {
|
||||||
|
callback([]);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const newRules: any = getRules(mForm, rules, { config: {} });
|
||||||
|
|
||||||
|
await expect(newRules[0].validator('ok')).resolves.toEqual({
|
||||||
|
result: false,
|
||||||
|
message: '错误一',
|
||||||
|
});
|
||||||
|
// 空数组视为无错误
|
||||||
|
await expect(newRules[1].validator('ok')).resolves.toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('typeMatch 扩展注册', () => {
|
describe('typeMatch 扩展注册', () => {
|
||||||
@ -748,4 +830,402 @@ describe('createTypeMatchValidator', () => {
|
|||||||
expect(errorSpy).toHaveBeenCalled();
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
errorSpy.mockRestore();
|
errorSpy.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('异步校验器返回错误文案时回调 Error', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async (value) => (value === 'ok' ? undefined : '异步校验失败'));
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
// 异步校验器不能把 Promise 交还给 async-validator,否则会重复回调
|
||||||
|
expect(validator({}, 'bad', callback, {}, {})).toBeUndefined();
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('异步校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器通过时无参回调', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async (value) => (value === 'ok' ? undefined : '异步校验失败'));
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
validator({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器通过后继续执行原始 validator', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => undefined);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const originalValidator = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(originalValidator).toHaveBeenCalled());
|
||||||
|
// 原始 validator 拿到 callback 后自行回调,这里不重复调用
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
expect(originalValidator.mock.calls[0][0].value).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器不通过时不执行原始 validator', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => '异步校验失败');
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const originalValidator = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'bad', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(originalValidator).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器 reject 时忽略异常并继续执行原始 validator', async () => {
|
||||||
|
registerTypeMatchRule('async-type', () => Promise.reject(new Error('boom')));
|
||||||
|
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const callback = vi.fn();
|
||||||
|
const originalValidator = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'value', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(originalValidator).toHaveBeenCalled());
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步路径下原始 validator 返回的 Promise 会被转成 callback', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => undefined);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: () => Promise.reject(new Error('原始校验失败')),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('原始校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('原始 validator 既调 callback 又返回 Promise 时只回调一次', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => undefined);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
// async 写法很常见:内部调了 callback,函数本身又返回 Promise
|
||||||
|
validator: async ({ callback: cb }: any) => {
|
||||||
|
cb(new Error('原始校验失败'));
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('原始校验失败');
|
||||||
|
|
||||||
|
// 等 Promise 链彻底跑完,确认没有第二次回调
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('异步校验器不影响同步内置规则', () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => '异步校验失败');
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'number' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
validator({}, 'not-a-number', callback, {}, {});
|
||||||
|
|
||||||
|
// 内置规则仍同步回调
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback.mock.calls[0][0].message).toContain('类型应为数字');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* async-validator 只解析同步返回给它的 validator 返回值,异步 typeMatch 通过后已脱离其调用栈,
|
||||||
|
* 因此这些约定必须由 createTypeMatchValidator 自行复刻,否则 callback 永不触发、校验一直挂起。
|
||||||
|
*/
|
||||||
|
describe('createTypeMatchValidator 原始 validator 返回值约定', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearTypeMatchRules();
|
||||||
|
registerTypeMatchRule('async-type', async () => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearTypeMatchRules();
|
||||||
|
});
|
||||||
|
|
||||||
|
const validatorOf = (originalValidator: any, type = 'async-type') =>
|
||||||
|
createTypeMatchValidator(mForm, propsOf({ type }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
/** async-type 走异步 typeMatch 路径,text 走同步内置规则路径,两者行为应完全一致 */
|
||||||
|
const paths = ['async-type', 'text'];
|
||||||
|
|
||||||
|
test.each(paths)('%s:返回 false 时回调错误', async (type) => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => false, type)({ fullField: 'title' }, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('title fails');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('返回 false 时优先使用 rule.message', async () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
message: '自定义错误',
|
||||||
|
validator: () => false,
|
||||||
|
} as any)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('自定义错误');
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(paths)('%s:返回 true 时无参回调', async (type) => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => true, type)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(paths)('%s:返回 Error 实例时透传', async (type) => {
|
||||||
|
const error = new Error('返回 Error');
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => error, type)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback).toHaveBeenCalledWith(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(paths)('%s:返回错误数组时透传', async (type) => {
|
||||||
|
const errors = [new Error('错误一'), new Error('错误二')];
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => errors, type)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback).toHaveBeenCalledWith(errors);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(paths)('%s:同步抛错时转成回调而非未捕获异常', async (type) => {
|
||||||
|
const unhandled: any[] = [];
|
||||||
|
const onUnhandled = (reason: any) => unhandled.push(reason);
|
||||||
|
process.on('unhandledRejection', onUnhandled);
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(() => {
|
||||||
|
throw new Error('validator 内部抛错');
|
||||||
|
}, type)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('validator 内部抛错');
|
||||||
|
|
||||||
|
// 等待微任务队列排空,确认异常没有变成游离的 rejected promise
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
process.off('unhandledRejection', onUnhandled);
|
||||||
|
expect(unhandled).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[
|
||||||
|
'抛出',
|
||||||
|
() => {
|
||||||
|
throw 'string error';
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['reject', () => Promise.reject('string error')],
|
||||||
|
])('%s非 Error 时包装成 Error', async (_case, originalValidator) => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
validatorOf(originalValidator)({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(callback.mock.calls[0][0]).toBeInstanceOf(Error);
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('string error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同步 typeMatch 路径不把 Promise 交还 async-validator,避免重复回调', async () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
// async 写法很常见:内部调了 callback,函数本身又返回 Promise
|
||||||
|
const returned = validatorOf(async ({ callback: cb }: any) => {
|
||||||
|
cb(new Error('原始校验失败'));
|
||||||
|
}, 'text')({}, 'ok', callback, {}, {});
|
||||||
|
|
||||||
|
// 返回 undefined,async-validator 不会再解析 Promise 二次回调
|
||||||
|
expect(returned).toBeUndefined();
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(callback.mock.calls[0][0].message).toBe('原始校验失败');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createTypeMatchValidator 异步校验竞态', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearTypeMatchRules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
clearTypeMatchRules();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 注册一个由测试手动控制结束时机的异步规则,返回按调用顺序收集的 resolve 队列 */
|
||||||
|
const registerManualRule = () => {
|
||||||
|
const resolvers: ((message: string | undefined) => void)[] = [];
|
||||||
|
registerTypeMatchRule('async-type', () => new Promise<string | undefined>((resolve) => resolvers.push(resolve)));
|
||||||
|
return resolvers;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('旧校验不会用过期结论结算,而是跟随最新一轮的结论', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const staleCallback = vi.fn();
|
||||||
|
const freshCallback = vi.fn();
|
||||||
|
validator({}, 'old', staleCallback, {}, {});
|
||||||
|
expect(staleCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 值变化触发新一轮校验:旧校验的结论作废,但不能提前按通过结算
|
||||||
|
validator({}, 'new', freshCallback, {}, {});
|
||||||
|
expect(staleCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 旧校验先返回,它的结论被丢弃
|
||||||
|
resolvers[0]('旧值校验失败');
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(staleCallback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// 最新一轮出结论后,两次调用都以新结论结算
|
||||||
|
resolvers[1]('新值校验失败');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(staleCallback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(freshCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(staleCallback.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
expect(freshCallback.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('旧校验被同步路径的校验取代时,跟随同步结论结算', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const staleCallback = vi.fn();
|
||||||
|
const freshCallback = vi.fn();
|
||||||
|
validator({}, 'bad', staleCallback, {}, {});
|
||||||
|
|
||||||
|
// 值被清空:空值走同步路径直接通过,在途的旧校验必须跟着通过
|
||||||
|
validator({}, '', freshCallback, {}, {});
|
||||||
|
expect(freshCallback).toHaveBeenCalledWith();
|
||||||
|
expect(staleCallback).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
// 旧校验的失败结论晚到,不能落到已经通过的新值上
|
||||||
|
resolvers[0]('异步校验失败');
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(staleCallback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('取值为同一引用(names / 对象值)时也能识别出旧校验', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const model: Record<string, any> = { start: 'old' };
|
||||||
|
const props = { ...propsOf({ type: 'async-type', names: ['start'] }), model };
|
||||||
|
const validator = createTypeMatchValidator(mForm, props, { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const staleCallback = vi.fn();
|
||||||
|
const freshCallback = vi.fn();
|
||||||
|
validator({}, 'x', staleCallback, {}, {});
|
||||||
|
|
||||||
|
// names 场景下校验的是 model 本身,就地修改后引用不变
|
||||||
|
model.start = 'new';
|
||||||
|
validator({}, 'y', freshCallback, {}, {});
|
||||||
|
|
||||||
|
// 新校验先通过,旧校验的失败结论后到,不能覆盖
|
||||||
|
resolvers[1](undefined);
|
||||||
|
resolvers[0]('旧值校验失败');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(freshCallback).toHaveBeenCalledTimes(1));
|
||||||
|
expect(freshCallback).toHaveBeenCalledWith();
|
||||||
|
expect(staleCallback).toHaveBeenCalledTimes(1);
|
||||||
|
expect(staleCallback).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同值的多次在途校验都会被最新一轮结算', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
const third = vi.fn();
|
||||||
|
// 同一个值可能被 blur、change 触发两次校验,之后值才变化
|
||||||
|
validator({}, 'old', first, {}, {});
|
||||||
|
validator({}, 'old', second, {}, {});
|
||||||
|
validator({}, 'new', third, {}, {});
|
||||||
|
|
||||||
|
resolvers[0]('旧值校验失败');
|
||||||
|
resolvers[1]('旧值校验失败');
|
||||||
|
resolvers[2]('新值校验失败');
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(third).toHaveBeenCalledTimes(1));
|
||||||
|
expect(first.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
expect(second.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
expect(third.mock.calls[0][0].message).toBe('新值校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('并发的多次校验都会拿到结论,不会有调用被漏掉', async () => {
|
||||||
|
registerTypeMatchRule('async-type', async () => '异步校验失败');
|
||||||
|
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), { typeMatch: true } as any);
|
||||||
|
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
validator({}, 'same', first, {}, {});
|
||||||
|
validator({}, 'same', second, {}, {});
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(first).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(first.mock.calls[0][0].message).toBe('异步校验失败');
|
||||||
|
expect(second.mock.calls[0][0].message).toBe('异步校验失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('结论已作废的旧校验返回后不再执行原始 validator', async () => {
|
||||||
|
const resolvers = registerManualRule();
|
||||||
|
const originalValidator = vi.fn(({ callback }: any) => callback());
|
||||||
|
const validator = createTypeMatchValidator(mForm, propsOf({ type: 'async-type' }), {
|
||||||
|
typeMatch: true,
|
||||||
|
validator: originalValidator,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
validator({}, 'old', vi.fn(), {}, {});
|
||||||
|
validator({}, 'new', vi.fn(), {}, {});
|
||||||
|
|
||||||
|
// 旧校验的结论已作废,它的 typeMatch 通过后不应再触发一次原始 validator(可能带副作用)
|
||||||
|
resolvers[0](undefined);
|
||||||
|
resolvers[1](undefined);
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(originalValidator).toHaveBeenCalled());
|
||||||
|
expect(originalValidator).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user