mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-28 08:55:50 +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 注册校验。自定义规则优先于内置规则。
|
||||
@ -107,7 +109,26 @@ deleteTypeMatchRule('foo');
|
||||
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;
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const callback = (error?: Error | string) => {
|
||||
const callback = (error?: Error | string | (Error | string)[]) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (error) {
|
||||
// async-validator 约定 callback 也可接收错误数组,TDesign 只能展示一条,取首条
|
||||
const first = Array.isArray(error) ? error[0] : error;
|
||||
if (first) {
|
||||
resolve({
|
||||
result: false,
|
||||
message: typeof error === 'string' ? error : error.message,
|
||||
message: typeof first === 'string' ? first : first.message,
|
||||
});
|
||||
} else {
|
||||
resolve(true);
|
||||
@ -74,8 +76,9 @@ export const adaptFormValidator = (validator: AsyncValidatorFn): AsyncValidatorF
|
||||
};
|
||||
|
||||
try {
|
||||
// 异步 validator 会先返回 undefined,稍后再调 callback,这里不能当成 thenable 取值
|
||||
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(
|
||||
() => {
|
||||
if (!settled) callback();
|
||||
|
||||
@ -38,8 +38,15 @@ export interface TypeMatchValidateContext {
|
||||
// #endregion TypeMatchValidateContext
|
||||
|
||||
// #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
|
||||
|
||||
const typeMatchRuleRegistry = new Map<string, TypeMatchValidator>();
|
||||
@ -692,12 +699,17 @@ const validateBuiltinTypeMatch = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验取值与字段 type 是否匹配;通过返回 undefined,否则返回错误文案。
|
||||
*
|
||||
* 命中的自定义规则是异步校验器时返回 Promise,内置规则始终同步返回。
|
||||
*/
|
||||
export const validateTypeMatch = (
|
||||
value: any,
|
||||
mForm: FormState | undefined,
|
||||
props: any,
|
||||
message?: string,
|
||||
): string | undefined => {
|
||||
): string | undefined | Promise<string | undefined> => {
|
||||
if (isEmptyValue(value) || isEmptyArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@ -724,43 +736,135 @@ export const validateTypeMatch = (
|
||||
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) => {
|
||||
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) => {
|
||||
const actualValue = props.config?.names ? props.model : value;
|
||||
try {
|
||||
const error = validateTypeMatch(actualValue, mForm, props, rule.message);
|
||||
|
||||
if (error) {
|
||||
callback(new Error(error));
|
||||
generation += 1;
|
||||
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;
|
||||
}
|
||||
} 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) {
|
||||
return originalValidator(
|
||||
{
|
||||
rule: asyncValidatorRule,
|
||||
value: actualValue,
|
||||
callback,
|
||||
source,
|
||||
options,
|
||||
},
|
||||
{
|
||||
values: mForm?.initValues || {},
|
||||
model: props.model,
|
||||
parent: mForm?.parentValues || {},
|
||||
formValue: mForm?.values || props.model,
|
||||
prop: props.prop,
|
||||
config: props.config,
|
||||
},
|
||||
mForm,
|
||||
);
|
||||
if (isPromise(error)) {
|
||||
error.then((asyncMessage) => {
|
||||
if (asyncMessage) {
|
||||
conclude(new Error(asyncMessage));
|
||||
return;
|
||||
}
|
||||
settleWithOriginalValidator();
|
||||
}, skipFailedTypeMatch);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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 扩展注册', () => {
|
||||
@ -748,4 +830,402 @@ describe('createTypeMatchValidator', () => {
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
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