161 lines
4.7 KiB
TypeScript
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([]);
|
|
});
|
|
});
|