diff --git a/package-lock.json b/package-lock.json index ecd2088..62ec608 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@types/node": "^12.20.55", "@types/sinonjs__fake-timers": "^8.1.5", "ava": "^4.3.3", - "typescript": "^4.8.4" + "typescript": "^5.4.2" }, "engines": { "node": ">=10" @@ -1975,16 +1975,16 @@ } }, "node_modules/typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/well-known-symbols": { @@ -3584,9 +3584,9 @@ "dev": true }, "typescript": { - "version": "4.8.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz", - "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "dev": true }, "well-known-symbols": { diff --git a/package.json b/package.json index 737ac2a..32905e0 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,6 @@ "@types/node": "^12.20.55", "@types/sinonjs__fake-timers": "^8.1.5", "ava": "^4.3.3", - "typescript": "^4.8.4" + "typescript": "^5.4.2" } } diff --git a/spec/ClearSubstitute.spec.ts b/spec/ClearSubstitute.spec.ts index 0dad128..f16339d 100644 --- a/spec/ClearSubstitute.spec.ts +++ b/spec/ClearSubstitute.spec.ts @@ -1,7 +1,7 @@ import test from 'ava' -import { Substitute, SubstituteOf } from '../src' -import { SubstituteNode } from '../src/SubstituteNode' +import { Substitute, SubstituteOf, clearReceivedCalls, received, returns } from '../src' +import { SubstituteNode, instance } from '../src/SubstituteNode' interface Calculator { add(a: number, b: number): number @@ -11,54 +11,18 @@ interface Calculator { } type InstanceReturningSubstitute = SubstituteOf & { - [SubstituteNode.instance]: SubstituteNode + [instance]: SubstituteNode } -test('clears everything on a substitute', t => { - const calculator = Substitute.for() as InstanceReturningSubstitute - calculator.add(1, 1) - calculator.received().add(1, 1) - calculator.clearSubstitute() - - t.is(calculator[SubstituteNode.instance].recorder.records.size, 0) - t.is(calculator[SubstituteNode.instance].recorder.indexedRecords.size, 0) - - t.throws(() => calculator.received().add(1, 1)) - - // explicitly using 'all' - calculator.add(1, 1) - calculator.received().add(1, 1) - calculator.clearSubstitute('all') - - t.is(calculator[SubstituteNode.instance].recorder.records.size, 0) - t.is(calculator[SubstituteNode.instance].recorder.indexedRecords.size, 0) - - t.throws(() => calculator.received().add(1, 1)) -}) - test('clears received calls on a substitute', t => { const calculator = Substitute.for() as InstanceReturningSubstitute calculator.add(1, 1) calculator.add(1, 1).returns(2) - calculator.clearSubstitute('receivedCalls') + calculator.clearReceivedCalls(); - t.is(calculator[SubstituteNode.instance].recorder.records.size, 2) - t.is(calculator[SubstituteNode.instance].recorder.indexedRecords.size, 2) + t.is(calculator[instance].recorder.records.size, 2) + t.is(calculator[instance].recorder.indexedRecords.size, 2) t.throws(() => calculator.received().add(1, 1)) t.is(2, calculator.add(1, 1)) -}) - -test('clears return values on a substitute', t => { - const calculator = Substitute.for() as InstanceReturningSubstitute - calculator.add(1, 1) - calculator.add(1, 1).returns(2) - calculator.clearSubstitute('substituteValues') - - t.is(calculator[SubstituteNode.instance].recorder.records.size, 2) - t.is(calculator[SubstituteNode.instance].recorder.indexedRecords.size, 2) - - t.notThrows(() => calculator.received().add(1, 1)) - // @ts-expect-error - t.true(calculator.add(1, 1)[SubstituteNode.instance] instanceof SubstituteNode) }) \ No newline at end of file diff --git a/spec/Recorder.spec.ts b/spec/Recorder.spec.ts index 348a0b9..a743e26 100644 --- a/spec/Recorder.spec.ts +++ b/spec/Recorder.spec.ts @@ -4,6 +4,7 @@ import { Recorder } from '../src/Recorder' import { RecordsSet } from '../src/RecordsSet' import { Substitute } from '../src/Substitute' import { SubstituteNodeBase } from '../src/SubstituteNodeBase' +import { returns } from '../src' const nodeFactory = (key: string) => { const node = Substitute.for() diff --git a/spec/regression/didNotReceive.spec.ts b/spec/regression/didNotReceive.spec.ts index 2b01ecc..55f4dba 100644 --- a/spec/regression/didNotReceive.spec.ts +++ b/spec/regression/didNotReceive.spec.ts @@ -1,5 +1,5 @@ import test from 'ava' -import { Substitute, Arg } from '../../src' +import { Substitute, Arg, didNotReceive, received, returns } from '../../src' import { SubstituteException } from '../../src/SubstituteException' interface Calculator { @@ -13,7 +13,7 @@ test('not calling a method correctly asserts the call count', t => { const calculator = Substitute.for() calculator.didNotReceive().add(1, 1) - t.throws(() => calculator.received(1).add(1, 1), { instanceOf: SubstituteException }) + t.throws(() => calculator.received().add(1, 1), { instanceOf: SubstituteException }) t.throws(() => calculator.received().add(Arg.all()), { instanceOf: SubstituteException }) }) diff --git a/spec/regression/index.test.ts b/spec/regression/index.test.ts index 6a5aff0..33c97de 100644 --- a/spec/regression/index.test.ts +++ b/spec/regression/index.test.ts @@ -1,6 +1,6 @@ import test from 'ava' -import { Substitute, Arg, SubstituteOf } from '../../src' +import { Substitute, Arg, SubstituteOf, received, mimicks, resolves, returns } from '../../src' class Dummy { @@ -20,7 +20,7 @@ export class Example { set v(x: string | null | undefined) { } - received(stuff: number | string) { + received(_stuff: string) { } @@ -28,7 +28,7 @@ export class Example { return Promise.resolve(new Dummy()) } - foo(): string | undefined | null { + foo(_arg?: string): string | undefined | null { return 'stuff' } @@ -47,22 +47,13 @@ function initialize() { const textModifierRegex = /\x1b\[\d+m/g -test('class with method called \'received\' can be used for call count verification when proxies are suspended', t => { - initialize() - - Substitute.disableFor(substitute).received(2) - - t.throws(() => substitute.received(2).received(2)) - t.notThrows(() => substitute.received(1).received(2)) -}) - -test('class with method called \'received\' can be used for call count verification', t => { - initialize() +test('class with method called \'received\' can be used for call count verification when using symbols', t => { + const substitute = Substitute.for() - Substitute.disableFor(substitute).received('foo') + substitute.received("foo") - t.notThrows(() => substitute.received(1).received('foo')) - t.throws(() => substitute.received(2).received('foo')) + t.notThrows(() => substitute[received](1).received("foo")) + t.throws(() => substitute[received](2).received("foo")) }) test('class string field set received', t => { @@ -79,16 +70,16 @@ test('class string field set received', t => { runLogic(substitute) - t.notThrows(() => substitute.received().v = 'hello') - t.notThrows(() => substitute.received(5).v = Arg.any()) - t.notThrows(() => substitute.received().v = Arg.any()) - t.notThrows(() => substitute.received(2).v = 'hello') - t.notThrows(() => substitute.received(2).v = Arg.is(x => typeof x === 'string' && x.indexOf('ll') > -1)) + t.notThrows(() => substitute[received]().v = 'hello') + t.notThrows(() => substitute[received](5).v = Arg.any()) + t.notThrows(() => substitute[received]().v = Arg.any()) + t.notThrows(() => substitute[received](2).v = 'hello') + t.notThrows(() => substitute[received](2).v = Arg.is(x => typeof x === 'string' && x.indexOf('ll') > -1)) - t.throws(() => substitute.received(2).v = Arg.any()) - t.throws(() => substitute.received(1).v = Arg.any()) - t.throws(() => substitute.received(1).v = Arg.is(x => typeof x === 'string' && x.indexOf('ll') > -1)) - t.throws(() => substitute.received(3).v = 'hello') + t.throws(() => substitute[received](2).v = Arg.any()) + t.throws(() => substitute[received](1).v = Arg.any()) + t.throws(() => substitute[received](1).v = Arg.is(x => typeof x === 'string' && x.indexOf('ll') > -1)) + t.throws(() => substitute[received](3).v = 'hello') }) test('resolving promises works', async t => { @@ -117,9 +108,9 @@ test('class method received', t => { void substitute.c('hi', 'there') void substitute.c('hi', 'there') - t.notThrows(() => substitute.received(4).c('hi', 'there')) - t.notThrows(() => substitute.received(1).c('hi', 'the1re')) - t.notThrows(() => substitute.received().c('hi', 'there')) + t.notThrows(() => substitute[received](4).c('hi', 'there')) + t.notThrows(() => substitute[received](1).c('hi', 'the1re')) + t.notThrows(() => substitute[received]().c('hi', 'there')) const expectedMessage = 'Expected 7 calls to the method c with arguments [\'hi\', \'there\'], but received 4 of such calls.\n' + 'All calls received to method c:\n' + @@ -128,7 +119,7 @@ test('class method received', t => { '-> call with arguments [\'hi\', \'there\']\n' + '-> call with arguments [\'hi\', \'there\']\n' + '-> call with arguments [\'hi\', \'there\']' - const { message } = t.throws(() => { substitute.received(7).c('hi', 'there') }) + const { message } = t.throws(() => { substitute[received](7).c('hi', 'there') }) t.is(message.replace(textModifierRegex, ''), expectedMessage) }) @@ -138,14 +129,14 @@ test('received call matches after partial mocks using property instance mimicks' substitute.d.mimicks(() => instance.d) substitute.c('lala', 'bar') - substitute.received(1).c('lala', 'bar') - substitute.received(1).c('lala', 'bar') + substitute[received](1).c('lala', 'bar') + substitute[received](1).c('lala', 'bar') - t.notThrows(() => substitute.received(1).c('lala', 'bar')) + t.notThrows(() => substitute[received](1).c('lala', 'bar')) const expectedMessage = 'Expected 2 calls to the method c with arguments [\'lala\', \'bar\'], but received 1 of such calls.\n' + 'All calls received to method c:\n' + '-> call with arguments [\'lala\', \'bar\']' - const { message } = t.throws(() => substitute.received(2).c('lala', 'bar')) + const { message } = t.throws(() => substitute[received](2).c('lala', 'bar')) t.is(message.replace(textModifierRegex, ''), expectedMessage) t.deepEqual(substitute.d, 1337) }) diff --git a/spec/regression/issues/11.test.ts b/spec/regression/issues/11.test.ts index 4bd6d77..64e7b08 100644 --- a/spec/regression/issues/11.test.ts +++ b/spec/regression/issues/11.test.ts @@ -1,5 +1,5 @@ import test from 'ava' -import { Substitute, Arg } from '../../../src' +import { Substitute, Arg, received, returns } from '../../../src' type Addands = { op1: number diff --git a/spec/regression/issues/178.test.ts b/spec/regression/issues/178.test.ts index dacf607..018b208 100644 --- a/spec/regression/issues/178.test.ts +++ b/spec/regression/issues/178.test.ts @@ -2,7 +2,7 @@ import test, { ExecutionContext, ThrowsExpectation } from 'ava' import * as fakeTimers from '@sinonjs/fake-timers' import { types } from 'util' -import { Substitute } from '../../../src' +import { Substitute, didNotReceive, received, returns } from '../../../src' import { SubstituteException } from '../../../src/SubstituteException' interface Library { diff --git a/spec/regression/issues/23.test.ts b/spec/regression/issues/23.test.ts index a26666f..1126b7e 100644 --- a/spec/regression/issues/23.test.ts +++ b/spec/regression/issues/23.test.ts @@ -1,6 +1,6 @@ import test from 'ava' -import { Substitute, Arg } from '../../../src' +import { Substitute, Arg, received, mimicks } from '../../../src' interface CalculatorInterface { add(a: number, b: number): number diff --git a/spec/regression/issues/36.test.ts b/spec/regression/issues/36.test.ts index 508b3c3..9d9be54 100644 --- a/spec/regression/issues/36.test.ts +++ b/spec/regression/issues/36.test.ts @@ -1,6 +1,6 @@ import test from 'ava' -import { Substitute, Arg } from '../../../src' +import { Substitute, Arg, received, returns } from '../../../src' class Key { private constructor(private _value: string) { } diff --git a/spec/regression/issues/45.test.ts b/spec/regression/issues/45.test.ts index e77f985..9c87540 100644 --- a/spec/regression/issues/45.test.ts +++ b/spec/regression/issues/45.test.ts @@ -1,6 +1,6 @@ import test from 'ava' -import { Substitute, Arg } from '../../../src' +import { Substitute, Arg, received } from '../../../src' class DependencyClass { public methodOne() { } diff --git a/spec/regression/issues/59.test.ts b/spec/regression/issues/59.test.ts index 36dd296..fbe6603 100644 --- a/spec/regression/issues/59.test.ts +++ b/spec/regression/issues/59.test.ts @@ -1,6 +1,6 @@ import test from 'ava' -import { Substitute } from '../../../src' +import { Substitute, received, returns } from '../../../src' interface IEcho { echo(a: string): string diff --git a/spec/regression/mimicks.spec.ts b/spec/regression/mimicks.spec.ts index a6b5b8a..d741131 100644 --- a/spec/regression/mimicks.spec.ts +++ b/spec/regression/mimicks.spec.ts @@ -1,5 +1,5 @@ import test from 'ava' -import { Substitute, Arg } from '../../src' +import { Substitute, Arg, mimicks } from '../../src' interface Calculator { add(a: number, b: number): number diff --git a/spec/regression/received.spec.ts b/spec/regression/received.spec.ts index c9a4519..8b6b112 100644 --- a/spec/regression/received.spec.ts +++ b/spec/regression/received.spec.ts @@ -1,5 +1,5 @@ import test from 'ava' -import { Substitute, Arg } from '../../src' +import { Substitute, Arg, received, returns } from '../../src' import { SubstituteException } from '../../src/SubstituteException' interface Calculator { diff --git a/spec/regression/rejects.spec.ts b/spec/regression/rejects.spec.ts index a92cdbf..016fa7b 100644 --- a/spec/regression/rejects.spec.ts +++ b/spec/regression/rejects.spec.ts @@ -1,6 +1,6 @@ import test from 'ava' -import { Substitute, Arg } from '../../src' +import { Substitute, Arg, rejects } from '../../src' interface Calculator { getMemory(): Promise diff --git a/spec/regression/resolves.spec.ts b/spec/regression/resolves.spec.ts index 6916d1b..10fe86b 100644 --- a/spec/regression/resolves.spec.ts +++ b/spec/regression/resolves.spec.ts @@ -1,6 +1,6 @@ import test from 'ava' -import { Substitute, Arg } from '../../src' +import { Substitute, Arg, resolves } from '../../src' interface Calculator { getMemory(): Promise diff --git a/spec/regression/returns.spec.ts b/spec/regression/returns.spec.ts index 54f61a6..068412b 100644 --- a/spec/regression/returns.spec.ts +++ b/spec/regression/returns.spec.ts @@ -1,7 +1,7 @@ import test from 'ava' import { types } from 'util' -import { Substitute, Arg } from '../../src' +import { Substitute, Arg, returns } from '../../src' interface Calculator { add(a: number, b: number): number @@ -19,9 +19,6 @@ interface Calculator { test('returns a primitive value for method with no arguments', t => { const calculator = Substitute.for() calculator.clear().returns() - // calculator.add(1, 2).toExponential() - // calculator.isEnabled2.viewResult().returns(3) - // calculator.other().viewResult().returns(2) t.is(void 0 as void, calculator.clear()) }) diff --git a/spec/regression/throws.spec.ts b/spec/regression/throws.spec.ts index 80d3c21..eac673d 100644 --- a/spec/regression/throws.spec.ts +++ b/spec/regression/throws.spec.ts @@ -1,6 +1,6 @@ import test from 'ava' -import { Substitute, Arg } from '../../src' +import { Substitute, Arg, returns, throws } from '../../src' interface Calculator { add(a: number, b: number): number diff --git a/src/Substitute.ts b/src/Substitute.ts index 3f87849..3e211df 100644 --- a/src/Substitute.ts +++ b/src/Substitute.ts @@ -1,42 +1,11 @@ -import { DisabledSubstituteObject, ObjectSubstitute } from './Transformations' +import { ObjectSubstitute } from './Transformations' import { SubstituteNode } from './SubstituteNode' export type SubstituteOf = ObjectSubstitute & T -type InstantiableSubstitute> = T & { [SubstituteNode.instance]: SubstituteNode } export class Substitute { public static for(): SubstituteOf { const substitute = SubstituteNode.createRoot() return substitute.proxy as unknown as SubstituteOf } - - public static disableFor>(substituteProxy: T): DisabledSubstituteObject { - const substitute = this.extractSubstituteNodeFromSubstitute(substituteProxy as InstantiableSubstitute) - - const disableProxy = < - TParameters extends unknown[], - TReturnType extends unknown - >(reflection: (...args: TParameters) => TReturnType): typeof reflection => (...args) => { - substitute.rootContext.substituteMethodsEnabled = false - const reflectionResult = reflection(...args) - substitute.rootContext.substituteMethodsEnabled = true - return reflectionResult - } - - return new Proxy(substitute.proxy, { - get: function (target, property) { - return disableProxy(Reflect.get)(target, property) - }, - set: function (target, property, value) { - return disableProxy(Reflect.set)(target, property, value) - }, - apply: function (target, _, args) { - return disableProxy(Reflect.apply)(target, _, args) - } - }) as DisabledSubstituteObject - } - - private static extractSubstituteNodeFromSubstitute(substitute: InstantiableSubstitute>): SubstituteNode { - return substitute[SubstituteNode.instance] - } } \ No newline at end of file diff --git a/src/SubstituteNode.ts b/src/SubstituteNode.ts index 03119ce..9a98023 100644 --- a/src/SubstituteNode.ts +++ b/src/SubstituteNode.ts @@ -2,20 +2,17 @@ import { inspect, InspectOptions, types } from 'util' import { SubstituteNodeBase } from './SubstituteNodeBase' import { RecordedArguments } from './RecordedArguments' -import { ClearType as ClearTypeMap, PropertyType as PropertyTypeMap, isAssertionMethod, isSubstituteMethod, isSubstitutionMethod, textModifier, isConfigurationMethod } from './Utilities' +import { PropertyType as PropertyTypeMap, isAssertionMethod, isSubstituteMethod, isSubstitutionMethod, textModifier, isConfigurationMethod, isThrowsFunction, isMimicksFunction, isResolvesFunction, isRejectsFunction, isReturnsFunction } from './Utilities' import { SubstituteException } from './SubstituteException' -import type { FilterFunction, SubstituteContext, SubstitutionMethod, ClearType, PropertyType } from './Types' -import type { ObjectSubstitute } from './Transformations' +import type { SubstituteContext, SubstitutionMethod, PropertyType } from './Types' +import type { ObjectSubstitute, OmitProxyMethods } from './Transformations' +import { didNotReceive, mimick, mimicks, received, rejects, resolves, returns, throws, clearReceivedCalls } from './Transformations' -const instance = Symbol('Substitute:Instance') -const clearTypeToFilterMap: Record> = { - all: () => true, - receivedCalls: node => !node.hasContext, - substituteValues: node => node.isSubstitution -} +export const instance = Symbol('Substitute:Instance') type SpecialProperty = typeof instance | typeof inspect.custom | 'then' -type RootContext = { substituteMethodsEnabled: boolean } + +type RootContext = { } export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitute { private _proxy: SubstituteNode @@ -30,41 +27,89 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu private constructor(key: PropertyKey, parent?: SubstituteNode) { super(key, parent) - if (this.isRoot()) this._rootContext = { substituteMethodsEnabled: true } - else this._rootContext = this.root.rootContext + if (this.isRoot()) { + this._rootContext = { } + } + else { + this._rootContext = this.root.rootContext + } + this._proxy = new Proxy( this, { get: function (target, property) { - if (target.isSpecialProperty(property)) return target.evaluateSpecialProperty(property) - if (target._retrySubstitutionExecutionAttempt) return target.reattemptSubstitutionExecution()[property] + if (target.isSpecialProperty(property)) { + // console.log('specialProperty', property) + return target.evaluateSpecialProperty(property) + } + + if (target._retrySubstitutionExecutionAttempt) { + // console.log('reattemptSubstitutionExecution', property) + return target.reattemptSubstitutionExecution()[property] + } + const newNode = SubstituteNode.createChild(property, target) - if (target.isAssertion) newNode.executeAssertion() - if (target.isRoot() && target.rootContext.substituteMethodsEnabled && (isAssertionMethod(property) || isConfigurationMethod(property))) { + if (target.isAssertion) { + // console.log('executeAssertion', property); + newNode.executeAssertion() + } + + if (target.isRoot() && (isAssertionMethod(property) || isConfigurationMethod(property))) { + // console.log("isRoot", property); newNode.assignContext(property) return newNode[property].bind(newNode) } + + // console.log("substitutionExecution", property); return newNode.attemptSubstitutionExecution() }, set: function (target, property, value) { const newNode = SubstituteNode.createChild(property, target) newNode.handleSetter(value) - if (target.isAssertion) newNode.executeAssertion() + if (target.isAssertion) + newNode.executeAssertion() + return true }, apply: function (target, _thisArg, rawArguments) { target.handleMethod(rawArguments) if (target.hasDepthOfAtLeast(2)) { - if (isSubstitutionMethod(target.property)) return target.parent.assignContext(target.property) - if (target.parent.isAssertion) return target.executeAssertion() + if (isSubstitutionMethod(target.property)) { + // console.log('isSubstitutionMethod', target.property) + return target.parent.assignContext(target.property) + } + + if (target.parent.isAssertion) { + // console.log('target.parent.isAssertion') + return target.executeAssertion() + } } - return target.isAssertion ? target.proxy : target.attemptSubstitutionExecution() + + // console.log('target.isAssertion', target.isAssertion) + + return target.isAssertion ? + target.proxy : + target.attemptSubstitutionExecution() } } ) } - public static instance: typeof instance = instance + public received(amount?: number | undefined) { + return this[received](amount); + } + + public didNotReceive() { + return this[didNotReceive](); + } + + public mimick(instance: OmitProxyMethods) { + return this[mimick](instance); + } + + public clearReceivedCalls() { + return this[clearReceivedCalls](); + } public static createRoot(): SubstituteNode { return new this('*Substitute') @@ -114,23 +159,24 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu return this._recordedArguments } - public received(amount?: number): SubstituteNode { + public [received](amount?: number): SubstituteNode { this.handleMethod([amount]) return this.proxy } - public didNotReceive(): SubstituteNode { + public [didNotReceive](): SubstituteNode { this.handleMethod([0]) return this.proxy } - public mimick() { + public [mimick](_instance: OmitProxyMethods) { throw new Error('Mimick is not implemented yet') } - public clearSubstitute(clearType: ClearType = ClearTypeMap.All): void { - this.handleMethod([clearType]) - const filter = clearTypeToFilterMap[clearType] + public [clearReceivedCalls](): void { + this.handleMethod([]) + + const filter = (node: SubstituteNode) => !node.hasContext this.recorder.clearRecords(filter) } @@ -139,7 +185,9 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu } private assignContext(context: SubstituteContext): void { - if (!isSubstituteMethod(context)) throw new Error(`Cannot assign context for property ${context.toString()}`) + if (!isSubstituteMethod(context)) + throw new Error(`Cannot assign context for property ${context.toString()}`) + this._context = context } @@ -151,43 +199,65 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu private attemptSubstitutionExecution(): SubstituteNode | any { const mostSuitableSubstitution = this.getMostSuitableSubstitution() + // console.log('mostSuitableSubstitution', mostSuitableSubstitution) return mostSuitableSubstitution instanceof SubstituteNode ? mostSuitableSubstitution.executeSubstitution(this.recordedArguments) : this.proxy } private executeSubstitution(contextArguments: RecordedArguments) { - if (!this.hasChild()) throw new TypeError('Substitue node has no child') - if (!this.child.recordedArguments.hasArguments()) throw new TypeError('Child args') + if (!this.hasChild()) + throw new TypeError('Substitue node has no child') + + if (!this.child.recordedArguments.hasArguments()) + throw new TypeError('Child args') const substitutionMethod = this.context as SubstitutionMethod const substitutionValue = this.child.recordedArguments.value.length > 1 ? this.child.recordedArguments.value?.shift() : this.child.recordedArguments.value[0] - switch (substitutionMethod) { - case 'throws': - throw substitutionValue - case 'mimicks': - if (this.propertyType === PropertyTypeMap.Property) return substitutionValue() - if (!contextArguments.hasArguments()) throw new TypeError('Context arguments cannot be undefined') - return substitutionValue(...contextArguments.value) - case 'resolves': - return Promise.resolve(substitutionValue) - case 'rejects': - return Promise.reject(substitutionValue) - case 'returns': - return substitutionValue - default: - throw SubstituteException.generic(`Substitution method '${substitutionMethod}' not implemented`) + + if (isThrowsFunction(substitutionMethod)) { + throw substitutionValue; + } + + if (isMimicksFunction(substitutionMethod)) { + if (this.propertyType === PropertyTypeMap.Property) + return substitutionValue() + + if (!contextArguments.hasArguments()) + throw new TypeError('Context arguments cannot be undefined') + + return substitutionValue(...contextArguments.value) + } + + if (isResolvesFunction(substitutionMethod)) { + return Promise.resolve(substitutionValue) + } + + if (isRejectsFunction(substitutionMethod)) { + return Promise.reject(substitutionValue) } + + if (isReturnsFunction(substitutionMethod)) { + return substitutionValue + } + + throw SubstituteException.generic(`Substitution method '${String(substitutionMethod)}' not implemented. Make sure you invoke ".returns(...)" or any other configuration call on the given method.`) } private executeAssertion(): void | never { - if (!this.hasDepthOfAtLeast(2)) throw new Error('Not possible') - if (!this.parent.recordedArguments.hasArguments()) throw new TypeError('Parent args') + if (!this.hasDepthOfAtLeast(2)) + throw new Error('Depth is less than 2') + + if (!this.parent.recordedArguments.hasArguments()) + throw new Error('No parent args present') + const expectedCount = this.parent.recordedArguments.value[0] ?? undefined const finiteExpectation = expectedCount !== undefined - if (finiteExpectation && (!Number.isInteger(expectedCount) || expectedCount < 0)) throw new Error('Expected count has to be a positive integer') + if (finiteExpectation && (!Number.isInteger(expectedCount) || expectedCount < 0)) { + return + } const siblings = [...this.getAllSiblings().filter(n => !n.hasContext && n.accessorType === this.accessorType)] const hasBeenCalled = siblings.length > 0 @@ -197,25 +267,33 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu if ( !hasBeenCalled && (!finiteExpectation || expectedCount > 0) - ) throw SubstituteException.forCallCountMissMatch( // Here we don't know here if it's a property or method, so we should throw something more generic - { expected: expectedCount, received: 0 }, - { type: this.propertyType, value: this.property }, - { expected: this.recordedArguments, received: allRecordedArguments } - ) + ) { + throw SubstituteException.forCallCountMissMatch( // Here we don't know here if it's a property or method, so we should throw something more generic + { expected: expectedCount, received: 0 }, + { type: this.propertyType, value: this.property }, + { expected: this.recordedArguments, received: allRecordedArguments } + ) + } if (!hasBeenCalled || hasSiblingOfSamePropertyType) { this._scheduledAssertionException = undefined const actualCount = allRecordedArguments.filter(r => r.match(this.recordedArguments)).length const matchedExpectation = (!finiteExpectation && actualCount > 0) || expectedCount === actualCount - if (matchedExpectation) return + if (matchedExpectation) + return + const exception = SubstituteException.forCallCountMissMatch( { expected: expectedCount, received: actualCount }, { type: this.propertyType, value: this.property }, { expected: this.recordedArguments, received: allRecordedArguments } ) const potentialMethodAssertion = this.propertyType === PropertyTypeMap.Property && siblings.some(sibling => sibling.propertyType === PropertyTypeMap.Method) - if (potentialMethodAssertion) this.schedulePropertyAssertionException(exception) - else throw exception + if (potentialMethodAssertion) { + this.schedulePropertyAssertionException(exception) + } + else { + throw exception + } } } @@ -237,6 +315,7 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu private handleMethod(rawArguments: any[]): void { this._propertyType = PropertyTypeMap.Method this._recordedArguments = RecordedArguments.from(rawArguments) + // console.log('handleMethod', rawArguments); } private getMostSuitableSubstitution(): SubstituteNode | void { @@ -252,24 +331,30 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu const potentialSuitableSubstitutions = [...potentialSuitableSubstitutionsSet] const hasSuitableSubstitutions = strictSuitableSubstitutions.length > 0 const onlySubstitutionsWithThisNodePropertyType = potentialSuitableSubstitutions.length === 0 - if (onlySubstitutionsWithThisNodePropertyType && hasSuitableSubstitutions) return RecordedArguments.sort(strictSuitableSubstitutions)[0] - if (!onlySubstitutionsWithThisNodePropertyType) this._retrySubstitutionExecutionAttempt = true + if (onlySubstitutionsWithThisNodePropertyType && hasSuitableSubstitutions) + return RecordedArguments.sort(strictSuitableSubstitutions)[0] + + if (!onlySubstitutionsWithThisNodePropertyType) + this._retrySubstitutionExecutionAttempt = true } private isSpecialProperty(property: PropertyKey): property is SpecialProperty { - return property === SubstituteNode.instance || property === inspect.custom || property === 'then' + return property === instance || property === inspect.custom || property === 'then' } private evaluateSpecialProperty(property: SpecialProperty) { switch (property) { - case SubstituteNode.instance: + case instance: return this + case inspect.custom: return this.printableForm.bind(this) + case 'then': return + default: - throw SubstituteException.generic(`Evaluation of special property ${property} is not implemented`) + throw SubstituteException.generic(`Evaluation of special property ${property} is not implemented.`) } } @@ -280,7 +365,7 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu private printRootNode(options: InspectOptions): string { const records = inspect(this.recorder, options) const instanceName = '*Substitute' // Substitute - return instanceName + ' {' + records + '\n}' + return instanceName + ' {' + records + '}\n' } private printNode(options: InspectOptions): string { diff --git a/src/Transformations.ts b/src/Transformations.ts index e927822..dbad376 100644 --- a/src/Transformations.ts +++ b/src/Transformations.ts @@ -1,5 +1,26 @@ import type { AllArguments } from './Arguments'; -import type { ClearType, FirstLevelMethod } from './Types'; +import type { FirstLevelMethod } from './Types'; +import { Prettify } from './Utilities'; + +export const received = Symbol('received'); +export const didNotReceive = Symbol('didNotReceive'); +export const mimick = Symbol('mimick'); +export const clearReceivedCalls = Symbol('clearReceivedCalls'); +export const mimicks = Symbol('mimicks'); +export const throws = Symbol('throws'); +export const returns = Symbol('returns'); +export const resolves = Symbol('resolves'); +export const rejects = Symbol('rejects'); + +export type ReceivedPropertyKey = typeof received | 'received'; +export type DidNotReceivePropertyKey = typeof didNotReceive | 'didNotReceive'; +export type MimickPropertyKey = typeof mimick | 'mimick'; +export type ClearReceivedCallsPropertyKey = typeof clearReceivedCalls | 'clearReceivedCalls'; +export type MimicksPropertyKey = typeof mimicks | 'mimicks'; +export type ThrowsPropertyKey = typeof throws | 'throws'; +export type ReturnsPropertyKey = typeof returns | 'returns'; +export type ResolvesPropertyKey = typeof resolves | 'resolves'; +export type RejectsPropertyKey = typeof rejects | 'rejects'; type FunctionSubstituteWithOverloads = TFunc extends { @@ -41,60 +62,109 @@ type FunctionHandler = FunctionSubstitute export type FunctionSubstitute = - ((...args: TArguments) => (TReturnType & MockObjectMixin)) & - ((allArguments: AllArguments) => (TReturnType & MockObjectMixin)) + ((...args: TArguments) => (TReturnType & MockObjectMixin)) & + ((allArguments: AllArguments) => (TReturnType & MockObjectMixin)) -export type NoArgumentFunctionSubstitute = () => TReturnType & NoArgumentMockObjectMixin; -export type PropertySubstitute = TReturnType & NoArgumentMockObjectMixin; +export type NoArgumentFunctionSubstitute = + () => + TReturnType & + NoArgumentMockObjectMixin; -type OneArgumentRequiredFunction = (requiredInput: TArgs, ...restInputs: TArgs[]) => TReturnType; - -type MockObjectPromise = TReturnType extends Promise ? { - resolves: OneArgumentRequiredFunction; - rejects: OneArgumentRequiredFunction; -} : {} +export type PropertySubstitute = + TReturnType & + NoArgumentMockObjectMixin; -type BaseMockObjectMixin = MockObjectPromise & { - returns: OneArgumentRequiredFunction; - throws: OneArgumentRequiredFunction; -} - -type NoArgumentMockObjectMixin = BaseMockObjectMixin & { - mimicks: OneArgumentRequiredFunction<() => TReturnType, void>; -} +type OneArgumentRequiredFunction = (requiredInput: TArgs, ...restInputs: TArgs[]) => TReturnType; -type MockObjectMixin = BaseMockObjectMixin & { - mimicks: OneArgumentRequiredFunction<(...args: TArguments) => TReturnType, void>; -} +type MockObjectPromise = TReturnType extends Promise ? ( + (TReturnType extends { resolves: any } ? + { [resolves]: OneArgumentRequiredFunction } : + { resolves: OneArgumentRequiredFunction }) & + (TReturnType extends { rejects: any } ? + { [rejects]: OneArgumentRequiredFunction } : + { rejects: OneArgumentRequiredFunction }) +) : {} + +type BaseMockObjectMixin = + MockObjectPromise & + ( + (TObject extends { returns: any } ? + { [returns]: OneArgumentRequiredFunction } : + { returns: OneArgumentRequiredFunction }) & + (TObject extends { throws: any } ? + { [throws]: OneArgumentRequiredFunction } : + { throws: OneArgumentRequiredFunction }) + ) + +type NoArgumentMockObjectMixin = + BaseMockObjectMixin & + (TObject extends { mimicks: any } ? + { [mimicks]: OneArgumentRequiredFunction<() => TReturnType, void> } : + { mimicks: OneArgumentRequiredFunction<() => TReturnType, void> }) + +type MockObjectMixin = + BaseMockObjectMixin & + (TReturnType extends { mimicks: any } ? + { [mimicks]: OneArgumentRequiredFunction<(...args: TArguments) => TReturnType, void> } : + { mimicks: OneArgumentRequiredFunction<(...args: TArguments) => TReturnType, void> }) type TerminatingFunction = ((...args: TArguments) => void) & ((arg: AllArguments) => void) type TryToExpandNonArgumentedTerminatingFunction = - TObject[TProperty] extends (...args: []) => unknown ? () => void : {} + TObject[TProperty] extends (...args: []) => unknown ? + () => void : + {} + type TryToExpandArgumentedTerminatingFunction = - TObject[TProperty] extends (...args: any) => any ? FunctionSubstituteWithOverloads : {} + TObject[TProperty] extends (...args: any) => any ? + FunctionSubstituteWithOverloads : + {} type TerminatingObject = { - [P in keyof T]: TryToExpandNonArgumentedTerminatingFunction & TryToExpandArgumentedTerminatingFunction & T[P]; + [P in keyof T]: + TryToExpandNonArgumentedTerminatingFunction & + TryToExpandArgumentedTerminatingFunction & + T[P]; } type TryToExpandNonArgumentedFunctionSubstitute = - TObject[TProperty] extends (...args: []) => infer R ? NoArgumentFunctionSubstitute : {} + TObject[TProperty] extends (...args: []) => infer R ? + NoArgumentFunctionSubstitute : + {} type TryToExpandArgumentedFunctionSubstitute = - TObject[TProperty] extends (...args: infer F) => infer R ? F extends [] ? {} : FunctionSubstituteWithOverloads : {} + TObject[TProperty] extends (...args: infer F) => any ? + F extends [] ? + {} : + FunctionSubstituteWithOverloads : + {} -type TryToExpandPropertySubstitute = PropertySubstitute +type TryToExpandPropertySubstitute = + PropertySubstitute type ObjectSubstituteTransformation> = { - [P in keyof T]: TryToExpandNonArgumentedFunctionSubstitute & TryToExpandArgumentedFunctionSubstitute & TryToExpandPropertySubstitute; + [P in keyof T]: + TryToExpandNonArgumentedFunctionSubstitute & + TryToExpandArgumentedFunctionSubstitute & + TryToExpandPropertySubstitute; } export type OmitProxyMethods = Omit; -export type ObjectSubstitute = ObjectSubstituteTransformation & { - received(amount?: number): TerminatingObject; - didNotReceive(): TerminatingObject; - mimick(instance: OmitProxyMethods): void; - clearSubstitute(clearType?: ClearType): void; -} -export type DisabledSubstituteObject = T extends ObjectSubstitute ? K : never; + +export type ObjectSubstitute = + ObjectSubstituteTransformation & + ObjectSubstituteMethods + +type ObjectSubstituteMethods = + (T extends { received: any } ? + { [received](amount?: number): TerminatingObject } : + { received(amount?: number): TerminatingObject }) & + (T extends { didNotReceive: any } ? + { [didNotReceive](): TerminatingObject } : + { didNotReceive(): TerminatingObject }) & + (T extends { mimick: any } ? + { [mimick](instance: OmitProxyMethods): void } : + { mimick(instance: OmitProxyMethods): void }) & + (T extends { clearReceivedCalls: any } ? + { [clearReceivedCalls](): void } : + { clearReceivedCalls(): void }) \ No newline at end of file diff --git a/src/Types.ts b/src/Types.ts index d9b6228..f655ccc 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1,12 +1,26 @@ +import type { ClearReceivedCallsPropertyKey, DidNotReceivePropertyKey, MimickPropertyKey, MimicksPropertyKey, ReceivedPropertyKey, RejectsPropertyKey, ResolvesPropertyKey, ReturnsPropertyKey, ThrowsPropertyKey, clearReceivedCalls, didNotReceive, mimick, mimicks, received, rejects, resolves, returns, throws } from "./Transformations" + export type PropertyType = 'method' | 'property' -export type AssertionMethod = 'received' | 'didNotReceive' -export type ConfigurationMethod = 'clearSubstitute' | 'mimick' -export type SubstitutionMethod = 'mimicks' | 'throws' | 'returns' | 'resolves' | 'rejects' + +export type AssertionMethod = + ReceivedPropertyKey | + DidNotReceivePropertyKey + +export type ConfigurationMethod = + ClearReceivedCallsPropertyKey | + MimickPropertyKey + +export type SubstitutionMethod = + MimicksPropertyKey | + ThrowsPropertyKey | + ReturnsPropertyKey | + ResolvesPropertyKey | + RejectsPropertyKey export type FirstLevelMethod = AssertionMethod | ConfigurationMethod + export type SubstituteMethod = FirstLevelMethod | SubstitutionMethod -export type SubstituteContext = SubstituteMethod | 'none' -export type ClearType = 'all' | 'receivedCalls' | 'substituteValues' +export type SubstituteContext = SubstituteMethod | 'none' export type FilterFunction = (item: T) => boolean \ No newline at end of file diff --git a/src/Utilities.ts b/src/Utilities.ts index 87414f4..406ae6d 100644 --- a/src/Utilities.ts +++ b/src/Utilities.ts @@ -1,6 +1,7 @@ import { inspect } from 'util' import { RecordedArguments } from './RecordedArguments' import type { AssertionMethod, ConfigurationMethod, SubstituteMethod, SubstitutionMethod } from './Types' +import { ClearReceivedCallsPropertyKey, DidNotReceivePropertyKey, MimickPropertyKey, MimicksPropertyKey, ReceivedPropertyKey, RejectsPropertyKey, ResolvesPropertyKey, ReturnsPropertyKey, ThrowsPropertyKey, clearReceivedCalls, didNotReceive, mimick, mimicks, received, rejects, resolves, returns, throws } from './Transformations' export const PropertyType = { Method: 'method', @@ -8,21 +9,24 @@ export const PropertyType = { } as const export const isAssertionMethod = (property: PropertyKey): property is AssertionMethod => - property === 'received' || property === 'didNotReceive' + isReceivedFunction(property) || + isDidNotReceiveFunction(property) -export const isConfigurationMethod = (property: PropertyKey): property is ConfigurationMethod => property === 'clearSubstitute' || property === 'mimick' +export const isConfigurationMethod = (property: PropertyKey): property is ConfigurationMethod => + isClearReceivedCallsFunction(property) || + isMimickFunction(property) export const isSubstitutionMethod = (property: PropertyKey): property is SubstitutionMethod => - property === 'mimicks' || property === 'returns' || property === 'throws' || property === 'resolves' || property === 'rejects' + isMimicksFunction(property) || + isReturnsFunction(property) || + isThrowsFunction(property) || + isResolvesFunction(property) || + isRejectsFunction(property) export const isSubstituteMethod = (property: PropertyKey): property is SubstituteMethod => - isSubstitutionMethod(property) || isConfigurationMethod(property) || isAssertionMethod(property) - -export const ClearType = { - All: 'all', - ReceivedCalls: 'receivedCalls', - SubstituteValues: 'substituteValues' -} as const + isSubstitutionMethod(property) || + isConfigurationMethod(property) || + isAssertionMethod(property) export const stringifyArguments = (args: RecordedArguments) => textModifier.faint( args.hasArguments() ? @@ -31,7 +35,8 @@ export const stringifyArguments = (args: RecordedArguments) => textModifier.fain ) export const stringifyCalls = (calls: RecordedArguments[]) => { - if (calls.length === 0) return ' (no calls)' + if (calls.length === 0) + return ' (no calls)' const key = '\n-> call with ' const callsDetails = calls.map(stringifyArguments) @@ -45,4 +50,45 @@ export const textModifier = { italic: (str: string) => baseTextModifier(str, 3, 23) } -export const plurify = (str: string, count?: number) => `${str}${count === 1 ? '' : 's'}` \ No newline at end of file +export const plurify = (str: string, count?: number) => `${str}${count === 1 ? '' : 's'}` + +export function isDidNotReceiveFunction(property: PropertyKey): property is DidNotReceivePropertyKey { + return property === didNotReceive || property === 'didNotReceive' +} + +export function isReceivedFunction(property: PropertyKey): property is ReceivedPropertyKey { + return property === received || property === 'received' +} + +export function isClearReceivedCallsFunction(property: PropertyKey): property is ClearReceivedCallsPropertyKey { + return property === clearReceivedCalls || property === 'clearReceivedCalls' +} + +export function isMimickFunction(property: PropertyKey): property is MimickPropertyKey { + return property === mimick || property === 'mimick' +} + +export function isRejectsFunction(property: PropertyKey): property is RejectsPropertyKey { + return property === rejects || property === 'rejects' +} + +export function isResolvesFunction(property: PropertyKey): property is ResolvesPropertyKey { + return property === resolves || property === 'resolves' +} + +export function isThrowsFunction(property: PropertyKey): property is ThrowsPropertyKey { + return property === throws || property === 'throws' +} + +export function isReturnsFunction(property: PropertyKey): property is ReturnsPropertyKey { + return property === returns || property === 'returns' +} + +export function isMimicksFunction(property: PropertyKey): property is MimicksPropertyKey { + return property === mimicks || property === 'mimicks' +} + +// https://www.totaltypescript.com/concepts/the-prettify-helper +export type Prettify = { + [K in keyof T]: T[K]; +} & {}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index ffd8ce0..0fc5f52 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,6 @@ import { Substitute, SubstituteOf } from './Substitute' export { Arg } from './Arguments' export { Substitute, SubstituteOf } -export { ClearType } from './Utilities' +export { clearReceivedCalls, didNotReceive, mimick, received, mimicks, rejects, resolves, returns, throws } from './Transformations' export default Substitute \ No newline at end of file