Files
openless/openless-all/app/scripts/audio-cue-runtime.test.mjs
16c3f841b5 chore(release): prepare 2.0.0-Beta.2 build 20260924
Integrate the reviewed 2.0 PRs and local fixes from 03136853.
Preserve the verified subtitle behavior and model/runtime corrections.
Keep the numeric Beta tag compatible with released clients while exposing
the date as application build metadata.

Co-authored-by: DepengWang <2818245+DepengWang@users.noreply.github.com>
Co-authored-by: HKLHaoBin <we3q@qq.com>
Co-authored-by: Nahida <1139500183@qq.com>
Co-authored-by: TRIP <1933142963@qq.com>
Co-authored-by: chyuwei <weichangyuwcy@gmail.com>
Co-authored-by: leonoxo <leonoxo@gmail.com>
Co-authored-by: tayir <arnoldincredible@126.com>
2026-09-24 17:28:24 +08:00

155 lines
5.1 KiB
JavaScript

import assert from 'node:assert/strict';
import { mock } from 'node:test';
import { tsImport } from 'tsx/esm/api';
const { playRecordStartCue, stopAudioCue } = await tsImport(
'../src/lib/audioCue.ts',
import.meta.url,
);
// Exercise the actual playback lifecycle, including promises that never resolve.
let now = 0;
mock.timers.enable({ apis: ['setTimeout'] });
mock.method(performance, 'now', () => now);
function tick(ms) {
now += ms;
mock.timers.tick(ms);
}
class FakeContext {
get currentTime() {
return this.frozen ? 0 : now / 1000;
}
constructor() {
this.state = FakeContext.initialState;
this.frozen = false;
this.voices = 0;
this.closeCalls = 0;
this.resumeImpl = FakeContext.initialResumeImpl;
this.destination = {};
FakeContext.instances.push(this);
}
resume() {
return this.resumeImpl();
}
close() {
this.closeCalls++;
this.state = 'closed';
return Promise.resolve();
}
createOscillator() {
this.voices++;
return {
frequency: { setValueAtTime() {} },
connect: (gain) => gain,
start() {},
stop() {},
disconnect() {},
};
}
createGain() {
return {
gain: {
setValueAtTime() {},
exponentialRampToValueAtTime() {},
cancelScheduledValues() {},
},
connect() {},
disconnect() {},
};
}
}
FakeContext.instances = [];
FakeContext.initialState = 'running';
FakeContext.initialResumeImpl = () => new Promise(() => {});
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { AudioContext: FakeContext },
});
try {
FakeContext.initialState = 'suspended';
playRecordStartCue();
const stuck = FakeContext.instances[0];
let releaseOldResume;
stuck.resumeImpl = () => new Promise((resolve) => (releaseOldResume = resolve));
playRecordStartCue();
FakeContext.initialState = 'running';
tick(250);
const healthy = FakeContext.instances[1];
assert.equal(stuck.closeCalls, 1, 'a hung resume must close the old context');
assert.equal(healthy.voices, 2, 'the same recording recovers its cue');
stuck.state = 'running';
releaseOldResume();
await Promise.resolve();
assert.equal(stuck.voices, 0, 'late resolution cannot play through a discarded context');
tick(315);
// Every request invalidates old work, including a new synchronous playback.
healthy.state = 'suspended';
let releaseSuperseded;
healthy.resumeImpl = () => new Promise((resolve) => (releaseSuperseded = resolve));
playRecordStartCue();
healthy.state = 'running';
playRecordStartCue();
releaseSuperseded();
await Promise.resolve();
assert.equal(healthy.voices, 4, 'a superseded resume must not duplicate the newer cue');
tick(315);
healthy.frozen = true;
playRecordStartCue();
tick(315);
const recovered = FakeContext.instances[2];
assert.equal(healthy.closeCalls, 1, 'running with a frozen clock must recover');
assert.equal(recovered.voices, 2);
tick(315);
// The original stop/time boundary survives recreation, so a late retry stays silent.
recovered.state = 'suspended';
playRecordStartCue();
stopAudioCue();
tick(1000);
assert.equal(recovered.closeCalls, 1, 'even a dropped late cue discards its broken context');
assert.equal(FakeContext.instances.length, 3, 'stopped recordings do not replay late');
playRecordStartCue();
const next = FakeContext.instances[3];
assert.equal(next.voices, 2, 'the next recording still works after a dropped cue');
tick(315);
next.state = 'suspended';
next.resumeImpl = () => {
throw new Error('audio interrupted');
};
FakeContext.initialState = 'suspended';
assert.doesNotThrow(playRecordStartCue);
tick(250);
assert.equal(FakeContext.instances.length, 5, 'recovery retries at most once per request');
assert.equal(FakeContext.instances[4].closeCalls, 1, 'failed retry releases audio resources');
// The 400ms deadline belongs to the recording, not to each resume attempt.
playRecordStartCue();
let releaseRetry;
FakeContext.initialResumeImpl = () => new Promise((resolve) => (releaseRetry = resolve));
tick(250);
const retry = FakeContext.instances[6];
stopAudioCue();
tick(151);
retry.state = 'running';
releaseRetry();
await Promise.resolve();
assert.equal(retry.voices, 0, 'a recreated context cannot reset the late-cue deadline');
playRecordStartCue();
assert.equal(retry.voices, 2, 'dropping the old cue must not disable the next recording');
tick(315);
retry.state = 'suspended';
retry.resumeImpl = () => Promise.reject(new Error('device changed'));
FakeContext.initialState = 'running';
playRecordStartCue();
await Promise.resolve();
assert.equal(retry.closeCalls, 1, 'asynchronous resume rejection also releases the context');
assert.equal(FakeContext.instances[7].voices, 2, 'device-change recovery plays only one cue');
tick(315);
console.log('[audioCue.runtime.test] playback recovery assertions passed');
} finally {
stopAudioCue();
mock.restoreAll();
mock.timers.reset();
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
}