diff --git a/src/store/authStore.login.test.ts b/src/store/authStore.login.test.ts new file mode 100644 index 00000000..7eb4a948 --- /dev/null +++ b/src/store/authStore.login.test.ts @@ -0,0 +1,139 @@ +/** + * Login / session / connection-state characterization for `authStore`. + * + * authStore has no single `login()` action — the UI composes: + * pingWithCredentials → addServer → setActiveServer → setLoggedIn + * Tests target that composed sequence + the connection-state surface + * (`isConnecting`, `connectionError`) and the Last.fm session helpers. + * + * Phase F2 / PR 3 of the pre-refactor testing plan. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useAuthStore } from './authStore'; +import { resetAuthStore } from '@/test/helpers/storeReset'; +import { makeServer } from '@/test/helpers/factories'; + +beforeEach(() => { + resetAuthStore(); +}); + +describe('login flow (composed)', () => { + it('successful login: addServer → setActiveServer → setLoggedIn pins the active session', () => { + const profile = makeServer({ name: 'Home' }); + const { addServer, setActiveServer, setLoggedIn } = useAuthStore.getState(); + + const id = addServer({ name: profile.name, url: profile.url, username: profile.username, password: profile.password }); + setActiveServer(id); + setLoggedIn(true); + + const s = useAuthStore.getState(); + expect(s.servers).toHaveLength(1); + expect(s.servers[0]?.id).toBe(id); + expect(s.servers[0]?.name).toBe('Home'); + expect(s.activeServerId).toBe(id); + expect(s.isLoggedIn).toBe(true); + }); + + it('failed login (no addServer call) leaves prior valid state intact', () => { + const existing = makeServer({ name: 'Existing' }); + const existingId = useAuthStore.getState().addServer({ + name: existing.name, url: existing.url, username: existing.username, password: existing.password, + }); + useAuthStore.getState().setActiveServer(existingId); + useAuthStore.getState().setLoggedIn(true); + + // Simulate ping failure — the UI sets connecting + error but does NOT + // touch servers / activeServerId / isLoggedIn. + useAuthStore.getState().setConnecting(true); + useAuthStore.getState().setConnectionError('connection refused'); + useAuthStore.getState().setConnecting(false); + + const s = useAuthStore.getState(); + expect(s.servers).toHaveLength(1); + expect(s.servers[0]?.id).toBe(existingId); + expect(s.activeServerId).toBe(existingId); + expect(s.isLoggedIn).toBe(true); + expect(s.connectionError).toBe('connection refused'); + }); + + it('addServer assigns a unique id even for duplicate-payload calls', () => { + const make = () => useAuthStore.getState().addServer({ + name: 'Same', url: 'https://same.test', username: 'u', password: 'p', + }); + const id1 = make(); + const id2 = make(); + expect(id1).not.toBe(id2); + expect(useAuthStore.getState().servers).toHaveLength(2); + }); +}); + +describe('connection state', () => { + it('setConnecting / setConnectionError toggle the loading + error fields independently', () => { + const { setConnecting, setConnectionError } = useAuthStore.getState(); + + setConnecting(true); + expect(useAuthStore.getState().isConnecting).toBe(true); + + setConnectionError('boom'); + expect(useAuthStore.getState().connectionError).toBe('boom'); + + setConnecting(false); + setConnectionError(null); + expect(useAuthStore.getState().isConnecting).toBe(false); + expect(useAuthStore.getState().connectionError).toBeNull(); + }); +}); + +describe('logout', () => { + it('clears isLoggedIn + musicFolders but keeps the server entry', () => { + const id = useAuthStore.getState().addServer({ + name: 'Home', url: 'https://x.test', username: 'u', password: 'p', + }); + useAuthStore.getState().setActiveServer(id); + useAuthStore.getState().setLoggedIn(true); + useAuthStore.getState().setMusicFolders([{ id: 'mf-1', name: 'Music' }]); + + useAuthStore.getState().logout(); + + const s = useAuthStore.getState(); + expect(s.isLoggedIn).toBe(false); + expect(s.musicFolders).toEqual([]); + expect(s.servers).toHaveLength(1); + expect(s.activeServerId).toBe(id); + }); +}); + +describe('Last.fm session', () => { + it('setLastfm stores api key + secret + session key + username together', () => { + useAuthStore.getState().setLastfm('api', 'sec', 'session', 'frank'); + const s = useAuthStore.getState(); + expect(s.lastfmApiKey).toBe('api'); + expect(s.lastfmApiSecret).toBe('sec'); + expect(s.lastfmSessionKey).toBe('session'); + expect(s.lastfmUsername).toBe('frank'); + }); + + it('connectLastfm sets only sessionKey + username (preserves api key / secret)', () => { + useAuthStore.getState().setLastfm('app-key', 'app-sec', '', ''); + useAuthStore.getState().connectLastfm('user-session', 'frank'); + const s = useAuthStore.getState(); + expect(s.lastfmApiKey).toBe('app-key'); + expect(s.lastfmApiSecret).toBe('app-sec'); + expect(s.lastfmSessionKey).toBe('user-session'); + expect(s.lastfmUsername).toBe('frank'); + }); + + it('disconnectLastfm clears session key + username + session error, keeps app credentials', () => { + useAuthStore.getState().setLastfm('app-key', 'app-sec', 'session', 'frank'); + useAuthStore.getState().setLastfmSessionError(true); + + useAuthStore.getState().disconnectLastfm(); + + const s = useAuthStore.getState(); + expect(s.lastfmSessionKey).toBe(''); + expect(s.lastfmUsername).toBe(''); + expect(s.lastfmSessionError).toBe(false); + expect(s.lastfmApiKey).toBe('app-key'); + expect(s.lastfmApiSecret).toBe('app-sec'); + }); +}); diff --git a/src/store/authStore.persistence.test.ts b/src/store/authStore.persistence.test.ts new file mode 100644 index 00000000..040fdc0c --- /dev/null +++ b/src/store/authStore.persistence.test.ts @@ -0,0 +1,202 @@ +/** + * authStore persistence + sync-storage characterization. + * + * Covers Zustand persist's hydration path (via `useAuthStore.persist.rehydrate()`): + * existing localStorage shapes load, missing fields default to the store's + * initial values, corrupt JSON does not crash bootstrap, the + * hotCacheEnabled / preloadMode≠'off' conflicting-legacy migration clears + * both, and a few smaller field-level migrations. + * + * Also pins the **synchronous storage** invariant called out in `CLAUDE.md` + * ("never switch to async storage") — regression §2 of the pre-refactor + * testing plan v2. + * + * Phase F2 / PR 3. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useAuthStore } from './authStore'; +import { resetAuthStore } from '@/test/helpers/storeReset'; + +const PERSIST_KEY = 'psysonic-auth'; + +function writePersistedState(state: Record): void { + localStorage.setItem(PERSIST_KEY, JSON.stringify({ state, version: 0 })); +} + +beforeEach(() => { + resetAuthStore(); + localStorage.removeItem(PERSIST_KEY); +}); + +describe('hydration — loads existing localStorage shape', () => { + it('restores servers + activeServerId from a fresh-shape payload', async () => { + const server = { id: 's1', name: 'Home', url: 'https://x.test', username: 'u', password: 'p' }; + writePersistedState({ servers: [server], activeServerId: 's1' }); + + await useAuthStore.persist.rehydrate(); + + const s = useAuthStore.getState(); + expect(s.servers).toHaveLength(1); + expect(s.servers[0]?.id).toBe('s1'); + expect(s.activeServerId).toBe('s1'); + }); + + it('defaults missing fields to their initial values', async () => { + // Minimal payload — no scrobblingEnabled, no replayGain settings, etc. + writePersistedState({ servers: [], activeServerId: null }); + + await useAuthStore.persist.rehydrate(); + + const s = useAuthStore.getState(); + expect(s.scrobblingEnabled).toBe(true); + expect(s.crossfadeEnabled).toBe(false); + expect(s.gaplessEnabled).toBe(false); + expect(s.replayGainEnabled).toBe(false); + expect(s.normalizationEngine).toBe('off'); + expect(s.preloadMode).toBe('balanced'); + }); + + it('preserves saved fields verbatim when present', async () => { + writePersistedState({ + servers: [], + activeServerId: null, + scrobblingEnabled: false, + crossfadeEnabled: true, + gaplessEnabled: false, + crossfadeSecs: 7, + }); + + await useAuthStore.persist.rehydrate(); + + const s = useAuthStore.getState(); + expect(s.scrobblingEnabled).toBe(false); + expect(s.crossfadeEnabled).toBe(true); + expect(s.crossfadeSecs).toBe(7); + }); +}); + +describe('hydration — corrupt / unexpected input', () => { + it('does not crash bootstrap when the persisted blob is not valid JSON', async () => { + localStorage.setItem(PERSIST_KEY, '{not[json}'); + await expect(useAuthStore.persist.rehydrate()).resolves.not.toThrow(); + + const s = useAuthStore.getState(); + // No servers loaded; defaults remain. + expect(s.servers).toEqual([]); + expect(s.scrobblingEnabled).toBe(true); + }); + + it('is robust to a missing top-level `state` field', async () => { + localStorage.setItem(PERSIST_KEY, JSON.stringify({ version: 0 })); + await expect(useAuthStore.persist.rehydrate()).resolves.not.toThrow(); + expect(useAuthStore.getState().servers).toEqual([]); + }); +}); + +describe('onRehydrate migrations', () => { + it('clears the conflicting hotCacheEnabled + preloadMode≠"off" legacy combo', async () => { + writePersistedState({ + servers: [], + activeServerId: null, + hotCacheEnabled: true, + preloadMode: 'balanced', + }); + + await useAuthStore.persist.rehydrate(); + + const s = useAuthStore.getState(); + expect(s.hotCacheEnabled).toBe(false); + expect(s.preloadMode).toBe('off'); + }); + + it('keeps hotCacheEnabled when preloadMode was already "off"', async () => { + writePersistedState({ + servers: [], + activeServerId: null, + hotCacheEnabled: true, + preloadMode: 'off', + }); + + await useAuthStore.persist.rehydrate(); + + const s = useAuthStore.getState(); + expect(s.hotCacheEnabled).toBe(true); + expect(s.preloadMode).toBe('off'); + }); + + it('migrates a legacy `waveform` seekbarStyle to `truewave`', async () => { + writePersistedState({ + servers: [], + activeServerId: null, + seekbarStyle: 'waveform', // legacy / no longer in VALID_SEEKBAR_STYLES + }); + + await useAuthStore.persist.rehydrate(); + expect(useAuthStore.getState().seekbarStyle).toBe('truewave'); + }); + + it('keeps a valid seekbarStyle unchanged', async () => { + writePersistedState({ + servers: [], + activeServerId: null, + seekbarStyle: 'neon', + }); + + await useAuthStore.persist.rehydrate(); + expect(useAuthStore.getState().seekbarStyle).toBe('neon'); + }); + + it('strips the removed `animationMode` and `reducedAnimations` legacy fields', async () => { + writePersistedState({ + servers: [], + activeServerId: null, + animationMode: 'reduced', + reducedAnimations: true, + }); + + await useAuthStore.persist.rehydrate(); + const s = useAuthStore.getState() as unknown as Record; + expect(s.animationMode).toBeUndefined(); + expect(s.reducedAnimations).toBeUndefined(); + }); +}); + +describe('partialize — what gets persisted', () => { + it('strips `musicFolders` from the persisted payload', () => { + useAuthStore.setState({ musicFolders: [{ id: 'mf-1', name: 'Music' }] }); + // Trigger persist (Zustand persist writes on every state change). + useAuthStore.setState({ scrobblingEnabled: false }); + + const raw = localStorage.getItem(PERSIST_KEY); + expect(raw).not.toBeNull(); + const parsed = JSON.parse(raw!) as { state: Record }; + expect(parsed.state.musicFolders).toBeUndefined(); + }); +}); + +describe('synchronous storage invariant (CLAUDE.md gotcha)', () => { + // Regression §2 of the v2 eval doc: a refactor that swaps localStorage + // for an async store (e.g. @tauri-apps/plugin-store) would break the + // bootstrap path — `getActiveServer()` would return undefined for one + // event-loop tick after `addServer` + `setActiveServer`. + it('addServer + setActiveServer → getActiveServer is visible in the same tick (no await)', () => { + const id = useAuthStore.getState().addServer({ + name: 'Sync', url: 'https://sync.test', username: 'u', password: 'p', + }); + useAuthStore.getState().setActiveServer(id); + + // No `await` between the writes and the read. If storage were async + // the activeServerId would not yet be reflected by `getActiveServer`. + expect(useAuthStore.getState().getActiveServer()?.id).toBe(id); + expect(useAuthStore.getState().getActiveServer()?.name).toBe('Sync'); + }); + + it('exposes a synchronous getter API — never returns a Promise', () => { + // Type-level + runtime check that the selectors stay sync. + const active = useAuthStore.getState().getActiveServer(); + const baseUrl = useAuthStore.getState().getBaseUrl(); + expect(active).not.toBeInstanceOf(Promise); + expect(baseUrl).not.toBeInstanceOf(Promise); + expect(typeof baseUrl).toBe('string'); + }); +}); diff --git a/src/store/authStore.servers.test.ts b/src/store/authStore.servers.test.ts new file mode 100644 index 00000000..889d4499 --- /dev/null +++ b/src/store/authStore.servers.test.ts @@ -0,0 +1,184 @@ +/** + * Server add / remove / update / switch characterization for `authStore`. + * + * Also includes the gapless / crossfade mutual-exclusion regression test + * from §4.3 of the pre-refactor testing plan v2 — both flags live on + * `authStore`, both UI surfaces (Settings + QueuePanel toolbar) clear the + * other one before setting their own. A refactor that pushes mutex + * enforcement into the setters would change the contract; the tests pin + * the current caller-clears-first behaviour. + * + * Phase F2 / PR 3. + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useAuthStore } from './authStore'; +import { resetAuthStore } from '@/test/helpers/storeReset'; +import { makeServer } from '@/test/helpers/factories'; + +function addThree(): { a: string; b: string; c: string } { + const a = useAuthStore.getState().addServer({ name: 'A', url: 'https://a.test', username: 'u', password: 'p' }); + const b = useAuthStore.getState().addServer({ name: 'B', url: 'https://b.test', username: 'u', password: 'p' }); + const c = useAuthStore.getState().addServer({ name: 'C', url: 'https://c.test', username: 'u', password: 'p' }); + return { a, b, c }; +} + +beforeEach(() => { + resetAuthStore(); +}); + +describe('addServer / updateServer', () => { + it('appends a new server and returns its id', () => { + const id = useAuthStore.getState().addServer({ + name: 'Home', url: 'https://x.test', username: 'u', password: 'p', + }); + const s = useAuthStore.getState(); + expect(s.servers).toHaveLength(1); + expect(s.servers[0]?.id).toBe(id); + expect(s.servers[0]?.name).toBe('Home'); + }); + + it('updateServer patches the matching server only', () => { + const { a, b } = addThree(); + useAuthStore.getState().updateServer(a, { name: 'A-renamed', url: 'https://a-new.test' }); + const s = useAuthStore.getState(); + const sa = s.servers.find(srv => srv.id === a); + const sb = s.servers.find(srv => srv.id === b); + expect(sa?.name).toBe('A-renamed'); + expect(sa?.url).toBe('https://a-new.test'); + expect(sb?.name).toBe('B'); + expect(sb?.url).toBe('https://b.test'); + }); + + it('updateServer is a no-op for an unknown id', () => { + const { a } = addThree(); + const before = useAuthStore.getState().servers; + useAuthStore.getState().updateServer('unknown-id', { name: 'X' }); + expect(useAuthStore.getState().servers).toEqual(before); + expect(useAuthStore.getState().servers.find(s => s.id === a)?.name).toBe('A'); + }); +}); + +describe('setActiveServer', () => { + it('updates activeServerId and clears musicFolders (forces a refetch)', () => { + const { a, b } = addThree(); + useAuthStore.getState().setActiveServer(a); + useAuthStore.getState().setMusicFolders([{ id: 'mf-1', name: 'Music' }]); + + useAuthStore.getState().setActiveServer(b); + const s = useAuthStore.getState(); + expect(s.activeServerId).toBe(b); + expect(s.musicFolders).toEqual([]); + }); +}); + +describe('removeServer', () => { + it('removes a non-active server without touching activeServerId or isLoggedIn', () => { + const { a, b } = addThree(); + useAuthStore.getState().setActiveServer(a); + useAuthStore.getState().setLoggedIn(true); + + useAuthStore.getState().removeServer(b); + const s = useAuthStore.getState(); + expect(s.servers.map(srv => srv.id)).not.toContain(b); + expect(s.activeServerId).toBe(a); + expect(s.isLoggedIn).toBe(true); + }); + + it('removing the active server picks newServers[0] as the deterministic fallback', () => { + const { a, b, c } = addThree(); + useAuthStore.getState().setActiveServer(a); + + useAuthStore.getState().removeServer(a); + const s = useAuthStore.getState(); + expect(s.servers.map(srv => srv.id)).toEqual([b, c]); + expect(s.activeServerId).toBe(b); + }); + + it('removing the only / active server clears activeServerId to null and forces logout', () => { + const id = useAuthStore.getState().addServer({ name: 'Solo', url: 'https://s.test', username: 'u', password: 'p' }); + useAuthStore.getState().setActiveServer(id); + useAuthStore.getState().setLoggedIn(true); + + useAuthStore.getState().removeServer(id); + const s = useAuthStore.getState(); + expect(s.servers).toHaveLength(0); + expect(s.activeServerId).toBeNull(); + expect(s.isLoggedIn).toBe(false); + }); + + it('cleans associated per-server bookkeeping maps (entityRatingSupport / instantMix / …)', () => { + const { a, b } = addThree(); + useAuthStore.setState({ + entityRatingSupportByServer: { [a]: 'full', [b]: 'track_only' }, + }); + + useAuthStore.getState().removeServer(a); + expect(useAuthStore.getState().entityRatingSupportByServer).toEqual({ [b]: 'track_only' }); + }); + + it('does not touch activeServerId when the removed id was inactive', () => { + const { a, b } = addThree(); + useAuthStore.getState().setActiveServer(b); + useAuthStore.getState().removeServer(a); + expect(useAuthStore.getState().activeServerId).toBe(b); + }); +}); + +describe('selectors — getBaseUrl / getActiveServer', () => { + it('getActiveServer returns the entry matching activeServerId', () => { + const { a, b } = addThree(); + useAuthStore.getState().setActiveServer(b); + expect(useAuthStore.getState().getActiveServer()?.id).toBe(b); + }); + + it('getActiveServer returns undefined when no server is active', () => { + expect(useAuthStore.getState().getActiveServer()).toBeUndefined(); + }); + + it('getBaseUrl strips trailing slashes and adds http:// when missing', () => { + useAuthStore.getState().addServer({ name: 'A', url: 'http://a.test/', username: 'u', password: 'p' }); + const a = useAuthStore.getState().servers[0]!.id; + useAuthStore.getState().setActiveServer(a); + expect(useAuthStore.getState().getBaseUrl()).toBe('http://a.test'); + + const b = useAuthStore.getState().addServer({ name: 'B', url: 'b.local', username: 'u', password: 'p' }); + useAuthStore.getState().setActiveServer(b); + expect(useAuthStore.getState().getBaseUrl()).toBe('http://b.local'); + }); + + it('getBaseUrl returns empty string when no server is active', () => { + expect(useAuthStore.getState().getBaseUrl()).toBe(''); + }); +}); + +describe('audio modes — gapless / crossfade mutual exclusion (regression §4.3 of v2 plan)', () => { + it('enabling gapless after the caller clears crossfade yields gapless-only', () => { + useAuthStore.setState({ crossfadeEnabled: true, gaplessEnabled: false }); + // Caller pattern matches Settings.tsx:2565 — gapless toggle clears crossfade first. + useAuthStore.getState().setCrossfadeEnabled(false); + useAuthStore.getState().setGaplessEnabled(true); + const s = useAuthStore.getState(); + expect(s.crossfadeEnabled).toBe(false); + expect(s.gaplessEnabled).toBe(true); + }); + + it('enabling crossfade after the caller clears gapless yields crossfade-only', () => { + useAuthStore.setState({ crossfadeEnabled: false, gaplessEnabled: true }); + useAuthStore.getState().setGaplessEnabled(false); + useAuthStore.getState().setCrossfadeEnabled(true); + const s = useAuthStore.getState(); + expect(s.gaplessEnabled).toBe(false); + expect(s.crossfadeEnabled).toBe(true); + }); + + it('the setters themselves do NOT auto-clear the other flag — callers are responsible', () => { + // Pinning the current contract: a refactor that moves mutex enforcement + // into the setters would silently change behaviour for any caller that + // doesn't already clear first. + useAuthStore.setState({ crossfadeEnabled: true, gaplessEnabled: false }); + useAuthStore.getState().setGaplessEnabled(true); + // Both end up true because the setter is a pure assignment. + expect(useAuthStore.getState().crossfadeEnabled).toBe(true); + expect(useAuthStore.getState().gaplessEnabled).toBe(true); + }); +}); diff --git a/src/store/authStore.settings.test.ts b/src/store/authStore.settings.test.ts new file mode 100644 index 00000000..b9e790e2 --- /dev/null +++ b/src/store/authStore.settings.test.ts @@ -0,0 +1,314 @@ +/** + * Setter-surface characterization for `authStore`. + * + * Pins the setter API so a refactor that renames or removes one of the + * dozens of `setX: (v) => set({ x: v })` action methods fails loudly. + * Most setters are trivial; the ones with logic (clamping, validation, + * transforms, side effects) get a focused test for the non-trivial bit. + * + * Phase F2 / PR 3. + */ +import { 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), +})); + +import { useAuthStore } from './authStore'; +import { resetAuthStore, resetPlayerStore } from '@/test/helpers/storeReset'; +import { onInvoke } from '@/test/mocks/tauri'; + +beforeEach(() => { + resetAuthStore(); + resetPlayerStore(); + onInvoke('audio_update_replay_gain', () => undefined); + onInvoke('audio_set_normalization', () => undefined); +}); + +describe('trivial pass-through setters', () => { + // Quick API-pin sweep. Each setter is `setX: (v) => set({ x: v })` — + // a refactor that renames a key without renaming the setter (or vice + // versa) breaks these. + it.each([ + ['setScrobblingEnabled', 'scrobblingEnabled', false], + ['setExcludeAudiobooks', 'excludeAudiobooks', true], + ['setInfiniteQueueEnabled', 'infiniteQueueEnabled', true], + ['setPreservePlayNextOrder', 'preservePlayNextOrder', true], + ['setShowArtistImages', 'showArtistImages', true], + ['setShowTrayIcon', 'showTrayIcon', false], + ['setMinimizeToTray', 'minimizeToTray', true], + ['setShowOrbitTrigger', 'showOrbitTrigger', false], + ['setDiscordRichPresence', 'discordRichPresence', true], + ['setEnableBandsintown', 'enableBandsintown', true], + ['setUseCustomTitlebar', 'useCustomTitlebar', true], + ['setPreloadMiniPlayer', 'preloadMiniPlayer', true], + ['setLinuxWebkitKineticScroll', 'linuxWebkitKineticScroll', false], + ['setNowPlayingEnabled', 'nowPlayingEnabled', true], + ['setShowFullscreenLyrics', 'showFullscreenLyrics', false], + ['setLyricsStaticOnly', 'lyricsStaticOnly', true], + ['setShowChangelogOnUpdate', 'showChangelogOnUpdate', false], + ['setQueueNowPlayingCollapsed', 'queueNowPlayingCollapsed', true], + ['setEnableHiRes', 'enableHiRes', true], + ['setHotCacheEnabled', 'hotCacheEnabled', true], + ['setMixMinRatingFilterEnabled', 'mixMinRatingFilterEnabled', true], + ['setShowLuckyMixMenu', 'showLuckyMixMenu', false], + ])('%s writes to %s', (setter, key, value) => { + const setFn = (useAuthStore.getState() as unknown as Record)[setter]; + expect(typeof setFn).toBe('function'); + (setFn as (v: unknown) => void)(value); + expect((useAuthStore.getState() as unknown as Record)[key]).toBe(value); + }); + + it.each([ + ['setMaxCacheMb', 'maxCacheMb', 2048], + ['setHotCacheMaxMb', 'hotCacheMaxMb', 1024], + ['setHotCacheDebounceSec', 'hotCacheDebounceSec', 60], + ['setPreloadCustomSeconds', 'preloadCustomSeconds', 45], + ['setFsPortraitDim', 'fsPortraitDim', 64], + ])('%s stores a numeric value', (setter, key, value) => { + (useAuthStore.getState() as unknown as Record void>)[setter](value); + expect((useAuthStore.getState() as unknown as Record)[key]).toBe(value); + }); + + it.each([ + ['setDownloadFolder', 'downloadFolder', '/tmp/downloads'], + ['setOfflineDownloadDir', 'offlineDownloadDir', '/tmp/offline'], + ['setHotCacheDownloadDir', 'hotCacheDownloadDir', '/tmp/hot'], + ['setLastSeenChangelogVersion', 'lastSeenChangelogVersion', '1.46.0'], + ['setDiscordTemplateDetails', 'discordTemplateDetails', '{artist} — {title}'], + ['setDiscordTemplateState', 'discordTemplateState', '{album}'], + ['setDiscordTemplateLargeText', 'discordTemplateLargeText', 'Hi'], + ])('%s stores a string value', (setter, key, value) => { + (useAuthStore.getState() as unknown as Record void>)[setter](value); + expect((useAuthStore.getState() as unknown as Record)[key]).toBe(value); + }); +}); + +describe('setters with validation / clamping', () => { + it('setTrackPreviewStartRatio clamps to [0, 0.9]', () => { + useAuthStore.getState().setTrackPreviewStartRatio(1.5); + expect(useAuthStore.getState().trackPreviewStartRatio).toBe(0.9); + + useAuthStore.getState().setTrackPreviewStartRatio(-0.5); + expect(useAuthStore.getState().trackPreviewStartRatio).toBe(0); + + useAuthStore.getState().setTrackPreviewStartRatio(0.33); + expect(useAuthStore.getState().trackPreviewStartRatio).toBe(0.33); + }); + + it('setTrackPreviewDurationSec clamps to [5, 120] and rounds to whole seconds', () => { + useAuthStore.getState().setTrackPreviewDurationSec(300); + expect(useAuthStore.getState().trackPreviewDurationSec).toBe(120); + + useAuthStore.getState().setTrackPreviewDurationSec(2); + expect(useAuthStore.getState().trackPreviewDurationSec).toBe(5); + + useAuthStore.getState().setTrackPreviewDurationSec(30.7); + expect(useAuthStore.getState().trackPreviewDurationSec).toBe(31); + }); + + it('setTrackPreviewsEnabled coerces truthy/falsy to boolean', () => { + useAuthStore.getState().setTrackPreviewsEnabled('yes' as unknown as boolean); + expect(useAuthStore.getState().trackPreviewsEnabled).toBe(true); + + useAuthStore.getState().setTrackPreviewsEnabled(0 as unknown as boolean); + expect(useAuthStore.getState().trackPreviewsEnabled).toBe(false); + }); + + it('setTrackPreviewLocation toggles a single location entry by key', () => { + const before = useAuthStore.getState().trackPreviewLocations; + const firstKey = Object.keys(before)[0] as keyof typeof before; + useAuthStore.getState().setTrackPreviewLocation(firstKey, !before[firstKey]); + expect(useAuthStore.getState().trackPreviewLocations[firstKey]).toBe(!before[firstKey]); + }); + + it('setLoudnessPreAnalysisAttenuationDb rejects non-finite input', () => { + const before = useAuthStore.getState().loudnessPreAnalysisAttenuationDb; + useAuthStore.getState().setLoudnessPreAnalysisAttenuationDb(Number.NaN); + expect(useAuthStore.getState().loudnessPreAnalysisAttenuationDb).toBe(before); + + useAuthStore.getState().setLoudnessPreAnalysisAttenuationDb(Number.POSITIVE_INFINITY); + expect(useAuthStore.getState().loudnessPreAnalysisAttenuationDb).toBe(before); + }); + + it('resetLoudnessPreAnalysisAttenuationDbDefault restores the canonical default', () => { + useAuthStore.getState().setLoudnessPreAnalysisAttenuationDb(-2); + useAuthStore.getState().resetLoudnessPreAnalysisAttenuationDbDefault(); + expect(useAuthStore.getState().loudnessPreAnalysisAttenuationDb).toBe(-4.5); + }); +}); + +describe('replay-gain related setters (write through to player store)', () => { + it('setReplayGainEnabled writes the flag (and pings updateReplayGainForCurrentTrack)', () => { + useAuthStore.getState().setReplayGainEnabled(true); + expect(useAuthStore.getState().replayGainEnabled).toBe(true); + useAuthStore.getState().setReplayGainEnabled(false); + expect(useAuthStore.getState().replayGainEnabled).toBe(false); + }); + + it('setNormalizationEngine accepts off / replaygain / loudness', () => { + useAuthStore.getState().setNormalizationEngine('replaygain'); + expect(useAuthStore.getState().normalizationEngine).toBe('replaygain'); + useAuthStore.getState().setNormalizationEngine('loudness'); + expect(useAuthStore.getState().normalizationEngine).toBe('loudness'); + useAuthStore.getState().setNormalizationEngine('off'); + expect(useAuthStore.getState().normalizationEngine).toBe('off'); + }); + + it('setReplayGainMode supports track / album / auto', () => { + useAuthStore.getState().setReplayGainMode('track'); + expect(useAuthStore.getState().replayGainMode).toBe('track'); + useAuthStore.getState().setReplayGainMode('album'); + expect(useAuthStore.getState().replayGainMode).toBe('album'); + useAuthStore.getState().setReplayGainMode('auto'); + expect(useAuthStore.getState().replayGainMode).toBe('auto'); + }); + + it('setReplayGainPreGainDb / setReplayGainFallbackDb store dB values', () => { + useAuthStore.getState().setReplayGainPreGainDb(-3); + useAuthStore.getState().setReplayGainFallbackDb(-7); + expect(useAuthStore.getState().replayGainPreGainDb).toBe(-3); + expect(useAuthStore.getState().replayGainFallbackDb).toBe(-7); + }); + + it('setLoudnessTargetLufs stores the target', () => { + useAuthStore.getState().setLoudnessTargetLufs(-14); + expect(useAuthStore.getState().loudnessTargetLufs).toBe(-14); + }); +}); + +describe('preload mode + discord cover source setters', () => { + it('setPreloadMode accepts off / balanced / early / custom', () => { + for (const mode of ['off', 'balanced', 'early', 'custom'] as const) { + useAuthStore.getState().setPreloadMode(mode); + expect(useAuthStore.getState().preloadMode).toBe(mode); + } + }); + + it('setDiscordCoverSource accepts none / apple / server', () => { + for (const src of ['none', 'apple', 'server'] as const) { + useAuthStore.getState().setDiscordCoverSource(src); + expect(useAuthStore.getState().discordCoverSource).toBe(src); + } + }); + + it('setLoggingMode accepts off / normal / debug', () => { + for (const mode of ['off', 'normal', 'debug'] as const) { + useAuthStore.getState().setLoggingMode(mode); + expect(useAuthStore.getState().loggingMode).toBe(mode); + } + }); +}); + +describe('per-server bookkeeping setters', () => { + it('setEntityRatingSupport scopes the value to the given serverId', () => { + useAuthStore.getState().setEntityRatingSupport('srv-1', 'full'); + useAuthStore.getState().setEntityRatingSupport('srv-2', 'track_only'); + expect(useAuthStore.getState().entityRatingSupportByServer).toEqual({ + 'srv-1': 'full', + 'srv-2': 'track_only', + }); + }); + + it('setAudiomuseNavidromeEnabled adds positive opt-ins and removes disabled entries', () => { + useAuthStore.getState().setAudiomuseNavidromeEnabled('srv-1', true); + useAuthStore.getState().setAudiomuseNavidromeEnabled('srv-2', true); + expect(useAuthStore.getState().audiomuseNavidromeByServer).toEqual({ + 'srv-1': true, + 'srv-2': true, + }); + + // Disabling removes the entry rather than storing `false` — the map + // tracks positive opt-ins only. + useAuthStore.getState().setAudiomuseNavidromeEnabled('srv-1', false); + expect(useAuthStore.getState().audiomuseNavidromeByServer).toEqual({ + 'srv-2': true, + }); + }); + + it('setAudiomuseNavidromeIssue scopes by serverId', () => { + useAuthStore.getState().setAudiomuseNavidromeIssue('srv-1', true); + expect(useAuthStore.getState().audiomuseNavidromeIssueByServer).toEqual({ + 'srv-1': true, + }); + }); +}); + +describe('genre blacklist + audio output device', () => { + it('setCustomGenreBlacklist replaces the list', () => { + useAuthStore.getState().setCustomGenreBlacklist(['Audiobook', 'Podcast']); + expect(useAuthStore.getState().customGenreBlacklist).toEqual(['Audiobook', 'Podcast']); + + useAuthStore.getState().setCustomGenreBlacklist([]); + expect(useAuthStore.getState().customGenreBlacklist).toEqual([]); + }); + + it('setAudioOutputDevice stores the device id or null', () => { + useAuthStore.getState().setAudioOutputDevice('hw:0,0'); + expect(useAuthStore.getState().audioOutputDevice).toBe('hw:0,0'); + + useAuthStore.getState().setAudioOutputDevice(null); + expect(useAuthStore.getState().audioOutputDevice).toBeNull(); + }); +}); + +describe('lyrics source setters', () => { + it('setLyricsSources replaces the source list verbatim', () => { + const sources = [ + { id: 'lrclib' as const, enabled: true }, + { id: 'server' as const, enabled: false }, + { id: 'netease' as const, enabled: true }, + ]; + useAuthStore.getState().setLyricsSources(sources); + expect(useAuthStore.getState().lyricsSources).toEqual(sources); + }); + + it('setLyricsMode + setFsLyricsStyle + setSidebarLyricsStyle write enum values through', () => { + useAuthStore.getState().setLyricsMode('lyricsplus'); + expect(useAuthStore.getState().lyricsMode).toBe('lyricsplus'); + + useAuthStore.getState().setFsLyricsStyle('apple'); + expect(useAuthStore.getState().fsLyricsStyle).toBe('apple'); + + useAuthStore.getState().setSidebarLyricsStyle('apple'); + expect(useAuthStore.getState().sidebarLyricsStyle).toBe('apple'); + }); +}); + +describe('mix filter setters — clamp to allowed range', () => { + it('setMixMinRatingSong / Album / Artist clamp out-of-range stars', () => { + useAuthStore.getState().setMixMinRatingSong(10); + useAuthStore.getState().setMixMinRatingAlbum(-3); + useAuthStore.getState().setMixMinRatingArtist(0); + const s = useAuthStore.getState(); + // The clamp function bounds to 0–5; values outside that range get pulled in. + expect(s.mixMinRatingSong).toBeGreaterThanOrEqual(0); + expect(s.mixMinRatingSong).toBeLessThanOrEqual(5); + expect(s.mixMinRatingAlbum).toBeGreaterThanOrEqual(0); + expect(s.mixMinRatingAlbum).toBeLessThanOrEqual(5); + expect(s.mixMinRatingArtist).toBeGreaterThanOrEqual(0); + expect(s.mixMinRatingArtist).toBeLessThanOrEqual(5); + }); + + it('setRandomMixSize clamps to a sensible range', () => { + useAuthStore.getState().setRandomMixSize(10_000); + const huge = useAuthStore.getState().randomMixSize; + expect(huge).toBeLessThan(10_000); + + useAuthStore.getState().setRandomMixSize(-5); + expect(useAuthStore.getState().randomMixSize).toBeGreaterThanOrEqual(0); + }); +});