Files
yellowjacket/frontend/test/components/library-status.test.ts
T
logan 451b46e63c fix(explore): show a requested album as queued, not absent
`library-status-indicator` has had three states since it was written
and produced two: all eight call sites were a two-way ternary between
`in-library` and `not-in-library`, so the `queued` state it styles and
labels was unreachable.

The result was the app contradicting itself on one page. An album added
to the request list showed a plus and announced "is not in your
library", forty pixels from a filled button reading "Wanted".

The rule was written at eight places, which is why none of them had all
of it, so it is `utils/library-status.ts` now: owning outranks wanting,
a satisfied request is not queued, and a request is by MBID — a track
inside a requested album is not itself requested and still says so.

`explore-view` gains the `downloadStore` subscription both detail views
already had, registered `whileActive` because it is a cached view that
never unmounts. `top-results-row` needs its own: its host re-rendering
sets the same `results` array back, so Lit stops at the property and
the row never hears about a change.
2026-08-13 14:13:30 -04:00

186 lines
5.9 KiB
TypeScript

/**
* Plan 009 phase 1: the badge tells the truth.
*
* `library-status-indicator` has had three states since it was written
* — a tick, an hourglass and a plus — and the hourglass was produced by
* nothing. All eight call sites were a two-way ternary, so an album the
* user had already asked for through "Want this" showed a plus and said
* it was not in their library, on the same page as a filled button
* reading "Wanted".
*
* Two tiers of assertion here, and the second is the one that would
* have failed:
*
* - the rule itself, which is now written once, and
* - a rendered Explore result whose release group is requested,
* because a helper nobody calls is a rule nobody follows.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import '@components/explore-view/explore-view';
import type { Request } from '@store/download-store';
import { libraryStatusFor } from '@utils/library-status';
import { Events } from '../../src/events';
import { emit, flush, stub } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
const SEARCH = 'explore.Service.SearchLocal';
function request(overrides: Partial<Request>): Request {
return {
id: 1,
mbid: 'rg-wanted',
entity: 'release-group',
libraryId: 1,
artist: 'An Artist',
title: 'Wanted Album',
scope: 'future',
secondary: false,
state: 'wanted',
attempts: 0,
...overrides,
} as Request;
}
/** Put a request list into the store the way the backend does. */
async function withRequests(rows: Request[]): Promise<void> {
stub('download.Service.ListRequests', rows);
emit(Events.RequestsChanged);
await flush();
}
const releaseGroup = (mbid: string, title: string) => ({
mbid,
title,
artistCredit: 'An Artist',
artistMbid: 'ar-1',
primaryType: 'Album',
firstReleaseDate: '1994-05-01',
popularity: 100,
listenerCount: 10,
inLibrary: false,
secondaryTypes: [],
});
describe('libraryStatusFor', () => {
beforeEach(async () => {
await withRequests([]);
});
it('says nothing about an entity with no MBID to ask about', () => {
expect(libraryStatusFor(false, '')).toBe('not-in-library');
expect(libraryStatusFor(false, undefined)).toBe('not-in-library');
});
it('reports a request as queued', async () => {
await withRequests([request({ mbid: 'rg-wanted' })]);
expect(libraryStatusFor(false, 'rg-wanted')).toBe('queued');
});
it('lets owning outrank wanting', async () => {
// Both are true of an album that has arrived but whose request has
// not been retired yet. What the user has is not news; what they
// have is.
await withRequests([request({ mbid: 'rg-wanted' })]);
expect(libraryStatusFor(true, 'rg-wanted')).toBe('in-library');
});
it('does not call a satisfied request queued', async () => {
// Nothing is coming: the request is history. An unowned entity with
// a satisfied request is a stale row, not a download in flight.
await withRequests([request({ mbid: 'rg-done', state: 'satisfied' })]);
expect(libraryStatusFor(false, 'rg-done')).toBe('not-in-library');
});
it('does count a paused request, which the user did ask for', async () => {
await withRequests([request({ mbid: 'rg-paused', state: 'paused' })]);
expect(libraryStatusFor(false, 'rg-paused')).toBe('queued');
});
it('answers about the entity it is on, not the one containing it', () => {
// A request is by MBID. A track inside a requested album is not
// itself requested, and saying otherwise promises that clicking it
// would find that recording.
expect(libraryStatusFor(false, 'recording-inside-rg-wanted')).toBe(
'not-in-library',
);
});
});
describe('<explore-view> badges', () => {
beforeEach(async () => {
stub('explore.Service.GetThumbnails', []);
stub('explore.Service.GetThumbnail', '');
stub('explore.Service.GetArtistImageURL', '');
stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' });
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetAllTracks', []);
await withRequests([]);
});
async function searchFor(rows: ReturnType<typeof releaseGroup>[]) {
stub(SEARCH, {
artists: [],
releaseGroups: rows,
recordings: [],
topResults: [],
});
const el = await fixture('explore-view');
(el as unknown as { viewActivated(): void }).viewActivated();
await flush();
const input = shadow<HTMLInputElement>(el, 'input');
if (input) {
input.value = 'anything';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
// The search box debounces, so waiting a frame measures the input
// echoing its own character.
await new Promise((resolve) => setTimeout(resolve, 300));
await flush();
await el.updateComplete;
return el;
}
it('shows a requested album as queued rather than as absent', async () => {
await withRequests([request({ mbid: 'rg-wanted' })]);
const el = await searchFor([
releaseGroup('rg-wanted', 'Wanted Album'),
releaseGroup('rg-other', 'Some Other Album'),
]);
const badges = shadowAll(el, 'library-status-indicator');
expect(badges.length).toBeGreaterThanOrEqual(2);
expect(badges.map((b) => b.getAttribute('status'))).toEqual([
'queued',
'not-in-library',
]);
});
it('re-renders when the request list changes underneath it', async () => {
// A background reconcile pass expands an artist or retires a want
// without this page doing anything, so the badge has to be told.
const el = await searchFor([releaseGroup('rg-wanted', 'Wanted Album')]);
expect(shadow(el, 'library-status-indicator')?.getAttribute('status')).toBe(
'not-in-library',
);
await withRequests([request({ mbid: 'rg-wanted' })]);
await el.updateComplete;
expect(shadow(el, 'library-status-indicator')?.getAttribute('status')).toBe(
'queued',
);
});
});