feat(generics): Improve assertion and matcher type definitions by adding execution mode generics for better async/sync support

This commit is contained in:
Philipp Kunz 2025-04-29 12:08:57 +00:00
parent 81bd8bfb13
commit 8cb70b6afe
12 changed files with 85 additions and 60 deletions

View File

@ -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

View File

@ -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.'
} }

View File

@ -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.

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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>);
} }
} }