Files
baiyue-logistics-chat/tests/frontend/voice.test.cjs
T

163 lines
8.9 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(){}
blur(){}
}
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('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);
});