Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
0d9fa72b29 | |||
8cb70b6afe | |||
81bd8bfb13 | |||
dd4037677d | |||
d0c2d04595 | |||
db49492af6 | |||
855e20a217 | |||
9b488a87a0 |
31
changelog.md
31
changelog.md
@ -1,5 +1,36 @@
|
||||
# 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
|
||||
|
||||
- Deleted the redundant expectAsync export in ts/index.ts
|
||||
- Users should now call expect(...).resolves or expect(...).rejects for asynchronous assertions
|
||||
|
||||
## 2025-04-29 - 2.1.1 - fix(Assertion)
|
||||
Improve chainability by fixing return types in assertion methods
|
||||
|
||||
- Update runCheck method to explicitly return the correct chainable type for both async and sync assertions
|
||||
- Ensure customAssertion propagates the chainable Assertion instance
|
||||
- Refactor internal promise handling for clarity and consistency
|
||||
|
||||
## 2025-04-28 - 2.1.0 - feat(core)
|
||||
Add new matchers and improve negation messaging
|
||||
|
||||
- Added expect.any() and expect.anything() matchers for enhanced object pattern matching
|
||||
- Introduced new number matchers: toBeNaN(), toBeFinite(), and toBeWithinRange()
|
||||
- Implemented alias toBeEmpty() for both string and array matchers
|
||||
- Enhanced function matchers with toThrowErrorMatching() and toThrowErrorWithMessage()
|
||||
- Improved negation messaging to provide clearer failure messages (e.g. 'Expected 5 not to be greater than 3')
|
||||
- Enhanced object assertions with a toHaveOwnProperty() shorthand that outputs unified diff-style messages
|
||||
|
||||
## 2025-04-28 - 2.0.1 - fix(assertion-matchers)
|
||||
Refactor matcher implementations to consistently use customAssertion for improved consistency and clarity.
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartexpect",
|
||||
"version": "2.0.1",
|
||||
"version": "2.2.0",
|
||||
"private": false,
|
||||
"description": "A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
@ -21,17 +21,35 @@ This document captures the roadmap for evolving the `expect` / `expectAsync` API
|
||||
- [ ] Group matchers under `.string`, `.array`, `.number`, etc. for discoverability.
|
||||
|
||||
## Phase 6: Jest-Style Convenience
|
||||
- [ ] Add `.toMatchObject()`, `.toMatchSnapshot()`, `expect.any()`, `expect.anything()`, etc.
|
||||
- [x] Add `expect.any()` and `expect.anything()` matchers for use in `.toMatchObject()` patterns
|
||||
(Snapshot matchers still TBD)
|
||||
|
||||
## Phase 7: Error Messages & Diffs
|
||||
- [ ] Integrate a diffing library for clear failure output with colorized diffs.
|
||||
The next items to tackle:
|
||||
|
||||
## Phase 8: Nested Access Chaining
|
||||
- [ ] Provide `.at(path)` or lens-based API for deep property assertions in one go.
|
||||
3. Improve negation (`.not`) messaging
|
||||
- Today `.not` simply flips pass/fail, but the failure message isn’t very descriptive. We should capture positive/negative message templates so e.g.
|
||||
> expect(5).not.toBeGreaterThan(3)
|
||||
emits:
|
||||
"Expected 5 not to be greater than 3"
|
||||
|
||||
## Phase 9: Pluggable Reporters
|
||||
- [ ] Allow consumers to swap output format: JSON, TAP, HTML, etc.
|
||||
4. Richer error output for objects/arrays
|
||||
- Integrate a diff library (or extend `fast-deep-equal`) to show unified diffs between expected and actual values
|
||||
|
||||
## Phase 10: API Cleanup
|
||||
- [ ] Audit and remove legacy aliases and redundant methods.
|
||||
- [ ] Finalize deprecations and bump to a major version.
|
||||
5. More built-in matchers
|
||||
- toBeNaN(), toBeFinite()
|
||||
- toBeWithinRange(min, max)
|
||||
- toHaveKeys(...), toHaveOwnKeys(...)
|
||||
- toThrowErrorMatching(/regex/), toThrowErrorWithMessage('…')
|
||||
- string/array: toBeEmpty() alias
|
||||
- object: toHaveOwnProperty() shorthand
|
||||
|
||||
6. TypeScript-friendliness
|
||||
- Enhance `.d.ts` so editors autocomplete namespace methods (e.g. `expect(x).string.`)
|
||||
- Statically type matcher arguments to catch wrong types at compile time
|
||||
|
||||
7. Async assertions and timeouts improvements
|
||||
- Support `.not.resolves`, `.rejects.toThrow()`
|
||||
- Provide clearer timeout errors (e.g. "Expected promise to resolve within …")
|
||||
|
||||
8. Plugin/extension API
|
||||
- Formalize `Assertion.extend()` plugin API for shipping matcher bundles
|
32
test/test.diffOutput.ts
Normal file
32
test/test.diffOutput.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { tap, expect as tExpect } from '@push.rocks/tapbundle';
|
||||
import * as smartexpect from '../dist_ts/index.js';
|
||||
|
||||
tap.test('diff-like output for object.toEqual mismatch', async () => {
|
||||
const a = { x: 1, y: 2 };
|
||||
const b = { x: 1, y: 3 };
|
||||
try {
|
||||
smartexpect.expect(a).object.toEqual(b);
|
||||
throw new Error('Assertion did not throw');
|
||||
} catch (err: any) {
|
||||
const msg: string = err.message;
|
||||
tExpect(msg.includes('Expected objects to be deeply equal')).toBeTrue();
|
||||
tExpect(msg.includes('Received:')).toBeTrue();
|
||||
tExpect(msg.includes('"y": 2')).toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('diff-like output for array.toContainEqual mismatch', async () => {
|
||||
const arr = [{ id: 1 }, { id: 2 }];
|
||||
const item = { id: 3 };
|
||||
try {
|
||||
smartexpect.expect(arr).array.toContainEqual(item);
|
||||
throw new Error('Assertion did not throw');
|
||||
} catch (err: any) {
|
||||
const msg: string = err.message;
|
||||
tExpect(msg.includes('Expected array to contain equal to')).toBeTrue();
|
||||
tExpect(msg.includes('Received:')).toBeTrue();
|
||||
tExpect(msg.includes('"id": 1')).toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
38
test/test.expectAny.ts
Normal file
38
test/test.expectAny.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { tap } from '@push.rocks/tapbundle';
|
||||
import * as smartexpect from '../dist_ts/index.js';
|
||||
|
||||
tap.test('expect.any and expect.anything basic usage', async () => {
|
||||
const obj = { a: 1, b: 'two', d: new Date() };
|
||||
// Using expect.any to match types
|
||||
smartexpect.expect(obj).object.toMatchObject({
|
||||
a: smartexpect.expect.any(Number),
|
||||
b: smartexpect.expect.any(String),
|
||||
d: smartexpect.expect.any(Date),
|
||||
});
|
||||
// Using expect.anything to match any defined value
|
||||
smartexpect.expect(obj).object.toMatchObject({
|
||||
a: smartexpect.expect.anything(),
|
||||
b: smartexpect.expect.anything(),
|
||||
d: smartexpect.expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
tap.test('expect.any mismatch and anything null/undefined rejection', async () => {
|
||||
const obj = { a: 1, b: null };
|
||||
// Mismatch for expect.any
|
||||
try {
|
||||
smartexpect.expect(obj).object.toMatchObject({ a: smartexpect.expect.any(String) });
|
||||
throw new Error('Expected mismatch for expect.any did not throw');
|
||||
} catch (err) {
|
||||
// success: thrown on mismatch
|
||||
}
|
||||
// anything should reject null or undefined
|
||||
try {
|
||||
smartexpect.expect(obj).object.toMatchObject({ b: smartexpect.expect.anything() });
|
||||
throw new Error('Expected anything() to reject null or undefined');
|
||||
} catch (err) {
|
||||
// success: thrown on null
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
22
test/test.negation.ts
Normal file
22
test/test.negation.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { tap, expect as tExpect } from '@push.rocks/tapbundle';
|
||||
import * as smartexpect from '../dist_ts/index.js';
|
||||
|
||||
tap.test('negation message for numeric matcher', async () => {
|
||||
try {
|
||||
smartexpect.expect(5).not.toBeGreaterThan(3);
|
||||
throw new Error('Assertion did not throw');
|
||||
} catch (err: any) {
|
||||
tExpect(err.message).toEqual('Expected number not to be greater than 3');
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('negation message for string matcher', async () => {
|
||||
try {
|
||||
smartexpect.expect('hello').not.string.toInclude('he');
|
||||
throw new Error('Assertion did not throw');
|
||||
} catch (err: any) {
|
||||
tExpect(err.message).toEqual('Expected string not to include "he"');
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@push.rocks/smartexpect',
|
||||
version: '2.0.1',
|
||||
version: '2.2.0',
|
||||
description: 'A testing library to manage expectations in code, offering both synchronous and asynchronous assertion methods.'
|
||||
}
|
||||
|
38
ts/index.ts
38
ts/index.ts
@ -1,4 +1,5 @@
|
||||
import { Assertion } 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
|
||||
|
||||
/**
|
||||
@ -12,32 +13,29 @@ import { Assertion } from './smartexpect.classes.assertion.js';
|
||||
* Entry point for assertions.
|
||||
* Automatically detects Promises to support async assertions.
|
||||
*/
|
||||
export function expect<T>(value: Promise<T>): Assertion<T>;
|
||||
export function expect<T>(value: T): Assertion<T>;
|
||||
export function expect<T>(value: any): Assertion<T> {
|
||||
export function expect<T>(value: Promise<T>): Assertion<T, 'async'>;
|
||||
export function expect<T>(value: T): Assertion<T, 'sync'>;
|
||||
export function expect<T>(value: any): Assertion<T, TExecutionType> {
|
||||
const isThenable = value != null && typeof (value as any).then === 'function';
|
||||
const mode: 'sync' | 'async' = isThenable ? 'async' : 'sync';
|
||||
return new Assertion<T>(value, mode);
|
||||
return new Assertion<T, TExecutionType>(value, mode);
|
||||
}
|
||||
/**
|
||||
* Register custom matchers.
|
||||
*/
|
||||
export namespace expect {
|
||||
export const extend = Assertion.extend;
|
||||
/**
|
||||
* Matcher for a specific constructor. Passes if value is instance of given constructor.
|
||||
*/
|
||||
export function any(constructor: any) {
|
||||
return new AnyMatcher(constructor);
|
||||
}
|
||||
/**
|
||||
* Matcher for any defined value (not null or undefined).
|
||||
*/
|
||||
export function anything() {
|
||||
return new AnythingMatcher();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use `expect(...)` with `.resolves` or `.rejects` instead.
|
||||
*/
|
||||
/**
|
||||
* @deprecated Use `expect(...)` with `.resolves` or `.rejects` instead.
|
||||
*/
|
||||
/**
|
||||
* @deprecated Use `expect(...)` with `.resolves` or `.rejects` instead.
|
||||
*/
|
||||
export const expectAsync = (baseArg: any) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[DEPRECATED] expectAsync() is deprecated. Use expect(...).resolves / .rejects');
|
||||
return new Assertion<any>(baseArg, 'async');
|
||||
};
|
||||
|
||||
|
@ -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<T> {
|
||||
constructor(private assertion: Assertion<T[]>) {}
|
||||
export class ArrayMatchers<T, M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<T[], M>) {}
|
||||
|
||||
toBeArray() {
|
||||
return this.assertion.customAssertion(
|
||||
@ -31,7 +32,9 @@ export class ArrayMatchers<T> {
|
||||
toContainEqual(item: T) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as T[]).some((e) => plugins.fastDeepEqual(e, item)),
|
||||
`Expected array to contain equal to ${JSON.stringify(item)}`
|
||||
(value) =>
|
||||
`Expected array to contain equal to ${JSON.stringify(item)}` +
|
||||
`\nReceived: ${JSON.stringify(value, null, 2)}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -55,6 +58,12 @@ export class ArrayMatchers<T> {
|
||||
`Expected array to be empty`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Alias for empty array check
|
||||
*/
|
||||
toBeEmpty() {
|
||||
return this.toBeEmptyArray();
|
||||
}
|
||||
|
||||
toHaveLengthGreaterThan(length: number) {
|
||||
return this.assertion.customAssertion(
|
||||
|
@ -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<boolean>) {}
|
||||
export class BooleanMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<boolean, M>) {}
|
||||
|
||||
toBeTrue() {
|
||||
return this.assertion.customAssertion(
|
||||
|
@ -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<Date>) {}
|
||||
export class DateMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<Date, M>) {}
|
||||
|
||||
toBeDate() {
|
||||
return this.assertion.customAssertion(
|
||||
|
@ -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<Function>) {}
|
||||
export class FunctionMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<Function, M>) {}
|
||||
|
||||
toThrow(expectedError?: any) {
|
||||
return this.assertion.customAssertion(
|
||||
@ -26,4 +27,36 @@ export class FunctionMatchers {
|
||||
`Expected function to throw${expectedError ? ` ${expectedError}` : ''}`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Assert thrown error message matches the given regex
|
||||
*/
|
||||
toThrowErrorMatching(regex: RegExp) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => {
|
||||
try {
|
||||
(value as Function)();
|
||||
} catch (e: any) {
|
||||
return regex.test(e && e.message);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
`Expected function to throw error matching ${regex}`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Assert thrown error message equals the given string
|
||||
*/
|
||||
toThrowErrorWithMessage(expectedMessage: string) {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => {
|
||||
try {
|
||||
(value as Function)();
|
||||
} catch (e: any) {
|
||||
return e && e.message === expectedMessage;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
`Expected function to throw error with message "${expectedMessage}"`
|
||||
);
|
||||
}
|
||||
}
|
@ -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<number>) {}
|
||||
export class NumberMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<number, M>) {}
|
||||
|
||||
toBeGreaterThan(value: number) {
|
||||
return this.assertion.customAssertion(
|
||||
@ -53,4 +54,31 @@ export class NumberMatchers {
|
||||
`Expected number to equal ${value}`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Checks for NaN
|
||||
*/
|
||||
toBeNaN() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => Number.isNaN(v as number),
|
||||
`Expected number to be NaN`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Checks for finite number
|
||||
*/
|
||||
toBeFinite() {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => Number.isFinite(v as number),
|
||||
`Expected number to be finite`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Checks if number is within inclusive range
|
||||
*/
|
||||
toBeWithinRange(min: number, max: number) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => (v as number) >= min && (v as number) <= max,
|
||||
`Expected number to be within range ${min} - ${max}`
|
||||
);
|
||||
}
|
||||
}
|
@ -1,30 +1,53 @@
|
||||
import { Assertion } 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';
|
||||
|
||||
/**
|
||||
* Namespace for object-specific matchers
|
||||
*/
|
||||
export class ObjectMatchers<T extends object> {
|
||||
constructor(private assertion: Assertion<T>) {}
|
||||
export class ObjectMatchers<T extends object, M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<T, M>) {}
|
||||
|
||||
toEqual(expected: any) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => plugins.fastDeepEqual(v, expected),
|
||||
`Expected objects to be deeply equal to ${JSON.stringify(expected)}`
|
||||
(v) =>
|
||||
`Expected objects to be deeply equal to ${JSON.stringify(expected, null, 2)}` +
|
||||
`\nReceived: ${JSON.stringify(v, null, 2)}`
|
||||
);
|
||||
}
|
||||
|
||||
toMatchObject(expected: object) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => {
|
||||
const obj = v as any;
|
||||
for (const key of Object.keys(expected)) {
|
||||
if (!plugins.fastDeepEqual((v as any)[key], (expected as any)[key])) {
|
||||
const expectedVal = (expected as any)[key];
|
||||
const actualVal = obj[key];
|
||||
if (expectedVal instanceof AnyMatcher) {
|
||||
const ctor = expectedVal.expectedConstructor;
|
||||
if (ctor === Number) {
|
||||
if (typeof actualVal !== 'number') return false;
|
||||
} else if (ctor === String) {
|
||||
if (typeof actualVal !== 'string') return false;
|
||||
} else if (ctor === Boolean) {
|
||||
if (typeof actualVal !== 'boolean') return false;
|
||||
} else {
|
||||
if (!(actualVal instanceof ctor)) return false;
|
||||
}
|
||||
} else if (expectedVal instanceof AnythingMatcher) {
|
||||
if (actualVal === null || actualVal === undefined) {
|
||||
return false;
|
||||
}
|
||||
} else if (!plugins.fastDeepEqual(actualVal, expectedVal)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
`Expected object to match properties ${JSON.stringify(expected)}`
|
||||
(v) =>
|
||||
`Expected object to match properties ${JSON.stringify(expected, null, 2)}` +
|
||||
`\nReceived: ${JSON.stringify(v, null, 2)}`
|
||||
);
|
||||
}
|
||||
|
||||
@ -86,4 +109,25 @@ export class ObjectMatchers<T extends object> {
|
||||
`Expected value to be null or undefined`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Checks own property only (not inherited)
|
||||
*/
|
||||
toHaveOwnProperty(property: string, value?: any) {
|
||||
return this.assertion.customAssertion(
|
||||
(v) => {
|
||||
const obj = v as any;
|
||||
if (!Object.prototype.hasOwnProperty.call(obj, property)) {
|
||||
return false;
|
||||
}
|
||||
if (arguments.length === 2) {
|
||||
return plugins.fastDeepEqual(obj[property], value);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
(v) =>
|
||||
`Expected object to have own property ${property}` +
|
||||
(value !== undefined ? ` with value ${JSON.stringify(value)}` : ``) +
|
||||
`\nReceived: ${JSON.stringify(v, null, 2)}`
|
||||
);
|
||||
}
|
||||
}
|
@ -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<string>) {}
|
||||
export class StringMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<string, M>) {}
|
||||
|
||||
toStartWith(prefix: string) {
|
||||
return this.assertion.customAssertion(
|
||||
@ -47,4 +48,13 @@ export class StringMatchers {
|
||||
`Expected string to have length ${length}`
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Alias for empty string check
|
||||
*/
|
||||
toBeEmpty() {
|
||||
return this.assertion.customAssertion(
|
||||
(value) => (value as string).length === 0,
|
||||
`Expected string to be empty`
|
||||
);
|
||||
}
|
||||
}
|
@ -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<any>) {}
|
||||
export class TypeMatchers<M extends TExecutionType> {
|
||||
constructor(private assertion: Assertion<any, M>) {}
|
||||
|
||||
toBeTypeofString() {
|
||||
return this.assertion.customAssertion(
|
||||
|
@ -18,8 +18,16 @@ import type { TMatcher, TExecutionType } from './types.js';
|
||||
/**
|
||||
* Core assertion class. Generic over the current value type T.
|
||||
*/
|
||||
export class Assertion<T = unknown> {
|
||||
executionMode: TExecutionType;
|
||||
/**
|
||||
* Internal matcher classes for expect.any and expect.anything
|
||||
*/
|
||||
export class AnyMatcher {
|
||||
constructor(public expectedConstructor: any) {}
|
||||
}
|
||||
export class AnythingMatcher {}
|
||||
|
||||
export class Assertion<T = unknown, M extends TExecutionType = 'sync'> {
|
||||
executionMode: M;
|
||||
baseReference: any;
|
||||
propertyDrillDown: Array<string | number> = [];
|
||||
|
||||
@ -33,8 +41,10 @@ export class Assertion<T = unknown> {
|
||||
private isResolves = false;
|
||||
private failMessage: string;
|
||||
private successMessage: string;
|
||||
/** 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;
|
||||
}
|
||||
@ -131,6 +141,16 @@ export class Assertion<T = unknown> {
|
||||
.replace('{value}', formattedValue)
|
||||
.replace('{path}', drillDown || '');
|
||||
}
|
||||
/**
|
||||
* Compute a negated failure message by inserting 'not' into the positive message.
|
||||
*/
|
||||
private computeNegationMessage(message: string): string {
|
||||
const idx = message.indexOf(' to ');
|
||||
if (idx !== -1) {
|
||||
return message.slice(0, idx) + ' not' + message.slice(idx);
|
||||
}
|
||||
return 'Negated: ' + message;
|
||||
}
|
||||
|
||||
public get not() {
|
||||
this.notSetting = true;
|
||||
@ -139,20 +159,23 @@ export class Assertion<T = unknown> {
|
||||
/**
|
||||
* 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<T, 'async'> {
|
||||
return new Assertion<T, 'async'>(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<T, 'async'> {
|
||||
const a = new Assertion<T, 'async'>(this.baseReference, 'async');
|
||||
// mark to expect rejection
|
||||
(a as any).isRejects = true;
|
||||
return a;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -183,25 +206,29 @@ export class Assertion<T = unknown> {
|
||||
return this;
|
||||
}
|
||||
|
||||
private runCheck(checkFunction: () => any) {
|
||||
// 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();
|
||||
} else {
|
||||
let isOk = false;
|
||||
try {
|
||||
runDirectOrNegated(checkFunction());
|
||||
// attempt positive assertion and expect it to throw
|
||||
checkFunction();
|
||||
} catch (e) {
|
||||
isOk = true;
|
||||
}
|
||||
if (!isOk) {
|
||||
throw new Error(this.failMessage || 'Negated assertion failed');
|
||||
const msg = this.failMessage || this.negativeMessage || 'Negated assertion failed';
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (this.executionMode === 'async') {
|
||||
const done = plugins.smartpromise.defer();
|
||||
const done = plugins.smartpromise.defer<Assertion<T, M>>();
|
||||
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)}`));
|
||||
@ -219,47 +246,59 @@ export class Assertion<T = unknown> {
|
||||
(res: any) => {
|
||||
done.reject(new Error(`Expected Promise to reject but it resolved with ${this.formatValue(res)}`));
|
||||
},
|
||||
(err: any) => {
|
||||
this.baseReference = err;
|
||||
try {
|
||||
const ret = runDirectOrNegated(checkFunction);
|
||||
done.resolve(ret);
|
||||
} catch (e: any) {
|
||||
done.reject(e);
|
||||
(err: any) => {
|
||||
this.baseReference = err;
|
||||
try {
|
||||
runDirectOrNegated(checkFunction);
|
||||
done.resolve(this);
|
||||
} catch (e: any) {
|
||||
done.reject(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
(this.baseReference as Promise<any>).then(
|
||||
(res: any) => {
|
||||
this.baseReference = res;
|
||||
try {
|
||||
const ret = runDirectOrNegated(checkFunction);
|
||||
done.resolve(ret);
|
||||
} catch (e: any) {
|
||||
done.reject(e);
|
||||
}
|
||||
},
|
||||
(res: any) => {
|
||||
this.baseReference = res;
|
||||
try {
|
||||
runDirectOrNegated(checkFunction);
|
||||
done.resolve(this);
|
||||
} catch (e: any) {
|
||||
done.reject(e);
|
||||
}
|
||||
},
|
||||
(err: any) => {
|
||||
done.reject(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
return done.promise;
|
||||
// return a promise resolving to this for chaining
|
||||
return done.promise.then(() => this) as any;
|
||||
}
|
||||
return runDirectOrNegated(checkFunction);
|
||||
// sync: run and return this for chaining
|
||||
runDirectOrNegated(checkFunction);
|
||||
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
|
||||
) {
|
||||
errorMessage: string | ((value: any) => string)
|
||||
): M extends 'async' ? Promise<Assertion<T, M>> : Assertion<T, M> {
|
||||
// Prepare negation message based on the positive error template, if static
|
||||
if (typeof errorMessage === 'string') {
|
||||
this.negativeMessage = this.computeNegationMessage(errorMessage);
|
||||
}
|
||||
return this.runCheck(() => {
|
||||
const value = this.getObjectToTestReference();
|
||||
if (!assertionFunction(value)) {
|
||||
throw new Error(this.failMessage || errorMessage);
|
||||
const msg = this.failMessage
|
||||
|| (typeof errorMessage === 'function' ? errorMessage(value) : errorMessage);
|
||||
throw new Error(msg);
|
||||
}
|
||||
});
|
||||
}) as any;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -267,9 +306,9 @@ export class Assertion<T = unknown> {
|
||||
* @param propertyName Name of the property to navigate into.
|
||||
* @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);
|
||||
return this as unknown as Assertion<NonNullable<T>[K]>;
|
||||
return this as unknown as Assertion<NonNullable<T>[K], M>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -277,9 +316,9 @@ export class Assertion<T = unknown> {
|
||||
* @param index Index of the array item.
|
||||
* @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);
|
||||
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() {
|
||||
@ -331,37 +370,38 @@ export class Assertion<T = unknown> {
|
||||
public toBeTypeofBoolean() { return this.type.toBeTypeofBoolean(); }
|
||||
public toBeTypeOf(typeName: string) { return this.type.toBeTypeOf(typeName); }
|
||||
public toBeDefined() { return this.type.toBeDefined(); }
|
||||
|
||||
// Namespaced matcher accessors
|
||||
/** String-specific matchers */
|
||||
public get string() {
|
||||
return new StringMatchers(this as Assertion<string>);
|
||||
public get string(): StringMatchers<M> {
|
||||
return new StringMatchers<M>(this as Assertion<string, M>);
|
||||
}
|
||||
/** Array-specific matchers */
|
||||
public get array() {
|
||||
return new ArrayMatchers<any>(this as Assertion<any[]>);
|
||||
public get array(): ArrayMatchers<any, M> {
|
||||
return new ArrayMatchers<any, M>(this as Assertion<any[], M>);
|
||||
}
|
||||
/** Number-specific matchers */
|
||||
public get number() {
|
||||
return new NumberMatchers(this as Assertion<number>);
|
||||
public get number(): NumberMatchers<M> {
|
||||
return new NumberMatchers<M>(this as Assertion<number, M>);
|
||||
}
|
||||
/** Boolean-specific matchers */
|
||||
public get boolean() {
|
||||
return new BooleanMatchers(this as Assertion<boolean>);
|
||||
public get boolean(): BooleanMatchers<M> {
|
||||
return new BooleanMatchers<M>(this as Assertion<boolean, M>);
|
||||
}
|
||||
/** Object-specific matchers */
|
||||
public get object() {
|
||||
return new ObjectMatchers<any>(this as Assertion<object>);
|
||||
public get object(): ObjectMatchers<any, M> {
|
||||
return new ObjectMatchers<any, M>(this as Assertion<object, M>);
|
||||
}
|
||||
/** Function-specific matchers */
|
||||
public get function() {
|
||||
return new FunctionMatchers(this as Assertion<Function>);
|
||||
public get function(): FunctionMatchers<M> {
|
||||
return new FunctionMatchers<M>(this as Assertion<Function, M>);
|
||||
}
|
||||
/** Date-specific matchers */
|
||||
public get date() {
|
||||
return new DateMatchers(this as Assertion<Date>);
|
||||
public get date(): DateMatchers<M> {
|
||||
return new DateMatchers<M>(this as Assertion<Date, M>);
|
||||
}
|
||||
/** Type-based matchers */
|
||||
public get type() {
|
||||
return new TypeMatchers(this as Assertion<any>);
|
||||
public get type(): TypeMatchers<M> {
|
||||
return new TypeMatchers<M>(this as Assertion<any, M>);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user