diff --git a/src/transformer/descriptor/helper/helper.ts b/src/transformer/descriptor/helper/helper.ts index ea86d74b7..b10f8b328 100644 --- a/src/transformer/descriptor/helper/helper.ts +++ b/src/transformer/descriptor/helper/helper.ts @@ -60,10 +60,10 @@ export namespace TypescriptHelper { return declarations[0]; } - export function GetParameterOfNode(node: ts.EntityName): ts.NodeArray { + export function GetParameterOfNode(node: ts.EntityName): ts.NodeArray | undefined { const declaration: ts.Declaration = GetDeclarationFromNode(node); - const { typeParameters = ts.createNodeArray([]) }: Declaration = (declaration as Declaration); + const { typeParameters }: Declaration = (declaration as Declaration); return typeParameters; } diff --git a/src/transformer/genericDeclaration/genericDeclaration.ts b/src/transformer/genericDeclaration/genericDeclaration.ts index 2320d43b6..2a619c120 100644 --- a/src/transformer/genericDeclaration/genericDeclaration.ts +++ b/src/transformer/genericDeclaration/genericDeclaration.ts @@ -2,14 +2,26 @@ import * as ts from 'typescript'; import { GetDescriptor } from '../descriptor/descriptor'; import { TypescriptHelper } from '../descriptor/helper/helper'; import { TypescriptCreator } from '../helper/creator'; -import { TransformerLogger } from '../logger/transformerLogger'; -import { MockDefiner } from '../mockDefiner/mockDefiner'; -import { MockIdentifierGenericParameterIds, MockIdentifierGenericParameterValue } from '../mockIdentifier/mockIdentifier'; +import { MockIdentifierGenericParameterIds, MockIdentifierGenericParameterValue, MockPrivatePrefix } from '../mockIdentifier/mockIdentifier'; import { Scope } from '../scope/scope'; import { IGenericDeclaration } from './genericDeclaration.interface'; import { GenericDeclarationSupported } from './genericDeclarationSupported'; import { GenericParameter } from './genericParameter'; +function isInstantiable(node: ts.Declaration | undefined): boolean { + let actualType: ts.Node | undefined = node; + + if (!actualType) { + return false; + } + + while (ts.isTypeAliasDeclaration(actualType)) { + actualType = actualType.type; + } + + return !TypescriptHelper.IsLiteralOrPrimitive(actualType); +} + export function GenericDeclaration(scope: Scope): IGenericDeclaration { const generics: GenericParameter[] = []; @@ -17,7 +29,7 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration { return !!node.typeArguments && !!node.typeArguments[index]; } - function getGenericNode(node: ts.TypeReferenceNode | ts.ExpressionWithTypeArguments, nodeDeclaration: ts.TypeParameterDeclaration, index: number): ts.Node { + function getGenericNode(node: ts.TypeReferenceNode | ts.ExpressionWithTypeArguments, nodeDeclaration: ts.TypeParameterDeclaration, index: number): ts.TypeNode { if (isGenericProvided(node, index)) { return node.typeArguments[index]; } @@ -40,11 +52,69 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration { } } - function createGenericParameter(ownerKey: string, nodeOwnerParameter: ts.TypeParameterDeclaration, genericDescriptor: ts.Expression): GenericParameter { + function createGenericParameter( + ownerKey: string, + nodeOwnerParameter: ts.TypeParameterDeclaration, + genericDescriptor: ts.Expression | undefined, + instantiable: boolean, + nextScope: Scope, + ): GenericParameter { const uniqueName: string = ownerKey + nodeOwnerParameter.name.escapedText.toString(); - const genericFunction: ts.FunctionExpression = TypescriptCreator.createFunctionExpression(ts.createBlock( - [ts.createReturn(genericDescriptor)], - )); + + const genericValueDescriptor: ts.Expression = ((): ts.Expression => { + if (!instantiable) { + return genericDescriptor || ts.createNull(); + } + + const scopeOwnerName: string = [nextScope.currentMockKey, uniqueName].join('_').replace(/@/g, ''); + const ownerReference: ts.Identifier = ts.createIdentifier([MockPrivatePrefix, scopeOwnerName].join('')); + + return ts.createNew( + genericDescriptor ? TypescriptCreator.createFunctionExpression( + ts.createBlock( + [ + TypescriptCreator.createVariableStatement([ + TypescriptCreator.createVariableDeclaration(ownerReference, ts.createIdentifier('this')), + ]), + ts.createExpressionStatement( + ts.createCall( + ts.createPropertyAccess( + ts.createIdentifier('Object'), + ts.createIdentifier('defineProperties'), + ), + undefined, + [ + ts.createIdentifier('this'), + ts.createCall( + ts.createPropertyAccess( + ts.createIdentifier('Object'), + ts.createIdentifier('getOwnPropertyDescriptors'), + ), + undefined, + [genericDescriptor] + ), + ] + ), + ), + ], + ), + ) : ts.createPropertyAccess( + ownerReference, + ts.createIdentifier('constructor'), + ), + undefined, + undefined, + ); + })(); + + const genericFunction: ts.FunctionExpression = + TypescriptCreator.createFunctionExpression( + ts.createBlock([ + ts.createReturn( + genericValueDescriptor, + ), + ]), + ); return { ids: [uniqueName], @@ -54,9 +124,9 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration { return { addFromTypeReferenceNode(node: ts.TypeReferenceNode, declarationKey: string): void { - const typeParameterDeclarations: ts.NodeArray = TypescriptHelper.GetParameterOfNode(node.typeName); + const typeParameterDeclarations: ts.NodeArray | undefined = TypescriptHelper.GetParameterOfNode(node.typeName); - if (!typeParameterDeclarations) { + if (!typeParameterDeclarations?.length) { return; } @@ -66,7 +136,10 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration { const genericParameter: GenericParameter = createGenericParameter( declarationKey, typeParameterDeclarations[index], - GetDescriptor(genericNode, scope)); + GetDescriptor(genericNode, scope), + false, + scope, + ); generics.push(genericParameter); }); @@ -77,26 +150,22 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration { extensionDeclaration: GenericDeclarationSupported, extensionDeclarationKey: string, extension: ts.ExpressionWithTypeArguments): void { + const nextScope: Scope = scope.newNestedScope(declarationKey); + const extensionDeclarationTypeParameters: ts.NodeArray | undefined = extensionDeclaration.typeParameters; - if (!extensionDeclarationTypeParameters) { + if (!extensionDeclarationTypeParameters?.length) { return; } extensionDeclarationTypeParameters.reduce((acc: GenericParameter[], declaration: ts.TypeParameterDeclaration, index: number) => { const genericNode: ts.Node = getGenericNode(extension, declaration, index); + let typeParameterDeclaration: ts.Declaration | undefined; + let genericValueDescriptor: ts.Expression | undefined; + if (ts.isTypeReferenceNode(genericNode)) { - const typeParameterDeclaration: ts.Declaration = TypescriptHelper.GetDeclarationFromNode(genericNode.typeName); - - const isExtendingItself: boolean = MockDefiner.instance.getDeclarationKeyMap(typeParameterDeclaration) === declarationKey; - if (isExtendingItself) { - // FIXME: Currently, circular generics aren't supported. See - // https://github.com/Typescript-TDD/ts-auto-mock/pull/312 for more - // details. - TransformerLogger().circularGenericNotSupported(genericNode.getText()); - return acc; - } + typeParameterDeclaration = TypescriptHelper.GetDeclarationFromNode(genericNode.typeName); if (ts.isTypeParameterDeclaration(typeParameterDeclaration)) { addGenericParameterToExisting( @@ -110,10 +179,16 @@ export function GenericDeclaration(scope: Scope): IGenericDeclaration { } } + if (!typeParameterDeclaration || !nextScope.isBoundFor(extensionDeclarationKey)) { + genericValueDescriptor = GetDescriptor(genericNode, nextScope.bindFor(extensionDeclarationKey)); + } + const genericParameter: GenericParameter = createGenericParameter( extensionDeclarationKey, - extensionDeclarationTypeParameters[index], - GetDescriptor(genericNode, scope), + declaration, + genericValueDescriptor, + isInstantiable(typeParameterDeclaration), + nextScope, ); acc.push(genericParameter); diff --git a/src/transformer/logger/transformerLogger.ts b/src/transformer/logger/transformerLogger.ts index 5464ef08e..d52e9e228 100644 --- a/src/transformer/logger/transformerLogger.ts +++ b/src/transformer/logger/transformerLogger.ts @@ -4,7 +4,6 @@ import { ILogger } from '../../logger/logger.interface'; let logger: ILogger; export interface TransformerLogger { - circularGenericNotSupported(nodeName: string): void; unexpectedCreateMock(mockFileName: string, expectedFileName: string): void; typeNotSupported(type: string): void; typeOfFunctionCallNotFound(node: string): void; @@ -15,12 +14,6 @@ export function TransformerLogger(): TransformerLogger { logger = logger || Logger('Transformer'); return { - circularGenericNotSupported(nodeName: string): void { - logger.warning( - `Found a circular generic of \`${nodeName}' and such generics are currently not supported. ` + - 'The generated mock will be incomplete.', - ); - }, unexpectedCreateMock(mockFileName: string, expectedFileName: string): void { logger.warning(`I\'ve found a mock creator but it comes from a different folder found: ${mockFileName} diff --git a/src/transformer/scope/scope.ts b/src/transformer/scope/scope.ts index 0d8cc1a3d..37fdb130a 100644 --- a/src/transformer/scope/scope.ts +++ b/src/transformer/scope/scope.ts @@ -1,12 +1,52 @@ import * as ts from 'typescript'; export type InterfaceOrClassDeclaration = ts.InterfaceDeclaration | ts.ClassDeclaration; + export class Scope { constructor(currentMockKey?: string) { this._currentMockKey = currentMockKey; } + private _boundFor: string | undefined; private readonly _currentMockKey: string | undefined; + private _parent: this | undefined; + + public newNestedScope(currentMockKey: string): Scope { + const nestedScope: Scope = new Scope(currentMockKey); + + nestedScope._parent = this; + + return nestedScope; + } + + public bindFor(key: string): this { + this._boundFor = key; + + return this; + } + + public isBoundFor(extensionKey: string): boolean { + let isBound: boolean = this._boundFor === extensionKey; + + if (isBound) { + return isBound; + } + + let parent: Scope | undefined = this._parent; + + while (parent) { + isBound = this._currentMockKey === parent._currentMockKey && parent._boundFor === extensionKey; + + if (isBound) { + break; + } + + parent = parent._parent; + + } + + return isBound; + } public get currentMockKey(): string | undefined { return this._currentMockKey; diff --git a/test/transformer/descriptor/generic/extends.test.ts b/test/transformer/descriptor/generic/extends.test.ts index 149dfa982..3ec9f5682 100644 --- a/test/transformer/descriptor/generic/extends.test.ts +++ b/test/transformer/descriptor/generic/extends.test.ts @@ -245,14 +245,56 @@ describe('for generic', () => { }); describe('with circular', () => { - interface A extends ClassWithGenerics { - b: number; + interface GenericC { + c: T; + } + + interface GenericD { + d: T; + } + + interface GenericE { + e: T; + } + + // NOTE: A, A, B + interface A extends GenericC, GenericD, GenericE { + a: number; + B: B; + } + + // NOTE: B, A, A + interface B extends GenericC, GenericD, GenericE { + b: string; + A: A; } it('should avoid infinite extension', () => { - const properties: A = createMock(); - expect(properties.a).toBeDefined(); - expect(properties.b).toBe(0); + const propertiesA: A = createMock(); + const propertiesB: B = createMock(); + + expect(propertiesA.B.d.a).toBe(0); + expect(propertiesA.B.e.a).toBe(0); + + expect(propertiesB.A.d.a).toBe(0); + expect(propertiesB.A.e.b).toBe(''); + + // NOTE: First generic reference becomes a new instance and the second + // reference becomes a call to the parent's constructor. + expect(propertiesA.c.c.c.a).toBe(0); + expect(propertiesB.c.c.c.b).toBe(''); + + expect(propertiesA.d.d.d.a).toBe(0); + expect(propertiesB.d.d.d.a).toBe(0); + + expect(propertiesA.e.e.e.b).toBe(''); + expect(propertiesB.e.e.e.a).toBe(0); + + expect(propertiesA.e.e.e.A.a).toBe(0); + expect(propertiesB.e.e.e.B.b).toBe(''); + + expect(propertiesA.c.d.e.b).toBe(''); + expect(propertiesB.c.d.e.b).toBe(''); }); }); diff --git a/ui/src/views/types-not-supported.mdx b/ui/src/views/types-not-supported.mdx index 54c66ed8f..76377ecf6 100644 --- a/ui/src/views/types-not-supported.mdx +++ b/ui/src/views/types-not-supported.mdx @@ -71,31 +71,6 @@ There is a branch created with a working version but it needs more investigation [link](https://github.com/Typescript-TDD/ts-auto-mock/tree/feature/extends-mapped-type) -## Circular Generics - -```ts -class C { - public propC: T - public test: string -} - -class A extends C { - public propA: number -} -const a: A = createMock(); - -// This will fail because we will not support generics of the same type. -expect(a.propC.propC.test).toBe(""); -``` - -These are discussed here: -[link](https://github.com/Typescript-TDD/ts-auto-mock/pull/312). As of this -writing, the problem with circular generics is that the generated AST will -circle `A` over and over, and result in an infinite nested tree of declaration -references. The intended behavior is to have the first back-reference stored -elsewhere in the generated output and let it reference itself, making the -runtime a lazy-evaluated sequence of getters. - ## Indexed access type with generics ```ts interface StandardInterface { @@ -128,6 +103,3 @@ interface StandardInterface { type Hello = StandardInterface['prop']; ``` - - -