scripts/test_player_end.mjs
Rama main · réplica de lectura del repositorio original.
// Run: node --experimental-vm-modules scripts/test_player_end.mjs
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
class Audio extends EventTarget {
src = '';
paused = true;
ended = false;
currentTime = 0;
duration = 120;
readyState = 4;
loads = 0;
pending = null;
play() { this.paused = false; this.ended = false; this.dispatchEvent(new Event('play')); return this.pending || Promise.resolve(); }
pause() { if (!this.paused) { this.paused = true; queueMicrotask(() => this.dispatchEvent(new Event('pause'))); } }
removeAttribute(name) { if (name === 'src') this.src = ''; }
load() {
this.loads++; this.currentTime = 0; this.ended = false; this.readyState = 0; this.paused = true;
queueMicrotask(() => { this.dispatchEvent(new Event('pause')); this.dispatchEvent(new Event('emptied')); });
}
}
const audio = new Audio(), actions = new Map(), storage = new Map();
const timers = new Map(), errors = [];
let timerId = 0, allowErrors = false, offlineLookup = async () => false;
const timeout = (callback, delay) => { timers.set(++timerId, { callback, delay }); return timerId; };
function stallDeadline() {
const pending = [...timers].filter(([, timer]) => timer.delay === 10000);
for (const [id, timer] of pending) { timers.delete(id); timer.callback(); }
}
const classes = () => {
const values = new Set();
return { contains: name => values.has(name), toggle(name, enabled) { enabled ? values.add(name) : values.delete(name); } };
};
const footer = { classList: classes(), querySelector: () => null, addEventListener() {} };
const document = Object.assign(new EventTarget(), {
body: { classList: classes() }, visibilityState: 'visible',
documentElement: { dataset: { theme: 'classic' } },
getElementById: id => id === 'audio-player' ? audio : ['player-title', 'player-cover'].includes(id) ? {} : null,
querySelector: selector => selector === '.player-footer' ? footer : null,
querySelectorAll: () => []
});
const navigator = { onLine: true, userAgent: 'Test', mediaSession: {
playbackState: 'none', metadata: null, setActionHandler: (action, handler) => actions.set(action, handler)
} };
const context = vm.createContext({ document, navigator, console, URL, URLSearchParams, Event, AbortController,
localStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value), removeItem: key => storage.delete(key) },
window: Object.assign(new EventTarget(), { setTimeout: () => 1, setInterval: () => 1, matchMedia: () => ({ matches: false }) }),
setTimeout: timeout, clearTimeout: id => timers.delete(id), clearInterval() {},
fetch: async () => ({ ok: true, json: async () => ({ db_id: 3, title: 'Random' }) })
});
const source = async name => new vm.SourceTextModule(await readFile(new URL(`../Aoede/assets/js/modules/${name}.js`, import.meta.url), 'utf8'), { context });
const synthetic = values => new vm.SyntheticModule(Object.keys(values), function () {
for (const [key, value] of Object.entries(values)) this.setExport(key, value);
}, { context });
const stateModule = await source('state');
await stateModule.link(() => assert.fail('state has no imports')); await stateModule.evaluate();
const state = stateModule.namespace;
const imports = {
'./state.js': stateModule,
'./ui.js': synthetic({ updatePlayButton() {}, updateUIProgress() {}, updateVolumeUI() {}, refreshIcons() {}, setupDraggable() {}, setupTrackSwipe() {},
toggleFullscreenPlayer: () => state.setIsFullscreenPlayerOpen(!state.isFullscreenPlayerOpen), showToast: (message, type) => { if (type === 'error') { if (!allowErrors) assert.fail(message); errors.push(message); } } }),
'./api.js': synthetic({ recordListenEvent: async () => {}, fetchRandomTrack: async () => ({ db_id: 3, title: 'Random' }) }),
'./audio_engine.js': synthetic({ setVolume() {}, resumeIfSuspended() {}, resetFade() {}, fadeIn() {}, applyReplayGain() {}, getCrossfadeSeconds: () => 0 }),
'./offline_store.js': synthetic({ isTrackAvailableOfflineSync: () => false, isTrackAvailableOffline: id => offlineLookup(id) })
};
const module = await source('player'); await module.link(name => imports[name]); await module.evaluate();
const player = module.namespace;
player.setupPlayer();
const first = { db_id: 1, title: 'First' }, last = { db_id: 2, title: 'Last' };
const flush = () => new Promise(setImmediate);
function reset(queue = [first, last], index = queue.length - 1) {
timers.clear(); errors.length = 0; audio.pending = null; audio.readyState = 4; navigator.onLine = true;
player.setPartyController(null);
state.setContextQueue(queue); state.setContextIndex(index); state.setUserQueue([]);
state.setCurrentTrack(queue[index] || last); state.setIsPlaying(true); state.setIsFullscreenPlayerOpen(true);
state.setRepeatMode('off'); state.setShuffleMode(false);
Object.assign(audio, { src: '/api/stream/2', currentTime: 42, duration: 120, paused: false, ended: false, _endHandled: false });
navigator.mediaSession.playbackState = 'playing';
player.syncPlayerShellVisibility();
}
for (const theme of ['classic', 'glass', 'vitrine']) {
document.documentElement.dataset.theme = theme;
for (const queue of [[first, last], [last], []]) {
for (const paused of [false, true]) {
reset(queue); audio.paused = paused; state.setIsPlaying(!paused);
const track = state.currentTrack;
await player.playNext(); await actions.get('nexttrack')(); await player.playNext(new Event('click'));
assert.equal(state.currentTrack, track); assert.equal(state.isPlaying, !paused);
assert.equal(audio.src, '/api/stream/2'); assert.equal(audio.currentTime, 42); assert.equal(audio.paused, paused);
assert.equal(state.isFullscreenPlayerOpen, true); assert.equal(footer.classList.contains('player-hidden'), false);
}
}
}
reset(); state.setRepeatMode('all'); await player.playNext(); await flush();
assert.equal(state.currentTrack, first); assert.equal(state.contextIndex, 0); assert.equal(audio.paused, false);
reset(); state.setUserQueue([first]); await player.playNext(); await flush();
assert.equal(state.currentTrack, first); assert.equal(state.contextIndex, 1); assert.equal(state.userQueue.length, 0);
reset(); state.setShuffleMode(true); await player.playNext(); await flush();
assert.equal(state.currentTrack.db_id, 3);
reset(); state.setLastPrevClickTime(0); player.playPrev();
assert.equal(state.currentTrack, last); assert.equal(audio.currentTime, 0); // The button still rewinds first.
reset(); state.setLastPrevClickTime(0); player.playPrev(true); await flush();
assert.equal(state.currentTrack, first); assert.equal(state.contextIndex, 0); // A right swipe selects the previous song.
reset(); state.setRepeatMode('one'); await player.playNext(); assert.equal(audio.currentTime, 42);
audio.currentTime = 120; audio.dispatchEvent(new Event('ended')); await flush();
assert.equal(state.currentTrack, last); assert.equal(audio.currentTime, 0); assert.equal(audio.paused, false);
reset(); let partyNext = 0;
player.setPartyController({ isActive: () => true, control(action) { assert.equal(action, 'next'); partyNext++; } });
await player.playNext(); assert.equal(partyNext, 1); assert.equal(state.currentTrack, last);
// The final note must play fully; final cleanup also handles background and missing ended events.
for (const finish of ['ended', 'timeupdate', 'visibilitychange']) {
reset(); storage.set('aoede.playback.session.v1', 'old session');
audio.currentTime = 119.9; audio.dispatchEvent(new Event('timeupdate'));
assert.equal(state.currentTrack, last); assert.equal(audio._endHandled, false); assert.equal(audio.paused, false);
audio.currentTime = 120;
if (finish === 'visibilitychange') { audio.ended = true; document.dispatchEvent(new Event(finish)); }
else audio.dispatchEvent(new Event(finish));
await flush(); // Includes the old resource's queued pause event.
assert.equal(state.currentTrack, null); assert.equal(state.isPlaying, false); assert.equal(audio.paused, true);
assert.equal(audio.src, ''); assert.equal(state.isFullscreenPlayerOpen, false);
assert.equal(navigator.mediaSession.playbackState, 'none'); assert.equal(navigator.mediaSession.metadata, null);
assert.equal(storage.has('aoede.playback.session.v1'), false); assert.equal(footer.classList.contains('player-hidden'), true);
}
console.log('PASS: final-track Next is inert across themes, queues and media controls; repeat, shuffle, party and natural/background completion remain functional.');
// Background buffering: retry only absent playable data and absent forward progress.
document.visibilityState = 'hidden';
for (const readyState of [3, 4]) {
reset(); audio.readyState = readyState;
const loads = audio.loads;
audio.dispatchEvent(new Event('stalled')); stallDeadline();
assert.equal(audio.loads, loads); assert.equal(audio.currentTime, 42);
}
reset(); audio.readyState = 2;
audio.dispatchEvent(new Event('waiting')); audio.currentTime++;
let loads = audio.loads; stallDeadline(); assert.equal(audio.loads, loads);
reset(); audio.readyState = 2;
audio.dispatchEvent(new Event('waiting')); audio.pause(); await flush();
stallDeadline(); assert.equal(audio.loads, loads); assert.equal(audio.paused, true);
reset(); audio.readyState = 2;
audio.dispatchEvent(new Event('waiting')); state.setCurrentTrack(first);
stallDeadline(); assert.equal(audio.loads, loads);
reset(); audio.readyState = 2; navigator.onLine = false;
audio.dispatchEvent(new Event('waiting')); stallDeadline();
assert.equal(audio.loads, loads); assert.equal(audio.paused, false);
navigator.onLine = true; stallDeadline(); await flush();
assert.equal(audio.loads, ++loads); assert.equal(audio.currentTime, 42); assert.equal(audio.paused, false);
audio.readyState = 3; audio.dispatchEvent(new Event('playing')); stallDeadline();
assert.equal(audio.loads, loads);
// A never-resolved play() during a track transition is still recovered, with two retries.
reset(); allowErrors = true;
audio.pending = new Promise(() => {});
void player.playTrack(first); await flush();
audio.readyState = 2; loads = audio.loads;
audio.dispatchEvent(new Event('waiting')); stallDeadline(); await flush();
assert.equal(audio.loads, loads + 1); assert.equal(audio.paused, false);
stallDeadline(); await flush(); assert.equal(audio.loads, loads + 2);
stallDeadline(); await flush();
assert.equal(audio.loads, loads + 2); assert.equal(audio.paused, true);
assert.equal(errors.length, 1); assert.match(errors[0], /Stream stalled/);
audio.dispatchEvent(new Event('loadedmetadata')); await flush();
assert.equal(audio.paused, true); // No abandoned metadata callback may undo a pause.
audio.play(); stallDeadline(); await flush();
assert.equal(audio.loads, loads + 3); assert.equal(audio.paused, false); // Explicit retry gets a fresh budget.
audio.readyState = 3; audio.currentTime++;
audio.dispatchEvent(new Event('timeupdate')); stallDeadline();
assert.equal(audio.loads, loads + 3); // Real progress also handles a missed playing event.
allowErrors = false;
// A delayed offline lookup for an old selection must not replace the newer audio.
reset(); let finishOld;
offlineLookup = id => id === first.db_id ? new Promise(resolve => { finishOld = resolve; }) : Promise.resolve(false);
const old = player.playTrack(first);
await player.playTrack(last);
const currentSource = audio.src;
finishOld(false); await old;
assert.equal(state.currentTrack, last); assert.equal(audio.src, currentSource);
assert.equal(navigator.mediaSession.metadata.title, last.title);
console.log('PASS: pending/background streams recover without false reloads, stale sources, unlimited retries or resuming a user pause.');