feat(web): add shuffle, repeat, and audio-event reporting to player store

toggleShuffle (Fisher-Yates over queue[index+1..end] with injectable
RNG for test determinism), cycleRepeat (off → all → one → off),
reportStateFromAudio with full repeat-mode semantics on 'ended',
plus simple state mirroring for playing/paused/waiting/error. Extends
skipNext to wrap when repeat='all'.
This commit is contained in:
2026-04-24 17:56:00 -04:00
parent 431f333e41
commit 1476d5081b
2 changed files with 205 additions and 2 deletions
+126 -1
View File
@@ -10,7 +10,11 @@ import {
registerAudioEl,
reportTimeUpdate,
reportDuration,
readStoredVolume
readStoredVolume,
toggleShuffle,
cycleRepeat,
reportStateFromAudio,
__setShuffleRng
} from './store.svelte';
import type { TrackRef } from '$lib/api/types';
@@ -31,6 +35,9 @@ beforeEach(() => {
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', () => {
@@ -154,3 +161,121 @@ describe('player store — basic actions', () => {
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');
});
});