import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { GaplessEngine, type EngineTrack } from './gapless-engine'; // These tests exist because the failure they cover is inaudible to the code and obvious to the ear: two // sources playing the same track at once. Nothing throws, no state looks wrong, `cur` points at a real // node that is really playing — there is simply a second one nobody is holding. The only way to catch it // is to count the nodes that were started. type StartCall = { when: number; offset: number }; class FakeSource { buffer: AudioBuffer | null = null; onended: (() => void) | null = null; started: StartCall | null = null; stopped = false; disconnected = false; constructor(private ctx: FakeContext) {} connect(): void {} disconnect(): void { this.disconnected = true; } start(when = 0, offset = 0): void { if (this.started) throw new Error('InvalidStateError: already started'); this.started = { when, offset }; this.ctx.started.push(this); } stop(): void { if (!this.started) throw new Error('InvalidStateError: not started'); this.stopped = true; } /** What the browser does at the end of the buffer (or at stop()) — the engine's advance trigger. */ end(): void { this.onended?.(); } } class FakeContext { currentTime = 0; state: 'running' | 'suspended' | 'closed' = 'running'; destination = {}; started: FakeSource[] = []; createGain() { return { gain: { value: 1 }, connect: () => {} }; } createBufferSource() { return new FakeSource(this) as unknown as AudioBufferSourceNode & FakeSource; } async resume(): Promise { this.state = 'running'; } async suspend(): Promise { this.state = 'suspended'; } async close(): Promise { this.state = 'closed'; } async decodeAudioData(): Promise { // Decoding is the slow step the bug hides inside — a macrotask is enough to model "not instant". await new Promise((r) => setTimeout(r, 0)); return { duration: 100 } as AudioBuffer; } } /** * Sources producing sound RIGHT NOW. The `when <= currentTime` clause is not a detail: a gapless engine * always has the next track already started, scheduled at the current track's end. Counting those as * audible would make the healthy state look like the bug. */ const audible = (ctx: FakeContext) => ctx.started.filter((s) => !s.stopped && s.started !== null && s.started.when <= ctx.currentTime); let ctx: FakeContext; const originalFetch = globalThis.fetch; const originalCtor = (globalThis as Record).AudioContext; const QUEUE: EngineTrack[] = [ { key: 'a', url: '/stream/a' }, { key: 'b', url: '/stream/b' }, { key: 'c', url: '/stream/c' }, ]; // Let every pending decode + its continuation run. const settle = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); }; beforeEach(() => { ctx = new FakeContext(); (globalThis as Record).AudioContext = function () { return ctx; }; globalThis.fetch = (async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8), })) as unknown as typeof fetch; }); afterEach(() => { globalThis.fetch = originalFetch; (globalThis as Record).AudioContext = originalCtor; }); describe('GaplessEngine', () => { test('load(autoplay) followed by play() starts the track exactly once', async () => { // The host's queue effect and playing effect both fire in the same commit when a queue starts from a // paused player. This is that commit, and it used to produce two sources of the same track. const engine = new GaplessEngine({}); engine.load(QUEUE, 0, true); engine.play(); await settle(); expect(audible(ctx)).toHaveLength(1); engine.destroy(); }); test('repeated play() during the decode does not stack sources', async () => { const engine = new GaplessEngine({}); engine.load(QUEUE, 0, true); engine.play(); engine.play(); engine.play(); await settle(); expect(audible(ctx)).toHaveLength(1); engine.destroy(); }); test('pause then play while still decoding does not stack sources', async () => { // The impatient-user path: nothing is audible yet because the file is still downloading, so the play // button gets hit again. const engine = new GaplessEngine({}); engine.load(QUEUE, 0, true); engine.pause(); engine.play(); await settle(); expect(audible(ctx)).toHaveLength(1); engine.destroy(); }); test('a stale source ending does not advance the queue', async () => { // Defence in depth: even if a source outlives its bookkeeping, only `cur` may move the index. const seen: number[] = []; const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) }); engine.load(QUEUE, 0, true); await settle(); const first = audible(ctx)[0]!; engine.skipTo(2); // bumps the generation and stops `first` await settle(); first.end(); // the browser still delivers its onended expect(seen).toEqual([]); // skipTo is a user action; only a NATURAL boundary reports an index engine.destroy(); }); test('switching queues leaves nothing from the old one sounding', async () => { const engine = new GaplessEngine({}); engine.load(QUEUE, 0, true); await settle(); engine.load([{ key: 'z', url: '/stream/z' }], 0, true); await settle(); expect(audible(ctx)).toHaveLength(1); expect(audible(ctx)[0]!.started).not.toBeNull(); engine.destroy(); }); test('a natural boundary advances the index exactly once', async () => { const seen: number[] = []; const engine = new GaplessEngine({ onIndex: (i) => seen.push(i) }); engine.load(QUEUE, 0, true); await settle(); const cur = audible(ctx)[0]!; cur.end(); await settle(); expect(seen).toEqual([1]); engine.destroy(); }); test('destroy() silences every source it started', async () => { const engine = new GaplessEngine({}); engine.load(QUEUE, 0, true); await settle(); engine.destroy(); expect(audible(ctx)).toHaveLength(0); }); });