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'); function makeHarness({supported = true, tts = true, onTranscript = () => Promise.resolve()} = {}) { const recognitions = []; const utterances = []; const states = []; const timers = 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) { const id = ++timerId; timers.set(id, callback); return id; }, clearTimeout(id) { timers.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}; } 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('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(), 'SPEAKING'); }); test('speech is disabled in screen-reader mode and gracefully degrades without synthesis', () => { const screenReader = makeHarness(); assert.equal(screenReader.controller.speak('dialog'), false); 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(global.GameVoice.parseVoiceCommand('upprepa'), null); assert.equal(global.GameVoice.parseVoiceCommand('hjälp'), null); assert.equal(global.GameVoice.parseVoiceCommand('röstkommando upprepa.' ).command, 'repeat'); assert.equal(global.GameVoice.parseVoiceCommand('röstkommando alternativ').command, 'settings'); assert.equal(global.GameVoice.parseVoiceCommand('röstinställningar').command, 'settings'); assert.equal(global.GameVoice.parseVoiceCommand('röstinställningar', {requirePrefix: true}), null); assert.equal(global.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 requested = {...global.GameVoice.DEFAULT_SETTINGS, mode: 'game-voice', autoSpeak: true, rate: 1.5, voiceName: 'Svensk'}; assert.equal(global.GameVoice.saveSettings(storage, 'voice', requested), true); const loaded = global.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(global.GameVoice.loadSettings(storage, 'voice').mode, 'screen-reader'); assert.equal(global.GameVoice.saveSettings({setItem() { throw new Error('blocked'); }}, 'voice', requested), false); });