d8b955ff0c
Adds _radioSeedId state and a module-level $effect.root that triggers a /api/radio?seed_track=…&exclude=… fetch when queue consumption reaches 80%, appending new tracks without resetting the radio seed. Clears seed on any non-radio enqueue (playQueue/enqueueTrack/enqueueTracks). Five new vitest cases cover the trigger threshold, append logic, in-flight guard, and manual-enqueue reset path.
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import {
|
|
player,
|
|
playQueue,
|
|
togglePlay,
|
|
skipNext,
|
|
skipPrev,
|
|
seekTo,
|
|
setVolume,
|
|
registerAudioEl,
|
|
reportTimeUpdate,
|
|
reportDuration,
|
|
readStoredVolume,
|
|
toggleShuffle,
|
|
cycleRepeat,
|
|
reportStateFromAudio,
|
|
__setShuffleRng
|
|
} from './store.svelte';
|
|
import type { TrackRef } from '$lib/api/types';
|
|
|
|
function track(id: string, dur = 180): TrackRef {
|
|
return {
|
|
id, title: `T ${id}`,
|
|
album_id: 'a1', album_title: 'A',
|
|
artist_id: 'ar1', artist_name: 'Ar',
|
|
track_number: Number(id), disc_number: 1,
|
|
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([]);
|
|
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');
|
|
});
|
|
});
|
|
|
|
import { enqueueTrack, enqueueTracks, playRadio } 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('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<unknown>);
|
|
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();
|
|
});
|
|
});
|