test(authStore): characterize login + servers + persistence + settings (Phase F2) (#543)

login.test.ts: composed addServer -> setActiveServer -> setLoggedIn flow,
failed-login leaves prior state intact, addServer assigns unique ids,
setConnecting / setConnectionError independence, logout clears
isLoggedIn + musicFolders but keeps server entry, Last.fm session
(setLastfm / connectLastfm / disconnectLastfm contracts).

servers.test.ts: addServer / updateServer (patch by id, no-op on unknown),
setActiveServer (clears musicFolders), removeServer (non-active no-effect,
active picks newServers[0] fallback, last server -> null + isLoggedIn
false, cleans per-server bookkeeping maps), getActiveServer / getBaseUrl
selectors. Includes the gapless / crossfade mutex regression test from
the v2 plan section 4.3 -- callers clear the other flag before setting,
the setters themselves do NOT auto-clear (contract pin).

persistence.test.ts: hydration loads existing localStorage shape, defaults
missing fields, preserves saved values verbatim. Robust to corrupt JSON
and missing top-level state. onRehydrate migrations: clears conflicting
hotCacheEnabled + preloadMode!=off legacy combo, keeps hotCache when
preload was already off, migrates legacy waveform seekbarStyle to truewave,
strips removed animationMode / reducedAnimations fields. partialize strips
musicFolders. Includes the synchronous-storage invariant regression from
the v2 plan section 2 (CLAUDE.md gotcha) -- getActiveServer is visible in
the same tick after addServer + setActiveServer with no await.

settings.test.ts: API-pin sweep across 22 trivial setters via it.each
(rename-resistant), focused tests for setters with logic (clamping in
setTrackPreviewStartRatio / setTrackPreviewDurationSec / setRandomMixSize,
boolean coercion in setTrackPreviewsEnabled, finite-number guard in
setLoudnessPreAnalysisAttenuationDb, default reset in
resetLoudnessPreAnalysisAttenuationDbDefault), per-server bookkeeping
contracts (setEntityRatingSupport / setAudiomuseNavidromeEnabled
positive-opt-in semantics), enum-value setters
(setPreloadMode / setDiscordCoverSource / setLoggingMode / setReplayGainMode /
setNormalizationEngine / setLyricsMode), genre blacklist + audio output
device replacement.

authStore.ts coverage 45.37% -> 79.29% lines (target was 60%). authStore.ts
not yet added to the hot-path gate -- want to see it stable across a few
real PRs first per the gate's curation rule.
This commit is contained in:
Frank Stellmacher
2026-05-11 22:30:45 +02:00
committed by GitHub
parent 8569a17797
commit ae23bf61eb
4 changed files with 839 additions and 0 deletions
+139
View File
@@ -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');
});
});
+202
View File
@@ -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<string, unknown>): 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<string, unknown>;
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<string, unknown> };
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');
});
});
+184
View File
@@ -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);
});
});
+314
View File
@@ -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<string, unknown>)[setter];
expect(typeof setFn).toBe('function');
(setFn as (v: unknown) => void)(value);
expect((useAuthStore.getState() as unknown as Record<string, unknown>)[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<string, (v: unknown) => void>)[setter](value);
expect((useAuthStore.getState() as unknown as Record<string, unknown>)[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<string, (v: unknown) => void>)[setter](value);
expect((useAuthStore.getState() as unknown as Record<string, unknown>)[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 05; 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);
});
});