feat(generics): Improve assertion and matcher type definitions by adding execution mode generics for better async/sync support
This commit is contained in:
		| @@ -1,5 +1,13 @@ | |||||||
| # Changelog | # Changelog | ||||||
|  |  | ||||||
|  | ## 2025-04-29 - 2.2.0 - feat(generics) | ||||||
|  | Improve assertion and matcher type definitions by adding execution mode generics for better async/sync support | ||||||
|  |  | ||||||
|  | - Updated ts/index.ts to import and use TExecutionType in the expect function | ||||||
|  | - Modified Assertion class to use a generic execution mode (M) for improved type inference | ||||||
|  | - Revised all matcher namespaces (array, boolean, date, function, number, object, string, type) to accept the new generic parameter | ||||||
|  | - Enhanced async/sync distinction for assertion methods like resolves and rejects | ||||||
|  |  | ||||||
| ## 2025-04-29 - 2.1.2 - fix(ts/index.ts) | ## 2025-04-29 - 2.1.2 - fix(ts/index.ts) | ||||||
| Remove deprecated expectAsync function and advise using .resolves/.rejects on expect for async assertions | Remove deprecated expectAsync function and advise using .resolves/.rejects on expect for async assertions | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,6 +3,6 @@ | |||||||
|  */ |  */ | ||||||
| export const commitinfo = { | export const commitinfo = { | ||||||
|   name: '@push.rocks/smartexpect', |   name: '@push.rocks/smartexpect', | ||||||
|   version: '2.1.2', |   version: '2.2.0', | ||||||
|   description: 'A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.' |   description: 'A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.' | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import { Assertion, AnyMatcher, AnythingMatcher } from './smartexpect.classes.assertion.js'; | import { Assertion, AnyMatcher, AnythingMatcher } from './smartexpect.classes.assertion.js'; | ||||||
|  | import type { TExecutionType } from './types.js'; | ||||||
| // import type { TMatcher } from './smartexpect.classes.assertion.js'; // unused | // import type { TMatcher } from './smartexpect.classes.assertion.js'; // unused | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -12,12 +13,12 @@ import { Assertion, AnyMatcher, AnythingMatcher } from './smartexpect.classes.as | |||||||
|  * Entry point for assertions.  |  * Entry point for assertions.  | ||||||
|  * Automatically detects Promises to support async assertions. |  * Automatically detects Promises to support async assertions. | ||||||
|  */ |  */ | ||||||
| export function expect<T>(value: Promise<T>): Assertion<T>; | export function expect<T>(value: Promise<T>): Assertion<T, 'async'>; | ||||||
| export function expect<T>(value: T): Assertion<T>; | export function expect<T>(value: T): Assertion<T, 'sync'>; | ||||||
| export function expect<T>(value: any): Assertion<T> { | export function expect<T>(value: any): Assertion<T, TExecutionType> { | ||||||
|   const isThenable = value != null && typeof (value as any).then === 'function'; |   const isThenable = value != null && typeof (value as any).then === 'function'; | ||||||
|   const mode: 'sync' | 'async' = isThenable ? 'async' : 'sync'; |   const mode: 'sync' | 'async' = isThenable ? 'async' : 'sync'; | ||||||
|   return new Assertion<T>(value, mode); |   return new Assertion<T, TExecutionType>(value, mode); | ||||||
| } | } | ||||||
| /** | /** | ||||||
|  * Register custom matchers. |  * Register custom matchers. | ||||||
|   | |||||||
| @@ -1,11 +1,12 @@ | |||||||
| import { Assertion } from '../smartexpect.classes.assertion.js'; | import { Assertion } from '../smartexpect.classes.assertion.js'; | ||||||
| import * as plugins from '../plugins.js'; | import * as plugins from '../plugins.js'; | ||||||
|  | import type { TExecutionType } from '../types.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Namespace for array-specific matchers |  * Namespace for array-specific matchers | ||||||
|  */ |  */ | ||||||
| export class ArrayMatchers<T> { | export class ArrayMatchers<T, M extends TExecutionType> { | ||||||
|   constructor(private assertion: Assertion<T[]>) {} |   constructor(private assertion: Assertion<T[], M>) {} | ||||||
|  |  | ||||||
|   toBeArray() { |   toBeArray() { | ||||||
|     return this.assertion.customAssertion( |     return this.assertion.customAssertion( | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| import { Assertion } from '../smartexpect.classes.assertion.js'; | import { Assertion } from '../smartexpect.classes.assertion.js'; | ||||||
|  | import type { TExecutionType } from '../types.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Namespace for boolean-specific matchers |  * Namespace for boolean-specific matchers | ||||||
|  */ |  */ | ||||||
| export class BooleanMatchers { | export class BooleanMatchers<M extends TExecutionType> { | ||||||
|   constructor(private assertion: Assertion<boolean>) {} |   constructor(private assertion: Assertion<boolean, M>) {} | ||||||
|  |  | ||||||
|   toBeTrue() { |   toBeTrue() { | ||||||
|     return this.assertion.customAssertion( |     return this.assertion.customAssertion( | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| import { Assertion } from '../smartexpect.classes.assertion.js'; | import { Assertion } from '../smartexpect.classes.assertion.js'; | ||||||
|  | import type { TExecutionType } from '../types.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Namespace for date-specific matchers |  * Namespace for date-specific matchers | ||||||
|  */ |  */ | ||||||
| export class DateMatchers { | export class DateMatchers<M extends TExecutionType> { | ||||||
|   constructor(private assertion: Assertion<Date>) {} |   constructor(private assertion: Assertion<Date, M>) {} | ||||||
|  |  | ||||||
|   toBeDate() { |   toBeDate() { | ||||||
|     return this.assertion.customAssertion( |     return this.assertion.customAssertion( | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| import { Assertion } from '../smartexpect.classes.assertion.js'; | import { Assertion } from '../smartexpect.classes.assertion.js'; | ||||||
|  | import type { TExecutionType } from '../types.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Namespace for function-specific matchers |  * Namespace for function-specific matchers | ||||||
|  */ |  */ | ||||||
| export class FunctionMatchers { | export class FunctionMatchers<M extends TExecutionType> { | ||||||
|   constructor(private assertion: Assertion<Function>) {} |   constructor(private assertion: Assertion<Function, M>) {} | ||||||
|  |  | ||||||
|   toThrow(expectedError?: any) { |   toThrow(expectedError?: any) { | ||||||
|     return this.assertion.customAssertion( |     return this.assertion.customAssertion( | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| import { Assertion } from '../smartexpect.classes.assertion.js'; | import { Assertion } from '../smartexpect.classes.assertion.js'; | ||||||
|  | import type { TExecutionType } from '../types.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Namespace for number-specific matchers |  * Namespace for number-specific matchers | ||||||
|  */ |  */ | ||||||
| export class NumberMatchers { | export class NumberMatchers<M extends TExecutionType> { | ||||||
|   constructor(private assertion: Assertion<number>) {} |   constructor(private assertion: Assertion<number, M>) {} | ||||||
|  |  | ||||||
|   toBeGreaterThan(value: number) { |   toBeGreaterThan(value: number) { | ||||||
|     return this.assertion.customAssertion( |     return this.assertion.customAssertion( | ||||||
|   | |||||||
| @@ -1,11 +1,12 @@ | |||||||
| import { Assertion, AnyMatcher, AnythingMatcher } from '../smartexpect.classes.assertion.js'; | import { Assertion, AnyMatcher, AnythingMatcher } from '../smartexpect.classes.assertion.js'; | ||||||
|  | import type { TExecutionType } from '../types.js'; | ||||||
| import * as plugins from '../plugins.js'; | import * as plugins from '../plugins.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Namespace for object-specific matchers |  * Namespace for object-specific matchers | ||||||
|  */ |  */ | ||||||
| export class ObjectMatchers<T extends object> { | export class ObjectMatchers<T extends object, M extends TExecutionType> { | ||||||
|   constructor(private assertion: Assertion<T>) {} |   constructor(private assertion: Assertion<T, M>) {} | ||||||
|  |  | ||||||
|   toEqual(expected: any) { |   toEqual(expected: any) { | ||||||
|     return this.assertion.customAssertion( |     return this.assertion.customAssertion( | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| import { Assertion } from '../smartexpect.classes.assertion.js'; | import { Assertion } from '../smartexpect.classes.assertion.js'; | ||||||
|  | import type { TExecutionType } from '../types.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Namespace for string-specific matchers |  * Namespace for string-specific matchers | ||||||
|  */ |  */ | ||||||
| export class StringMatchers { | export class StringMatchers<M extends TExecutionType> { | ||||||
|   constructor(private assertion: Assertion<string>) {} |   constructor(private assertion: Assertion<string, M>) {} | ||||||
|  |  | ||||||
|   toStartWith(prefix: string) { |   toStartWith(prefix: string) { | ||||||
|     return this.assertion.customAssertion( |     return this.assertion.customAssertion( | ||||||
|   | |||||||
| @@ -1,10 +1,11 @@ | |||||||
| import { Assertion } from '../smartexpect.classes.assertion.js'; | import { Assertion } from '../smartexpect.classes.assertion.js'; | ||||||
|  | import type { TExecutionType } from '../types.js'; | ||||||
|  |  | ||||||
| /** | /** | ||||||
|  * Namespace for type-based matchers |  * Namespace for type-based matchers | ||||||
|  */ |  */ | ||||||
| export class TypeMatchers { | export class TypeMatchers<M extends TExecutionType> { | ||||||
|   constructor(private assertion: Assertion<any>) {} |   constructor(private assertion: Assertion<any, M>) {} | ||||||
|  |  | ||||||
|   toBeTypeofString() { |   toBeTypeofString() { | ||||||
|     return this.assertion.customAssertion( |     return this.assertion.customAssertion( | ||||||
|   | |||||||
| @@ -26,8 +26,8 @@ export class AnyMatcher { | |||||||
| } | } | ||||||
| export class AnythingMatcher {} | export class AnythingMatcher {} | ||||||
|  |  | ||||||
| export class Assertion<T = unknown> { | export class Assertion<T = unknown, M extends TExecutionType = 'sync'> { | ||||||
|   executionMode: TExecutionType; |   executionMode: M; | ||||||
|   baseReference: any; |   baseReference: any; | ||||||
|   propertyDrillDown: Array<string | number> = []; |   propertyDrillDown: Array<string | number> = []; | ||||||
|  |  | ||||||
| @@ -44,7 +44,7 @@ export class Assertion<T = unknown> { | |||||||
|   /** Computed negation failure message for the current assertion */ |   /** Computed negation failure message for the current assertion */ | ||||||
|   private negativeMessage: string; |   private negativeMessage: string; | ||||||
|  |  | ||||||
|   constructor(baseReferenceArg: any, executionModeArg: TExecutionType) { |   constructor(baseReferenceArg: any, executionModeArg: M) { | ||||||
|     this.baseReference = baseReferenceArg; |     this.baseReference = baseReferenceArg; | ||||||
|     this.executionMode = executionModeArg; |     this.executionMode = executionModeArg; | ||||||
|   } |   } | ||||||
| @@ -159,20 +159,23 @@ export class Assertion<T = unknown> { | |||||||
|   /** |   /** | ||||||
|    * Assert that a Promise resolves. |    * Assert that a Promise resolves. | ||||||
|    */ |    */ | ||||||
|   public get resolves(): this { |   /** | ||||||
|     this.isResolves = true; |    * Switch to async (resolve) mode. Subsequent matchers return Promises. | ||||||
|     this.isRejects = false; |    */ | ||||||
|     this.executionMode = 'async'; |   public get resolves(): Assertion<T, 'async'> { | ||||||
|     return this; |     return new Assertion<T, 'async'>(this.baseReference, 'async'); | ||||||
|   } |   } | ||||||
|   /** |   /** | ||||||
|    * Assert that a Promise rejects. |    * Assert that a Promise rejects. | ||||||
|    */ |    */ | ||||||
|   public get rejects(): this { |   /** | ||||||
|     this.isRejects = true; |    * Switch to async (reject) mode. Subsequent matchers return Promises. | ||||||
|     this.isResolves = false; |    */ | ||||||
|     this.executionMode = 'async'; |   public get rejects(): Assertion<T, 'async'> { | ||||||
|     return this; |     const a = new Assertion<T, 'async'>(this.baseReference, 'async'); | ||||||
|  |     // mark to expect rejection | ||||||
|  |     (a as any).isRejects = true; | ||||||
|  |     return a; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -203,7 +206,9 @@ export class Assertion<T = unknown> { | |||||||
|     return this; |     return this; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   private runCheck(checkFunction: () => any): Assertion<T> | Promise<Assertion<T>> { |   // Internal check runner: returns Promise in async mode, else sync Assertion | ||||||
|  |   // Internal check runner; returns Promise or this at runtime, but typed via customAssertion | ||||||
|  |   private runCheck(checkFunction: () => any): any { | ||||||
|     const runDirectOrNegated = (checkFunction: () => any) => { |     const runDirectOrNegated = (checkFunction: () => any) => { | ||||||
|       if (!this.notSetting) { |       if (!this.notSetting) { | ||||||
|         return checkFunction(); |         return checkFunction(); | ||||||
| @@ -223,7 +228,7 @@ export class Assertion<T = unknown> { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     if (this.executionMode === 'async') { |     if (this.executionMode === 'async') { | ||||||
|       const done = plugins.smartpromise.defer<Assertion<T>>(); |       const done = plugins.smartpromise.defer<Assertion<T, M>>(); | ||||||
|       const isThenable = this.baseReference && typeof (this.baseReference as any).then === 'function'; |       const isThenable = this.baseReference && typeof (this.baseReference as any).then === 'function'; | ||||||
|       if (!isThenable) { |       if (!isThenable) { | ||||||
|         done.reject(new Error(`Expected a Promise but received: ${this.formatValue(this.baseReference)}`)); |         done.reject(new Error(`Expected a Promise but received: ${this.formatValue(this.baseReference)}`)); | ||||||
| @@ -268,17 +273,20 @@ export class Assertion<T = unknown> { | |||||||
|         ); |         ); | ||||||
|       } |       } | ||||||
|       // return a promise resolving to this for chaining |       // return a promise resolving to this for chaining | ||||||
|       return done.promise.then(() => this); |       return done.promise.then(() => this) as any; | ||||||
|     } |     } | ||||||
|     // sync: run and return this for chaining |     // sync: run and return this for chaining | ||||||
|     runDirectOrNegated(checkFunction); |     runDirectOrNegated(checkFunction); | ||||||
|     return this; |     return this as any; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Execute a custom assertion. Returns a Promise in async mode, else returns this. | ||||||
|  |    */ | ||||||
|   public customAssertion( |   public customAssertion( | ||||||
|     assertionFunction: (value: any) => boolean, |     assertionFunction: (value: any) => boolean, | ||||||
|     errorMessage: string | ((value: any) => string) |     errorMessage: string | ((value: any) => string) | ||||||
|   ): Assertion<T> | Promise<Assertion<T>> { |   ): M extends 'async' ? Promise<Assertion<T, M>> : Assertion<T, M> { | ||||||
|     // Prepare negation message based on the positive error template, if static |     // Prepare negation message based on the positive error template, if static | ||||||
|     if (typeof errorMessage === 'string') { |     if (typeof errorMessage === 'string') { | ||||||
|       this.negativeMessage = this.computeNegationMessage(errorMessage); |       this.negativeMessage = this.computeNegationMessage(errorMessage); | ||||||
| @@ -290,7 +298,7 @@ export class Assertion<T = unknown> { | |||||||
|           || (typeof errorMessage === 'function' ? errorMessage(value) : errorMessage); |           || (typeof errorMessage === 'function' ? errorMessage(value) : errorMessage); | ||||||
|         throw new Error(msg); |         throw new Error(msg); | ||||||
|       } |       } | ||||||
|     }); |     }) as any; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -298,9 +306,9 @@ export class Assertion<T = unknown> { | |||||||
|    * @param propertyName Name of the property to navigate into. |    * @param propertyName Name of the property to navigate into. | ||||||
|    * @returns Assertion of the property type. |    * @returns Assertion of the property type. | ||||||
|    */ |    */ | ||||||
|   public property<K extends keyof NonNullable<T>>(propertyName: K): Assertion<NonNullable<T>[K]> { |   public property<K extends keyof NonNullable<T>>(propertyName: K): Assertion<NonNullable<T>[K], M> { | ||||||
|     this.propertyDrillDown.push(propertyName as string); |     this.propertyDrillDown.push(propertyName as string); | ||||||
|     return this as unknown as Assertion<NonNullable<T>[K]>; |     return this as unknown as Assertion<NonNullable<T>[K], M>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
| @@ -308,9 +316,9 @@ export class Assertion<T = unknown> { | |||||||
|    * @param index Index of the array item. |    * @param index Index of the array item. | ||||||
|    * @returns Assertion of the element type. |    * @returns Assertion of the element type. | ||||||
|    */ |    */ | ||||||
|   public arrayItem(index: number): Assertion<T extends Array<infer U> ? U : unknown> { |   public arrayItem(index: number): Assertion<T extends Array<infer U> ? U : unknown, M> { | ||||||
|     this.propertyDrillDown.push(index); |     this.propertyDrillDown.push(index); | ||||||
|     return this as unknown as Assertion<T extends Array<infer U> ? U : unknown>; |     return this as unknown as Assertion<T extends Array<infer U> ? U : unknown, M>; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   public log() { |   public log() { | ||||||
| @@ -365,35 +373,35 @@ export class Assertion<T = unknown> { | |||||||
|    |    | ||||||
|   // Namespaced matcher accessors |   // Namespaced matcher accessors | ||||||
|   /** String-specific matchers */ |   /** String-specific matchers */ | ||||||
|   public get string() { |   public get string(): StringMatchers<M> { | ||||||
|     return new StringMatchers(this as Assertion<string>); |     return new StringMatchers<M>(this as Assertion<string, M>); | ||||||
|   } |   } | ||||||
|   /** Array-specific matchers */ |   /** Array-specific matchers */ | ||||||
|   public get array() { |   public get array(): ArrayMatchers<any, M> { | ||||||
|     return new ArrayMatchers<any>(this as Assertion<any[]>); |     return new ArrayMatchers<any, M>(this as Assertion<any[], M>); | ||||||
|   } |   } | ||||||
|   /** Number-specific matchers */ |   /** Number-specific matchers */ | ||||||
|   public get number() { |   public get number(): NumberMatchers<M> { | ||||||
|     return new NumberMatchers(this as Assertion<number>); |     return new NumberMatchers<M>(this as Assertion<number, M>); | ||||||
|   } |   } | ||||||
|   /** Boolean-specific matchers */ |   /** Boolean-specific matchers */ | ||||||
|   public get boolean() { |   public get boolean(): BooleanMatchers<M> { | ||||||
|     return new BooleanMatchers(this as Assertion<boolean>); |     return new BooleanMatchers<M>(this as Assertion<boolean, M>); | ||||||
|   } |   } | ||||||
|   /** Object-specific matchers */ |   /** Object-specific matchers */ | ||||||
|   public get object() { |   public get object(): ObjectMatchers<any, M> { | ||||||
|     return new ObjectMatchers<any>(this as Assertion<object>); |     return new ObjectMatchers<any, M>(this as Assertion<object, M>); | ||||||
|   } |   } | ||||||
|   /** Function-specific matchers */ |   /** Function-specific matchers */ | ||||||
|   public get function() { |   public get function(): FunctionMatchers<M> { | ||||||
|     return new FunctionMatchers(this as Assertion<Function>); |     return new FunctionMatchers<M>(this as Assertion<Function, M>); | ||||||
|   } |   } | ||||||
|   /** Date-specific matchers */ |   /** Date-specific matchers */ | ||||||
|   public get date() { |   public get date(): DateMatchers<M> { | ||||||
|     return new DateMatchers(this as Assertion<Date>); |     return new DateMatchers<M>(this as Assertion<Date, M>); | ||||||
|   } |   } | ||||||
|   /** Type-based matchers */ |   /** Type-based matchers */ | ||||||
|   public get type() { |   public get type(): TypeMatchers<M> { | ||||||
|     return new TypeMatchers(this as Assertion<any>); |     return new TypeMatchers<M>(this as Assertion<any, M>); | ||||||
|   } |   } | ||||||
| } | } | ||||||
		Reference in New Issue
	
	Block a user