From a4d8e2a2558a253fd92381ee8c2773bdab4c6425 Mon Sep 17 00:00:00 2001 From: Darius Cepulis Date: Tue, 7 Apr 2026 09:24:23 -0500 Subject: [PATCH] =?UTF-8?q?Feature:=20Feature=20and=20preset=20reference?= =?UTF-8?q?=20=E2=80=94=20E2E=20tests=20+=20implementation=20(#1248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.6 (1M context) --- .../api-docs-builder/src/feature-handler.ts | 216 +++++++++++ site/scripts/api-docs-builder/src/pipeline.ts | 61 ++++ .../api-docs-builder/src/preset-handler.ts | 240 +++++++++++++ .../api-docs-builder/src/tests/e2e.test.ts | 335 +++++++++++++++++- .../packages/core/src/core/media/state.ts | 55 +++ .../monorepo/packages/core/src/dom/feature.ts | 9 + .../src/dom/store/features/feature.parts.ts | 8 + .../core/src/dom/store/features/index.ts | 12 + .../core/src/dom/store/features/playback.ts | 20 ++ .../core/src/dom/store/features/presets.ts | 13 + .../core/src/dom/store/features/volume.ts | 23 ++ .../packages/html/src/define/audio/skin.ts | 10 + .../packages/html/src/define/skin-element.ts | 11 + .../html/src/define/video/minimal-skin.ts | 10 + .../html/src/define/video/skin.tailwind.ts | 10 + .../packages/html/src/define/video/skin.ts | 10 + .../packages/html/src/presets/audio.ts | 7 + .../packages/html/src/presets/video.ts | 11 + .../packages/react/src/media/audio.ts | 4 + .../packages/react/src/media/video.ts | 7 + .../packages/react/src/presets/audio/index.ts | 8 + .../packages/react/src/presets/audio/skin.ts | 4 + .../packages/react/src/presets/video/index.ts | 11 + .../react/src/presets/video/minimal-skin.ts | 6 + .../react/src/presets/video/skin.tailwind.ts | 6 + .../packages/react/src/presets/video/skin.ts | 6 + 26 files changed, 1112 insertions(+), 1 deletion(-) create mode 100644 site/scripts/api-docs-builder/src/feature-handler.ts create mode 100644 site/scripts/api-docs-builder/src/preset-handler.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/core/media/state.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/feature.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/feature.parts.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/playback.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/presets.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/core/src/dom/store/features/volume.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/audio/skin.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/skin-element.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/video/minimal-skin.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/video/skin.tailwind.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/define/video/skin.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/presets/audio.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/html/src/presets/video.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/media/audio.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/media/video.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/presets/audio/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/presets/audio/skin.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/presets/video/index.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/presets/video/minimal-skin.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/presets/video/skin.tailwind.ts create mode 100644 site/scripts/api-docs-builder/src/tests/fixtures/monorepo/packages/react/src/presets/video/skin.ts diff --git a/site/scripts/api-docs-builder/src/feature-handler.ts b/site/scripts/api-docs-builder/src/feature-handler.ts new file mode 100644 index 00000000..31142ef2 --- /dev/null +++ b/site/scripts/api-docs-builder/src/feature-handler.ts @@ -0,0 +1,216 @@ +/** + * Feature reference extraction. + * + * Discovers features from packages/core/src/dom/store/features/ and extracts + * state/action definitions from their state interfaces in media/state.ts. + * + * Uses the TypeScript checker API (not TAE) for interface extraction because + * state interfaces use method signatures (play(): void) which TAE doesn't + * handle — it only handles property-with-function-type syntax. + * + * Convention: + * - Feature files: *.ts in the features directory (excluding index, presets, feature.parts) + * - Feature exports: const matching *Feature (singular, not *Features) + * - State type: explicit return type annotation on the state() arrow function + * - State interfaces: exported from packages/core/src/core/media/state.ts + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as ts from 'typescript'; +import * as tae from 'typescript-api-extractor'; +import type { FeatureActionDef, FeatureReference, FeatureResult, FeatureStateDef } from './pipeline.js'; + +const SKIP_FILES = new Set(['index.ts', 'presets.ts', 'feature.parts.ts']); + +interface FeatureSource { + filePath: string; + name: string; + stateTypeName: string; +} + +// ─── Discovery ──────────────────────────────────────────────────── + +function discoverFeatureSources(featuresDir: string): FeatureSource[] { + const sources: FeatureSource[] = []; + const files = fs.readdirSync(featuresDir).filter((f) => f.endsWith('.ts') && !SKIP_FILES.has(f)); + + for (const file of files) { + const filePath = path.join(featuresDir, file); + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + + ts.forEachChild(sourceFile, (node) => { + if (!ts.isVariableStatement(node)) return; + if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return; + + for (const decl of node.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) continue; + const varName = decl.name.text; + if (!varName.endsWith('Feature') || varName.endsWith('Features')) continue; + + if (!decl.initializer || !ts.isCallExpression(decl.initializer)) continue; + const arg = decl.initializer.arguments[0]; + if (!arg || !ts.isObjectLiteralExpression(arg)) continue; + + let name: string | undefined; + let stateTypeName: string | undefined; + + for (const prop of arg.properties) { + if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue; + + if (prop.name.text === 'name' && ts.isStringLiteral(prop.initializer)) { + name = prop.initializer.text; + } + + if (prop.name.text === 'state') { + const fn = prop.initializer; + if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && fn.type && ts.isTypeReferenceNode(fn.type)) { + stateTypeName = fn.type.typeName.getText(sourceFile); + } + } + } + + if (name && stateTypeName) { + sources.push({ filePath, name, stateTypeName }); + } + } + }); + } + + return sources; +} + +// ─── Type Formatting ────────────────────────────────────────────── + +function formatCheckerType(type: ts.Type, checker: ts.TypeChecker): string { + if (type.isUnion()) { + // TypeScript internally represents `boolean` as `false | true` + const isBooleanUnion = + type.types.length === 2 && type.types.every((t) => !!(t.flags & ts.TypeFlags.BooleanLiteral)); + if (isBooleanUnion) return 'boolean'; + + return type.types.map((t) => formatCheckerType(t, checker)).join(' | '); + } + if (type.isStringLiteral()) { + return `'${type.value}'`; + } + return checker.typeToString(type); +} + +// ─── JSDoc Extraction ───────────────────────────────────────────── + +function getJSDocDescription(node: ts.Node): string | undefined { + const jsDocNodes = (node as { jsDoc?: ts.JSDoc[] }).jsDoc; + if (!jsDocNodes || jsDocNodes.length === 0) return undefined; + + const doc = jsDocNodes[0]!; + if (typeof doc.comment === 'string') return doc.comment; + if (!doc.comment) return undefined; + + // NodeArray — concatenate text parts + const parts: string[] = []; + for (const part of doc.comment) { + if (typeof part === 'string') { + parts.push(part); + } else if ('text' in part) { + parts.push(part.text); + } + } + return parts.join('') || undefined; +} + +// ─── Interface Extraction ───────────────────────────────────────── + +function extractInterfaceMembers( + interfaceDecl: ts.InterfaceDeclaration, + checker: ts.TypeChecker, + sourceFile: ts.SourceFile +): { state: Record; actions: Record } { + const state: Record = {}; + const actions: Record = {}; + + for (const member of interfaceDecl.members) { + const name = member.name?.getText(sourceFile); + if (!name) continue; + + const description = getJSDocDescription(member); + + if (ts.isMethodSignature(member)) { + const params = member.parameters + .map((p) => { + const pName = p.name.getText(sourceFile); + const pType = p.type ? formatCheckerType(checker.getTypeFromTypeNode(p.type), checker) : 'unknown'; + return `${pName}: ${pType}`; + }) + .join(', '); + + let returnType = 'void'; + if (member.type) { + returnType = formatCheckerType(checker.getTypeFromTypeNode(member.type), checker); + } + + const def: FeatureActionDef = { type: `(${params}) => ${returnType}` }; + if (description) def.description = description; + actions[name] = def; + } else if (ts.isPropertySignature(member) && member.type) { + const memberType = checker.getTypeFromTypeNode(member.type); + const typeStr = formatCheckerType(memberType, checker); + const def: FeatureStateDef = { type: typeStr }; + if (description) def.description = description; + state[name] = def; + } + } + + return { state, actions }; +} + +// ─── Pipeline ───────────────────────────────────────────────────── + +export function generateFeatureReferences(monorepoRoot: string): FeatureResult[] { + const featuresDir = path.join(monorepoRoot, 'packages/core/src/dom/store/features'); + const stateFilePath = path.join(monorepoRoot, 'packages/core/src/core/media/state.ts'); + + if (!fs.existsSync(featuresDir) || !fs.existsSync(stateFilePath)) return []; + + const sources = discoverFeatureSources(featuresDir); + if (sources.length === 0) return []; + + // Create a TS program with the state file for the checker + const tsconfigPath = path.join(monorepoRoot, 'tsconfig.base.json'); + const config = tae.loadConfig(tsconfigPath); + config.options.rootDir = monorepoRoot; + const program = ts.createProgram([stateFilePath], config.options); + const checker = program.getTypeChecker(); + const stateSourceFile = program.getSourceFile(stateFilePath); + if (!stateSourceFile) return []; + + // Build a map of interface name → declaration + const interfaces = new Map(); + ts.forEachChild(stateSourceFile, (node) => { + if (ts.isInterfaceDeclaration(node)) { + interfaces.set(node.name.text, node); + } + }); + + const results: FeatureResult[] = []; + for (const source of sources) { + const interfaceDecl = interfaces.get(source.stateTypeName); + if (!interfaceDecl) continue; + + const description = getJSDocDescription(interfaceDecl); + const { state, actions } = extractInterfaceMembers(interfaceDecl, checker, stateSourceFile); + + const ref: FeatureReference = { + name: source.name, + slug: source.name, + state, + actions, + }; + + if (description) ref.description = description; + + results.push({ name: source.name, slug: source.name, reference: ref }); + } + + return results; +} diff --git a/site/scripts/api-docs-builder/src/pipeline.ts b/site/scripts/api-docs-builder/src/pipeline.ts index 4062ca9c..459cbeea 100644 --- a/site/scripts/api-docs-builder/src/pipeline.ts +++ b/site/scripts/api-docs-builder/src/pipeline.ts @@ -509,3 +509,64 @@ export function generateComponentReferences(monorepoRoot: string): ComponentResu return results; } + +// ═══════════════════════════════════════════════════════════════════════ +// FEATURE REFERENCE PIPELINE +// ═══════════════════════════════════════════════════════════════════════ + +export interface FeatureStateDef { + type: string; + detailedType?: string; + description?: string; +} + +export interface FeatureActionDef { + type: string; + detailedType?: string; + description?: string; +} + +export interface FeatureReference { + name: string; + slug: string; + description?: string; + state: Record; + actions: Record; +} + +export interface FeatureResult { + name: string; + slug: string; + reference: FeatureReference; +} + +export { generateFeatureReferences } from './feature-handler.js'; + +// ═══════════════════════════════════════════════════════════════════════ +// PRESET REFERENCE PIPELINE +// ═══════════════════════════════════════════════════════════════════════ + +export interface PresetSkinDef { + name: string; + tagName?: string; +} + +export interface PresetReference { + name: string; + featureBundle: string; + features: string[]; + html: { + skins: PresetSkinDef[]; + }; + react: { + skins: PresetSkinDef[]; + mediaElement: string; + }; +} + +export interface PresetResult { + name: string; + reference: PresetReference; +} + +export { generatePresetReferences } from './preset-handler.js'; diff --git a/site/scripts/api-docs-builder/src/preset-handler.ts b/site/scripts/api-docs-builder/src/preset-handler.ts new file mode 100644 index 00000000..f6644c3e --- /dev/null +++ b/site/scripts/api-docs-builder/src/preset-handler.ts @@ -0,0 +1,240 @@ +/** + * Preset reference extraction. + * + * Discovers presets from packages/{html,react}/src/presets/ and extracts + * feature bundles, skins, and media elements from their index files. + * + * Uses raw TypeScript AST (no type checker needed) since classification + * is naming-convention-based and tagName extraction is from static properties. + * + * Convention: + * - HTML presets: packages/html/src/presets/{name}.ts + * - React presets: packages/react/src/presets/{name}/index.ts + * - Feature bundles: exports matching *Features (plural) + * - Skins: exports matching *Skin or *SkinElement (not *Tailwind*) + * - Tailwind: source specifier contains '.tailwind' → excluded + * - Media elements: remaining value exports (React only) + * - Feature resolution: packages/core/src/dom/store/features/presets.ts + */ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as ts from 'typescript'; +import type { PresetReference, PresetResult, PresetSkinDef } from './pipeline.js'; + +interface ExportInfo { + name: string; + sourceSpecifier: string; +} + +// ─── Export Parsing ─────────────────────────────────────────────── + +function parseNamedExports(filePath: string): ExportInfo[] { + if (!fs.existsSync(filePath)) return []; + + const content = fs.readFileSync(filePath, 'utf-8'); + const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true); + const exports: ExportInfo[] = []; + + ts.forEachChild(sourceFile, (node) => { + if (!ts.isExportDeclaration(node) || !node.moduleSpecifier) return; + const sourceSpecifier = (node.moduleSpecifier as ts.StringLiteral).text; + + if (node.exportClause && ts.isNamedExports(node.exportClause)) { + for (const element of node.exportClause.elements) { + // Skip type-only exports + if (element.isTypeOnly) continue; + exports.push({ name: element.name.text, sourceSpecifier }); + } + } + // Note: `export * from` (namespace re-exports) are skipped — we only handle named exports + }); + + return exports; +} + +// ─── Export Classification ──────────────────────────────────────── + +function isFeatureBundle(name: string): boolean { + return name.endsWith('Features'); +} + +function isTailwind(sourceSpecifier: string): boolean { + return sourceSpecifier.includes('.tailwind'); +} + +function isSkin(name: string): boolean { + return /Skin(Element)?$/.test(name); +} + +// ─── Tag Name Extraction ───────────────────────────────────────── + +function extractTagName(elementFilePath: string): string | undefined { + if (!fs.existsSync(elementFilePath)) return undefined; + + const content = fs.readFileSync(elementFilePath, 'utf-8'); + const sourceFile = ts.createSourceFile(elementFilePath, content, ts.ScriptTarget.Latest, true); + + let tagName: string | undefined; + + function visit(node: ts.Node) { + if ( + ts.isPropertyDeclaration(node) && + node.name && + ts.isIdentifier(node.name) && + node.name.text === 'tagName' && + node.modifiers?.some((m) => m.kind === ts.SyntaxKind.StaticKeyword) && + node.initializer && + ts.isStringLiteral(node.initializer) + ) { + tagName = node.initializer.text; + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return tagName; +} + +// ─── Feature Bundle Resolution ──────────────────────────────────── + +function parseFeatureBundles(presetsFilePath: string): Map { + const map = new Map(); + if (!fs.existsSync(presetsFilePath)) return map; + + const content = fs.readFileSync(presetsFilePath, 'utf-8'); + const sourceFile = ts.createSourceFile(presetsFilePath, content, ts.ScriptTarget.Latest, true); + + ts.forEachChild(sourceFile, (node) => { + if (!ts.isVariableStatement(node)) return; + if (!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return; + + for (const decl of node.declarationList.declarations) { + if (!ts.isIdentifier(decl.name)) continue; + const name = decl.name.text; + if (!name.endsWith('Features')) continue; + + if (decl.initializer && ts.isArrayLiteralExpression(decl.initializer)) { + const features: string[] = []; + for (const element of decl.initializer.elements) { + if (ts.isIdentifier(element)) { + // Strip 'Feature' suffix: playbackFeature → playback + const featureName = element.text.replace(/Feature$/, ''); + features.push(featureName); + } + } + map.set(name, features); + } + } + }); + + return map; +} + +// ─── Preset Discovery ───────────────────────────────────────────── + +function discoverPresetNames(htmlPresetsDir: string, reactPresetsDir: string): string[] { + const names = new Set(); + + // HTML presets: {name}.ts files + if (fs.existsSync(htmlPresetsDir)) { + for (const file of fs.readdirSync(htmlPresetsDir)) { + if (file.endsWith('.ts')) { + names.add(file.replace(/\.ts$/, '')); + } + } + } + + // React presets: {name}/ directories with index.ts + if (fs.existsSync(reactPresetsDir)) { + for (const dir of fs.readdirSync(reactPresetsDir, { withFileTypes: true })) { + if (dir.isDirectory() && fs.existsSync(path.join(reactPresetsDir, dir.name, 'index.ts'))) { + names.add(dir.name); + } + } + } + + return [...names].sort(); +} + +// ─── Preset Reference Building ──────────────────────────────────── + +function buildPresetReference( + presetName: string, + htmlPresetsDir: string, + reactPresetsDir: string, + featureBundleMap: Map, + monorepoRoot: string +): PresetResult | null { + const htmlPresetFile = path.join(htmlPresetsDir, `${presetName}.ts`); + const reactPresetFile = path.join(reactPresetsDir, presetName, 'index.ts'); + + const htmlExports = parseNamedExports(htmlPresetFile); + const reactExports = parseNamedExports(reactPresetFile); + + // Find feature bundle (from either HTML or React exports) + const allExports = [...htmlExports, ...reactExports]; + const bundleExport = allExports.find((e) => isFeatureBundle(e.name)); + if (!bundleExport) return null; + + const features = featureBundleMap.get(bundleExport.name) ?? []; + + // Classify HTML exports + const htmlSkins: PresetSkinDef[] = []; + for (const exp of htmlExports) { + if (isFeatureBundle(exp.name)) continue; + if (isTailwind(exp.sourceSpecifier)) continue; + if (isSkin(exp.name)) { + // Resolve the source file to extract tagName + const resolvedPath = path.resolve(path.dirname(htmlPresetFile), `${exp.sourceSpecifier}.ts`); + const tagName = extractTagName(resolvedPath); + if (tagName) { + htmlSkins.push({ name: exp.name, tagName }); + } + } + } + + // Classify React exports + const reactSkins: PresetSkinDef[] = []; + let reactMediaElement: string | undefined; + for (const exp of reactExports) { + if (isFeatureBundle(exp.name)) continue; + if (isTailwind(exp.sourceSpecifier)) continue; + if (isSkin(exp.name)) { + reactSkins.push({ name: exp.name }); + } else { + // Remaining value exports → media element + reactMediaElement = exp.name; + } + } + + const ref: PresetReference = { + name: presetName, + featureBundle: bundleExport.name, + features, + html: { skins: htmlSkins }, + react: { skins: reactSkins, mediaElement: reactMediaElement ?? '' }, + }; + + return { name: presetName, reference: ref }; +} + +// ─── Pipeline ───────────────────────────────────────────────────── + +export function generatePresetReferences(monorepoRoot: string): PresetResult[] { + const htmlPresetsDir = path.join(monorepoRoot, 'packages/html/src/presets'); + const reactPresetsDir = path.join(monorepoRoot, 'packages/react/src/presets'); + const presetsFilePath = path.join(monorepoRoot, 'packages/core/src/dom/store/features/presets.ts'); + + const presetNames = discoverPresetNames(htmlPresetsDir, reactPresetsDir); + if (presetNames.length === 0) return []; + + const featureBundleMap = parseFeatureBundles(presetsFilePath); + + const results: PresetResult[] = []; + for (const name of presetNames) { + const result = buildPresetReference(name, htmlPresetsDir, reactPresetsDir, featureBundleMap, monorepoRoot); + if (result) results.push(result); + } + + return results; +} diff --git a/site/scripts/api-docs-builder/src/tests/e2e.test.ts b/site/scripts/api-docs-builder/src/tests/e2e.test.ts index 9e177c66..42b63a18 100644 --- a/site/scripts/api-docs-builder/src/tests/e2e.test.ts +++ b/site/scripts/api-docs-builder/src/tests/e2e.test.ts @@ -35,10 +35,40 @@ * create* factory, mixin display name stripping, selector discovery, * @label overloads, slug collision (react vs html create-player), * framework assignment. + * + * Features (packages/core/src/dom/store/features/): + * playback.ts — Simple feature. Exercises: boolean state properties, + * void/Promise action methods, JSDoc description extraction. + * volume.ts — Complex feature. Exercises: numeric state, type alias + * (MediaFeatureAvailability), methods with params + returns, + * interface-level JSDoc → feature description. + * presets.ts — Feature bundles. Exercises: plural *Features naming + * (filtered out of feature discovery), array resolution + * for preset feature lists. + * feature.parts.ts — Short aliases (playbackFeature as playback, etc.). + * Exercises: namespace re-export filtering (export * as features). + * index.ts — Re-export barrel. Exercises: feature discovery filtering + * (singular *Feature only, not *Features or namespaces). + * + * Presets: + * HTML (packages/html/src/presets/): + * video.ts — Exercises: feature bundle export, multiple HTML skins + * (SkinElement inheritance), tailwind skin exclusion. + * audio.ts — Exercises: single skin, subset of features. + * React (packages/react/src/presets/): + * video/ — Exercises: feature bundle, React skins (*Skin naming), + * media element export, tailwind skin exclusion. + * audio/ — Exercises: single skin, different media element. */ import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { generateComponentReferences } from '../pipeline'; +import { + type FeatureResult, + generateComponentReferences, + generateFeatureReferences, + generatePresetReferences, + type PresetResult, +} from '../pipeline'; import { getUtilEntries, type UtilEntry } from '../util-handler'; const FIXTURE_ROOT = path.resolve(import.meta.dirname, 'fixtures/monorepo'); @@ -629,3 +659,306 @@ describe('Util pipeline (end-to-end)', () => { }); }); }); + +// ═══════════════════════════════════════════════════════════════════════ +// FEATURE PIPELINE +// ═══════════════════════════════════════════════════════════════════════ +// +// Features are defined via `definePlayerFeature()` and discovered from +// the features index. Each feature's state interface is split into two +// records: `state` (non-method properties) and `actions` (methods). +// +// Key behaviors: +// - Discovery: singular *Feature exports from the features index +// - Filtering: plural *Features (feature bundles) are excluded +// - State extraction: interface properties → state record +// - Action extraction: interface methods → actions record +// - JSDoc: member descriptions flow through, interface-level JSDoc +// becomes the feature description +// - Type aliases: expanded in the output (MediaFeatureAvailability → +// 'available' | 'unavailable' | 'unsupported') +// - Slug: derived from feature name, used for cross-linking from presets + +describe('Feature pipeline (end-to-end)', () => { + const results = generateFeatureReferences(FIXTURE_ROOT); + + function findFeature(name: string): FeatureResult | undefined { + return results.find((r) => r.name === name); + } + + // ───────────────────────────────────────────────────────────────── + // DISCOVERY + // ───────────────────────────────────────────────────────────────── + + describe('Discovery', () => { + it('discovers features from the features index', () => { + const names = results.map((r) => r.name); + expect(names).toContain('playback'); + expect(names).toContain('volume'); + }); + + it('excludes feature bundles (plural *Features)', () => { + const names = results.map((r) => r.name); + expect(names).not.toContain('videoFeatures'); + expect(names).not.toContain('audioFeatures'); + }); + + it('excludes namespace re-exports (export * as features)', () => { + const names = results.map((r) => r.name); + expect(names).not.toContain('features'); + }); + + it('produces one result per feature', () => { + expect(results.length).toBe(2); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // PLAYBACK FEATURE (simple: booleans + void methods) + // ───────────────────────────────────────────────────────────────── + // + // MediaPlaybackState has: + // - paused: boolean (state) + // - ended: boolean (state) + // - play(): Promise (action) + // - pause(): void (action) + // No interface-level JSDoc → no feature description. + + describe('playback (simple feature)', () => { + it('has name and slug', () => { + const playback = findFeature('playback'); + expect(playback).toBeDefined(); + expect(playback!.slug).toBe('playback'); + expect(playback!.reference.name).toBe('playback'); + expect(playback!.reference.slug).toBe('playback'); + }); + + it('has no description (no interface-level JSDoc)', () => { + const ref = findFeature('playback')!.reference; + expect(ref.description).toBeUndefined(); + }); + + it('extracts boolean properties as state', () => { + const state = findFeature('playback')!.reference.state; + expect(state.paused).toEqual({ + type: 'boolean', + description: 'Whether playback is paused.', + }); + expect(state.ended).toEqual({ + type: 'boolean', + description: 'Whether playback has reached the end.', + }); + }); + + it('extracts methods as actions', () => { + const actions = findFeature('playback')!.reference.actions; + + expect(actions.play).toBeDefined(); + expect(actions.play!.type).toContain('Promise'); + expect(actions.play!.description).toBe('Start playback.'); + + expect(actions.pause).toBeDefined(); + expect(actions.pause!.type).toContain('void'); + expect(actions.pause!.description).toBe('Pause playback.'); + }); + + it('does not mix state and actions', () => { + const ref = findFeature('playback')!.reference; + // Methods should not appear in state + expect(ref.state['play' as keyof typeof ref.state]).toBeUndefined(); + expect(ref.state['pause' as keyof typeof ref.state]).toBeUndefined(); + // Properties should not appear in actions + expect(ref.actions['paused' as keyof typeof ref.actions]).toBeUndefined(); + expect(ref.actions['ended' as keyof typeof ref.actions]).toBeUndefined(); + }); + }); + + // ───────────────────────────────────────────────────────────────── + // VOLUME FEATURE (complex: types, params, returns, description) + // ───────────────────────────────────────────────────────────────── + // + // MediaVolumeState has interface-level JSDoc → feature description. + // - volume: number (state) + // - muted: boolean (state) + // - volumeAvailability: MediaFeatureAvailability (state, type alias) + // - setVolume(volume: number): number (action with param + return) + // - toggleMuted(): boolean (action with return) + + describe('volume (complex feature)', () => { + it('has description from interface-level JSDoc', () => { + const ref = findFeature('volume')!.reference; + expect(ref.description).toBe('Controls audio volume and mute state.'); + }); + + it('extracts state with various types', () => { + const state = findFeature('volume')!.reference.state; + + expect(state.volume).toMatchObject({ + type: 'number', + description: 'Volume level from 0 (silent) to 1 (max).', + }); + + expect(state.muted).toMatchObject({ + type: 'boolean', + description: 'Whether audio is muted.', + }); + }); + + it('expands type aliases in state', () => { + const state = findFeature('volume')!.reference.state; + // MediaFeatureAvailability should be expanded to the union + const avail = state.volumeAvailability!; + expect(avail.type).toContain("'available'"); + expect(avail.type).toContain("'unavailable'"); + expect(avail.type).toContain("'unsupported'"); + }); + + it('extracts actions with parameters and return types', () => { + const actions = findFeature('volume')!.reference.actions; + + // setVolume has a parameter and returns a number + expect(actions.setVolume).toBeDefined(); + expect(actions.setVolume!.type).toContain('number'); + expect(actions.setVolume!.description).toBe('Set volume (clamped 0-1). Returns the clamped value.'); + + // toggleMuted returns a boolean + expect(actions.toggleMuted).toBeDefined(); + expect(actions.toggleMuted!.type).toContain('boolean'); + expect(actions.toggleMuted!.description).toBe('Toggle mute state. Returns the new muted value.'); + }); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════ +// PRESET PIPELINE +// ═══════════════════════════════════════════════════════════════════════ +// +// Presets bundle features, skins, and media elements for a specific use +// case. They are discovered from directories under packages/{html,react}/ +// src/presets/. +// +// Key behaviors: +// - Discovery: directories under both HTML and React preset paths +// - Feature bundle: *Features export → resolved to list of feature names +// - HTML skins: classes extending SkinElement, with tagName +// - React skins: exports matching *Skin naming +// - Media element: React exports that aren't bundles or skins +// - Tailwind exclusion: .tailwind files/exports are filtered out +// - HTML media element: implied by preset name (video →