mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-28 01:16:40 +00:00
db98d30a78
* fix(playback): keep browsed server when queue plays elsewhere Lucky Mix on a non-playback server now clears the old queue and pins the active server before building. Now Playing metadata uses apiForServer against the queue server instead of forcing ensurePlaybackServerActive on every activeServerId change. * docs: note PR #768 in CHANGELOG and settings credits * fix(now-playing): gate AudioMuse similar artists on playback server Match getArtistInfoForServer credentials: when browsing another server while the queue plays elsewhere, similar-artist count follows the queue server's AudioMuse flag, not the browsed server.
212 lines
8.8 KiB
TypeScript
212 lines
8.8 KiB
TypeScript
/**
|
|
* Regression tests for the id-gating tuple pattern in `useNowPlayingFetchers`.
|
|
*
|
|
* Each id-keyed slot (`artistInfo`, `songMeta`, `albumData`, `discography`) is
|
|
* held as a `{ id, value }` tuple internally and gated on id-match at the
|
|
* return statement. This guarantees that consumers building a `cacheKey` from
|
|
* the current id can never receive a value paired with a previously-current
|
|
* id — the bug that PR #732 fixed inside `NowPlayingInfo.tsx` and that this
|
|
* hook would otherwise leak into every other consumer (e.g. ArtistCard on the
|
|
* NowPlaying page).
|
|
*/
|
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { SubsonicArtistInfo, SubsonicSong, SubsonicAlbum } from '../api/subsonicTypes';
|
|
|
|
vi.mock('../api/subsonicArtists');
|
|
vi.mock('../api/subsonicLibrary');
|
|
vi.mock('../api/bandsintown');
|
|
vi.mock('../api/lastfm');
|
|
|
|
import { getArtistForServer, getArtistInfoForServer, getTopSongsForServer } from '../api/subsonicArtists';
|
|
import { getAlbumForServer, getSongForServer } from '../api/subsonicLibrary';
|
|
import { fetchBandsintownEvents } from '../api/bandsintown';
|
|
import { lastfmGetArtistStats, lastfmGetTrackInfo, lastfmIsConfigured } from '../api/lastfm';
|
|
import { useNowPlayingFetchers, type NowPlayingFetchersDeps } from './useNowPlayingFetchers';
|
|
|
|
// The real getArtistInfo signature returns `Promise<SubsonicArtistInfo>`, but
|
|
// the hook treats `null` as the "no info available" case and stores it as
|
|
// such in its tuple. The tests mirror that — cast to a nullable-returning
|
|
// shape so we can mock the empty case without `as any` at every site.
|
|
const mockArtistInfo = vi.mocked(getArtistInfoForServer) as unknown as {
|
|
mockImplementation: (impl: (serverId: string, id: string) => Promise<SubsonicArtistInfo | null>) => void;
|
|
mockResolvedValue: (v: SubsonicArtistInfo | null) => void;
|
|
};
|
|
|
|
const baseDeps: NowPlayingFetchersDeps = {
|
|
songId: undefined,
|
|
artistId: undefined,
|
|
albumId: undefined,
|
|
artistName: '',
|
|
enableBandsintown: false,
|
|
audiomuseNavidromeEnabled: false,
|
|
lastfmUsername: '',
|
|
currentTrack: null,
|
|
subsonicServerId: 'srv1',
|
|
fetchEnabled: true,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
|
vi.mocked(getArtistForServer).mockResolvedValue({ albums: [] } as any);
|
|
vi.mocked(fetchBandsintownEvents).mockResolvedValue([]);
|
|
vi.mocked(lastfmIsConfigured).mockReturnValue(false);
|
|
vi.mocked(lastfmGetTrackInfo).mockResolvedValue(null);
|
|
vi.mocked(lastfmGetArtistStats).mockResolvedValue(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
/** Deferred promise helper — lets a test step the fetch resolution manually. */
|
|
function deferred<T>() {
|
|
let resolve!: (v: T) => void;
|
|
let reject!: (e?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
describe('useNowPlayingFetchers — id-gated artistInfo', () => {
|
|
it('returns null artistInfo while the previously-resolved info belongs to a different artistId', async () => {
|
|
const a = deferred<SubsonicArtistInfo | null>();
|
|
const b = deferred<SubsonicArtistInfo | null>();
|
|
mockArtistInfo.mockImplementation(async (_sid, id) => {
|
|
if (id === 'art-A') return a.promise;
|
|
if (id === 'art-B') return b.promise;
|
|
return null;
|
|
});
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ artistId }: { artistId: string }) =>
|
|
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
|
|
{ initialProps: { artistId: 'art-A' } },
|
|
);
|
|
|
|
// Before any resolve, no info yet.
|
|
expect(result.current.artistInfo).toBeNull();
|
|
|
|
// Resolve A — artistInfo becomes A's info.
|
|
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
|
await waitFor(() => {
|
|
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'A.jpg' });
|
|
});
|
|
|
|
// Switch to artistId B. Without id-gating, artistInfo would still be A's
|
|
// info paired with the new B id — the bug that PR #732 fixed in the queue
|
|
// info panel. With gating, artistInfo flips to null immediately.
|
|
rerender({ artistId: 'art-B' });
|
|
expect(result.current.artistInfo).toBeNull();
|
|
|
|
// Resolve B — artistInfo now becomes B's info, never paired with A.
|
|
await act(async () => { b.resolve({ largeImageUrl: 'B.jpg' } as SubsonicArtistInfo); });
|
|
await waitFor(() => {
|
|
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
|
|
});
|
|
});
|
|
|
|
it('does not leak a late-arriving resolve for a stale artistId', async () => {
|
|
// Race: artist A's fetch resolves AFTER the consumer switched to B.
|
|
const a = deferred<SubsonicArtistInfo | null>();
|
|
mockArtistInfo.mockImplementation(async (_sid, id) => {
|
|
if (id === 'art-A') return a.promise;
|
|
if (id === 'art-B') return { largeImageUrl: 'B.jpg' } as SubsonicArtistInfo;
|
|
return null;
|
|
});
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ artistId }: { artistId: string }) =>
|
|
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
|
|
{ initialProps: { artistId: 'art-A' } },
|
|
);
|
|
|
|
// Switch to B before A resolves.
|
|
rerender({ artistId: 'art-B' });
|
|
await waitFor(() => {
|
|
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
|
|
});
|
|
|
|
// Late A resolve must not overwrite the displayed value for B.
|
|
await act(async () => { a.resolve({ largeImageUrl: 'A.jpg' } as SubsonicArtistInfo); });
|
|
expect(result.current.artistInfo).toEqual({ largeImageUrl: 'B.jpg' });
|
|
});
|
|
});
|
|
|
|
describe('useNowPlayingFetchers — id-gated songMeta / albumData / discography', () => {
|
|
it('gates songMeta on songId match', async () => {
|
|
const s1 = deferred<SubsonicSong | null>();
|
|
const s2 = deferred<SubsonicSong | null>();
|
|
vi.mocked(getSongForServer).mockImplementation(async (_sid, id) => {
|
|
if (id === 's1') return s1.promise;
|
|
if (id === 's2') return s2.promise;
|
|
return null;
|
|
});
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ songId }: { songId: string }) =>
|
|
useNowPlayingFetchers({ ...baseDeps, songId }),
|
|
{ initialProps: { songId: 's1' } },
|
|
);
|
|
|
|
await act(async () => { s1.resolve({ id: 's1', title: 'Track 1' } as SubsonicSong); });
|
|
await waitFor(() => expect(result.current.songMeta?.title).toBe('Track 1'));
|
|
|
|
rerender({ songId: 's2' });
|
|
expect(result.current.songMeta).toBeNull();
|
|
|
|
await act(async () => { s2.resolve({ id: 's2', title: 'Track 2' } as SubsonicSong); });
|
|
await waitFor(() => expect(result.current.songMeta?.title).toBe('Track 2'));
|
|
});
|
|
|
|
it('gates albumData on albumId match', async () => {
|
|
const al1 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
|
|
const al2 = deferred<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null>();
|
|
vi.mocked(getAlbumForServer).mockImplementation(async (_sid, id) => {
|
|
if (id === 'alb1') return al1.promise as any;
|
|
if (id === 'alb2') return al2.promise as any;
|
|
return null as any;
|
|
});
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ albumId }: { albumId: string }) =>
|
|
useNowPlayingFetchers({ ...baseDeps, albumId }),
|
|
{ initialProps: { albumId: 'alb1' } },
|
|
);
|
|
|
|
await act(async () => { al1.resolve({ album: { id: 'alb1', name: 'A1' } as SubsonicAlbum, songs: [] }); });
|
|
await waitFor(() => expect(result.current.albumData?.album.name).toBe('A1'));
|
|
|
|
rerender({ albumId: 'alb2' });
|
|
expect(result.current.albumData).toBeNull();
|
|
|
|
await act(async () => { al2.resolve({ album: { id: 'alb2', name: 'A2' } as SubsonicAlbum, songs: [] }); });
|
|
await waitFor(() => expect(result.current.albumData?.album.name).toBe('A2'));
|
|
});
|
|
|
|
it('gates discography on artistId match (empty fallback while gated)', async () => {
|
|
const d1 = deferred<{ artist: any; albums: SubsonicAlbum[] }>();
|
|
const d2 = deferred<{ artist: any; albums: SubsonicAlbum[] }>();
|
|
vi.mocked(getArtistForServer).mockImplementation(async (_sid, id) => {
|
|
if (id === 'art-D1') return d1.promise as any;
|
|
if (id === 'art-D2') return d2.promise as any;
|
|
return { albums: [] } as any;
|
|
});
|
|
mockArtistInfo.mockResolvedValue(null);
|
|
|
|
const { result, rerender } = renderHook(
|
|
({ artistId }: { artistId: string }) =>
|
|
useNowPlayingFetchers({ ...baseDeps, artistId, artistName: artistId }),
|
|
{ initialProps: { artistId: 'art-D1' } },
|
|
);
|
|
|
|
await act(async () => { d1.resolve({ artist: {}, albums: [{ id: 'al-D1' } as SubsonicAlbum] }); });
|
|
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['al-D1']));
|
|
|
|
rerender({ artistId: 'art-D2' });
|
|
expect(result.current.discography).toEqual([]);
|
|
|
|
await act(async () => { d2.resolve({ artist: {}, albums: [{ id: 'al-D2' } as SubsonicAlbum] }); });
|
|
await waitFor(() => expect(result.current.discography.map(a => a.id)).toEqual(['al-D2']));
|
|
});
|
|
});
|