mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
A dial click shorter than the device start-up let suspend() run on a still-suspended context, so the later resume() left it running with no nodes. Cover paused, connecting and voice-fallback releases in radioNextState tests.
418 lines
18 KiB
JavaScript
418 lines
18 KiB
JavaScript
// Radio selection, fallbacks and voice handoffs on the real layer and its published view.
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { radioLayer } from './radio.js';
|
|
import { setLocale } from '../i18n/index.js';
|
|
import { renderRadioState } from '../ui/radioPresentation.js';
|
|
import { radioStationRow, withRadioLayer } from '../testSupport/radioLayerHarness.mjs';
|
|
|
|
const READY = 'Ready — playback starts only from your action';
|
|
const ids = [1, 2, 3].map((n) => `00000000-0000-4000-8000-00000000009${n}`);
|
|
const stations = ids.map((id, index) => radioStationRow({
|
|
id,
|
|
name: `Station ${'ABC'[index]}`,
|
|
lat: 30 + index,
|
|
lon: -97 - index,
|
|
streamUrl: `https://radio.example.com/${'abc'[index]}.mp3`,
|
|
}));
|
|
const withLayer = (failingUrls, body) => withRadioLayer({ stations, failingUrls }, body);
|
|
const settle = () => new Promise((resolve) => setTimeout(resolve));
|
|
|
|
// What the Radio panel and the Cockpit player show for a state.
|
|
function render(state) {
|
|
const previousDocument = globalThis.document;
|
|
globalThis.document = { getElementById: () => null, activeElement: null };
|
|
try {
|
|
let view = null;
|
|
const host = {
|
|
destroyed: false,
|
|
_radioPanel: {},
|
|
store: { publish: (published) => (view = published), publishTuner() {} },
|
|
actions: {
|
|
getLifecycle: () => null,
|
|
preservePanelStateDuringClear: () => false,
|
|
setPanelCollapsed() {},
|
|
scheduleLayout() {},
|
|
},
|
|
};
|
|
renderRadioState.call(host, state);
|
|
return {
|
|
status: view.playback.text,
|
|
error: Boolean(view.playback.error),
|
|
button: view.transport.play.text,
|
|
stopDisabled: view.transport.stopDisabled,
|
|
cockpit: view.cockpit.play.glyph,
|
|
};
|
|
} finally {
|
|
if (previousDocument === undefined) delete globalThis.document;
|
|
else globalThis.document = previousDocument;
|
|
}
|
|
}
|
|
|
|
const readyToPlay = { status: READY, error: false, button: 'PLAY', stopDisabled: true, cockpit: '▶' };
|
|
|
|
test('Next before Play only selects, and Play tries just that station', async () => {
|
|
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
|
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
|
|
await settle();
|
|
assert.deepEqual(playUrls, []);
|
|
assert.equal(radioLayer.getUIState().audioState, 'stopped');
|
|
await radioLayer.togglePlayback({ origin: 'user' });
|
|
await settle();
|
|
const state = radioLayer.getUIState();
|
|
assert.deepEqual(playUrls, [stations[1].streamUrl], 'no fallback picked by the silent step');
|
|
assert.equal(state.selected?.id, ids[1]);
|
|
assert.equal(state.audioState, 'error');
|
|
});
|
|
});
|
|
|
|
test('Next after a failed stream leaves the new station ready to play, without the old error', async () => {
|
|
setLocale('en');
|
|
await withLayer([stations[0].streamUrl], async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
|
await settle();
|
|
assert.equal(radioLayer.getUIState().audioState, 'error');
|
|
assert.equal(radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' }), true);
|
|
const state = radioLayer.getUIState();
|
|
assert.equal(state.selected?.id, ids[1]);
|
|
assert.equal(state.audioState, 'stopped');
|
|
assert.equal(state.playingStationId, null);
|
|
assert.deepEqual(render(state), readyToPlay);
|
|
await radioLayer.togglePlayback({ origin: 'user' });
|
|
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
|
|
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
|
|
});
|
|
});
|
|
|
|
test('Next while paused releases the paused stream, and Play starts the new station', async () => {
|
|
setLocale('en');
|
|
await withLayer([], async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
|
await settle();
|
|
await radioLayer.togglePlayback({ origin: 'user' });
|
|
assert.equal(radioLayer.getUIState().audioState, 'paused');
|
|
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
|
|
const state = radioLayer.getUIState();
|
|
assert.equal(state.selected?.id, ids[1]);
|
|
assert.equal(state.audioState, 'stopped');
|
|
assert.equal(state.playingStationId, null);
|
|
assert.deepEqual(render(state), readyToPlay);
|
|
await radioLayer.togglePlayback({ origin: 'user' });
|
|
assert.deepEqual(playUrls, [stations[0].streamUrl, stations[1].streamUrl]);
|
|
assert.equal(radioLayer.getUIState().audioState, 'playing');
|
|
assert.equal(radioLayer.getUIState().playingStationId, ids[1]);
|
|
});
|
|
});
|
|
|
|
test('a catalog refresh after a paused Next cannot bring back Resume for an unnamed station', async () => {
|
|
setLocale('en');
|
|
await withLayer([], async ({ catalog }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
|
await settle();
|
|
await radioLayer.togglePlayback({ origin: 'user' });
|
|
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
|
|
catalog.rows = stations.filter(({ id }) => id !== ids[1]);
|
|
catalog.generation = 2;
|
|
await radioLayer.update();
|
|
const state = radioLayer.getUIState();
|
|
assert.equal(state.selected, null);
|
|
assert.equal(state.audioState, 'stopped');
|
|
const shown = render(state);
|
|
assert.equal(shown.status, READY);
|
|
assert.equal(shown.button, 'PLAY');
|
|
});
|
|
});
|
|
|
|
test('a voice station request after a voice pause keeps the fallback of that request', async () => {
|
|
const failing = [];
|
|
await withLayer(failing, async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
|
await settle();
|
|
radioLayer.pause({ origin: 'voice-duck' });
|
|
playUrls.length = 0;
|
|
// Anchor on the far station so the paused one is not the first match.
|
|
const first = radioLayer.selectRequestedStation(
|
|
{ categoryId: 'all', anchor: { lat: 32, lon: -99 } },
|
|
{ autoplay: false },
|
|
);
|
|
failing.push(first.streamUrl);
|
|
await radioLayer.play({ origin: 'voice' });
|
|
await settle();
|
|
await settle();
|
|
assert.equal(playUrls.length, 2);
|
|
assert.equal(playUrls[0], first.streamUrl);
|
|
assert.equal(radioLayer.getUIState().audioState, 'playing');
|
|
});
|
|
});
|
|
|
|
test('a cancelled voice step leaves no fallback for a later Play from the panel', async () => {
|
|
await withLayer([stations[1].streamUrl], async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
|
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
|
// The handoff is cancelled (barge-in), so voice never starts playback.
|
|
await settle();
|
|
await radioLayer.togglePlayback({ origin: 'user' });
|
|
await settle();
|
|
await settle();
|
|
const state = radioLayer.getUIState();
|
|
assert.deepEqual(playUrls, [stations[1].streamUrl]);
|
|
assert.equal(state.selected?.id, ids[1]);
|
|
assert.equal(state.audioState, 'error');
|
|
});
|
|
});
|
|
|
|
test('only a user step to another station tells voice to drop its prepared playback', async () => {
|
|
await withLayer([], async () => {
|
|
const controls = [];
|
|
const unsubscribe = radioLayer.subscribePlaybackControls((event) => controls.push(event));
|
|
try {
|
|
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
|
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, origin: 'user' });
|
|
radioLayer.cycleStation(1, { stationIds: [ids[1]], autoplay: false, origin: 'user' });
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
assert.equal(radioLayer.getUIState().selected?.id, ids[1]);
|
|
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['select', 'user']]);
|
|
});
|
|
});
|
|
|
|
test('an unavailable tuner release stops the paused stream once, and Play still falls back', async () => {
|
|
setLocale('en');
|
|
const failing = [];
|
|
await withLayer(failing, async ({ playUrls, catalog }) => {
|
|
radioLayer.selectStation(ids[2], { autoplay: true, origin: 'user' });
|
|
await settle();
|
|
assert.equal(radioLayer.beginTuning(), true);
|
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
|
// A directory refresh changes the station under the needle mid-drag.
|
|
catalog.rows = stations.map((row) => row.id === ids[1]
|
|
? { ...row, streamUrl: 'https://radio.example.com/b2.mp3' }
|
|
: row);
|
|
catalog.generation = 2;
|
|
await radioLayer.update();
|
|
const states = [];
|
|
const controls = [];
|
|
const unsubscribe = radioLayer.subscribe((state) => states.push(state));
|
|
const unsubscribeControls = radioLayer.subscribePlaybackControls((event) => controls.push(event));
|
|
states.length = 0;
|
|
try {
|
|
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).reason, 'station-unavailable');
|
|
} finally {
|
|
unsubscribe();
|
|
unsubscribeControls();
|
|
}
|
|
assert.equal(states.length, 1);
|
|
assert.equal(states[0].tuningUnavailableStationId, ids[1]);
|
|
assert.deepEqual(controls.map(({ action, origin }) => [action, origin]), [['stop', 'user']]);
|
|
const state = radioLayer.getUIState();
|
|
assert.equal(state.selected, null);
|
|
assert.equal(state.audioState, 'stopped');
|
|
assert.equal(state.playingStationId, null);
|
|
assert.equal(render(state).button, 'PLAY');
|
|
playUrls.length = 0;
|
|
// Play picks the nearest station; its broadcaster fails.
|
|
const play = globalThis.Audio.prototype.play;
|
|
globalThis.Audio.prototype.play = function playFirstFails() {
|
|
if (!failing.length) failing.push(this.src);
|
|
return play.call(this);
|
|
};
|
|
try {
|
|
await radioLayer.togglePlayback({ origin: 'user' });
|
|
await settle();
|
|
await settle();
|
|
} finally {
|
|
globalThis.Audio.prototype.play = play;
|
|
}
|
|
assert.equal(playUrls.length, 2);
|
|
assert.equal(radioLayer.getUIState().audioState, 'playing');
|
|
});
|
|
});
|
|
|
|
test('while a stream connects the transport buttons offer Pause, because a click pauses', () => {
|
|
setLocale('en');
|
|
const shown = render({
|
|
enabled: true,
|
|
audioState: 'loading',
|
|
audioError: null,
|
|
selected: { id: 'a', name: 'Station A' },
|
|
selectedIndex: 0,
|
|
filteredCount: 1,
|
|
stationCount: 1,
|
|
volume: 1,
|
|
});
|
|
assert.equal(shown.status, 'Connecting directly to broadcaster…');
|
|
assert.equal(shown.button, 'PAUSE');
|
|
assert.equal(shown.cockpit, 'Ⅱ');
|
|
});
|
|
|
|
test('a dial release plays only if a stream was active when the drag began', async () => {
|
|
await withLayer([], async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
|
assert.equal(radioLayer.beginTuning(), true);
|
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
|
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
|
|
await settle();
|
|
let state = radioLayer.getUIState();
|
|
assert.deepEqual(playUrls, []);
|
|
assert.equal(state.selected?.id, ids[1]);
|
|
assert.equal(state.audioState, 'stopped');
|
|
assert.equal(state.tuningStatic, false);
|
|
|
|
await radioLayer.togglePlayback({ origin: 'user' });
|
|
assert.equal(radioLayer.beginTuning(), true);
|
|
radioLayer.previewTuningStation(ids[2], { rotate: false });
|
|
radioLayer.commitTuningStation(ids[2], { origin: 'user' });
|
|
await settle();
|
|
state = radioLayer.getUIState();
|
|
assert.deepEqual(playUrls, [stations[1].streamUrl, stations[2].streamUrl]);
|
|
assert.equal(state.audioState, 'playing');
|
|
assert.equal(state.playingStationId, ids[2]);
|
|
});
|
|
});
|
|
|
|
// Like Chrome, resume() flips `state` only once the device starts, and suspend()
|
|
// on a context that is still suspended resolves at once without effect.
|
|
const RESUME_MS = 5;
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
function withFakeAudioContext(body) {
|
|
const log = { contexts: [], sources: [] };
|
|
const node = () => ({ connect() {}, disconnect() {} });
|
|
class FakeAudioContext {
|
|
constructor() {
|
|
Object.assign(this, { state: 'suspended', sampleRate: 8000, currentTime: 0, destination: {} });
|
|
log.contexts.push(this);
|
|
}
|
|
resume() {
|
|
if (this.state === 'running') return Promise.resolve();
|
|
return new Promise((resolve) => setTimeout(() => {
|
|
if (this.state !== 'closed') this.state = 'running';
|
|
resolve();
|
|
}, RESUME_MS));
|
|
}
|
|
suspend() {
|
|
if (this.state === 'running') this.state = 'suspended';
|
|
return Promise.resolve();
|
|
}
|
|
close() { this.state = 'closed'; return Promise.resolve(); }
|
|
createBuffer(_channels, length) { return { getChannelData: () => new Float32Array(length) }; }
|
|
createBufferSource() {
|
|
const source = { ...node(), started: false, stopped: false, start() { this.started = true; }, stop() { this.stopped = true; } };
|
|
log.sources.push(source);
|
|
return source;
|
|
}
|
|
createBiquadFilter() { return { ...node(), frequency: { value: 0 }, Q: { value: 0 } }; }
|
|
createGain() {
|
|
return { ...node(), gain: { value: 0, cancelScheduledValues() {}, setValueAtTime() {}, linearRampToValueAtTime() {} } };
|
|
}
|
|
}
|
|
const original = globalThis.AudioContext;
|
|
globalThis.AudioContext = FakeAudioContext;
|
|
return Promise.resolve(body(log)).finally(() => {
|
|
if (original === undefined) delete globalThis.AudioContext;
|
|
else globalThis.AudioContext = original;
|
|
});
|
|
}
|
|
|
|
test('a silent dial release stops the tuner noise and suspends its audio context, even before resume() settles', async () => {
|
|
await withFakeAudioContext(async ({ contexts, sources }) => {
|
|
await withLayer([], async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
|
assert.equal(radioLayer.beginTuning(), true);
|
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
|
assert.equal(radioLayer.commitTuningStation(ids[1], { origin: 'user' }).ok, true);
|
|
await sleep(RESUME_MS * 4);
|
|
assert.deepEqual(playUrls, []);
|
|
assert.equal(sources[0].started, true);
|
|
assert.equal(sources[0].stopped, true, 'noise source still looping');
|
|
assert.equal(contexts[0].state, 'suspended', 'audio context still running');
|
|
});
|
|
});
|
|
});
|
|
|
|
test('a second gesture before the first resume() settles keeps its noise graph', async () => {
|
|
await withFakeAudioContext(async ({ contexts, sources }) => {
|
|
await withLayer([], async () => {
|
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
|
radioLayer.beginTuning();
|
|
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
|
|
assert.equal(radioLayer.beginTuning(), true);
|
|
await sleep(RESUME_MS * 4);
|
|
assert.equal(contexts[0].state, 'running', 'second gesture lost its context');
|
|
assert.equal(sources.at(-1).stopped, false, 'second gesture lost its source');
|
|
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
|
|
await sleep(RESUME_MS * 4);
|
|
assert.equal(sources.at(-1).stopped, true);
|
|
assert.equal(contexts[0].state, 'suspended');
|
|
});
|
|
});
|
|
});
|
|
|
|
test('a dial release from a paused stream only selects, without static', async () => {
|
|
await withFakeAudioContext(async ({ contexts }) => {
|
|
await withLayer([], async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
|
await settle();
|
|
await radioLayer.togglePlayback({ origin: 'user' });
|
|
assert.equal(radioLayer.getUIState().audioState, 'paused');
|
|
assert.equal(radioLayer.beginTuning(), true);
|
|
await sleep(RESUME_MS * 4);
|
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
|
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
|
|
await sleep(RESUME_MS * 4);
|
|
const state = radioLayer.getUIState();
|
|
assert.deepEqual(playUrls, [stations[0].streamUrl]);
|
|
assert.equal(state.selected?.id, ids[1]);
|
|
assert.equal(state.audioState, 'stopped');
|
|
assert.equal(state.tuningStatic, false);
|
|
assert.equal(state.tuningAwaitingStationId, null);
|
|
assert.equal(contexts[0].state, 'suspended');
|
|
});
|
|
});
|
|
});
|
|
|
|
test('a dial release while a stream is still connecting plays the new station', async () => {
|
|
await withLayer([], async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: true, origin: 'user' });
|
|
assert.equal(radioLayer.getUIState().audioState, 'loading');
|
|
assert.equal(radioLayer.beginTuning(), true);
|
|
radioLayer.previewTuningStation(ids[1], { rotate: false });
|
|
radioLayer.commitTuningStation(ids[1], { origin: 'user' });
|
|
await settle();
|
|
const state = radioLayer.getUIState();
|
|
assert.equal(playUrls.at(-1), stations[1].streamUrl);
|
|
assert.equal(state.audioState, 'playing');
|
|
assert.equal(state.playingStationId, ids[1]);
|
|
});
|
|
});
|
|
|
|
test('a silent user step drops the fallback a voice step left behind', async () => {
|
|
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
|
// Voice steps to B and keeps C as its fallback; the user steps back to A.
|
|
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
|
radioLayer.cycleStation(-1, { stationIds: ids, autoplay: false, origin: 'user' });
|
|
await radioLayer.play({ origin: 'voice' });
|
|
await settle();
|
|
await settle();
|
|
assert.deepEqual(playUrls, [stations[0].streamUrl]);
|
|
assert.equal(radioLayer.getUIState().audioState, 'error');
|
|
});
|
|
});
|
|
|
|
test('a silent dial release drops the fallback a voice step left behind', async () => {
|
|
await withLayer(stations.map(({ streamUrl }) => streamUrl), async ({ playUrls }) => {
|
|
radioLayer.selectStation(ids[0], { autoplay: false });
|
|
radioLayer.cycleStation(1, { stationIds: ids, autoplay: false, fallback: true, origin: 'voice' });
|
|
assert.equal(radioLayer.beginTuning(), true);
|
|
radioLayer.previewTuningStation(ids[0], { rotate: false });
|
|
radioLayer.commitTuningStation(ids[0], { origin: 'user' });
|
|
await radioLayer.play({ origin: 'voice' });
|
|
await settle();
|
|
await settle();
|
|
assert.deepEqual(playUrls, [stations[0].streamUrl]);
|
|
assert.equal(radioLayer.getUIState().audioState, 'error');
|
|
});
|
|
});
|