import { beforeEach, describe, expect, test, vi } from 'vitest'; import { player, playQueue, togglePlay, skipNext, skipPrev, seekTo, setVolume, registerAudioEl, reportTimeUpdate, reportDuration, readStoredVolume, toggleShuffle, cycleRepeat, reportStateFromAudio, __setShuffleRng, restoreQueue } from './store.svelte'; import type { TrackRef } from '$lib/api/types'; import { writePersistedQueue, clearPersistedQueue } from './persisted'; import { makeTrack } from '$test-utils/fixtures/track'; vi.mock('$lib/auth/store.svelte', () => ({ user: { get value() { return { id: 'test-user' }; } } })); function track(id: string, dur = 180): TrackRef { return makeTrack({ id, title: `T ${id}`, duration_sec: dur, stream_url: `/api/tracks/${id}/stream` }); } beforeEach(() => { localStorage.clear(); // Reset state by starting with an empty queue (playQueue([]) leaves queue=[], index=0). playQueue([]); closeQueueDrawer(); setVolume(1); registerAudioEl(null); // Reset shuffle and repeat to defaults if (player.shuffle) toggleShuffle(); while (player.repeat !== 'off') cycleRepeat(); }); describe('player store — basic actions', () => { test('playQueue sets queue, index, state="loading", position=0', () => { playQueue([track('1'), track('2'), track('3')], 1); expect(player.queue.length).toBe(3); expect(player.index).toBe(1); expect(player.state).toBe('loading'); expect(player.position).toBe(0); expect(player.current?.id).toBe('2'); }); test('playQueue clamps startIndex to [0, length-1]', () => { playQueue([track('1'), track('2')], 99); expect(player.index).toBe(1); playQueue([track('1'), track('2')], -5); expect(player.index).toBe(0); }); test('togglePlay flips paused<->playing; no-op on idle', () => { expect(player.state).toBe('idle'); togglePlay(); expect(player.state).toBe('idle'); // still idle with empty queue playQueue([track('1')]); // After playQueue we're in 'loading'; togglePlay should not advance without an audio event. // But togglePlay from 'loading' should set the intent to play anyway — tested via report later. // Here, simulate the paused state: reportTimeUpdate(0); // manually put us in paused state by calling togglePlay twice isn't clean; set via the state-report API in Task 2. // Instead, test the simpler case: playQueue then togglePlay = still 'loading' (intent is to play). togglePlay(); // from 'loading' — inverts intent; store moves to 'paused' intent expect(player.state).toBe('paused'); togglePlay(); // from 'paused' — moves to 'loading' (re-play intent) expect(player.state).toBe('loading'); }); test('skipNext mid-queue: index++, position=0', () => { playQueue([track('1'), track('2'), track('3')], 0); reportTimeUpdate(50); skipNext(); expect(player.index).toBe(1); expect(player.position).toBe(0); }); test('skipNext at end with no repeat: index stays, state=paused, position=duration', () => { playQueue([track('1'), track('2')], 1); reportDuration(180); skipNext(); expect(player.index).toBe(1); expect(player.state).toBe('paused'); expect(player.position).toBe(180); }); test('skipPrev with position<3 AND index>0: decrements index', () => { playQueue([track('1'), track('2'), track('3')], 2); reportTimeUpdate(2); skipPrev(); expect(player.index).toBe(1); expect(player.position).toBe(0); }); test('skipPrev with position>=3: seeks to 0, index unchanged', () => { playQueue([track('1'), track('2')], 1); reportTimeUpdate(10); skipPrev(); expect(player.index).toBe(1); expect(player.position).toBe(0); }); test('skipPrev at index=0: position=0, index stays', () => { playQueue([track('1'), track('2')], 0); reportTimeUpdate(2); skipPrev(); expect(player.index).toBe(0); expect(player.position).toBe(0); }); test('setVolume clamps to [0,1] and persists to localStorage', () => { setVolume(0.5); expect(player.volume).toBe(0.5); expect(localStorage.getItem('minstrel.volume')).toBe('0.5'); setVolume(-1); expect(player.volume).toBe(0); setVolume(2); expect(player.volume).toBe(1); }); test('readStoredVolume returns 1.0 when missing or invalid', () => { localStorage.clear(); expect(readStoredVolume()).toBe(1); localStorage.setItem('minstrel.volume', 'not-a-number'); expect(readStoredVolume()).toBe(1); localStorage.setItem('minstrel.volume', '-10'); expect(readStoredVolume()).toBe(1); // out of range → fallback localStorage.setItem('minstrel.volume', '0.7'); expect(readStoredVolume()).toBe(0.7); }); test('seekTo writes position and, when registered, audioEl.currentTime', () => { const el = { currentTime: 0 } as unknown as HTMLAudioElement; registerAudioEl(el); playQueue([track('1')]); seekTo(42); expect(player.position).toBe(42); expect(el.currentTime).toBe(42); // After unregister, seekTo still updates store but doesn't throw. registerAudioEl(null); seekTo(10); expect(player.position).toBe(10); }); test('reportTimeUpdate and reportDuration mutate position and duration', () => { playQueue([track('1')]); reportTimeUpdate(15); reportDuration(240); expect(player.position).toBe(15); expect(player.duration).toBe(240); }); }); describe('player store — shuffle + repeat + audio reports', () => { test('cycleRepeat: off → all → one → off', () => { playQueue([track('1')]); expect(player.repeat).toBe('off'); cycleRepeat(); expect(player.repeat).toBe('all'); cycleRepeat(); expect(player.repeat).toBe('one'); cycleRepeat(); expect(player.repeat).toBe('off'); }); test('toggleShuffle randomizes queue[index+1..end], leaves current and earlier alone', () => { // Deterministic RNG: always returns 0 so Fisher-Yates swaps each i with i itself → no change. // Flip to always 0.999 to force swaps. const seq = [0.999, 0.999, 0.999, 0.999, 0.999]; let i = 0; __setShuffleRng(() => seq[i++ % seq.length]); const tracks = [track('1'), track('2'), track('3'), track('4'), track('5')]; playQueue(tracks, 1); toggleShuffle(); expect(player.shuffle).toBe(true); // Index 0 and 1 preserved: expect(player.queue[0].id).toBe('1'); expect(player.queue[1].id).toBe('2'); // Index 2..4 reordered: because RNG = 0.999 always, Math.floor(rng() * (j+1)) === j for each, // so swaps are with self — queue is unchanged. So validate that the IDs 3/4/5 are still the // tail set, in any order, to tolerate future RNG semantics. const tailIds = [player.queue[2].id, player.queue[3].id, player.queue[4].id].sort(); expect(tailIds).toEqual(['3', '4', '5']); __setShuffleRng(Math.random); }); test('skipNext at end with repeat=all: wraps to 0', () => { playQueue([track('1'), track('2')], 1); cycleRepeat(); // repeat=all skipNext(); expect(player.index).toBe(0); expect(player.state).toBe('loading'); }); test('skipNext at end with repeat=one: still ends (explicit skip overrides)', () => { playQueue([track('1')], 0); cycleRepeat(); // all cycleRepeat(); // one reportDuration(120); skipNext(); expect(player.state).toBe('paused'); expect(player.position).toBe(120); }); test('reportStateFromAudio("ended") with repeat="one": position=0, index unchanged, state=loading', () => { playQueue([track('1'), track('2')], 0); reportTimeUpdate(120); cycleRepeat(); cycleRepeat(); // repeat=one reportStateFromAudio('ended'); expect(player.index).toBe(0); expect(player.position).toBe(0); expect(player.state).toBe('loading'); }); test('reportStateFromAudio("ended") with repeat="all": wraps when at end', () => { playQueue([track('1'), track('2')], 1); cycleRepeat(); // repeat=all reportStateFromAudio('ended'); expect(player.index).toBe(0); expect(player.state).toBe('loading'); }); test('reportStateFromAudio("ended") with repeat="all" mid-queue: advances', () => { playQueue([track('1'), track('2'), track('3')], 0); cycleRepeat(); // repeat=all reportStateFromAudio('ended'); expect(player.index).toBe(1); }); test('reportStateFromAudio("ended") with repeat="off" at end: paused', () => { playQueue([track('1'), track('2')], 1); reportDuration(180); reportStateFromAudio('ended'); expect(player.state).toBe('paused'); expect(player.position).toBe(180); }); test('reportStateFromAudio("playing") sets state=playing, clears error', () => { playQueue([track('1')]); reportStateFromAudio('error', 'boom'); expect(player.error).toBe('boom'); reportStateFromAudio('playing'); expect(player.state).toBe('playing'); expect(player.error).toBeNull(); }); test('reportStateFromAudio("paused") sets state=paused', () => { playQueue([track('1')]); reportStateFromAudio('playing'); reportStateFromAudio('paused'); expect(player.state).toBe('paused'); }); test('reportStateFromAudio("waiting") sets state=loading', () => { playQueue([track('1')]); reportStateFromAudio('playing'); reportStateFromAudio('waiting'); expect(player.state).toBe('loading'); }); test('reportStateFromAudio("error", msg) sets state=error, error=msg', () => { playQueue([track('1')]); reportStateFromAudio('error', 'network lost'); expect(player.state).toBe('error'); expect(player.error).toBe('network lost'); }); test('reportStateFromAudio("error") auto-advances past a bad track', () => { playQueue([track('1'), track('2'), track('3')]); reportStateFromAudio('error', 'boom'); expect(player.index).toBe(1); expect(player.state).toBe('loading'); expect(player.error).toBeNull(); }); test('reportStateFromAudio("error") dead-ends once the whole queue is unplayable', () => { playQueue([track('1'), track('2')]); reportStateFromAudio('error', 'boom'); // track 1 fails → skip to track 2 expect(player.index).toBe(1); expect(player.state).toBe('loading'); reportStateFromAudio('error', 'boom'); // track 2 also fails → exhausted expect(player.state).toBe('error'); expect(player.error).toBe('boom'); }); test('reportStateFromAudio("error") self-heals a refreshable queue on total failure', async () => { const fresh = [track('a'), track('b')]; const refetch = vi.fn().mockResolvedValue(fresh); playQueue([track('1')], 0, { source: 'for_you', refetch }); reportStateFromAudio('error', 'boom'); // sole track fails → exhausted → self-heal expect(refetch).toHaveBeenCalledOnce(); expect(player.state).toBe('loading'); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(player.queue.map((t) => t.id)).toEqual(['a', 'b']); expect(player.index).toBe(0); }); test('self-heal fires at most once per exhaustion, then dead-ends', async () => { const refetch = vi.fn().mockResolvedValue([track('x')]); playQueue([track('1')], 0, { source: 'for_you', refetch }); reportStateFromAudio('error', 'boom'); // exhausted → self-heal #1 await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); reportStateFromAudio('error', 'boom'); // re-pulled track also fails; budget spent → error expect(player.state).toBe('error'); expect(refetch).toHaveBeenCalledOnce(); }); }); import { enqueueTrack, enqueueTracks, playNext, playRadio, moveQueueItem, removeFromQueue, playFromQueueIndex, toggleQueueDrawer, closeQueueDrawer } from './store.svelte'; import * as client from '$lib/api/client'; describe('player store — enqueue + playRadio', () => { test('enqueueTrack appends to a non-empty queue, _index unchanged', () => { playQueue([track('1'), track('2')], 1); enqueueTrack(track('3')); expect(player.queue.length).toBe(3); expect(player.queue[2].id).toBe('3'); expect(player.index).toBe(1); }); test('enqueueTrack on idle (empty queue) sets index=0 and _state stays idle', () => { playQueue([]); expect(player.state).toBe('idle'); enqueueTrack(track('1')); expect(player.queue.length).toBe(1); expect(player.index).toBe(0); expect(player.state).toBe('idle'); }); test('enqueueTracks([]) is a no-op', () => { playQueue([track('1')]); enqueueTracks([]); expect(player.queue.length).toBe(1); }); test('enqueueTracks concatenates non-empty list', () => { playQueue([track('1')]); enqueueTracks([track('2'), track('3')]); expect(player.queue.map((t) => t.id)).toEqual(['1', '2', '3']); }); test('playRadio fetches /api/radio and feeds tracks into playQueue', async () => { const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: [track('seed'), track('next')] }); await playRadio('seed'); expect(apiGet).toHaveBeenCalledWith('/api/radio?seed_track=seed'); expect(player.queue.length).toBe(2); expect(player.index).toBe(0); expect(player.state).toBe('loading'); apiGet.mockRestore(); }); test('playRadio with empty response leaves queue alone', async () => { playQueue([track('a')]); const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: [] }); await playRadio('seed'); expect(player.queue.map((t) => t.id)).toEqual(['a']); apiGet.mockRestore(); }); }); describe('playNext', () => { test('splices at _index + 1, leaving _index unchanged', () => { playQueue([track('a'), track('b')], 0); playNext(track('c')); expect(player.queue.map((t) => t.id)).toEqual(['a', 'c', 'b']); expect(player.index).toBe(0); }); test('on empty queue, seeds with the track at index 0', () => { playQueue([]); playNext(track('x')); expect(player.queue.map((t) => t.id)).toEqual(['x']); expect(player.index).toBe(0); }); }); describe('M4c radio auto-refresh at 80%', () => { test('refresh fires at 80% queue consumption', async () => { const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); await playRadio('seed'); apiGet.mockClear(); apiGet.mockResolvedValueOnce({ tracks: [track('seed'), track('new1')] }); // Advance to index 3 (4 of 5 = 80%) skipNext(); skipNext(); skipNext(); // Allow $effect to flush await new Promise((r) => setTimeout(r, 30)); expect(apiGet).toHaveBeenCalled(); const callArg = apiGet.mock.calls[0][0] as string; expect(callArg).toContain('seed_track=seed'); expect(callArg).toContain('exclude='); apiGet.mockRestore(); }); test('refresh below 80% does not fire', async () => { const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); await playRadio('seed'); apiGet.mockClear(); skipNext(); skipNext(); // index = 2 (3 of 5 = 60%) await new Promise((r) => setTimeout(r, 30)); expect(apiGet).not.toHaveBeenCalled(); apiGet.mockRestore(); }); test('refresh appends new tracks excluding seed at index 0', async () => { const initial = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: initial }); await playRadio('seed'); apiGet.mockClear(); // Refresh response: 5 tracks where the first is the seed (will be stripped). const refreshTracks = [track('seed'), track('n1'), track('n2'), track('n3'), track('n4')]; apiGet.mockResolvedValueOnce({ tracks: refreshTracks }); skipNext(); skipNext(); skipNext(); // index = 3 (80%) await new Promise((r) => setTimeout(r, 30)); // Initial 5 + 4 new (5 in response - 1 seed stripped) = 9 expect(player.queue.length).toBe(9); apiGet.mockRestore(); }); test('refresh does NOT double-fire while in-flight', async () => { const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); await playRadio('seed'); apiGet.mockClear(); // Hang the refresh call so the in-flight flag stays set. let resolveFn!: (v: unknown) => void; const hanging = new Promise((r) => { resolveFn = r; }); apiGet.mockReturnValueOnce(hanging as unknown as Promise); skipNext(); skipNext(); skipNext(); // 80% — fires refresh await new Promise((r) => setTimeout(r, 30)); // Now advance further while in-flight; should NOT fire a second call. skipNext(); await new Promise((r) => setTimeout(r, 30)); expect(apiGet).toHaveBeenCalledTimes(1); resolveFn({ tracks: [] }); apiGet.mockRestore(); }); test('refresh resets when user enqueues from non-radio source', async () => { const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')]; const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks }); await playRadio('seed'); enqueueTrack(track('manual1')); apiGet.mockClear(); skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the 6-track queue await new Promise((r) => setTimeout(r, 30)); expect(apiGet).not.toHaveBeenCalled(); apiGet.mockRestore(); }); }); describe('queue mutations', () => { test('moveQueueItem reorders correctly when the playing track stays put', () => { playQueue([track('a'), track('b'), track('c')], 0); moveQueueItem(1, 2); expect(player.queue.map((x) => x.id)).toEqual(['a', 'c', 'b']); expect(player.index).toBe(0); }); test('moveQueueItem keeps _index pointing at the playing track when its row moves', () => { playQueue([track('a'), track('b'), track('c')], 1); moveQueueItem(1, 0); expect(player.queue.map((x) => x.id)).toEqual(['b', 'a', 'c']); expect(player.index).toBe(0); }); test('moveQueueItem preserves _index logical pointer when other rows reorder around it', () => { playQueue([track('a'), track('b'), track('c'), track('d')], 1); moveQueueItem(0, 3); expect(player.queue.map((x) => x.id)).toEqual(['b', 'c', 'd', 'a']); expect(player.index).toBe(0); }); test('moveQueueItem with from === to is a no-op', () => { playQueue([track('a'), track('b'), track('c')], 1); moveQueueItem(1, 1); expect(player.queue.map((x) => x.id)).toEqual(['a', 'b', 'c']); expect(player.index).toBe(1); }); test('moveQueueItem with out-of-range from or to is a no-op', () => { playQueue([track('a'), track('b'), track('c')], 0); moveQueueItem(-1, 1); expect(player.queue.map((x) => x.id)).toEqual(['a', 'b', 'c']); moveQueueItem(0, 5); expect(player.queue.map((x) => x.id)).toEqual(['a', 'b', 'c']); }); test('removeFromQueue with idx < _index decrements _index', () => { playQueue([track('a'), track('b'), track('c')], 2); removeFromQueue(0); expect(player.queue.map((x) => x.id)).toEqual(['b', 'c']); expect(player.index).toBe(1); }); test('removeFromQueue with idx > _index leaves _index unchanged', () => { playQueue([track('a'), track('b'), track('c')], 0); removeFromQueue(2); expect(player.queue.map((x) => x.id)).toEqual(['a', 'b']); expect(player.index).toBe(0); }); test('removeFromQueue with idx === _index advances to next, sets state to loading', () => { playQueue([track('a'), track('b'), track('c')], 1); removeFromQueue(1); expect(player.queue.map((x) => x.id)).toEqual(['a', 'c']); expect(player.index).toBe(1); expect(player.state).toBe('loading'); }); test('removeFromQueue emptying the queue sets state to idle', () => { playQueue([track('a')], 0); removeFromQueue(0); expect(player.queue).toEqual([]); expect(player.state).toBe('idle'); }); test('removeFromQueue of the currently-playing last track pauses on the previous track', () => { playQueue([track('a'), track('b'), track('c')], 2); removeFromQueue(2); expect(player.queue.map((x) => x.id)).toEqual(['a', 'b']); expect(player.index).toBe(1); expect(player.state).toBe('paused'); expect(player.position).toBe(0); }); test('playFromQueueIndex sets index, resets position, sets state to loading', () => { playQueue([track('a'), track('b'), track('c')], 0); playFromQueueIndex(2); expect(player.index).toBe(2); expect(player.position).toBe(0); expect(player.state).toBe('loading'); }); test('playFromQueueIndex with out-of-range idx is a no-op', () => { playQueue([track('a'), track('b')], 0); const stateBefore = player.state; playFromQueueIndex(5); expect(player.index).toBe(0); expect(player.state).toBe(stateBefore); }); test('toggleQueueDrawer flips _queueDrawerOpen', () => { expect(player.queueDrawerOpen).toBe(false); toggleQueueDrawer(); expect(player.queueDrawerOpen).toBe(true); toggleQueueDrawer(); expect(player.queueDrawerOpen).toBe(false); }); test('closeQueueDrawer is idempotent', () => { closeQueueDrawer(); expect(player.queueDrawerOpen).toBe(false); toggleQueueDrawer(); expect(player.queueDrawerOpen).toBe(true); closeQueueDrawer(); expect(player.queueDrawerOpen).toBe(false); }); test('restoreQueue rehydrates queue, index, position and parks state at paused', () => { // Seed localStorage manually so we don't depend on the persistence // effect (which is integration-tested at the layout level). const userId = 'test-user'; writePersistedQueue(userId, { queue: [track('a'), track('b'), track('c')], index: 1, position: 42.5 }); restoreQueue(userId); expect(player.queue.map((x) => x.id)).toEqual(['a', 'b', 'c']); expect(player.index).toBe(1); expect(player.position).toBe(42.5); expect(player.state).toBe('paused'); clearPersistedQueue(userId); }); test('restoreQueue clamps out-of-range index', () => { const userId = 'test-user'; writePersistedQueue(userId, { queue: [track('a'), track('b'), track('c')], index: 99, position: 0 }); restoreQueue(userId); expect(player.index).toBe(2); clearPersistedQueue(userId); }); test('restoreQueue with empty userId is a no-op', () => { playQueue([track('a')], 0); restoreQueue(''); expect(player.queue.map((x) => x.id)).toEqual(['a']); }); }); describe('crossfade', () => { test('deriveFadeScalar returns 1 when crossfadeSec is 0', async () => { const { deriveFadeScalar } = await import('./store.svelte'); expect(deriveFadeScalar(50, 200, 0)).toBe(1); expect(deriveFadeScalar(0, 200, 0)).toBe(1); expect(deriveFadeScalar(200, 200, 0)).toBe(1); }); test('deriveFadeScalar returns 1 when track is too short to fade twice', async () => { const { deriveFadeScalar } = await import('./store.svelte'); // 6s track with 4s crossfade: 6 < 4*2, so no fade. expect(deriveFadeScalar(0, 6, 4)).toBe(1); expect(deriveFadeScalar(3, 6, 4)).toBe(1); }); test('deriveFadeScalar ramps in over the leading X seconds', async () => { const { deriveFadeScalar } = await import('./store.svelte'); // 60s track, 4s crossfade. expect(deriveFadeScalar(0, 60, 4)).toBe(0); expect(deriveFadeScalar(2, 60, 4)).toBe(0.5); expect(deriveFadeScalar(4, 60, 4)).toBe(1); }); test('deriveFadeScalar ramps out over the trailing X seconds', async () => { const { deriveFadeScalar } = await import('./store.svelte'); expect(deriveFadeScalar(56, 60, 4)).toBe(1); expect(deriveFadeScalar(58, 60, 4)).toBe(0.5); expect(deriveFadeScalar(60, 60, 4)).toBe(0); }); test('setCrossfade clamps to [0, 12] and persists', async () => { const { setCrossfade, player } = await import('./store.svelte'); setCrossfade(4); expect(player.crossfadeSec).toBe(4); expect(localStorage.getItem('minstrel.crossfade')).toBe('4'); setCrossfade(99); expect(player.crossfadeSec).toBe(12); setCrossfade(-3); expect(player.crossfadeSec).toBe(0); }); test('readStoredCrossfade survives an invalid stored value', async () => { const { readStoredCrossfade } = await import('./store.svelte'); localStorage.setItem('minstrel.crossfade', 'banana'); expect(readStoredCrossfade()).toBe(0); localStorage.setItem('minstrel.crossfade', '500'); expect(readStoredCrossfade()).toBe(0); localStorage.setItem('minstrel.crossfade', '7'); expect(readStoredCrossfade()).toBe(7); }); });