Files
Psychotoxical-psysonic/src/store/authStore.settings.test.ts
T
cucadmuh 70c2fdfbf9 Linux: session-native GDK/WebKit mitigations and in-page browse scroll (#731)
* feat(linux): session GDK defaults, nvidia-quirk, optional x11-legacy wrap

Ship PSYSONIC_ALLOW_NATIVE_GDK from Nix/AUR instead of pinning WEBKIT_DISABLE_*
and GDK x11. Add flake psysonic-x11-legacy for the old wrap; alias gdk-session
to psysonic. Startup uses webkit2gtk-nvidia-quirk and Wayland-aware compositing;
refresh Help (a45) and nixos-install docs.

* fix(linux): session GDK and nvidia-quirk only; drop wrapper env heuristics

Remove PSYSONIC_ALLOW_NATIVE_GDK and devShell GDK/WEBKIT exports; stop
synthesizing GDK/WebKit vars in main.rs. Update Nix/AUR wrappers, install
docs, CHANGELOG, and help FAQ with practical user-facing workarounds.

* fix(linux): X11-pinned GDK uses DMABUF quirk path, not Wayland explicit-sync

When GDK_BACKEND is forced to x11 on a wayland user session, webkit2gtk-nvidia-quirk
would still apply __NV_DISABLE_EXPLICIT_SYNC and gray out the webview. Map that case
to WEBKIT_DISABLE_DMABUF_RENDERER like native X11.

* fix(ui): stabilize WebKitGTK/Wayland hover paint for nav and media cards

Sidebar nav links avoid transition:all and promote icons with translateZ(0).
Artist rows and album/artist/song cards use compositing hints; card shadows
and borders no longer interpolate so cover zoom can stay smooth without jitter.

* fix(ui): isolate artist/album card text and cover paint on WebKitGTK

Promote cover blocks with contain/paint and text stacks with translateZ(0);
use artist-card-info on the artists grid for the same layout as other cards.

* feat(artists): in-page overlay scroll and locked main viewport

Move list/grid into an inner OverlayScrollArea, stop sticky toolbar from
owning the route scroll, align the rail with the main panel edge, and skip
the main-route overlay thumb when the viewport cannot scroll vertically.

* feat(browse): extend in-page overlay scroll to more library routes

Reuse the locked main viewport pattern from Artists for Albums, Composers,
Lossless albums, and New releases; wire VirtualCardGrid and scroll chrome
to the matching in-page viewport ids.

* fix(linux): improve Wayland GPU compositing text clarity in WebKitGTK

Use on-demand hardware acceleration on main and mini webviews when the
session is Wayland and compositing stays on; gate subpixel body AA on the
same conditions via new Tauri probes. Document PSYSONIC_SKIP_WAYLAND_FONT_TUNING
for opt-out and changelog.

* fix(rust): satisfy clippy needless_return in Linux webkit helpers

* fix(linux): tune Wayland text rendering with HW policy env and CSS

Allow PSYSONIC_WEBKIT_WAYLAND_HW_POLICY to select WebKit hardware
acceleration policy (never/always vs default on-demand). Extend Wayland
font CSS to #root with geometricPrecision and text-size-adjust on html.

* feat(linux): Wayland text presets in settings, safe WebKit apply, CPU default

Persist profile to app config; apply WebKit policy at startup/mini only to
avoid WebKitGTK hangs on live toggles. UI + CSS preview stays live; default
preset is sharp (CPU-friendly).

* fix(linux): map Wayland sharp preset to OnDemand WebKit policy

HardwareAccelerationPolicy::Never at startup broke main-viewport wheel
scrolling on WebKitGTK+Wayland; sharp vs balanced remains a CSS AA path.
Use PSYSONIC_WEBKIT_WAYLAND_HW_POLICY for a true Never policy.

* fix(rust): gate Linux-only Wayland WebKit helpers for Windows builds

Re-export startup helpers only under cfg(linux) and drop non-Linux stubs so
Windows compiles without unused-import and dead-code warnings.

* chore(release): CHANGELOG + credits for Linux session/WebKit work (PR #731)

Consolidate scattered incremental changelog notes into two [1.47.0]
entries with PR link; remove duplicate Linux blocks from [1.46.0] Fixed.
Append settings credit line for cucadmuh.
2026-05-18 21:00:46 +03:00

333 lines
15 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';
import {
LIBRARY_GRID_MAX_COLUMNS_MAX,
LIBRARY_GRID_MAX_COLUMNS_MIN,
} from './authStoreDefaults';
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],
['setClockFormat', 'clockFormat', '24h'],
['setShowOrbitTrigger', 'showOrbitTrigger', false],
['setDiscordRichPresence', 'discordRichPresence', true],
['setEnableBandsintown', 'enableBandsintown', true],
['setUseCustomTitlebar', 'useCustomTitlebar', true],
['setPreloadMiniPlayer', 'preloadMiniPlayer', true],
['setLinuxWebkitKineticScroll', 'linuxWebkitKineticScroll', false],
['setLinuxWaylandTextRenderProfile', 'linuxWaylandTextRenderProfile', 'gpu'],
['setNowPlayingEnabled', 'nowPlayingEnabled', true],
['setShowFullscreenLyrics', 'showFullscreenLyrics', false],
['setLyricsStaticOnly', 'lyricsStaticOnly', true],
['setShowChangelogOnUpdate', 'showChangelogOnUpdate', false],
['setQueueNowPlayingCollapsed', 'queueNowPlayingCollapsed', true],
['setQueueDurationDisplayMode', 'queueDurationDisplayMode', 'eta'],
['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('setLibraryGridMaxColumns clamps to the allowed column range', () => {
useAuthStore.getState().setLibraryGridMaxColumns(99);
expect(useAuthStore.getState().libraryGridMaxColumns).toBe(LIBRARY_GRID_MAX_COLUMNS_MAX);
useAuthStore.getState().setLibraryGridMaxColumns(1);
expect(useAuthStore.getState().libraryGridMaxColumns).toBe(LIBRARY_GRID_MAX_COLUMNS_MIN);
useAuthStore.getState().setLibraryGridMaxColumns(6);
expect(useAuthStore.getState().libraryGridMaxColumns).toBe(6);
});
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);
});
});