diff --git a/src/store/playerStore.queue.test.ts b/src/store/playerStore.queue.test.ts new file mode 100644 index 00000000..765c7c11 --- /dev/null +++ b/src/store/playerStore.queue.test.ts @@ -0,0 +1,282 @@ +/** + * Queue-mutation characterization for `playerStore` (Phase F1 / PR 2a). + * + * Covers public queue actions — `enqueue`, `enqueueAt`, `playNext`, + * `clearQueue`, `reorderQueue`, `removeTrack`, plus the undo/redo flow. + * The queue-undo stack lives at module scope (outside the Zustand state), + * so each test drains it before exercising — see `resetForQueueTest`. + * + * Playback actions (`play`, `pause`, `seek`, `next`, `previous`, repeat / + * shuffle modes) live in PR 2b. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// `playerStore` pulls `savePlayQueue` from `@/api/subsonic`, which talks to a +// real server. Override only what the queue path touches; everything else +// stays as the actual module so unrelated imports don't break. +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}`), + }; +}); + +// `enqueue` / `enqueueAt` call `orbitBulkGuard` for multi-track inserts when +// the caller hasn't pre-confirmed. Force the guard to short-circuit through. +vi.mock('@/utils/orbitBulkGuard', () => ({ + orbitBulkGuard: vi.fn(async () => true), +})); + +import { usePlayerStore } from './playerStore'; +import { onInvoke } from '@/test/mocks/tauri'; +import { resetPlayerStore } from '@/test/helpers/storeReset'; +import { makeTrack, makeTracks } from '@/test/helpers/factories'; + +beforeEach(() => { + resetPlayerStore(); + // `clearQueue` fires `invoke('audio_stop')`; every queue mutation triggers a + // debounced `syncQueueToServer` we don't need to advance. + onInvoke('audio_stop', () => undefined); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('enqueue', () => { + it('appends a single track to an empty queue', () => { + const t1 = makeTrack({ id: 't1' }); + usePlayerStore.getState().enqueue([t1], true); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['t1']); + }); + + it('appends multiple tracks in order', () => { + const tracks = makeTracks(3); + usePlayerStore.getState().enqueue(tracks, true); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(tracks.map(t => t.id)); + }); + + it('inserts before the first upcoming auto-added separator', () => { + const head = makeTrack({ id: 'head' }); + const auto = makeTrack({ id: 'auto', autoAdded: true }); + const tail = makeTrack({ id: 'tail', autoAdded: true }); + usePlayerStore.setState({ queue: [head, auto, tail], queueIndex: 0 }); + const incoming = makeTrack({ id: 'new' }); + usePlayerStore.getState().enqueue([incoming], true); + // Insert lands between `head` (the currently-playing one) and the first + // auto-added track, so the auto-added group stays at the tail. + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['head', 'new', 'auto', 'tail']); + }); + + it('appends at the end when there are no auto-added tracks after the cursor', () => { + const head = makeTrack({ id: 'head' }); + const mid = makeTrack({ id: 'mid' }); + usePlayerStore.setState({ queue: [head, mid], queueIndex: 0 }); + usePlayerStore.getState().enqueue([makeTrack({ id: 'tail' })], true); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['head', 'mid', 'tail']); + }); + + it('ignores auto-added separators that already passed (behind the cursor)', () => { + const past = makeTrack({ id: 'past', autoAdded: true }); + const current = makeTrack({ id: 'current' }); + usePlayerStore.setState({ queue: [past, current], queueIndex: 1 }); + usePlayerStore.getState().enqueue([makeTrack({ id: 'new' })], true); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['past', 'current', 'new']); + }); +}); + +describe('enqueueAt', () => { + it('inserts at the given index', () => { + const queue = makeTracks(3); + usePlayerStore.setState({ queue, queueIndex: 0 }); + const ins = makeTrack({ id: 'ins' }); + usePlayerStore.getState().enqueueAt([ins], 2, true); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([queue[0].id, queue[1].id, 'ins', queue[2].id]); + }); + + it('shifts queueIndex forward when inserting at or before the cursor', () => { + const queue = makeTracks(3); + usePlayerStore.setState({ queue, queueIndex: 2 }); + usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' }), makeTrack({ id: 'b' })], 1, true); + // Two tracks inserted at idx 1 → cursor (was 2) moves to 4. + expect(usePlayerStore.getState().queueIndex).toBe(4); + }); + + it('keeps queueIndex when inserting after the cursor', () => { + const queue = makeTracks(3); + usePlayerStore.setState({ queue, queueIndex: 1 }); + usePlayerStore.getState().enqueueAt([makeTrack({ id: 'a' })], 3, true); + expect(usePlayerStore.getState().queueIndex).toBe(1); + }); + + it('clamps a negative insertIndex to 0', () => { + const queue = makeTracks(2); + usePlayerStore.setState({ queue, queueIndex: 0 }); + usePlayerStore.getState().enqueueAt([makeTrack({ id: 'front' })], -5, true); + expect(usePlayerStore.getState().queue[0].id).toBe('front'); + }); + + it('clamps an over-large insertIndex to the queue length', () => { + const queue = makeTracks(2); + usePlayerStore.setState({ queue, queueIndex: 0 }); + usePlayerStore.getState().enqueueAt([makeTrack({ id: 'back' })], 99, true); + const q = usePlayerStore.getState().queue; + expect(q[q.length - 1].id).toBe('back'); + }); +}); + +describe('playNext', () => { + it('tags inserted tracks with playNextAdded', () => { + const queue = makeTracks(2); + usePlayerStore.setState({ queue, queueIndex: 0, currentTrack: queue[0] }); + usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]); + const inserted = usePlayerStore.getState().queue.find(t => t.id === 'pn'); + expect(inserted?.playNextAdded).toBe(true); + }); + + it('inserts immediately after the current track', () => { + const a = makeTrack({ id: 'a' }); + const b = makeTrack({ id: 'b' }); + usePlayerStore.setState({ queue: [a, b], queueIndex: 0, currentTrack: a }); + usePlayerStore.getState().playNext([makeTrack({ id: 'pn' })]); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(['a', 'pn', 'b']); + }); + + it('returns early on an empty input list', () => { + const queue = makeTracks(2); + usePlayerStore.setState({ queue, queueIndex: 0, currentTrack: queue[0] }); + usePlayerStore.getState().playNext([]); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(queue.map(t => t.id)); + }); +}); + +describe('clearQueue', () => { + it('empties the queue and resets playback bookkeeping', () => { + const tracks = makeTracks(3); + usePlayerStore.setState({ + queue: tracks, + queueIndex: 1, + currentTrack: tracks[1], + isPlaying: true, + progress: 0.5, + currentTime: 42, + buffered: 0.8, + }); + usePlayerStore.getState().clearQueue(); + const s = usePlayerStore.getState(); + expect(s.queue).toEqual([]); + expect(s.queueIndex).toBe(0); + expect(s.currentTrack).toBeNull(); + expect(s.isPlaying).toBe(false); + expect(s.progress).toBe(0); + expect(s.currentTime).toBe(0); + expect(s.buffered).toBe(0); + }); + + it('calls audio_stop on the engine', () => { + const stop = vi.fn(() => undefined); + onInvoke('audio_stop', stop); + usePlayerStore.setState({ queue: makeTracks(2), queueIndex: 0 }); + usePlayerStore.getState().clearQueue(); + expect(stop).toHaveBeenCalled(); + }); +}); + +describe('reorderQueue', () => { + it('moves a track from startIndex to endIndex', () => { + const [a, b, c, d] = makeTracks(4); + usePlayerStore.setState({ queue: [a, b, c, d], queueIndex: 0 }); + usePlayerStore.getState().reorderQueue(1, 3); // b → after d + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([a.id, c.id, d.id, b.id]); + }); + + it('preserves queueIndex by following the current track id, not the slot', () => { + const [a, b, c] = makeTracks(3); + usePlayerStore.setState({ queue: [a, b, c], queueIndex: 1, currentTrack: b }); + usePlayerStore.getState().reorderQueue(1, 2); // b moves to the end + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([a.id, c.id, b.id]); + expect(usePlayerStore.getState().queueIndex).toBe(2); // followed `b` + }); + + it('keeps queueIndex when the current track is unaffected by the move', () => { + const [a, b, c] = makeTracks(3); + usePlayerStore.setState({ queue: [a, b, c], queueIndex: 1, currentTrack: b }); + usePlayerStore.getState().reorderQueue(0, 2); // a moves to the end + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([b.id, c.id, a.id]); + expect(usePlayerStore.getState().queueIndex).toBe(0); // followed `b` + }); +}); + +describe('removeTrack', () => { + it('removes the track at the given index', () => { + const tracks = makeTracks(3); + usePlayerStore.setState({ queue: tracks, queueIndex: 0 }); + usePlayerStore.getState().removeTrack(1); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([tracks[0].id, tracks[2].id]); + }); + + it('clamps queueIndex when the removal makes the queue shorter than the cursor', () => { + const tracks = makeTracks(3); + usePlayerStore.setState({ queue: tracks, queueIndex: 2 }); + usePlayerStore.getState().removeTrack(2); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([tracks[0].id, tracks[1].id]); + expect(usePlayerStore.getState().queueIndex).toBe(1); + }); + + it('keeps queueIndex when removing a track after the cursor', () => { + const tracks = makeTracks(4); + usePlayerStore.setState({ queue: tracks, queueIndex: 1 }); + usePlayerStore.getState().removeTrack(3); + expect(usePlayerStore.getState().queueIndex).toBe(1); + }); +}); + +describe('undo / redo', () => { + it('returns false on undo when the history is empty', () => { + expect(usePlayerStore.getState().undoLastQueueEdit()).toBe(false); + }); + + it('returns false on redo when the redo stack is empty', () => { + expect(usePlayerStore.getState().redoLastQueueEdit()).toBe(false); + }); + + it('rolls back the most recent destructive edit', () => { + const seed = makeTracks(2); + usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] }); + usePlayerStore.getState().enqueue([makeTrack({ id: 'add' })], true); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id, 'add']); + + const undone = usePlayerStore.getState().undoLastQueueEdit(); + expect(undone).toBe(true); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id]); + }); + + it('replays the undone edit via redo', () => { + const seed = makeTracks(2); + usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] }); + usePlayerStore.getState().removeTrack(1); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id]); + + usePlayerStore.getState().undoLastQueueEdit(); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id, seed[1].id]); + + usePlayerStore.getState().redoLastQueueEdit(); + expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual([seed[0].id]); + }); + + it('a new edit drops any pending redo (Word-style history)', () => { + const seed = makeTracks(3); + usePlayerStore.setState({ queue: seed, queueIndex: 0, currentTrack: seed[0] }); + + usePlayerStore.getState().removeTrack(2); // edit A: [s0, s1] + usePlayerStore.getState().undoLastQueueEdit(); // [s0, s1, s2] + expect(usePlayerStore.getState().queue).toHaveLength(3); + + usePlayerStore.getState().removeTrack(1); // edit B drops the pending redo + + expect(usePlayerStore.getState().redoLastQueueEdit()).toBe(false); + }); +}); diff --git a/src/store/playerStore.trackShape.test.ts b/src/store/playerStore.trackShape.test.ts new file mode 100644 index 00000000..ba6f205c --- /dev/null +++ b/src/store/playerStore.trackShape.test.ts @@ -0,0 +1,205 @@ +/** + * Pure-helper characterization for `playerStore` mapping + utility functions. + * + * Scope: `songToTrack`, `resolveReplayGainDb`, `shuffleArray`. No store + * mutation — these are exported pure functions used by enqueue paths and the + * server-queue restore. + * + * Pinned as part of Phase F1 / PR 2a (pre-refactor testing plan, 2026-05-11). + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resolveReplayGainDb, shuffleArray, songToTrack, type Track } from './playerStore'; +import { makeSubsonicSong } from '@/test/helpers/factories'; + +describe('songToTrack', () => { + it('maps required Subsonic fields verbatim', () => { + const song = makeSubsonicSong({ + id: 's1', + title: 'Hello', + artist: 'World', + album: 'Sample', + albumId: 'a1', + duration: 240, + }); + const t = songToTrack(song); + expect(t.id).toBe('s1'); + expect(t.title).toBe('Hello'); + expect(t.artist).toBe('World'); + expect(t.album).toBe('Sample'); + expect(t.albumId).toBe('a1'); + expect(t.duration).toBe(240); + }); + + it('copies optional fields when present', () => { + const song = makeSubsonicSong({ + id: 's2', + artistId: 'ar-1', + track: 5, + year: 2024, + bitRate: 320, + suffix: 'flac', + userRating: 4, + starred: '2026-05-01T00:00:00Z', + genre: 'Rock', + samplingRate: 48000, + bitDepth: 24, + size: 9_000_000, + coverArt: 's2', + }); + const t = songToTrack(song); + expect(t.artistId).toBe('ar-1'); + expect(t.track).toBe(5); + expect(t.year).toBe(2024); + expect(t.bitRate).toBe(320); + expect(t.suffix).toBe('flac'); + expect(t.userRating).toBe(4); + expect(t.starred).toBe('2026-05-01T00:00:00Z'); + expect(t.genre).toBe('Rock'); + expect(t.samplingRate).toBe(48000); + expect(t.bitDepth).toBe(24); + expect(t.size).toBe(9_000_000); + expect(t.coverArt).toBe('s2'); + }); + + it('flattens replayGain into replayGainTrackDb / AlbumDb / Peak', () => { + const song = makeSubsonicSong({ + replayGain: { + trackGain: -6.5, + albumGain: -7.1, + trackPeak: 0.98, + albumPeak: 0.99, + }, + }); + const t = songToTrack(song); + expect(t.replayGainTrackDb).toBe(-6.5); + expect(t.replayGainAlbumDb).toBe(-7.1); + expect(t.replayGainPeak).toBe(0.98); + // albumPeak is intentionally not surfaced — only trackPeak. + expect((t as Track & { replayGainAlbumPeak?: number }).replayGainAlbumPeak).toBeUndefined(); + }); + + it('leaves replayGain fields undefined when the song has no replayGain block', () => { + const song = makeSubsonicSong({ replayGain: undefined }); + const t = songToTrack(song); + expect(t.replayGainTrackDb).toBeUndefined(); + expect(t.replayGainAlbumDb).toBeUndefined(); + expect(t.replayGainPeak).toBeUndefined(); + }); + + it('does not invent fields that the Subsonic song lacks', () => { + const song = makeSubsonicSong({}); + const t = songToTrack(song); + // Internal queue-routing flags are added by enqueue paths, not by mapping. + expect(t.autoAdded).toBeUndefined(); + expect(t.radioAdded).toBeUndefined(); + expect(t.playNextAdded).toBeUndefined(); + }); +}); + +describe('resolveReplayGainDb', () => { + const t = (overrides: Partial = {}): Track => ({ + id: 'x', + title: 'x', + artist: 'x', + album: 'a', + albumId: 'a-1', + duration: 100, + ...overrides, + }); + + it('returns null when ReplayGain is disabled', () => { + const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 }); + expect(resolveReplayGainDb(track, null, null, false, 'track')).toBeNull(); + expect(resolveReplayGainDb(track, null, null, false, 'album')).toBeNull(); + expect(resolveReplayGainDb(track, null, null, false, 'auto')).toBeNull(); + }); + + it('mode=track uses the track gain', () => { + const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 }); + expect(resolveReplayGainDb(track, null, null, true, 'track')).toBe(-6); + }); + + it('mode=album uses the album gain when present', () => { + const track = t({ replayGainTrackDb: -6, replayGainAlbumDb: -7 }); + expect(resolveReplayGainDb(track, null, null, true, 'album')).toBe(-7); + }); + + it('mode=album falls back to track gain when album is missing', () => { + const track = t({ replayGainTrackDb: -6 }); + expect(resolveReplayGainDb(track, null, null, true, 'album')).toBe(-6); + }); + + it('mode=auto picks album gain when the prev neighbour shares the albumId', () => { + const track = t({ albumId: 'shared', replayGainTrackDb: -6, replayGainAlbumDb: -8 }); + const prev = t({ albumId: 'shared' }); + expect(resolveReplayGainDb(track, prev, null, true, 'auto')).toBe(-8); + }); + + it('mode=auto picks album gain when the next neighbour shares the albumId', () => { + const track = t({ albumId: 'shared', replayGainTrackDb: -6, replayGainAlbumDb: -8 }); + const next = t({ albumId: 'shared' }); + expect(resolveReplayGainDb(track, null, next, true, 'auto')).toBe(-8); + }); + + it('mode=auto picks track gain when neither neighbour shares the albumId', () => { + const track = t({ albumId: 'a-1', replayGainTrackDb: -6, replayGainAlbumDb: -8 }); + const other = t({ albumId: 'a-2' }); + expect(resolveReplayGainDb(track, other, other, true, 'auto')).toBe(-6); + }); + + it('mode=auto treats a missing albumId as no-album-match (returns track gain)', () => { + const track = t({ albumId: '', replayGainTrackDb: -6, replayGainAlbumDb: -8 } as Track); + const prev = t({ albumId: '' } as Track); + expect(resolveReplayGainDb(track, prev, null, true, 'auto')).toBe(-6); + }); + + it('returns null when both gains are missing', () => { + const track = t({}); + expect(resolveReplayGainDb(track, null, null, true, 'track')).toBeNull(); + expect(resolveReplayGainDb(track, null, null, true, 'album')).toBeNull(); + expect(resolveReplayGainDb(track, null, null, true, 'auto')).toBeNull(); + }); +}); + +describe('shuffleArray', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not mutate the input array', () => { + const input = [1, 2, 3, 4, 5]; + const snapshot = [...input]; + shuffleArray(input); + expect(input).toEqual(snapshot); + }); + + it('preserves the multiset of elements (same length, same members)', () => { + const input = ['a', 'b', 'c', 'd', 'e']; + const out = shuffleArray(input); + expect(out).toHaveLength(input.length); + expect([...out].sort()).toEqual([...input].sort()); + }); + + it('returns a copy (not the same reference)', () => { + const input = [1, 2, 3]; + expect(shuffleArray(input)).not.toBe(input); + }); + + it('returns an empty array when called with an empty array', () => { + expect(shuffleArray([])).toEqual([]); + }); + + it('returns the single-element input unchanged', () => { + expect(shuffleArray(['only'])).toEqual(['only']); + }); + + it('produces a deterministic order under a mocked RNG (Math.random=0 picks j=0 each iteration)', () => { + vi.spyOn(Math, 'random').mockReturnValue(0); + // With Math.random()=0, j=floor(0 * (i+1))=0 for every i. The Fisher-Yates + // step swaps arr[i] with arr[0]. Walk it through for [1,2,3,4]: + // i=3: swap(3,0) → [4,2,3,1] + // i=2: swap(2,0) → [3,2,4,1] + // i=1: swap(1,0) → [2,3,4,1] + expect(shuffleArray([1, 2, 3, 4])).toEqual([2, 3, 4, 1]); + }); +}); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 6f839ca6..dc835696 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -457,6 +457,12 @@ type QueueUndoSnapshot = { const queueUndoStack: QueueUndoSnapshot[] = []; const queueRedoStack: QueueUndoSnapshot[] = []; +/** Test-only: clears module-scoped undo/redo stacks so each test starts clean. */ +export function _resetQueueUndoStacksForTest(): void { + queueUndoStack.length = 0; + queueRedoStack.length = 0; +} + /** QueuePanel registers a reader so undo snapshots capture list scroll position. */ let queueListScrollTopReader: (() => number | undefined) | null = null; diff --git a/src/test/helpers/storeReset.ts b/src/test/helpers/storeReset.ts index 1bbf75cd..eacaaec7 100644 --- a/src/test/helpers/storeReset.ts +++ b/src/test/helpers/storeReset.ts @@ -20,7 +20,7 @@ * // or for cross-store tests: * beforeEach(resetAllStores); */ -import { usePlayerStore } from '@/store/playerStore'; +import { usePlayerStore, _resetQueueUndoStacksForTest } from '@/store/playerStore'; import { useAuthStore } from '@/store/authStore'; import { usePreviewStore } from '@/store/previewStore'; import { useOrbitStore } from '@/store/orbitStore'; @@ -32,6 +32,8 @@ const INITIAL_ORBIT_STATE = useOrbitStore.getState(); export function resetPlayerStore(): void { usePlayerStore.setState(INITIAL_PLAYER_STATE, true); + // Module-scoped queue undo/redo stacks live outside the Zustand state. + _resetQueueUndoStacksForTest(); } export function resetAuthStore(): void {