185 lines
9.9 KiB
JavaScript
185 lines
9.9 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings } from "../src/race_audio.js";
|
|
|
|
const manifest = {
|
|
events: { transponder_pass: { effect: "effects/transponder_pass.wav" }, start_tone: { effect: "effects/start_beep.wav" } },
|
|
voice_events: { sv: { drivers_ready: "Redo", start_sequence: "Start på signalen", track_call: "Stopp på banan" }, en: { drivers_ready: "Ready" } },
|
|
};
|
|
|
|
test("race audio settings normalize volume, delay, language and switches", () => {
|
|
const settings = normalizeRaceAudioSettings({ masterVolume: 4, effectsVolume: -1, language: "en", minStartDelay: 2, maxStartDelay: 1, events: { transponder_pass: { effect: false } } });
|
|
assert.equal(settings.masterVolume, 1);
|
|
assert.equal(settings.effectsVolume, 0);
|
|
assert.equal(settings.language, "en");
|
|
assert.equal(settings.voiceRate, 1.08);
|
|
assert.equal(settings.voicePitch, 0.9);
|
|
assert.equal(settings.minStartDelay, 2);
|
|
assert.equal(settings.maxStartDelay, 2);
|
|
assert.equal(settings.events.transponder_pass.effect, false);
|
|
});
|
|
|
|
test("missing custom passing sound never rejects and falls back safely", async () => {
|
|
const played = [];
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
|
|
audioFactory: (src) => ({ play: () => { played.push(src); if (src.includes("pass_01")) throw new Error("missing"); return Promise.resolve(); }, volume: 1 }),
|
|
getSettings: () => normalizeRaceAudioSettings(),
|
|
});
|
|
assert.equal(await engine.playEffect("transponder_pass", { effectPath: "pass_01.wav" }), true);
|
|
assert.deepEqual(played, ["/race-sounds/sounds/effects/pass_01.wav", "/race-sounds/sounds/effects/transponder_pass.wav"]);
|
|
});
|
|
|
|
test("critical control voice is prioritized over queued low-priority voice", async () => {
|
|
const resolvers = [];
|
|
const spoken = [];
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => ({ ...manifest, voice_events: { sv: { ...manifest.voice_events.sv, personal_best: "PB" } } }) }),
|
|
audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
|
|
speak: (text, options) => new Promise((resolve) => { spoken.push([text, options.interrupt]); resolvers.push(resolve); }),
|
|
getSettings: () => normalizeRaceAudioSettings(),
|
|
});
|
|
engine.trigger("personal_best");
|
|
while (spoken.length < 1) await new Promise((resolve) => setImmediate(resolve));
|
|
engine.trigger("track_call");
|
|
while (spoken.length < 2) await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(spoken.at(-1)[0], "Stopp på banan");
|
|
assert.equal(spoken.at(-1)[1], true);
|
|
resolvers.forEach((resolve) => resolve(true));
|
|
});
|
|
|
|
test("start lights, random delay and tone precede the official start callback", async () => {
|
|
const calls = [];
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
|
|
audioFactory: (src) => ({ play: () => { calls.push(["effect", src]); return Promise.resolve(); }, volume: 1 }),
|
|
speak: async (text) => { calls.push(["voice", text]); return true; },
|
|
wait: async (ms) => calls.push(["wait", ms]),
|
|
random: () => 0.5,
|
|
getSettings: () => normalizeRaceAudioSettings({ startCountdownSeconds: 0, lightIntervalMs: 300, minStartDelay: 1, maxStartDelay: 4 }),
|
|
});
|
|
const states = [];
|
|
engine.subscribeStartLights((state) => states.push(state));
|
|
await engine.runStartSequence({ id: "race", type: "final" }, { onTone: () => calls.push(["timing-start"]) });
|
|
assert.deepEqual(states, ["IDLE", "GRID", "READY", "LIGHT_1", "LIGHT_2", "LIGHT_3", "LIGHT_4", "LIGHT_5", "WAIT_RANDOM", "GO"]);
|
|
assert.equal(calls.at(-1)[0], "timing-start");
|
|
assert.deepEqual(calls.slice(-3), [["wait", 2500], ["effect", "/race-sounds/sounds/effects/start_beep.wav"], ["timing-start"]]);
|
|
});
|
|
|
|
test("cancelling a pending start prevents the official start callback", async () => {
|
|
let releaseDelay;
|
|
let started = false;
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
|
|
audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
|
|
speak: async () => true,
|
|
wait: () => new Promise((resolve) => { releaseDelay = resolve; }),
|
|
getSettings: () => normalizeRaceAudioSettings({ startLights: false, minStartDelay: 1, maxStartDelay: 1 }),
|
|
});
|
|
const pending = engine.runStartSequence({ id: "race" }, { onTone: () => { started = true; } });
|
|
while (!releaseDelay) await new Promise((resolve) => setImmediate(resolve));
|
|
engine.cancelStartSequence();
|
|
releaseDelay();
|
|
assert.equal(await pending, false);
|
|
assert.equal(started, false);
|
|
});
|
|
|
|
test("event catalog covers every supported race-audio event", () => {
|
|
const keys = new Set(RACE_AUDIO_EVENT_DEFINITIONS.map((item) => item.key));
|
|
[
|
|
"grid_open", "grid_closed", "race_1_minute", "race_30_seconds", "race_10_seconds", "drivers_ready", "start_sequence", "start_tone", "false_start", "restart_race",
|
|
"transponder_pass", "lap_completed", "personal_best", "fastest_lap", "track_record", "training_stable_pace", "new_leader", "position_gain", "position_loss", "final_lap", "one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "race_finished",
|
|
"track_call", "track_clear", "yellow_flag", "red_flag", "race_paused", "race_resumed", "marshal_warning", "marshal_required", "penalty", "stop_and_go", "drive_through", "disqualified",
|
|
"pit_lane_open", "pit_lane_closed", "entering_pits", "leaving_pits", "pit_speed_warning",
|
|
"qualifying_start", "qualifying_remaining", "qualifying_finished", "provisional_pole", "pole_position",
|
|
"race_winner", "second_place", "third_place", "podium", "heat_winner", "overall_winner",
|
|
"timing_connected", "timing_disconnected", "transponder_detected", "unknown_transponder", "audio_test",
|
|
].forEach((key) => assert.equal(keys.has(key), true, `missing event: ${key}`));
|
|
});
|
|
|
|
test("voice follows the selected language and interpolates dynamic race data", async () => {
|
|
let language = "sv";
|
|
const spoken = [];
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => ({ ...manifest, voice_events: {
|
|
sv: { ...manifest.voice_events.sv, new_leader: "Ny ledare {driverName}" },
|
|
en: { ...manifest.voice_events.en, new_leader: "New leader {driverName}" },
|
|
} }) }),
|
|
audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
|
|
speak: async (text, options) => { spoken.push([text, options.language]); return true; },
|
|
getSettings: () => normalizeRaceAudioSettings({ language }),
|
|
});
|
|
await engine.trigger("new_leader", { variables: { driverName: "Alex" } }).voiceDone;
|
|
language = "en";
|
|
await engine.trigger("new_leader", { variables: { driverName: "Sam" }, force: true }).voiceDone;
|
|
assert.deepEqual(spoken, [["Ny ledare Alex", "sv"], ["New leader Sam", "en"]]);
|
|
});
|
|
|
|
test("simultaneous transponder pass effects overlap without entering the voice queue", async () => {
|
|
const releases = [];
|
|
const played = [];
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
|
|
audioFactory: (src) => ({ play: () => { played.push(src); return new Promise((resolve) => releases.push(resolve)); }, volume: 1 }),
|
|
getSettings: () => normalizeRaceAudioSettings(),
|
|
});
|
|
const first = engine.trigger("transponder_pass", { competitorKey: "one" });
|
|
const second = engine.trigger("transponder_pass", { competitorKey: "two" });
|
|
while (played.length < 2) await new Promise((resolve) => setImmediate(resolve));
|
|
assert.equal(played.length, 2);
|
|
assert.equal(engine.getStatus().currentVoice, "");
|
|
releases.forEach((resolve) => resolve());
|
|
assert.deepEqual(await Promise.all([first.effectDone, second.effectDone]), [true, true]);
|
|
});
|
|
|
|
test("event cooldown debounces one scope without suppressing another competitor", async () => {
|
|
let timestamp = 1_000;
|
|
const logged = [];
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => manifest }),
|
|
audioFactory: () => ({ play: () => Promise.resolve(), volume: 1 }),
|
|
speak: async () => true,
|
|
now: () => timestamp,
|
|
log: (entry) => logged.push(entry),
|
|
getSettings: () => normalizeRaceAudioSettings(),
|
|
});
|
|
engine.trigger("personal_best", { driverId: "d1" });
|
|
timestamp += 100;
|
|
engine.trigger("personal_best", { driverId: "d1" });
|
|
engine.trigger("personal_best", { driverId: "d2" });
|
|
timestamp += 1_500;
|
|
engine.trigger("personal_best", { driverId: "d1" });
|
|
await Promise.resolve();
|
|
assert.deepEqual(logged.map((entry) => entry.event), ["personal_best", "personal_best", "personal_best"]);
|
|
});
|
|
|
|
test("fastest lap, track record and leader events use mapped effects and dynamic voice", async () => {
|
|
const played = [];
|
|
const spoken = [];
|
|
const completeManifest = {
|
|
...manifest,
|
|
events: {
|
|
...manifest.events,
|
|
fastest_lap: { effect: "effects/fastest.wav" },
|
|
track_record: { effect: "effects/record.wav" },
|
|
},
|
|
voice_events: { sv: {
|
|
...manifest.voice_events.sv,
|
|
fastest_lap: "Snabbast {driverName} {lapTime}",
|
|
track_record: "Rekord {driverName} {lapTime}",
|
|
new_leader: "Ledare {driverName}",
|
|
} },
|
|
};
|
|
const engine = createRaceAudioEngine({
|
|
fetchImpl: async () => ({ ok: true, json: async () => completeManifest }),
|
|
audioFactory: (src) => ({ play: () => { played.push(src); return Promise.resolve(); }, volume: 1 }),
|
|
speak: async (text) => { spoken.push(text); return true; },
|
|
getSettings: () => normalizeRaceAudioSettings(),
|
|
});
|
|
for (const key of ["fastest_lap", "track_record", "new_leader"]) {
|
|
await engine.trigger(key, { variables: { driverName: "Kim", lapTime: "16.200" }, force: true }).voiceDone;
|
|
}
|
|
assert.deepEqual(played, ["/race-sounds/sounds/effects/fastest.wav", "/race-sounds/sounds/effects/record.wav"]);
|
|
assert.deepEqual(spoken, ["Snabbast Kim 16.200", "Rekord Kim 16.200", "Ledare Kim"]);
|
|
});
|