Advance M8-M11 parity workflows
This commit is contained in:
164
web/tests/unit/sequencer-audio-recovery.test.mjs
Normal file
164
web/tests/unit/sequencer-audio-recovery.test.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import ts from "typescript";
|
||||
|
||||
const root = path.resolve(import.meta.dirname, "../../..");
|
||||
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "sequencer-audio-recovery-unit-"));
|
||||
const protocolPath = path.join(root, "web/protocol/sequencer-audio-session.ts");
|
||||
const runtimePath = path.join(root, "web/app/src/sequencer/SequencerAudioSession.ts");
|
||||
|
||||
function transpile(source, fileName) {
|
||||
const result = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 },
|
||||
fileName,
|
||||
reportDiagnostics: true,
|
||||
});
|
||||
assert.deepEqual(result.diagnostics, []);
|
||||
return result.outputText;
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(temporary, "sequencer-audio-session.mjs"),
|
||||
transpile(fs.readFileSync(protocolPath, "utf8").replace('import type { ErrorCode } from "./error";\n', ""), protocolPath),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(temporary, "SequencerAudioSession.mjs"),
|
||||
transpile(
|
||||
fs.readFileSync(runtimePath, "utf8").replace(
|
||||
'from "../../../protocol/sequencer-audio-session";',
|
||||
'from "./sequencer-audio-session.mjs";',
|
||||
),
|
||||
runtimePath,
|
||||
),
|
||||
);
|
||||
|
||||
const protocol = await import(pathToFileURL(path.join(temporary, "sequencer-audio-session.mjs")));
|
||||
const runtime = await import(pathToFileURL(path.join(temporary, "SequencerAudioSession.mjs")));
|
||||
const golden = JSON.parse(fs.readFileSync(path.join(root, "tests/golden/M11-13/sequencer-audio-recovery.json"), "utf8"));
|
||||
|
||||
class FakeAudioParam {
|
||||
value = 1;
|
||||
events = [];
|
||||
cancelScheduledValues(time) { this.events.push(["cancel", time]); }
|
||||
setValueAtTime(value, time) { this.value = value; this.events.push(["set", value, time]); }
|
||||
}
|
||||
|
||||
class FakeGainNode {
|
||||
gain = new FakeAudioParam();
|
||||
connected = false;
|
||||
disconnected = false;
|
||||
connect() { this.connected = true; }
|
||||
disconnect() { this.disconnected = true; }
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
static instances = [];
|
||||
state = "suspended";
|
||||
currentTime = 1;
|
||||
destination = {};
|
||||
output = new FakeGainNode();
|
||||
closeCount = 0;
|
||||
constructor() { FakeAudioContext.instances.push(this); }
|
||||
createGain() { return this.output; }
|
||||
async resume() { this.state = "running"; }
|
||||
async suspend() { this.state = "suspended"; }
|
||||
async close() { this.closeCount += 1; this.state = "closed"; }
|
||||
}
|
||||
|
||||
const summary = (report) => [
|
||||
report.contextState,
|
||||
report.outputState,
|
||||
report.muted,
|
||||
report.outputGain,
|
||||
report.issueCode,
|
||||
];
|
||||
|
||||
test("M11-13 restores output after real session suspend and mute transitions", async () => {
|
||||
FakeAudioContext.instances.length = 0;
|
||||
const session = new runtime.SequencerAudioSession({
|
||||
scope: { AudioContext: FakeAudioContext },
|
||||
outputGain: golden.outputGain,
|
||||
});
|
||||
const reports = [];
|
||||
reports.push(await session.initialize());
|
||||
reports.push(session.setMuted(true));
|
||||
reports.push(await session.resume());
|
||||
reports.push(session.setMuted(false));
|
||||
reports.push(await session.suspend());
|
||||
reports.push(await session.resume());
|
||||
reports.push(await session.close());
|
||||
|
||||
assert.deepEqual(reports.map(summary), golden.lifecycle);
|
||||
assert.deepEqual(reports.map((report) => report.revision), [1, 2, 3, 4, 5, 6, 7]);
|
||||
const context = FakeAudioContext.instances[0];
|
||||
assert.equal(context.output.gain.value, 0);
|
||||
assert.equal(context.output.connected, true);
|
||||
assert.equal(context.output.disconnected, true);
|
||||
assert.equal(context.closeCount, 1);
|
||||
assert.ok(context.output.gain.events.some((event) => event[0] === "set" && event[1] === 0));
|
||||
assert.ok(context.output.gain.events.some((event) => event[0] === "set" && event[1] === golden.outputGain));
|
||||
});
|
||||
|
||||
test("M11-13 blocks a missing audio device and can retry after the runtime becomes available", async () => {
|
||||
FakeAudioContext.instances.length = 0;
|
||||
const scope = {};
|
||||
const session = new runtime.SequencerAudioSession({ scope, outputGain: golden.outputGain });
|
||||
const missing = await session.initialize();
|
||||
assert.deepEqual(
|
||||
[missing.contextState, missing.outputState, missing.issueCode],
|
||||
golden.missingDevice,
|
||||
);
|
||||
assert.deepEqual(session.snapshot(), missing);
|
||||
|
||||
scope.AudioContext = FakeAudioContext;
|
||||
const recovered = await session.recoverDevice();
|
||||
assert.deepEqual(summary(recovered), golden.lifecycle[0]);
|
||||
const resumed = await session.resume();
|
||||
assert.deepEqual(summary(resumed), ["RUNNING", "ENABLED", false, golden.outputGain, null]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("M11-13 keeps failed resume and malformed reports silent and structured", async () => {
|
||||
class ResumeFailureContext extends FakeAudioContext {
|
||||
async resume() { throw new Error("injected resume failure"); }
|
||||
}
|
||||
const failed = new runtime.SequencerAudioSession({
|
||||
contextFactory: () => new ResumeFailureContext(),
|
||||
outputGain: golden.outputGain,
|
||||
});
|
||||
await failed.initialize();
|
||||
const resumeFailure = await failed.resume();
|
||||
assert.deepEqual(
|
||||
[resumeFailure.contextState, resumeFailure.outputState, resumeFailure.issueCode],
|
||||
golden.resumeFailure,
|
||||
);
|
||||
await failed.close();
|
||||
|
||||
const unavailable = new runtime.SequencerAudioSession({
|
||||
contextFactory: () => { throw new Error("no output device"); },
|
||||
});
|
||||
assert.deepEqual(
|
||||
[
|
||||
(await unavailable.initialize()).contextState,
|
||||
unavailable.snapshot().outputState,
|
||||
unavailable.snapshot().issueCode,
|
||||
],
|
||||
golden.missingDevice,
|
||||
);
|
||||
assert.throws(() => new runtime.SequencerAudioSession({ outputGain: 0 }), {
|
||||
code: "SEQUENCER_AUDIO_CONTEXT_INVALID",
|
||||
});
|
||||
assert.throws(() => protocol.parseSequencerAudioSessionReport({
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
contextState: "RUNNING",
|
||||
outputState: "ENABLED",
|
||||
muted: true,
|
||||
outputGain: 1,
|
||||
issueCode: null,
|
||||
}), { code: "SEQUENCER_AUDIO_CONTEXT_INVALID" });
|
||||
});
|
||||
Reference in New Issue
Block a user