'use strict'; const { test } = require('node:test'), assert = require('node:assert/strict'); const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'); function fixture(options = {}) { const contexts = [], played = [], logs = []; let clock = 0, created = 0; const module = { exports: {} }; const platform = { createInnerAudioContext() { if (options.factoryThrows) throw Error('factory unavailable'); const listeners = {}, ctx = { stops: 0, destroyed: false, obeyMuteSwitch: true, onEnded(fn) { if (options.listenerThrows) throw new TypeError("Cannot read properties of undefined (reading 'appxDomain')"); listeners.ended = fn; }, onError(fn) { listeners.error = fn; }, play() { assert.equal(this.stops, 0, 'never play a stopped native context'); played.push(this.src); if (options.syncError && ++created === 1) listeners.error({ errCode: 123 }); }, stop() { this.stops++; if (options.stopEmitsError && listeners.error) listeners.error({}); }, destroy() { this.destroyed = true; }, end() { listeners.ended(); }, fail(error) { listeners.error(error || {}); }, ready() { listeners.ready(); } }; if (options.readyEvent) ctx[options.readyEvent] = fn => { listeners.ready = fn; }; if (options.readonly) for (const key of ['volume', 'loop', 'autoplay', 'obeyMuteSwitch']) { Object.defineProperty(ctx, key, { configurable: true, get: () => undefined, set() { throw Error('readonly host property'); } }); } contexts.push(ctx); return ctx; } }; if (options.missing) delete platform.createInnerAudioContext; vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../utils/voice.js'), 'utf8'), { module, my: platform, console: { info: value => logs.push(value) }, Date: { now: () => clock }, require(name) { assert.equal(name, './api.js'); return { assetUrl: value => 'https://assets.example/' + value }; } }); return { voice: module.exports, contexts, played, logs, clock: value => { clock = value; } }; } test('first audio plays without a preceding stop and completed/stopped native contexts are never reused', () => { const f = fixture(); f.voice.play('step1-front'); assert.equal(f.contexts[0].stops, 0); assert.equal(f.contexts[0].volume, 1); assert.equal(f.contexts[0].obeyMuteSwitch, true, 'Taobao-unsupported property is not modified'); f.voice.play('countdown-321'); assert.equal(f.contexts.length, 1, 'flow is queued'); f.contexts[0].end(); assert.equal(f.contexts[0].destroyed, true); assert.equal(f.contexts.length, 2); assert.match(f.played[1], /countdown-321\.mp3$/); f.voice.stop(true); assert.equal(f.contexts[1].destroyed, true); f.voice.play('shot-done'); assert.equal(f.contexts.length, 3); f.voice.stop(true); }); test('late errors and ended events from interrupted audio cannot clear the current clip or drain its queue', () => { const f = fixture({ stopEmitsError: true }); f.voice.play('step1-front'); const old = f.contexts[0]; f.voice.play('shot-done', { cut: true }); f.voice.play('step2-rightface'); old.end(); old.fail(); assert.equal(f.contexts.length, 2); assert.equal(f.contexts[1].destroyed, false); f.contexts[1].end(); assert.equal(f.contexts.length, 3); assert.match(f.played[2], /step2-rightface\.mp3$/); f.voice.stop(true); }); test('step and countdown flow has priority over pose hints, with four-second hint deduplication', () => { const f = fixture(); f.voice.play('step1-front'); f.voice.play('hint-turn-left', { hint: true }); f.voice.play('countdown-321'); f.contexts[0].end(); assert.match(f.played[1], /countdown-321/); f.contexts[1].end(); assert.match(f.played[2], /hint-turn-left/); f.contexts[2].end(); f.voice.play('hint-turn-left', { hint: true }); assert.equal(f.played.length, 3); f.clock(4000); f.voice.play('hint-turn-left', { hint: true }); assert.equal(f.played.length, 4); f.voice.stop(true); }); test('dropping an old pose hint prevents it from being spoken after the next step', () => { const f = fixture(); f.voice.play('shot-done'); f.voice.play('hint-turn-left', { hint: true }); f.voice.dropHint(); f.voice.play('step3-leftface'); f.contexts[0].end(); f.contexts[1].end(); assert.equal(f.played.length, 2); assert.match(f.played[1], /step3-leftface/); }); test('synchronous native errors, unavailable factories and readonly optional properties never break capture', () => { const f = fixture({ syncError: true }); assert.doesNotThrow(() => f.voice.play('step1-front')); assert.equal(f.contexts[0].destroyed, true); f.voice.play('step2-rightface'); assert.match(f.played[1], /step2-rightface/); f.voice.stop(true); for (const options of [{ missing: true }, { factoryThrows: true }]) { const g = fixture(options); let done = false; assert.doesNotThrow(() => g.voice.play('step1-front')); g.voice.afterCurrent(() => { done = true; }); assert.equal(done, true); } const g = fixture({ readonly: true }); assert.doesNotThrow(() => g.voice.play('step1-front')); assert.equal(g.played.length, 1); g.voice.stop(true); }); test('leaving capture clears queued speech and waiters; returning can start a fresh voice', () => { const f = fixture(); let callbacks = 0; f.voice.play('step1-front'); f.voice.play('step2-rightface'); f.voice.afterCurrent(() => callbacks++); const old = f.contexts[0]; f.voice.stop(true); old.end(); assert.equal(callbacks, 0); assert.equal(f.played.length, 1); f.voice.play('step1-front'); f.voice.afterCurrent(() => callbacks++); f.contexts[1].end(); assert.equal(callbacks, 1); }); test('both native can-play spellings defer playback until preparation is complete and never restart a clip', () => { for (const readyEvent of ['onCanPlay', 'onCanplay']) { const f = fixture({ readyEvent }); f.voice.play('step1-front'); assert.equal(f.played.length, 0); assert.equal(f.voice.diagnostics().stage, 'loading'); f.contexts[0].ready(); assert.equal(f.played.length, 1); f.contexts[0].ready(); assert.equal(f.played.length, 1); f.voice.play('shot-done', { cut: true }); f.contexts[0].ready(); assert.equal(f.played.length, 1, 'stale readiness cannot play an old clip'); f.contexts[1].ready(); assert.equal(f.played.length, 2); f.voice.stop(true); } }); test('audio diagnostics keep native error codes while excluding returned URLs and private data', () => { const f = fixture(); f.voice.play('step1-front'); f.contexts[0].fail({ errorCode: 6001, errorMessage: 'domain rejected https://private.example/?token=PRIVATE_SECRET', userId: 'PRIVATE_ACCOUNT' }); const status = f.voice.diagnostics(); assert.equal(status.code, '6001'); assert.equal(status.category, 'domain'); assert.equal(status.errorEvents, 1); assert.doesNotMatch(JSON.stringify(status) + f.logs.join(''), /https:|PRIVATE_|userId|token=/); status.code = 'mutated'; assert.equal(f.voice.diagnostics().code, '6001'); }); test('SDK listener exceptions are distinguished from domain rejection and do not block capture', () => { const f = fixture({ listenerThrows: true }); assert.doesNotThrow(() => f.voice.play('step1-front')); const d = f.voice.diagnostics(); assert.equal(d.operation, 'listen_ended'); assert.equal(d.errorType, 'TypeError'); assert.equal(d.reason, 'APPX_DOMAIN_CONTEXT'); assert.equal(f.contexts[0].destroyed, true); let ready = false; f.voice.afterCurrent(() => { ready = true; }); assert.equal(ready, true); });