`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.
186 lines
5.9 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|