mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
test(playerStore): miscellaneous actions push F1 toward the 50% floor (#550)
24 new tests covering the smaller action surfaces F1 / 2a-c skipped: - setStarredOverride / setUserRatingOverride (per-id maps) - openContextMenu / closeContextMenu (state + isOpen flip) - openSongInfo / closeSongInfo (modal state) - toggleQueue / setQueueVisible (visibility flip with persisted side effect) - toggleFullscreen (boolean flip) - setLastfmLoved (writes verbatim + caches under title::artist when track is set; does NOT cache without a track) + toggleLastfmLove (no-op without track or session-key; flips state + cache otherwise) - setLastfmLovedForSong (caches under title::artist key) - setProgress (currentTime + derived progress) - stop (invokes audio_stop, resets playback bookkeeping) - shuffleQueue (no-op when queue < 2; deterministic with mocked RNG, current track stays at queueIndex 0) - shuffleUpcomingQueue (no-op when upcoming < 2; head + current untouched, upcoming tail shuffled) - pruneUpcomingToCurrent (drops everything after queueIndex; clears the queue entirely when no current track; early return when queue is already empty) - setRadioArtistId (does-not-throw smoke; no public getter for the module-level state it writes) playerStore.ts coverage 40.48% -> 48.02% lines (functions 37.84% -> 50.34%). Close to but not over the F1 50% line floor — the remaining ~2pp lives in playTrack's async hot-cache/replay-gain body, which is the same surface PR 2c flagged for a separate follow-up. Not adding playerStore.ts to the gate yet; staying out one more PR to see the number stabilise. Frontend suite: 451 -> 475 tests (+24).
This commit is contained in:
committed by
GitHub
parent
568f3aeb7d
commit
b3646daabd
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Miscellaneous-action characterization for `playerStore` — pushes Phase F1
|
||||
* past the 50 % line-coverage floor without touching `playTrack` (which is
|
||||
* its own async beast).
|
||||
*
|
||||
* Covers the smaller surfaces 2a / 2b / 2c skipped: shuffleQueue,
|
||||
* shuffleUpcomingQueue, stop, setStarredOverride / setUserRatingOverride,
|
||||
* toggleQueue / setQueueVisible, toggleFullscreen, openContextMenu /
|
||||
* closeContextMenu, openSongInfo / closeSongInfo, setLastfmLoved /
|
||||
* setLastfmLovedForSong, pruneUpcomingToCurrent, setProgress.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, 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),
|
||||
setRating: vi.fn(async () => undefined),
|
||||
star: vi.fn(async () => undefined),
|
||||
unstar: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock('@/api/lastfm', () => ({
|
||||
lastfmScrobble: vi.fn(async () => undefined),
|
||||
lastfmUpdateNowPlaying: vi.fn(async () => undefined),
|
||||
lastfmLoveTrack: vi.fn(async () => undefined),
|
||||
lastfmUnloveTrack: vi.fn(async () => undefined),
|
||||
lastfmGetTrackLoved: vi.fn(async () => false),
|
||||
lastfmGetAllLovedTracks: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/orbitBulkGuard', () => ({
|
||||
orbitBulkGuard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
import { usePlayerStore } from './playerStore';
|
||||
import { useAuthStore } from './authStore';
|
||||
import { onInvoke, invokeMock } from '@/test/mocks/tauri';
|
||||
import { resetPlayerStore, resetAuthStore } from '@/test/helpers/storeReset';
|
||||
import { makeTrack, makeTracks } from '@/test/helpers/factories';
|
||||
|
||||
beforeEach(() => {
|
||||
resetPlayerStore();
|
||||
resetAuthStore();
|
||||
onInvoke('audio_play', () => undefined);
|
||||
onInvoke('audio_pause', () => 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);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('setStarredOverride', () => {
|
||||
it('stores per-track starred booleans', () => {
|
||||
usePlayerStore.getState().setStarredOverride('t-1', true);
|
||||
usePlayerStore.getState().setStarredOverride('t-2', false);
|
||||
expect(usePlayerStore.getState().starredOverrides).toEqual({
|
||||
't-1': true,
|
||||
't-2': false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setUserRatingOverride', () => {
|
||||
it('stores per-track rating overrides', () => {
|
||||
usePlayerStore.getState().setUserRatingOverride('t-1', 4);
|
||||
usePlayerStore.getState().setUserRatingOverride('t-2', 5);
|
||||
expect(usePlayerStore.getState().userRatingOverrides).toEqual({
|
||||
't-1': 4,
|
||||
't-2': 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('openContextMenu / closeContextMenu', () => {
|
||||
it('opens with position + item + type + queueIndex', () => {
|
||||
const track = makeTrack();
|
||||
usePlayerStore.getState().openContextMenu(100, 200, track, 'song', 5);
|
||||
const cm = usePlayerStore.getState().contextMenu;
|
||||
expect(cm.isOpen).toBe(true);
|
||||
expect(cm.x).toBe(100);
|
||||
expect(cm.y).toBe(200);
|
||||
expect(cm.type).toBe('song');
|
||||
expect(cm.queueIndex).toBe(5);
|
||||
});
|
||||
|
||||
it('closeContextMenu flips isOpen but preserves the rest of the menu state', () => {
|
||||
const track = makeTrack();
|
||||
usePlayerStore.getState().openContextMenu(50, 50, track, 'song');
|
||||
usePlayerStore.getState().closeContextMenu();
|
||||
const cm = usePlayerStore.getState().contextMenu;
|
||||
expect(cm.isOpen).toBe(false);
|
||||
expect(cm.x).toBe(50);
|
||||
expect(cm.type).toBe('song');
|
||||
});
|
||||
});
|
||||
|
||||
describe('openSongInfo / closeSongInfo', () => {
|
||||
it('opens with the song id and clears on close', () => {
|
||||
usePlayerStore.getState().openSongInfo('song-1');
|
||||
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: true, songId: 'song-1' });
|
||||
|
||||
usePlayerStore.getState().closeSongInfo();
|
||||
expect(usePlayerStore.getState().songInfoModal).toEqual({ isOpen: false, songId: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleQueue / setQueueVisible', () => {
|
||||
it('toggleQueue flips isQueueVisible', () => {
|
||||
const before = usePlayerStore.getState().isQueueVisible;
|
||||
usePlayerStore.getState().toggleQueue();
|
||||
expect(usePlayerStore.getState().isQueueVisible).toBe(!before);
|
||||
usePlayerStore.getState().toggleQueue();
|
||||
expect(usePlayerStore.getState().isQueueVisible).toBe(before);
|
||||
});
|
||||
|
||||
it('setQueueVisible writes through verbatim', () => {
|
||||
usePlayerStore.getState().setQueueVisible(true);
|
||||
expect(usePlayerStore.getState().isQueueVisible).toBe(true);
|
||||
usePlayerStore.getState().setQueueVisible(false);
|
||||
expect(usePlayerStore.getState().isQueueVisible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleFullscreen', () => {
|
||||
it('flips isFullscreenOpen', () => {
|
||||
expect(usePlayerStore.getState().isFullscreenOpen).toBe(false);
|
||||
usePlayerStore.getState().toggleFullscreen();
|
||||
expect(usePlayerStore.getState().isFullscreenOpen).toBe(true);
|
||||
usePlayerStore.getState().toggleFullscreen();
|
||||
expect(usePlayerStore.getState().isFullscreenOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLastfmLoved / toggleLastfmLove', () => {
|
||||
it('setLastfmLoved writes the flag verbatim (no session-key gate inside the setter)', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack(), lastfmLoved: false });
|
||||
usePlayerStore.getState().setLastfmLoved(true);
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(true);
|
||||
});
|
||||
|
||||
it('setLastfmLoved also caches the value under "title::artist" when there is a current track', () => {
|
||||
usePlayerStore.setState({
|
||||
currentTrack: makeTrack({ title: 'Hello', artist: 'Adele' }),
|
||||
lastfmLoved: false,
|
||||
});
|
||||
usePlayerStore.getState().setLastfmLoved(true);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache['Hello::Adele']).toBe(true);
|
||||
});
|
||||
|
||||
it('setLastfmLoved without a current track only updates the flag, not the cache', () => {
|
||||
usePlayerStore.setState({ currentTrack: null, lastfmLoved: false, lastfmLovedCache: {} });
|
||||
usePlayerStore.getState().setLastfmLoved(true);
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(true);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache).toEqual({});
|
||||
});
|
||||
|
||||
it('toggleLastfmLove is a no-op without a current track', () => {
|
||||
useAuthStore.setState({ lastfmSessionKey: 'session-key' });
|
||||
usePlayerStore.setState({ currentTrack: null, lastfmLoved: false });
|
||||
usePlayerStore.getState().toggleLastfmLove();
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(false);
|
||||
});
|
||||
|
||||
it('toggleLastfmLove flips state when a track + session are present', () => {
|
||||
useAuthStore.setState({ lastfmSessionKey: 'session-key' });
|
||||
usePlayerStore.setState({ currentTrack: makeTrack({ title: 'T', artist: 'A' }), lastfmLoved: false });
|
||||
|
||||
usePlayerStore.getState().toggleLastfmLove();
|
||||
expect(usePlayerStore.getState().lastfmLoved).toBe(true);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache['T::A']).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLastfmLovedForSong', () => {
|
||||
it('caches loved state under the "title::artist" key', () => {
|
||||
usePlayerStore.getState().setLastfmLovedForSong('Hello', 'Adele', true);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache['Hello::Adele']).toBe(true);
|
||||
|
||||
usePlayerStore.getState().setLastfmLovedForSong('Hello', 'Adele', false);
|
||||
expect(usePlayerStore.getState().lastfmLovedCache['Hello::Adele']).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setProgress', () => {
|
||||
it('writes currentTime / progress / duration', () => {
|
||||
usePlayerStore.setState({ currentTrack: makeTrack({ duration: 200 }) });
|
||||
usePlayerStore.getState().setProgress(50, 200);
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.currentTime).toBe(50);
|
||||
expect(s.progress).toBeCloseTo(0.25, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stop', () => {
|
||||
it('invokes audio_stop and clears playback state', () => {
|
||||
usePlayerStore.setState({
|
||||
queue: makeTracks(2),
|
||||
queueIndex: 0,
|
||||
currentTrack: makeTrack(),
|
||||
isPlaying: true,
|
||||
progress: 0.5,
|
||||
currentTime: 60,
|
||||
});
|
||||
usePlayerStore.getState().stop();
|
||||
expect(invokeMock).toHaveBeenCalledWith('audio_stop');
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.isPlaying).toBe(false);
|
||||
expect(s.progress).toBe(0);
|
||||
expect(s.currentTime).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffleQueue', () => {
|
||||
it('is a no-op when the queue has fewer than 2 tracks', () => {
|
||||
const t = makeTrack({ id: 'only' });
|
||||
usePlayerStore.setState({ queue: [t], queueIndex: 0, currentTrack: t });
|
||||
usePlayerStore.getState().shuffleQueue();
|
||||
expect(usePlayerStore.getState().queue.map(q => q.id)).toEqual(['only']);
|
||||
});
|
||||
|
||||
it('keeps the current track at queueIndex 0 with the rest shuffled around it', () => {
|
||||
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
|
||||
const current = tracks[2];
|
||||
usePlayerStore.setState({ queue: tracks, queueIndex: 2, currentTrack: current });
|
||||
|
||||
// Pin the RNG so the shuffle is deterministic.
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
usePlayerStore.getState().shuffleQueue();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.queue[0].id).toBe(current.id);
|
||||
expect(s.queueIndex).toBe(0);
|
||||
// The set of ids is preserved.
|
||||
expect([...s.queue.map(t => t.id)].sort()).toEqual(['t-0', 't-1', 't-2', 't-3', 't-4'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('shuffleUpcomingQueue', () => {
|
||||
it('is a no-op when fewer than 2 upcoming tracks remain', () => {
|
||||
const tracks = makeTracks(3, i => ({ id: `t-${i}` }));
|
||||
usePlayerStore.setState({ queue: tracks, queueIndex: 2, currentTrack: tracks[2] });
|
||||
const beforeIds = tracks.map(t => t.id);
|
||||
usePlayerStore.getState().shuffleUpcomingQueue();
|
||||
expect(usePlayerStore.getState().queue.map(t => t.id)).toEqual(beforeIds);
|
||||
});
|
||||
|
||||
it('keeps the head + current in place and shuffles only the upcoming tail', () => {
|
||||
const tracks = makeTracks(5, i => ({ id: `t-${i}` }));
|
||||
usePlayerStore.setState({ queue: tracks, queueIndex: 1, currentTrack: tracks[1] });
|
||||
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0);
|
||||
usePlayerStore.getState().shuffleUpcomingQueue();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
const s = usePlayerStore.getState();
|
||||
// First two entries unchanged (head + current).
|
||||
expect(s.queue[0].id).toBe('t-0');
|
||||
expect(s.queue[1].id).toBe('t-1');
|
||||
// The tail still contains the same ids in some order.
|
||||
expect([...s.queue.slice(2).map(t => t.id)].sort()).toEqual(['t-2', 't-3', 't-4'].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneUpcomingToCurrent', () => {
|
||||
it('drops everything after queueIndex', () => {
|
||||
const tracks = makeTracks(5);
|
||||
usePlayerStore.setState({ queue: tracks, queueIndex: 1, currentTrack: tracks[1] });
|
||||
usePlayerStore.getState().pruneUpcomingToCurrent();
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.queue.map(t => t.id)).toEqual([tracks[0].id, tracks[1].id]);
|
||||
expect(s.queueIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('clears the queue entirely when there is no current track (orphaned queue → empty)', () => {
|
||||
usePlayerStore.setState({ queue: makeTracks(3), queueIndex: 0, currentTrack: null });
|
||||
usePlayerStore.getState().pruneUpcomingToCurrent();
|
||||
const s = usePlayerStore.getState();
|
||||
expect(s.queue).toEqual([]);
|
||||
expect(s.queueIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('returns early without clearing when no current track AND queue is already empty', () => {
|
||||
usePlayerStore.setState({ queue: [], queueIndex: 0, currentTrack: null });
|
||||
usePlayerStore.getState().pruneUpcomingToCurrent();
|
||||
expect(usePlayerStore.getState().queue).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRadioArtistId', () => {
|
||||
it('accepts an artist id without throwing (module-level state, observable via radio playback)', () => {
|
||||
// No public getter for radioArtistId — assertion via does-not-throw.
|
||||
expect(() => usePlayerStore.getState().setRadioArtistId('ar-1')).not.toThrow();
|
||||
expect(() => usePlayerStore.getState().setRadioArtistId('ar-2')).not.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user