mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
committed by
GitHub
parent
8569a17797
commit
ae23bf61eb
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user