From 8cb70b6afe2cbf7ab813ce0dc652ca48559bc823 Mon Sep 17 00:00:00 2001 From: Philipp Kunz Date: Tue, 29 Apr 2025 12:08:57 +0000 Subject: [PATCH] feat(generics): Improve assertion and matcher type definitions by adding execution mode generics for better async/sync support --- changelog.md | 8 +++ ts/00_commitinfo_data.ts | 2 +- ts/index.ts | 9 +-- ts/namespaces/array.ts | 5 +- ts/namespaces/boolean.ts | 5 +- ts/namespaces/date.ts | 5 +- ts/namespaces/function.ts | 5 +- ts/namespaces/number.ts | 5 +- ts/namespaces/object.ts | 5 +- ts/namespaces/string.ts | 5 +- ts/namespaces/type.ts | 5 +- ts/smartexpect.classes.assertion.ts | 86 ++++++++++++++++------------- 12 files changed, 85 insertions(+), 60 deletions(-) diff --git a/changelog.md b/changelog.md index b11af0a..fa72e9f 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # 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) Remove deprecated expectAsync function and advise using .resolves/.rejects on expect for async assertions diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e9ab340..7dddf82 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { 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.' } diff --git a/ts/index.ts b/ts/index.ts index 738248c..648aa81 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -1,4 +1,5 @@ import { Assertion, AnyMatcher, AnythingMatcher } from './smartexpect.classes.assertion.js'; +import type { TExecutionType } from './types.js'; // 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. * Automatically detects Promises to support async assertions. */ -export function expect(value: Promise): Assertion; -export function expect(value: T): Assertion; -export function expect(value: any): Assertion { +export function expect(value: Promise): Assertion; +export function expect(value: T): Assertion; +export function expect(value: any): Assertion { const isThenable = value != null && typeof (value as any).then === 'function'; const mode: 'sync' | 'async' = isThenable ? 'async' : 'sync'; - return new Assertion(value, mode); + return new Assertion(value, mode); } /** * Register custom matchers. diff --git a/ts/namespaces/array.ts b/ts/namespaces/array.ts index 0758e5a..a096cb4 100644 --- a/ts/namespaces/array.ts +++ b/ts/namespaces/array.ts @@ -1,11 +1,12 @@ import { Assertion } from '../smartexpect.classes.assertion.js'; import * as plugins from '../plugins.js'; +import type { TExecutionType } from '../types.js'; /** * Namespace for array-specific matchers */ -export class ArrayMatchers { - constructor(private assertion: Assertion) {} +export class ArrayMatchers { + constructor(private assertion: Assertion) {} toBeArray() { return this.assertion.customAssertion( diff --git a/ts/namespaces/boolean.ts b/ts/namespaces/boolean.ts index 68b841c..570d28a 100644 --- a/ts/namespaces/boolean.ts +++ b/ts/namespaces/boolean.ts @@ -1,10 +1,11 @@ import { Assertion } from '../smartexpect.classes.assertion.js'; +import type { TExecutionType } from '../types.js'; /** * Namespace for boolean-specific matchers */ -export class BooleanMatchers { - constructor(private assertion: Assertion) {} +export class BooleanMatchers { + constructor(private assertion: Assertion) {} toBeTrue() { return this.assertion.customAssertion( diff --git a/ts/namespaces/date.ts b/ts/namespaces/date.ts index a525070..c00eba6 100644 --- a/ts/namespaces/date.ts +++ b/ts/namespaces/date.ts @@ -1,10 +1,11 @@ import { Assertion } from '../smartexpect.classes.assertion.js'; +import type { TExecutionType } from '../types.js'; /** * Namespace for date-specific matchers */ -export class DateMatchers { - constructor(private assertion: Assertion) {} +export class DateMatchers { + constructor(private assertion: Assertion) {} toBeDate() { return this.assertion.customAssertion( diff --git a/ts/namespaces/function.ts b/ts/namespaces/function.ts index f4faa0b..3318f2d 100644 --- a/ts/namespaces/function.ts +++ b/ts/namespaces/function.ts @@ -1,10 +1,11 @@ import { Assertion } from '../smartexpect.classes.assertion.js'; +import type { TExecutionType } from '../types.js'; /** * Namespace for function-specific matchers */ -export class FunctionMatchers { - constructor(private assertion: Assertion) {} +export class FunctionMatchers { + constructor(private assertion: Assertion) {} toThrow(expectedError?: any) { return this.assertion.customAssertion( diff --git a/ts/namespaces/number.ts b/ts/namespaces/number.ts index 922e6d9..338d15f 100644 --- a/ts/namespaces/number.ts +++ b/ts/namespaces/number.ts @@ -1,10 +1,11 @@ import { Assertion } from '../smartexpect.classes.assertion.js'; +import type { TExecutionType } from '../types.js'; /** * Namespace for number-specific matchers */ -export class NumberMatchers { - constructor(private assertion: Assertion) {} +export class NumberMatchers { + constructor(private assertion: Assertion) {} toBeGreaterThan(value: number) { return this.assertion.customAssertion( diff --git a/ts/namespaces/object.ts b/ts/namespaces/object.ts index 96db200..0a1d723 100644 --- a/ts/namespaces/object.ts +++ b/ts/namespaces/object.ts @@ -1,11 +1,12 @@ import { Assertion, AnyMatcher, AnythingMatcher } from '../smartexpect.classes.assertion.js'; +import type { TExecutionType } from '../types.js'; import * as plugins from '../plugins.js'; /** * Namespace for object-specific matchers */ -export class ObjectMatchers { - constructor(private assertion: Assertion) {} +export class ObjectMatchers { + constructor(private assertion: Assertion) {} toEqual(expected: any) { return this.assertion.customAssertion( diff --git a/ts/namespaces/string.ts b/ts/namespaces/string.ts index b0e4be9..278e56a 100644 --- a/ts/namespaces/string.ts +++ b/ts/namespaces/string.ts @@ -1,10 +1,11 @@ import { Assertion } from '../smartexpect.classes.assertion.js'; +import type { TExecutionType } from '../types.js'; /** * Namespace for string-specific matchers */ -export class StringMatchers { - constructor(private assertion: Assertion) {} +export class StringMatchers { + constructor(private assertion: Assertion) {} toStartWith(prefix: string) { return this.assertion.customAssertion( diff --git a/ts/namespaces/type.ts b/ts/namespaces/type.ts index f7e3b09..544a62f 100644 --- a/ts/namespaces/type.ts +++ b/ts/namespaces/type.ts @@ -1,10 +1,11 @@ import { Assertion } from '../smartexpect.classes.assertion.js'; +import type { TExecutionType } from '../types.js'; /** * Namespace for type-based matchers */ -export class TypeMatchers { - constructor(private assertion: Assertion) {} +export class TypeMatchers { + constructor(private assertion: Assertion) {} toBeTypeofString() { return this.assertion.customAssertion( diff --git a/ts/smartexpect.classes.assertion.ts b/ts/smartexpect.classes.assertion.ts index 484a68e..1b6bdfd 100644 --- a/ts/smartexpect.classes.assertion.ts +++ b/ts/smartexpect.classes.assertion.ts @@ -26,8 +26,8 @@ export class AnyMatcher { } export class AnythingMatcher {} -export class Assertion { - executionMode: TExecutionType; +export class Assertion { + executionMode: M; baseReference: any; propertyDrillDown: Array = []; @@ -44,7 +44,7 @@ export class Assertion { /** Computed negation failure message for the current assertion */ private negativeMessage: string; - constructor(baseReferenceArg: any, executionModeArg: TExecutionType) { + constructor(baseReferenceArg: any, executionModeArg: M) { this.baseReference = baseReferenceArg; this.executionMode = executionModeArg; } @@ -159,20 +159,23 @@ export class Assertion { /** * Assert that a Promise resolves. */ - public get resolves(): this { - this.isResolves = true; - this.isRejects = false; - this.executionMode = 'async'; - return this; + /** + * Switch to async (resolve) mode. Subsequent matchers return Promises. + */ + public get resolves(): Assertion { + return new Assertion(this.baseReference, 'async'); } /** * Assert that a Promise rejects. */ - public get rejects(): this { - this.isRejects = true; - this.isResolves = false; - this.executionMode = 'async'; - return this; + /** + * Switch to async (reject) mode. Subsequent matchers return Promises. + */ + public get rejects(): Assertion { + const a = new Assertion(this.baseReference, 'async'); + // mark to expect rejection + (a as any).isRejects = true; + return a; } /** @@ -203,7 +206,9 @@ export class Assertion { return this; } - private runCheck(checkFunction: () => any): Assertion | Promise> { + // 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) => { if (!this.notSetting) { return checkFunction(); @@ -223,7 +228,7 @@ export class Assertion { }; if (this.executionMode === 'async') { - const done = plugins.smartpromise.defer>(); + const done = plugins.smartpromise.defer>(); const isThenable = this.baseReference && typeof (this.baseReference as any).then === 'function'; if (!isThenable) { done.reject(new Error(`Expected a Promise but received: ${this.formatValue(this.baseReference)}`)); @@ -268,17 +273,20 @@ export class Assertion { ); } // 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 runDirectOrNegated(checkFunction); - return this; + return this as any; } + /** + * Execute a custom assertion. Returns a Promise in async mode, else returns this. + */ public customAssertion( assertionFunction: (value: any) => boolean, errorMessage: string | ((value: any) => string) - ): Assertion | Promise> { + ): M extends 'async' ? Promise> : Assertion { // Prepare negation message based on the positive error template, if static if (typeof errorMessage === 'string') { this.negativeMessage = this.computeNegationMessage(errorMessage); @@ -290,7 +298,7 @@ export class Assertion { || (typeof errorMessage === 'function' ? errorMessage(value) : errorMessage); throw new Error(msg); } - }); + }) as any; } /** @@ -298,9 +306,9 @@ export class Assertion { * @param propertyName Name of the property to navigate into. * @returns Assertion of the property type. */ - public property>(propertyName: K): Assertion[K]> { + public property>(propertyName: K): Assertion[K], M> { this.propertyDrillDown.push(propertyName as string); - return this as unknown as Assertion[K]>; + return this as unknown as Assertion[K], M>; } /** @@ -308,9 +316,9 @@ export class Assertion { * @param index Index of the array item. * @returns Assertion of the element type. */ - public arrayItem(index: number): Assertion ? U : unknown> { + public arrayItem(index: number): Assertion ? U : unknown, M> { this.propertyDrillDown.push(index); - return this as unknown as Assertion ? U : unknown>; + return this as unknown as Assertion ? U : unknown, M>; } public log() { @@ -365,35 +373,35 @@ export class Assertion { // Namespaced matcher accessors /** String-specific matchers */ - public get string() { - return new StringMatchers(this as Assertion); + public get string(): StringMatchers { + return new StringMatchers(this as Assertion); } /** Array-specific matchers */ - public get array() { - return new ArrayMatchers(this as Assertion); + public get array(): ArrayMatchers { + return new ArrayMatchers(this as Assertion); } /** Number-specific matchers */ - public get number() { - return new NumberMatchers(this as Assertion); + public get number(): NumberMatchers { + return new NumberMatchers(this as Assertion); } /** Boolean-specific matchers */ - public get boolean() { - return new BooleanMatchers(this as Assertion); + public get boolean(): BooleanMatchers { + return new BooleanMatchers(this as Assertion); } /** Object-specific matchers */ - public get object() { - return new ObjectMatchers(this as Assertion); + public get object(): ObjectMatchers { + return new ObjectMatchers(this as Assertion); } /** Function-specific matchers */ - public get function() { - return new FunctionMatchers(this as Assertion); + public get function(): FunctionMatchers { + return new FunctionMatchers(this as Assertion); } /** Date-specific matchers */ - public get date() { - return new DateMatchers(this as Assertion); + public get date(): DateMatchers { + return new DateMatchers(this as Assertion); } /** Type-based matchers */ - public get type() { - return new TypeMatchers(this as Assertion); + public get type(): TypeMatchers { + return new TypeMatchers(this as Assertion); } } \ No newline at end of file