Files
psysonic/src/utils/buildInfiniteQueueCandidates.test.ts
T
Frank Stellmacher 006635de4b refactor(api): F.49 — extract library + artists + ratings (#614)
Three domain-eng modules peel ~316 LOC of fetch/mapping out of
`api/subsonic.ts`:

- `subsonicLibrary.ts` — browse + random + per-song fetch
  (`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`,
  `getRandomAlbums`, `getAlbumList`, `getRandomSongs`,
  `getRandomSongsFiltered`, `getSong`, `getAlbum`,
  `filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus
  the private `albumIdsInActiveLibraryScope` cache).
- `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`,
  `getArtistInfo`, `getTopSongs`, `getSimilarSongs2`,
  `getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and
  `similarSongsRequestCount` for the per-library scoping fallback.
- `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus
  `prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers
  with the shared 7-min cache. Calls back into Library/Artists for
  the per-id fetch.

51 external call sites migrated to direct imports. No re-export
shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts`
keep their `RATING_CACHE_TTL` constant locally (same 7-min window).

subsonic.ts: 1078 → 762 LOC (−316).
2026-05-13 00:23:53 +02:00

251 lines
9.2 KiB
TypeScript

/**
* Characterization for `buildInfiniteQueueCandidates` (Instant-Mix-style
* top-up source for the infinite queue).
*
* Originally lived in `playerStore.ts`; extracted in M0 of the frontend
* refactor (2026-05-12). This test pins the artist-first / random-fallback
* order, the dedup contract against existingIds, and the autoAdded flag.
*/
import { getSimilarSongs2, getTopSongs } from '../api/subsonicArtists';
import { getRandomSongs } from '../api/subsonicLibrary';
import type { Track } from '../store/playerStoreTypes';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../api/subsonicArtists', () => ({
getSimilarSongs2: vi.fn(),
getTopSongs: vi.fn(),
}));
vi.mock('../api/subsonicLibrary', () => ({
getRandomSongs: vi.fn(),
}));
vi.mock('./mixRatingFilter', () => ({
getMixMinRatingsConfigFromAuth: vi.fn(),
enrichSongsForMixRatingFilter: vi.fn(),
passesMixMinRatings: vi.fn(),
}));
import { buildInfiniteQueueCandidates } from './buildInfiniteQueueCandidates';
import {
enrichSongsForMixRatingFilter,
getMixMinRatingsConfigFromAuth,
} from './mixRatingFilter';
import { makeSubsonicSong } from '@/test/helpers/factories';
const seed = (overrides: Partial<Track> = {}): Track => ({
id: 'seed',
title: 'Seed',
artist: 'Artist A',
album: 'Album A',
albumId: 'al-A',
artistId: 'ar-A',
duration: 180,
genre: 'Rock',
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
// Default mocks — individual tests override as needed. The random-topup loop
// calls getRandomSongs unconditionally when artist sources don't fill `count`,
// so a default empty resolution avoids "Cannot read properties of undefined".
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getRandomSongs).mockResolvedValue([]);
// Default: filter disabled — the function then short-circuits the enrich path.
vi.mocked(getMixMinRatingsConfigFromAuth).mockReturnValue({
enabled: false,
minSong: 0,
minAlbum: 0,
minArtist: 0,
});
// Deterministic shuffle: Math.random()=0 collapses Fisher-Yates to a known order.
vi.spyOn(Math, 'random').mockReturnValue(0);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('buildInfiniteQueueCandidates', () => {
it('asks for similar + top in parallel when seedTrack has artistId + artist', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
await buildInfiniteQueueCandidates(seed(), new Set(), 5);
expect(getSimilarSongs2).toHaveBeenCalledWith('ar-A');
expect(getTopSongs).toHaveBeenCalledWith('Artist A');
});
it('skips similar when artistId is missing', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
await buildInfiniteQueueCandidates(seed({ artistId: undefined }), new Set(), 5);
expect(getSimilarSongs2).not.toHaveBeenCalled();
expect(getTopSongs).toHaveBeenCalledWith('Artist A');
});
it('skips top when artist name is missing', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
vi.mocked(getTopSongs).mockResolvedValue([]);
await buildInfiniteQueueCandidates(seed({ artist: '' }), new Set(), 5);
expect(getSimilarSongs2).toHaveBeenCalledWith('ar-A');
expect(getTopSongs).not.toHaveBeenCalled();
});
it('skips both when seedTrack is null', async () => {
vi.mocked(getRandomSongs).mockResolvedValue([]);
await buildInfiniteQueueCandidates(null, new Set(), 5);
expect(getSimilarSongs2).not.toHaveBeenCalled();
expect(getTopSongs).not.toHaveBeenCalled();
});
it('marks every returned candidate with autoAdded=true', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([
makeSubsonicSong({ id: 'sim-1' }),
makeSubsonicSong({ id: 'sim-2' }),
]);
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
vi.mocked(getRandomSongs).mockResolvedValue([]);
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
expect(out.length).toBeGreaterThan(0);
for (const t of out) expect(t.autoAdded).toBe(true);
});
it('excludes the seedTrack id and existingIds from the result', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([
makeSubsonicSong({ id: 'seed' }), // self → excluded
makeSubsonicSong({ id: 'already-in-queue' }), // in existingIds → excluded
makeSubsonicSong({ id: 'fresh-1' }),
]);
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getRandomSongs).mockResolvedValue([]);
const out = await buildInfiniteQueueCandidates(seed(), new Set(['already-in-queue']), 5);
const ids = out.map(t => t.id);
expect(ids).toContain('fresh-1');
expect(ids).not.toContain('seed');
expect(ids).not.toContain('already-in-queue');
});
it('falls back to getRandomSongs when artist sources are empty', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getRandomSongs).mockResolvedValue([
makeSubsonicSong({ id: 'rnd-1' }),
makeSubsonicSong({ id: 'rnd-2' }),
makeSubsonicSong({ id: 'rnd-3' }),
]);
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 3);
expect(getRandomSongs).toHaveBeenCalled();
expect(out.map(t => t.id).sort()).toEqual(['rnd-1', 'rnd-2', 'rnd-3']);
});
it('passes the seed track genre to getRandomSongs', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getRandomSongs).mockResolvedValue([makeSubsonicSong({ id: 'rnd-1' })]);
await buildInfiniteQueueCandidates(seed({ genre: 'Jazz' }), new Set(), 1);
expect(getRandomSongs).toHaveBeenCalledWith(expect.any(Number), 'Jazz');
});
it('stops after up to 8 random batches when supply is exhausted', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
vi.mocked(getTopSongs).mockResolvedValue([]);
// Each batch returns one same song that's already counted → no progress.
vi.mocked(getRandomSongs).mockResolvedValue([makeSubsonicSong({ id: 'dup' })]);
await buildInfiniteQueueCandidates(seed(), new Set(['dup']), 5);
// Cap is 8 batches.
expect(vi.mocked(getRandomSongs).mock.calls.length).toBeLessThanOrEqual(8);
});
it('breaks the random loop early when getRandomSongs returns an empty batch', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getRandomSongs).mockResolvedValue([]);
await buildInfiniteQueueCandidates(seed(), new Set(), 5);
// First batch is empty → loop breaks immediately, no second call.
expect(vi.mocked(getRandomSongs).mock.calls.length).toBe(1);
});
it('survives a rejected getSimilarSongs2 call (catches and treats as empty)', async () => {
vi.mocked(getSimilarSongs2).mockRejectedValue(new Error('boom'));
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
vi.mocked(getRandomSongs).mockResolvedValue([]);
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
expect(out.map(t => t.id)).toContain('top-1');
});
it('survives a rejected getTopSongs call (catches and treats as empty)', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
vi.mocked(getTopSongs).mockRejectedValue(new Error('boom'));
vi.mocked(getRandomSongs).mockResolvedValue([]);
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
expect(out.map(t => t.id)).toContain('sim-1');
});
it('returns at most `count` items even when sources oversupply', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue(
Array.from({ length: 10 }, (_, i) => makeSubsonicSong({ id: `sim-${i}` })),
);
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getRandomSongs).mockResolvedValue([]);
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 3);
expect(out).toHaveLength(3);
});
it('runs the rating-filter enrichment pipeline when filter is enabled', async () => {
vi.mocked(getMixMinRatingsConfigFromAuth).mockReturnValue({
enabled: true,
minSong: 3,
minAlbum: 0,
minArtist: 0,
});
vi.mocked(enrichSongsForMixRatingFilter).mockResolvedValue([
makeSubsonicSong({ id: 'sim-1' }),
]);
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getRandomSongs).mockResolvedValue([]);
await buildInfiniteQueueCandidates(seed(), new Set(), 5);
expect(enrichSongsForMixRatingFilter).toHaveBeenCalled();
});
it('returns an empty array when nothing usable is found', async () => {
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
vi.mocked(getTopSongs).mockResolvedValue([]);
vi.mocked(getRandomSongs).mockResolvedValue([]);
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
expect(out).toEqual([]);
});
});