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:
Frank Stellmacher
2026-05-11 23:48:25 +02:00
committed by GitHub
parent 568f3aeb7d
commit b3646daabd
+312
View File
@@ -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();
});
});