From a2bb99ffbd153e5c5b6f42b7bb38a01691005a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=B6hlmann?= Date: Fri, 7 Oct 2022 21:00:37 +0200 Subject: [PATCH] refactor substitute core --- src/Substitute.ts | 63 +++-------------- src/SubstituteBase.ts | 35 ---------- src/SubstituteNode.ts | 139 +++++++++++++++++++++++++++----------- src/SubstituteNodeBase.ts | 73 +++++++++++--------- src/SubstituteProxy.ts | 32 --------- 5 files changed, 152 insertions(+), 190 deletions(-) delete mode 100644 src/SubstituteBase.ts delete mode 100644 src/SubstituteProxy.ts diff --git a/src/Substitute.ts b/src/Substitute.ts index 21afa3f..a522d78 100644 --- a/src/Substitute.ts +++ b/src/Substitute.ts @@ -1,49 +1,25 @@ -import { inspect, InspectOptions } from 'util' +import { DisabledSubstituteObject, ObjectSubstitute } from './Transformations' +import { SubstituteNode } from './SubstituteNode' -import { SubstituteBase } from './SubstituteBase' -import { createSubstituteProxy } from './SubstituteProxy' -import { Recorder } from './Recorder' -import { DisabledSubstituteObject, ObjectSubstitute, OmitProxyMethods } from './Transformations' - -export type SubstituteOf = ObjectSubstitute, T> & T -type Instantiable = { [SubstituteBase.instance]?: T } - -export class Substitute extends SubstituteBase { - private _proxy: Substitute - private _recorder: Recorder = new Recorder() - private _context: { disableAssertions: boolean } = { disableAssertions: false } - - constructor() { - super() - this._proxy = createSubstituteProxy( - this, - { - get: (target, _property, _, node) => { - if (target.context.disableAssertions) node.disableAssertions() - } - // apply: (target, _, args, __, proxy) => { - // const rootProperty = proxy.get(target, '()', proxy) TODO: Implement to support callable interfaces - // return Reflect.apply(rootProperty, rootProperty, args) - // } - } - ) - } +export type SubstituteOf = ObjectSubstitute & T +type InstantiableSubstitute = SubstituteOf & { [SubstituteNode.instance]?: SubstituteNode } +export class Substitute { static for(): SubstituteOf { - const substitute = new this() + const substitute = SubstituteNode.createRoot() return substitute.proxy as unknown as SubstituteOf } - static disableFor & Instantiable>(substituteProxy: T): DisabledSubstituteObject { - const substitute = substituteProxy[SubstituteBase.instance] + static disableFor(substituteProxy: T): DisabledSubstituteObject { + const substitute = substituteProxy[SubstituteNode.instance] const disableProxy = < TParameters extends unknown[], TReturnType extends unknown >(reflection: (...args: TParameters) => TReturnType): typeof reflection => (...args) => { - substitute.context.disableAssertions = true + substitute.rootContext.substituteMethodsEnabled = false const reflectionResult = reflection(...args) - substitute.context.disableAssertions = false + substitute.rootContext.substituteMethodsEnabled = true return reflectionResult } @@ -59,23 +35,4 @@ export class Substitute extends SubstituteBase { } }) as DisabledSubstituteObject } - - public get proxy() { - return this._proxy - } - - public get recorder() { - return this._recorder - } - - public get context() { - return this._context - } - - protected printableForm(_: number, options: InspectOptions): string { - const records = inspect(this.recorder, options) - - const instanceName = 'Substitute' // Substitute - return instanceName + ' {' + records + '\n}' - } } \ No newline at end of file diff --git a/src/SubstituteBase.ts b/src/SubstituteBase.ts deleted file mode 100644 index a0eb4c0..0000000 --- a/src/SubstituteBase.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { inspect, InspectOptions, types } from 'util' -import { SubstituteException } from './SubstituteException' - -const instance = Symbol('Substitute:Instance') -type SpecialProperty = typeof instance | typeof inspect.custom | 'then' -export abstract class SubstituteBase extends Function { - constructor() { - super() - } - - static instance: typeof instance = instance - - protected isSpecialProperty(property: PropertyKey): property is SpecialProperty { - return property === SubstituteBase.instance || property === inspect.custom || property === 'then' - } - - protected evaluateSpecialProperty(property: SpecialProperty) { - switch (property) { - case SubstituteBase.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`) - } - } - - protected abstract printableForm(_: number, options: InspectOptions): string - - public [inspect.custom](...args: [_: number, options: InspectOptions]): string { - return types.isProxy(this) ? this[inspect.custom](...args) : this.printableForm(...args) - } -} \ No newline at end of file diff --git a/src/SubstituteNode.ts b/src/SubstituteNode.ts index 800603a..a15236f 100644 --- a/src/SubstituteNode.ts +++ b/src/SubstituteNode.ts @@ -1,53 +1,79 @@ -import { inspect, InspectOptions } from 'util' +import { inspect, InspectOptions, types } from 'util' -import { PropertyType, isSubstitutionMethod, isAssertionMethod, AssertionMethod, SubstitutionMethod, textModifier, ConfigurationMethod, isSubstituteMethod } from './Utilities' -import { SubstituteException } from './SubstituteException' -import { RecordedArguments } from './RecordedArguments' import { SubstituteNodeBase } from './SubstituteNodeBase' -import { SubstituteBase } from './SubstituteBase' -import { createSubstituteProxy } from './SubstituteProxy' -import { ClearType } from './Transformations' +import { RecordedArguments } from './RecordedArguments' +import { ClearType as ClearTypeMap, PropertyType as PropertyTypeMap, isAssertionMethod, isSubstituteMethod, isSubstitutionMethod, textModifier } from './Utilities' +import { SubstituteException } from './SubstituteException' +import type { FilterFunction, SubstituteContext, SubstitutionMethod, ClearType, PropertyType } from './Types' + +const instance = Symbol('Substitute:Instance') +type SpecialProperty = typeof instance | typeof inspect.custom | 'then' +type RootContext = { substituteMethodsEnabled: boolean } -type SubstituteContext = SubstitutionMethod | AssertionMethod | ConfigurationMethod | 'none' -const clearTypeToFilterMap: Record boolean> = { +const clearTypeToFilterMap: Record> = { all: () => true, receivedCalls: node => !node.hasContext, substituteValues: node => node.isSubstitution } -export class SubstituteNode extends SubstituteNodeBase { +export class SubstituteNode extends SubstituteNodeBase { private _proxy: SubstituteNode - private _propertyType: PropertyType = PropertyType.property + private _rootContext: RootContext + + private _propertyType: PropertyType = PropertyTypeMap.Property private _accessorType: 'get' | 'set' = 'get' private _recordedArguments: RecordedArguments = RecordedArguments.none() private _context: SubstituteContext = 'none' - private _disabledAssertions: boolean = false + private _disabledSubstituteMethods: boolean = false - constructor(property: PropertyKey, parent: SubstituteNode | SubstituteBase) { - super(property, parent) - this._proxy = createSubstituteProxy( + private constructor(key: PropertyKey, parent?: SubstituteNode) { + super(key, parent) + if (this.isRoot()) this._rootContext = { substituteMethodsEnabled: true } + if (this.isIntermediateNode()) this._rootContext = this.root.rootContext + this._proxy = new Proxy( this, { - get: (node, _, __, nextNode) => { - if (node.isAssertion) nextNode.executeAssertion() + get: function (target, property) { + if (target.isSpecialProperty(property)) return target.evaluateSpecialProperty(property) + const newNode = SubstituteNode.createChild(property, target) + if (target.isRoot() && !target.rootContext.substituteMethodsEnabled) newNode.disableSubstituteMethods() + if (target.isIntermediateNode() && target.isAssertion) newNode.executeAssertion() + return newNode.read() }, - set: (node, _, __, ___, nextNode) => { - if (node.isAssertion) nextNode.executeAssertion() + set: function (target, property, value) { + const newNode = SubstituteNode.createChild(property, target) + newNode.write(value) + if (target.isAssertion) newNode.executeAssertion() + return true }, - apply: (node, _, rawArguments) => { - node.handleMethod(rawArguments) - if (node.context === 'clearSubstitute') return node.clear() - return node.parent?.isAssertion ?? false ? node.executeAssertion() : node.read() + apply: function (target, _thisArg, rawArguments) { + target.handleMethod(rawArguments) + if (target.hasContext) target.handleSpecialContext() + return (target.parent?.isAssertion ?? false) ? target.executeAssertion() : target.read() } } ) } + public static instance: typeof instance = instance + + public static createRoot(): SubstituteNode { + return new this('*Substitute') + } + + public static createChild(key: PropertyKey, parent: SubstituteNode): SubstituteNode { + return new this(key, parent) + } + public get proxy() { return this._proxy } + public get rootContext() { + return this._rootContext + } + get context(): SubstituteContext { return this._context } @@ -80,20 +106,20 @@ export class SubstituteNode extends SubstituteNodeBase { return this._recordedArguments } - public get disabledAssertions() { - return this._disabledAssertions + public get disabledSubstituteMethods() { + return this._disabledSubstituteMethods } public assignContext(context: SubstituteContext): void { this._context = context } - public disableAssertions() { - this._disabledAssertions = true + public disableSubstituteMethods() { + this._disabledSubstituteMethods = true } public read(): SubstituteNode | void | never { - if (this.parent?.isSubstitution ?? false) return + if ((this.parent?.isSubstitution ?? false) || this.context === 'clearSubstitute') return if (this.isAssertion) return this.proxy const mostSuitableSubstitution = this.getMostSuitableSubstitution() @@ -108,9 +134,9 @@ export class SubstituteNode extends SubstituteNodeBase { } public clear() { - const clearType: ClearType = this.recordedArguments.value[0] ?? 'all' - const filter = clearTypeToFilterMap[clearType] as (node: SubstituteNodeBase) => boolean - this.root.recorder.clearRecords(filter) + const clearType: ClearType = this.recordedArguments.value[0] ?? ClearTypeMap.All + const filter = clearTypeToFilterMap[clearType] + this.recorder.clearRecords(filter) } public executeSubstitution(contextArguments: RecordedArguments) { @@ -122,7 +148,7 @@ export class SubstituteNode extends SubstituteNodeBase { case 'throws': throw substitutionValue case 'mimicks': - const argumentsToApply = this.propertyType === PropertyType.property ? [] : contextArguments.value + const argumentsToApply = this.propertyType === PropertyTypeMap.Property ? [] : contextArguments.value return substitutionValue(...argumentsToApply) case 'resolves': return Promise.resolve(substitutionValue) @@ -136,8 +162,8 @@ export class SubstituteNode extends SubstituteNodeBase { } public executeAssertion(): void | never { - const siblings = [...this.getAllSiblings().filter(n => !n.hasContext && n.accessorType === this.accessorType)] if (!this.isIntermediateNode()) throw new Error('Not possible') + const siblings = [...this.getAllSiblings().filter(n => !n.hasContext && n.accessorType === this.accessorType)] const expectedCount = this.parent.recordedArguments.value[0] ?? undefined const finiteExpectation = expectedCount !== undefined @@ -168,14 +194,20 @@ export class SubstituteNode extends SubstituteNodeBase { } public handleMethod(rawArguments: any[]): void { - this._propertyType = PropertyType.method + this._propertyType = PropertyTypeMap.Method this._recordedArguments = RecordedArguments.from(rawArguments) - if (!isSubstituteMethod(this.property)) return + this.tryToAssignContext() + } + private tryToAssignContext() { + if (!isSubstituteMethod(this.property)) return if (this.isIntermediateNode() && isSubstitutionMethod(this.property)) return this.parent.assignContext(this.property) - if (this.disabledAssertions || !this.isHead()) return - + if (this.disabledSubstituteMethods) return this.assignContext(this.property) + } + + private handleSpecialContext(): void { + if (this.context === 'clearSubstitute') return this.clear() if (this.context === 'didNotReceive') this._recordedArguments = RecordedArguments.from([0]) } @@ -188,7 +220,38 @@ export class SubstituteNode extends SubstituteNodeBase { return sortedNodes[0] } - protected printableForm(_: number, options: InspectOptions): string { + private isSpecialProperty(property: PropertyKey): property is SpecialProperty { + return property === SubstituteNode.instance || property === inspect.custom || property === 'then' + } + + private evaluateSpecialProperty(property: SpecialProperty) { + switch (property) { + case SubstituteNode.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`) + } + } + + public [inspect.custom](...args: [_: number, options: InspectOptions]): string { + return types.isProxy(this) ? this[inspect.custom](...args) : this.printableForm(...args) + } + + private printableForm(_: number, options: InspectOptions): string { + return this.isRoot() ? this.printRootNode(options) : this.printNode(options) + } + + private printRootNode(options: InspectOptions): string { + const records = inspect(this.recorder, options) + const instanceName = '*Substitute' // Substitute + return instanceName + ' {' + records + '\n}' + } + + private printNode(options: InspectOptions): string { const hasContext = this.hasContext const args = inspect(this.recordedArguments, options) const label = this.isSubstitution diff --git a/src/SubstituteNodeBase.ts b/src/SubstituteNodeBase.ts index 21c9e4d..2c8acac 100644 --- a/src/SubstituteNodeBase.ts +++ b/src/SubstituteNodeBase.ts @@ -1,65 +1,74 @@ -import { SubstituteBase } from './SubstituteBase' -import { Substitute } from './Substitute' +import { Recorder } from './Recorder' import { RecordsSet } from './RecordsSet' -export abstract class SubstituteNodeBase> extends SubstituteBase { - private _parent?: T - private _child?: T - private _head: T & { parent: undefined } - private _root: Substitute +export abstract class SubstituteNodeBase extends Function { + private _root: this & { parent: undefined } + private _parent?: this + private _child?: this + private _depth: number - constructor(private _key: PropertyKey, caller: SubstituteBase) { - super() + private _recorder: Recorder - if (caller instanceof Substitute) { - caller.recorder.addIndexedRecord(this) - this._root = caller + constructor(private _key: PropertyKey, parent?: SubstituteNodeBase) { + super() + const shouldBeRoot = !this.isNode(parent) + if (shouldBeRoot) { + this._recorder = Recorder.withIdentityProperty('key') + this._root = this as this & { parent: undefined } + this._depth = 0 + return } - if (!(caller instanceof SubstituteNodeBase)) return - this._parent = caller as T - this._head = caller.head as T & { parent: undefined } - caller.child = this + parent.child = this + this._parent = parent + this._recorder = parent.recorder + this._root = parent.root + this._depth = parent.depth + 1 + if (this.parent === this.root) this.recorder.addIndexedRecord(this) + } + + private isNode(node?: SubstituteNodeBase): node is this { + return typeof node !== 'undefined' } - get key(): PropertyKey { + public get key(): PropertyKey { return this._key } - set parent(parent: T | undefined) { - this._parent = parent + public get recorder(): Recorder { + return this._recorder } - get parent(): T | undefined { - return this._parent + protected get parent(): this | undefined { + return this._parent as this } - set child(child: T) { + protected set child(child: this) { this._child = child } - get child(): T { + protected get child(): this { return this._child } - get head(): T & { parent: undefined } { - return this.isHead() ? this : this._head + protected get root(): this & { parent: undefined } { + return this._root } - protected get root(): Substitute { - return this.head._root + protected get depth(): number { + return this._depth } - protected isHead(): this is T & { parent: undefined } { + protected isRoot(): this is this & { parent: undefined } { return typeof this._parent === 'undefined' } - protected isIntermediateNode(): this is T & { parent: T } { - return !this.isHead() + protected isIntermediateNode(): this is this & { parent: ThisType } { + return !this.isRoot() } - protected getAllSiblings(): RecordsSet { - return this.root.recorder.getSiblingsOf(this) as RecordsSet + protected getAllSiblings(): RecordsSet { + return this.recorder.getSiblingsOf(this) } public abstract read(): void diff --git a/src/SubstituteProxy.ts b/src/SubstituteProxy.ts deleted file mode 100644 index c8e927c..0000000 --- a/src/SubstituteProxy.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { SubstituteBase } from './SubstituteBase' -import { SubstituteNode } from './SubstituteNode' - -type BeforeNodeExecutionHook = { - [Handler in keyof ProxyHandler]?: (...args: [..._: Parameters[Handler]>, node: SubstituteNode | undefined, proxy: ProxyHandler]) => void -} -export const createSubstituteProxy = ( - target: T, - beforeNodeExecutionHook: BeforeNodeExecutionHook -) => new Proxy( - target, - { - get: function (this: SubstituteBase, ...args) { - const [target, property] = args - if (target.isSpecialProperty(property)) return target.evaluateSpecialProperty(property) - const newNode = new SubstituteNode(property, target) - beforeNodeExecutionHook.get?.(...args, newNode, this) - return newNode.read() - }, - set: function (...args) { - const [target, property, value] = args - const newNode = new SubstituteNode(property, target) - newNode.write(value) - beforeNodeExecutionHook.set?.(...args, newNode, this) - return true - }, - apply: function (...args) { - return beforeNodeExecutionHook.apply?.(...args, undefined, this) - - } - } -) \ No newline at end of file