Files
Psychotoxical-psysonic/src/store/previewStore.test.ts
T
Frank Stellmacher 6e646351ee test(previewStore): startPreview + main-player volume sync (Phase F4) (#545)
Adds 22 new tests on top of the existing 7 _on* / stopPreview ones.

startPreview happy path: invokes audio_preview_play with the configured
args (id, url, durationSec, startSec, volume) and stores the previewing
track + duration + reset elapsed / audioStarted. Short tracks
(duration <= previewDuration * 1.5) start at 0; longer tracks seek to
duration * trackPreviewStartRatio. Camel-case IPC keys pinned
(startSec / durationSec, not snake_case -- CLAUDE.md gotcha).

Cross-store guard tests: no-op when previews globally disabled, no-op
when disabled at the calling location, no-op while a host or guest is
inside any Orbit phase (active / joining / starting), falls through to
play when role is null (no session).

Same-id re-click: treats it as a stop -- audio_preview_stop fires,
audio_preview_play does not.

Failure path: engine invoke rejects -> store state rolls back
(previewingId / previewingTrack / audioStarted) and the error propagates
to the caller.

Loudness pre-attenuation folding: with normalizationEngine=loudness +
loudnessPreAnalysisAttenuationDb=-6 dB, volume is multiplied by
10^(-6/20). normalizationEngine=off keeps volume verbatim. Positive
pre-attenuation values are pulled to 0 by the Math.min(0, ...) guard.

Main-player volume sync side-effect (module-level
usePlayerStore.subscribe): pings audio_preview_set_volume when volume
changes during a preview, skips when no preview is active, skips when
the new value equals the prior value (subscription guard).

previewStore.ts coverage 33% -> 100% lines. Added to the hot-path gate.
Plus the typed `OrbitRole` is `'host' | 'guest'` (null when no session),
not 'idle' as a string -- minor type-correctness alignment.
2026-05-11 22:57:26 +02:00

367 lines
13 KiB
TypeScript

/**
* Characterization tests for `previewStore`.
*
* Phases F0 (bootstrap, _on* handlers + stopPreview) + F4 (startPreview
* cross-store reads + failure paths + main-playback volume sync).
*
* Drives the store through its public action surface with the real
* Zustand instance, stubs the Tauri commands via `onInvoke`, and uses
* `vi.mock('@/api/subsonic')` because `startPreview` calls `buildStreamUrl`.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
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),
}));
import { usePreviewStore } from './previewStore';
import { useAuthStore } from './authStore';
import { useOrbitStore } from './orbitStore';
import { usePlayerStore } from './playerStore';
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
import { resetAuthStore, resetPreviewStore, resetPlayerStore, resetOrbitStore } from '@/test/helpers/storeReset';
function resetStore() {
usePreviewStore.setState({
previewingId: null,
previewingTrack: null,
elapsed: 0,
duration: 30,
audioStarted: false,
});
}
describe('previewStore — event handlers', () => {
beforeEach(resetStore);
describe('_onStart', () => {
it('flips audioStarted to true when the id matches the active preview', () => {
usePreviewStore.setState({
previewingId: 'song-1',
previewingTrack: { id: 'song-1', title: 't', artist: 'a' },
});
usePreviewStore.getState()._onStart('song-1');
expect(usePreviewStore.getState().audioStarted).toBe(true);
expect(usePreviewStore.getState().previewingId).toBe('song-1');
});
it('takes over the previewingId when the engine fires start for an unknown id', () => {
usePreviewStore.setState({ previewingId: null });
usePreviewStore.getState()._onStart('song-99');
const state = usePreviewStore.getState();
expect(state.previewingId).toBe('song-99');
expect(state.elapsed).toBe(0);
expect(state.audioStarted).toBe(true);
});
});
describe('_onProgress', () => {
it('updates elapsed + duration when the id matches', () => {
usePreviewStore.setState({ previewingId: 'song-1' });
usePreviewStore.getState()._onProgress('song-1', 12.5, 30);
const state = usePreviewStore.getState();
expect(state.elapsed).toBe(12.5);
expect(state.duration).toBe(30);
});
it('ignores progress for a stale id', () => {
usePreviewStore.setState({ previewingId: 'song-1', elapsed: 5 });
usePreviewStore.getState()._onProgress('song-stale', 99, 30);
expect(usePreviewStore.getState().elapsed).toBe(5);
});
});
describe('_onEnd', () => {
it('clears state when the id matches', () => {
usePreviewStore.setState({
previewingId: 'song-1',
previewingTrack: { id: 'song-1', title: 't', artist: 'a' },
elapsed: 27,
audioStarted: true,
});
usePreviewStore.getState()._onEnd('song-1');
const state = usePreviewStore.getState();
expect(state.previewingId).toBeNull();
expect(state.previewingTrack).toBeNull();
expect(state.elapsed).toBe(0);
expect(state.audioStarted).toBe(false);
});
it('ignores end events for a stale id', () => {
usePreviewStore.setState({ previewingId: 'song-1', elapsed: 5, audioStarted: true });
usePreviewStore.getState()._onEnd('song-stale');
expect(usePreviewStore.getState().previewingId).toBe('song-1');
expect(usePreviewStore.getState().audioStarted).toBe(true);
});
});
});
describe('previewStore — stopPreview', () => {
beforeEach(resetStore);
it('returns early without invoking when no preview is active', async () => {
await usePreviewStore.getState().stopPreview();
expect(invokeMock).not.toHaveBeenCalled();
});
it('invokes audio_preview_stop when a preview is active', async () => {
usePreviewStore.setState({ previewingId: 'song-1' });
onInvoke('audio_preview_stop', () => undefined);
await usePreviewStore.getState().stopPreview();
expect(invokeMock).toHaveBeenCalledWith('audio_preview_stop');
});
it('falls back to clearing state locally if invoke rejects', async () => {
usePreviewStore.setState({
previewingId: 'song-1',
previewingTrack: { id: 'song-1', title: 't', artist: 'a' },
audioStarted: true,
});
onInvoke('audio_preview_stop', () => {
throw new Error('engine offline');
});
await usePreviewStore.getState().stopPreview();
const state = usePreviewStore.getState();
expect(state.previewingId).toBeNull();
expect(state.previewingTrack).toBeNull();
expect(state.audioStarted).toBe(false);
});
});
describe('previewStore — startPreview', () => {
beforeEach(() => {
resetPreviewStore();
resetAuthStore();
resetOrbitStore();
resetPlayerStore();
onInvoke('audio_preview_play', () => undefined);
onInvoke('audio_preview_stop', () => undefined);
onInvoke('audio_preview_set_volume', () => undefined);
});
const song = (id = 'song-1') => ({
id,
title: `Title ${id}`,
artist: 'Artist',
coverArt: id,
duration: 240,
});
it('invokes audio_preview_play with the configured args and stores the previewing track', async () => {
await usePreviewStore.getState().startPreview(song('song-1'), 'suggestions');
expect(invokeMock).toHaveBeenCalledWith(
'audio_preview_play',
expect.objectContaining({
id: 'song-1',
url: expect.stringContaining('song-1'),
durationSec: 30,
startSec: 240 * 0.33,
}),
);
const state = usePreviewStore.getState();
expect(state.previewingId).toBe('song-1');
expect(state.previewingTrack).toEqual({
id: 'song-1', title: 'Title song-1', artist: 'Artist', coverArt: 'song-1',
});
expect(state.elapsed).toBe(0);
expect(state.audioStarted).toBe(false);
expect(state.duration).toBe(30);
});
it('starts at 0 when the track is too short to need a mid-track seek', async () => {
// duration <= previewDuration * 1.5 → start at 0.
await usePreviewStore.getState().startPreview({ ...song(), duration: 30 }, 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
expect(call?.[1]).toEqual(expect.objectContaining({ startSec: 0 }));
});
it('passes camelCase keys (Tauri IPC contract — snake_case silently drops to undefined)', async () => {
await usePreviewStore.getState().startPreview(song(), 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
const args = call?.[1] as Record<string, unknown>;
expect(args).toHaveProperty('startSec');
expect(args).toHaveProperty('durationSec');
expect(args).not.toHaveProperty('start_sec');
expect(args).not.toHaveProperty('duration_sec');
});
it('no-ops when previews are globally disabled', async () => {
useAuthStore.setState({ trackPreviewsEnabled: false });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
expect(usePreviewStore.getState().previewingId).toBeNull();
});
it('no-ops when previews are disabled at the calling location', async () => {
useAuthStore.setState({
trackPreviewLocations: {
suggestions: false,
albums: true, playlists: true, favorites: true, artist: true, randomMix: true,
},
});
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
});
it.each(['active', 'joining', 'starting'] as const)(
'no-ops while the user is a host inside an Orbit %s phase',
async (phase) => {
useOrbitStore.setState({ role: 'host', phase });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
},
);
it.each(['active', 'joining', 'starting'] as const)(
'no-ops while the user is a guest inside an Orbit %s phase',
async (phase) => {
useOrbitStore.setState({ role: 'guest', phase });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
},
);
it('falls through to startPreview when no orbit session is active (role=null)', async () => {
useOrbitStore.setState({ role: null, phase: 'idle' });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
expect(invokeMock).toHaveBeenCalledWith('audio_preview_play', expect.anything());
});
it('treats re-clicking the active preview id as a stop', async () => {
usePreviewStore.setState({ previewingId: 'song-1' });
await usePreviewStore.getState().startPreview(song('song-1'), 'suggestions');
// Goes through stopPreview, not audio_preview_play.
expect(invokeMock).toHaveBeenCalledWith('audio_preview_stop');
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_play', expect.anything());
});
it('rolls back optimistic state when the engine invoke rejects', async () => {
usePreviewStore.setState({
previewingId: 'older',
previewingTrack: { id: 'older', title: 'x', artist: 'y' },
audioStarted: true,
});
onInvoke('audio_preview_play', () => {
throw new Error('engine offline');
});
await expect(usePreviewStore.getState().startPreview(song('song-2'), 'suggestions')).rejects.toThrow(/engine offline/);
// Only rolls back when the rolled-back id is still the optimistic one.
const state = usePreviewStore.getState();
expect(state.previewingId).toBeNull();
expect(state.previewingTrack).toBeNull();
expect(state.audioStarted).toBe(false);
});
it('folds in the loudness pre-attenuation when normalization=loudness', async () => {
useAuthStore.setState({
normalizationEngine: 'loudness',
loudnessPreAnalysisAttenuationDb: -6,
});
usePlayerStore.setState({ volume: 1.0 });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
const args = call?.[1] as { volume: number };
// 1.0 * 10^(-6/20) ≈ 0.501 — clamped to [0, 1].
expect(args.volume).toBeCloseTo(Math.pow(10, -6 / 20), 4);
});
it('does NOT fold pre-attenuation when normalizationEngine is off', async () => {
useAuthStore.setState({ normalizationEngine: 'off' });
usePlayerStore.setState({ volume: 0.7 });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
const args = call?.[1] as { volume: number };
expect(args.volume).toBeCloseTo(0.7, 5);
});
it('does NOT fold a positive pre-attenuation value (Math.min(0, …) guard)', async () => {
useAuthStore.setState({
normalizationEngine: 'loudness',
loudnessPreAnalysisAttenuationDb: 3, // positive — guard pulls to 0
});
usePlayerStore.setState({ volume: 0.5 });
await usePreviewStore.getState().startPreview(song(), 'suggestions');
const call = invokeMock.mock.calls.find(c => c[0] === 'audio_preview_play');
const args = call?.[1] as { volume: number };
expect(args.volume).toBeCloseTo(0.5, 5);
});
});
describe('previewStore — main-player volume sync during preview', () => {
beforeEach(() => {
resetPreviewStore();
resetAuthStore();
resetPlayerStore();
onInvoke('audio_preview_set_volume', () => undefined);
invokeMock.mockClear();
});
it('pings the engine when the main player volume changes mid-preview', () => {
usePreviewStore.setState({ previewingId: 'song-1' });
usePlayerStore.setState({ volume: 0.5 });
usePlayerStore.setState({ volume: 0.8 });
expect(invokeMock).toHaveBeenCalledWith(
'audio_preview_set_volume',
expect.objectContaining({ volume: 0.8 }),
);
});
it('does NOT ping the engine when no preview is active', () => {
usePreviewStore.setState({ previewingId: null });
usePlayerStore.setState({ volume: 0.5 });
usePlayerStore.setState({ volume: 0.8 });
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_set_volume', expect.anything());
});
it('does NOT ping when the volume value did not actually change', () => {
usePreviewStore.setState({ previewingId: 'song-1' });
usePlayerStore.setState({ volume: 0.5 });
invokeMock.mockClear();
// Setting to the same value should be skipped by the subscription guard.
usePlayerStore.setState({ volume: 0.5 });
expect(invokeMock).not.toHaveBeenCalledWith('audio_preview_set_volume', expect.anything());
});
});