Files
yellowjacket/frontend/test/components/artist-release-menu.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

173 lines
5.0 KiB
TypeScript

/**
* The context menu on a release card on the artist page.
*
* The page already had one, for the top *tracks*, and the release cards
* — which are most of the page — had none: a right-click did whatever
* the browser does and the keyboard had no way in at all.
*
* What is worth pinning is not that the menu opens. It is that the
* items shown match what the release can actually do, because the three
* cases genuinely differ: an owned release has files to queue, an
* unowned catalog release has nothing to play but can be requested, and
* a library-only release has no catalog id, so it can be played and is
* the one case with nothing to view upstream. A menu that offers Play
* on a release with no files is the fault `library-status-indicator`
* was rewritten to stop making, one control over.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-artist-details/explore-artist-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow } from '@test/support/render';
const ARTIST = 'artist-0001';
/** The labels of the open menu's items, trimmed. */
function menuItems(el: LitElement): string[] {
const panel = shadow(el, '.context-menu-panel');
if (!panel) return [];
return [...panel.querySelectorAll('wa-dropdown-item')].map(
(item) => item.textContent?.trim() ?? '',
);
}
/** Right-click the nth discography card and let the menu render. */
async function openMenuOnAlbum(el: LitElement, index: number): Promise<void> {
const cards = el.shadowRoot?.querySelectorAll('.album-card') ?? [];
const card = cards[index];
expect(card, `no album card at index ${index}`).toBeTruthy();
card?.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, cancelable: true }),
);
await flush();
}
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupArtist', { mbid: ARTIST, name: 'Tideline' });
stub('explore.Service.TopReleaseGroupsForArtist', []);
stub('explore.Service.TopRecordingsForArtist', []);
stub('explore.Service.SimilarArtists', []);
stub('explore.Service.PrefetchReleases', undefined);
// One of each case, in the order the assertions below index them.
stub('explore.Service.BrowseReleaseGroups', [
{
mbid: 'rg-owned',
title: 'Foreshore',
artistCredit: 'Tideline',
primaryType: 'Album',
inLibrary: true,
localId: 7,
},
{
mbid: 'rg-wanted',
title: 'Backwash',
artistCredit: 'Tideline',
primaryType: 'Album',
},
{
mbid: 'local:12',
title: 'Bootleg Tape',
artistCredit: 'Tideline',
primaryType: 'Album',
localId: 12,
},
]);
});
async function mount(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-artist-details', {
artistMBID: ARTIST,
artistName: 'Tideline',
});
await flush();
return el;
}
describe('the context menu on an artist page release', () => {
it('opens on a right-click and names itself a release menu', async () => {
const el = await mount();
await openMenuOnAlbum(el, 0);
const panel = shadow(el, '.context-menu-panel');
expect(panel).toBeTruthy();
// The panel is shared with the track menu, so a label that does not
// move with the target is confidently wrong rather than merely
// missing.
expect(panel?.getAttribute('aria-label')).toBe('Release actions');
});
it('offers playback for a release with local files', async () => {
const el = await mount();
await openMenuOnAlbum(el, 0);
const items = menuItems(el);
expect(items).toContain('Play');
expect(items).toContain('Add to Queue');
expect(items).toContain('Play Next');
// Owned: there is nothing left to ask for.
expect(items).not.toContain('Want This');
});
it('offers a request, and no playback, for a release nobody owns', async () => {
const el = await mount();
await openMenuOnAlbum(el, 1);
const items = menuItems(el);
expect(items).not.toContain('Play');
expect(items).not.toContain('Add to Queue');
expect(items).toContain('Want This');
expect(items).toContain('View on MusicBrainz');
});
it('drops the catalog items for a library-only release', async () => {
const el = await mount();
await openMenuOnAlbum(el, 2);
const items = menuItems(el);
// It has files, so it plays…
expect(items).toContain('Play');
// …but a `local:` id names nothing upstream, and wanting something
// already in the library is not a thing to offer.
expect(items).not.toContain('View on MusicBrainz');
expect(items).not.toContain('Want This');
});
it('opens from the keyboard on Shift+F10', async () => {
const el = await mount();
const card = el.shadowRoot?.querySelectorAll('.album-card')[0];
card?.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'F10',
shiftKey: true,
bubbles: true,
cancelable: true,
}),
);
await flush();
expect(shadow(el, '.context-menu-panel')).toBeTruthy();
});
});