import test from "node:test"; import assert from "node:assert/strict"; import { announceRaceStartedHelper, 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("race start only creates the overlay event - the old start tone and voice are gone (race audio owns the start)", async () => { const calls = []; announceRaceStartedHelper( { id: "session_1", name: "A Final", type: "final" }, { // Old saved settings from the removed legacy panel must not bring the extra cues back. state: { settings: { audioEnabled: true, startSoundEnabled: true, finishVoiceEnabled: true, raceAudio: { enabled: false } } }, t: (key) => key, pushOverlayEvent: (type, label, options) => calls.push([type, label, options]), playStartCue: () => calls.push(["tone"]), speakText: (value, options) => calls.push(["speech", value, options]), } ); await new Promise((resolve) => setTimeout(resolve, 700)); assert.deepEqual(calls, [["start", "A Final • timing.start", { silentCue: 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 { // audioEnabled false: the removed legacy "Sound on" switch used to silence every race-audio // voice (while effects still played). It must no longer have any effect. const started = speakTextHelper("Race finished", { state: { settings: { audioEnabled: false, 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; } });