mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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.
This commit is contained in:
committed by
GitHub
parent
4a18e15489
commit
8569a17797
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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<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),
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user