Add browser voice input and speech output
Build and publish frontend / publish (push) Failing after 4s
Build and publish frontend / publish (push) Failing after 4s
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user