mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
test(playerStore): characterize pure helpers + queue mutations (Phase F1 / PR 2a) (#540)
trackShape.test.ts: songToTrack mapping (required, optional, replayGain flattening, missing-block fallback, no invented flags); resolveReplayGainDb precedence (disabled, track, album, auto with prev/next neighbour match, missing albumId fallback, both gains missing); shuffleArray (non-mutating, preserves multiset, copies, empty/single, deterministic under mocked RNG). queue.test.ts: enqueue / enqueueAt (auto-added separator placement, queueIndex shift, index clamping), playNext (playNextAdded tag, position, empty input), clearQueue (state reset, audio_stop call), reorderQueue (splice, currentTrack-id-following queueIndex), removeTrack (clamp on shrink, keep when after cursor), undo / redo (empty stacks return false, rollback, replay, new edit drops pending redo). Adds a 5-line test-only export _resetQueueUndoStacksForTest in playerStore.ts because the undo/redo arrays live at module scope outside the Zustand state graph; storeReset.ts now calls it so resetPlayerStore is fully complete. playerStore.ts coverage 4.76% -> 18.1% lines. PR 2b + 2c push further toward the F1 floor of 50%.
This commit is contained in:
committed by
GitHub
parent
4f9ad07d65
commit
42e3fdb976
@@ -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<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}`),
|
||||
};
|
||||
});
|
||||
|
||||
// `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);
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user