121 lines
7.3 KiB
JavaScript
121 lines
7.3 KiB
JavaScript
'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);
|
|
});
|