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); });