UNPKG

@plugjs/expect5

Version:

Unit Testing for the PlugJS Build System ========================================

1,268 lines (1,076 loc) 41.9 kB
import { diff } from './diff' import { toInclude, toMatchContents } from './include' import { ExpectationError, isMatcher, prefixType, stringifyConstructor, stringifyValue, typeOf, } from './types' import type { Diff } from './diff' import type { Matcher } from './matchers' import type { Constructor, TypeMappings, TypeName, } from './types' /* ========================================================================== * * TYPES SUPPORTING EXPECTATIONS * * ========================================================================== */ /** An assertion function, for sub-expectations and value introspection */ export type AssertionFunction<T = unknown> = (assert: Expectations<T>) => any /** Return the type asserted by an {@link AssertionFunction} or `T` */ export type AssertedType<T, F extends AssertionFunction<any>, R = ReturnType<F>> = R extends Expectations<infer I> ? unknown extends I ? T : // returns Expectations<unknown>, use T I : // returns Expectations<something>, use "something" T // returns something else (void), use T /** Infer the type of a {@link Matcher} */ export type InferMatcher<T, M extends Matcher> = M extends Matcher<infer I> ? unknown extends I ? T : T & I : never /** Recursively infer the type of a {@link Matcher} in a `Record` */ export type InferToEqual<T> = T extends Matcher<infer V> ? V : T extends Record<any, any> ? { [ k in keyof T ] : InferToEqual<T[k]> } : T /** Simple wrapper defining the _parent_ instance of an {@link Expectations}. */ export type ExpectationsParent = { /** Parent {@link Expectations} instance */ expectations: Expectations, /** Property associating _this_ to the parent */ prop: string | number | symbol, } /* ========================================================================== * * EXPECTATIONS * * ========================================================================== */ /** Main class containing all supported expectations */ export class Expectations<T = unknown> { /** The {@link NegativeExpectations} associated with _this_ */ readonly not: NegativeExpectations<T> /** * Create an {@link Expectations} instance associated with the specified * value and error remarks. * * Optionally a parent {@link Expectations} instance can be specified. */ constructor( /** The value associated with this instance */ readonly value: T, /** Optional additional _remarks_ to associate with errors */ readonly remarks: string | undefined, // required, but migth be undefined /** * An optional {@link ExpectationsParent} defining _this_ to be a * child of another {@link Expectations} instance */ readonly parent?: ExpectationsParent, ) { this.not = new NegativeExpectations(this) } /** Throw an {@link ExpectationError} associated with _this_ */ protected _fail(details: string, diff?: Diff): never { const error = new ExpectationError(this, details, diff) Error.captureStackTrace(error, this._fail) throw error } /* ------------------------------------------------------------------------ * * BASIC * * ------------------------------------------------------------------------ */ /** * Expects the value to be of the specified _extended_ {@link TypeName type}. * * Negation: {@link NegativeExpectations.toBeA `not.toBeA(...)`} */ toBeA<Name extends TypeName>(type: Name): Expectations<TypeMappings[Name]> /** * Expects the value to be of the specified _extended_ {@link TypeName type}, * and further validates it with a {@link Matcher}. * * Negation: {@link NegativeExpectations.toBeA `not.toBeA(...)`} */ toBeA< Name extends TypeName, Mapped extends TypeMappings[Name], Match extends Matcher, >( type: Name, matcher: Match, ): Expectations<InferMatcher<Mapped, Match>> /** * Expects the value to be of the specified _extended_ {@link TypeName type}, * and further asserts it with an {@link AssertionFunction}. * * Negation: {@link NegativeExpectations.toBeA `not.toBeA(...)`} */ toBeA< Name extends TypeName, Mapped extends TypeMappings[Name], Assert extends AssertionFunction<Mapped>, >( type: Name, assertion: Assert, ): Expectations<AssertedType<Mapped, Assert>> toBeA( type: TypeName, assertionOrMatcher?: AssertionFunction | Matcher, ): Expectations { if (typeOf(this.value) === type) { if (isMatcher(assertionOrMatcher)) { assertionOrMatcher.expect(this.value) } else if (assertionOrMatcher) { assertionOrMatcher(this as Expectations<any>) } return this as Expectations<any> } this._fail(`to be ${prefixType(type)}`) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `Date`, a `string` parseable into a `Date`, or a * `number` indicating the milliseconds from the epoch, _strictly after_ * the specified date. * * Negation: {@link Expectations.toBeBeforeOrEqual `toBeBeforeOrEqual(...)`} */ toBeAfter(value: Date | number | string, deltaMs?: number): Expectations<T> { const after = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : new Date(value).getTime() const timestamp = this.value instanceof Date ? this.value.getTime() : typeof this.value === 'string' ? new Date(this.value).getTime() : typeof this.value === 'number' ? this.value : undefined if (typeof timestamp !== 'number') { this._fail(`to be a string, a number or an instance of ${stringifyConstructor(Date)}`) } else if (isNaN(timestamp)) { this._fail('to be a valid date') } if (timestamp <= after) { this._fail(`to be after ${stringifyValue(new Date(after))}`) } if (deltaMs !== undefined) return this.toBeBefore(after + deltaMs + 1) return this } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `Date`, a `string` parseable into a `Date`, or a * `number` indicating the milliseconds from the epoch, _after or equal_ * the specified date. * * Negation: {@link Expectations.toBeBefore `toBeBefore(...)`} */ toBeAfterOrEqual(value: Date | number | string, deltaMs?: number): Expectations<T> { const after = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : new Date(value).getTime() const delta = deltaMs === undefined ? undefined : deltaMs + 1 return this.toBeAfter(after - 1, delta) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `Date`, a `string` parseable into a `Date`, or a * `number` indicating the milliseconds from the epoch, _strictly before_ * the specified date. * * Negation: {@link Expectations.toBeAfterOrEqual `toBeAfterOrEqual(...)`} */ toBeBefore(value: Date | number | string, deltaMs?: number): Expectations<T> { const before = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : new Date(value).getTime() const timestamp = this.value instanceof Date ? this.value.getTime() : typeof this.value === 'string' ? new Date(this.value).getTime() : typeof this.value === 'number' ? this.value : undefined if (typeof timestamp !== 'number') { this._fail(`to be a string, a number or an instance of ${stringifyConstructor(Date)}`) } else if (isNaN(timestamp)) { this._fail('to be a valid date') } if (timestamp >= before) { this._fail(`to be before ${stringifyValue(new Date(before))}`) } if (deltaMs !== undefined) return this.toBeAfter(before - deltaMs - 1) return this } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `Date`, a `string` parseable into a `Date`, or a * `number` indicating the milliseconds from the epoch, _before or equal_ * the specified date. * * Negation: {@link Expectations.toBeAfter `toBeAfter(...)`} */ toBeBeforeOrEqual(value: Date | number | string, deltaMs?: number): Expectations<T> { const before = value instanceof Date ? value.getTime() : typeof value === 'number' ? value : new Date(value).getTime() const delta = deltaMs === undefined ? undefined : deltaMs + 1 return this.toBeBefore(before + 1, delta) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `number` within a given +/- _delta_ range of the * specified expected value. * * Negation: {@link NegativeExpectations.toBeCloseTo `not.toBeCloseTo(...)`} */ toBeCloseTo(value: number, delta: number): Expectations<number> /** * Expects the value to be a `bigint` within a given +/- _delta_ range of the * specified expected value. * * Negation: {@link NegativeExpectations.toBeCloseTo `not.toBeCloseTo(...)`} */ toBeCloseTo(value: bigint, delta: bigint): Expectations<bigint> toBeCloseTo(value: number | bigint, delta: number | bigint): Expectations { const min = (value as number) - (delta as number) const max = (value as number) + (delta as number) return this.toBeWithinRange(min, max) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be neither `null` nor `undefined`. * * Negation: {@link NegativeExpectations.toBeDefined `not.toBeDefined()`} */ toBeDefined(): Expectations<T> { if ((this.value !== null) && (this.value !== undefined)) return this this._fail(`to be neither ${stringifyValue(null)} nor ${stringifyValue(undefined)}`) } /* ------------------------------------------------------------------------ */ /** * Expect the value to be an instance of {@link Error}. * * If specified, the {@link Error}'s own message will be further expected to * either match the specified {@link RegExp}, or equal the specified `string`. */ toBeError( message?: string | RegExp, substring?: boolean, ): Expectations<Error> /** * Expect the value to be an instance of {@link Error} and further asserts * it to be an instance of the specifed {@link Error} {@link Constructor}. * * If specified, the {@link Error}'s own message will be further expected to * either match the specified {@link RegExp}, or equal the specified `string`. */ toBeError<Class extends Constructor<Error>>( constructor: Class, message?: string | RegExp, substring?: boolean, ): Expectations<InstanceType<Class>> toBeError( constructorOrMessage?: string | RegExp | Constructor, maybeMessageOrSubstring?: string | RegExp | boolean, maybeSubstring?: boolean, ): Expectations { const [ constructor, message, substring = false ] = typeof constructorOrMessage === 'function' ? [ constructorOrMessage, maybeMessageOrSubstring as string | RegExp, maybeSubstring ] : [ Error, constructorOrMessage as string | RegExp, maybeMessageOrSubstring as boolean ] if (message === undefined) return this.toBeInstanceOf(constructor) return this.toBeInstanceOf(constructor, (assert) => { assert.toHaveProperty('message', (assertMessage) => { assertMessage.toBeA('string') if ((typeof message === 'string') && (substring === false)) { assertMessage.toStrictlyEqual(message) } else { assertMessage.toMatch(message) } }) }) } /* ------------------------------------------------------------------------ */ /** Expects the value strictly equal to `false`. */ toBeFalse(): Expectations<false> { return this.toStrictlyEqual(false) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be _falsy_ (zero, empty string, `false`, ...). * * Negation: {@link Expectations.toBeTruthy `toBeTruthy()`} */ toBeFalsy(): Expectations<T> { if (! this.value) return this this._fail('to be falsy') } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `number` greater than the specified expected * value. * * Negation: {@link Expectations.toBeLessThanOrEqual `toBeLessThanOrEqual(...)`} */ toBeGreaterThan(value: number): Expectations<number> /** * Expects the value to be a `bigint` greater than the specified expected * value. * * Negation: {@link Expectations.toBeLessThanOrEqual `toBeLessThanOrEqual(...)`} */ toBeGreaterThan(value: bigint): Expectations<bigint> toBeGreaterThan(value: number | bigint): Expectations { this.toBeA(typeof value) if ((this.value as any) > value) return this this._fail(`to be greater than ${stringifyValue(value)}`) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `number` greater than or equal to the specified * expected value. * * Negation: {@link Expectations.toBeLessThan `toBeLessThan(...)`} */ toBeGreaterThanOrEqual(value: number): Expectations<number> /** * Expects the value to be a `bigint` greater than or equal to the specified * expected value. * * Negation: {@link Expectations.toBeLessThan `toBeLessThan(...)`} */ toBeGreaterThanOrEqual(value: bigint): Expectations<bigint> toBeGreaterThanOrEqual(value: number | bigint): Expectations { this.toBeA(typeof value) if ((this.value as any) >= value) return this this._fail(`to be greater than or equal to ${stringifyValue(value)}`) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be an instance of the specified {@link Constructor}. * * Negation: {@link NegativeExpectations.toBeInstanceOf `not.toInstanceOf(...)`} */ toBeInstanceOf<Class extends Constructor>( constructor: Class, ): Expectations<InstanceType<Class>> /** * Expects the value to be an instance of the specified {@link Constructor}, * and further validates it with a {@link Matcher}. * * Negation: {@link NegativeExpectations.toBeInstanceOf `not.toInstanceOf(...)`} */ toBeInstanceOf< Class extends Constructor, Match extends Matcher, >( constructor: Class, matcher: Match, ): Expectations<InferMatcher<InstanceType<Class>, Match>> /** * Expects the value to be an instance of the specified {@link Constructor}, * and further asserts it with an {@link AssertionFunction}. * * Negation: {@link NegativeExpectations.toBeInstanceOf `not.toInstanceOf(...)`} */ toBeInstanceOf< Class extends Constructor, Assert extends AssertionFunction<InstanceType<Class>>, >( constructor: Class, assertion: Assert, ): Expectations<AssertedType<InstanceType<Class>, Assert>> toBeInstanceOf( constructor: Constructor, assertionOrMatcher?: AssertionFunction | Matcher, ): Expectations { if (this.value instanceof constructor) { if (isMatcher(assertionOrMatcher)) { assertionOrMatcher.expect(this.value) } else if (assertionOrMatcher) { assertionOrMatcher(this as Expectations<any>) } return this as Expectations<any> } this._fail(`to be an instance of ${stringifyConstructor(constructor)}`) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `number` less than the specified expected value. * * Negation: {@link Expectations.toBeGreaterThanOrEqual `toBeGreaterThanOrEqual(...)`} */ toBeLessThan(value: number): Expectations<number> /** * Expects the value to be a `bigint` less than the specified expected value. * * Negation: {@link Expectations.toBeGreaterThanOrEqual `toBeGreaterThanOrEqual(...)`} */ toBeLessThan(value: bigint): Expectations<bigint> toBeLessThan(value: number | bigint): Expectations { this.toBeA(typeof value) if ((this.value as any) < value) return this this._fail(`to be less than ${stringifyValue(value)}`) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `number` less than or equal to the specified * expected value. * * Negation: {@link Expectations.toBeGreaterThan `toBeGreaterThan(...)`} */ toBeLessThanOrEqual(value: number): Expectations<number> /** * Expects the value to be a `bigint` less than or equal to the specified * expected value. * * Negation: {@link Expectations.toBeGreaterThan `toBeGreaterThan(...)`} */ toBeLessThanOrEqual(value: bigint): Expectations<bigint> toBeLessThanOrEqual(value: number | bigint): Expectations { this.toBeA(typeof value) if ((this.value as any) <= value) return this this._fail(`to be less than or equal to ${stringifyValue(value)}`) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be `NaN`. * * Negation: {@link NegativeExpectations.toBeNaN `not.toBeNaN()`} */ toBeNaN(): Expectations<number> { const expectations = this.toBeA('number') if (isNaN(expectations.value)) return expectations this._fail(`to be ${stringifyValue(NaN)}`) } /* ------------------------------------------------------------------------ */ /** Expects the value to strictly equal `null`. */ toBeNull(): Expectations<null> { return this.toStrictlyEqual(null) } /* ------------------------------------------------------------------------ */ /** Expects the value to strictly equal `true`. */ toBeTrue(): Expectations<true> { return this.toStrictlyEqual(true) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be _falsy_ (non-zero, non-empty string, ...). * * Negation: {@link Expectations.toBeFalsy `toBeFalsy()`} */ toBeTruthy(): Expectations<T> { if (this.value) return this this._fail('to be truthy') } /* ------------------------------------------------------------------------ */ /** Expects the value to strictly equal `undefined`. */ toBeUndefined(): Expectations<undefined> { return this.toStrictlyEqual(undefined) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `number` within the specified range where the * minimum and maximum values are inclusive. * * Negation: {@link NegativeExpectations.toBeWithinRange `not.toBeWithinRange(...)`} */ toBeWithinRange(min: number, max: number): Expectations<number> /** * Expects the value to be a `bigint` within the specified range where the * minimum and maximum values are inclusive. * * Negation: {@link NegativeExpectations.toBeWithinRange `not.toBeWithinRange(...)`} */ toBeWithinRange(min: bigint, max: bigint): Expectations<bigint> toBeWithinRange(min: number | bigint, max: number | bigint): Expectations { if (max < min) { const num = max max = min min = num } this.toBeA(typeof min) if (((this.value as any) < min) || ((this.value as any) > max)) { this._fail(`to be within ${stringifyValue(min)}...${stringifyValue(max)}`) } return this } /* ------------------------------------------------------------------------ */ /** * Expects the value to be _deep equal to_ the specified expected one. * * When `strict` is `true` (defaults to `false`) enumerable keys associated * with an `undefined` value found in the _actual_ object will have to be * also defined in the _expected_ object. * * For example: * * ```ts * // non-strict mode * expect({ foo: undefined }).toEqual({}) // will pass * expect({}).toEqual({ foo: undefined }) // will pass * expect({ foo: undefined }).toEqual({ foo: undefined }) // will pass * * // strict mode * expect({ foo: undefined }).toEqual({}, true) // will fail * expect({}).toEqual({ foo: undefined }, true) // will fail * expect({}).toEqual({ foo: undefined }) // will fail ("foo" is missing, whether "strict" is true or false) * ``` * * Negation: {@link NegativeExpectations.toEqual `not.toEqual(...)`} */ toEqual<Type>(expected: Type, strict: boolean = false): Expectations<InferToEqual<Type>> { if ((this.value as any) === expected) return this as Expectations<any> const result = diff(this.value, expected, strict) if (result.diff) { if (isMatcher(expected)) { this._fail('to satisfy expectation matcher', result) } else { this._fail(`to equal ${stringifyValue(expected)}`, result) } } else { return this as Expectations<any> } } /* ------------------------------------------------------------------------ */ /** * Expects the value to have a `number` _property_ `length` with the specified * expected value. * * Negation: {@link NegativeExpectations.toHaveLength `not.toHaveLength(...)`} */ toHaveLength(length: number): Expectations<T & { length: number }> { this.toBeDefined() const realLength = (this.value as any)['length'] if (typeof realLength !== 'number') { this._fail('to have a numeric "length" property') } else if (realLength !== length) { this._fail(`to have length ${length}`) } return this as Expectations<any> } /* ------------------------------------------------------------------------ */ /** * Expects the value to have the specified _property_. * * The value associated with the property should not be `undefined`. * * For example: * * ```ts * expect({}).toHaveProperty('foo') // fails * expect({ foo: undefined }).toHaveProperty('foo') // fails * ``` * * Negation: {@link NegativeExpectations.toHaveProperty `not.toHaveProperty(...)`} */ toHaveProperty<Prop extends string | number | symbol>( property: Prop, ): Expectations<T & { [keyt in Prop] : unknown }> /** * Expects the value to have the specified _property_ and (if found) * further validates its value with a {@link Matcher}. * * This also works with `undefined` values, for example: * ```ts * expect({ foo: undefined }).toHaveProperty('foo') // no matcher, fails * expect({ foo: undefined }).toHaveProperty('foo', expect.toBeUndefined()) // works! * ``` * * Negation: {@link NegativeExpectations.toHaveProperty `not.toHaveProperty(...)`} */ toHaveProperty< Prop extends string | number | symbol, Match extends Matcher, >( property: Prop, matcher: Match, ): Expectations<T & { [keyt in Prop] : InferMatcher<unknown, Match> }> /** * Expects the value to have the specified _property_ and (if specified) * further asserts its value with an {@link AssertionFunction}. * * Negation: {@link NegativeExpectations.toHaveProperty `not.toHaveProperty(...)`} */ toHaveProperty< Prop extends string | number | symbol, Assert extends AssertionFunction, >( property: Prop, assertion: Assert, ): Expectations<T & { [keyt in Prop] : AssertedType<unknown, Assert> }> toHaveProperty( property: string | number | symbol, assertionOrMatcher?: AssertionFunction | Matcher, ): Expectations { this.toBeDefined() const propertyValue = (this.value as any)[property] let hasProperty: boolean try { // this is for "normal" objects hasProperty = property in (this.value as any) } catch { // when "in" doesn't apply (primitives) use the value hasProperty = propertyValue !== undefined } if (! hasProperty) { this._fail(`to have property "${String(property)}"`) } if (assertionOrMatcher) { const parent: ExpectationsParent = { expectations: this, prop: property } try { if (isMatcher(assertionOrMatcher)) { assertionOrMatcher.expect(propertyValue, parent) } else if (assertionOrMatcher) { const expectations = new Expectations(propertyValue, this.remarks, parent) assertionOrMatcher(expectations) } } catch (error) { // any caught error difference gets remapped as a property diff if ((error instanceof ExpectationError) && (error.diff)) { error.diff = { diff: true, value: this.value, props: { [property]: error.diff }, } } // re-throw throw error } } else if (propertyValue === undefined) { this._fail(`has property "${String(property)}" with value ${stringifyValue(undefined)}`) } return this as Expectations<any> } /* ------------------------------------------------------------------------ */ /** * Expects the value to have a `number` _property_ `size` with the specified * expected value. * * Negation: {@link NegativeExpectations.toHaveSize `not.toHaveSize(...)`} */ toHaveSize(size: number): Expectations<T & { size: number }> { this.toBeDefined() const realSize = (this.value as any)['size'] if (typeof realSize !== 'number') { this._fail('to have a numeric "size" property') } else if (realSize !== size) { this._fail(`to have size ${size}`) } return this as Expectations<any> } /* ------------------------------------------------------------------------ */ /** * Expect the value to include _all_ properties from the specified _object_. * * If the object being expected is a {@link Map}, the properties specified * here will be treated as _mappings_ for said {@link Map}. * * Negation: {@link NegativeExpectations.toInclude `not.toInclude(...)`} */ toInclude<P extends Record<string, any>>(properties: P): Expectations<T> /** * Expect the value to include _all_ mappings from the specified {@link Map}. * * Negation: {@link NegativeExpectations.toInclude `not.toInclude(...)`} */ toInclude(mappings: Map<any, any>): Expectations<T> /** * Expect the value to be an {@link Iterable} object includind _all_ values * from the specified {@link Set}, in any order. * * Negation: {@link NegativeExpectations.toInclude `not.toInclude(...)`} */ toInclude(entries: Set<any>): Expectations<T> /** * Expect the value to be an {@link Iterable} object includind _all_ values * from the specified _array_, in any order. * * Negation: {@link NegativeExpectations.toInclude `not.toInclude(...)`} */ toInclude(values: any[]): Expectations<T> toInclude( contents: Record<string, any> | Map<any, any> | Set<any> | any[], ): Expectations { toInclude(this, false, contents) return this } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `string` _matching_ the specified sub-`string` * or {@link RegExp}. * * Negation: {@link NegativeExpectations.toMatch `not.toMatch(...)`} */ toMatch<Matcher extends string | RegExp>( matcher: Matcher, ): Expectations<string> { const expectations = this.toBeA('string') if (typeof matcher === 'string') { if (expectations.value.includes(matcher)) return expectations } else { if (expectations.value.match(matcher)) return expectations } this._fail(`to match ${stringifyValue(matcher)}`) } /* ------------------------------------------------------------------------ */ /** * Expect the value to be an {@link Iterable} object includind _all_ values * (and only those values) from the specified _array_ or {@link Set}, * in any order. */ toMatchContents(contents: any[] | Set<any>): Expectations<T> { toMatchContents(this, contents) return this } /* ------------------------------------------------------------------------ */ /** * Expects the value to be _strictly equal to_ the specified expected one. * * Negation: {@link NegativeExpectations.toStrictlyEqual `not.toStrictlyEqual(...)`} */ toStrictlyEqual<Type>(expected: Type): Expectations<Type> { if ((this.value as any) === expected) return this as Expectations<any> const diff = { diff: true, value: this.value, expected } this._fail(`to strictly equal ${stringifyValue(expected)}`, diff) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `function` throwing. * * Negation: {@link NegativeExpectations.toThrow `not.toThrow()`} */ toThrow(): Expectations<() => any> /** * Expects the value to be a `function` throwing, and further validates the * thrown value with a {@link Matcher}. * * Negation: {@link NegativeExpectations.toThrow `not.toThrow()`} */ toThrow(matcher: Matcher): Expectations<() => any> /** * Expects the value to be a `function` throwing, and further asserts the * thrown value with an {@link AssertionFunction}. * * Negation: {@link NegativeExpectations.toThrow `not.toThrow()`} */ toThrow(assert: AssertionFunction): Expectations<() => any> toThrow( assertionOrMatcher?: AssertionFunction | Matcher, ): Expectations<() => any> { const func = this.toBeA('function') let passed = false try { func.value() passed = true } catch (thrown) { if (isMatcher(assertionOrMatcher)) { assertionOrMatcher.expect(thrown) } else if (assertionOrMatcher) { assertionOrMatcher(new Expectations(thrown, this.remarks)) } } if (passed) this._fail('to throw') return this as Expectations<any> } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `function` throwing an {@link Error}. * * If specified, the {@link Error}'s own message will be further expected to * either match the specified {@link RegExp}, or equal to the specified * `string`. * * Negation: {@link NegativeExpectations.toThrow `not.toThrow()`} */ toThrowError( message?: string | RegExp, substring?: boolean, ): Expectations<() => any> /** * Expects the value to be a `function` throwing an instance of the * {@link Error} identified by the specified {@link Constructor}. * * If specified, the {@link Error}'s own message will be further expected to * either match the specified {@link RegExp}, or equal to the specified * `string`. * * Negation: {@link NegativeExpectations.toThrow `not.toThrow()`} */ toThrowError<Class extends Constructor<Error>>( constructor: Class, message?: string | RegExp, substring?: boolean, ): Expectations<() => any> toThrowError( constructorOrMessage?: string | RegExp | Constructor, maybeMessageOrSubstring?: string | RegExp | boolean, maybeSubstring?: boolean, ): Expectations { const [ constructor, message, substring = false ] = typeof constructorOrMessage === 'function' ? [ constructorOrMessage, maybeMessageOrSubstring as string | RegExp, maybeSubstring ] : [ Error, constructorOrMessage as string | RegExp, maybeMessageOrSubstring as boolean ] return this.toThrow((assert) => assert.toBeError(constructor, message, substring)) } } /* ========================================================================== * * NEGATIVE EXPECTATIONS * * ========================================================================== */ /** Negative expectations, as a subset of (meaningful) expectations. */ export class NegativeExpectations<T = unknown> { /** For convenience, the value associated with the {@link Expectations} */ private readonly _value: T /** * Create a {@link NegativeExpectations} instance associated with the * specified (positive) {@link Expectations}. */ constructor( /** The {@link Expectations} instance associated with this one */ private readonly _expectations: Expectations<T>, ) { this._value = _expectations.value } /** Throw an {@link ExpectationError} associated with _this_ */ private _fail(details: string, diff?: Diff): never { throw new ExpectationError(this._expectations, details, diff) } /* ------------------------------------------------------------------------ */ /** * Expects the value _**NOT**_ to be of the specified _extended_ * {@link TypeName type}. * * Negates: {@link Expectations.toBeA `toBeA(...)`} */ toBeA(type: TypeName): Expectations<T> { if (typeOf(this._value) !== type) return this._expectations this._fail(`not to be ${prefixType(type)}`) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `number` _**OUTSIDE**_ of the given +/- _delta_ * range of the specified expected value. * * Negates: {@link Expectations.toBeCloseTo `toBeCloseTo(...)`} */ toBeCloseTo(value: number, delta: number): Expectations<number> /** * Expects the value to be a `bigint` _**OUTSIDE**_ of the given +/- _delta_ * range of the specified expected value. * * Negates: {@link Expectations.toBeCloseTo `toBeCloseTo(...)`} */ toBeCloseTo(value: bigint, delta: bigint): Expectations<bigint> toBeCloseTo(value: number | bigint, delta: number | bigint): Expectations { const min = (value as number) - (delta as number) const max = (value as number) + (delta as number) return this.toBeWithinRange(min, max) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be either `null` or `undefined`. * * Negates: {@link Expectations.toBeDefined `toBeDefined()`} */ toBeDefined(): Expectations<null | undefined> { if ((this._value === null) || (this._value === undefined)) { return this._expectations as Expectations<any> } this._fail(`to be ${stringifyValue(null)} or ${stringifyValue(undefined)}`) } /* ------------------------------------------------------------------------ */ /** * Expects the value _**NOT**_ to be an instance of the specified * {@link Constructor}. * * Negates: {@link Expectations.toBeInstanceOf `toBeInstanceOf(...)`} */ toBeInstanceOf(constructor: Constructor): Expectations<T> { if (this._value instanceof constructor) { this._fail(`not to be an instance of ${stringifyConstructor(constructor)}`) } return this._expectations } /* ------------------------------------------------------------------------ */ /** * Expects the value _**NOT**_ to be `NaN`. * * Negates: {@link Expectations.toBeNaN `toBeNaN()`} */ toBeNaN(): Expectations<number> { const expectations = this._expectations.toBeA('number') if (isNaN(expectations.value)) this._fail(`not to be ${stringifyValue(NaN)}`) return expectations } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `number` _**OUTSIDE**_ of the specified range * where minimum and maximum values are inclusive. * * Negates: {@link Expectations.toBeWithinRange `toBeWithinRange(...)`} */ toBeWithinRange(min: number, max: number): Expectations<number> /** * Expects the value to be a `bigint` _**OUTSIDE**_ of the specified range * where minimum and maximum values are inclusive. * * Negates: {@link Expectations.toBeWithinRange `toBeWithinRange(...)`} */ toBeWithinRange(min: bigint, max: bigint): Expectations<bigint> toBeWithinRange(min: number | bigint, max: number | bigint): Expectations { if (max < min) { const num = max max = min min = num } this._expectations.toBeA(typeof min) if (((this._value as any) >= min) && ((this._value as any) <= max)) { this._fail(`not to be within ${stringifyValue(min)}...${stringifyValue(max)}`) } return this._expectations } /* ------------------------------------------------------------------------ */ /** * Expects the value _**NOT**_ to be _deep equal to_ the specified expected * one. * * Negates: {@link Expectations.toEqual `toEqual(...)`} */ toEqual(expected: any, strict: boolean = false): Expectations<T> { let result: Diff = { diff: false, value: this._value } if (this._value !== expected) { result = diff(this._value, expected, strict) if (result.diff) return this._expectations } if (isMatcher(expected)) { this._fail('not to satisfy expectation matcher', result) } else { this._fail(`not to equal ${stringifyValue(expected)}`, result) } } /* ------------------------------------------------------------------------ */ /** * Expects the value to have a `number` _property_ `length` _different_ from * the specified expected value. * * Negates: {@link Expectations.toHaveLength `toHaveLength(...)`} */ toHaveLength(length: number): Expectations<T & { length: number }> { this._expectations.toBeDefined() const realLength = (this._value as any)['length'] if (typeof realLength !== 'number') { this._fail('to have a numeric "length" property') } else if (realLength === length) { this._fail(`not to have length ${length}`) } return this._expectations as Expectations<any> } /* ------------------------------------------------------------------------ */ /** * Expects the value _**NOT**_ to have the specified _property_. * * Negates: {@link Expectations.toHaveProperty `toHaveProperty(...)`} */ toHaveProperty(property: string | number | symbol): Expectations<T> { this._expectations.toBeDefined() let hasProperty: boolean try { hasProperty = property in (this._value as any) } catch { hasProperty = (this._value as any)[property] !== undefined } if (hasProperty) this._fail(`not to have property "${String(property)}"`) return this._expectations } /* ------------------------------------------------------------------------ */ /** * Expects the value to have a `number` _property_ `size` _different_ from * the specified expected value. * * Negates: {@link Expectations.toHaveSize `toHaveSize(...)`} */ toHaveSize(size: number): Expectations<T & { size: number }> { this._expectations.toBeDefined() const realSize = (this._value as any)['size'] if (typeof realSize !== 'number') { this._fail('to have a numeric "size" property') } else if (realSize === size) { this._fail(`not to have size ${size}`) } return this._expectations as Expectations<any> } /* ------------------------------------------------------------------------ */ /** * Expect the value to include _none_ of the properties from the specified * _object_. * * If the object being expected is a {@link Map}, the properties specified * here will be treated as _mappings_ for said {@link Map}. * * Negates: {@link Expectations.toInclude `toInclude(...)`} */ toInclude<P extends Record<string, any>>(properties: P): Expectations<T> /** * Expect the value to include _none_ of the mappings from the specified * {@link Map}. * * Negates: {@link Expectations.toInclude `toInclude(...)`} */ toInclude(mappings: Map<any, any>): Expectations<T> /** * Expect the value to be an {@link Iterable} object includind _none_ of the * values from the specified {@link Set}. * * Negates: {@link Expectations.toInclude `toInclude(...)`} */ toInclude(entries: Set<any>): Expectations<T> /** * Expect the value to be an {@link Iterable} object includind _none_ of the * values from the specified _array_. * * Negates: {@link Expectations.toInclude `toInclude(...)`} */ toInclude(values: any[]): Expectations<T> toInclude( contents: Record<string, any> | Map<any, any> | Set<any> | any[], ): Expectations { toInclude(this._expectations, true, contents) return this._expectations } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `string` _**NOT MATCHING**_ the specified * sub-`string` or {@link RegExp}. * * Negates: {@link Expectations.toMatch `toMatch(...)`} */ toMatch(matcher: string | RegExp): Expectations<string> { const expectations = this._expectations.toBeA('string') if (! expectations.value.match(matcher)) return expectations this._fail(`not to match ${stringifyValue(matcher)}`) } /* ------------------------------------------------------------------------ */ /** * Expects the value to be a `function` not throwing anything. * * Negates: {@link Expectations.toThrow `toThrow(...)`} */ toThrow(): Expectations<() => any> { const expectations = this._expectations.toBeA('function') try { expectations.value() return expectations } catch { this._fail('not to throw') } } /* ------------------------------------------------------------------------ */ /** * Expects the value _**NOT**_ to be _strictly equal to_ the specified * expected one. * * Negates: {@link Expectations.toStrictlyEqual `toStrictlyEqual(...)`} */ toStrictlyEqual(expected: any): Expectations<T> { if (this._value !== expected) return this._expectations this._fail(`not to strictly equal ${stringifyValue(expected)}`) } }