const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); const vm = require('node:vm'); const source = fs.readFileSync(path.join(__dirname, '..', 'voice.js'), 'utf8'); const apiWindow = {}; vm.runInNewContext(source, {window: apiWindow, globalThis: apiWindow, Promise, String, Math, Object}); const GameVoice = apiWindow.GameVoice; function makeHarness({supported = true, tts = true, onTranscript = () => Promise.resolve()} = {}) { const recognitions = []; const utterances = []; const states = []; const timers = new Map(); const timerDelays = new Map(); let timerId = 0; class Recognition { constructor() { recognitions.push(this); } start() { this.started = true; } stop() { this.stopped = true; } abort() { this.aborted = true; } } class Utterance { constructor(text) { this.text = text; utterances.push(this); } } const synthesis = tts ? { spoken: [], cancelled: 0, getVoices: () => [{name: 'Svensk', lang: 'sv-SE'}], speak(utterance) { this.spoken.push(utterance); }, cancel() { this.cancelled += 1; }, } : undefined; const window = { setTimeout(callback, delay = 0) { const id = ++timerId; timers.set(id, callback); timerDelays.set(id, delay); return id; }, clearTimeout(id) { timers.delete(id); timerDelays.delete(id); }, speechSynthesis: synthesis, SpeechSynthesisUtterance: tts ? Utterance : undefined, SpeechRecognition: supported ? Recognition : undefined, }; vm.runInNewContext(source, {window, globalThis: window, Promise, String, Math, Object}); const controller = window.GameVoice.createVoiceController({ window, onState: (state, message) => states.push({state, message}), onTranscript, }); return { controller, recognitions, utterances, synthesis, states, timers, runTimer(delay) { const match = [...timerDelays.entries()].find(([, value]) => value === delay); if (!match) throw new Error(`No timer found with delay ${delay}`); timers.get(match[0])(); }, }; } test('recognition is single-shot Swedish and sends only final transcript once', async () => { const received = []; const h = makeHarness({onTranscript: text => { received.push(text); }}); assert.equal(h.controller.startListening(), true); const recognition = h.recognitions[0]; assert.equal(recognition.lang, 'sv-SE'); assert.equal(recognition.continuous, false); assert.equal(recognition.interimResults, false); recognition.onresult({resultIndex: 0, results: [{isFinal: false, 0: {transcript: 'delvis'}}]}); recognition.onresult({resultIndex: 0, results: [{isFinal: true, 0: {transcript: ' Hej världen '}}]}); recognition.onresult({resultIndex: 0, results: [{isFinal: true, 0: {transcript: 'dubblett'}}]}); await Promise.resolve(); await Promise.resolve(); assert.deepEqual(received, ['Hej världen']); assert.equal(h.controller.getState(), 'IDLE'); }); test('second microphone activation stops the active recognition session', () => { const h = makeHarness(); h.controller.startListening(); h.controller.startListening(); assert.equal(h.recognitions.length, 1); assert.equal(h.recognitions[0].aborted, true); assert.equal(h.controller.getState(), 'IDLE'); }); test('unsupported recognition stays recoverable and leaves text input available', () => { const h = makeHarness({supported: false}); assert.equal(h.controller.startListening(), false); assert.equal(h.controller.getState(), 'IDLE'); assert.match(h.states.at(-1).message, /skriva/); }); test('permission errors reset state and a later session can start', () => { const h = makeHarness(); h.controller.startListening(); h.recognitions[0].onerror({error: 'not-allowed'}); assert.equal(h.controller.getState(), 'IDLE'); assert.match(h.states.at(-1).message, /behörighet nekades/); assert.equal(h.controller.startListening(), true); assert.equal(h.recognitions.length, 2); }); test('late recognition results after cancellation are ignored', () => { let delivered = 0; const h = makeHarness({onTranscript: () => { delivered += 1; }}); h.controller.startListening(); const old = h.recognitions[0]; h.controller.stopListening(); old.onresult({resultIndex: 0, results: [{isFinal: true, 0: {transcript: 'för sent'}}]}); assert.equal(delivered, 0); }); test('recognition watchdog closes a session that never ends and permits retry', () => { const h = makeHarness(); h.controller.startListening(); const watchdog = [...h.timers.values()][0]; watchdog(); assert.equal(h.controller.getState(), 'IDLE'); assert.match(h.states.at(-1).message, /en minut/); assert.equal(h.controller.startListening(), true); assert.equal(h.recognitions.length, 2); }); test('TTS selects Swedish fallback, applies rate, and cancellation invalidates callbacks', () => { const h = makeHarness(); h.controller.update({mode: 'game-voice', rate: 1.4}); assert.equal(h.controller.speak('Hej.'), true); const first = h.utterances[0]; assert.equal(first.lang, 'sv-SE'); assert.equal(first.rate, 1.4); assert.equal(first.voice.name, 'Svensk'); h.controller.cancelSpeech(); first.onend(); assert.equal(h.controller.getState(), 'IDLE'); }); test('a player gesture restores the iOS speech primer after cancelling a queued reply', () => { const h = makeHarness(); assert.equal(h.controller.activateFromUserGesture(), true); assert.equal(h.synthesis.spoken[0].text, ' '); h.controller.speak('Tidigare replik'); h.controller.cancelSpeech(); const countAfterCancel = h.synthesis.spoken.length; assert.equal(h.controller.activateFromUserGesture(), true); assert.equal(h.synthesis.spoken.length, countAfterCancel + 1); assert.equal(h.synthesis.spoken.at(-1).text, ' '); h.controller.speak('Nytt svar'); assert.equal(h.synthesis.spoken.at(-1).text, 'Nytt svar'); }); test('speech is reported as in progress only after the native onstart event', () => { const h = makeHarness(); h.controller.speak('Hej.'); assert.equal(h.controller.getState(), 'QUEUED'); assert.match(h.states.at(-1).message, /Startar uppläsningen/); h.utterances[0].onstart(); assert.equal(h.controller.getState(), 'SPEAKING'); assert.match(h.states.at(-1).message, /Webbläsaren har startat talsyntesen/); assert.match(h.states.at(-1).message, /Läs senaste repliken/); }); test('iOS user gesture primes speech silently and idle speech is not cancelled before speak', () => { const h = makeHarness(); assert.equal(h.controller.activateFromUserGesture(), true); assert.equal(h.synthesis.spoken[0].text, ' '); assert.equal(h.synthesis.spoken[0].volume, 0); const cancelledBeforeReply = h.synthesis.cancelled; assert.equal(h.controller.speak('Ett svar efter nätverksanropet'), true); assert.equal(h.synthesis.spoken.at(-1).text, 'Ett svar efter nätverksanropet'); assert.equal(h.synthesis.cancelled, cancelledBeforeReply); }); test('replacing active speech waits one event-loop turn after WebKit cancel', () => { const h = makeHarness(); h.controller.speak('Första repliken'); const cancelledBeforeReplace = h.synthesis.cancelled; h.controller.speak('Nästa replik'); assert.equal(h.synthesis.cancelled, cancelledBeforeReplace + 1); assert.notEqual(h.synthesis.spoken.at(-1).text, 'Nästa replik'); h.runTimer(100); assert.equal(h.synthesis.spoken.at(-1).text, 'Nästa replik'); }); test('speech start timeout tells the player how to recover when mobile playback is blocked', () => { const h = makeHarness(); h.controller.speak('En replik'); const cancelledBeforeTimeout = h.synthesis.cancelled; h.runTimer(4000); assert.match(h.states.at(-1).message, /Tryck på Läs senaste repliken/); assert.equal(h.synthesis.cancelled, cancelledBeforeTimeout + 1); const cancelledBeforeRetry = h.synthesis.cancelled; h.controller.speak('Manuell återläsning'); assert.equal(h.synthesis.spoken.at(-1).text, 'Manuell återläsning'); assert.equal(h.synthesis.cancelled, cancelledBeforeRetry); }); test('iOS not-allowed speech error offers an immediate gesture-based retry', () => { const h = makeHarness(); h.controller.speak('En replik'); h.utterances[0].onerror({error: 'not-allowed'}); assert.match(h.states.at(-1).message, /Safari blockerade uppläsningen/); assert.match(h.states.at(-1).message, /Läs senaste repliken/); assert.equal(h.controller.speak('Försök igen'), true); assert.equal(h.synthesis.spoken.at(-1).text, 'Försök igen'); }); test('character voice identity gives stable distinct masculine, feminine, and neutral timbres', () => { const profile = GameVoice.characterVoiceProfile; const masculine = profile('masculine', 'character-1'); const feminine = profile('feminine', 'character-1'); const neutral = profile('neutral', 'character-1'); assert.equal(profile('masculine', 'character-1').pitch, masculine.pitch); assert.ok(masculine.pitch < neutral.pitch); assert.ok(neutral.pitch < feminine.pitch); assert.notEqual(profile('masculine', 'character-2').pitch, masculine.pitch); }); test('narrator uses neutral pitch while character replies use their saved voice identity', () => { const h = makeHarness(); h.controller.update({mode: 'game-voice'}); h.controller.speak('Berättarröst', {role: 'narrator', voiceIdentity: 'feminine', characterId: 'narrator'}); h.controller.speak('NPC-replik', {voiceIdentity: 'masculine', characterId: 'npc-1'}); h.runTimer(100); assert.equal(h.utterances[0].pitch, 1); assert.ok(h.utterances[1].pitch < 1); }); test('TTS and microphone are mutually exclusive', () => { const h = makeHarness(); h.controller.update({mode: 'game-voice'}); h.controller.startListening(); h.controller.speak('Svar'); assert.equal(h.recognitions[0].aborted, true); assert.equal(h.controller.getState(), 'QUEUED'); h.utterances[0].onstart(); assert.equal(h.controller.getState(), 'SPEAKING'); }); test('game speech stays automatic even with legacy text-only settings and gracefully degrades without synthesis', () => { const screenReader = makeHarness(); screenReader.controller.update({mode: 'text-only', autoSpeak: false}); assert.equal(screenReader.controller.speak('dialog'), true); assert.equal(screenReader.synthesis.spoken[0].text, 'dialog'); const noTts = makeHarness({tts: false}); noTts.controller.update({mode: 'game-voice'}); assert.equal(noTts.controller.speak('dialog'), false); }); test('system commands require an explicit prefix; ordinary dialogue words are untouched', () => { const global = {}; vm.runInNewContext(source, {window: global, globalThis: global, Promise, String, Math, Object}); assert.equal(GameVoice.parseVoiceCommand('upprepa'), null); assert.equal(GameVoice.parseVoiceCommand('hjälp'), null); assert.equal(GameVoice.parseVoiceCommand('röstkommando upprepa.' ).command, 'repeat'); assert.equal(GameVoice.parseVoiceCommand('röstkommando alternativ').command, 'settings'); assert.equal(GameVoice.parseVoiceCommand('röstinställningar').command, 'settings'); assert.equal(GameVoice.parseVoiceCommand('röstinställningar', {requirePrefix: true}), null); assert.equal(GameVoice.parseVoiceCommand('röstkommando alternativ', {requirePrefix: true}).command, 'settings'); }); test('settings survive reload and invalid storage falls back to accessible defaults', () => { const global = {}; vm.runInNewContext(source, {window: global, globalThis: global, Promise, String, Math, Object}); const values = new Map(); const storage = {getItem: key => values.get(key) || null, setItem: (key, value) => values.set(key, value)}; const defaults = GameVoice.loadSettings(storage, 'new-player'); assert.equal(defaults.mode, 'game-voice'); assert.equal(defaults.autoSpeak, true); values.set('voice-v1', JSON.stringify({mode: 'text-only', autoSpeak: false, rate: 1.4, voiceName: 'Svensk', soundSignals: false})); const migrated = GameVoice.loadSettings(storage, 'voice-v2', 'voice-v1'); assert.equal(migrated.mode, 'game-voice'); assert.equal(migrated.autoSpeak, true); assert.equal(migrated.rate, 1.4); assert.equal(migrated.voiceName, 'Svensk'); assert.equal(migrated.soundSignals, false); const requested = {...GameVoice.DEFAULT_SETTINGS, mode: 'game-voice', autoSpeak: true, rate: 1.5, voiceName: 'Svensk'}; assert.equal(GameVoice.saveSettings(storage, 'voice', requested), true); const loaded = GameVoice.loadSettings(storage, 'voice'); assert.equal(loaded.mode, 'game-voice'); assert.equal(loaded.autoSpeak, true); assert.equal(loaded.rate, 1.5); assert.equal(loaded.voiceName, 'Svensk'); values.set('voice', '{trasig'); assert.equal(GameVoice.loadSettings(storage, 'voice').mode, 'game-voice'); assert.equal(GameVoice.loadSettings(storage, 'voice').autoSpeak, true); assert.equal(GameVoice.saveSettings({setItem() { throw new Error('blocked'); }}, 'voice', requested), false); });