From a123747e9d250166c2bb0f1d3ce3a39dd5baea9f Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 28 Jul 2026 15:32:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(form):=20=E6=94=AF=E6=8C=81=20typeMatch=20?= =?UTF-8?q?=E5=BC=82=E6=AD=A5=E6=A0=A1=E9=AA=8C=E4=B8=8E=20validator=20?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E5=80=BC=E7=BA=A6=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/form-config/rules.md | 23 +- packages/form/src/utils/form.ts | 11 +- packages/form/src/utils/typeMatch.ts | 162 ++++-- .../form/tests/unit/utils/typeMatch.spec.ts | 480 ++++++++++++++++++ 4 files changed, 642 insertions(+), 34 deletions(-) diff --git a/docs/form-config/rules.md b/docs/form-config/rules.md index 83f81d16..83fa8cf5 100644 --- a/docs/form-config/rules.md +++ b/docs/form-config/rules.md @@ -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`。返回错误文案表示失败,返回 `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()` 也不会对一个尚未校验完的值返回成功。 ### 安装时注册 diff --git a/packages/form/src/utils/form.ts b/packages/form/src/utils/form.ts index c79add8f..cc97ae67 100644 --- a/packages/form/src/utils/form.ts +++ b/packages/form/src/utils/form.ts @@ -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).then === 'function') { + if (result && typeof (result as PromiseLike).then === 'function') { Promise.resolve(result).then( () => { if (!settled) callback(); diff --git a/packages/form/src/utils/typeMatch.ts b/packages/form/src/utils/typeMatch.ts index c21fc285..3f5a8222 100644 --- a/packages/form/src/utils/typeMatch.ts +++ b/packages/form/src/utils/typeMatch.ts @@ -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; // #endregion TypeMatchValidator const typeMatchRuleRegistry = new Map(); @@ -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 => { 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; + 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(); }; }; diff --git a/packages/form/tests/unit/utils/typeMatch.spec.ts b/packages/form/tests/unit/utils/typeMatch.spec.ts index f9adc93a..698ba587 100644 --- a/packages/form/tests/unit/utils/typeMatch.spec.ts +++ b/packages/form/tests/unit/utils/typeMatch.spec.ts @@ -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((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 = { 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); + }); });