diff --git a/src/store/playerStore.misc.test.ts b/src/store/playerStore.misc.test.ts new file mode 100644 index 00000000..70830871 --- /dev/null +++ b/src/store/playerStore.misc.test.ts @@ -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(); + }); +});