diff --git a/ava.config.js b/ava.config.js index cc81cf1..d47ef16 100644 --- a/ava.config.js +++ b/ava.config.js @@ -1,6 +1,6 @@ module.exports = { - files: ['spec/**/*.ts'], + files: ['spec/**/*.spec.ts'], typescript: { compile: false, rewritePaths: { @@ -10,4 +10,4 @@ module.exports = { cache: false, failFast: true, failWithoutAssertions: true -} \ No newline at end of file +} diff --git a/compatibility-checks/test/index.ts b/compatibility-checks/test/index.ts index ce90233..afaa9ff 100644 --- a/compatibility-checks/test/index.ts +++ b/compatibility-checks/test/index.ts @@ -10,7 +10,6 @@ type TestRunner = { test.describe('Verifies test runner compatibility', () => { const failingExec = (command: string): string => { - process.env const { FORCE_COLOR, ...environment } = { ...process.env, CI: '1', NO_COLOR: '1' } as Partial> try { execSync(command, { env: environment, stdio: 'pipe' }) @@ -43,11 +42,10 @@ test.describe('Verifies test runner compatibility', () => { `Could not find the expected failure location in the output. Expected "${testRunner.failureLocationText}"` ) assert.ok( - result.includes('SubstituteException: Expected'), + result.includes('SubstituteException: Call count mismatch'), `Could not find the expected exception message in the output: ${result}` ) }) }) }) }) - diff --git a/package-lock.json b/package-lock.json index 8e56c98..2efcbf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,10 @@ "devDependencies": { "@ava/typescript": "^3.0.1", "@sinonjs/fake-timers": "^11.2.2", - "@types/node": "^18.19.22", + "@types/node": "^18.19.122", "@types/sinonjs__fake-timers": "^8.1.5", "ava": "^4.3.3", - "typescript": "^4.8.4" + "typescript": "^5.9.2" }, "engines": { "node": ">=10" @@ -91,9 +91,9 @@ } }, "node_modules/@types/node": { - "version": "18.19.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.22.tgz", - "integrity": "sha512-p3pDIfuMg/aXBmhkyanPshdfJuX5c5+bQjYLIikPLXAUycEogij/c50n/C+8XOA5L93cU4ZRXtn+dNQGi0IZqQ==", + "version": "18.19.122", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.122.tgz", + "integrity": "sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA==", "dev": true, "dependencies": { "undici-types": "~5.26.4" @@ -1978,16 +1978,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.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=14.17" } }, "node_modules/undici-types": { @@ -2282,9 +2282,9 @@ } }, "@types/node": { - "version": "18.19.22", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.22.tgz", - "integrity": "sha512-p3pDIfuMg/aXBmhkyanPshdfJuX5c5+bQjYLIikPLXAUycEogij/c50n/C+8XOA5L93cU4ZRXtn+dNQGi0IZqQ==", + "version": "18.19.122", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.122.tgz", + "integrity": "sha512-yzegtT82dwTNEe/9y+CM8cgb42WrUfMMCg2QqSddzO1J6uPmBD7qKCZ7dOHZP2Yrpm/kb0eqdNMn2MUyEiqBmA==", "dev": true, "requires": { "undici-types": "~5.26.4" @@ -3596,9 +3596,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.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true }, "undici-types": { diff --git a/package.json b/package.json index 631e8d1..cca6f69 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,10 @@ "devDependencies": { "@ava/typescript": "^3.0.1", "@sinonjs/fake-timers": "^11.2.2", - "@types/node": "^18.19.22", + "@types/node": "^18.19.122", "@types/sinonjs__fake-timers": "^8.1.5", "ava": "^4.3.3", - "typescript": "^4.8.4" + "typescript": "^5.9.2" }, "volta": { "node": "18.19.1" diff --git a/spec/ClearSubstitute.spec.ts b/spec/ClearSubstitute.spec.ts deleted file mode 100644 index 0dad128..0000000 --- a/spec/ClearSubstitute.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import test from 'ava' - -import { Substitute, SubstituteOf } from '../src' -import { SubstituteNode } from '../src/SubstituteNode' - -interface Calculator { - add(a: number, b: number): number - subtract(a: number, b: number): number - divide(a: number, b: number): number - isEnabled: boolean -} - -type InstanceReturningSubstitute = SubstituteOf & { - [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') - - t.is(calculator[SubstituteNode.instance].recorder.records.size, 2) - t.is(calculator[SubstituteNode.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/RecordedArguments.spec.ts b/spec/RecordedArguments.spec.ts index 103efb3..ea37629 100644 --- a/spec/RecordedArguments.spec.ts +++ b/spec/RecordedArguments.spec.ts @@ -2,7 +2,7 @@ import test from 'ava' import { inspect } from 'util' import { Arg } from '../src' -import { RecordedArguments } from '../src/RecordedArguments' +import { RecordedArguments } from '../src/internals/RecordedArguments' const testObject = { 'foo': 'bar' } const testArray = ['a', 1, true] @@ -135,4 +135,4 @@ test('generates custom text representation', t => { t.is(inspect(RecordedArguments.from([])), '()') t.is(inspect(RecordedArguments.from([undefined])), 'undefined') t.is(inspect(RecordedArguments.from([undefined, 1])), '(undefined, 1)') -}) \ No newline at end of file +}) diff --git a/spec/Recorder.spec.ts b/spec/Recorder.spec.ts index 348a0b9..47b55cf 100644 --- a/spec/Recorder.spec.ts +++ b/spec/Recorder.spec.ts @@ -1,9 +1,9 @@ import test from 'ava' -import { Recorder } from '../src/Recorder' -import { RecordsSet } from '../src/RecordsSet' -import { Substitute } from '../src/Substitute' -import { SubstituteNodeBase } from '../src/SubstituteNodeBase' +import { Recorder } from '../src/internals/Recorder' +import { RecordsSet } from '../src/internals/RecordsSet' +import { Substitute } from '../src/api/Substitute' +import { SubstituteNodeBase } from '../src/internals/SubstituteNodeBase' const nodeFactory = (key: string) => { const node = Substitute.for() diff --git a/spec/RecordsSet.spec.ts b/spec/RecordsSet.spec.ts index 62b83ae..d17aec4 100644 --- a/spec/RecordsSet.spec.ts +++ b/spec/RecordsSet.spec.ts @@ -1,6 +1,6 @@ import test, { ExecutionContext } from 'ava' -import { RecordsSet } from '../src/RecordsSet' +import { RecordsSet } from '../src/internals/RecordsSet' const dataArray = [1, 2, 3] function* dataArrayGenerator() { @@ -63,4 +63,4 @@ test('applies and preserves the order of filter and map functions everytime the t.deepEqual([...setWithFilter], [1, 3]) t.deepEqual([...setWithFilterAndMap], ['1', '3']) t.deepEqual([...setWithFilterMapAndAnotherFilter], ['3']) -}) \ No newline at end of file +}) diff --git a/spec/regression/Arguments.spec.ts b/spec/regression/Arguments.spec.ts index a04e5d2..1625fd4 100644 --- a/spec/regression/Arguments.spec.ts +++ b/spec/regression/Arguments.spec.ts @@ -1,6 +1,6 @@ import test from 'ava' import { Arg } from '../../src' -import { Argument } from '../../src/Arguments' +import { Argument } from '../../src/shared' const testObject = { "foo": "bar" } const testArray = ["a", 1, true] @@ -107,4 +107,4 @@ test('should not match the argument with the predicate function using Arg.is.not t.true(Arg.is.not(x => x === 'foo').matches('bar')) t.true(Arg.is.not(x => x % 2 == 0).matches(3)) -}) \ No newline at end of file +}) diff --git a/spec/regression/clearReceivedCalls.spec.ts b/spec/regression/clearReceivedCalls.spec.ts new file mode 100644 index 0000000..4325f5c --- /dev/null +++ b/spec/regression/clearReceivedCalls.spec.ts @@ -0,0 +1,28 @@ +import test from 'ava' + +import { Substitute, SubstituteOf, clearReceivedCalls } from '../../src' +import { SubstituteNode, instance } from '../../src/internals/SubstituteNode' + +interface Calculator { + add(a: number, b: number): number + subtract(a: number, b: number): number + divide(a: number, b: number): number + isEnabled: boolean +} + +type InstanceReturningSubstitute = SubstituteOf & { + [instance]: SubstituteNode +} + +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[clearReceivedCalls](); + + 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)) +}) diff --git a/spec/regression/didNotReceive.spec.ts b/spec/regression/didNotReceive.spec.ts index 2b01ecc..588da00 100644 --- a/spec/regression/didNotReceive.spec.ts +++ b/spec/regression/didNotReceive.spec.ts @@ -1,6 +1,6 @@ import test from 'ava' import { Substitute, Arg } from '../../src' -import { SubstituteException } from '../../src/SubstituteException' +import { SubstituteException } from '../../src/internals/SubstituteException' interface Calculator { add(a: number, b: number): number @@ -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 }) }) @@ -49,4 +49,4 @@ test('not getting a property with mock correctly asserts the call count', t => { calculator.didNotReceive().isEnabled t.throws(() => calculator.received(1).isEnabled, { instanceOf: SubstituteException }) t.throws(() => calculator.received().isEnabled, { instanceOf: SubstituteException }) -}) \ No newline at end of file +}) diff --git a/spec/regression/index.test.ts b/spec/regression/index.test.ts index 16aba9e..45107c3 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 } 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,24 +108,24 @@ 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 = 'Call count mismatch in @Substitute.c:\n' + `Expected to receive 7 method calls matching c('hi', 'there'), but received 4.\n` + 'All property or method calls to @Substitute.c received so far:\n' + `› ✔ @Substitute.c('hi', 'there')\n` + - ` called at (${process.cwd()}/spec/regression/index.test.ts:114:18)\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:105:18)\n` + `› ✘ @Substitute.c('hi', 'the1re')\n` + - ` called at (${process.cwd()}/spec/regression/index.test.ts:115:18)\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:106:18)\n` + `› ✔ @Substitute.c('hi', 'there')\n` + - ` called at (${process.cwd()}/spec/regression/index.test.ts:116:18)\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:107:18)\n` + `› ✔ @Substitute.c('hi', 'there')\n` + - ` called at (${process.cwd()}/spec/regression/index.test.ts:117:18)\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:108:18)\n` + `› ✔ @Substitute.c('hi', 'there')\n` + - ` called at (${process.cwd()}/spec/regression/index.test.ts:118:18)\n` - const { message } = t.throws(() => { substitute.received(7).c('hi', 'there') }) + ` called at (${process.cwd()}/spec/regression/index.test.ts:109:18)\n` + const { message } = t.throws(() => { substitute[received](7).c('hi', 'there') }) t.is(message.replace(textModifierRegex, ''), expectedMessage) }) @@ -144,16 +135,16 @@ 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 = 'Call count mismatch in @Substitute.c:\n' + `Expected to receive 2 method calls matching c('lala', 'bar'), but received 1.\n` + 'All property or method calls to @Substitute.c received so far:\n' + `› ✔ @Substitute.c('lala', 'bar')\n` + - ` called at (${process.cwd()}/spec/regression/index.test.ts:145:13)\n` - const { message } = t.throws(() => substitute.received(2).c('lala', 'bar')) + ` called at (${process.cwd()}/spec/regression/index.test.ts:136:13)\n` + 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..78efeb6 100644 --- a/spec/regression/issues/178.test.ts +++ b/spec/regression/issues/178.test.ts @@ -3,7 +3,7 @@ import * as fakeTimers from '@sinonjs/fake-timers' import { types } from 'util' import { Substitute } from '../../../src' -import { SubstituteException } from '../../../src/SubstituteException' +import { SubstituteException } from '../../../src/internals/SubstituteException' interface Library { subSection: Subsection 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/overlap.spec.ts b/spec/regression/overlap.spec.ts new file mode 100644 index 0000000..b2cc942 --- /dev/null +++ b/spec/regression/overlap.spec.ts @@ -0,0 +1,54 @@ +import test from 'ava' + +import { Substitute, received, didNotReceive } from '../../src' + +export interface OverlappingInterface { + received(value: number): string + didNotReceive(): unknown + clearReceivedCalls(): void +} + +test('(clearReceivedCalls) handles overlaps safely without substitutions', t => { + const substitute = Substitute.for>() + substitute.clearReceivedCalls() + t.notThrows(() => substitute.received(1).clearReceivedCalls()) + t.throws(() => substitute[didNotReceive]().clearReceivedCalls()) + }) + +test('(clearReceivedCalls) handles overlaps safely with substitutions', t => { + const substitute = Substitute.for>() + substitute.clearReceivedCalls().returns() + substitute.clearReceivedCalls() + t.notThrows(() => substitute.received(1).clearReceivedCalls()) + t.throws(() => substitute[didNotReceive]().clearReceivedCalls()) +}) + +test('(received) handles overlaps safely without substitutions', t => { + const substitute = Substitute.for>() + substitute.received(10) + t.notThrows(() => substitute[received](1).received(10)) + t.throws(() => substitute.didNotReceive().received(10)) +}) + +test('(received) handles overlaps safely with substitutions', t => { + const substitute = Substitute.for>() + substitute.received(10).returns('foo') + t.is('foo', substitute.received(10)) + t.notThrows(() => substitute[received](1).received(10)) + t.throws(() => substitute.didNotReceive().received(10)) +}) + +test('(didNotReceive) handles overlaps safely without substitutions', t => { + const substitute = Substitute.for>() + substitute.didNotReceive() + t.notThrows(() => substitute.received(1).didNotReceive()) + t.throws(() => substitute[didNotReceive]().didNotReceive()) +}) + +test('(didNotReceive) handles overlaps safely with substitutions', t => { + const substitute = Substitute.for>() + substitute.didNotReceive().returns('foo') + t.is('foo' as unknown, substitute.didNotReceive()) + t.notThrows(() => substitute.received(1).didNotReceive()) + t.throws(() => substitute[didNotReceive]().didNotReceive()) +}) diff --git a/spec/regression/received.spec.ts b/spec/regression/received.spec.ts index c9a4519..69651f9 100644 --- a/spec/regression/received.spec.ts +++ b/spec/regression/received.spec.ts @@ -1,6 +1,6 @@ import test from 'ava' import { Substitute, Arg } from '../../src' -import { SubstituteException } from '../../src/SubstituteException' +import { SubstituteException } from '../../src/internals/SubstituteException' interface Calculator { add(a: number, b: number): number @@ -137,4 +137,4 @@ test('calling a method does not interfere with other properties or methods call t.throws(() => calculator.received().multiply(1, 1), { instanceOf: SubstituteException }) t.throws(() => calculator.received().multiply(Arg.all()), { instanceOf: SubstituteException }) -}) \ No newline at end of file +}) 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..6f8fb93 100644 --- a/spec/regression/returns.spec.ts +++ b/spec/regression/returns.spec.ts @@ -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()) }) @@ -186,4 +183,4 @@ test('returns another substituted instance on a property', async t => { const result = await calculator.model t.is(result, modelResult) t.is(result.replace('...', '---'), 'TI-83') -}) \ No newline at end of file +}) 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/Arguments.ts b/src/Arguments.ts deleted file mode 100644 index 6db6412..0000000 --- a/src/Arguments.ts +++ /dev/null @@ -1,101 +0,0 @@ -type PredicateFunction = (arg: T) => boolean -type ArgumentOptions = { - inverseMatch?: boolean -} -class BaseArgument { - private _description: string - constructor( - description: string, - private _matchingFunction: PredicateFunction, - private _options?: ArgumentOptions - ) { - this._description = `${this._options?.inverseMatch ? 'Not ' : ''}${description}` - } - - matches(arg: T) { - const inverseMatch = this._options?.inverseMatch ?? false - return inverseMatch ? !this._matchingFunction(arg) : this._matchingFunction(arg) - } - - toString() { - return this._description - } - - [Symbol.for('nodejs.util.inspect.custom')]() { - return this._description - } -} - -export class Argument extends BaseArgument { - private readonly _type = 'SingleArgument'; - get type(): 'SingleArgument' { - return this._type - } -} - -export class AllArguments extends BaseArgument { - private readonly _type = 'AllArguments'; - constructor() { - super('Arg.all{}', () => true, {}) - } - get type(): 'AllArguments' { - return this._type // TODO: Needed? - } -} - -export namespace Arg { - type Inversable = T & { not: T } - type ExtractFirstArg = T extends AllArguments ? TArgs[0] : T - type ReturnArg = Argument & T - const createInversable = (target: (arg: TArg, opt?: ArgumentOptions) => TReturn): Inversable<(arg: TArg) => TReturn> => { - const inversable = (arg: TArg) => target(arg) - inversable.not = (arg: TArg) => target(arg, { inverseMatch: true }) - return inversable - } - - const toStringify = (obj: any) => { - if (typeof obj.inspect === 'function') return obj.inspect() - if (typeof obj.toString === 'function') return obj.toString() - return obj - } - - export const all = (): AllArguments => new AllArguments() - - type Is = (predicate: PredicateFunction>) => ReturnArg> - const isFunction = (predicate: PredicateFunction>, options?: ArgumentOptions) => new Argument( - `Arg.is{${toStringify(predicate)}}`, predicate, options - ) - export const is = createInversable(isFunction) as Inversable - - type MapAnyReturn = T extends 'any' ? - ReturnArg : T extends 'string' ? - ReturnArg : T extends 'number' ? - ReturnArg : T extends 'boolean' ? - ReturnArg : T extends 'symbol' ? - ReturnArg : T extends 'undefined' ? - ReturnArg : T extends 'object' ? - ReturnArg : T extends 'function' ? - ReturnArg : T extends 'array' ? - ReturnArg : any - - type AnyType = 'string' | 'number' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | 'array' | 'any' - type Any = (type?: T) => MapAnyReturn - - const anyFunction = (type: AnyType = 'any', options?: ArgumentOptions) => { - const description = `Arg.any{${type}}` - const predicate = (x: any) => { - switch (type) { - case 'any': - return true - case 'array': - return Array.isArray(x) - default: - return typeof x === type - } - } - - return new Argument(description, predicate, options) - } - - export const any = createInversable(anyFunction) as Inversable -} \ No newline at end of file diff --git a/src/Substitute.ts b/src/Substitute.ts deleted file mode 100644 index 3f87849..0000000 --- a/src/Substitute.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { DisabledSubstituteObject, 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/Transformations.ts b/src/Transformations.ts deleted file mode 100644 index e927822..0000000 --- a/src/Transformations.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { AllArguments } from './Arguments'; -import type { ClearType, FirstLevelMethod } from './Types'; - -type FunctionSubstituteWithOverloads = - TFunc extends { - (...args: infer A1): infer R1; - (...args: infer A2): infer R2; - (...args: infer A3): infer R3; - (...args: infer A4): infer R4; - (...args: infer A5): infer R5; - } ? - FunctionHandler & FunctionHandler & - FunctionHandler & FunctionHandler - & FunctionHandler : TFunc extends { - (...args: infer A1): infer R1; - (...args: infer A2): infer R2; - (...args: infer A3): infer R3; - (...args: infer A4): infer R4; - } ? - FunctionHandler & FunctionHandler & - FunctionHandler & FunctionHandler : TFunc extends { - (...args: infer A1): infer R1; - (...args: infer A2): infer R2; - (...args: infer A3): infer R3; - } ? - FunctionHandler & FunctionHandler - & FunctionHandler : TFunc extends { - (...args: infer A1): infer R1; - (...args: infer A2): infer R2; - } ? - FunctionHandler & FunctionHandler : TFunc extends { - (...args: infer A1): infer R1; - } ? - FunctionHandler : never; - -type Equals = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; -type FunctionHandler = - Equals extends true ? - {} : Terminating extends true ? - TerminatingFunction : - FunctionSubstitute - -export type FunctionSubstitute = - ((...args: TArguments) => (TReturnType & MockObjectMixin)) & - ((allArguments: AllArguments) => (TReturnType & MockObjectMixin)) - -export type NoArgumentFunctionSubstitute = () => TReturnType & NoArgumentMockObjectMixin; -export type PropertySubstitute = TReturnType & NoArgumentMockObjectMixin; - -type OneArgumentRequiredFunction = (requiredInput: TArgs, ...restInputs: TArgs[]) => TReturnType; - -type MockObjectPromise = TReturnType extends Promise ? { - resolves: OneArgumentRequiredFunction; - rejects: OneArgumentRequiredFunction; -} : {} - -type BaseMockObjectMixin = MockObjectPromise & { - returns: OneArgumentRequiredFunction; - throws: OneArgumentRequiredFunction; -} - -type NoArgumentMockObjectMixin = BaseMockObjectMixin & { - mimicks: OneArgumentRequiredFunction<() => TReturnType, void>; -} - -type MockObjectMixin = BaseMockObjectMixin & { - mimicks: OneArgumentRequiredFunction<(...args: TArguments) => TReturnType, void>; -} - -type TerminatingFunction = ((...args: TArguments) => void) & ((arg: AllArguments) => void) - -type TryToExpandNonArgumentedTerminatingFunction = - TObject[TProperty] extends (...args: []) => unknown ? () => void : {} -type TryToExpandArgumentedTerminatingFunction = - TObject[TProperty] extends (...args: any) => any ? FunctionSubstituteWithOverloads : {} - -type TerminatingObject = { - [P in keyof T]: TryToExpandNonArgumentedTerminatingFunction & TryToExpandArgumentedTerminatingFunction & T[P]; -} - -type TryToExpandNonArgumentedFunctionSubstitute = - TObject[TProperty] extends (...args: []) => infer R ? NoArgumentFunctionSubstitute : {} - -type TryToExpandArgumentedFunctionSubstitute = - TObject[TProperty] extends (...args: infer F) => infer R ? F extends [] ? {} : FunctionSubstituteWithOverloads : {} - -type TryToExpandPropertySubstitute = PropertySubstitute - -type ObjectSubstituteTransformation> = { - [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; diff --git a/src/Types.ts b/src/Types.ts deleted file mode 100644 index 1fe75b6..0000000 --- a/src/Types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { RecordedArguments } from './RecordedArguments' - -export type PropertyType = 'method' | 'property' -export type AccessorType = 'get' | 'set' -export type AssertionMethod = 'received' | 'didNotReceive' -export type ConfigurationMethod = 'clearSubstitute' | 'mimick' -export type SubstitutionMethod = 'mimicks' | 'throws' | 'returns' | 'resolves' | 'rejects' - -export type FirstLevelMethod = AssertionMethod | ConfigurationMethod -export type SubstituteMethod = FirstLevelMethod | SubstitutionMethod -export type SubstituteContext = SubstituteMethod | 'none' - -export type ClearType = 'all' | 'receivedCalls' | 'substituteValues' - -export type SubstituteExceptionType = 'CallCountMismatch' | 'PropertyNotMocked' - -export type FilterFunction = (item: T) => boolean - -export type SubstituteNodeModel = { - propertyType: PropertyType - property: PropertyKey - context: SubstituteContext - recordedArguments: RecordedArguments - stack?: string -} \ No newline at end of file diff --git a/src/api/Arg.ts b/src/api/Arg.ts new file mode 100644 index 0000000..6798412 --- /dev/null +++ b/src/api/Arg.ts @@ -0,0 +1,56 @@ +import { Argument, AllArguments, type ArgumentOptions, type PredicateFunction } from '../shared' + +type Inversable = T & { not: T } +type ExtractFirstArg = T extends AllArguments ? TArgs[0] : T +type ReturnArg = Argument & T +const createInversable = (target: (arg: TArg, opt?: ArgumentOptions) => TReturn): Inversable<(arg: TArg) => TReturn> => { + const inversable = (arg: TArg) => target(arg) + inversable.not = (arg: TArg) => target(arg, { inverseMatch: true }) + return inversable +} + +const toStringify = (obj: any) => { + if (typeof obj.inspect === 'function') return obj.inspect() + if (typeof obj.toString === 'function') return obj.toString() + return obj +} +const isFunction = (predicate: PredicateFunction>, options?: ArgumentOptions) => new Argument( + `Arg.is{${toStringify(predicate)}}`, predicate, options +) +type Is = (predicate: PredicateFunction>) => ReturnArg> + +type MapAnyReturn = T extends 'any' ? +ReturnArg : T extends 'string' ? +ReturnArg : T extends 'number' ? +ReturnArg : T extends 'boolean' ? +ReturnArg : T extends 'symbol' ? +ReturnArg : T extends 'undefined' ? +ReturnArg : T extends 'object' ? +ReturnArg : T extends 'function' ? +ReturnArg : T extends 'array' ? +ReturnArg : any + +type AnyType = 'string' | 'number' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | 'array' | 'any' +type Any = (type?: T) => MapAnyReturn + +const anyFunction = (type: AnyType = 'any', options?: ArgumentOptions) => { + const description = `Arg.any{${type}}` + const predicate = (x: any) => { + switch (type) { + case 'any': + return true + case 'array': + return Array.isArray(x) + default: + return typeof x === type + } + } + + return new Argument(description, predicate, options) +} + +export const Arg = { + all: (): AllArguments => new AllArguments(), + is: createInversable(isFunction) as Inversable, + any: createInversable(anyFunction) as Inversable +} diff --git a/src/api/Constants.ts b/src/api/Constants.ts new file mode 100644 index 0000000..d53a809 --- /dev/null +++ b/src/api/Constants.ts @@ -0,0 +1,13 @@ +import { constants as sharedConstants } from '../shared' + +const received: typeof sharedConstants.CONTEXT.received.symbol = sharedConstants.CONTEXT.received.symbol +const didNotReceive: typeof sharedConstants.CONTEXT.didNotReceive.symbol = sharedConstants.CONTEXT.didNotReceive.symbol +const clearReceivedCalls: typeof sharedConstants.CONTEXT.clearReceivedCalls.symbol = sharedConstants.CONTEXT.clearReceivedCalls.symbol +const mimick: typeof sharedConstants.CONTEXT.mimick.symbol = sharedConstants.CONTEXT.mimick.symbol +const mimicks: typeof sharedConstants.CONTEXT.mimicks.symbol = sharedConstants.CONTEXT.mimicks.symbol +const throws: typeof sharedConstants.CONTEXT.throws.symbol = sharedConstants.CONTEXT.throws.symbol +const returns: typeof sharedConstants.CONTEXT.returns.symbol = sharedConstants.CONTEXT.returns.symbol +const resolves: typeof sharedConstants.CONTEXT.resolves.symbol = sharedConstants.CONTEXT.resolves.symbol +const rejects: typeof sharedConstants.CONTEXT.rejects.symbol = sharedConstants.CONTEXT.rejects.symbol + +export { received, didNotReceive, clearReceivedCalls, mimick, mimicks, throws, returns, resolves, rejects } diff --git a/src/api/Substitute.ts b/src/api/Substitute.ts new file mode 100644 index 0000000..384ec39 --- /dev/null +++ b/src/api/Substitute.ts @@ -0,0 +1,11 @@ +import { ObjectSubstitute } from './types' +import { SubstituteNode } from '../internals' + +export type SubstituteOf = ObjectSubstitute & T + +export class Substitute { + public static for(): SubstituteOf { + const substitute = SubstituteNode.createRoot() + return substitute.proxy as unknown as SubstituteOf + } +} diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..eb6396c --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,3 @@ +export * from './Arg' +export * from './Substitute' +export * from './types' diff --git a/src/api/types/FunctionSubstitute.ts b/src/api/types/FunctionSubstitute.ts new file mode 100644 index 0000000..872faec --- /dev/null +++ b/src/api/types/FunctionSubstitute.ts @@ -0,0 +1,48 @@ +import { AllArguments } from '../../shared' +import { MockObjectMixin, NoArgumentMockObjectMixin } from './SubstitutionLevel' + +type TerminatingFunction = ((...args: TArguments) => void) & ((arg: AllArguments) => void) +export type FunctionSubstitute = + ((...args: TArguments) => (TReturnType & MockObjectMixin)) & + ((allArguments: AllArguments) => (TReturnType & MockObjectMixin)) + +export type FunctionSubstituteWithOverloads = + TFunc extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + (...args: infer A5): infer R5; + } ? + FunctionHandler & FunctionHandler & + FunctionHandler & FunctionHandler + & FunctionHandler : TFunc extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + } ? + FunctionHandler & FunctionHandler & + FunctionHandler & FunctionHandler : TFunc extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + } ? + FunctionHandler & FunctionHandler + & FunctionHandler : TFunc extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + } ? + FunctionHandler & FunctionHandler : TFunc extends { + (...args: infer A1): infer R1; + } ? + FunctionHandler : never; + +type Equals = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type FunctionHandler = + Equals extends true ? + {} : Terminating extends true ? + TerminatingFunction : + FunctionSubstitute + +export type NoArgumentFunctionSubstitute = () => TReturnType & NoArgumentMockObjectMixin diff --git a/src/api/types/Substitute.ts b/src/api/types/Substitute.ts new file mode 100644 index 0000000..6921940 --- /dev/null +++ b/src/api/types/Substitute.ts @@ -0,0 +1,38 @@ +import { NoArgumentMockObjectMixin } from './SubstitutionLevel' +import { FunctionSubstituteWithOverloads, NoArgumentFunctionSubstitute } from './FunctionSubstitute' + +export type PropertySubstitute = + TReturnType & + NoArgumentMockObjectMixin; + +export type TryToExpandNonArgumentedTerminatingFunction = + TObject[TProperty] extends (...args: []) => unknown ? + () => void : + {} + +export type TryToExpandArgumentedTerminatingFunction = + TObject[TProperty] extends (...args: any) => any ? + FunctionSubstituteWithOverloads : + {} + +type TryToExpandNonArgumentedFunctionSubstitute = + TObject[TProperty] extends (...args: []) => infer R ? + NoArgumentFunctionSubstitute : + {} + +type TryToExpandArgumentedFunctionSubstitute = + TObject[TProperty] extends (...args: infer F) => any ? + F extends [] ? + {} : + FunctionSubstituteWithOverloads : + {} + +type TryToExpandPropertySubstitute = + PropertySubstitute + +export type ObjectSubstituteTransformation> = { + [P in keyof T]: + TryToExpandNonArgumentedFunctionSubstitute & + TryToExpandArgumentedFunctionSubstitute & + TryToExpandPropertySubstitute; +} diff --git a/src/api/types/SubstituteMethods.ts b/src/api/types/SubstituteMethods.ts new file mode 100644 index 0000000..e059418 --- /dev/null +++ b/src/api/types/SubstituteMethods.ts @@ -0,0 +1,29 @@ +import { constants } from '../../shared' +import { + TryToExpandNonArgumentedTerminatingFunction, + TryToExpandArgumentedTerminatingFunction +} from './Substitute' + +type TerminatingObject = { + [P in keyof T]: + TryToExpandNonArgumentedTerminatingFunction & + TryToExpandArgumentedTerminatingFunction & + T[P] +} + +export const received: typeof constants.CONTEXT.received.symbol = constants.CONTEXT.received.symbol +export const didNotReceive: typeof constants.CONTEXT.didNotReceive.symbol = constants.CONTEXT.didNotReceive.symbol +export const mimick: typeof constants.CONTEXT.mimick.symbol = constants.CONTEXT.mimick.symbol +export const clearReceivedCalls: typeof constants.CONTEXT.clearReceivedCalls.symbol = constants.CONTEXT.clearReceivedCalls.symbol + +export 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: T): void } : + { mimick(instance: T): void }) & + { [clearReceivedCalls](): void } diff --git a/src/api/types/SubstitutionLevel.ts b/src/api/types/SubstitutionLevel.ts new file mode 100644 index 0000000..8cf326d --- /dev/null +++ b/src/api/types/SubstitutionLevel.ts @@ -0,0 +1,41 @@ +import { constants } from '../../shared' + +export const returns: typeof constants.CONTEXT.returns.symbol = constants.CONTEXT.returns.symbol +export const throws: typeof constants.CONTEXT.throws.symbol = constants.CONTEXT.throws.symbol +export const resolves: typeof constants.CONTEXT.resolves.symbol = constants.CONTEXT.resolves.symbol +export const rejects: typeof constants.CONTEXT.rejects.symbol = constants.CONTEXT.rejects.symbol +export const mimicks: typeof constants.CONTEXT.mimicks.symbol = constants.CONTEXT.mimicks.symbol + +type OneArgumentRequiredFunction = (requiredInput: TArgs, ...restInputs: TArgs[]) => TReturnType; + +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 }) + ) + +export type NoArgumentMockObjectMixin = + BaseMockObjectMixin & + (TObject extends { mimicks: any } ? + { [mimicks]: OneArgumentRequiredFunction<() => TReturnType, void> } : + { mimicks: OneArgumentRequiredFunction<() => TReturnType, void> }) + +export type MockObjectMixin = + BaseMockObjectMixin & + (TReturnType extends { mimicks: any } ? + { [mimicks]: OneArgumentRequiredFunction<(...args: TArguments) => TReturnType, void> } : + { mimicks: OneArgumentRequiredFunction<(...args: TArguments) => TReturnType, void> }) diff --git a/src/api/types/index.ts b/src/api/types/index.ts new file mode 100644 index 0000000..ef1e2ce --- /dev/null +++ b/src/api/types/index.ts @@ -0,0 +1,9 @@ +import { ObjectSubstituteTransformation } from './Substitute' +import { ObjectSubstituteMethods } from './SubstituteMethods' + +export type ObjectSubstitute = + ObjectSubstituteMethods & + ObjectSubstituteTransformation + +export { received, didNotReceive, clearReceivedCalls, mimick } from './SubstituteMethods' +export { returns, throws, resolves, rejects, mimicks } from './SubstitutionLevel' diff --git a/src/index.ts b/src/index.ts index 66a2325..75f54fb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,3 @@ -import { Substitute, SubstituteOf } from './Substitute' -import { constants } from './utilities' -const clear = constants.CLEAR - -export { Arg } from './Arguments' -export { Substitute, SubstituteOf } -export { clear as ClearType } - -export default Substitute \ No newline at end of file +import { Substitute } from './api' +export * from './api' +export default Substitute diff --git a/src/internals/Constants.ts b/src/internals/Constants.ts new file mode 100644 index 0000000..5d40f45 --- /dev/null +++ b/src/internals/Constants.ts @@ -0,0 +1,18 @@ +const method = Symbol('method') +const property = Symbol('property') +const propertyTypes = { method, property } as const + +const get = Symbol('get') +const set = Symbol('set') +const accessorTypes = { get, set } as const + +const substituteExceptionTypes = { + callCountMismatch: 'CallCountMismatch', + propertyNotMocked: 'PropertyNotMocked' +} as const + +export const constants = { + PROPERTY: propertyTypes, + ACCESSOR: accessorTypes, + EXCEPTION: substituteExceptionTypes +} as const diff --git a/src/RecordedArguments.ts b/src/internals/RecordedArguments.ts similarity index 98% rename from src/RecordedArguments.ts rename to src/internals/RecordedArguments.ts index 3ea8910..470d489 100644 --- a/src/RecordedArguments.ts +++ b/src/internals/RecordedArguments.ts @@ -1,5 +1,5 @@ import { inspect, InspectOptions, isDeepStrictEqual } from 'util' -import { Argument, AllArguments } from './Arguments' +import { Argument, AllArguments } from '../shared' type ArgumentsClass = 'plain' | 'with-predicate' | 'wildcard' const argumentsClassDigitMapper: Record = { @@ -83,4 +83,4 @@ export class RecordedArguments { ? `(${inspectedValues.join(', ')})` : inspectedValues[0] } -} \ No newline at end of file +} diff --git a/src/Recorder.ts b/src/internals/Recorder.ts similarity index 99% rename from src/Recorder.ts rename to src/internals/Recorder.ts index bc6b97c..c6e3540 100644 --- a/src/Recorder.ts +++ b/src/internals/Recorder.ts @@ -70,4 +70,4 @@ export class Recorder { indexedRecord.delete(record) if (indexedRecord.size === 0) this.indexedRecords.delete(id) } -} \ No newline at end of file +} diff --git a/src/RecordsSet.ts b/src/internals/RecordsSet.ts similarity index 99% rename from src/RecordsSet.ts rename to src/internals/RecordsSet.ts index cc8026e..06fdab3 100644 --- a/src/RecordsSet.ts +++ b/src/internals/RecordsSet.ts @@ -72,4 +72,4 @@ export class RecordsSet extends Set { } } } -} \ No newline at end of file +} diff --git a/src/SubstituteException.ts b/src/internals/SubstituteException.ts similarity index 94% rename from src/SubstituteException.ts rename to src/internals/SubstituteException.ts index b396057..d84706e 100644 --- a/src/SubstituteException.ts +++ b/src/internals/SubstituteException.ts @@ -1,5 +1,6 @@ import { SubstituteNodeModel, SubstituteExceptionType } from './Types' -import { stringify, TextBuilder, constants } from './utilities' +import { stringify, TextBuilder } from './utilities' +import { constants } from './Constants' export class SubstituteException extends Error { public type?: SubstituteExceptionType @@ -43,4 +44,4 @@ export class SubstituteException extends Error { public static generic(message: string) { return new this(message) } -} \ No newline at end of file +} diff --git a/src/SubstituteNode.ts b/src/internals/SubstituteNode.ts similarity index 64% rename from src/SubstituteNode.ts rename to src/internals/SubstituteNode.ts index 3abc044..d549b2b 100644 --- a/src/SubstituteNode.ts +++ b/src/internals/SubstituteNode.ts @@ -2,47 +2,60 @@ import { inspect, InspectOptions, types } from 'util' import { SubstituteNodeBase } from './SubstituteNodeBase' import { RecordedArguments } from './RecordedArguments' -import { constants, is, stringify } from './utilities' import { SubstituteException } from './SubstituteException' -import type { FilterFunction, SubstituteContext, SubstitutionMethod, ClearType, PropertyType, SubstituteNodeModel, AccessorType } from './Types' -import type { ObjectSubstitute } from './Transformations' - -const instance = Symbol('Substitute:Instance') -const clearTypeToFilterMap: Record> = { - all: () => true, - receivedCalls: node => is.CONTEXT.none(node.context), - substituteValues: node => is.CONTEXT.substitution(node.context) -} +import { is, stringify, transform } from './utilities' + +import { constants } from './Constants' +import type { SubstituteContext, PropertyType, SubstituteNodeModel, AccessorType, SubstituteMethod, SubstitutionMethod, AssertionMethod } from './Types' +import { constants as sharedConstants } from '../shared/Constants' + + +export const instance = Symbol('Substitute:Instance') type SpecialProperty = typeof instance | typeof inspect.custom | typeof Symbol.toPrimitive | 'then' | 'toJSON' -type RootContext = { substituteMethodsEnabled: boolean } +type TT = Exclude +type ObjectSubstitute = { + [P in typeof sharedConstants.CONTEXT[TT]['raw'] | typeof sharedConstants.CONTEXT[TT]['symbol']]: + (...args: any[]) => T | void | Promise | Promise +} export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitute, SubstituteNodeModel { private _proxy: SubstituteNode - private _rootContext: RootContext private _propertyType: PropertyType = constants.PROPERTY.property private _accessorType: AccessorType = constants.ACCESSOR.get private _recordedArguments: RecordedArguments = RecordedArguments.none() - private _context: SubstituteContext = constants.CONTEXT.none + private _context: SubstituteContext = sharedConstants.CONTEXT.none.symbol private _retrySubstitutionExecutionAttempt: boolean = false public stack?: string private constructor(key: PropertyKey, parent?: SubstituteNode) { super(key, parent) - if (this.isRoot()) this._rootContext = { substituteMethodsEnabled: true } - 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)) { + return target.evaluateSpecialProperty(property) + } + + if (target._retrySubstitutionExecutionAttempt) { + return target.reattemptSubstitutionExecution()[property] + } + const newNode = SubstituteNode.createChild(property, target) if (target.isAssertion) newNode.executeAssertion() - if (target.isRoot() && target.rootContext.substituteMethodsEnabled && (is.method.assertion(property) || is.method.configuration(property))) { + const unresolvedAssertionFollowedBySubstitution = !target.hasContext && is.method.assertion(target.property) && !is.method.substitution(newNode.property) + if (target.hasDepthOfAtLeast(1) && unresolvedAssertionFollowedBySubstitution) { + target.assignContext(target.property) + target[target.context as AssertionMethod](...Array.isArray(target.recordedArguments.value) ? target.recordedArguments.value : [undefined]) + if (target.isAssertion) newNode.executeAssertion() + // if (target.isConfiguration) newNode.executeConfiguration() + } + if (target.isRoot() && is.method.contextValue(property) && (is.method.assertion(property) || is.method.configuration(property))) { newNode.assignContext(property) return newNode[property].bind(newNode) } @@ -52,7 +65,15 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu set: function (target, property, value) { const newNode = SubstituteNode.createChild(property, target) newNode.handleSetter(value) - if (target.isAssertion) newNode.executeAssertion() + if (target.isAssertion) + newNode.executeAssertion() + + if (target.hasDepthOfAtLeast(1) && !target.hasContext && is.method.assertion(target.property)) { + target.assignContext(target.property) + target[target.property](...Array.isArray(target.recordedArguments.value) ? target.recordedArguments.value : [undefined]) + if (target.isAssertion) newNode.executeAssertion() + } + return true }, apply: function (target, _thisArg, rawArguments) { @@ -68,7 +89,21 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu ) } - public static instance: typeof instance = instance + public received(amount?: number | undefined) { + return this[sharedConstants.CONTEXT.received.symbol](amount); + } + + public didNotReceive() { + return this[sharedConstants.CONTEXT.didNotReceive.symbol](); + } + + public mimick(instance: unknown) { + return this[sharedConstants.CONTEXT.mimick.symbol](instance); + } + + public clearReceivedCalls() { + return this[sharedConstants.CONTEXT.clearReceivedCalls.symbol](); + } public static createRoot(): SubstituteNode { return new this('*Substitute') @@ -82,24 +117,20 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu return this._proxy } - public get rootContext(): RootContext { - return this._rootContext - } - get context(): SubstituteContext { return this._context } get hasContext(): boolean { - return this.context !== 'none' + return this.context !== sharedConstants.CONTEXT.none.symbol } get isSubstitution(): boolean { - return is.method.substitution(this.context) + return is.CONTEXT.substitution(this.context) } get isAssertion(): boolean { - return is.method.assertion(this.context) + return is.CONTEXT.assertion(this.context) } get property(): PropertyKey { @@ -118,23 +149,24 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu return this._recordedArguments } - public received(amount?: number): SubstituteNode { + public [sharedConstants.CONTEXT.received.symbol](amount?: number): SubstituteNode { this.handleMethod([amount]) return this.proxy } - public didNotReceive(): SubstituteNode { + public [sharedConstants.CONTEXT.didNotReceive.symbol](): SubstituteNode { this.handleMethod([0]) return this.proxy } - public mimick() { + public [sharedConstants.CONTEXT.mimick.symbol](_instance: unknown) { throw new Error('Mimick is not implemented yet') } - public clearSubstitute(clearType: ClearType = constants.CLEAR.all): void { - this.handleMethod([clearType]) - const filter = clearTypeToFilterMap[clearType] + public [sharedConstants.CONTEXT.clearReceivedCalls.symbol](): void { + this.handleMethod([]) + + const filter = (node: SubstituteNode) => is.CONTEXT.none(node.context) this.recorder.clearRecords(filter) } @@ -142,9 +174,10 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu return types.isProxy(this) ? this[inspect.custom](...args) : this.printableForm(...args) } - private assignContext(context: SubstituteContext): void { - if (!is.method.substitute(context)) throw new Error(`Cannot assign context for property ${context.toString()}`) - this._context = context + private assignContext(context: SubstituteMethod): void { + this._context = typeof context === 'string' ? + transform.rawSymbolContextMap[context] : + context } private reattemptSubstitutionExecution(): SubstituteNode | any { @@ -161,24 +194,32 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu } 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 sharedConstants.CONTEXT.throws.symbol: case 'throws': throw substitutionValue + case sharedConstants.CONTEXT.mimicks.symbol: case 'mimicks': if (is.PROPERTY.property(this.propertyType)) return substitutionValue() if (!contextArguments.hasArguments()) throw new TypeError('Context arguments cannot be undefined') return substitutionValue(...contextArguments.value) + case sharedConstants.CONTEXT.resolves.symbol: case 'resolves': return Promise.resolve(substitutionValue) + case sharedConstants.CONTEXT.rejects.symbol: case 'rejects': return Promise.reject(substitutionValue) + case sharedConstants.CONTEXT.returns.symbol: case 'returns': return substitutionValue default: @@ -187,17 +228,24 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu } private executeAssertion(): void | never { - if (!this.hasDepthOfAtLeast(2)) throw new Error('Not possible') - if (!this.parent.recordedArguments.hasArguments()) throw new TypeError('Parent args') - const expectedCount: number | undefined = this.parent.recordedArguments.value[0] ?? undefined + 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 withContext = this.parent.property === sharedConstants.CONTEXT.received.symbol + const withContext = false + const siblings = [...this.getAllSiblings().filter(n => (withContext || !n.hasContext) && n.accessorType === this.accessorType)] const hasBeenCalled = siblings.length > 0 const hasSiblingOfSamePropertyType = siblings.some(sibling => sibling.propertyType === this.propertyType) const allRecordedArguments = siblings.map(sibling => sibling.recordedArguments) - if ( !hasBeenCalled && (!finiteExpectation || expectedCount > 0) @@ -254,30 +302,35 @@ 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 === Symbol.toPrimitive || property === 'then' || property === 'toJSON' + return property === instance || property === inspect.custom || property === Symbol.toPrimitive || property === 'then' || property === 'toJSON' } private evaluateSpecialProperty(property: SpecialProperty) { switch (property) { - case SubstituteNode.instance: + case instance: return this case 'toJSON': case inspect.custom: case Symbol.toPrimitive: 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.`) } } private printableForm(_: number, options: InspectOptions): string { return this.isRoot() ? stringify.rootNode(this, inspect(this.recorder, options)) : stringify.node(this, this.child, options) } -} \ No newline at end of file +} diff --git a/src/SubstituteNodeBase.ts b/src/internals/SubstituteNodeBase.ts similarity index 100% rename from src/SubstituteNodeBase.ts rename to src/internals/SubstituteNodeBase.ts diff --git a/src/internals/Types.ts b/src/internals/Types.ts new file mode 100644 index 0000000..560cf01 --- /dev/null +++ b/src/internals/Types.ts @@ -0,0 +1,42 @@ +import { RecordedArguments } from './RecordedArguments' +import type { constants } from './Constants' +import type { constants as sharedConstants } from '../shared' + +type ContextMap = typeof sharedConstants.CONTEXT +type Unfold = T[keyof T] + +export type ContextReceived = Unfold +export type ContextDidNotReceive = Unfold +export type ContextClearReceivedCalls = Unfold +export type ContextMimick = Unfold +export type ContextMimicks = Unfold +export type ContextThrows = Unfold +export type ContextReturns = Unfold +export type ContextResolves = Unfold +export type ContextRejects = Unfold + +export type AssertionMethod = ContextReceived | ContextDidNotReceive +export type ConfigurationMethod = ContextClearReceivedCalls | ContextMimick +export type SubstitutionMethod = ContextMimicks | ContextThrows | ContextReturns | ContextResolves | ContextRejects +export type ContextNone = Unfold + +export type SubstituteMethod = AssertionMethod | ConfigurationMethod | SubstitutionMethod +export type SubstituteContext = Exclude +// export type SubstituteContext2 = ContextMap[keyof ContextMap]['symbol'] +// export type ClearType = typeof constants.CLEAR[keyof typeof constants.CLEAR]['raw'] + +// export type ClearType = 'all' | 'receivedCalls' | 'substituteValues' +type PropertyType = typeof constants.PROPERTY.method | typeof constants.PROPERTY.property +type AccessorType = typeof constants.ACCESSOR.get | typeof constants.ACCESSOR.set +type SubstituteExceptionType = typeof constants.EXCEPTION.callCountMismatch | typeof constants.EXCEPTION.propertyNotMocked + +export type FilterFunction = (item: T) => boolean + +export type { PropertyType, AccessorType, SubstituteExceptionType } +export type SubstituteNodeModel = { + propertyType: PropertyType + property: PropertyKey + context: SubstituteContext + recordedArguments: RecordedArguments + stack?: string +} diff --git a/src/internals/index.ts b/src/internals/index.ts new file mode 100644 index 0000000..87c8170 --- /dev/null +++ b/src/internals/index.ts @@ -0,0 +1 @@ +export * from './SubstituteNode' diff --git a/src/internals/utilities/Guards.ts b/src/internals/utilities/Guards.ts new file mode 100644 index 0000000..bb6baa0 --- /dev/null +++ b/src/internals/utilities/Guards.ts @@ -0,0 +1,73 @@ +import { AssertionMethod, ConfigurationMethod, SubstituteMethod, SubstitutionMethod, SubstituteContext, PropertyType, ContextNone, ContextReceived, ContextDidNotReceive, ContextClearReceivedCalls, ContextMimick, ContextMimicks, ContextThrows, ContextReturns, ContextResolves, ContextRejects } from '../Types' +import { constants } from '../Constants' +import { constants as sharedConstants } from '../../shared' + +const isAssertionMethod = (property: PropertyKey): property is AssertionMethod => + property === sharedConstants.CONTEXT.received.raw || + property === sharedConstants.CONTEXT.received.symbol || + property === sharedConstants.CONTEXT.didNotReceive.raw || + property === sharedConstants.CONTEXT.didNotReceive.symbol +const isConfigurationMethod = (property: PropertyKey): property is ConfigurationMethod => + property === sharedConstants.CONTEXT.clearReceivedCalls.raw || + property === sharedConstants.CONTEXT.clearReceivedCalls.symbol || + property === sharedConstants.CONTEXT.mimick.raw || + property === sharedConstants.CONTEXT.mimick.symbol +const isSubstitutionMethod = (property: PropertyKey): property is SubstitutionMethod => + property === sharedConstants.CONTEXT.mimicks.raw || + property === sharedConstants.CONTEXT.mimicks.symbol || + property === sharedConstants.CONTEXT.returns.raw || + property === sharedConstants.CONTEXT.returns.symbol || + property === sharedConstants.CONTEXT.throws.raw || + property === sharedConstants.CONTEXT.throws.symbol || + property === sharedConstants.CONTEXT.resolves.raw || + property === sharedConstants.CONTEXT.resolves.symbol || + property === sharedConstants.CONTEXT.rejects.raw || + property === sharedConstants.CONTEXT.rejects.symbol +const isSubstituteMethod = (property: PropertyKey): property is SubstituteMethod => + isSubstitutionMethod(property) || isConfigurationMethod(property) || isAssertionMethod(property) + +const isPropertyProperty = (value: PropertyType): value is (typeof constants['PROPERTY']['property']) => value === constants.PROPERTY.property +const isPropertyMethod = (value: PropertyType): value is (typeof constants['PROPERTY']['method']) => value === constants.PROPERTY.method + +const isContextNone = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.none.symbol +const isContextReceived = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.received.symbol +const isContextDidNotReceive = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.didNotReceive.symbol +const isContextClearSubstitute = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.clearReceivedCalls.symbol +const isContextMimick = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.mimick.symbol +const isContextMimicks = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.mimicks.symbol +const isContextThrows = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.throws.symbol +const isContextReturns = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.returns.symbol +const isContextResolves = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.resolves.symbol +const isContextRejects = (value: SubstituteContext): value is Exclude => value === sharedConstants.CONTEXT.rejects.symbol +const isContextSubstitution = (value: SubstituteContext): value is Exclude => typeof value !== 'string' && isSubstitutionMethod(value) +const isContextAssertion = (value: SubstituteContext): value is Exclude => typeof value !== 'string' && isAssertionMethod(value) + +const isContextValue = (property: PropertyKey): property is SubstituteContext => typeof property !== 'string' && isSubstituteMethod(property) + +export const method = { + assertion: isAssertionMethod, + configuration: isConfigurationMethod, + substitution: isSubstitutionMethod, + substitute: isSubstituteMethod, + contextValue: isContextValue +} + +export const PROPERTY = { + property: isPropertyProperty, + method: isPropertyMethod +} + +export const CONTEXT = { + none: isContextNone, + received: isContextReceived, + didNotReceive: isContextDidNotReceive, + clearSubstitute: isContextClearSubstitute, + mimick: isContextMimick, + mimicks: isContextMimicks, + throws: isContextThrows, + returns: isContextReturns, + resolves: isContextResolves, + rejects: isContextRejects, + substitution: isContextSubstitution, + assertion: isContextAssertion +} diff --git a/src/utilities/Stringify.ts b/src/internals/utilities/Stringify.ts similarity index 95% rename from src/utilities/Stringify.ts rename to src/internals/utilities/Stringify.ts index b4ec0bb..099d7d9 100644 --- a/src/utilities/Stringify.ts +++ b/src/internals/utilities/Stringify.ts @@ -40,7 +40,7 @@ const stringifyExpectation = (expected: { count: number | undefined, call: Subst const textBuilder = new TextBuilder() textBuilder.add(expected.count === undefined ? '1 or more' : expected.count.toString(), t => t.bold()) .add(' ') - .add(expected.call.propertyType, t => t.bold()) + .add(expected.call.propertyType.description!, t => t.bold()) .add(plurify(' call', expected.count), t => t.bold()) .add(' matching ') .addParts(...stringifyCall({ callPath: expected.call.property.toString() })(expected.call).map(t => t.bold())) @@ -76,7 +76,7 @@ const stringifyNode = (node: SubstituteNodeModel, childNode: SubstituteNodeModel '' const s = hasContext ? `${label}${inspect(childNode?.recordedArguments, options)}` : '' - return `${node.propertyType}<${node.property.toString()}>: ${args}${s}` + return `${node.propertyType.description}<${node.property.toString()}>: ${args}${s}` } export const stringify = { diff --git a/src/utilities/TextBuilder.ts b/src/internals/utilities/TextBuilder.ts similarity index 100% rename from src/utilities/TextBuilder.ts rename to src/internals/utilities/TextBuilder.ts diff --git a/src/internals/utilities/Transformations.ts b/src/internals/utilities/Transformations.ts new file mode 100644 index 0000000..d593e98 --- /dev/null +++ b/src/internals/utilities/Transformations.ts @@ -0,0 +1,17 @@ +import { constants as sharedConstants } from '../../shared' + +type A = typeof sharedConstants.CONTEXT[Exclude] +type B = A['raw'] +type C = A['symbol'] + +export const rawSymbolContextMap: Record = { + [sharedConstants.CONTEXT.received.raw]: sharedConstants.CONTEXT.received.symbol, + [sharedConstants.CONTEXT.didNotReceive.raw]: sharedConstants.CONTEXT.didNotReceive.symbol, + [sharedConstants.CONTEXT.clearReceivedCalls.raw]: sharedConstants.CONTEXT.clearReceivedCalls.symbol, + [sharedConstants.CONTEXT.mimick.raw]: sharedConstants.CONTEXT.mimick.symbol, + [sharedConstants.CONTEXT.mimicks.raw]: sharedConstants.CONTEXT.mimicks.symbol, + [sharedConstants.CONTEXT.throws.raw]: sharedConstants.CONTEXT.throws.symbol, + [sharedConstants.CONTEXT.returns.raw]: sharedConstants.CONTEXT.returns.symbol, + [sharedConstants.CONTEXT.resolves.raw]: sharedConstants.CONTEXT.resolves.symbol, + [sharedConstants.CONTEXT.rejects.raw]: sharedConstants.CONTEXT.rejects.symbol, +} diff --git a/src/utilities/index.ts b/src/internals/utilities/index.ts similarity index 65% rename from src/utilities/index.ts rename to src/internals/utilities/index.ts index 6448fa3..4c92f76 100644 --- a/src/utilities/index.ts +++ b/src/internals/utilities/index.ts @@ -1,4 +1,4 @@ export * from './TextBuilder' export * from './Stringify' -export * from './Constants' export * as is from './Guards' +export * as transform from './Transformations' diff --git a/src/shared/Arguments.ts b/src/shared/Arguments.ts new file mode 100644 index 0000000..267c69c --- /dev/null +++ b/src/shared/Arguments.ts @@ -0,0 +1,44 @@ +export type PredicateFunction = (arg: T) => boolean +export type ArgumentOptions = { + inverseMatch?: boolean +} +class BaseArgument { + private _description: string + constructor( + description: string, + private _matchingFunction: PredicateFunction, + private _options?: ArgumentOptions + ) { + this._description = `${this._options?.inverseMatch ? 'Not ' : ''}${description}` + } + + matches(arg: T) { + const inverseMatch = this._options?.inverseMatch ?? false + return inverseMatch ? !this._matchingFunction(arg) : this._matchingFunction(arg) + } + + toString() { + return this._description + } + + [Symbol.for('nodejs.util.inspect.custom')]() { + return this._description + } +} + +export class Argument extends BaseArgument { + private readonly _type = 'SingleArgument'; + get type(): 'SingleArgument' { + return this._type + } +} + +export class AllArguments extends BaseArgument { + private readonly _type = 'AllArguments'; + constructor() { + super('Arg.all{}', () => true, {}) + } + get type(): 'AllArguments' { + return this._type // TODO: Needed? + } +} diff --git a/src/shared/Constants.ts b/src/shared/Constants.ts new file mode 100644 index 0000000..2ba6616 --- /dev/null +++ b/src/shared/Constants.ts @@ -0,0 +1,75 @@ +type ValueToMap = { [key in T as Uncapitalize]: key } + +export type AssertionMethodRaw = 'received' | 'didNotReceive' +export type ConfigurationMethodRaw = 'clearReceivedCalls' | 'mimick' +export type SubstitutionMethodRaw = 'mimicks' | 'throws' | 'returns' | 'resolves' | 'rejects' + +const contextMethodTypes: ValueToMap = { + received: 'received', + didNotReceive: 'didNotReceive', + clearReceivedCalls: 'clearReceivedCalls', + mimick: 'mimick', + mimicks: 'mimicks', + throws: 'throws', + returns: 'returns', + resolves: 'resolves', + rejects: 'rejects' +} + +const received = Symbol(contextMethodTypes.received) +const didNotReceive = Symbol(contextMethodTypes.didNotReceive) +const clearReceivedCalls = Symbol(contextMethodTypes.clearReceivedCalls) +const mimick = Symbol(contextMethodTypes.mimick) +const mimicks = Symbol(contextMethodTypes.mimicks) +const throws = Symbol(contextMethodTypes.throws) +const returns = Symbol(contextMethodTypes.returns) +const resolves = Symbol(contextMethodTypes.resolves) +const rejects = Symbol(contextMethodTypes.rejects) +const none = Symbol('none') + +const contextTypes = { + none: { + raw: 'none', + symbol: none + }, + received: { + raw: contextMethodTypes.received, + symbol: received + }, + didNotReceive: { + raw: contextMethodTypes.didNotReceive, + symbol: didNotReceive + }, + clearReceivedCalls: { + raw: contextMethodTypes.clearReceivedCalls, + symbol: clearReceivedCalls + }, + mimick: { + raw: contextMethodTypes.mimick, + symbol: mimick + }, + mimicks: { + raw: contextMethodTypes.mimicks, + symbol: mimicks + }, + throws: { + raw: contextMethodTypes.throws, + symbol: throws + }, + returns: { + raw: contextMethodTypes.returns, + symbol: returns + }, + resolves: { + raw: contextMethodTypes.resolves, + symbol: resolves + }, + rejects: { + raw: contextMethodTypes.rejects, + symbol: rejects + } +} as const + +export const constants = { + CONTEXT: contextTypes +} diff --git a/src/shared/index.ts b/src/shared/index.ts new file mode 100644 index 0000000..c827a99 --- /dev/null +++ b/src/shared/index.ts @@ -0,0 +1,2 @@ +export * from './Constants' +export * from './Arguments' diff --git a/src/utilities/Constants.ts b/src/utilities/Constants.ts deleted file mode 100644 index ae7f42f..0000000 --- a/src/utilities/Constants.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { AccessorType, ClearType, PropertyType, SubstituteContext, SubstituteExceptionType } from '../Types' - -type ValueToMap = { [key in T as Uncapitalize]: key } -const propertyTypes: ValueToMap = { - method: 'method', - property: 'property' -} -const accessorTypes: ValueToMap = { - get: 'get', - set: 'set' -} -const clearTypes: ValueToMap = { - all: 'all', - receivedCalls: 'receivedCalls', - substituteValues: 'substituteValues' -} -const contextTypes: ValueToMap = { - none: 'none', - received: 'received', - didNotReceive: 'didNotReceive', - clearSubstitute: 'clearSubstitute', - mimick: 'mimick', - mimicks: 'mimicks', - throws: 'throws', - returns: 'returns', - resolves: 'resolves', - rejects: 'rejects' -} -const SubstituteExceptionTypes: ValueToMap = { - callCountMismatch: 'CallCountMismatch', - propertyNotMocked: 'PropertyNotMocked' -} - -export const constants = { - PROPERTY: propertyTypes, - ACCESSOR: accessorTypes, - CLEAR: clearTypes, - CONTEXT: contextTypes, - EXCEPTION: SubstituteExceptionTypes -} \ No newline at end of file diff --git a/src/utilities/Guards.ts b/src/utilities/Guards.ts deleted file mode 100644 index 26db312..0000000 --- a/src/utilities/Guards.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { AssertionMethod, ClearType, ConfigurationMethod, PropertyType, SubstituteMethod, SubstitutionMethod, SubstituteContext } from '../Types' -import { constants } from './Constants' - -const isAssertionMethod = (property: PropertyKey): property is AssertionMethod => - property === 'received' || property === 'didNotReceive' -const isConfigurationMethod = (property: PropertyKey): property is ConfigurationMethod => property === 'clearSubstitute' || property === 'mimick' -const isSubstitutionMethod = (property: PropertyKey): property is SubstitutionMethod => - property === 'mimicks' || property === 'returns' || property === 'throws' || property === 'resolves' || property === 'rejects' -const isSubstituteMethod = (property: PropertyKey): property is SubstituteMethod => - isSubstitutionMethod(property) || isConfigurationMethod(property) || isAssertionMethod(property) - -const isPropertyProperty = (value: PropertyType): value is (typeof constants['PROPERTY']['property']) => value === constants.PROPERTY.property -const isPropertyMethod = (value: PropertyType): value is (typeof constants['PROPERTY']['method']) => value === constants.PROPERTY.method - -const isClearAll = (value: ClearType): value is (typeof constants['CLEAR']['all']) => value === constants.CLEAR.all -const isClearReceivedCalls = (value: ClearType): value is (typeof constants['CLEAR']['receivedCalls']) => value === constants.CLEAR.receivedCalls -const isClearSubstituteValues = (value: ClearType): value is (typeof constants['CLEAR']['substituteValues']) => value === constants.CLEAR.substituteValues - -const isContextNone = (value: SubstituteContext): value is (typeof constants['CONTEXT']['none']) => value === constants.CONTEXT.none -const isContextReceived = (value: SubstituteContext): value is (typeof constants['CONTEXT']['received']) => value === constants.CONTEXT.received -const isContextDidNotReceive = (value: SubstituteContext): value is (typeof constants['CONTEXT']['didNotReceive']) => value === constants.CONTEXT.didNotReceive -const isContextClearSubstitute = (value: SubstituteContext): value is (typeof constants['CONTEXT']['clearSubstitute']) => value === constants.CONTEXT.clearSubstitute -const isContextMimick = (value: SubstituteContext): value is (typeof constants['CONTEXT']['mimick']) => value === constants.CONTEXT.mimick -const isContextMimicks = (value: SubstituteContext): value is (typeof constants['CONTEXT']['mimicks']) => value === constants.CONTEXT.mimicks -const isContextThrows = (value: SubstituteContext): value is (typeof constants['CONTEXT']['throws']) => value === constants.CONTEXT.throws -const isContextReturns = (value: SubstituteContext): value is (typeof constants['CONTEXT']['returns']) => value === constants.CONTEXT.returns -const isContextResolves = (value: SubstituteContext): value is (typeof constants['CONTEXT']['resolves']) => value === constants.CONTEXT.resolves -const isContextRejects = (value: SubstituteContext): value is (typeof constants['CONTEXT']['rejects']) => value === constants.CONTEXT.rejects -const isContextSubstitution = (value: SubstituteContext): value is SubstitutionMethod => isSubstitutionMethod(value) -const isContextAssertion = (value: SubstituteContext): value is AssertionMethod => isAssertionMethod(value) - -export const method = { - assertion: isAssertionMethod, - configuration: isConfigurationMethod, - substitution: isSubstitutionMethod, - substitute: isSubstituteMethod, -} - -export const PROPERTY = { - property: isPropertyProperty, - method: isPropertyMethod -} - -export const CLEAR = { - all: isClearAll, - receivedCalls: isClearReceivedCalls, - substituteValues: isClearSubstituteValues -} - -export const CONTEXT = { - none: isContextNone, - received: isContextReceived, - didNotReceive: isContextDidNotReceive, - clearSubstitute: isContextClearSubstitute, - mimick: isContextMimick, - mimicks: isContextMimicks, - throws: isContextThrows, - returns: isContextReturns, - resolves: isContextResolves, - rejects: isContextRejects, - substitution: isContextSubstitution, - assertion: isContextAssertion -} -