mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
test(playerStore): playback actions + audio event handlers (Phase F1 / PR 2b) (#541)
playbackActions.test.ts: pause (invoke + failed-invoke controlled), resume (warm path, no-track guard), togglePlay (both branches), seek (clamp to dur-0.25, 100 ms debounce, coalesce rapid drags, no-op guards), next (advance, repeat=all wrap, repeat=off audio_stop + reset), previous (>3 s restart, jump-back, queueIndex=0 no-op), toggleRepeat cycle. events.test.ts: audio:progress (commit on active transport, drop without track / paused, duration=0 falls back to track.duration), audio:track_switched (advance, repeat=one pin, repeat=all wrap, end+off no-op, scrobbled+ lastfmLoved reset), audio:ended (immediate playback reset, radio path clears currentRadio without queue advance). 4 listener-lifecycle regression tests cover section 4.2 of the pre-refactor testing plan v2 -- cleanup actually unsubs; re-init keeps count=1; double-init without cleanup stacks (contract pin so a refactor that drops the cleanup return value fails loudly). playerStore.ts coverage 18.1% -> 39.55% lines. PR 2c (progress snapshot + persistence flush) pushes past the F1 50% floor.
This commit is contained in:
committed by
GitHub
parent
42e3fdb976
commit
4a18e15489
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Audio-event handler characterization for `playerStore` (Phase F1 / PR 2b).
|
||||
*
|
||||
* Drives the Rust engine's `audio:*` channels through `emitTauriEvent` and
|
||||
* asserts on observable store state. Also covers the listener-lifecycle
|
||||
* regression test from §4.2 of the pre-refactor testing plan v2 — the
|
||||
* cleanup function returned by `initAudioListeners` must actually unsub.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/subsonic', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/subsonic')>('@/api/subsonic');
|
||||
return {
|
||||
...actual,
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
getSong: vi.fn(async () => null),
|
||||
getRandomSongs: vi.fn(async () => []),
|
||||
getSimilarSongs2: vi.fn(async () => []),
|
||||
getTopSongs: vi.fn(async () => []),
|
||||
getAlbumInfo2: vi.fn(async () => null),
|
||||
reportNowPlaying: vi.fn(async () => undefined),
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
setRating: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { initAudioListeners, usePlayerStore } from './playerStore';
|
||||
import {
|
||||
emitTauriEvent,
|
||||
onInvoke,
|
||||
tauriMockListenerCount,
|
||||
} from '@/test/mocks/tauri';
|
||||
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
||||
|
||||
function stubPlaybackInvokes(): void {
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_resume', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
onInvoke('audio_set_normalization', () => undefined);
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
onInvoke('frontend_debug_log', () => undefined);
|
||||
}
|
||||
|
||||
let cleanupListeners: (() => void) | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
resetPlayerStore();
|
||||
resetAuthStore();
|
||||
stubPlaybackInvokes();
|
||||
cleanupListeners = initAudioListeners();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupListeners?.();
|
||||
cleanupListeners = null;
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('audio:progress', () => {
|
||||
it('commits currentTime to the store when transport is active', () => {
|
||||
const track = makeTrack({ duration: 100 });
|
||||
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
|
||||
|
||||
// Above the 5 s store-commit threshold so the first event causes a write.
|
||||
emitTauriEvent('audio:progress', { current_time: 25, duration: 100 });
|
||||
|
||||
expect(usePlayerStore.getState().currentTime).toBeCloseTo(25, 5);
|
||||
expect(usePlayerStore.getState().progress).toBeCloseTo(0.25, 5);
|
||||
});
|
||||
|
||||
it('is ignored when there is no current track', () => {
|
||||
usePlayerStore.setState({ currentTrack: null, currentTime: 0 });
|
||||
emitTauriEvent('audio:progress', { current_time: 25, duration: 100 });
|
||||
expect(usePlayerStore.getState().currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it('is ignored when transport is inactive (paused, no radio)', () => {
|
||||
const track = makeTrack({ duration: 100 });
|
||||
usePlayerStore.setState({ currentTrack: track, isPlaying: false, currentTime: 0 });
|
||||
emitTauriEvent('audio:progress', { current_time: 25, duration: 100 });
|
||||
expect(usePlayerStore.getState().currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it('uses the track duration when the event reports duration ≤ 0', () => {
|
||||
const track = makeTrack({ duration: 200 });
|
||||
usePlayerStore.setState({ currentTrack: track, isPlaying: true });
|
||||
emitTauriEvent('audio:progress', { current_time: 50, duration: 0 });
|
||||
// Falls back to track.duration = 200, so progress = 50/200 = 0.25.
|
||||
expect(usePlayerStore.getState().progress).toBeCloseTo(0.25, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audio:track_switched', () => {
|
||||
it('advances to queue[queueIndex + 1] under repeatMode=off', () => {
|
||||
const queue = makeTracks(3);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 0,
|
||||
currentTrack: queue[0],
|
||||
repeatMode: 'off',
|
||||
});
|
||||
emitTauriEvent('audio:track_switched', queue[1].duration);
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTrack?.id).toBe(queue[1].id);
|
||||
expect(s.queueIndex).toBe(1);
|
||||
expect(s.isPlaying).toBe(true);
|
||||
expect(s.scrobbled).toBe(false);
|
||||
expect(s.progress).toBe(0);
|
||||
expect(s.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it('replays the same track under repeatMode=one (queueIndex stays put)', () => {
|
||||
const queue = makeTracks(3);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 1,
|
||||
currentTrack: queue[1],
|
||||
repeatMode: 'one',
|
||||
});
|
||||
emitTauriEvent('audio:track_switched', queue[1].duration);
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTrack?.id).toBe(queue[1].id);
|
||||
expect(s.queueIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('wraps to queue[0] when at the end with repeatMode=all', () => {
|
||||
const queue = makeTracks(3);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 2,
|
||||
currentTrack: queue[2],
|
||||
repeatMode: 'all',
|
||||
});
|
||||
emitTauriEvent('audio:track_switched', queue[0].duration);
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTrack?.id).toBe(queue[0].id);
|
||||
expect(s.queueIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op when at the end with repeatMode=off', () => {
|
||||
const queue = makeTracks(2);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 1,
|
||||
currentTrack: queue[1],
|
||||
repeatMode: 'off',
|
||||
});
|
||||
emitTauriEvent('audio:track_switched', queue[1].duration);
|
||||
// No next candidate → handler returns early before state changes.
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTrack?.id).toBe(queue[1].id);
|
||||
expect(s.queueIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('resets scrobbled + lastfmLoved flags so the new track can be rescored', () => {
|
||||
const queue = makeTracks(2);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 0,
|
||||
currentTrack: queue[0],
|
||||
scrobbled: true,
|
||||
lastfmLoved: true,
|
||||
});
|
||||
emitTauriEvent('audio:track_switched', queue[1].duration);
|
||||
expect(usePlayerStore.getState().scrobbled).toBe(false);
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('audio:ended', () => {
|
||||
// Module-scope `lastGaplessSwitchTime` is updated by handleAudioTrackSwitched
|
||||
// in adjacent tests; the ghost-guard skips audio:ended events fired within
|
||||
// 600 ms of a track switch. Advance the fake clock to bypass the guard.
|
||||
beforeEach(() => {
|
||||
vi.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
it('immediately resets playback bookkeeping (before the 150 ms next() timer)', () => {
|
||||
const queue = makeTracks(2);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 0,
|
||||
currentTrack: queue[0],
|
||||
isPlaying: true,
|
||||
progress: 0.99,
|
||||
currentTime: 178,
|
||||
buffered: 1,
|
||||
});
|
||||
emitTauriEvent('audio:ended', undefined);
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.isPlaying).toBe(false);
|
||||
expect(s.progress).toBe(0);
|
||||
expect(s.currentTime).toBe(0);
|
||||
expect(s.buffered).toBe(0);
|
||||
// currentTrack stays — `next()` (deferred 150 ms) will replace it.
|
||||
expect(s.currentTrack?.id).toBe(queue[0].id);
|
||||
});
|
||||
|
||||
it('clears state and currentRadio for a radio stream without advancing the queue', () => {
|
||||
const queue = makeTracks(2);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 0,
|
||||
currentTrack: queue[0],
|
||||
currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' },
|
||||
isPlaying: true,
|
||||
});
|
||||
emitTauriEvent('audio:ended', undefined);
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.isPlaying).toBe(false);
|
||||
expect(s.currentRadio).toBeNull();
|
||||
expect(s.progress).toBe(0);
|
||||
expect(s.currentTime).toBe(0);
|
||||
// Queue not advanced.
|
||||
expect(s.queueIndex).toBe(0);
|
||||
expect(s.currentTrack?.id).toBe(queue[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initAudioListeners — listener lifecycle (regression §4.2)', () => {
|
||||
it('registers exactly one listener per audio:* channel', () => {
|
||||
// beforeEach already called initAudioListeners once.
|
||||
expect(tauriMockListenerCount('audio:progress')).toBe(1);
|
||||
expect(tauriMockListenerCount('audio:ended')).toBe(1);
|
||||
expect(tauriMockListenerCount('audio:track_switched')).toBe(1);
|
||||
expect(tauriMockListenerCount('audio:playing')).toBe(1);
|
||||
});
|
||||
|
||||
it('cleanup() removes all audio:* listeners it registered', async () => {
|
||||
// Tear down the listeners attached by beforeEach.
|
||||
cleanupListeners?.();
|
||||
cleanupListeners = null;
|
||||
// `pending.forEach(p => p.then(unlisten => unlisten()))` runs in microtasks
|
||||
// — flush twice to ride through the then-chain.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(tauriMockListenerCount('audio:progress')).toBe(0);
|
||||
expect(tauriMockListenerCount('audio:ended')).toBe(0);
|
||||
expect(tauriMockListenerCount('audio:track_switched')).toBe(0);
|
||||
expect(tauriMockListenerCount('audio:playing')).toBe(0);
|
||||
});
|
||||
|
||||
it('re-init after cleanup keeps the count at 1 (no leak)', async () => {
|
||||
cleanupListeners?.();
|
||||
cleanupListeners = null;
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
cleanupListeners = initAudioListeners();
|
||||
expect(tauriMockListenerCount('audio:progress')).toBe(1);
|
||||
expect(tauriMockListenerCount('audio:track_switched')).toBe(1);
|
||||
});
|
||||
|
||||
it('init without cleanup stacks listeners — guards against missing useEffect cleanup', () => {
|
||||
// Demonstrates the pre-ShortcutMap bug shape: calling init twice without
|
||||
// tearing down accumulates handlers. The Real Fix lives in the consumer
|
||||
// (React useEffect cleanup); this test pins the contract so a refactor
|
||||
// that silently swallows the cleanup return value fails loudly.
|
||||
const second = initAudioListeners();
|
||||
expect(tauriMockListenerCount('audio:progress')).toBe(2);
|
||||
second();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* Playback-action characterization for `playerStore` (Phase F1 / PR 2b).
|
||||
*
|
||||
* Covers transport actions — pause / resume / togglePlay / seek / next /
|
||||
* previous / toggleRepeat — and asserts that a failing Tauri invoke
|
||||
* produces controlled error state, not partial mutation. Audio event
|
||||
* handlers live in `playerStore.events.test.ts`.
|
||||
*
|
||||
* Heavy module-level mocking: `subsonic.ts` (server APIs) and `lastfm.ts`
|
||||
* (scrobble + loved lookups) are mocked to no-ops so navigation-style
|
||||
* `playTrack` calls (from `next` / `previous`) don't try to hit a real
|
||||
* server. The store's own action bodies still run for real.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/api/subsonic', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/api/subsonic')>('@/api/subsonic');
|
||||
return {
|
||||
...actual,
|
||||
savePlayQueue: vi.fn(async () => undefined),
|
||||
getPlayQueue: vi.fn(async () => ({ songs: [], current: undefined, position: 0 })),
|
||||
buildStreamUrl: vi.fn((id: string) => `https://mock/stream/${id}`),
|
||||
buildCoverArtUrl: vi.fn((id: string) => `https://mock/cover/${id}`),
|
||||
getSong: vi.fn(async () => null),
|
||||
getRandomSongs: vi.fn(async () => []),
|
||||
getSimilarSongs2: vi.fn(async () => []),
|
||||
getTopSongs: vi.fn(async () => []),
|
||||
getAlbumInfo2: vi.fn(async () => null),
|
||||
reportNowPlaying: vi.fn(async () => undefined),
|
||||
scrobbleSong: vi.fn(async () => undefined),
|
||||
setRating: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
||||
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
||||
|
||||
function stubPlaybackInvokes(): void {
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => undefined);
|
||||
onInvoke('audio_resume', () => undefined);
|
||||
onInvoke('audio_stop', () => undefined);
|
||||
onInvoke('audio_seek', () => undefined);
|
||||
onInvoke('audio_get_state', () => ({ playing: false }));
|
||||
onInvoke('audio_update_replay_gain', () => undefined);
|
||||
onInvoke('audio_set_normalization', () => undefined);
|
||||
onInvoke('discord_update_presence', () => undefined);
|
||||
onInvoke('frontend_debug_log', () => undefined);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Fake timers across the file so module-scoped locks (`togglePlayLock`,
|
||||
// `seekDebounce`) don't bleed between tests. afterEach drains pending
|
||||
// timer callbacks so each next test sees a clean slate.
|
||||
vi.useFakeTimers();
|
||||
resetPlayerStore();
|
||||
resetAuthStore();
|
||||
stubPlaybackInvokes();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('pause', () => {
|
||||
it('invokes audio_pause and clears isPlaying', () => {
|
||||
usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
|
||||
usePlayerStore.getState().pause();
|
||||
expect(invokeMock).toHaveBeenCalledWith('audio_pause');
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('still clears isPlaying when the engine invoke rejects (controlled error)', () => {
|
||||
onInvoke('audio_pause', () => { throw new Error('engine gone'); });
|
||||
usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
|
||||
usePlayerStore.getState().pause();
|
||||
// `invoke('audio_pause').catch(...)` is fire-and-forget — state mutation
|
||||
// happens regardless of whether the engine call succeeds.
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('clears any pending scheduled pause / resume timers', () => {
|
||||
usePlayerStore.setState({
|
||||
isPlaying: true,
|
||||
currentTrack: makeTrack(),
|
||||
scheduledPauseAtMs: Date.now() + 60_000,
|
||||
scheduledPauseStartMs: Date.now(),
|
||||
scheduledResumeAtMs: Date.now() + 120_000,
|
||||
scheduledResumeStartMs: Date.now(),
|
||||
});
|
||||
usePlayerStore.getState().pause();
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.scheduledPauseAtMs).toBeNull();
|
||||
expect(s.scheduledPauseStartMs).toBeNull();
|
||||
expect(s.scheduledResumeAtMs).toBeNull();
|
||||
expect(s.scheduledResumeStartMs).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resume — warm path (engine has the track loaded, just paused)', () => {
|
||||
it('invokes audio_resume and sets isPlaying', () => {
|
||||
// Set up a "warm" state: pause was called previously so isAudioPaused=true.
|
||||
usePlayerStore.setState({ currentTrack: makeTrack(), isPlaying: true });
|
||||
usePlayerStore.getState().pause();
|
||||
invokeMock.mockClear();
|
||||
|
||||
usePlayerStore.getState().resume();
|
||||
expect(invokeMock).toHaveBeenCalledWith('audio_resume');
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
||||
});
|
||||
|
||||
it('returns without invoking when there is no current track', () => {
|
||||
usePlayerStore.setState({ currentTrack: null });
|
||||
usePlayerStore.getState().resume();
|
||||
expect(invokeMock).not.toHaveBeenCalledWith('audio_resume');
|
||||
});
|
||||
});
|
||||
|
||||
describe('togglePlay', () => {
|
||||
it('calls pause when isPlaying is true', () => {
|
||||
usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
|
||||
usePlayerStore.getState().togglePlay();
|
||||
expect(invokeMock).toHaveBeenCalledWith('audio_pause');
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('calls resume (warm path) when isPlaying is false', () => {
|
||||
// Bring the engine into the "paused-but-loaded" state first.
|
||||
usePlayerStore.setState({ isPlaying: true, currentTrack: makeTrack() });
|
||||
usePlayerStore.getState().pause();
|
||||
invokeMock.mockClear();
|
||||
|
||||
usePlayerStore.getState().togglePlay();
|
||||
expect(invokeMock).toHaveBeenCalledWith('audio_resume');
|
||||
expect(usePlayerStore.getState().isPlaying).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('seek', () => {
|
||||
it('clamps to duration - 0.25 and updates optimistic progress immediately', () => {
|
||||
const track = makeTrack({ duration: 100 });
|
||||
usePlayerStore.setState({ currentTrack: track });
|
||||
usePlayerStore.getState().seek(1.0); // 100% — should clamp to 99.75
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTime).toBeCloseTo(99.75, 5);
|
||||
expect(s.progress).toBeCloseTo(99.75 / 100, 5);
|
||||
});
|
||||
|
||||
it('debounces 100 ms before invoking audio_seek', () => {
|
||||
const track = makeTrack({ duration: 120 });
|
||||
usePlayerStore.setState({ currentTrack: track });
|
||||
usePlayerStore.getState().seek(0.5);
|
||||
expect(invokeMock).not.toHaveBeenCalledWith('audio_seek', expect.anything());
|
||||
vi.advanceTimersByTime(100);
|
||||
expect(invokeMock).toHaveBeenCalledWith('audio_seek', expect.objectContaining({ seconds: 60 }));
|
||||
});
|
||||
|
||||
it('coalesces rapid drags into a single backend seek', () => {
|
||||
const track = makeTrack({ duration: 120 });
|
||||
usePlayerStore.setState({ currentTrack: track });
|
||||
const s = usePlayerStore.getState();
|
||||
s.seek(0.25);
|
||||
s.seek(0.5);
|
||||
s.seek(0.75);
|
||||
vi.advanceTimersByTime(100);
|
||||
const seekCalls = invokeMock.mock.calls.filter(c => c[0] === 'audio_seek');
|
||||
expect(seekCalls).toHaveLength(1);
|
||||
expect(seekCalls[0]?.[1]).toEqual(expect.objectContaining({ seconds: 90 }));
|
||||
});
|
||||
|
||||
it('is a no-op when there is no current track', () => {
|
||||
usePlayerStore.setState({ currentTrack: null });
|
||||
usePlayerStore.getState().seek(0.5);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it('is a no-op when the current track has zero duration', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack({ duration: 0 }) });
|
||||
usePlayerStore.getState().seek(0.5);
|
||||
expect(usePlayerStore.getState().currentTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('next', () => {
|
||||
it('advances to queue[queueIndex + 1] when one is available', () => {
|
||||
const queue = makeTracks(3);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 0,
|
||||
currentTrack: queue[0],
|
||||
});
|
||||
usePlayerStore.getState().next();
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
|
||||
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('wraps to queue[0] when at the end with repeatMode=all', () => {
|
||||
const queue = makeTracks(3);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 2,
|
||||
currentTrack: queue[2],
|
||||
repeatMode: 'all',
|
||||
});
|
||||
usePlayerStore.getState().next();
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
|
||||
expect(usePlayerStore.getState().queueIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('stops the engine and clears playback when at the end with repeatMode=off', () => {
|
||||
// infiniteQueueEnabled and the radio fetch path are both off by default,
|
||||
// so the no-next branch falls through to `audio_stop`.
|
||||
const queue = makeTracks(2);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 1,
|
||||
currentTrack: queue[1],
|
||||
repeatMode: 'off',
|
||||
isPlaying: true,
|
||||
});
|
||||
usePlayerStore.getState().next();
|
||||
expect(invokeMock).toHaveBeenCalledWith('audio_stop');
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.isPlaying).toBe(false);
|
||||
expect(s.currentTime).toBe(0);
|
||||
expect(s.progress).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('previous', () => {
|
||||
it('restarts the current track when currentTime > 3 s', () => {
|
||||
const queue = makeTracks(3);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 1,
|
||||
currentTrack: queue[1],
|
||||
});
|
||||
// The store's `currentTime` is the source for the "restart vs jump back"
|
||||
// branch. `getPlaybackProgressSnapshot` reads from the same field.
|
||||
usePlayerStore.setState({ currentTime: 10, progress: 10 / queue[1].duration });
|
||||
usePlayerStore.getState().previous();
|
||||
expect(invokeMock).toHaveBeenCalledWith('audio_seek', expect.objectContaining({ seconds: 0 }));
|
||||
expect(usePlayerStore.getState().queueIndex).toBe(1); // stayed on the same track
|
||||
});
|
||||
|
||||
it('jumps to the previous track when currentTime ≤ 3 s and queueIndex > 0', () => {
|
||||
const queue = makeTracks(3);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 2,
|
||||
currentTrack: queue[2],
|
||||
currentTime: 1.0,
|
||||
});
|
||||
usePlayerStore.getState().previous();
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[1].id);
|
||||
expect(usePlayerStore.getState().queueIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('is a no-op when queueIndex is 0 and currentTime ≤ 3 s', () => {
|
||||
const queue = makeTracks(2);
|
||||
usePlayerStore.setState({
|
||||
queue,
|
||||
queueIndex: 0,
|
||||
currentTrack: queue[0],
|
||||
currentTime: 0.5,
|
||||
});
|
||||
usePlayerStore.getState().previous();
|
||||
expect(usePlayerStore.getState().queueIndex).toBe(0);
|
||||
expect(usePlayerStore.getState().currentTrack?.id).toBe(queue[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleRepeat', () => {
|
||||
it('cycles off → all → one → off', () => {
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('off');
|
||||
usePlayerStore.getState().toggleRepeat();
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('all');
|
||||
usePlayerStore.getState().toggleRepeat();
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('one');
|
||||
usePlayerStore.getState().toggleRepeat();
|
||||
expect(usePlayerStore.getState().repeatMode).toBe('off');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user