Files
psysonic/src/store/authStore.settings.test.ts
T
Frank Stellmacher ae23bf61eb 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.
2026-05-11 22:30:45 +02:00

315 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});