Files
yellowjacket/frontend/test/components/artist-prefetch.test.ts
T
yonluandClaude Opus 5 edb13a6f39 perf(explore): ask the disk once, prefetch once, and menu the releases
Three things on the Explore surfaces, all about not asking twice.

A portrait already on disk costs no network call. explore-view seeded
only from the library store — owned artists, which on a catalog search
is nearly none of the results — and sent everything else to
GetArtistImageURL, the resolving entry point, one await at a time.
GetArtistImagesCachedPaths asks the disk about every unresolved artist
in one call, and only what it does not answer reaches the resolver,
in parallel.

The artist page's two sections both wanted PrefetchReleases and each
called it, so the most expensive call the app makes was issued twice
for an overlapping set on a 1 req/s limiter. They are collected and
sent once on a microtask, and prefetchRequested stops the cold-artist
refetch re-asking for what it already asked for.

The release cards — most of the artist page — had no context menu at
all. They have one now on both release shapes, normalised to a
ReleaseMenuTarget when the menu opens so the union does not reach the
action handlers. It is a discriminated union rather than one nullable
field per kind because the panel is shared with the track menu: that is
what keeps aria-label moving with the target, which is the fault
cover-grid shipped. Which items appear is three different questions —
playback is gated on a local album id, not on "owned", and the request
needs a catalog MBID, so it is absent for a library-only release.

Note on the docs: the CLAUDE.md and NOTES.md prose here was
reconstructed after a mishandled `git stash --keep-index` destroyed the
uncommitted originals. One NOTES.md section is marked as incomplete
where its text could not be recovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 13:34:15 -04:00

120 lines
3.8 KiB
TypeScript

/**
* How much the artist page asks the catalog to warm up.
*
* `PrefetchReleases` fires up to eight `BrowseReleases` calls, which is
* the most expensive request the app makes — every version of a release
* group, with `recordings` and `media`, on a shared rate limiter. The
* page used to call it from *both* the top-releases fetch and the
* discography fetch, and because the backend skips groups it has
* already cached, the second call did not collapse into the first: it
* spent its own cap of eight on the next eight albums. On a cold artist
* `ArtistDiscographyReady` re-runs both fetchers, so one page view could
* queue thirty-two of them.
*
* The rule under test is therefore about call *count*, not content: the
* two sections contribute to one batched request, the top releases lead
* it because that is what a visitor clicks, and nothing is asked for
* twice.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-artist-details/explore-artist-details';
import { stub, emit, flush, resetHarness, calls } from '@test/support/harness';
import { fixture } from '@test/support/render';
const ARTIST = 'artist-0001';
/** Every argument list PrefetchReleases has been called with. */
function prefetchCalls(): string[][] {
return calls('explore.Service.PrefetchReleases').map(
(c) => (c.args[0] as string[]) ?? [],
);
}
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupArtist', {
mbid: ARTIST,
name: 'Tideline',
});
// Top releases and the full discography overlap, as they do in life:
// the top list is a subset of the discography.
stub('explore.Service.TopReleaseGroupsForArtist', [
{ releaseGroupMbid: 'rg-top-1', title: 'Foreshore', artistName: 'Tideline' },
{ releaseGroupMbid: 'rg-top-2', title: 'Backwash', artistName: 'Tideline' },
]);
stub('explore.Service.BrowseReleaseGroups', [
{ mbid: 'rg-top-1', title: 'Foreshore', artistCredit: 'Tideline' },
{ mbid: 'rg-deep-1', title: 'Spring Tide', artistCredit: 'Tideline' },
]);
stub('explore.Service.TopRecordingsForArtist', []);
stub('explore.Service.SimilarArtists', []);
stub('explore.Service.PrefetchReleases', undefined);
});
describe('what the artist page asks the catalog to prefetch', () => {
it('makes one prefetch call for both sections, not one each', async () => {
await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
expect(prefetchCalls().length).toBe(1);
});
it('leads with the top releases and includes the deep cuts', async () => {
await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
const batch = prefetchCalls()[0] ?? [];
expect(batch.slice(0, 2)).toEqual(['rg-top-1', 'rg-top-2']);
expect(batch).toContain('rg-deep-1');
});
it('asks for each release group once, across both sections', async () => {
await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
const batch = prefetchCalls()[0] ?? [];
expect(batch.length).toBe(new Set(batch).size);
expect(batch.filter((m) => m === 'rg-top-1').length).toBe(1);
});
it('does not re-ask on the cold-artist refetch', async () => {
await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
const asked = prefetchCalls().flat().length;
// The background discography fetch reports in, and both fetchers
// run again against the freshly-populated index.
emit('ArtistDiscographyReady', ARTIST);
await flush();
const askedAfter = prefetchCalls().flat().length;
expect(askedAfter).toBe(asked);
});
});