From 8569a17797c96a27d324e010a17bd66946776b57 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 11 May 2026 22:14:57 +0200 Subject: [PATCH] test(playerStore): progress snapshot + persistence flush (Phase F1 / PR 2c) (#542) progress.test.ts: getPlaybackProgressSnapshot shape + post-emit reflection, subscribePlaybackProgress (notify, (next, prev) pair, near-duplicate epsilon coalesce at 0.005 s, unsub stops notifications, multiple subscribers independent), live-emit throttling guard (drops within 1500 ms + < 0.9 s delta; large delta passes; time threshold passes). persistence.test.ts: flushPlayQueuePosition forwards (ids, currentId, posMs) to savePlayQueue, caps song-id list at 1000, no-ops on radio / no-track / empty queue, swallows backend errors, floors position to whole ms. playerStore.ts coverage 39.55% -> 40.48%. F1 50% floor not met -- remaining ~10pp lives in playTrack's async hot-cache/replay-gain body, shuffleQueue, stop, enqueueRadio, initializeFromServerQueue and the orbit auto-merge paths. Either a follow-up PR 2d or a revised F1 floor; flagged in the PR body. Gate unchanged -- playerStore.ts stays out until the floor is met. Discovered + fixed during this PR: the spread ...await vi.importActual() mock pattern lets the real savePlayQueue leak through to playerStore.ts's relative import (../api/subsonic) even when the alias-form is mocked. Switched persistence.test.ts to an explicit non-spread mock map listing every export the store uses. --- src/store/playerStore.persistence.test.ts | 190 +++++++++++++++++ src/store/playerStore.progress.test.ts | 244 ++++++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 src/store/playerStore.persistence.test.ts create mode 100644 src/store/playerStore.progress.test.ts diff --git a/src/store/playerStore.persistence.test.ts b/src/store/playerStore.persistence.test.ts new file mode 100644 index 00000000..426547b5 --- /dev/null +++ b/src/store/playerStore.persistence.test.ts @@ -0,0 +1,190 @@ +/** + * Server play-queue persistence flush characterization (Phase F1 / PR 2c). + * + * `flushPlayQueuePosition` is the synchronous-from-the-caller's-view path + * that the playback heartbeat / close handler / `pause()` use to push the + * current position to the Subsonic server so cross-device resume works. + * + * Mocks `savePlayQueue` at the module boundary so we can assert the exact + * args passed to the Subsonic API call. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Explicit (non-spread) mock map — the `...actual` spread pattern lets the +// real `savePlayQueue` leak through to `playerStore.ts`'s relative import. +// Listing every export the store uses keeps the override stable. +vi.mock('@/api/subsonic', () => ({ + 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}`), + buildDownloadUrl: vi.fn((id: string) => `https://mock/download/${id}`), + coverArtCacheKey: vi.fn((id: string, size = 256) => `mock:cover:${id}:${size}`), + 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), + probeEntityRatingSupport: vi.fn(async () => 'track_only'), + pingWithCredentials: vi.fn(async () => ({ ok: true })), +})); + +vi.mock('@/api/lastfm', () => ({ + lastfmScrobble: vi.fn(async () => undefined), + lastfmUpdateNowPlaying: vi.fn(async () => undefined), + lastfmGetTrackLoved: vi.fn(async () => false), + lastfmGetAllLovedTracks: vi.fn(async () => []), +})); + +import { savePlayQueue } from '@/api/subsonic'; +import { + flushPlayQueuePosition, + initAudioListeners, + usePlayerStore, +} from './playerStore'; +import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri'; +import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; +import { makeTrack, makeTracks } from '@/test/helpers/factories'; + +function stubInvokes(): 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(); + stubInvokes(); + vi.mocked(savePlayQueue).mockClear(); + cleanupListeners = initAudioListeners(); +}); + +afterEach(() => { + cleanupListeners?.(); + cleanupListeners = null; + vi.runOnlyPendingTimers(); + vi.useRealTimers(); +}); + +describe('flushPlayQueuePosition', () => { + it('forwards the queue, current track, and millisecond position to savePlayQueue', async () => { + const [t1, t2, t3] = makeTracks(3); + usePlayerStore.setState({ + queue: [t1, t2, t3], + queueIndex: 1, + currentTrack: t2, + isPlaying: true, + }); + // Drive a live-progress snapshot so flushPlayQueuePosition has a non-zero + // position to flush — readonly snapshot is what the API call samples. + emitTauriEvent('audio:progress', { current_time: 12.345, duration: t2.duration }); + // The audio:progress handler itself fires the 15 s heartbeat flush on the + // first event (lastQueueHeartbeatAt starts at 0). Discard that call so the + // assertion below targets only our explicit flushPlayQueuePosition(). + vi.mocked(savePlayQueue).mockClear(); + + await flushPlayQueuePosition(); + + expect(savePlayQueue).toHaveBeenCalledTimes(1); + expect(savePlayQueue).toHaveBeenCalledWith( + [t1.id, t2.id, t3.id], + t2.id, + 12345, // Math.floor(12.345 * 1000) + ); + }); + + it('caps the song-id list at 1000 entries', async () => { + const tracks = makeTracks(1100); + usePlayerStore.setState({ + queue: tracks, + queueIndex: 0, + currentTrack: tracks[0], + }); + emitTauriEvent('audio:progress', { current_time: 1, duration: tracks[0].duration }); + vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit + + await flushPlayQueuePosition(); + + expect(savePlayQueue).toHaveBeenCalledTimes(1); + const idsArg = vi.mocked(savePlayQueue).mock.calls[0]?.[0]; + expect(idsArg).toHaveLength(1000); + expect(idsArg?.[999]).toBe(tracks[999].id); + }); + + it('is a no-op when a radio stream is active', async () => { + const track = makeTrack(); + usePlayerStore.setState({ + queue: [track], + queueIndex: 0, + currentTrack: track, + currentRadio: { id: 'r1', name: 'Test FM', streamUrl: 'https://radio.test/stream' }, + }); + + await flushPlayQueuePosition(); + + expect(savePlayQueue).not.toHaveBeenCalled(); + }); + + it('is a no-op when there is no current track', async () => { + usePlayerStore.setState({ + queue: makeTracks(2), + queueIndex: 0, + currentTrack: null, + }); + + await flushPlayQueuePosition(); + + expect(savePlayQueue).not.toHaveBeenCalled(); + }); + + it('is a no-op when the queue is empty', async () => { + usePlayerStore.setState({ + queue: [], + queueIndex: 0, + currentTrack: null, + }); + + await flushPlayQueuePosition(); + + expect(savePlayQueue).not.toHaveBeenCalled(); + }); + + it('swallows backend errors without propagating to the caller', async () => { + const track = makeTrack(); + usePlayerStore.setState({ queue: [track], queueIndex: 0, currentTrack: track }); + vi.mocked(savePlayQueue).mockRejectedValueOnce(new Error('offline')); + + await expect(flushPlayQueuePosition()).resolves.toBeUndefined(); + }); + + it('floors the position to whole milliseconds', async () => { + const track = makeTrack({ duration: 200 }); + usePlayerStore.setState({ + queue: [track], + queueIndex: 0, + currentTrack: track, + isPlaying: true, + }); + emitTauriEvent('audio:progress', { current_time: 12.9999, duration: 200 }); + vi.mocked(savePlayQueue).mockClear(); // discard heartbeat call from emit + + await flushPlayQueuePosition(); + + const posArg = vi.mocked(savePlayQueue).mock.calls[0]?.[2]; + expect(posArg).toBe(12999); // Math.floor(12.9999 * 1000) + }); +}); diff --git a/src/store/playerStore.progress.test.ts b/src/store/playerStore.progress.test.ts new file mode 100644 index 00000000..03a25db7 --- /dev/null +++ b/src/store/playerStore.progress.test.ts @@ -0,0 +1,244 @@ +/** + * Playback-progress snapshot + subscriber characterization (Phase F1 / PR 2c). + * + * `getPlaybackProgressSnapshot` + `subscribePlaybackProgress` are the + * low-overhead channel UI components (waveform, seekbar) read instead of + * subscribing to the full Zustand store. Heavy state writes still go + * through Zustand at the coarser store-commit interval — see + * `playerStore.events.test.ts` for that side. + * + * Drive emits via the `audio:progress` Tauri event (the only public path + * to `emitPlaybackProgress`). + */ +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), + getSimilarSongs2: vi.fn(async () => []), + getTopSongs: vi.fn(async () => []), + getAlbumInfo2: vi.fn(async () => null), + reportNowPlaying: vi.fn(async () => undefined), + scrobbleSong: vi.fn(async () => undefined), + }; +}); + +vi.mock('@/api/lastfm', () => ({ + lastfmScrobble: vi.fn(async () => undefined), + lastfmUpdateNowPlaying: vi.fn(async () => undefined), + lastfmGetTrackLoved: vi.fn(async () => false), + lastfmGetAllLovedTracks: vi.fn(async () => []), +})); + +import { + getPlaybackProgressSnapshot, + initAudioListeners, + subscribePlaybackProgress, + usePlayerStore, + type PlaybackProgressSnapshot, +} from './playerStore'; +import { emitTauriEvent, onInvoke } from '@/test/mocks/tauri'; +import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset'; +import { makeTrack } from '@/test/helpers/factories'; + +function stubInvokes(): 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(); + stubInvokes(); + cleanupListeners = initAudioListeners(); +}); + +afterEach(() => { + cleanupListeners?.(); + cleanupListeners = null; + vi.runOnlyPendingTimers(); + vi.useRealTimers(); +}); + +describe('getPlaybackProgressSnapshot', () => { + it('returns the same shape (currentTime / progress / buffered)', () => { + const snap = getPlaybackProgressSnapshot(); + expect(snap).toHaveProperty('currentTime'); + expect(snap).toHaveProperty('progress'); + expect(snap).toHaveProperty('buffered'); + expect(typeof snap.currentTime).toBe('number'); + expect(typeof snap.progress).toBe('number'); + expect(typeof snap.buffered).toBe('number'); + }); + + it('reflects a fresh audio:progress event when transport is active', () => { + const track = makeTrack({ duration: 100 }); + usePlayerStore.setState({ currentTrack: track, isPlaying: true }); + emitTauriEvent('audio:progress', { current_time: 42, duration: 100 }); + + const snap = getPlaybackProgressSnapshot(); + expect(snap.currentTime).toBeCloseTo(42, 3); + expect(snap.progress).toBeCloseTo(0.42, 3); + }); +}); + +describe('subscribePlaybackProgress', () => { + it('notifies the subscriber on each observable update', () => { + const track = makeTrack({ duration: 100 }); + usePlayerStore.setState({ currentTrack: track, isPlaying: true }); + + const calls: PlaybackProgressSnapshot[] = []; + const unsub = subscribePlaybackProgress(next => calls.push(next)); + + emitTauriEvent('audio:progress', { current_time: 10, duration: 100 }); + vi.advanceTimersByTime(2000); // beyond LIVE_PROGRESS_EMIT_MIN_MS = 1500 + emitTauriEvent('audio:progress', { current_time: 20, duration: 100 }); + + expect(calls).toHaveLength(2); + expect(calls[0]?.currentTime).toBeCloseTo(10, 3); + expect(calls[1]?.currentTime).toBeCloseTo(20, 3); + + unsub(); + }); + + it('passes (next, prev) snapshots to the subscriber', () => { + const track = makeTrack({ duration: 100 }); + usePlayerStore.setState({ currentTrack: track, isPlaying: true }); + + const pairs: Array<[PlaybackProgressSnapshot, PlaybackProgressSnapshot]> = []; + const unsub = subscribePlaybackProgress((next, prev) => pairs.push([next, prev])); + + emitTauriEvent('audio:progress', { current_time: 5, duration: 100 }); + vi.advanceTimersByTime(2000); + emitTauriEvent('audio:progress', { current_time: 15, duration: 100 }); + + expect(pairs).toHaveLength(2); + const [[next2, prev2]] = pairs.slice(-1); + expect(next2.currentTime).toBeCloseTo(15, 3); + expect(prev2.currentTime).toBeCloseTo(5, 3); + + unsub(); + }); + + it('coalesces near-duplicate snapshots (epsilon guard inside emitPlaybackProgress)', () => { + const track = makeTrack({ duration: 100 }); + usePlayerStore.setState({ currentTrack: track, isPlaying: true }); + + const calls: PlaybackProgressSnapshot[] = []; + const unsub = subscribePlaybackProgress(next => calls.push(next)); + + emitTauriEvent('audio:progress', { current_time: 10, duration: 100 }); + vi.advanceTimersByTime(2000); + // Re-emit with a delta smaller than the snapshot's 0.005 s epsilon. + emitTauriEvent('audio:progress', { current_time: 10.001, duration: 100 }); + + expect(calls).toHaveLength(1); + unsub(); + }); + + it('stops notifying after the returned unsubscribe runs', () => { + const track = makeTrack({ duration: 100 }); + usePlayerStore.setState({ currentTrack: track, isPlaying: true }); + + const calls: PlaybackProgressSnapshot[] = []; + const unsub = subscribePlaybackProgress(next => calls.push(next)); + + emitTauriEvent('audio:progress', { current_time: 7, duration: 100 }); + expect(calls).toHaveLength(1); + + unsub(); + vi.advanceTimersByTime(2000); + emitTauriEvent('audio:progress', { current_time: 50, duration: 100 }); + + expect(calls).toHaveLength(1); + }); + + it('supports multiple subscribers independently', () => { + const track = makeTrack({ duration: 100 }); + usePlayerStore.setState({ currentTrack: track, isPlaying: true }); + + const a: number[] = []; + const b: number[] = []; + const unsubA = subscribePlaybackProgress(n => a.push(n.currentTime)); + const unsubB = subscribePlaybackProgress(n => b.push(n.currentTime)); + + emitTauriEvent('audio:progress', { current_time: 11, duration: 100 }); + expect(a).toHaveLength(1); + expect(b).toHaveLength(1); + + unsubA(); + vi.advanceTimersByTime(2000); + emitTauriEvent('audio:progress', { current_time: 21, duration: 100 }); + expect(a).toHaveLength(1); + expect(b).toHaveLength(2); + + unsubB(); + }); +}); + +describe('audio:progress throttling (live-emit guard)', () => { + it('drops a second event that fires within the time threshold and below the delta threshold', () => { + const track = makeTrack({ duration: 100 }); + usePlayerStore.setState({ currentTrack: track, isPlaying: true }); + + const calls: PlaybackProgressSnapshot[] = []; + const unsub = subscribePlaybackProgress(next => calls.push(next)); + + emitTauriEvent('audio:progress', { current_time: 5, duration: 100 }); + // Δt only 50 ms (< 1500 ms), Δs only 0.5 (< 0.9 s) — second event dropped. + vi.advanceTimersByTime(50); + emitTauriEvent('audio:progress', { current_time: 5.5, duration: 100 }); + + expect(calls).toHaveLength(1); + expect(calls[0]?.currentTime).toBeCloseTo(5, 3); + unsub(); + }); + + it('lets through a second event when the position delta is large enough (≥ 0.9 s)', () => { + const track = makeTrack({ duration: 100 }); + usePlayerStore.setState({ currentTrack: track, isPlaying: true }); + + const calls: PlaybackProgressSnapshot[] = []; + const unsub = subscribePlaybackProgress(next => calls.push(next)); + + emitTauriEvent('audio:progress', { current_time: 5, duration: 100 }); + vi.advanceTimersByTime(50); + // Δs = 1.0 ≥ 0.9 → emit passes the live-emit gate even though Δt is small. + emitTauriEvent('audio:progress', { current_time: 6.0, duration: 100 }); + + expect(calls).toHaveLength(2); + unsub(); + }); + + it('lets through a second event when enough time has passed (≥ 1500 ms)', () => { + const track = makeTrack({ duration: 100 }); + usePlayerStore.setState({ currentTrack: track, isPlaying: true }); + + const calls: PlaybackProgressSnapshot[] = []; + const unsub = subscribePlaybackProgress(next => calls.push(next)); + + emitTauriEvent('audio:progress', { current_time: 5, duration: 100 }); + vi.advanceTimersByTime(1600); // ≥ LIVE_PROGRESS_EMIT_MIN_MS + emitTauriEvent('audio:progress', { current_time: 5.05, duration: 100 }); + + expect(calls).toHaveLength(2); + unsub(); + }); +});