diff --git a/.github/frontend-hot-path-files.txt b/.github/frontend-hot-path-files.txt index b5a321a2..fef58350 100644 --- a/.github/frontend-hot-path-files.txt +++ b/.github/frontend-hot-path-files.txt @@ -19,7 +19,6 @@ # work lands. # # Deferred from the gate, with current coverage shown for reference: -# - src/store/previewStore.ts (33 % — only `_on*` + `stopPreview` covered, Phase 4 target) # - src/store/playerStore.ts (40 % — F1 closed under the 50 % floor; further coverage TBD) # - src/store/authStore.ts (79 % — F2 cleared 60 % floor; staying out one or two PRs to verify stability) # - src/api/subsonic.ts (13 % — F3 covered the URL-builder + parser surface; async API endpoints need axios mocking, deferred) @@ -32,3 +31,6 @@ src/utils/shareLink.ts src/utils/dynamicColors.ts src/utils/resolvePlaybackUrl.ts src/utils/copyEntityShareLink.ts + +# ── stores (added as their tests grew past the floor) ──────────────── +src/store/previewStore.ts diff --git a/src/store/previewStore.test.ts b/src/store/previewStore.test.ts index 1d5eae4f..034b743e 100644 --- a/src/store/previewStore.test.ts +++ b/src/store/previewStore.test.ts @@ -1,19 +1,37 @@ /** * Characterization tests for `previewStore`. * - * Pattern demonstration: drives the store through its public action surface - * with the real Zustand instance, and uses the `onInvoke` helper to stub the - * Tauri commands the actions call. Aims to lock current behaviour before - * playerStore-adjacent refactoring lands in Phase 2. + * Phases F0 (bootstrap, _on* handlers + stopPreview) + F4 (startPreview + * cross-store reads + failure paths + main-playback volume sync). * - * Scope here is intentionally narrow — the internal `_on*` event handlers - * plus `stopPreview`. `startPreview` adds dependencies on authStore / - * orbitStore and is covered in its own follow-up suite once we settle on a - * provider strategy for cross-store reads. + * 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 } from 'vitest'; +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({ @@ -138,3 +156,211 @@ describe('previewStore — stopPreview', () => { 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; + 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()); + }); +});