mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
test(site): replace api-docs-builder design doc with E2E spec tests (#1225)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,378 +0,0 @@
|
||||
import * as tae from 'typescript-api-extractor';
|
||||
import { describe, expect, it, type MockInstance, vi } from 'vitest';
|
||||
import { extractCore, extractDefaultProps } from '../core-handler.js';
|
||||
import { createTestProgram } from './test-utils.js';
|
||||
|
||||
vi.mock('typescript-api-extractor', async () => {
|
||||
const actual = await vi.importActual<typeof tae>('typescript-api-extractor');
|
||||
return {
|
||||
...actual,
|
||||
parseFromProgram: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockParseFromProgram = tae.parseFromProgram as unknown as MockInstance;
|
||||
|
||||
describe('extractDefaultProps', () => {
|
||||
it("extracts string literals with quotes ('label' → \"''\")", () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
label: '',
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result.label).toBe("''");
|
||||
});
|
||||
|
||||
it("extracts non-empty string literals ('Play' → \"'Play'\")", () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
label: 'Play',
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result.label).toBe("'Play'");
|
||||
});
|
||||
|
||||
it('extracts booleans (false → "false")', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result.disabled).toBe('false');
|
||||
});
|
||||
|
||||
it('extracts booleans (true → "true")', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result.enabled).toBe('true');
|
||||
});
|
||||
|
||||
it('extracts null values (null → "null")', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
value: null,
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result.value).toBe('null');
|
||||
});
|
||||
|
||||
it('extracts empty arrays ([] → "[]")', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result.items).toBe('[]');
|
||||
});
|
||||
|
||||
it('extracts empty objects ({} → "{}")', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
config: {},
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result.config).toBe('{}');
|
||||
});
|
||||
|
||||
it('extracts numeric literals', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
count: 42,
|
||||
ratio: 1.5,
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result.count).toBe('42');
|
||||
expect(result.ratio).toBe('1.5');
|
||||
});
|
||||
|
||||
it('returns empty object when class not found', () => {
|
||||
const code = `
|
||||
export class OtherClass {
|
||||
static readonly defaultProps = {
|
||||
label: 'test',
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('returns empty object when no defaultProps property', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly otherProperty = {
|
||||
label: 'test',
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores non-static defaultProps', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
readonly defaultProps = {
|
||||
label: 'test',
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPropertyValue', () => {
|
||||
it('falls back to getText for complex expressions', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
label: \`hello \${world}\`,
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDefaultProps('test.ts', program, 'MockComponent');
|
||||
|
||||
// biome-ignore lint/suspicious/noTemplateCurlyInString: testing template literal extraction
|
||||
expect(result.label).toBe('`hello ${world}`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractCore', () => {
|
||||
function createMockAst(exports: Array<{ name: string; type: unknown; documentation?: unknown }>) {
|
||||
return { exports };
|
||||
}
|
||||
|
||||
function createMockObjectNode(properties: tae.PropertyNode[]): tae.ObjectNode {
|
||||
const node = Object.create(tae.ObjectNode.prototype);
|
||||
node.properties = properties;
|
||||
return node;
|
||||
}
|
||||
|
||||
function createMockIntrinsicNode(intrinsic: string): tae.IntrinsicNode {
|
||||
const node = Object.create(tae.IntrinsicNode.prototype);
|
||||
node.intrinsic = intrinsic;
|
||||
node.typeName = undefined;
|
||||
return node;
|
||||
}
|
||||
|
||||
function createMockPropertyNode(
|
||||
name: string,
|
||||
typeName: string,
|
||||
options: { optional?: boolean; description?: string; defaultValue?: string } = {}
|
||||
): tae.PropertyNode {
|
||||
const type = createMockIntrinsicNode(typeName);
|
||||
const documentation =
|
||||
options.description !== undefined || options.defaultValue !== undefined
|
||||
? ({
|
||||
description: options.description,
|
||||
defaultValue: options.defaultValue,
|
||||
hasTag: () => false,
|
||||
} as unknown as tae.Documentation)
|
||||
: undefined;
|
||||
|
||||
return { name, type, optional: options.optional ?? false, documentation } as tae.PropertyNode;
|
||||
}
|
||||
|
||||
it('returns null when neither Props nor State export is found', () => {
|
||||
const code = 'export const x = 1;';
|
||||
const program = createTestProgram(code);
|
||||
|
||||
mockParseFromProgram.mockReturnValueOnce(
|
||||
createMockAst([{ name: 'SomethingElse', type: createMockIntrinsicNode('string') }])
|
||||
);
|
||||
|
||||
const result = extractCore('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts props when propsExport.type is an ObjectNode', () => {
|
||||
const code = 'export const x = 1;';
|
||||
const program = createTestProgram(code);
|
||||
|
||||
const propsType = createMockObjectNode([
|
||||
createMockPropertyNode('label', 'string', { optional: true }),
|
||||
createMockPropertyNode('disabled', 'boolean', { optional: true }),
|
||||
]);
|
||||
|
||||
mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentProps', type: propsType }]));
|
||||
|
||||
const result = extractCore('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.props).toHaveLength(2);
|
||||
expect(result!.props[0]!.name).toBe('label');
|
||||
expect(result!.props[0]!.type).toBe('string');
|
||||
expect(result!.props[1]!.name).toBe('disabled');
|
||||
expect(result!.props[1]!.type).toBe('boolean');
|
||||
});
|
||||
|
||||
it('extracts state when stateExport.type is an ObjectNode', () => {
|
||||
const code = 'export const x = 1;';
|
||||
const program = createTestProgram(code);
|
||||
|
||||
const stateType = createMockObjectNode([createMockPropertyNode('paused', 'boolean', { optional: false })]);
|
||||
|
||||
mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentState', type: stateType }]));
|
||||
|
||||
const result = extractCore('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.state).toHaveLength(1);
|
||||
expect(result!.state[0]!.name).toBe('paused');
|
||||
expect(result!.state[0]!.type).toBe('boolean');
|
||||
});
|
||||
|
||||
it('extracts description from propsExport documentation', () => {
|
||||
const code = 'export const x = 1;';
|
||||
const program = createTestProgram(code);
|
||||
|
||||
const propsType = createMockObjectNode([createMockPropertyNode('label', 'string', { optional: true })]);
|
||||
|
||||
mockParseFromProgram.mockReturnValueOnce(
|
||||
createMockAst([
|
||||
{
|
||||
name: 'MockComponentProps',
|
||||
type: propsType,
|
||||
documentation: { description: 'Props for the play button.' },
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
const result = extractCore('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.description).toBe('Props for the play button.');
|
||||
});
|
||||
|
||||
it('skips props when propsExport.type is not an ObjectNode', () => {
|
||||
const code = 'export const x = 1;';
|
||||
const program = createTestProgram(code);
|
||||
|
||||
mockParseFromProgram.mockReturnValueOnce(
|
||||
createMockAst([
|
||||
{ name: 'MockComponentProps', type: createMockIntrinsicNode('string') },
|
||||
{ name: 'MockComponentState', type: createMockObjectNode([createMockPropertyNode('paused', 'boolean')]) },
|
||||
])
|
||||
);
|
||||
|
||||
const result = extractCore('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.props).toHaveLength(0);
|
||||
expect(result!.state).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('expands type aliases via allExports', () => {
|
||||
const code = 'export const x = 1;';
|
||||
const program = createTestProgram(code);
|
||||
|
||||
// Create an ExternalTypeNode referencing 'TimeType'
|
||||
const externalTypeNode = Object.create(tae.ExternalTypeNode.prototype);
|
||||
externalTypeNode.typeName = new tae.TypeName('TimeType');
|
||||
|
||||
const propsType = createMockObjectNode([
|
||||
{
|
||||
name: 'type',
|
||||
type: externalTypeNode,
|
||||
optional: true,
|
||||
documentation: undefined,
|
||||
} as tae.PropertyNode,
|
||||
]);
|
||||
|
||||
// TimeType is also in the exports list with its resolved union type
|
||||
const timeTypeLiteral1 = Object.create(tae.LiteralNode.prototype);
|
||||
timeTypeLiteral1.value = "'current'";
|
||||
const timeTypeLiteral2 = Object.create(tae.LiteralNode.prototype);
|
||||
timeTypeLiteral2.value = "'duration'";
|
||||
const timeTypeLiteral3 = Object.create(tae.LiteralNode.prototype);
|
||||
timeTypeLiteral3.value = "'remaining'";
|
||||
const timeTypeUnion = Object.create(tae.UnionNode.prototype);
|
||||
timeTypeUnion.types = [timeTypeLiteral1, timeTypeLiteral2, timeTypeLiteral3];
|
||||
timeTypeUnion.typeName = undefined;
|
||||
|
||||
mockParseFromProgram.mockReturnValueOnce(
|
||||
createMockAst([
|
||||
{ name: 'MockComponentProps', type: propsType },
|
||||
{ name: 'TimeType', type: timeTypeUnion },
|
||||
])
|
||||
);
|
||||
|
||||
const result = extractCore('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.props[0]!.name).toBe('type');
|
||||
expect(result!.props[0]!.type).toBe("'current' | 'duration' | 'remaining'");
|
||||
});
|
||||
|
||||
it('merges defaultProps from extractDefaultProps into result', () => {
|
||||
const code = `
|
||||
export class MockComponentCore {
|
||||
static readonly defaultProps = {
|
||||
label: 'Play',
|
||||
};
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
|
||||
const propsType = createMockObjectNode([createMockPropertyNode('label', 'string', { optional: true })]);
|
||||
|
||||
mockParseFromProgram.mockReturnValueOnce(createMockAst([{ name: 'MockComponentProps', type: propsType }]));
|
||||
|
||||
const result = extractCore('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.defaultProps).toEqual({ label: "'Play'" });
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractCSSVars } from '../css-vars-handler.js';
|
||||
import { createTestProgram } from './test-utils.js';
|
||||
|
||||
describe('extractCSSVars', () => {
|
||||
it('extracts from {Name}CSSVars constant', () => {
|
||||
const code = `
|
||||
export const MockComponentCSSVars = {
|
||||
fill: '--media-slider-fill',
|
||||
pointer: '--media-slider-pointer',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractCSSVars('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.vars).toHaveLength(2);
|
||||
expect(result!.vars[0]!.name).toBe('--media-slider-fill');
|
||||
expect(result!.vars[1]!.name).toBe('--media-slider-pointer');
|
||||
});
|
||||
|
||||
it('extracts JSDoc comments as descriptions', () => {
|
||||
const code = `
|
||||
export const MockComponentCSSVars = {
|
||||
/** Fill level percentage (0-100). */
|
||||
fill: '--media-slider-fill',
|
||||
/** Pointer position percentage (0-100). */
|
||||
pointer: '--media-slider-pointer',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractCSSVars('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.vars[0]!.description).toBe('Fill level percentage (0-100).');
|
||||
expect(result!.vars[1]!.description).toBe('Pointer position percentage (0-100).');
|
||||
});
|
||||
|
||||
it('handles object without as const', () => {
|
||||
const code = `
|
||||
export const MockComponentCSSVars = {
|
||||
fill: '--media-slider-fill',
|
||||
};
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractCSSVars('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.vars).toHaveLength(1);
|
||||
expect(result!.vars[0]!.name).toBe('--media-slider-fill');
|
||||
});
|
||||
|
||||
it('handles as const satisfies expression', () => {
|
||||
const code = `
|
||||
export const MockComponentCSSVars = {
|
||||
fill: '--media-slider-fill',
|
||||
} as const satisfies Record<string, string>;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractCSSVars('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.vars).toHaveLength(1);
|
||||
expect(result!.vars[0]!.name).toBe('--media-slider-fill');
|
||||
});
|
||||
|
||||
it('returns null when constant not found', () => {
|
||||
const code = `
|
||||
export const OtherConstant = {
|
||||
fill: '--media-slider-fill',
|
||||
};
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractCSSVars('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty description when no JSDoc present', () => {
|
||||
const code = `
|
||||
export const MockComponentCSSVars = {
|
||||
fill: '--media-slider-fill',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractCSSVars('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.vars[0]!.description).toBe('');
|
||||
});
|
||||
|
||||
it('skips properties with non-string-literal values', () => {
|
||||
const code = `
|
||||
const PREFIX = '--media-';
|
||||
export const MockComponentCSSVars = {
|
||||
fill: PREFIX + 'fill',
|
||||
pointer: '--media-slider-pointer',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractCSSVars('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.vars).toHaveLength(1);
|
||||
expect(result!.vars[0]!.name).toBe('--media-slider-pointer');
|
||||
});
|
||||
|
||||
it('extracts single-line // comments as descriptions', () => {
|
||||
const code = `
|
||||
export const MockComponentCSSVars = {
|
||||
// Fill level percentage (0-100).
|
||||
fill: '--media-slider-fill',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractCSSVars('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.vars[0]!.description).toBe('Fill level percentage (0-100).');
|
||||
});
|
||||
});
|
||||
@@ -1,314 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractDataAttrs } from '../data-attrs-handler.js';
|
||||
import { createTestProgram, createTypedTestProgram } from './test-utils.js';
|
||||
|
||||
describe('extractDataAttrs', () => {
|
||||
it('extracts from {Name}DataAttrs constant', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttrs = {
|
||||
active: 'data-active',
|
||||
disabled: 'data-disabled',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs).toHaveLength(2);
|
||||
expect(result!.attrs[0]!.name).toBe('data-active');
|
||||
expect(result!.attrs[1]!.name).toBe('data-disabled');
|
||||
});
|
||||
|
||||
it('extracts from {Name}DataAttributes constant (alternate naming)', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttributes = {
|
||||
paused: 'data-paused',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs).toHaveLength(1);
|
||||
expect(result!.attrs[0]!.name).toBe('data-paused');
|
||||
});
|
||||
|
||||
it('extracts JSDoc comments for each property', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttrs = {
|
||||
/** Present when the component is active. */
|
||||
active: 'data-active',
|
||||
/** Present when the component is disabled. */
|
||||
disabled: 'data-disabled',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.description).toBe('Present when the component is active.');
|
||||
expect(result!.attrs[1]!.description).toBe('Present when the component is disabled.');
|
||||
});
|
||||
|
||||
it('handles object without as const', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttrs = {
|
||||
value: 'data-value',
|
||||
};
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('extracts from {Name}DataAttrs with as const satisfies', () => {
|
||||
const code = `
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
interface MockComponentState {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export const MockComponentDataAttrs = {
|
||||
active: 'data-active',
|
||||
} as const satisfies StateAttrMap<MockComponentState>;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs).toHaveLength(1);
|
||||
expect(result!.attrs[0]!.name).toBe('data-active');
|
||||
});
|
||||
|
||||
it('extracts from {Name}DataAttrs with satisfies expression', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttrs = ({
|
||||
active: 'data-active',
|
||||
}) satisfies Record<string, string>;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs).toHaveLength(1);
|
||||
expect(result!.attrs[0]!.name).toBe('data-active');
|
||||
});
|
||||
|
||||
it('extracts JSDoc comments for properties wrapped with satisfies', () => {
|
||||
const code = `
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
interface MockComponentState {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export const MockComponentDataAttrs = {
|
||||
/** Present when the component is active. */
|
||||
active: 'data-active',
|
||||
} as const satisfies StateAttrMap<MockComponentState>;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.description).toBe('Present when the component is active.');
|
||||
});
|
||||
|
||||
it('returns null when constant not found', () => {
|
||||
const code = `
|
||||
export const OtherConstant = {
|
||||
value: 'data-value',
|
||||
};
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts single-line // comments for properties', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttrs = {
|
||||
// Present when the component is focused.
|
||||
focused: 'data-focused',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.description).toBe('Present when the component is focused.');
|
||||
});
|
||||
|
||||
it('extracts @type JSDoc tag as type field', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttrs = {
|
||||
/**
|
||||
* The fill level.
|
||||
* @type {'empty' | 'partial' | 'full'}
|
||||
*/
|
||||
fillState: 'data-fill-state',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.type).toBe("'empty' | 'partial' | 'full'");
|
||||
});
|
||||
|
||||
it('separates description from @type line', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttrs = {
|
||||
/**
|
||||
* The fill level.
|
||||
* @type {'empty' | 'partial' | 'full'}
|
||||
*/
|
||||
fillState: 'data-fill-state',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.description).toBe('The fill level.');
|
||||
expect(result!.attrs[0]!.description).not.toContain('@type');
|
||||
});
|
||||
|
||||
it('omits type when no @type tag present', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttrs = {
|
||||
/** Present when the component is active. */
|
||||
active: 'data-active',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.type).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to data-{key} when value is not a string literal', () => {
|
||||
const code = `
|
||||
const PREFIX = 'data-';
|
||||
export const MockComponentDataAttrs = {
|
||||
active: PREFIX + 'active',
|
||||
};
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.name).toBe('data-active');
|
||||
});
|
||||
|
||||
it('infers boolean as omitted type', () => {
|
||||
const code = `
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
interface MockComponentState {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export const MockComponentDataAttrs = {
|
||||
active: 'data-active',
|
||||
} as const satisfies StateAttrMap<MockComponentState>;
|
||||
`;
|
||||
const program = createTypedTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.type).toBeUndefined();
|
||||
});
|
||||
|
||||
it('infers string literal union from state type', () => {
|
||||
const code = `
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
interface MockComponentState {
|
||||
level: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export const MockComponentDataAttrs = {
|
||||
level: 'data-level',
|
||||
} as const satisfies StateAttrMap<MockComponentState>;
|
||||
`;
|
||||
const program = createTypedTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.type).toBe("'low' | 'medium' | 'high'");
|
||||
});
|
||||
|
||||
it('infers number type from state', () => {
|
||||
const code = `
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
interface MockComponentState {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const MockComponentDataAttrs = {
|
||||
count: 'data-count',
|
||||
} as const satisfies StateAttrMap<MockComponentState>;
|
||||
`;
|
||||
const program = createTypedTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.type).toBe('number');
|
||||
});
|
||||
|
||||
it('infers through type alias to expanded literals', () => {
|
||||
const code = `
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
type VolumeLevel = 'off' | 'low';
|
||||
interface MockComponentState {
|
||||
level: VolumeLevel;
|
||||
}
|
||||
|
||||
export const MockComponentDataAttrs = {
|
||||
level: 'data-level',
|
||||
} as const satisfies StateAttrMap<MockComponentState>;
|
||||
`;
|
||||
const program = createTypedTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.type).toBe("'off' | 'low'");
|
||||
});
|
||||
|
||||
it('JSDoc @type overrides inferred type', () => {
|
||||
const code = `
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
interface MockComponentState {
|
||||
level: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export const MockComponentDataAttrs = {
|
||||
/**
|
||||
* The volume level.
|
||||
* @type {'quiet' | 'loud'}
|
||||
*/
|
||||
level: 'data-level',
|
||||
} as const satisfies StateAttrMap<MockComponentState>;
|
||||
`;
|
||||
const program = createTypedTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.type).toBe("'quiet' | 'loud'");
|
||||
});
|
||||
|
||||
it('no satisfies expression produces no inferred type', () => {
|
||||
const code = `
|
||||
export const MockComponentDataAttrs = {
|
||||
active: 'data-active',
|
||||
} as const;
|
||||
`;
|
||||
const program = createTypedTestProgram(code);
|
||||
const result = extractDataAttrs('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.attrs[0]!.type).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,631 @@
|
||||
/**
|
||||
* ┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
* │ API DOCS BUILDER — END-TO-END SPEC │
|
||||
* │ │
|
||||
* │ This file IS the specification for the API docs builder pipeline. │
|
||||
* │ It exercises every pattern the builder must handle, using a mock │
|
||||
* │ monorepo under fixtures/monorepo/. If you're an agent trying to │
|
||||
* │ understand how the builder works: read this file. The fixtures are │
|
||||
* │ the inputs, the expected JSON objects are the outputs. │
|
||||
* │ │
|
||||
* │ The builder is a black box: given TypeScript source files following │
|
||||
* │ specific conventions, it produces JSON reference objects. These tests │
|
||||
* │ verify the contract between input conventions and output shape. │
|
||||
* └─────────────────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* FIXTURE LAYOUT (under fixtures/monorepo/):
|
||||
*
|
||||
* Components (packages/core/src/core/ui/):
|
||||
* toggle-button/ — Single-part component. Exercises: props, state, data-attrs,
|
||||
* CSS vars, defaultProps, HTML element, type abbreviation,
|
||||
* @ignore skipping, ref auto-skip, function-typed props.
|
||||
* gauge/ — Multi-part component. Exercises: primary part detection via
|
||||
* Core instantiation, sub-parts with/without HTML elements,
|
||||
* React-only parts (no platforms.html), sub-part data-attr
|
||||
* inheritance (stateAttrMap heuristic), non-boolean type
|
||||
* inference (number, string literal union via type alias).
|
||||
* slider/ — Base multi-part component. Exercises: base component whose
|
||||
* parts are re-exported by domain variants.
|
||||
* volume-slider/ — Domain variant. Exercises: re-exported parts from slider,
|
||||
* origin-based element + data-attr resolution, re-exported
|
||||
* parts are never primary, always multi-part (no fallback).
|
||||
*
|
||||
* Utils (already existing fixtures for hooks, controllers, selectors, etc.):
|
||||
* Exercises: hook discovery, controller discovery, @public context,
|
||||
* create* factory, mixin display name stripping, selector discovery,
|
||||
* @label overloads, slug collision (react vs html create-player),
|
||||
* framework assignment.
|
||||
*/
|
||||
import * as path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateComponentReferences } from '../pipeline';
|
||||
import { getUtilEntries, type UtilEntry } from '../util-handler';
|
||||
|
||||
const FIXTURE_ROOT = path.resolve(import.meta.dirname, 'fixtures/monorepo');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// COMPONENT PIPELINE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
describe('Component pipeline (end-to-end)', () => {
|
||||
// Run the full pipeline once and reuse results across tests.
|
||||
const results = generateComponentReferences(FIXTURE_ROOT);
|
||||
|
||||
function findComponent(name: string) {
|
||||
return results.find((r) => r.name === name);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// SINGLE-PART COMPONENT: ToggleButton
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A single-part component is the simplest case. The builder merges
|
||||
// data from three source files into one flat reference object:
|
||||
// - Core file → Props interface, State interface, defaultProps
|
||||
// - Data-attrs file → data attribute names + JSDoc descriptions
|
||||
// - CSS-vars file → CSS custom property names + descriptions
|
||||
// - HTML element file → tagName for platforms.html
|
||||
//
|
||||
// Key behaviors tested:
|
||||
// - Props with `@ignore` JSDoc are excluded from output
|
||||
// - Props named `ref` are auto-excluded (React internal)
|
||||
// - Function-typed props get abbreviated ("function") with detailedType
|
||||
// - Union props with function members get "type | function" abbreviation
|
||||
// - defaultProps values are merged as string representations
|
||||
// - Boolean data-attrs have NO type field (presence/absence convention)
|
||||
// - CSS custom properties appear in cssCustomProperties
|
||||
// - platforms.html is present when an HTML element file exists
|
||||
// - No `parts` field on single-part components
|
||||
|
||||
describe('ToggleButton (single-part)', () => {
|
||||
it('produces the expected JSON reference', () => {
|
||||
const toggle = findComponent('ToggleButton');
|
||||
expect(toggle).toBeDefined();
|
||||
|
||||
const ref = toggle!.reference;
|
||||
|
||||
// Top-level shape
|
||||
expect(ref.name).toBe('ToggleButton');
|
||||
expect(ref.parts).toBeUndefined();
|
||||
|
||||
// ── Props ──
|
||||
// `ref` prop is auto-skipped. `_internalFlag` has @ignore and is skipped.
|
||||
// What remains: disabled, label, onPressedChange.
|
||||
expect(Object.keys(ref.props)).toEqual(expect.arrayContaining(['disabled', 'label', 'onPressedChange']));
|
||||
expect(ref.props['ref' as keyof typeof ref.props]).toBeUndefined();
|
||||
expect(ref.props['_internalFlag' as keyof typeof ref.props]).toBeUndefined();
|
||||
|
||||
// disabled: simple boolean, has defaultProps value.
|
||||
// Props that are non-optional in the interface have required: true,
|
||||
// even when they have a runtime default (defaultProps is separate from optionality).
|
||||
expect(ref.props.disabled).toEqual({
|
||||
type: 'boolean',
|
||||
description: 'Whether the button is disabled.',
|
||||
default: 'false',
|
||||
required: true,
|
||||
});
|
||||
|
||||
// label: union with function → abbreviated to "string | function"
|
||||
// defaultProps '' → "''"
|
||||
expect(ref.props.label).toMatchObject({
|
||||
type: 'string | function',
|
||||
description: 'Custom label for the button.',
|
||||
default: "''",
|
||||
});
|
||||
// detailedType shows the full function signature
|
||||
expect(ref.props.label!.detailedType).toBeDefined();
|
||||
expect(ref.props.label!.detailedType).toContain('=>');
|
||||
|
||||
// onPressedChange: pure function → abbreviated to "function"
|
||||
expect(ref.props.onPressedChange).toMatchObject({
|
||||
type: 'function',
|
||||
description: 'Callback when pressed state changes.',
|
||||
});
|
||||
expect(ref.props.onPressedChange!.detailedType).toBeDefined();
|
||||
|
||||
// ── State ──
|
||||
expect(ref.state.pressed).toEqual({
|
||||
type: 'boolean',
|
||||
description: 'Whether the toggle is pressed.',
|
||||
});
|
||||
expect(ref.state.disabled).toEqual({
|
||||
type: 'boolean',
|
||||
description: 'Whether the button is disabled.',
|
||||
});
|
||||
|
||||
// ── Data attributes ──
|
||||
// Boolean state types → type field is OMITTED (presence/absence convention).
|
||||
expect(ref.dataAttributes['data-pressed']).toEqual({
|
||||
description: 'Present when the toggle is pressed.',
|
||||
});
|
||||
expect(ref.dataAttributes['data-disabled']).toEqual({
|
||||
description: 'Present when the button is disabled.',
|
||||
});
|
||||
|
||||
// ── CSS custom properties ──
|
||||
expect(ref.cssCustomProperties['--media-toggle-pressed-bg']).toEqual({
|
||||
description: 'Background color when pressed.',
|
||||
});
|
||||
expect(ref.cssCustomProperties['--media-toggle-transition']).toEqual({
|
||||
description: 'Transition duration for the toggle animation.',
|
||||
});
|
||||
|
||||
// ── Platforms ──
|
||||
// HTML element exists → platforms.html with tagName
|
||||
expect(ref.platforms.html).toEqual({ tagName: 'media-toggle-button' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// MULTI-PART COMPONENT: Gauge
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A multi-part component is detected when `index.parts.ts` exists
|
||||
// in the React package. The top-level reference has EMPTY props,
|
||||
// state, dataAttributes, cssCustomProperties, and platforms. All
|
||||
// meaningful data lives in the `parts` record.
|
||||
//
|
||||
// Parts are discovered from index.parts.ts exports:
|
||||
// - PRIMARY PART: The part whose React source instantiates the
|
||||
// component's own Core class (matches `new {Name}Core\b`).
|
||||
// Gets: shared core Props/State, data-attrs, CSS vars, root tagName.
|
||||
// - SUB-PARTS: All other parts. Get: their own tagName (if element
|
||||
// file exists), description from React JSDoc, shared data-attrs
|
||||
// (only if React source references `stateAttrMap`), and custom
|
||||
// React props from `{LocalName}Props` interface.
|
||||
// - REACT-ONLY PARTS: Sub-parts with no matching HTML element file.
|
||||
// Get platforms.react but NOT platforms.html.
|
||||
//
|
||||
// Non-boolean data-attr types are inferred from StateAttrMap<State>:
|
||||
// - number → type: "number"
|
||||
// - string literal union → type: "'empty' | 'partial' | 'full'"
|
||||
// - type alias → expanded to literals (FillLevel → 'empty' | ...)
|
||||
// - boolean → type field OMITTED
|
||||
|
||||
describe('Gauge (multi-part)', () => {
|
||||
it('has empty top-level and parts record', () => {
|
||||
const gauge = findComponent('Gauge');
|
||||
expect(gauge).toBeDefined();
|
||||
|
||||
const ref = gauge!.reference;
|
||||
|
||||
// Top-level is empty for multi-part components
|
||||
expect(ref.props).toEqual({});
|
||||
expect(ref.state).toEqual({});
|
||||
expect(ref.dataAttributes).toEqual({});
|
||||
expect(ref.cssCustomProperties).toEqual({});
|
||||
expect(ref.platforms).toEqual({});
|
||||
|
||||
// Parts record exists
|
||||
expect(ref.parts).toBeDefined();
|
||||
expect(Object.keys(ref.parts!)).toEqual(expect.arrayContaining(['indicator', 'track', 'fill', 'label']));
|
||||
});
|
||||
|
||||
it('primary part (Indicator) gets core props, state, data-attrs, CSS vars', () => {
|
||||
const parts = findComponent('Gauge')!.reference.parts!;
|
||||
const indicator = parts.indicator!;
|
||||
|
||||
expect(indicator.name).toBe('Indicator');
|
||||
expect(indicator.description).toBe('A visual indicator for the current value. Renders a `<span>` element.');
|
||||
|
||||
// Props from shared core (GaugeProps), with defaultProps merged
|
||||
expect(indicator.props.min).toMatchObject({ type: 'number', default: '0' });
|
||||
expect(indicator.props.max).toMatchObject({ type: 'number', default: '100' });
|
||||
expect(indicator.props.label).toMatchObject({
|
||||
type: 'string | function',
|
||||
default: "''",
|
||||
});
|
||||
|
||||
// State from shared core (GaugeState)
|
||||
expect(indicator.state.percentage).toMatchObject({
|
||||
type: 'number',
|
||||
description: 'Current value as a percentage (0\u20131).',
|
||||
});
|
||||
|
||||
// Data attributes with non-boolean type inference
|
||||
expect(indicator.dataAttributes['data-percentage']).toMatchObject({
|
||||
description: 'Current percentage as a string.',
|
||||
type: 'number',
|
||||
});
|
||||
expect(indicator.dataAttributes['data-fill-level']).toMatchObject({
|
||||
description: 'The fill level.',
|
||||
});
|
||||
// FillLevel type alias → expanded to string literal union
|
||||
const fillType = indicator.dataAttributes['data-fill-level']!.type;
|
||||
expect(fillType).toBeDefined();
|
||||
expect(fillType).toContain("'empty'");
|
||||
expect(fillType).toContain("'partial'");
|
||||
expect(fillType).toContain("'full'");
|
||||
|
||||
// CSS vars from shared css-vars file
|
||||
expect(indicator.cssCustomProperties['--media-gauge-fill']).toEqual({
|
||||
description: 'The fill color of the gauge.',
|
||||
});
|
||||
|
||||
// Platforms: both html and react
|
||||
expect(indicator.platforms.html).toEqual({ tagName: 'media-gauge' });
|
||||
expect(indicator.platforms.react).toEqual({});
|
||||
});
|
||||
|
||||
it('sub-part (Track) gets its own tagName, empty props/state', () => {
|
||||
const track = findComponent('Gauge')!.reference.parts!.track!;
|
||||
|
||||
expect(track.name).toBe('Track');
|
||||
expect(track.description).toBe('The track area of the gauge. Renders a `<div>` element.');
|
||||
expect(track.props).toEqual({});
|
||||
expect(track.state).toEqual({});
|
||||
expect(track.dataAttributes).toEqual({});
|
||||
expect(track.cssCustomProperties).toEqual({});
|
||||
|
||||
// Has both HTML and React platforms
|
||||
expect(track.platforms.html).toEqual({ tagName: 'media-gauge-track' });
|
||||
expect(track.platforms.react).toEqual({});
|
||||
});
|
||||
|
||||
it('sub-part (Fill) inherits shared data-attrs via stateAttrMap heuristic', () => {
|
||||
const fill = findComponent('Gauge')!.reference.parts!.fill!;
|
||||
|
||||
expect(fill.name).toBe('Fill');
|
||||
// Sub-part custom React props: extracted from `FillProps` interface.
|
||||
// `children` is auto-excluded by the builder.
|
||||
expect(fill.props.color).toMatchObject({ type: 'string' });
|
||||
expect(fill.props.children).toBeUndefined();
|
||||
expect(fill.state).toEqual({});
|
||||
|
||||
// Fill's React source references `stateAttrMap`, so it gets the
|
||||
// component's shared data-attrs from gauge-data-attrs.ts
|
||||
expect(Object.keys(fill.dataAttributes).length).toBeGreaterThan(0);
|
||||
expect(fill.dataAttributes['data-percentage']).toBeDefined();
|
||||
expect(fill.dataAttributes['data-fill-level']).toBeDefined();
|
||||
|
||||
expect(fill.platforms.html).toEqual({ tagName: 'media-gauge-fill' });
|
||||
expect(fill.platforms.react).toEqual({});
|
||||
});
|
||||
|
||||
it('React-only sub-part (Label) has no platforms.html', () => {
|
||||
const label = findComponent('Gauge')!.reference.parts!.label!;
|
||||
|
||||
expect(label.name).toBe('Label');
|
||||
expect(label.description).toBe('An accessible label for the gauge value. Renders a `<span>` element.');
|
||||
|
||||
// React-only: has platforms.react but NOT platforms.html
|
||||
expect(label.platforms.react).toEqual({});
|
||||
expect(label.platforms.html).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// MULTI-PART WITH RE-EXPORTS: VolumeSlider
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Domain variant components like VolumeSlider re-export parts from
|
||||
// a base component (Slider). The builder resolves re-exports:
|
||||
// - Parses the origin's index.parts.ts to find the original export
|
||||
// - Derives element file paths from the ORIGIN component
|
||||
// - Data-attrs come from the ORIGIN component's data-attrs file
|
||||
// - Re-exported parts are NEVER primary
|
||||
// - Components with re-exported parts always produce multi-part
|
||||
// output (no single-part fallback, even if only 1 local export)
|
||||
|
||||
describe('VolumeSlider (multi-part with re-exports)', () => {
|
||||
it('has empty top-level and parts from both local and re-exported sources', () => {
|
||||
const vs = findComponent('VolumeSlider');
|
||||
expect(vs).toBeDefined();
|
||||
|
||||
const ref = vs!.reference;
|
||||
expect(ref.props).toEqual({});
|
||||
expect(ref.state).toEqual({});
|
||||
expect(ref.parts).toBeDefined();
|
||||
|
||||
// Root is local, Thumb and Track are re-exported from slider
|
||||
expect(ref.parts!.root).toBeDefined();
|
||||
expect(ref.parts!.thumb).toBeDefined();
|
||||
expect(ref.parts!.track).toBeDefined();
|
||||
});
|
||||
|
||||
it('local primary part (Root) gets VolumeSlider core data', () => {
|
||||
const root = findComponent('VolumeSlider')!.reference.parts!.root!;
|
||||
|
||||
expect(root.name).toBe('Root');
|
||||
// Props come from VolumeSliderProps
|
||||
expect(root.props.orientation).toBeDefined();
|
||||
// State comes from VolumeSliderState
|
||||
expect(root.state.volume).toBeDefined();
|
||||
// HTML tag comes from volume-slider-element.ts
|
||||
expect(root.platforms.html).toEqual({ tagName: 'media-volume-slider' });
|
||||
expect(root.platforms.react).toEqual({});
|
||||
});
|
||||
|
||||
it('re-exported sub-part (Thumb) resolves from slider origin', () => {
|
||||
const thumb = findComponent('VolumeSlider')!.reference.parts!.thumb!;
|
||||
|
||||
expect(thumb.name).toBe('Thumb');
|
||||
// HTML tag comes from SLIDER's element file (slider-thumb-element.ts),
|
||||
// not volume-slider's directory
|
||||
expect(thumb.platforms.html).toEqual({ tagName: 'media-slider-thumb' });
|
||||
expect(thumb.platforms.react).toEqual({});
|
||||
|
||||
// Data-attrs come from SLIDER's data-attrs file because the origin
|
||||
// React source (slider-thumb.tsx) references stateAttrMap
|
||||
expect(Object.keys(thumb.dataAttributes).length).toBeGreaterThan(0);
|
||||
expect(thumb.dataAttributes['data-value']).toBeDefined();
|
||||
expect(thumb.dataAttributes['data-dragging']).toBeDefined();
|
||||
});
|
||||
|
||||
it('re-exported sub-part (Track) with no stateAttrMap gets empty data-attrs', () => {
|
||||
const track = findComponent('VolumeSlider')!.reference.parts!.track!;
|
||||
|
||||
expect(track.name).toBe('Track');
|
||||
// slider-track.tsx does NOT reference stateAttrMap, so no data-attrs
|
||||
expect(track.dataAttributes).toEqual({});
|
||||
// HTML tag from slider's track element
|
||||
expect(track.platforms.html).toEqual({ tagName: 'media-slider-track' });
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// BASE COMPONENT: Slider
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The slider base is also discovered as its own component.
|
||||
// It has index.parts.ts with 3 local parts (Root, Thumb, Track).
|
||||
// This tests that the base component is independently valid.
|
||||
|
||||
describe('Slider (base multi-part)', () => {
|
||||
it('is discovered and has parts', () => {
|
||||
const slider = findComponent('Slider');
|
||||
expect(slider).toBeDefined();
|
||||
|
||||
const ref = slider!.reference;
|
||||
expect(ref.parts).toBeDefined();
|
||||
|
||||
// Root is primary (instantiates SliderCore)
|
||||
expect(ref.parts!.root).toBeDefined();
|
||||
expect(ref.parts!.root!.props.min).toBeDefined();
|
||||
expect(ref.parts!.root!.props.max).toBeDefined();
|
||||
expect(ref.parts!.root!.state.value).toBeDefined();
|
||||
expect(ref.parts!.root!.state.dragging).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// CROSS-CUTTING CONVENTIONS
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Cross-cutting conventions', () => {
|
||||
it('all components are discovered from core/ui directories', () => {
|
||||
const names = results.map((r) => r.name).sort();
|
||||
expect(names).toEqual(expect.arrayContaining(['Gauge', 'PiPButton', 'Slider', 'ToggleButton', 'VolumeSlider']));
|
||||
});
|
||||
|
||||
it('kebab name matches directory name', () => {
|
||||
expect(findComponent('ToggleButton')!.kebab).toBe('toggle-button');
|
||||
expect(findComponent('Gauge')!.kebab).toBe('gauge');
|
||||
expect(findComponent('Slider')!.kebab).toBe('slider');
|
||||
expect(findComponent('VolumeSlider')!.kebab).toBe('volume-slider');
|
||||
});
|
||||
|
||||
it('NAME_OVERRIDES: pip-button → PiPButton (not PipButton)', () => {
|
||||
// The NAME_OVERRIDES map handles cases where standard kebab-to-PascalCase
|
||||
// conversion is wrong. "pip-button" would normally become "PipButton",
|
||||
// but the override maps it to "PiPButton".
|
||||
const pip = findComponent('PiPButton');
|
||||
expect(pip).toBeDefined();
|
||||
expect(pip!.kebab).toBe('pip-button');
|
||||
expect(pip!.reference.name).toBe('PiPButton');
|
||||
// Props use the overridden name for interface lookup (PiPButtonProps)
|
||||
expect(pip!.reference.props.disabled).toBeDefined();
|
||||
expect(pip!.reference.state.active).toBeDefined();
|
||||
});
|
||||
|
||||
it('primary part appears first in parts record (sorted by isPrimary)', () => {
|
||||
const gaugeParts = Object.keys(findComponent('Gauge')!.reference.parts!);
|
||||
expect(gaugeParts[0]).toBe('indicator');
|
||||
|
||||
const vsParts = Object.keys(findComponent('VolumeSlider')!.reference.parts!);
|
||||
expect(vsParts[0]).toBe('root');
|
||||
});
|
||||
|
||||
it('props are sorted: required first, then alphabetical', () => {
|
||||
// All ToggleButton props are required (non-optional in the interface),
|
||||
// so they should be purely alphabetical within the required group.
|
||||
const toggleProps = Object.keys(findComponent('ToggleButton')!.reference.props);
|
||||
const sorted = [...toggleProps].sort((a, b) => a.localeCompare(b));
|
||||
expect(toggleProps).toEqual(sorted);
|
||||
});
|
||||
|
||||
it('optional fields are omitted from JSON when undefined', () => {
|
||||
const ref = findComponent('ToggleButton')!.reference;
|
||||
|
||||
// disabled has no detailedType (simple boolean, no abbreviation)
|
||||
expect('detailedType' in ref.props.disabled!).toBe(false);
|
||||
|
||||
// Boolean data-attrs have no type field
|
||||
expect('type' in ref.dataAttributes['data-pressed']!).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// UTIL PIPELINE
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
//
|
||||
// The util pipeline scans fixed entry points for exports matching
|
||||
// naming conventions (use*, select*, create*, *Controller) or @public
|
||||
// JSDoc. Each export produces a UtilReference JSON with overloads.
|
||||
//
|
||||
// Key behaviors:
|
||||
// - Hooks (use*): discovered from React entry points, framework: "react"
|
||||
// - Controllers (*Controller): discovered from HTML entry points, framework: "html"
|
||||
// - Selectors (select*): framework-agnostic (null)
|
||||
// - Factories (create*): framework depends on entry point
|
||||
// - @public exports: explicit inclusion regardless of naming
|
||||
// - create*Mixin: display name strips "create" prefix
|
||||
// - Slug collisions: React keeps bare slug, HTML gets prefixed with "html-"
|
||||
// - Multi-overload functions: each overload is preserved in the overloads array
|
||||
// - @label JSDoc: becomes the overload's label field
|
||||
// - Controller params: "- " prefix stripped from @param descriptions
|
||||
|
||||
describe('Util pipeline (end-to-end)', () => {
|
||||
const entries = getUtilEntries(FIXTURE_ROOT);
|
||||
|
||||
function findByName(name: string, framework?: 'react' | 'html' | null): UtilEntry | undefined {
|
||||
return entries.find((e) => e.data.name === name && (framework === undefined || e.framework === framework));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// DISCOVERY & FRAMEWORK ASSIGNMENT
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Exports are discovered by scanning entry point files and their
|
||||
// local re-exports. The framework is determined by which entry
|
||||
// point the export was found in.
|
||||
|
||||
describe('Discovery', () => {
|
||||
it('discovers hooks from React entry points', () => {
|
||||
expect(findByName('usePlayer', 'react')).toBeDefined();
|
||||
expect(findByName('useStore', 'react')).toBeDefined();
|
||||
expect(findByName('useFormat', 'react')).toBeDefined();
|
||||
});
|
||||
|
||||
it('discovers controllers from HTML entry points', () => {
|
||||
expect(findByName('PlayerController', 'html')).toBeDefined();
|
||||
expect(findByName('SnapshotController', 'html')).toBeDefined();
|
||||
});
|
||||
|
||||
it('discovers selectors as framework-agnostic (null)', () => {
|
||||
for (const name of ['selectPlayback', 'selectVolume', 'selectTime']) {
|
||||
const entry = findByName(name, null);
|
||||
expect(entry, `expected ${name} to be framework-agnostic`).toBeDefined();
|
||||
expect(entry!.framework).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('discovers @public exports (mergeProps, playerContext)', () => {
|
||||
expect(findByName('mergeProps', 'react')).toBeDefined();
|
||||
expect(findByName('playerContext', 'html')).toBeDefined();
|
||||
});
|
||||
|
||||
it('discovers factories from both React and HTML', () => {
|
||||
expect(findByName('createPlayer', 'react')).toBeDefined();
|
||||
expect(findByName('createPlayer', 'html')).toBeDefined();
|
||||
expect(findByName('createSelector', null)).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// DISPLAY NAME & SLUG
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Display names are the export name as-is, EXCEPT create*Mixin
|
||||
// factories which strip the "create" prefix.
|
||||
// Slugs are kebab-case of the display name. On collision, React
|
||||
// keeps the bare slug and HTML gets "html-" prefixed.
|
||||
|
||||
describe('Display name & slug', () => {
|
||||
it('strips "create" prefix from mixin display names', () => {
|
||||
const mixin = findByName('ContainerMixin', 'html');
|
||||
expect(mixin).toBeDefined();
|
||||
expect(mixin!.slug).toBe('container-mixin');
|
||||
});
|
||||
|
||||
it('resolves slug collisions: React bare, HTML prefixed', () => {
|
||||
const reactCreate = entries.find((e) => e.slug === 'create-player' && e.framework === 'react');
|
||||
const htmlCreate = entries.find((e) => e.slug === 'html-create-player' && e.framework === 'html');
|
||||
|
||||
expect(reactCreate).toBeDefined();
|
||||
expect(htmlCreate).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// OVERLOADS
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// When a function or constructor has multiple signatures, each
|
||||
// becomes a separate entry in the overloads array.
|
||||
// @label JSDoc tags on overload signatures become the label field.
|
||||
|
||||
describe('Overloads', () => {
|
||||
it('preserves multiple overload signatures', () => {
|
||||
const usePlayer = findByName('usePlayer', 'react');
|
||||
expect(usePlayer!.data.overloads.length).toBe(2);
|
||||
|
||||
const useStore = findByName('useStore', 'react');
|
||||
expect(useStore!.data.overloads.length).toBe(2);
|
||||
});
|
||||
|
||||
it('extracts @label from overload JSDoc', () => {
|
||||
const useFormat = findByName('useFormat', 'react');
|
||||
expect(useFormat!.data.overloads[0]!.label).toBe('Number');
|
||||
expect(useFormat!.data.overloads[1]!.label).toBe('String');
|
||||
});
|
||||
|
||||
it('omits label when @label is absent', () => {
|
||||
const useStore = findByName('useStore', 'react');
|
||||
expect(useStore!.data.overloads[0]!.label).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// EXTRACTION SHAPE
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Each util reference has: name, description?, overloads[].
|
||||
// Each overload has: label?, description?, parameters, returnValue.
|
||||
// Parameters and returnValue follow the same PropDef/StateDef shape
|
||||
// used by component references.
|
||||
|
||||
describe('Extraction shape', () => {
|
||||
it('hooks have description and overloads with parameters + returnValue', () => {
|
||||
const usePlayer = findByName('usePlayer', 'react');
|
||||
expect(usePlayer!.data.description).toBeDefined();
|
||||
|
||||
const overload = usePlayer!.data.overloads[0]!;
|
||||
expect(overload.returnValue).toBeDefined();
|
||||
expect(overload.returnValue.type).toBeDefined();
|
||||
});
|
||||
|
||||
it('controllers have constructor params and public members as returnValue.fields', () => {
|
||||
const snapshot = findByName('SnapshotController', 'html');
|
||||
expect(snapshot!.data.description).toBeDefined();
|
||||
|
||||
const overload = snapshot!.data.overloads[0]!;
|
||||
// Constructor parameters
|
||||
expect(overload.parameters.host).toBeDefined();
|
||||
|
||||
// Return value type includes class name with type params
|
||||
expect(overload.returnValue.type).toContain('SnapshotController');
|
||||
|
||||
// Public members as fields
|
||||
expect(overload.returnValue.fields).toBeDefined();
|
||||
expect(overload.returnValue.fields!.value).toBeDefined();
|
||||
expect(overload.returnValue.fields!.track).toBeDefined();
|
||||
});
|
||||
|
||||
it('controller param descriptions have "- " prefix stripped', () => {
|
||||
const snapshot = findByName('SnapshotController', 'html');
|
||||
const hostParam = snapshot!.data.overloads[0]!.parameters.host;
|
||||
expect(hostParam!.description).toBe('The host element.');
|
||||
expect(hostParam!.description).not.toMatch(/^-\s/);
|
||||
});
|
||||
|
||||
it('contexts (@public non-function) have empty parameters and type as returnValue', () => {
|
||||
const ctx = findByName('playerContext', 'html');
|
||||
expect(ctx!.data.description).toBeDefined();
|
||||
|
||||
const overload = ctx!.data.overloads[0]!;
|
||||
expect(overload.parameters).toEqual({});
|
||||
expect(overload.returnValue.type).toBeDefined();
|
||||
});
|
||||
|
||||
it('selectors have parameters and returnValue', () => {
|
||||
const sel = findByName('selectPlayback', null);
|
||||
expect(sel!.data.description).toBeDefined();
|
||||
|
||||
const overload = sel!.data.overloads[0]!;
|
||||
expect(Object.keys(overload.parameters).length).toBeGreaterThan(0);
|
||||
expect(overload.returnValue.type).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Multi-part component fixture (core).
|
||||
*
|
||||
* Exercises: multi-part Props/State extraction, defaultProps merging,
|
||||
* non-boolean data-attr type inference (string union, number).
|
||||
*/
|
||||
|
||||
export type FillLevel = 'empty' | 'partial' | 'full';
|
||||
|
||||
export interface GaugeProps {
|
||||
/** Minimum value. */
|
||||
min: number;
|
||||
/** Maximum value. */
|
||||
max: number;
|
||||
/** Custom label for accessibility. */
|
||||
label: string | ((state: GaugeState) => string);
|
||||
}
|
||||
|
||||
export interface GaugeState {
|
||||
/** Current value as a percentage (0–1). */
|
||||
percentage: number;
|
||||
/** The fill level. */
|
||||
fillLevel: FillLevel;
|
||||
}
|
||||
|
||||
export class GaugeCore {
|
||||
static readonly defaultProps = {
|
||||
min: 0,
|
||||
max: 100,
|
||||
label: '',
|
||||
};
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* CSS vars fixture for multi-part component.
|
||||
*
|
||||
* Exercises: CSS custom properties on a multi-part component (assigned to primary part).
|
||||
*/
|
||||
|
||||
export const GaugeCSSVars = {
|
||||
/** The fill color of the gauge. */
|
||||
fill: '--media-gauge-fill',
|
||||
} as const;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Data attributes fixture for multi-part component.
|
||||
*
|
||||
* Exercises: non-boolean type inference through satisfies StateAttrMap<State>.
|
||||
* - percentage → number type (shown in output)
|
||||
* - fillLevel → string literal union (shown in output, expanded from FillLevel alias)
|
||||
*/
|
||||
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
|
||||
type FillLevel = 'empty' | 'partial' | 'full';
|
||||
|
||||
interface GaugeState {
|
||||
percentage: number;
|
||||
fillLevel: FillLevel;
|
||||
}
|
||||
|
||||
export const GaugeDataAttrs = {
|
||||
/** Current percentage as a string. */
|
||||
percentage: 'data-percentage',
|
||||
/** The fill level. */
|
||||
fillLevel: 'data-fill-level',
|
||||
} as const satisfies StateAttrMap<GaugeState>;
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* NAME_OVERRIDES fixture.
|
||||
*
|
||||
* Exercises: The NAME_OVERRIDES map in pipeline.ts. The directory name is
|
||||
* "pip-button", which kebabToPascal would convert to "PipButton". But the
|
||||
* override maps it to "PiPButton" (capital P at position 2).
|
||||
*
|
||||
* This covers cases where standard kebab-to-PascalCase conversion produces
|
||||
* the wrong name. The builder uses NAME_OVERRIDES[dirName] ?? kebabToPascal(dirName).
|
||||
*/
|
||||
|
||||
export interface PiPButtonProps {
|
||||
/** Whether the button is disabled. */
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface PiPButtonState {
|
||||
/** Whether picture-in-picture is active. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export class PiPButtonCore {}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Base slider component (core).
|
||||
*
|
||||
* Exercises: base component whose parts get re-exported by domain variants
|
||||
* (volume-slider). This component is also discovered on its own.
|
||||
*/
|
||||
|
||||
export interface SliderProps {
|
||||
/** Minimum slider value. */
|
||||
min: number;
|
||||
/** Maximum slider value. */
|
||||
max: number;
|
||||
}
|
||||
|
||||
export interface SliderState {
|
||||
/** Current slider value (0–1). */
|
||||
value: number;
|
||||
/** Whether the user is dragging. */
|
||||
dragging: boolean;
|
||||
}
|
||||
|
||||
export class SliderCore {
|
||||
static readonly defaultProps = {
|
||||
min: 0,
|
||||
max: 100,
|
||||
};
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Data attributes for slider base (used by re-exported sub-parts).
|
||||
*
|
||||
* Exercises: boolean type (omitted) + non-boolean type for re-exported parts.
|
||||
*/
|
||||
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
|
||||
interface SliderState {
|
||||
value: number;
|
||||
dragging: boolean;
|
||||
}
|
||||
|
||||
export const SliderDataAttrs = {
|
||||
/** The current slider value. */
|
||||
value: 'data-value',
|
||||
/** Present when the user is dragging the slider. */
|
||||
dragging: 'data-dragging',
|
||||
} as const satisfies StateAttrMap<SliderState>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Single-part component fixture.
|
||||
*
|
||||
* Exercises: Props interface, State interface, defaultProps, function-typed prop
|
||||
* (triggers type abbreviation), @ignore JSDoc (skipped prop), ref prop (auto-skipped),
|
||||
* required prop (no default, not optional).
|
||||
*/
|
||||
|
||||
export interface ToggleButtonProps {
|
||||
/** Whether the button is disabled. */
|
||||
disabled: boolean;
|
||||
/** Custom label for the button. */
|
||||
label: string | ((state: ToggleButtonState) => string);
|
||||
/** @ignore Internal ref — should be excluded from output. */
|
||||
_internalFlag: boolean;
|
||||
/** React ref — auto-skipped by the builder. */
|
||||
ref: unknown;
|
||||
/** Callback when pressed state changes. */
|
||||
onPressedChange: (pressed: boolean) => void;
|
||||
}
|
||||
|
||||
export interface ToggleButtonState {
|
||||
/** Whether the toggle is pressed. */
|
||||
pressed: boolean;
|
||||
/** Whether the button is disabled. */
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export class ToggleButtonCore {
|
||||
static readonly defaultProps = {
|
||||
disabled: false,
|
||||
label: '',
|
||||
};
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* CSS vars fixture for single-part component.
|
||||
*
|
||||
* Exercises: CSS custom property extraction with JSDoc descriptions.
|
||||
*/
|
||||
|
||||
export const ToggleButtonCSSVars = {
|
||||
/** Background color when pressed. */
|
||||
pressed: '--media-toggle-pressed-bg',
|
||||
/** Transition duration for the toggle animation. */
|
||||
transition: '--media-toggle-transition',
|
||||
} as const;
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Data attributes fixture for single-part component.
|
||||
*
|
||||
* Exercises: boolean type inference (omitted), satisfies StateAttrMap<State> pattern.
|
||||
*/
|
||||
|
||||
type StateAttrMap<State> = { [Key in keyof State]?: string };
|
||||
|
||||
interface ToggleButtonState {
|
||||
pressed: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export const ToggleButtonDataAttrs = {
|
||||
/** Present when the toggle is pressed. */
|
||||
pressed: 'data-pressed',
|
||||
/** Present when the button is disabled. */
|
||||
disabled: 'data-disabled',
|
||||
} as const satisfies StateAttrMap<ToggleButtonState>;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Domain variant component (core).
|
||||
*
|
||||
* Exercises: domain variant components that share base logic (slider/)
|
||||
* but have their own directory under core/ui/. The builder discovers
|
||||
* components by directory — this file must exist for volume-slider to be found.
|
||||
*/
|
||||
|
||||
export interface VolumeSliderProps {
|
||||
/** The orientation of the slider. */
|
||||
orientation: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
export interface VolumeSliderState {
|
||||
/** Current volume (0–1). */
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export class VolumeSliderCore {
|
||||
static readonly defaultProps = {
|
||||
orientation: 'horizontal',
|
||||
};
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* HTML element fixture for multi-part primary part.
|
||||
*
|
||||
* Exercises: primary part gets the root element's tagName.
|
||||
*/
|
||||
|
||||
export class GaugeElement {
|
||||
static readonly tagName = 'media-gauge';
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* HTML element fixture for multi-part sub-part.
|
||||
*/
|
||||
|
||||
export class GaugeFillElement {
|
||||
static readonly tagName = 'media-gauge-fill';
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* HTML element fixture for multi-part sub-part.
|
||||
*
|
||||
* Exercises: sub-part element file naming convention ({component}-{part}-element.ts).
|
||||
*/
|
||||
|
||||
export class GaugeTrackElement {
|
||||
static readonly tagName = 'media-gauge-track';
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export class SliderElement {
|
||||
static readonly tagName = 'media-slider';
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export class SliderThumbElement {
|
||||
static readonly tagName = 'media-slider-thumb';
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export class SliderTrackElement {
|
||||
static readonly tagName = 'media-slider-track';
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* HTML element fixture for single-part component.
|
||||
*
|
||||
* Exercises: static tagName extraction for platforms.html.
|
||||
*/
|
||||
|
||||
export class ToggleButtonElement {
|
||||
static readonly tagName = 'media-toggle-button';
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export class VolumeSliderElement {
|
||||
static readonly tagName = 'media-volume-slider';
|
||||
}
|
||||
site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/gauge-fill.tsx
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Sub-part React component that references stateAttrMap.
|
||||
*
|
||||
* Exercises:
|
||||
* 1. Sub-part inheriting shared data-attrs from the component's data-attrs
|
||||
* file. The builder uses a string search heuristic — if the React source
|
||||
* contains "stateAttrMap", the sub-part gets shared data attributes.
|
||||
* 2. Sub-part custom React props. The builder extracts own members from the
|
||||
* `{LocalName}Props` interface (must be `interface`, not `type`).
|
||||
* `children` and React DOM attributes are excluded.
|
||||
*/
|
||||
|
||||
import type { GaugeDataAttrs } from '../../../../core/src/core/ui/gauge/gauge-data-attrs';
|
||||
|
||||
const stateAttrMap = {} as typeof GaugeDataAttrs;
|
||||
|
||||
/** The filled portion of the gauge. Renders a `<div>` element. */
|
||||
export function Fill() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Must be `interface` (not `type`) for extractSubPartProps to detect it.
|
||||
// `children` is auto-excluded by the builder.
|
||||
export interface FillProps {
|
||||
/** The color of the fill bar. */
|
||||
color: string;
|
||||
children: unknown;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Primary part React component.
|
||||
*
|
||||
* Exercises: primary part detection via `new GaugeCore` instantiation.
|
||||
* The builder checks React source files for `new {ComponentName}Core\b`.
|
||||
*/
|
||||
|
||||
class GaugeCore {}
|
||||
|
||||
/** A visual indicator for the current value. Renders a `<span>` element. */
|
||||
export function Indicator() {
|
||||
const core = new GaugeCore();
|
||||
return null;
|
||||
}
|
||||
|
||||
export type IndicatorProps = {};
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* React-only sub-part (no HTML element counterpart).
|
||||
*
|
||||
* Exercises: framework-divergent parts. Parts discovered from index.parts.ts
|
||||
* always get platforms.react. Parts WITHOUT a matching HTML element file do NOT
|
||||
* get platforms.html. This part has no gauge-label-element.ts in the HTML dir.
|
||||
*/
|
||||
|
||||
/** An accessible label for the gauge value. Renders a `<span>` element. */
|
||||
export function Label() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export type LabelProps = {};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Sub-part React component with no special behavior.
|
||||
*
|
||||
* Exercises: sub-part that has an HTML element counterpart but no data-attrs reference.
|
||||
* Gets empty props, state, dataAttributes, cssCustomProperties.
|
||||
*/
|
||||
|
||||
/** The track area of the gauge. Renders a `<div>` element. */
|
||||
export function Track() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export type TrackProps = {};
|
||||
site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/ui/gauge/index.parts.ts
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* React parts index for multi-part component.
|
||||
*
|
||||
* Exercises: multi-part detection, local exports for part discovery.
|
||||
* - Indicator: primary part (instantiates GaugeCore)
|
||||
* - Track: sub-part with HTML element
|
||||
* - Fill: sub-part with HTML element and stateAttrMap reference (gets shared data-attrs)
|
||||
* - Label: React-only part (no HTML element file)
|
||||
*/
|
||||
|
||||
export { Fill, type FillProps } from './gauge-fill';
|
||||
export { Indicator, type IndicatorProps } from './gauge-indicator';
|
||||
export { Label, type LabelProps } from './gauge-label';
|
||||
export { Track, type TrackProps } from './gauge-track';
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Slider base parts index.
|
||||
*
|
||||
* All local exports. volume-slider re-exports Thumb and Track from here.
|
||||
*/
|
||||
export { Root, type RootProps } from './slider-root';
|
||||
export { Thumb, type ThumbProps } from './slider-thumb';
|
||||
export { Track, type TrackProps } from './slider-track';
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Primary part of slider — instantiates SliderCore.
|
||||
*/
|
||||
|
||||
class SliderCore {}
|
||||
|
||||
/** The root slider container. Renders a `<div>` element. */
|
||||
export function Root() {
|
||||
const core = new SliderCore();
|
||||
return null;
|
||||
}
|
||||
|
||||
export type RootProps = {};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Slider sub-part that references stateAttrMap.
|
||||
*
|
||||
* When volume-slider re-exports this part, data-attrs come from
|
||||
* the ORIGIN component (slider), not the consuming component (volume-slider).
|
||||
*/
|
||||
|
||||
const stateAttrMap = {};
|
||||
|
||||
/** The draggable thumb of the slider. Renders a `<div>` element. */
|
||||
export function Thumb() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ThumbProps = {};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/** The track area of the slider. Renders a `<div>` element. */
|
||||
export function Track() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export type TrackProps = {};
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Volume slider parts index — re-exports from slider base.
|
||||
*
|
||||
* Exercises: re-exported parts from another component.
|
||||
* - Root: local export (primary part, instantiates VolumeSliderCore)
|
||||
* - Thumb: re-exported from slider (gets slider's HTML elements + data-attrs)
|
||||
* - Track: re-exported from slider (gets slider's HTML elements)
|
||||
*
|
||||
* Re-exported parts are NEVER primary. Their element files and data-attrs
|
||||
* are resolved from the ORIGIN component (slider), not the consumer (volume-slider).
|
||||
*
|
||||
* Because there are re-exported parts, this always produces multi-part output
|
||||
* (no single-part fallback).
|
||||
*/
|
||||
|
||||
export { Thumb, type ThumbProps, Track, type TrackProps } from '../slider/index.parts';
|
||||
export { Root, type RootProps } from './volume-slider-root';
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Primary part of volume-slider — instantiates VolumeSliderCore.
|
||||
*/
|
||||
|
||||
class VolumeSliderCore {}
|
||||
|
||||
/** The root volume slider container. Renders a `<div>` element. */
|
||||
export function Root() {
|
||||
const core = new VolumeSliderCore();
|
||||
return null;
|
||||
}
|
||||
|
||||
export type RootProps = {};
|
||||
@@ -1,105 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractHtml } from '../html-handler.js';
|
||||
import { createTestProgram } from './test-utils.js';
|
||||
|
||||
describe('extractHtml', () => {
|
||||
it('extracts tagName from {Name}Element class', () => {
|
||||
const code = `
|
||||
export class MockComponentElement {
|
||||
static readonly tagName = 'media-mock-component';
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.tagName).toBe('media-mock-component');
|
||||
});
|
||||
|
||||
it('extracts tagName without readonly modifier', () => {
|
||||
const code = `
|
||||
export class MockComponentElement {
|
||||
static tagName = 'media-mock-component';
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.tagName).toBe('media-mock-component');
|
||||
});
|
||||
|
||||
it('returns null when Element class not found', () => {
|
||||
const code = `
|
||||
export class OtherClass {
|
||||
static readonly tagName = 'media-other';
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when tagName not static', () => {
|
||||
const code = `
|
||||
export class MockComponentElement {
|
||||
readonly tagName = 'media-mock-component';
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when tagName is not a string literal', () => {
|
||||
const code = `
|
||||
const TAG = 'media-mock-component';
|
||||
export class MockComponentElement {
|
||||
static readonly tagName = TAG;
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when no tagName property exists', () => {
|
||||
const code = `
|
||||
export class MockComponentElement {
|
||||
static readonly otherProperty = 'value';
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'MockComponent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts tagName using custom elementName override', () => {
|
||||
const code = `
|
||||
export class TimeGroupElement {
|
||||
static readonly tagName = 'media-time-group';
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'Time', 'TimeGroupElement');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.tagName).toBe('media-time-group');
|
||||
});
|
||||
|
||||
it('returns null when elementName override does not match', () => {
|
||||
const code = `
|
||||
export class TimeGroupElement {
|
||||
static readonly tagName = 'media-time-group';
|
||||
}
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractHtml('test.ts', program, 'Time', 'TimeSeparatorElement');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,187 +0,0 @@
|
||||
import * as tae from 'typescript-api-extractor';
|
||||
import { describe, expect, it, type MockInstance, vi } from 'vitest';
|
||||
import { extractPartDescription, extractParts } from '../parts-handler.js';
|
||||
import { createTestProgram } from './test-utils.js';
|
||||
|
||||
vi.mock('typescript-api-extractor', async () => {
|
||||
const actual = await vi.importActual<typeof tae>('typescript-api-extractor');
|
||||
return {
|
||||
...actual,
|
||||
parseFromProgram: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockParseFromProgram = tae.parseFromProgram as unknown as MockInstance;
|
||||
|
||||
describe('extractParts', () => {
|
||||
it('extracts value exports from index.parts.ts', () => {
|
||||
const code = `
|
||||
export { Group, type GroupProps } from './time-group';
|
||||
export { Separator, type SeparatorProps } from './time-separator';
|
||||
export { Value, type ValueProps } from './time-value';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'Group', localName: 'Group', source: './time-group' },
|
||||
{ name: 'Separator', localName: 'Separator', source: './time-separator' },
|
||||
{ name: 'Value', localName: 'Value', source: './time-value' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters out type-only exports', () => {
|
||||
const code = `
|
||||
export { Group, type GroupProps } from './time-group';
|
||||
export type { SomeType } from './types';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([{ name: 'Group', localName: 'Group', source: './time-group' }]);
|
||||
});
|
||||
|
||||
it('returns empty array for file with no exports', () => {
|
||||
const code = `const x = 1;`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles multiple value exports from same source', () => {
|
||||
const code = `
|
||||
export { Foo, Bar } from './source';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'Foo', localName: 'Foo', source: './source' },
|
||||
{ name: 'Bar', localName: 'Bar', source: './source' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips type-only specifiers within a value export declaration', () => {
|
||||
const code = `
|
||||
export { Value, type ValueProps, type ValueState } from './time-value';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([{ name: 'Value', localName: 'Value', source: './time-value' }]);
|
||||
});
|
||||
|
||||
it('captures local symbol name for aliased exports', () => {
|
||||
const code = `
|
||||
export { ControlsRoot as Root, type ControlsRootProps as RootProps } from './controls-root';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([{ name: 'Root', localName: 'ControlsRoot', source: './controls-root' }]);
|
||||
});
|
||||
|
||||
it('includes non-local re-exports (caller is responsible for filtering)', () => {
|
||||
const code = `
|
||||
export { Root, type RootProps } from './slider-root';
|
||||
export { Thumb, type ThumbProps } from '../slider/index.parts';
|
||||
`;
|
||||
const program = createTestProgram(code);
|
||||
const result = extractParts('test.ts', program);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'Root', localName: 'Root', source: './slider-root' },
|
||||
{ name: 'Thumb', localName: 'Thumb', source: '../slider/index.parts' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPartDescription', () => {
|
||||
it('extracts JSDoc description from a named export', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [
|
||||
{
|
||||
name: 'Value',
|
||||
documentation: {
|
||||
description: 'Displays a formatted time value (current, duration, or remaining).',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Value');
|
||||
|
||||
expect(result).toBe('Displays a formatted time value (current, duration, or remaining).');
|
||||
});
|
||||
|
||||
it('strips @example blocks from description', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [
|
||||
{
|
||||
name: 'Group',
|
||||
documentation: {
|
||||
description: 'Container for composed time displays.\n\n@example\n```tsx\n<Time.Group />\n```',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Group');
|
||||
|
||||
expect(result).toBe('Container for composed time displays.');
|
||||
});
|
||||
|
||||
it('extracts JSDoc description from a local (non-aliased) symbol name', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [
|
||||
{
|
||||
name: 'ControlsRoot',
|
||||
documentation: {
|
||||
description: 'Root container for player controls.',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'ControlsRoot');
|
||||
|
||||
expect(result).toBe('Root container for player controls.');
|
||||
});
|
||||
|
||||
it('returns undefined when export is not found', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [{ name: 'OtherComponent', documentation: { description: 'Some desc.' } }],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Value');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when export has no documentation', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [{ name: 'Value' }],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Value');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for empty description', () => {
|
||||
const program = createTestProgram('');
|
||||
mockParseFromProgram.mockReturnValue({
|
||||
exports: [{ name: 'Value', documentation: { description: '' } }],
|
||||
});
|
||||
|
||||
const result = extractPartDescription('test.tsx', program, 'Value');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import * as ts from 'typescript';
|
||||
|
||||
/** Only suitable for AST-walking tests — no type resolution. */
|
||||
export function createTestProgram(code: string, fileName = 'test.ts'): ts.Program {
|
||||
const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
|
||||
const compilerHost = ts.createCompilerHost({});
|
||||
const originalGetSourceFile = compilerHost.getSourceFile;
|
||||
compilerHost.getSourceFile = (name, ...args) => {
|
||||
return name === fileName ? sourceFile : originalGetSourceFile.call(compilerHost, name, ...args);
|
||||
};
|
||||
compilerHost.fileExists = (name) => name === fileName;
|
||||
return ts.createProgram([fileName], {}, compilerHost);
|
||||
}
|
||||
|
||||
/** Suitable for tests that need type resolution via `getTypeChecker()`. */
|
||||
export function createTypedTestProgram(code: string, fileName = 'test.ts'): ts.Program {
|
||||
const sourceFile = ts.createSourceFile(fileName, code, ts.ScriptTarget.ESNext, true, ts.ScriptKind.TS);
|
||||
const options: ts.CompilerOptions = { strict: true, target: ts.ScriptTarget.ESNext };
|
||||
const compilerHost = ts.createCompilerHost(options);
|
||||
const originalGetSourceFile = compilerHost.getSourceFile;
|
||||
const originalFileExists = compilerHost.fileExists;
|
||||
compilerHost.getSourceFile = (name, ...args) => {
|
||||
return name === fileName ? sourceFile : originalGetSourceFile.call(compilerHost, name, ...args);
|
||||
};
|
||||
compilerHost.fileExists = (name) => name === fileName || originalFileExists.call(compilerHost, name);
|
||||
return ts.createProgram([fileName], options, compilerHost);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import * as path from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getUtilEntries, type UtilEntry } from '../util-handler';
|
||||
|
||||
const FIXTURE_ROOT = path.resolve(import.meta.dirname, 'fixtures/monorepo');
|
||||
|
||||
describe('getUtilEntries', () => {
|
||||
const entries = getUtilEntries(FIXTURE_ROOT);
|
||||
|
||||
function findByName(name: string, framework?: 'react' | 'html' | null): UtilEntry | undefined {
|
||||
return entries.find((e) => e.data.name === name && (framework === undefined || e.framework === framework));
|
||||
}
|
||||
|
||||
it('discovers hooks', () => {
|
||||
expect(findByName('usePlayer', 'react')).toBeDefined();
|
||||
expect(findByName('useStore', 'react')).toBeDefined();
|
||||
});
|
||||
|
||||
it('discovers controllers', () => {
|
||||
expect(findByName('PlayerController', 'html')).toBeDefined();
|
||||
expect(findByName('SnapshotController', 'html')).toBeDefined();
|
||||
});
|
||||
|
||||
it('discovers mixin with stripped display name', () => {
|
||||
const mixin = findByName('ContainerMixin', 'html');
|
||||
expect(mixin).toBeDefined();
|
||||
expect(mixin!.slug).toBe('container-mixin');
|
||||
});
|
||||
|
||||
it('discovers factories including createSelector', () => {
|
||||
const reactCreate = findByName('createPlayer', 'react');
|
||||
const htmlCreate = findByName('createPlayer', 'html');
|
||||
const createSelector = findByName('createSelector', null);
|
||||
|
||||
expect(reactCreate).toBeDefined();
|
||||
expect(htmlCreate).toBeDefined();
|
||||
expect(createSelector).toBeDefined();
|
||||
});
|
||||
|
||||
it('discovers @public utility and context', () => {
|
||||
expect(findByName('mergeProps', 'react')).toBeDefined();
|
||||
expect(findByName('playerContext', 'html')).toBeDefined();
|
||||
});
|
||||
|
||||
it('discovers selectors as framework-agnostic', () => {
|
||||
const selectorNames = ['selectPlayback', 'selectVolume', 'selectTime'];
|
||||
for (const name of selectorNames) {
|
||||
const entry = findByName(name, null);
|
||||
expect(entry, `expected to find ${name}`).toBeDefined();
|
||||
expect(entry!.framework).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('assigns correct frameworks', () => {
|
||||
// React
|
||||
expect(findByName('usePlayer')!.framework).toBe('react');
|
||||
expect(findByName('useStore')!.framework).toBe('react');
|
||||
expect(findByName('mergeProps')!.framework).toBe('react');
|
||||
|
||||
// HTML
|
||||
expect(findByName('PlayerController')!.framework).toBe('html');
|
||||
expect(findByName('SnapshotController')!.framework).toBe('html');
|
||||
expect(findByName('playerContext')!.framework).toBe('html');
|
||||
|
||||
// Framework-agnostic
|
||||
expect(findByName('selectPlayback')!.framework).toBeNull();
|
||||
expect(findByName('createSelector')!.framework).toBeNull();
|
||||
});
|
||||
|
||||
it('handles slug collision', () => {
|
||||
const reactCreate = entries.find((e) => e.slug === 'create-player');
|
||||
const htmlCreate = entries.find((e) => e.slug === 'html-create-player');
|
||||
|
||||
expect(reactCreate).toBeDefined();
|
||||
expect(reactCreate!.framework).toBe('react');
|
||||
expect(htmlCreate).toBeDefined();
|
||||
expect(htmlCreate!.framework).toBe('html');
|
||||
});
|
||||
|
||||
it('extracts multi-overload signatures', () => {
|
||||
const usePlayer = findByName('usePlayer', 'react');
|
||||
expect(usePlayer!.data.overloads).toHaveLength(2);
|
||||
|
||||
const useStore = findByName('useStore', 'react');
|
||||
expect(useStore!.data.overloads).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('preserves overloads with identical return types', () => {
|
||||
const useFormat = findByName('useFormat', 'react');
|
||||
expect(useFormat).toBeDefined();
|
||||
expect(useFormat!.data.overloads).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('extracts @label from overload JSDoc', () => {
|
||||
const useFormat = findByName('useFormat', 'react');
|
||||
expect(useFormat!.data.overloads[0]!.label).toBe('Number');
|
||||
expect(useFormat!.data.overloads[1]!.label).toBe('String');
|
||||
});
|
||||
|
||||
it('omits label when @label is not present', () => {
|
||||
const useStore = findByName('useStore', 'react');
|
||||
expect(useStore!.data.overloads[0]!.label).toBeUndefined();
|
||||
expect(useStore!.data.overloads[1]!.label).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips "- " prefix from controller param descriptions', () => {
|
||||
const snapshot = findByName('SnapshotController', 'html');
|
||||
expect(snapshot).toBeDefined();
|
||||
|
||||
const firstOverload = snapshot!.data.overloads[0]!;
|
||||
const hostParam = firstOverload.parameters.host;
|
||||
expect(hostParam).toBeDefined();
|
||||
expect(hostParam!.description).toBe('The host element.');
|
||||
expect(hostParam!.description).not.toMatch(/^-\s/);
|
||||
});
|
||||
|
||||
it('extracts JSDoc descriptions', () => {
|
||||
const usePlayer = findByName('usePlayer', 'react');
|
||||
expect(usePlayer!.data.description).toBeDefined();
|
||||
|
||||
const playerController = findByName('PlayerController', 'html');
|
||||
expect(playerController!.data.description).toBeDefined();
|
||||
|
||||
const playerContext = findByName('playerContext', 'html');
|
||||
expect(playerContext!.data.description).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { kebabToPascal, partKebabFromSource, sortProps } from '../utils.js';
|
||||
|
||||
describe('kebabToPascal', () => {
|
||||
it("converts 'play-button' to 'PlayButton'", () => {
|
||||
expect(kebabToPascal('play-button')).toBe('PlayButton');
|
||||
});
|
||||
|
||||
it("converts 'slider' to 'Slider'", () => {
|
||||
expect(kebabToPascal('slider')).toBe('Slider');
|
||||
});
|
||||
|
||||
it("converts 'time-display-current' to 'TimeDisplayCurrent'", () => {
|
||||
expect(kebabToPascal('time-display-current')).toBe('TimeDisplayCurrent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('partKebabFromSource', () => {
|
||||
it("derives 'value' from './time-value' with component 'time'", () => {
|
||||
expect(partKebabFromSource('./time-value', 'time')).toBe('value');
|
||||
});
|
||||
|
||||
it("derives 'group' from './time-group' with component 'time'", () => {
|
||||
expect(partKebabFromSource('./time-group', 'time')).toBe('group');
|
||||
});
|
||||
|
||||
it("derives 'separator' from './time-separator' with component 'time'", () => {
|
||||
expect(partKebabFromSource('./time-separator', 'time')).toBe('separator');
|
||||
});
|
||||
|
||||
it("handles multi-segment component names like 'play-button'", () => {
|
||||
expect(partKebabFromSource('./play-button-icon', 'play-button')).toBe('icon');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortProps', () => {
|
||||
it('sorts required props before optional props', () => {
|
||||
const props = {
|
||||
optional: { type: 'string' },
|
||||
required: { type: 'string', required: true as const },
|
||||
};
|
||||
|
||||
const result = sortProps(props);
|
||||
const keys = Object.keys(result);
|
||||
|
||||
expect(keys).toEqual(['required', 'optional']);
|
||||
});
|
||||
|
||||
it('sorts alphabetically within each group', () => {
|
||||
const props = {
|
||||
zebra: { type: 'string', required: true as const },
|
||||
apple: { type: 'string', required: true as const },
|
||||
mango: { type: 'string' },
|
||||
banana: { type: 'string' },
|
||||
};
|
||||
|
||||
const result = sortProps(props);
|
||||
const keys = Object.keys(result);
|
||||
|
||||
expect(keys).toEqual(['apple', 'zebra', 'banana', 'mango']);
|
||||
});
|
||||
|
||||
it('keeps all-optional props alphabetical', () => {
|
||||
const props = {
|
||||
charlie: { type: 'string' },
|
||||
alpha: { type: 'string' },
|
||||
bravo: { type: 'string' },
|
||||
};
|
||||
|
||||
const result = sortProps(props);
|
||||
const keys = Object.keys(result);
|
||||
|
||||
expect(keys).toEqual(['alpha', 'bravo', 'charlie']);
|
||||
});
|
||||
|
||||
it('keeps all-required props alphabetical', () => {
|
||||
const props = {
|
||||
charlie: { type: 'string', required: true as const },
|
||||
alpha: { type: 'string', required: true as const },
|
||||
bravo: { type: 'string', required: true as const },
|
||||
};
|
||||
|
||||
const result = sortProps(props);
|
||||
const keys = Object.keys(result);
|
||||
|
||||
expect(keys).toEqual(['alpha', 'bravo', 'charlie']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user