Initial Commit
This commit is contained in:
65
packages/media/audio/audio-cli/tests/exports.test.ts
Normal file
65
packages/media/audio/audio-cli/tests/exports.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as rootBarrel from "../src/index.js";
|
||||
|
||||
describe("audio-cli subpath exports", () => {
|
||||
it("every advertised subpath has an exports entry in package.json", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = await readFile(
|
||||
join(__dirname, "..", "package.json"),
|
||||
"utf8"
|
||||
);
|
||||
const pkg = JSON.parse(raw) as { exports: Record<string, unknown> };
|
||||
for (const sub of [
|
||||
".",
|
||||
"./package.json",
|
||||
"./cli",
|
||||
"./error-codes",
|
||||
"./pipeline/parser",
|
||||
"./commands/process",
|
||||
"./commands/batch",
|
||||
"./commands/analyze",
|
||||
"./commands/convert",
|
||||
"./commands/info",
|
||||
"./commands/play",
|
||||
"./commands/record",
|
||||
]) {
|
||||
expect(
|
||||
Object.hasOwn(pkg.exports, sub),
|
||||
`missing export ${sub}`
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("root barrel exports AudioCLI, parsePipeline, error codes, every runX", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const expected = [
|
||||
"AudioCLI",
|
||||
"parsePipeline",
|
||||
"validatePipeline",
|
||||
"AUDIO_CLI_CODES",
|
||||
"AudioCliError",
|
||||
"EXIT_CODES",
|
||||
"exitCodeFor",
|
||||
"runProcess",
|
||||
"runBatch",
|
||||
"runAnalyze",
|
||||
"runConvert",
|
||||
"runInfo",
|
||||
"runPlay",
|
||||
"runRecord",
|
||||
"createSilentMicrophone",
|
||||
"noopSpeaker",
|
||||
"createDefaultCodecRegistry",
|
||||
"detectCodecForOutput",
|
||||
];
|
||||
for (const name of expected) {
|
||||
expect(name in rootBarrel, `root barrel missing ${name}`).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
27
packages/media/audio/audio-cli/tests/helpers.ts
Normal file
27
packages/media/audio/audio-cli/tests/helpers.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { WavCodec } from "@suaveplan/audio-codecs";
|
||||
|
||||
/** Write a minimal mono 44.1 kHz WAV file for fixture purposes. */
|
||||
export async function writeWav(path: string, samples = 256): Promise<void> {
|
||||
const bytes = await WavCodec.encode([new Float32Array(samples)], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
await writeFile(path, bytes);
|
||||
}
|
||||
|
||||
/** Write a noisy 44.1 kHz mono WAV file so loudness analyses report a value. */
|
||||
export async function writeNoisyWav(
|
||||
path: string,
|
||||
samples = 44_100
|
||||
): Promise<void> {
|
||||
const buf = new Float32Array(samples);
|
||||
for (let i = 0; i < samples; i++) buf[i] = Math.sin(i * 0.01) * 0.5;
|
||||
const bytes = await WavCodec.encode([buf], {
|
||||
sampleRate: 44_100,
|
||||
bitDepth: 16,
|
||||
format: "pcm",
|
||||
});
|
||||
await writeFile(path, bytes);
|
||||
}
|
||||
73
packages/media/audio/audio-cli/tests/integration.test.ts
Normal file
73
packages/media/audio/audio-cli/tests/integration.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { AudioCLI } from "../src/cli/cli.js";
|
||||
import { writeWav } from "./helpers.js";
|
||||
|
||||
describe("audio-cli integration", () => {
|
||||
let dir: string;
|
||||
let cli: AudioCLI;
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "audio-cli-int-"));
|
||||
cli = new AudioCLI();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("process → analyze → convert round-trip via dispatch", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const a = join(dir, "in.wav");
|
||||
await writeWav(a, 256);
|
||||
|
||||
// process
|
||||
expect(
|
||||
await cli.dispatch([
|
||||
"process",
|
||||
a,
|
||||
join(dir, "out.wav"),
|
||||
"--pipeline",
|
||||
"normalize",
|
||||
])
|
||||
).toBe(0);
|
||||
expect((await stat(join(dir, "out.wav"))).size).toBeGreaterThan(0);
|
||||
|
||||
// analyze with --output
|
||||
const report = join(dir, "report.json");
|
||||
expect(await cli.dispatch(["analyze", a, "--output", report])).toBe(0);
|
||||
expect((await stat(report)).size).toBeGreaterThan(0);
|
||||
|
||||
// convert
|
||||
expect(await cli.dispatch(["convert", a, join(dir, "conv.wav")])).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it("batch dispatch over a comma list returns a JSON report", {
|
||||
timeout: 30_000,
|
||||
}, async () => {
|
||||
const a = join(dir, "a.wav");
|
||||
const b = join(dir, "b.wav");
|
||||
await writeWav(a, 32);
|
||||
await writeWav(b, 32);
|
||||
expect(
|
||||
await cli.dispatch([
|
||||
"batch",
|
||||
"--input",
|
||||
`${a},${b}`,
|
||||
"--output",
|
||||
dir,
|
||||
"--pipeline",
|
||||
"",
|
||||
])
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
49
packages/media/audio/audio-cli/tests/property.test.ts
Normal file
49
packages/media/audio/audio-cli/tests/property.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parsePipeline } from "../src/pipeline/parser.js";
|
||||
|
||||
/**
|
||||
* Property-style tests: the same op chain produced via the pipe-form string
|
||||
* and via the positional bare-op tokens MUST yield the same internal edit
|
||||
* chain. This is the parser invariant that lets the bare-op CLI form and
|
||||
* `batch --pipeline` share a single downstream code path.
|
||||
*/
|
||||
describe("parsePipeline determinism", () => {
|
||||
const cases: Array<{ str: string; tokens: readonly string[] }> = [
|
||||
{ str: "trim 30s", tokens: ["trim", "30s"] },
|
||||
{ str: "normalize -16lufs", tokens: ["normalize", "-16lufs"] },
|
||||
{
|
||||
str: "trim 30s | normalize -16lufs",
|
||||
tokens: ["trim", "30s", "normalize", "-16lufs"],
|
||||
},
|
||||
{
|
||||
str: "trim 30s | normalize | fade 0.5s 1s",
|
||||
tokens: ["trim", "30s", "normalize", "fade", "0.5s", "1s"],
|
||||
},
|
||||
{ str: "gain 6db", tokens: ["gain", "6db"] },
|
||||
];
|
||||
for (const { str, tokens } of cases) {
|
||||
it(`pipe-form and bare-op produce the same chain for: ${str}`, {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(parsePipeline(tokens)).toEqual(parsePipeline(str));
|
||||
});
|
||||
}
|
||||
|
||||
it("idempotent: parse(parse(x)) is the identity for chained pipe forms", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const once = parsePipeline("trim 30s | normalize -16lufs");
|
||||
const reSerialised = once
|
||||
.map(([name, opts]) => {
|
||||
const args = Object.entries(opts).map(([k, v]) => {
|
||||
if (k === "start") return `${v}s`;
|
||||
if (k === "lufs") return `${v}lufs`;
|
||||
return `${k}=${v}`;
|
||||
});
|
||||
return [name, ...args].join(" ");
|
||||
})
|
||||
.join(" | ");
|
||||
const twice = parsePipeline(reSerialised);
|
||||
expect(twice).toEqual(once);
|
||||
});
|
||||
});
|
||||
91
packages/media/audio/audio-cli/tests/purity.test.ts
Normal file
91
packages/media/audio/audio-cli/tests/purity.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = join(__dirname, "..");
|
||||
const SRC = join(PKG_ROOT, "src");
|
||||
|
||||
// Match a *use* of the global as a free identifier (assignment, property access,
|
||||
// or function call), not occurrences inside compound identifiers like `AudioDocument`.
|
||||
const BROWSER_GLOBALS = [
|
||||
"window",
|
||||
"document",
|
||||
"navigator",
|
||||
"AudioContext",
|
||||
"AudioBuffer",
|
||||
];
|
||||
|
||||
function stripCommentsAndStrings(src: string): string {
|
||||
// Drop /* ... */, // ..., and "..." / '...' content so we only see code.
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||||
.replace(/\/\/[^\n]*/g, " ")
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
||||
.replace(/'(?:[^'\\]|\\.)*'/g, "''");
|
||||
}
|
||||
|
||||
function refersToGlobal(src: string, name: string): boolean {
|
||||
const code = stripCommentsAndStrings(src);
|
||||
// Token must start after a non-identifier char and be followed by a
|
||||
// property access (.), call site (() or [), assignment (= but not ==),
|
||||
// semicolon, comma, or close paren.
|
||||
const re = new RegExp(
|
||||
`(?:^|[^A-Za-z0-9_.])${name}(?=[.(\\[]|\\s*=[^=])`,
|
||||
"m"
|
||||
);
|
||||
return re.test(code);
|
||||
}
|
||||
|
||||
async function* walk(dir: string): AsyncGenerator<string> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) yield* walk(full);
|
||||
else if (
|
||||
e.isFile() &&
|
||||
e.name.endsWith(".ts") &&
|
||||
!e.name.endsWith(".test.ts")
|
||||
)
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("audio-cli purity", () => {
|
||||
it("source references no browser-only globals", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
for await (const path of walk(SRC)) {
|
||||
const src = await readFile(path, "utf8");
|
||||
for (const global of BROWSER_GLOBALS) {
|
||||
expect(
|
||||
refersToGlobal(src, global),
|
||||
`${path} references ${global}`
|
||||
).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("declared dependency set is limited to the spec list", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const raw = await readFile(join(PKG_ROOT, "package.json"), "utf8");
|
||||
const pkg = JSON.parse(raw) as {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
const allowed = new Set([
|
||||
"@suaveplan/audio-codecs",
|
||||
"@suaveplan/audio-core",
|
||||
"@suaveplan/audio-engine-server",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
"@opentelemetry/api",
|
||||
"tslib",
|
||||
"zod",
|
||||
]);
|
||||
for (const dep of Object.keys(pkg.dependencies ?? {})) {
|
||||
expect(allowed.has(dep), `unexpected runtime dep ${dep}`).toBe(
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user