feat(web): add player rune store with basic actions
Core state (queue, index, state, position, duration, volume, shuffle, repeat, error) plus actions: playQueue, togglePlay, linear skipNext, skipPrev 3-second rule, seekTo, setVolume (localStorage-persisted), registerAudioEl, reportTimeUpdate, reportDuration. Shuffle/repeat logic and audio-event reporting land in follow-up commits. Also install an in-memory Storage polyfill in vitest.setup.ts: Node 25 exposes a partial localStorage global that lacks getItem/setItem/clear (requires --localstorage-file with a path), and jsdom defers to the Node global when present, leaving tests unable to exercise storage.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import {
|
||||
player,
|
||||
playQueue,
|
||||
togglePlay,
|
||||
skipNext,
|
||||
skipPrev,
|
||||
seekTo,
|
||||
setVolume,
|
||||
registerAudioEl,
|
||||
reportTimeUpdate,
|
||||
reportDuration,
|
||||
readStoredVolume
|
||||
} 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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user