From 4a18e15489be70f600290cee5e092f6f83eb01b8 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 11 May 2026 21:56:24 +0200 Subject: [PATCH] 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. --- src/store/playerStore.events.test.ts | 285 +++++++++++++++++ src/store/playerStore.playbackActions.test.ts | 300 ++++++++++++++++++ 2 files changed, 585 insertions(+) create mode 100644 src/store/playerStore.events.test.ts create mode 100644 src/store/playerStore.playbackActions.test.ts diff --git a/src/store/playerStore.events.test.ts b/src/store/playerStore.events.test.ts new file mode 100644 index 00000000..c4e851e0 --- /dev/null +++ b/src/store/playerStore.events.test.ts @@ -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('@/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(); + }); +}); diff --git a/src/store/playerStore.playbackActions.test.ts b/src/store/playerStore.playbackActions.test.ts new file mode 100644 index 00000000..9dfeac07 --- /dev/null +++ b/src/store/playerStore.playbackActions.test.ts @@ -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('@/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'); + }); +});