Initial Commit
This commit is contained in:
40
packages/media/audio/audio-controllers/tests/exports.test.ts
Normal file
40
packages/media/audio/audio-controllers/tests/exports.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_DIR = join(__dirname, "..");
|
||||
|
||||
interface PackageJson {
|
||||
exports: Record<string, unknown>;
|
||||
sideEffects?: boolean;
|
||||
}
|
||||
|
||||
describe("subpath exports", () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(PKG_DIR, "package.json"), "utf8")
|
||||
) as PackageJson;
|
||||
|
||||
it("every feature module has a subpath export entry", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const required = [
|
||||
".",
|
||||
"./base/midi-controller-plugin",
|
||||
"./sequencer/sequencer",
|
||||
"./arpeggiator/arpeggiator",
|
||||
"./learn/learn-mode",
|
||||
"./binding",
|
||||
"./plugins/kontrol-s88",
|
||||
"./plugins/maschine",
|
||||
"./plugins/traktor-s4",
|
||||
"./error-codes",
|
||||
];
|
||||
for (const path of required) {
|
||||
expect(pkg.exports[path], `missing export: ${path}`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
});
|
||||
132
packages/media/audio/audio-controllers/tests/fake-midi.ts
Normal file
132
packages/media/audio/audio-controllers/tests/fake-midi.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Fake MIDI fixture — supplies the package's spec-mandated injectable seam.
|
||||
* Lives outside `src/` so it does not appear in coverage reports yet remains
|
||||
* shared between the unit, integration, and e2e suites.
|
||||
*/
|
||||
|
||||
import type {
|
||||
MidiAccessAdapter,
|
||||
MidiAccessLike,
|
||||
MidiInputLike,
|
||||
MidiOutputLike,
|
||||
} from "../src/base/midi-controller-plugin.types.js";
|
||||
|
||||
export interface FakeMidiInput extends MidiInputLike {
|
||||
emit(data: ReadonlyArray<number>, timeStamp?: number): void;
|
||||
}
|
||||
|
||||
export interface FakeMidiOutput extends MidiOutputLike {
|
||||
readonly sent: number[][];
|
||||
disconnect(): void;
|
||||
}
|
||||
|
||||
export interface FakeMidiAccess extends MidiAccessLike {
|
||||
readonly mutableInputs: Map<string, FakeMidiInput>;
|
||||
readonly mutableOutputs: Map<string, FakeMidiOutput>;
|
||||
disconnectPort(port: FakeMidiInput | FakeMidiOutput): void;
|
||||
reconnectPort(port: FakeMidiInput | FakeMidiOutput): void;
|
||||
}
|
||||
|
||||
function createInput(id: string, name: string): FakeMidiInput {
|
||||
const port: FakeMidiInput = {
|
||||
id,
|
||||
name,
|
||||
state: "connected",
|
||||
type: "input",
|
||||
onmidimessage: null,
|
||||
emit(data: ReadonlyArray<number>, timeStamp = 0) {
|
||||
port.onmidimessage?.({
|
||||
data: new Uint8Array(data),
|
||||
timeStamp,
|
||||
});
|
||||
},
|
||||
} as FakeMidiInput;
|
||||
return port;
|
||||
}
|
||||
|
||||
function createOutput(id: string, name: string): FakeMidiOutput {
|
||||
const sent: number[][] = [];
|
||||
let connectedState: "connected" | "disconnected" = "connected";
|
||||
const port = {
|
||||
id,
|
||||
name,
|
||||
get state(): "connected" | "disconnected" {
|
||||
return connectedState;
|
||||
},
|
||||
type: "output" as const,
|
||||
sent,
|
||||
send(data: ReadonlyArray<number> | Uint8Array) {
|
||||
sent.push(Array.isArray(data) ? data.slice() : Array.from(data));
|
||||
},
|
||||
disconnect() {
|
||||
connectedState = "disconnected";
|
||||
},
|
||||
} satisfies FakeMidiOutput;
|
||||
return port;
|
||||
}
|
||||
|
||||
export function createFakeAccess(
|
||||
options: { readonly inputName?: string; readonly outputName?: string } = {}
|
||||
): FakeMidiAccess {
|
||||
const inputName = options.inputName ?? "Demo Input";
|
||||
const outputName = options.outputName ?? "Demo Output";
|
||||
const input = createInput("in-1", inputName);
|
||||
const output = createOutput("out-1", outputName);
|
||||
const mutableInputs = new Map<string, FakeMidiInput>([[input.id, input]]);
|
||||
const mutableOutputs = new Map<string, FakeMidiOutput>([
|
||||
[output.id, output],
|
||||
]);
|
||||
const access: FakeMidiAccess = {
|
||||
get inputs(): ReadonlyMap<string, MidiInputLike> {
|
||||
return mutableInputs;
|
||||
},
|
||||
get outputs(): ReadonlyMap<string, MidiOutputLike> {
|
||||
return mutableOutputs;
|
||||
},
|
||||
mutableInputs,
|
||||
mutableOutputs,
|
||||
onstatechange: null,
|
||||
disconnectPort(port) {
|
||||
if (port.type === "input") mutableInputs.delete(port.id);
|
||||
else {
|
||||
port.disconnect();
|
||||
mutableOutputs.delete(port.id);
|
||||
}
|
||||
access.onstatechange?.({
|
||||
port: { ...port, state: "disconnected" },
|
||||
});
|
||||
},
|
||||
reconnectPort(port) {
|
||||
if (port.type === "input") mutableInputs.set(port.id, port);
|
||||
else mutableOutputs.set(port.id, port);
|
||||
access.onstatechange?.({
|
||||
port: { ...port, state: "connected" },
|
||||
});
|
||||
},
|
||||
};
|
||||
return access;
|
||||
}
|
||||
|
||||
export function createFakeAdapter(
|
||||
access: FakeMidiAccess,
|
||||
options: { readonly reject?: Error } = {}
|
||||
): MidiAccessAdapter {
|
||||
return {
|
||||
async requestAccess() {
|
||||
if (options.reject) throw options.reject;
|
||||
return access;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getFirstInput(access: FakeMidiAccess): FakeMidiInput {
|
||||
const first = access.mutableInputs.values().next().value;
|
||||
if (!first) throw new Error("no input");
|
||||
return first;
|
||||
}
|
||||
|
||||
export function getFirstOutput(access: FakeMidiAccess): FakeMidiOutput {
|
||||
const first = access.mutableOutputs.values().next().value;
|
||||
if (!first) throw new Error("no output");
|
||||
return first;
|
||||
}
|
||||
131
packages/media/audio/audio-controllers/tests/fake-transport.ts
Normal file
131
packages/media/audio/audio-controllers/tests/fake-transport.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Real `TransportLike` implementation backed by an in-memory event log.
|
||||
* Used in tests so step/arpeggio callbacks can be driven deterministically
|
||||
* without spinning a real wall-clock scheduler. This is a real object that
|
||||
* satisfies the `TransportLike` contract — not a runner mock.
|
||||
*/
|
||||
|
||||
import type {
|
||||
BarsBeatsSixteenths,
|
||||
SignalLike,
|
||||
TransportCallback,
|
||||
TransportLike,
|
||||
TransportTime,
|
||||
} from "@suaveplan/audio-transport";
|
||||
|
||||
interface ScheduledRepeat {
|
||||
readonly id: number;
|
||||
readonly callback: TransportCallback;
|
||||
readonly interval: TransportTime;
|
||||
}
|
||||
|
||||
interface ScheduledOnce {
|
||||
readonly id: number;
|
||||
readonly callback: TransportCallback;
|
||||
readonly time: number;
|
||||
}
|
||||
|
||||
const NOOP_BBS: BarsBeatsSixteenths = "0:0:0";
|
||||
|
||||
function makeFakeSignal(): SignalLike {
|
||||
let v = 120;
|
||||
const points: { time: number; value: number; type: "set" }[] = [];
|
||||
const sig: SignalLike = {
|
||||
get points() {
|
||||
return points.slice();
|
||||
},
|
||||
get initialValue() {
|
||||
return 120;
|
||||
},
|
||||
get value() {
|
||||
return v;
|
||||
},
|
||||
setValueAtTime(value: number, time: number) {
|
||||
v = value;
|
||||
points.push({ time, value, type: "set" });
|
||||
},
|
||||
linearRampToValueAtTime(value: number) {
|
||||
v = value;
|
||||
},
|
||||
exponentialRampToValueAtTime(value: number) {
|
||||
v = value;
|
||||
},
|
||||
getValueAtTime() {
|
||||
return v;
|
||||
},
|
||||
cancelScheduledValues() {
|
||||
points.length = 0;
|
||||
},
|
||||
};
|
||||
return sig;
|
||||
}
|
||||
|
||||
export class FakeTransport implements TransportLike {
|
||||
public bpm: SignalLike = makeFakeSignal();
|
||||
public readonly ppq = 192;
|
||||
public readonly timeSignature = 4;
|
||||
public swing = 0;
|
||||
public swingSubdivision: TransportTime = "8n";
|
||||
public loop = false;
|
||||
public loopStart: TransportTime = 0;
|
||||
public loopEnd: TransportTime = 0;
|
||||
public state: "stopped" | "playing" | "paused" = "stopped";
|
||||
public position: BarsBeatsSixteenths = NOOP_BBS;
|
||||
public ticks = 0;
|
||||
public seconds = 0;
|
||||
|
||||
public readonly repeats = new Map<number, ScheduledRepeat>();
|
||||
public readonly onces = new Map<number, ScheduledOnce>();
|
||||
private nextId = 1;
|
||||
|
||||
start(): void {
|
||||
this.state = "playing";
|
||||
}
|
||||
stop(): void {
|
||||
this.state = "stopped";
|
||||
}
|
||||
pause(): void {
|
||||
this.state = "paused";
|
||||
}
|
||||
schedule(callback: TransportCallback, time: TransportTime): number {
|
||||
return this.scheduleOnce(callback, time);
|
||||
}
|
||||
scheduleOnce(callback: TransportCallback, time: TransportTime): number {
|
||||
const id = this.nextId++;
|
||||
const num = typeof time === "number" ? time : 0;
|
||||
this.onces.set(id, { id, callback, time: num });
|
||||
return id;
|
||||
}
|
||||
scheduleRepeat(
|
||||
callback: TransportCallback,
|
||||
interval: TransportTime
|
||||
): number {
|
||||
const id = this.nextId++;
|
||||
this.repeats.set(id, { id, callback, interval });
|
||||
return id;
|
||||
}
|
||||
cancel(eventId: number): void {
|
||||
this.repeats.delete(eventId);
|
||||
this.onces.delete(eventId);
|
||||
}
|
||||
cancelAfter(_time: TransportTime): void {
|
||||
// Not used by audio-controllers tests.
|
||||
}
|
||||
toSeconds(time: TransportTime): number {
|
||||
return typeof time === "number" ? time / this.ppq : 0;
|
||||
}
|
||||
toTicks(time: TransportTime): number {
|
||||
return typeof time === "number" ? time * this.ppq : 0;
|
||||
}
|
||||
|
||||
/** Test helper: drive every active repeat once. */
|
||||
tickAll(time = 0): void {
|
||||
for (const r of this.repeats.values()) r.callback(time);
|
||||
}
|
||||
|
||||
/** Test helper: fire every scheduled `scheduleOnce`. */
|
||||
flushOnces(): void {
|
||||
for (const o of this.onces.values()) o.callback(o.time);
|
||||
this.onces.clear();
|
||||
}
|
||||
}
|
||||
56
packages/media/audio/audio-controllers/tests/purity.test.ts
Normal file
56
packages/media/audio/audio-controllers/tests/purity.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_DIR = join(__dirname, "..");
|
||||
const DIST_DIR = join(PKG_DIR, "dist");
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
if (!existsSync(dir)) return out;
|
||||
for (const name of readdirSync(dir)) {
|
||||
const p = join(dir, name);
|
||||
const s = statSync(p);
|
||||
if (s.isDirectory()) walk(p, out);
|
||||
else if (/\.(js|mjs|cjs)$/.test(p)) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("purity", () => {
|
||||
it("dist references no Node globals", { timeout: 30_000 }, () => {
|
||||
if (!existsSync(DIST_DIR)) {
|
||||
// dist will exist after build; skip purity if not yet built so the
|
||||
// test suite can still run on a fresh clone.
|
||||
expect(true).toBe(true);
|
||||
return;
|
||||
}
|
||||
const offenders: string[] = [];
|
||||
for (const file of walk(DIST_DIR)) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
if (/\bnode:[a-z]+\b/.test(src)) offenders.push(`${file}: node:*`);
|
||||
if (/\bprocess\.(env|cwd|platform)\b/.test(src))
|
||||
offenders.push(`${file}: process`);
|
||||
if (/\bBuffer\.from\b/.test(src)) offenders.push(`${file}: Buffer`);
|
||||
}
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
it("importing the package mutates no global state", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const filter = (keys: string[]): string[] =>
|
||||
keys
|
||||
.filter(
|
||||
(k) =>
|
||||
!k.startsWith("__zod") &&
|
||||
!k.startsWith("__vitest") &&
|
||||
!k.startsWith("__VITEST")
|
||||
)
|
||||
.sort();
|
||||
const beforeKeys = filter(Object.keys(globalThis));
|
||||
const mod = await import("../src/index.js");
|
||||
expect(mod.MidiControllerPlugin).toBeDefined();
|
||||
const afterKeys = filter(Object.keys(globalThis));
|
||||
expect(afterKeys).toEqual(beforeKeys);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user