mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
d3e5a6b704
* feat(albums): restore scroll position when returning from album detail Save in-page scroll and grid depth when opening an album from All Albums, then on browser back restore filters, preload enough rows, and apply scroll before revealing the grid to avoid a visible jump from the top. * feat(albums): smart back navigation and restore browse session on return Remember the originating route when opening album detail, restore All Albums filters/scroll on back (including explicit returnTo navigation), hide the grid until scroll is applied, and fix filters being cleared after albumBrowseRestore state is stripped from the location. * feat(search): restore Advanced Search session when returning from album Stash filters and results when leaving /search/advanced for album detail, then restore them on back navigation (POP or returnTo with advancedSearchRestore). * feat(search): restore Advanced Search album row scroll on return from album Save horizontal scrollLeft when opening an album from Advanced Search and reapply it via AlbumRow on return; keep main viewport at top. Add snapshot helpers and session stash fields; extend AlbumRow with restoreScrollLeft. * feat(search): restore Advanced Search session scroll and artist return path Save filters, main scroll, and album-row scroll when leaving to album or artist; restore without flash via hidden-until-ready. Add useNavigateToArtist, restoreMainViewportScroll helper, and AppShell scroll reset only on pathname change. * feat(search): speed up Advanced Search back restore and year-only queries Reveal the page right after sync scroll instead of blocking on full viewport and album-row restore. Retry local index without the ready gate during sync; use open-ended byYear params on network fallback, matching All Albums browse. * feat(search): restore Advanced Search artist row scroll on back Save leave snapshot when opening artist from ArtistCardLocal, persist artistRowScrollLeft in session stash, and keep row restore targets in refs so horizontal scroll survives finishLeaveRestoreUi like vertical scrollTop. * feat(nav): route mouse back on album/artist detail like UI back Trap history popstate when returnTo is set and call navigateAlbumDetailBack so browser/mouse back restores browse/search session the same way as the header button. * feat(artists): restore browse filters and scroll on back from artist detail Persist Artists page filters, view settings, and vertical scroll when opening an artist and returning via UI or mouse back, matching All Albums behavior. * feat(search): unify quick and advanced search; fix LiveSearch dismiss on Enter Serve /search and /search/advanced from one page with shared session restore and scroll snapshot. Reset live search overlay state when navigating to full search so the dropdown does not linger or reopen. * feat(tracks): unify with search session and restore scroll on back Route /tracks through AdvancedSearch with shared leave snapshot, song browse stash, and main-viewport scroll restore when returning from album or artist detail. Wait for hero/rails layout before applying scroll. * refactor(search): rename AdvancedSearch page to SearchBrowsePage The shared route shell serves /search, /search/advanced, and /tracks; rename the page component and refresh stale file references in comments. * feat(albums): restore New Releases and Random Albums on back from detail Unify album grid leave-restore with surface-scoped session stash, live scroll snapshot sync, and in-page scroll for Random Albums. Keep the same random batch when returning from album detail; Refresh fetches anew and scrolls up. * docs: add CHANGELOG and credits for PR #936
326 lines
10 KiB
TypeScript
326 lines
10 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { onInvoke } from '@/test/mocks/tauri';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
|
import {
|
|
resolveTrackCoverArtId,
|
|
runLocalAdvancedSearch,
|
|
runLocalSongBrowse,
|
|
runNetworkAdvancedYearAlbums,
|
|
trackToSong,
|
|
tryRunLocalAdvancedSearch,
|
|
} from './advancedSearchLocal';
|
|
import * as albumBrowseNetwork from './albumBrowseNetwork';
|
|
|
|
const opts = (over: Partial<Parameters<typeof runLocalAdvancedSearch>[1]> = {}) => ({
|
|
query: '',
|
|
genre: '',
|
|
yearFrom: '',
|
|
yearTo: '',
|
|
bpmFrom: '',
|
|
bpmTo: '',
|
|
moodGroup: '',
|
|
losslessOnly: false,
|
|
resultType: 'all' as const,
|
|
...over,
|
|
});
|
|
|
|
const ready = () =>
|
|
onInvoke('library_get_status', () => ({
|
|
serverId: 's1',
|
|
libraryScope: '',
|
|
syncPhase: 'ready',
|
|
capabilityFlags: 0,
|
|
libraryTier: 'unknown',
|
|
syncedAt: 0,
|
|
}));
|
|
|
|
describe('runLocalAdvancedSearch', () => {
|
|
beforeEach(() => {
|
|
useLibraryIndexStore.setState({ masterEnabled: true });
|
|
});
|
|
|
|
it('returns null (→ network fallback) when the index is not ready', async () => {
|
|
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
|
|
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
|
|
expect(res).toBeNull();
|
|
});
|
|
|
|
it('returns null when the index is disabled for the server', async () => {
|
|
useLibraryIndexStore.setState({ masterEnabled: false });
|
|
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
|
|
expect(res).toBeNull();
|
|
});
|
|
|
|
it('passes libraryScope from the sidebar music library filter', async () => {
|
|
useAuthStore.setState({ musicLibraryFilterByServer: { s1: 'lib7' } });
|
|
ready();
|
|
let captured: unknown;
|
|
onInvoke('library_advanced_search', (args) => {
|
|
captured = args;
|
|
return {
|
|
artists: [],
|
|
albums: [],
|
|
tracks: [],
|
|
totals: { artists: 0, albums: 0, tracks: 0 },
|
|
source: 'local',
|
|
};
|
|
});
|
|
await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
|
|
expect(captured).toMatchObject({ request: { libraryScope: 'lib7' } });
|
|
});
|
|
|
|
it('passes lossless is_true filter to library_advanced_search', async () => {
|
|
ready();
|
|
let captured: unknown;
|
|
onInvoke('library_advanced_search', (args) => {
|
|
captured = args;
|
|
return {
|
|
artists: [],
|
|
albums: [],
|
|
tracks: [],
|
|
totals: { artists: 0, albums: 0, tracks: 0 },
|
|
source: 'local',
|
|
};
|
|
});
|
|
await runLocalAdvancedSearch('s1', opts({ losslessOnly: true }), 100);
|
|
expect(captured).toMatchObject({
|
|
request: { filters: [{ field: 'lossless', op: 'is_true' }] },
|
|
});
|
|
});
|
|
|
|
it('passes bpm between filter to library_advanced_search', async () => {
|
|
ready();
|
|
let captured: unknown;
|
|
onInvoke('library_advanced_search', (args) => {
|
|
captured = args;
|
|
return {
|
|
artists: [],
|
|
albums: [],
|
|
tracks: [],
|
|
totals: { artists: 0, albums: 0, tracks: 0 },
|
|
source: 'local',
|
|
};
|
|
});
|
|
await runLocalAdvancedSearch('s1', opts({ bpmFrom: '120', bpmTo: '130' }), 100);
|
|
expect(captured).toMatchObject({
|
|
request: { filters: [{ field: 'bpm', op: 'between', value: 120, valueTo: 130 }] },
|
|
});
|
|
});
|
|
|
|
it('resolveTrackCoverArtId falls back to albumId when coverArtId is empty', () => {
|
|
expect(
|
|
resolveTrackCoverArtId(
|
|
{ coverArtId: undefined, albumId: 'al-42' },
|
|
{ coverArt: '', albumId: 'al-42' },
|
|
),
|
|
).toBe('al-42');
|
|
expect(resolveTrackCoverArtId({ coverArtId: 'cv1', albumId: 'al-42' })).toBe('cv1');
|
|
});
|
|
|
|
it('resolveTrackCoverArtId prefers raw_json mf art over stale index column', () => {
|
|
expect(
|
|
resolveTrackCoverArtId(
|
|
{ coverArtId: 'mf-disc1', albumId: 'al-box' },
|
|
{ coverArt: 'mf-disc2', albumId: 'al-box', discNumber: 2 },
|
|
),
|
|
).toBe('mf-disc2');
|
|
});
|
|
|
|
it('trackToSong sets coverArt from albumId when the index row has no cover_art_id', () => {
|
|
const song = trackToSong({
|
|
serverId: 's1',
|
|
id: 't1',
|
|
title: 'T',
|
|
album: 'Alb',
|
|
albumId: 'al-42',
|
|
durationSec: 100,
|
|
syncedAt: 0,
|
|
rawJson: { id: 't1', title: 'T', artist: 'A', album: 'Alb', albumId: 'al-42', duration: 100 },
|
|
});
|
|
expect(song.coverArt).toBe('al-42');
|
|
});
|
|
|
|
it('trackToSong keeps resolved bpm and source over rawJson tag', () => {
|
|
const song = trackToSong({
|
|
serverId: 's1',
|
|
id: 't1',
|
|
title: 'T',
|
|
album: 'Alb',
|
|
durationSec: 100,
|
|
syncedAt: 0,
|
|
bpm: 128,
|
|
bpmSource: 'analysis',
|
|
rawJson: { id: 't1', title: 'T', artist: 'A', album: 'Alb', albumId: 'al1', duration: 100, bpm: 90 },
|
|
});
|
|
expect(song.bpm).toBe(128);
|
|
expect(song.localBpmSource).toBe('analysis');
|
|
});
|
|
|
|
it('prefers rawJson, falls back to hot columns, and reports the full total', async () => {
|
|
ready();
|
|
onInvoke('library_advanced_search', () => ({
|
|
artists: [],
|
|
albums: [],
|
|
tracks: [
|
|
{
|
|
serverId: 's1', id: 't1', title: 'Hot Title', album: 'Alb', albumId: 'al1',
|
|
durationSec: 100, syncedAt: 0,
|
|
// rawJson is the authoritative original song — must win.
|
|
rawJson: {
|
|
id: 't1', title: 'Raw Title', artist: 'Raw Artist', album: 'Alb', albumId: 'al1',
|
|
duration: 100, contributors: [{ role: 'composer', artist: { name: 'C' } }],
|
|
},
|
|
},
|
|
{
|
|
serverId: 's1', id: 't2', title: 'Only Hot', album: 'Alb2', albumId: 'al2',
|
|
artist: 'Hot Artist', durationSec: 200, year: 1999, genre: 'Rock',
|
|
starredAt: 1_700_000_000_000, syncedAt: 0,
|
|
rawJson: {}, // sparse → hot-column fallback
|
|
},
|
|
],
|
|
totals: { artists: 0, albums: 0, tracks: 42 },
|
|
appliedFilters: [],
|
|
source: 'local',
|
|
}));
|
|
|
|
const res = await runLocalAdvancedSearch('s1', opts({ resultType: 'songs' }), 100);
|
|
expect(res).not.toBeNull();
|
|
expect(res!.songs).toHaveLength(2);
|
|
|
|
// rawJson wins where present + carries OpenSubsonic extras.
|
|
expect(res!.songs[0].title).toBe('Raw Title');
|
|
expect(res!.songs[0].artist).toBe('Raw Artist');
|
|
expect(res!.songs[0].contributors).toBeDefined();
|
|
|
|
// hot-column fallback when rawJson is sparse.
|
|
expect(res!.songs[1].title).toBe('Only Hot');
|
|
expect(res!.songs[1].artist).toBe('Hot Artist');
|
|
expect(res!.songs[1].year).toBe(1999);
|
|
expect(res!.songs[1].genre).toBe('Rock');
|
|
expect(res!.songs[1].starred).toBeTruthy();
|
|
|
|
// Total is the full match count, not the page size.
|
|
expect(res!.songsTotal).toBe(42);
|
|
});
|
|
|
|
it('returns null without throwing when the local query errors', async () => {
|
|
ready();
|
|
onInvoke('library_advanced_search', () => {
|
|
throw new Error('boom');
|
|
});
|
|
const res = await runLocalAdvancedSearch('s1', opts({ query: 'x' }), 100);
|
|
expect(res).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('runLocalSongBrowse', () => {
|
|
beforeEach(() => {
|
|
useLibraryIndexStore.setState({ masterEnabled: true });
|
|
});
|
|
|
|
it('returns null for a missing server id (→ network browse)', async () => {
|
|
expect(await runLocalSongBrowse(null, 0, 50)).toBeNull();
|
|
});
|
|
|
|
it('returns null (→ network browse) when the index is not ready', async () => {
|
|
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
|
|
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
|
|
});
|
|
|
|
it('returns null when the response is not local', async () => {
|
|
ready();
|
|
onInvoke('library_advanced_search', () => ({
|
|
artists: [], albums: [], tracks: [],
|
|
totals: { artists: 0, albums: 0, tracks: 0 }, appliedFilters: [], source: 'network',
|
|
}));
|
|
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
|
|
});
|
|
|
|
it('maps the local browse page to Subsonic songs (rawJson wins)', async () => {
|
|
ready();
|
|
onInvoke('library_advanced_search', () => ({
|
|
artists: [],
|
|
albums: [],
|
|
tracks: [
|
|
{
|
|
serverId: 's1', id: 't1', title: 'Hot', album: 'Alb', albumId: 'al1',
|
|
durationSec: 100, syncedAt: 0,
|
|
rawJson: { id: 't1', title: 'Raw', artist: 'Raw Artist', album: 'Alb', albumId: 'al1', duration: 100 },
|
|
},
|
|
],
|
|
totals: { artists: 0, albums: 0, tracks: 1 }, appliedFilters: [], source: 'local',
|
|
}));
|
|
const songs = await runLocalSongBrowse('s1', 0, 50);
|
|
expect(songs).not.toBeNull();
|
|
expect(songs!).toHaveLength(1);
|
|
expect(songs![0].title).toBe('Raw');
|
|
expect(songs![0].artist).toBe('Raw Artist');
|
|
});
|
|
|
|
it('returns null without throwing on error', async () => {
|
|
ready();
|
|
onInvoke('library_advanced_search', () => {
|
|
throw new Error('boom');
|
|
});
|
|
expect(await runLocalSongBrowse('s1', 0, 50)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('tryRunLocalAdvancedSearch', () => {
|
|
beforeEach(() => {
|
|
useLibraryIndexStore.setState({ masterEnabled: true });
|
|
});
|
|
|
|
it('retries without the ready gate when sync is still in progress', async () => {
|
|
onInvoke('library_get_status', () => ({
|
|
serverId: 's1',
|
|
libraryScope: '',
|
|
syncPhase: 'initial_sync',
|
|
localTrackCount: 100,
|
|
serverTrackCount: 1000,
|
|
capabilityFlags: 0,
|
|
libraryTier: 'unknown',
|
|
syncedAt: 0,
|
|
}));
|
|
let searchCalls = 0;
|
|
onInvoke('library_advanced_search', () => {
|
|
searchCalls += 1;
|
|
return {
|
|
source: 'local',
|
|
artists: [],
|
|
albums: [],
|
|
tracks: [],
|
|
totals: { artists: 0, albums: 0, tracks: 0 },
|
|
appliedFilters: ['year'],
|
|
};
|
|
});
|
|
const res = await tryRunLocalAdvancedSearch('s1', opts({ yearFrom: '2020' }), 100);
|
|
expect(res).not.toBeNull();
|
|
expect(searchCalls).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('runNetworkAdvancedYearAlbums', () => {
|
|
it('passes open-ended year bounds to album browse (not 1900…now defaults)', async () => {
|
|
const spy = vi.spyOn(albumBrowseNetwork, 'fetchAlbumBrowseNetwork').mockResolvedValue({
|
|
albums: [{
|
|
id: 'a1',
|
|
name: 'Al',
|
|
artist: 'Ar',
|
|
artistId: 'ar1',
|
|
songCount: 1,
|
|
duration: 100,
|
|
}],
|
|
hasMore: false,
|
|
});
|
|
await runNetworkAdvancedYearAlbums(opts({ yearTo: '1990' }), 100);
|
|
expect(spy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ year: { to: 1990 } }),
|
|
0,
|
|
100,
|
|
);
|
|
spy.mockRestore();
|
|
});
|
|
});
|