Files
suaveplan/packages/ui/react/react-audio/tests/exports.test.ts
2026-05-27 23:47:53 +01:00

161 lines
4.7 KiB
TypeScript

import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = resolve(__dirname, "..");
const PKG_JSON = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as { exports: Record<string, unknown> };
const REQUIRED_HOOKS = [
"use-engine-state",
"use-audio-engine",
"use-timeline",
"use-transport",
"use-transport-time",
"use-track",
"use-plugin-chain",
"use-chain-plugin",
"use-audio-fx-plugin",
"use-parameter",
"use-preset",
"use-chain-snapshot",
"use-bus",
"use-midi-controller",
"use-analyser",
"use-playlist",
];
const REQUIRED_COMPONENTS = [
"plugin-slot",
"plugin-chain-view",
"plugin-parameter-knob",
"plugin-parameter-slider",
"preset-manager",
"midi-controller-setup",
"transport-controls",
"track-list",
"track-row",
"master-meter",
"mixer",
"audio-visualizer",
"audio-player",
"basic-player",
"premium-player",
"microphone-controls",
"glados-controls",
"plugin-loader",
];
const REQUIRED_SURFACES = [
".",
"./provider",
"./hooks",
...REQUIRED_HOOKS.map((h) => `./hooks/${h}`),
"./components",
...REQUIRED_COMPONENTS.map((c) => `./components/${c}`),
"./stores",
"./a11y",
"./error-codes",
];
describe("subpath exports", () => {
it("every Phase-1 surface has a subpath export entry", {
timeout: 5000,
}, () => {
for (const surface of REQUIRED_SURFACES) {
expect(PKG_JSON.exports[surface]).toBeDefined();
}
});
it("every component has a subpath export entry", {
timeout: 5000,
}, () => {
for (const c of REQUIRED_COMPONENTS) {
expect(PKG_JSON.exports[`./components/${c}`]).toBeDefined();
}
expect(PKG_JSON.exports["./components"]).toBeDefined();
});
it("includes exactly 16 hook subpaths (13 foundation + 1 Phase 3 + 2 player-tier)", {
timeout: 5000,
}, () => {
const hookKeys = Object.keys(PKG_JSON.exports).filter((k) =>
k.startsWith("./hooks/")
);
expect(hookKeys.length).toBe(16);
});
});
const STORY_NAME_REGEX = /^(Default|Interactive|Edge.*)$/;
function readStoryNames(filePath: string): ReadonlyArray<string> {
if (!existsSync(filePath)) return [];
const text = readFileSync(filePath, "utf8");
const names: string[] = [];
const re = /export\s+const\s+(\w+)\s*:\s*Story\s*=/g;
let m: RegExpExecArray | null = re.exec(text);
while (m !== null) {
names.push(m[1] as string);
m = re.exec(text);
}
return names;
}
interface ExportedComponentFile {
readonly path: string;
readonly storyPath: string;
}
function listExportedComponentFiles(): ExportedComponentFile[] {
const files: ExportedComponentFile[] = [];
const componentsDir = join(PKG_ROOT, "src", "components");
for (const name of readdirSync(componentsDir)) {
const dir = join(componentsDir, name);
if (!statSync(dir).isDirectory()) continue;
if (name === "internal") continue;
files.push({
path: join(dir, `${name}.tsx`),
storyPath: join(dir, `${name}.stories.tsx`),
});
}
files.push({
path: join(PKG_ROOT, "src", "provider", "audio-provider.tsx"),
storyPath: join(
PKG_ROOT,
"src",
"provider",
"audio-provider.stories.tsx"
),
});
return files;
}
describe("Storybook coverage gate", () => {
it("every component has default, interactive, and edge-case Storybook stories", {
timeout: 5000,
}, () => {
const componentFiles = listExportedComponentFiles();
const missing: string[] = [];
for (const file of componentFiles) {
if (!existsSync(file.storyPath)) {
missing.push(`${file.path}: no sibling ${file.storyPath}`);
continue;
}
const names = readStoryNames(file.storyPath);
const matched = names.filter((n) => STORY_NAME_REGEX.test(n));
const hasDefault = matched.includes("Default");
const hasInteractive = matched.includes("Interactive");
const hasEdge = matched.some((n) => n.startsWith("Edge"));
if (!hasDefault || !hasInteractive || !hasEdge) {
missing.push(
`${file.path}: stories=[${names.join(
", "
)}]; require Default + Interactive + Edge*`
);
}
}
expect(missing).toEqual([]);
});
});