Files
Live_RC/tests/audio_runtime.test.js
2026-08-28 13:47:25 +02:00

128 lines
5.0 KiB
JavaScript

import test from "node:test";
import assert from "node:assert/strict";
import {
announcePassingHelper,
announceRaceStartedHelper,
getSessionAnnouncementText,
handleSessionTimerTickHelper,
selectSpeechVoice,
speakTextHelper,
} from "../src/runtime_services.js";
test("speech voice selection prefers an explicit voice and then the current language", () => {
const voices = [
{ name: "English", lang: "en-US", voiceURI: "voice-en", localService: true },
{ name: "Swedish", lang: "sv-SE", voiceURI: "voice-sv", localService: true },
];
assert.equal(selectSpeechVoice(voices, "voice-en", "sv-SE")?.voiceURI, "voice-en");
assert.equal(selectSpeechVoice(voices, "", "sv-SE")?.voiceURI, "voice-sv");
assert.equal(selectSpeechVoice(voices, "missing", "de-DE"), null);
});
test("session announcements distinguish practice from racing", () => {
const t = (key) => key;
assert.equal(getSessionAnnouncementText({ type: "open_practice" }, "start", { t }), "timing.practice_started_voice");
assert.equal(getSessionAnnouncementText({ type: "practice" }, "finish", { t }), "timing.practice_finished_voice");
assert.equal(getSessionAnnouncementText({ type: "final" }, "start", { t }), "timing.race_started_voice");
assert.equal(getSessionAnnouncementText({ type: "heat" }, "finish", { t }), "timing.race_finished_voice");
});
test("passing announcement uses the driver's spoken name and beeps while speech is busy", () => {
const calls = [];
announcePassingHelper(
{ displayName: "Anders", speechName: "Ann-ders" },
{
state: { settings: { audioEnabled: true, passingSoundMode: "name" } },
t: () => "Unknown",
speakText: (value, options) => { calls.push([value, options]); return false; },
playPassingBeep: () => calls.push(["beep"]),
}
);
assert.deepEqual(calls, [["Ann-ders", { interrupt: false }], ["beep"]]);
});
test("race start creates an overlay event and plays enabled start cues", async () => {
const calls = [];
announceRaceStartedHelper(
{ id: "session_1", name: "A Final", type: "final" },
{
state: { settings: { audioEnabled: true, startSoundEnabled: true, finishVoiceEnabled: true } },
t: (key) => key,
pushOverlayEvent: (type, label, options) => calls.push([type, label, options]),
playStartCue: () => calls.push(["tone"]),
speakText: (value, options) => calls.push(["speech", value, options]),
}
);
assert.deepEqual(calls, [
["start", "A Final • timing.start", { silentCue: true }],
["tone"],
]);
await new Promise((resolve) => setTimeout(resolve, 700));
assert.deepEqual(calls[2], ["speech", "timing.race_started_voice", { interrupt: true }]);
});
test("session timer emits each race-audio clock marker once without changing finish rules", () => {
const active = { id: "s1", eventId: "e1", type: "final", status: "running", followUpSec: 30, audioAnnouncements: [] };
const announced = [];
let remainingMs = 59_000;
let saves = 0;
const run = () => handleSessionTimerTickHelper({
getActiveSession: () => active,
isUntimedSession: () => false,
getSessionTiming: () => ({ remainingMs, followUpRemainingMs: 30_000 }),
saveState: () => { saves += 1; },
getLastFinishAnnouncementSessionId: () => null,
setLastFinishAnnouncementSessionId: () => {},
announceRaceFinished: () => announced.push("race_finished"),
announceRaceClockEvent: (key) => announced.push(key),
});
run(); run();
remainingMs = 29_000; run();
remainingMs = 9_000; run();
remainingMs = 0; run();
assert.deepEqual(announced, ["one_minute_remaining", "thirty_seconds_remaining", "ten_seconds_remaining", "time_expired", "final_lap"]);
assert.equal(active.status, "running");
assert.ok(active.followUpStartedAt);
assert.ok(saves >= 4);
});
test("race audio can ignore a mismatched configured voice and select the announcement language", () => {
const originalWindow = globalThis.window;
const OriginalUtterance = globalThis.SpeechSynthesisUtterance;
const spoken = [];
const voices = [
{ name: "English", lang: "en-US", voiceURI: "voice-en", localService: true },
{ name: "Swedish", lang: "sv-SE", voiceURI: "voice-sv", localService: true },
];
globalThis.window = {
speechSynthesis: {
speaking: false,
pending: false,
cancel() {},
getVoices: () => voices,
speak: (utterance) => spoken.push(utterance),
},
};
globalThis.SpeechSynthesisUtterance = class {
constructor(text) { this.text = text; }
};
try {
const started = speakTextHelper("Race finished", {
state: { settings: { audioEnabled: true, speechVoiceUri: "voice-sv", speechRate: 1, speechPitch: 1 } },
currentLanguage: () => "sv",
}, { language: "en", voiceUri: "", rate: 1.1, pitch: 0.85 });
assert.equal(started, true);
assert.equal(spoken[0].lang, "en-US");
assert.equal(spoken[0].voice.voiceURI, "voice-en");
assert.equal(spoken[0].rate, 1.1);
assert.equal(spoken[0].pitch, 0.85);
} finally {
globalThis.window = originalWindow;
globalThis.SpeechSynthesisUtterance = OriginalUtterance;
}
});