172 lines
9.3 KiB
JavaScript
172 lines
9.3 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
const flush = () => new Promise(setImmediate);
|
|
|
|
function voicePage(options={}){
|
|
const elements={}, timers=new Map(), docEvents={}, pageEvents={}, calls=[], messages=[], statuses=[];
|
|
let now=1000, timerId=0, contextId='conversation-1', recorder, permissionCalls=0, trackStops=0;
|
|
class Element {
|
|
constructor(){ this.listeners={}; this.attributes={}; this.classes=new Set(); this.value='';
|
|
this.classList={toggle:(name,on) => on ? this.classes.add(name) : this.classes.delete(name),remove:name=>this.classes.delete(name)}; }
|
|
setAttribute(name,value){this.attributes[name]=value;}
|
|
addEventListener(name,callback){this.listeners[name]=callback;}
|
|
setPointerCapture(){}
|
|
focus(options){this.focusOptions=options;}
|
|
blur(){this.blurred=true;}
|
|
}
|
|
for(const key of ['toggle','hold','panel','label','clock','inputArea','input']) elements[key]=new Element();
|
|
const tracks=[{stop(){trackStops++;}}], stream={getTracks:()=>tracks};
|
|
class Recorder {
|
|
constructor(source, settings){ recorder=this; this.state='inactive'; this.mimeType=settings.mimeType || 'audio/mp4'; }
|
|
static isTypeSupported(type){return options.mp4 ? type==='audio/mp4' : type.startsWith('audio/webm');}
|
|
start(){this.state='recording';}
|
|
stop(){this.state='inactive'; this.ondataavailable({data:new Blob(['audio fixture'],{type:this.mimeType})}); this.onstop();}
|
|
}
|
|
class ClockDate extends Date {static now(){return now;}}
|
|
const response = data => ({ok:true,json:async()=>data});
|
|
const sandbox=vm.createContext({console,Blob,AbortController,Date:ClockDate,
|
|
FormData:class{constructor(){this.values={};}append(key,value){this.values[key]=value;}},
|
|
navigator:{mediaDevices:{getUserMedia:async()=>{permissionCalls++; if(options.permission) return options.permission(); return stream;}}},
|
|
document:{hidden:false,addEventListener:(name,callback)=>{docEvents[name]=callback;}},
|
|
window:{isSecureContext:options.secure!==false,MediaRecorder:options.unsupported ? undefined : Recorder,
|
|
PointerEvent:options.touch ? undefined : function(){},addEventListener:(name,callback)=>{pageEvents[name]=callback;}},
|
|
setTimeout:(callback,delay)=>{const id=++timerId;timers.set(id,{callback,delay});return id;},
|
|
clearTimeout:id=>timers.delete(id),setInterval:()=>++timerId,clearInterval(){},
|
|
fetch:async(url,init)=>{calls.push({url,init});
|
|
if(url==='/api/speech/config') return response({enabled:options.enabled!==false,max_bytes:5242880,max_seconds:60});
|
|
if(options.transcribe) return options.transcribe(init);
|
|
return response({ok:true,text:'查询订单 FL20261030001'});
|
|
},
|
|
});
|
|
vm.runInContext(fs.readFileSync(path.join(__dirname,'../../static/js/voice.js'),'utf8'),sandbox);
|
|
const voice=sandbox.ChatVoice.create({...elements,text:key=>key,status:(...args)=>statuses.push(args),
|
|
language:()=> 'zh',context:()=>contextId,canStart:()=>options.reason || '',onText:async text=>messages.push(text)});
|
|
const event=(extra={})=>({button:0,pointerId:7,clientY:300,preventDefault(){},...extra});
|
|
return {voice,elements,calls,messages,statuses,stream,
|
|
get permissionCalls(){return permissionCalls;},get trackStops(){return trackStops;},get recorder(){return recorder;},
|
|
context:value=>{contextId=value;},
|
|
open:()=>elements.toggle.onclick(),
|
|
down:async()=>{elements.hold.listeners.pointerdown(event());await flush();},
|
|
up:async(duration=1000)=>{now+=duration;elements.hold.listeners.pointerup(event());await flush();},
|
|
move:y=>elements.hold.listeners.pointermove(event({clientY:y})),
|
|
event,docEvents,pageEvents,sandbox,
|
|
expire:async delay=>{for(const [id,timer] of [...timers]) if(timer.delay===delay){timers.delete(id);now+=delay;timer.callback();}await flush();},
|
|
};
|
|
}
|
|
|
|
test('hold and release transcribes audio and sends text once',async()=>{
|
|
const app=voicePage();app.open();await app.down();
|
|
assert.equal(app.recorder.state,'recording');
|
|
assert.equal(app.elements.panel.hidden,false);
|
|
await app.up();
|
|
assert.deepEqual(app.messages,['查询订单 FL20261030001']);
|
|
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,1);
|
|
assert.ok(app.trackStops>0);
|
|
assert.equal(app.elements.panel.hidden,true);
|
|
await app.up();
|
|
assert.equal(app.messages.length,1);
|
|
});
|
|
|
|
test('switching between voice and keyboard avoids scrolling the page on focus',()=>{
|
|
const app=voicePage();
|
|
app.open();
|
|
assert.ok(app.elements.input.blurred);
|
|
app.open();
|
|
assert.equal(app.elements.input.focusOptions.preventScroll,true);
|
|
assert.equal(app.elements.toggle.attributes['aria-pressed'],'false');
|
|
});
|
|
|
|
test('sliding up cancels without uploading and supports another recording',async()=>{
|
|
const app=voicePage();app.open();await app.down();app.move(200);await app.up();
|
|
assert.equal(app.messages.length,0);
|
|
assert.equal(app.calls.filter(call=>call.url==='/api/speech/transcribe').length,0);
|
|
assert.ok(app.trackStops>0);
|
|
await app.down();await app.up();
|
|
assert.equal(app.messages.length,1);
|
|
});
|
|
|
|
test('short presses and unavailable service do not upload recordings',async()=>{
|
|
const app=voicePage();app.open();await app.down();await app.up(50);
|
|
assert.equal(app.messages.length,0);
|
|
assert.equal(app.statuses.at(-1)[0],'voiceTooShort');
|
|
const disabled=voicePage({enabled:false});disabled.open();await disabled.down();
|
|
assert.equal(disabled.permissionCalls,0);
|
|
assert.equal(disabled.statuses.at(-1)[0],'voiceUnavailable');
|
|
});
|
|
|
|
test('late microphone permission after cancellation immediately stops its tracks',async()=>{
|
|
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
|
|
app.open();await app.down();await app.up();grant(app.stream);await flush();
|
|
assert.equal(app.messages.length,0);
|
|
assert.ok(app.trackStops>0);
|
|
assert.equal(app.voice.busy(),false);
|
|
});
|
|
|
|
test('switching conversation or explicitly cancelling ignores a late transcript',async()=>{
|
|
for(const cancelled of [false,true]){
|
|
let resolve;const app=voicePage({transcribe:()=>new Promise(done=>{resolve=done;})});
|
|
app.open();await app.down();await app.up();
|
|
if(cancelled) app.voice.cancel(); else app.context('conversation-2');
|
|
resolve({ok:true,json:async()=>({ok:true,text:'must not enter the new conversation'})});await flush();
|
|
assert.equal(app.messages.length,0);
|
|
assert.equal(app.voice.busy(),false);
|
|
}
|
|
});
|
|
|
|
test('permission denial and unsupported or insecure browsers leave typing available',async()=>{
|
|
const denied=voicePage({permission:()=>Promise.reject(Object.assign(new Error(),{name:'NotAllowedError'}))});
|
|
denied.open();await denied.down();
|
|
assert.equal(denied.voice.busy(),false);
|
|
assert.equal(denied.statuses.at(-1)[0],'voicePermission');
|
|
for(const options of [{unsupported:true},{secure:false}]){
|
|
const app=voicePage(options);app.open();
|
|
assert.equal(app.elements.inputArea.classes.has('voice-mode'),false);
|
|
assert.equal(app.permissionCalls,0);
|
|
}
|
|
});
|
|
|
|
test('pending drafts and busy chat do not request microphone access',async()=>{
|
|
const app=voicePage({reason:'wait for current chat'});app.open();await app.down();
|
|
assert.equal(app.permissionCalls,0);
|
|
assert.equal(app.calls.length,0);
|
|
assert.equal(app.statuses.at(-1)[0],'wait for current chat');
|
|
});
|
|
|
|
test('maximum recording length stops the microphone and sends once',async()=>{
|
|
const app=voicePage({mp4:true});app.open();await app.down();await app.expire(60000);
|
|
assert.equal(app.messages.length,1);
|
|
assert.ok(app.trackStops>0);
|
|
assert.equal(app.calls.find(call=>call.url==='/api/speech/transcribe').init.body.values.audio.type,'audio/mp4');
|
|
await app.up();assert.equal(app.messages.length,1);
|
|
});
|
|
|
|
test('touch fallback and browser cancellation do not submit accidentally',async()=>{
|
|
const app=voicePage({touch:true});app.open();
|
|
app.elements.hold.listeners.touchstart(app.event({touches:[{identifier:1,clientY:300}]}));await flush();
|
|
app.elements.hold.listeners.touchcancel();await flush();
|
|
assert.equal(app.messages.length,0);
|
|
assert.ok(app.trackStops>0);
|
|
});
|
|
|
|
test('a stalled transcription times out and releases the recorder state',async()=>{
|
|
const app=voicePage({transcribe:init=>new Promise((resolve,reject)=>{
|
|
init.signal.addEventListener('abort',()=>reject(Object.assign(new Error(),{name:'AbortError'})));
|
|
})});app.open();await app.down();await app.up();await app.expire(40000);
|
|
assert.equal(app.voice.busy(),false);
|
|
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
|
|
assert.equal(app.messages.length,0);
|
|
});
|
|
|
|
test('an unanswered microphone permission prompt times out and a late stream is closed',async()=>{
|
|
let grant;const app=voicePage({permission:()=>new Promise(resolve=>{grant=resolve;})});
|
|
app.open();await app.down();await app.expire(15000);
|
|
assert.equal(app.voice.busy(),false);
|
|
assert.equal(app.statuses.at(-1)[0],'voiceTimeout');
|
|
grant(app.stream);await flush();
|
|
assert.ok(app.trackStops>0);
|
|
assert.equal(app.messages.length,0);
|
|
});
|