import * as tae from 'typescript-api-extractor'; import { describe, expect, it } from 'vitest'; import { abbreviateType, formatDetailedType, formatProperties, formatType } from '../formatter'; describe('abbreviateType', () => { it("returns 'function' for pure function types (no union)", () => { expect(abbreviateType('selector', '((state: UnknownState) => R)')).toBe('function'); expect(abbreviateType('subscribe', '((state: State) => void)')).toBe('function'); expect(abbreviateType('isEqual', '((a: R, b: R) => boolean)')).toBe('function'); expect(abbreviateType('config', '((options: Options) => Config)')).toBe('function'); }); it("returns 'function' for function types with union return", () => { expect(abbreviateType('selector', '(state: object) => InferSliceState | undefined')).toBe('function'); expect(abbreviateType('selector', '(state: object) => string | undefined')).toBe('function'); }); it("returns 'undefined | function' for true top-level union of undefined and function", () => { expect(abbreviateType('selector', '((state: object) => string) | undefined')).toBe('undefined | function'); }); it("returns 'function' for callback props (onX with =>)", () => { expect(abbreviateType('onClick', '(event: Event) => void')).toBe('function'); expect(abbreviateType('onChange', '(value: string) => void')).toBe('function'); }); it("returns 'function' for getter props (getX with =>)", () => { expect(abbreviateType('getValue', '() => string')).toBe('function'); expect(abbreviateType('getState', '() => State')).toBe('function'); }); it("returns 'string | function' for className with =>", () => { expect(abbreviateType('className', 'string | ((state: State) => string)')).toBe('string | function'); }); it("returns 'CSSProperties | function' for style with =>", () => { expect(abbreviateType('style', 'CSSProperties | ((state: State) => CSSProperties)')).toBe( 'CSSProperties | function' ); }); it("returns 'ReactElement | function' for render with =>", () => { expect(abbreviateType('render', 'ReactElement | ((state: State) => ReactElement)')).toBe('ReactElement | function'); }); it('returns undefined for simple types (boolean, string, number)', () => { expect(abbreviateType('disabled', 'boolean')).toBeUndefined(); expect(abbreviateType('label', 'string')).toBeUndefined(); expect(abbreviateType('count', 'number')).toBeUndefined(); }); it('returns undefined for short unions (< 3 members and < 40 chars)', () => { expect(abbreviateType('size', "'small' | 'large'")).toBeUndefined(); expect(abbreviateType('value', 'string | number')).toBeUndefined(); }); it("returns 'type | function' for short callback unions (< 40 chars, 2 members)", () => { const type = 'string | ((state: TimeState) => string)'; expect(abbreviateType('label', type)).toBe('string | function'); }); it("returns 'type | function' for unions containing functions", () => { const type = "string | ((state: State) => string) | 'auto'"; expect(abbreviateType('label', type)).toBe("string | 'auto' | function"); }); it('returns undefined for complex unions (NOT "Union")', () => { // Complex union with 3+ members, no function const complexUnion = "'small' | 'medium' | 'large' | 'xlarge'"; expect(abbreviateType('size', complexUnion)).toBeUndefined(); }); it('returns "object" for object literals > 40 chars', () => { const longObject = '{ volume: number; muted: boolean; level: string }'; expect(longObject.length).toBeGreaterThan(40); expect(abbreviateType('result', longObject)).toBe('object'); }); it('returns undefined for object literals <= 40 chars', () => { const shortObject = '{ x: number; y: number }'; expect(abbreviateType('point', shortObject)).toBeUndefined(); }); it('truncates other types > 40 chars', () => { const longType = "'option-a' | 'option-b' | 'option-c' | 'option-d' | 'option-e'"; expect(longType.length).toBeGreaterThan(40); expect(abbreviateType('choice', longType)).toBe(`${longType.slice(0, 37)}...`); }); it('returns undefined for other types <= 40 chars', () => { const shortType = "'small' | 'medium' | 'large' | 'xlarge'"; expect(shortType.length).toBeLessThanOrEqual(40); expect(abbreviateType('size', shortType)).toBeUndefined(); }); }); describe('formatProperties', () => { it('skips ref prop', () => { const props: tae.PropertyNode[] = [ createPropertyNode('label', 'string', { optional: true }), createPropertyNode('ref', 'any', { optional: true }), ]; const result = formatProperties(props); expect(result).toHaveProperty('label'); expect(result).not.toHaveProperty('ref'); }); it('skips props with @ignore JSDoc tag', () => { const props: tae.PropertyNode[] = [ createPropertyNode('label', 'string', { optional: true }), createPropertyNode('ignoredProp', 'string', { optional: true, hasIgnoreTag: true }), ]; const result = formatProperties(props); expect(result).toHaveProperty('label'); expect(result).not.toHaveProperty('ignoredProp'); }); it('sets required: true for non-optional props', () => { const props: tae.PropertyNode[] = [ createPropertyNode('required', 'string', { optional: false }), createPropertyNode('optional', 'string', { optional: true }), ]; const result = formatProperties(props); expect(result.required?.required).toBe(true); expect(result.optional?.required).toBeUndefined(); }); it('cleans up undefined values from result', () => { const props: tae.PropertyNode[] = [createPropertyNode('simple', 'boolean', { optional: true })]; const result = formatProperties(props); expect(result.simple).toEqual({ type: 'boolean' }); expect(Object.keys(result.simple!)).not.toContain('detailedType'); expect(Object.keys(result.simple!)).not.toContain('default'); expect(Object.keys(result.simple!)).not.toContain('required'); }); it('passes through description from documentation', () => { const props: tae.PropertyNode[] = [ createPropertyNode('label', 'string', { optional: true, description: 'The button label.' }), ]; const result = formatProperties(props); expect(result.label?.description).toBe('The button label.'); }); it('passes through default from documentation.defaultValue', () => { const props: tae.PropertyNode[] = [ createPropertyNode('disabled', 'boolean', { optional: true, defaultValue: 'false' }), ]; const result = formatProperties(props); expect(result.disabled?.default).toBe('false'); }); it('expands type aliases when allExports is provided', () => { // Create a property with an ExternalTypeNode referencing 'TimeType' const externalType = createExternalTypeNode('TimeType'); const prop = { name: 'type', type: externalType, optional: true, documentation: undefined, } as tae.PropertyNode; // Create allExports with TimeType resolved to a union const timeTypeExport = { name: 'TimeType', type: createUnionNode([ createLiteralNode("'current'"), createLiteralNode("'duration'"), createLiteralNode("'remaining'"), ]), documentation: undefined, } as tae.ExportNode; const result = formatProperties([prop], [timeTypeExport]); expect(result.type?.type).toBe("'current' | 'duration' | 'remaining'"); }); it('sets abbreviated type and detailedType for callback props', () => { const fnType = createFunctionNode([ { parameters: [ { name: 'event', type: createIntrinsicNode('Event'), optional: false, documentation: undefined, defaultValue: undefined, } as tae.Parameter, ], returnValueType: createIntrinsicNode('void'), } as tae.CallSignature, ]); const prop = { name: 'onClick', type: fnType, optional: true, documentation: undefined, } as tae.PropertyNode; const result = formatProperties([prop]); expect(result.onClick?.type).toBe('function'); expect(result.onClick?.detailedType).toBe('((event: Event) => void)'); }); }); describe('formatType', () => { it('formats IntrinsicNode (boolean, string, number)', () => { const boolNode = createIntrinsicNode('boolean'); const strNode = createIntrinsicNode('string'); const numNode = createIntrinsicNode('number'); expect(formatType(boolNode, false)).toBe('boolean'); expect(formatType(strNode, false)).toBe('string'); expect(formatType(numNode, false)).toBe('number'); }); it('formats UnionNode and removes undefined when optional', () => { const unionNode = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('undefined')]); expect(formatType(unionNode, true)).toBe('string'); expect(formatType(unionNode, false)).toBe('string | undefined'); }); it('flattens nested unions', () => { const innerUnion = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]); const outerUnion = createUnionNode([innerUnion, createIntrinsicNode('boolean')]); expect(formatType(outerUnion, false)).toBe('string | number | boolean'); }); it('formats ObjectNode with properties', () => { const objNode = createObjectNode([ { name: 'x', type: createIntrinsicNode('number'), optional: false }, { name: 'y', type: createIntrinsicNode('number'), optional: true }, ]); expect(formatType(objNode, false)).toBe('{ x: number; y?: number }'); }); it('formats ArrayNode with parentheses for complex element types', () => { const simpleArray = createArrayNode(createIntrinsicNode('string')); const complexArray = createArrayNode( createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]) ); expect(formatType(simpleArray, false)).toBe('string[]'); expect(formatType(complexArray, false)).toBe('(string | number)[]'); }); it('orders members with null/undefined/any last', () => { const unionNode = createUnionNode([ createIntrinsicNode('null'), createIntrinsicNode('string'), createIntrinsicNode('undefined'), createIntrinsicNode('number'), ]); expect(formatType(unionNode, false)).toBe('string | number | null | undefined'); }); it('normalizes quotes (double to single)', () => { const literalNode = createLiteralNode('"hello"'); expect(formatType(literalNode, false)).toBe("'hello'"); }); // --- ExternalTypeNode --- it('formats ExternalTypeNode ReactElement to just ReactElement', () => { const node = createExternalTypeNode('ReactElement', undefined, [ { type: createIntrinsicNode('Props'), equalToDefault: false }, ]); expect(formatType(node, false)).toBe('ReactElement'); }); it('formats ExternalTypeNode with React namespace by stripping namespace', () => { const node = createExternalTypeNode('CSSProperties', ['React']); expect(formatType(node, false)).toBe('CSSProperties'); }); it('formats ExternalTypeNode with fully qualified name', () => { const node = createExternalTypeNode('Baz', ['Foo', 'Bar']); expect(formatType(node, false)).toBe('Foo.Bar.Baz'); }); it('formats ExternalTypeNode with non-default type arguments', () => { const node = createExternalTypeNode('Map', undefined, [ { type: createIntrinsicNode('string'), equalToDefault: false }, { type: createIntrinsicNode('number'), equalToDefault: false }, ]); expect(formatType(node, false)).toBe('Map'); }); // --- IntersectionNode --- it('formats IntersectionNode without typeName', () => { const node = createIntersectionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]); expect(formatType(node, false)).toBe('string & number'); }); it('formats IntersectionNode with typeName as fully qualified name', () => { const typeName = createTypeName('Combined'); const node = createIntersectionNode([createIntrinsicNode('string'), createIntrinsicNode('number')], typeName); expect(formatType(node, false)).toBe('Combined'); }); // --- FunctionNode --- it('formats FunctionNode without typeName', () => { const node = createFunctionNode([ { parameters: [ { name: 'x', type: createIntrinsicNode('string'), optional: false, documentation: undefined, defaultValue: undefined, } as tae.Parameter, ], returnValueType: createIntrinsicNode('void'), } as tae.CallSignature, ]); expect(formatType(node, false)).toBe('((x: string) => void)'); }); it('formats FunctionNode with typeName as fully qualified name', () => { const typeName = createTypeName('MyHandler'); const node = createFunctionNode( [ { parameters: [], returnValueType: createIntrinsicNode('void'), } as tae.CallSignature, ], typeName ); expect(formatType(node, false)).toBe('MyHandler'); }); // --- TupleNode --- it('formats TupleNode without typeName', () => { const node = createTupleNode([createIntrinsicNode('string'), createIntrinsicNode('number')]); expect(formatType(node, false)).toBe('[string, number]'); }); it('formats TupleNode with typeName as fully qualified name', () => { const typeName = createTypeName('Pair'); const node = createTupleNode([createIntrinsicNode('string'), createIntrinsicNode('number')], typeName); expect(formatType(node, false)).toBe('Pair'); }); // --- TypeParameterNode --- it('formats TypeParameterNode with constraint', () => { const node = createTypeParameterNode('T', createIntrinsicNode('string')); expect(formatType(node, false)).toBe('string'); }); it('formats TypeParameterNode without constraint returns the name', () => { const node = createTypeParameterNode('T'); expect(formatType(node, false)).toBe('T'); }); it('returns type name for TypeParameterNode with large union constraint (>5 members)', () => { const largeUnion = createUnionNode([ createLiteralNode("'a'"), createLiteralNode("'b'"), createLiteralNode("'c'"), createLiteralNode("'d'"), createLiteralNode("'e'"), createLiteralNode("'f'"), ]); const node = createTypeParameterNode('TagName', largeUnion); expect(formatType(node, false)).toBe('TagName'); }); it('expands TypeParameterNode with small union constraint (<=5 members)', () => { const smallUnion = createUnionNode([createLiteralNode("'a'"), createLiteralNode("'b'"), createLiteralNode("'c'")]); const node = createTypeParameterNode('T', smallUnion); expect(formatType(node, false)).toBe("'a' | 'b' | 'c'"); }); // --- UnionNode with typeName --- it('formats UnionNode with typeName as fully qualified name', () => { const typeName = createTypeName('Status'); const node = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')], typeName); expect(formatType(node, false)).toBe('Status'); }); // --- ObjectNode edge cases --- it('formats empty ObjectNode as object', () => { const node = createObjectNode([]); expect(formatType(node, false)).toBe('object'); }); // --- Unknown node --- it('returns unknown for unrecognized node type', () => { const node = {} as tae.AnyType; expect(formatType(node, false)).toBe('unknown'); }); // --- Union dedup --- it('deduplicates union members via uniq', () => { const node = createUnionNode([ createIntrinsicNode('string'), createIntrinsicNode('string'), createIntrinsicNode('number'), ]); expect(formatType(node, false)).toBe('string | number'); }); // --- TypeParameterNode constraint flattening in union --- it('flattens TypeParameterNode constraint in union when small (<=5 members)', () => { const constraintUnion = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('number')]); const typeParam = createTypeParameterNode('T', constraintUnion); const union = createUnionNode([typeParam, createIntrinsicNode('boolean')]); expect(formatType(union, false)).toBe('string | number | boolean'); }); it('does not flatten TypeParameterNode constraint in union when large (>5 members)', () => { const largeConstraint = createUnionNode([ createLiteralNode("'a'"), createLiteralNode("'b'"), createLiteralNode("'c'"), createLiteralNode("'d'"), createLiteralNode("'e'"), createLiteralNode("'f'"), ]); const typeParam = createTypeParameterNode('TagName', largeConstraint); const union = createUnionNode([typeParam, createIntrinsicNode('boolean')]); expect(formatType(union, false)).toBe('TagName | boolean'); }); }); describe('formatDetailedType', () => { it('expands ExternalTypeNode when found in allExports', () => { const externalType = createExternalTypeNode('TimeType'); const resolvedUnion = createUnionNode([ createLiteralNode("'current'"), createLiteralNode("'duration'"), createLiteralNode("'remaining'"), ]); const allExports = [{ name: 'TimeType', type: resolvedUnion, documentation: undefined }] as tae.ExportNode[]; expect(formatDetailedType(externalType, allExports, false)).toBe("'current' | 'duration' | 'remaining'"); }); it('returns qualified name when not found in allExports', () => { const externalType = createExternalTypeNode('UnknownType'); expect(formatDetailedType(externalType, [], false)).toBe('UnknownType'); }); it('skips re-exported types (reexportedFrom is set)', () => { const externalType = createExternalTypeNode('TimeType'); const resolvedUnion = createUnionNode([createLiteralNode("'current'"), createLiteralNode("'duration'")]); const reexport = { name: 'TimeType', type: resolvedUnion, documentation: undefined, reexportedFrom: 'OriginalTimeType', } as unknown as tae.ExportNode; expect(formatDetailedType(externalType, [reexport], false)).toBe('TimeType'); }); it('expands UnionNode with typeName (ignores alias, expands members)', () => { const typeName = createTypeName('VolumeLevel'); const union = createUnionNode( [ createLiteralNode("'off'"), createLiteralNode("'low'"), createLiteralNode("'medium'"), createLiteralNode("'high'"), ], typeName ); const allExports: tae.ExportNode[] = []; expect(formatDetailedType(union, allExports, false)).toBe("'off' | 'low' | 'medium' | 'high'"); }); it('handles removeUndefined for optional props', () => { const union = createUnionNode([createIntrinsicNode('string'), createIntrinsicNode('undefined')]); expect(formatDetailedType(union, [], true)).toBe('string'); expect(formatDetailedType(union, [], false)).toBe('string | undefined'); }); it('prevents infinite recursion via visited set', () => { const externalType = createExternalTypeNode('SelfRef'); // SelfRef resolves to itself const selfRefExport = { name: 'SelfRef', type: createExternalTypeNode('SelfRef'), documentation: undefined, } as tae.ExportNode; // Should not stack overflow; falls back to formatType expect(formatDetailedType(externalType, [selfRefExport], false)).toBe('SelfRef'); }); it('expands IntersectionNode members', () => { const externalA = createExternalTypeNode('BaseProps'); const basePropsExport = { name: 'BaseProps', type: createObjectNode([{ name: 'id', type: createIntrinsicNode('string'), optional: false }]), documentation: undefined, } as tae.ExportNode; const intersection = createIntersectionNode([externalA, createIntrinsicNode('number')]); expect(formatDetailedType(intersection, [basePropsExport], false)).toBe('{ id: string } & number'); }); it('delegates non-expandable nodes to formatType', () => { const intrinsic = createIntrinsicNode('boolean'); expect(formatDetailedType(intrinsic, [], false)).toBe('boolean'); }); }); // --- Helper factories --- function createPropertyNode( name: string, typeName: string, options: { optional?: boolean; hasIgnoreTag?: boolean; description?: string; defaultValue?: string } = {} ): tae.PropertyNode { const type = createIntrinsicNode(typeName); const documentation = options.hasIgnoreTag || options.description !== undefined || options.defaultValue !== undefined ? createDocumentation(options) : undefined; return { name, type, optional: options.optional ?? false, documentation, } as tae.PropertyNode; } function createDocumentation(options: { hasIgnoreTag?: boolean; description?: string; defaultValue?: string; }): tae.Documentation { return { description: options.description, defaultValue: options.defaultValue, hasTag: (tag: string) => (tag === 'ignore' ? (options.hasIgnoreTag ?? false) : false), } as unknown as tae.Documentation; } function createIntrinsicNode(intrinsic: string): tae.IntrinsicNode { const node = Object.create(tae.IntrinsicNode.prototype); node.intrinsic = intrinsic; node.typeName = undefined; return node; } function createUnionNode(types: tae.AnyType[], typeName?: tae.TypeName): tae.UnionNode { const node = Object.create(tae.UnionNode.prototype); node.types = types; node.typeName = typeName; return node; } function createObjectNode( properties: Array<{ name: string; type: tae.AnyType; optional: boolean }>, typeName?: tae.TypeName ): tae.ObjectNode { const node = Object.create(tae.ObjectNode.prototype); node.properties = properties.map((p) => ({ name: p.name, type: p.type, optional: p.optional, })); node.typeName = typeName; return node; } function createArrayNode(elementType: tae.AnyType): tae.ArrayNode { const node = Object.create(tae.ArrayNode.prototype); node.elementType = elementType; return node; } function createLiteralNode(value: string): tae.LiteralNode { const node = Object.create(tae.LiteralNode.prototype); node.value = value; return node; } function createExternalTypeNode( name: string, namespaces?: string[], typeArguments?: Array<{ type: tae.AnyType; equalToDefault: boolean }> ): tae.ExternalTypeNode { const node = Object.create(tae.ExternalTypeNode.prototype); node.typeName = createTypeName(name, namespaces, typeArguments); return node; } function createIntersectionNode(types: tae.AnyType[], typeName?: tae.TypeName): tae.IntersectionNode { const node = Object.create(tae.IntersectionNode.prototype); node.types = types; node.typeName = typeName; node.properties = []; return node; } function createFunctionNode(callSignatures: tae.CallSignature[], typeName?: tae.TypeName): tae.FunctionNode { const node = Object.create(tae.FunctionNode.prototype); node.callSignatures = callSignatures; node.typeName = typeName; return node; } function createTupleNode(types: tae.AnyType[], typeName?: tae.TypeName): tae.TupleNode { const node = Object.create(tae.TupleNode.prototype); node.types = types; node.typeName = typeName; return node; } function createTypeParameterNode(name: string, constraint?: tae.AnyType): tae.TypeParameterNode { const node = Object.create(tae.TypeParameterNode.prototype); node.name = name; node.constraint = constraint; return node; } function createTypeName( name: string, namespaces?: string[], typeArguments?: Array<{ type: tae.AnyType; equalToDefault: boolean }> ): tae.TypeName { return new tae.TypeName(name, namespaces, typeArguments); }