222 lines
7.2 KiB
TypeScript
222 lines
7.2 KiB
TypeScript
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
AUDIO_FX_CODES,
|
|
type AudioFxError,
|
|
} from "../error-codes/error-codes.js";
|
|
import { DefaultBusRegistry } from "./bus-registry.js";
|
|
|
|
function frame(samples: number[], channels = 1): Float32Array[] {
|
|
const out: Float32Array[] = [];
|
|
for (let ch = 0; ch < channels; ch++) {
|
|
out.push(Float32Array.from(samples));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
describe("DefaultBusRegistry: publish + read round-trip", () => {
|
|
it("returns undefined for an unknown bus", { timeout: 5_000 }, () => {
|
|
const r = new DefaultBusRegistry();
|
|
expect(r.get("missing")).toBeUndefined();
|
|
expect(r.list()).toEqual([]);
|
|
});
|
|
|
|
it("publishes a named bus reachable via get + list", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const pub = r.publish("kick", 2);
|
|
expect(pub.bus.name).toBe("kick");
|
|
expect(pub.bus.channels).toBe(2);
|
|
expect(r.get("kick")).toBe(pub.bus);
|
|
expect(r.list()).toEqual(["kick"]);
|
|
});
|
|
|
|
it("round-trips a buffer from write to read", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const pub = r.publish("kick", 1);
|
|
pub.write(frame([0.1, 0.2, 0.3]));
|
|
const scratch = [new Float32Array(3)];
|
|
r.get("kick")!.read(scratch);
|
|
expect(Array.from(scratch[0]!)).toEqual([
|
|
expect.closeTo(0.1, 6),
|
|
expect.closeTo(0.2, 6),
|
|
expect.closeTo(0.3, 6),
|
|
]);
|
|
});
|
|
|
|
it("reads silence before any write", { timeout: 5_000 }, () => {
|
|
const r = new DefaultBusRegistry();
|
|
r.publish("kick", 1);
|
|
const scratch = [new Float32Array(4)];
|
|
r.get("kick")!.read(scratch);
|
|
expect(Array.from(scratch[0]!)).toEqual([0, 0, 0, 0]);
|
|
});
|
|
|
|
it("pads with silence when scratch is larger than the written frame", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const pub = r.publish("kick", 1);
|
|
pub.write(frame([1, 1]));
|
|
const scratch = [new Float32Array(4)];
|
|
r.get("kick")!.read(scratch);
|
|
expect(Array.from(scratch[0]!)).toEqual([1, 1, 0, 0]);
|
|
});
|
|
|
|
it("reuses the accumulator when consecutive writes are the same size", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const pub = r.publish("kick", 1);
|
|
pub.write(frame([1, 2, 3]));
|
|
pub.write(frame([4, 5, 6])); // same size — accumulator reused
|
|
const scratch = [new Float32Array(3)];
|
|
r.get("kick")!.read(scratch);
|
|
expect(Array.from(scratch[0]!)).toEqual([4, 5, 6]);
|
|
});
|
|
});
|
|
|
|
describe("DefaultBusRegistry: channel-count enforcement", () => {
|
|
it("throws BUS_CHANNEL_MISMATCH when read scratch length differs", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
r.publish("kick", 2);
|
|
try {
|
|
r.get("kick")!.read([new Float32Array(4)]);
|
|
} catch (error) {
|
|
expect((error as AudioFxError).code).toBe(
|
|
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH
|
|
);
|
|
expect((error as AudioFxError).context).toEqual({
|
|
bus: "kick",
|
|
expected: 2,
|
|
actual: 1,
|
|
});
|
|
return;
|
|
}
|
|
throw new Error("expected BUS_CHANNEL_MISMATCH");
|
|
});
|
|
|
|
it("throws BUS_CHANNEL_MISMATCH when write frame length differs", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const pub = r.publish("kick", 1);
|
|
try {
|
|
pub.write(frame([1, 1], 2));
|
|
} catch (error) {
|
|
expect((error as AudioFxError).code).toBe(
|
|
AUDIO_FX_CODES.BUS_CHANNEL_MISMATCH
|
|
);
|
|
return;
|
|
}
|
|
throw new Error("expected BUS_CHANNEL_MISMATCH");
|
|
});
|
|
});
|
|
|
|
describe("DefaultBusRegistry: close semantics", () => {
|
|
it("close removes the bus from get/list and reads as silence", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const pub = r.publish("kick", 1);
|
|
const bus = pub.bus;
|
|
pub.write(frame([1, 1]));
|
|
pub.close();
|
|
expect(r.get("kick")).toBeUndefined();
|
|
expect(r.list()).toEqual([]);
|
|
// The local bus handle is still callable but reads silence.
|
|
const scratch = [new Float32Array(2)];
|
|
bus.read(scratch);
|
|
expect(Array.from(scratch[0]!)).toEqual([0, 0]);
|
|
});
|
|
|
|
it("close is idempotent", { timeout: 5_000 }, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const pub = r.publish("kick", 1);
|
|
pub.close();
|
|
// second close must be a no-op
|
|
expect(() => pub.close()).not.toThrow();
|
|
});
|
|
|
|
it("write on a closed publisher is a no-op", { timeout: 5_000 }, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const pub = r.publish("kick", 1);
|
|
pub.close();
|
|
expect(() => pub.write(frame([1]))).not.toThrow();
|
|
});
|
|
|
|
it("republishing the same name closes the previous publisher", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const first = r.publish("kick", 1);
|
|
const events: Array<unknown> = [];
|
|
r.subscribe("kick", (b) => events.push(b));
|
|
const second = r.publish("kick", 2);
|
|
expect(r.get("kick")).toBe(second.bus);
|
|
expect(first.bus.channels).toBe(1);
|
|
// subscribers see: close of first (undefined), then second (bus)
|
|
expect(events.length).toBe(2);
|
|
expect(events[0]).toBeUndefined();
|
|
expect(events[1]).toBe(second.bus);
|
|
});
|
|
});
|
|
|
|
describe("DefaultBusRegistry: subscribe", () => {
|
|
it("fires the listener on publish and on close", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const events: Array<unknown> = [];
|
|
const unsub = r.subscribe("kick", (b) => events.push(b));
|
|
const pub = r.publish("kick", 1);
|
|
pub.close();
|
|
unsub();
|
|
expect(events.length).toBe(2);
|
|
expect(events[0]).toBe(pub.bus);
|
|
expect(events[1]).toBeUndefined();
|
|
});
|
|
|
|
it("multiple listeners on the same name all fire", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
let countA = 0;
|
|
let countB = 0;
|
|
r.subscribe("kick", () => countA++);
|
|
r.subscribe("kick", () => countB++);
|
|
r.publish("kick", 1);
|
|
expect(countA).toBe(1);
|
|
expect(countB).toBe(1);
|
|
});
|
|
|
|
it("unsubscribe removes only that listener; cleans up empty buckets", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
let countA = 0;
|
|
let countB = 0;
|
|
const unsubA = r.subscribe("kick", () => countA++);
|
|
r.subscribe("kick", () => countB++);
|
|
unsubA();
|
|
r.publish("kick", 1);
|
|
expect(countA).toBe(0);
|
|
expect(countB).toBe(1);
|
|
});
|
|
|
|
it("subscribe before any publish is harmless", {
|
|
timeout: 5_000,
|
|
}, () => {
|
|
const r = new DefaultBusRegistry();
|
|
const events: Array<unknown> = [];
|
|
r.subscribe("kick", (b) => events.push(b));
|
|
expect(events.length).toBe(0);
|
|
r.publish("kick", 1);
|
|
expect(events.length).toBe(1);
|
|
});
|
|
});
|