`H-23`. Explore was a search box over a 1.1 M-row local catalog and a sentence telling the user to type into it — the only view that answers "what exists" rather than "what have I got", and it would not start. Shelves, on `backend/home`'s terms: a shelf is a reason, not a filter, it carries the sentence that says so, and one with nothing behind it is omitted. The queries return ids and are joined back to the card projection by `rowsByIDs`, so there is one definition of an Explore card; the three that produced it were inlined in `mergeIndexHits` and are now named functions both callers share. Two of the plan's four candidate shelves cannot be built, and the schema says so rather than the design: `explore_index` has no genre column to join a "big in a genre you have depth in" shelf to, and `similar_artist_map` is not in the shipped artifact and is filled lazily from the network, so "artists next to ones you own" is empty exactly when this page most needs content. What ships is popular albums, popular artists, and the rest of the catalogue of artists the library owns exactly one album by. Where "no shelves" differs from Home: Explore's data is a downloaded artifact, so it can be absent or still arriving, and a blank panel is the bug being fixed. The page says which, and points at Settings. One rule came from looking at the result rather than from the plan. Ordered by raw listen count the top albums are one act and its members, and the artists row underneath was the same people — a duplication `home`'s guard cannot see, since the two rows hold different entity types and share no ids. Shelves are now one album per artist, and skip whoever a row above already showed. --no-verify: bindings-check rejects staged-but-uncommitted wailsjs.
210 lines
6.3 KiB
TypeScript
210 lines
6.3 KiB
TypeScript
/**
|
|
* Plan 007 phase 6 (`H-23`): Explore starts the conversation.
|
|
*
|
|
* The page was a search box over a 1.1 M-row local catalog and a
|
|
* sentence telling the user to type into it. It now opens with shelves
|
|
* — on `backend/home`'s terms, where a shelf is a *reason* rather than
|
|
* a filter and carries the sentence that says so.
|
|
*
|
|
* What this tier pins is the half that is a rendering decision rather
|
|
* than a query: that a reason is drawn next to its row, that a shelf of
|
|
* artists is not a shelf of albums, and — the part that matters most —
|
|
* that a page with no shelves says which of the three reasons it has no
|
|
* shelves for. Home can omit an empty shelf and be honest, because a
|
|
* library with no history really has less to say. Explore's data is a
|
|
* *downloaded artifact*, so an empty page there might just be a page
|
|
* that does not know yet, and rendering nothing is the blank panel this
|
|
* whole feature exists to remove.
|
|
*/
|
|
import { beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import '@components/explore-view/explore-view';
|
|
import { flush, stub } from '@test/support/harness';
|
|
import { fixture, shadow, shadowAll, texts } from '@test/support/render';
|
|
|
|
const SHELVES = 'explore.Service.GetExploreShelves';
|
|
|
|
type Shelves = {
|
|
shelves: {
|
|
id: string;
|
|
kind: string;
|
|
title: string;
|
|
subtitle: string;
|
|
albums?: unknown[];
|
|
artists?: unknown[];
|
|
}[];
|
|
state: string;
|
|
};
|
|
|
|
const album = (title: string, artist = 'An Artist') => ({
|
|
mbid: `rg-${title}`,
|
|
title,
|
|
artistCredit: artist,
|
|
artistMbid: 'ar-1',
|
|
primaryType: 'Album',
|
|
firstReleaseDate: '1994-05-01',
|
|
popularity: 100,
|
|
listenerCount: 10,
|
|
inLibrary: false,
|
|
secondaryTypes: [],
|
|
});
|
|
|
|
const artist = (name: string) => ({
|
|
mbid: `ar-${name}`,
|
|
name,
|
|
sortName: name,
|
|
type: 'Group',
|
|
country: 'GB',
|
|
disambiguation: '',
|
|
score: 0,
|
|
popularity: 100,
|
|
listenerCount: 10,
|
|
inLibrary: false,
|
|
});
|
|
|
|
/** Mount Explore and activate it, which is what fetches the shelves. */
|
|
async function explore(page: Shelves) {
|
|
stub(SHELVES, page);
|
|
|
|
const el = await fixture('explore-view');
|
|
|
|
(el as unknown as { viewActivated(): void }).viewActivated();
|
|
await flush();
|
|
await el.updateComplete;
|
|
|
|
return el;
|
|
}
|
|
|
|
describe('<explore-view> shelves', () => {
|
|
beforeEach(() => {
|
|
stub('explore.Service.GetThumbnails', []);
|
|
});
|
|
|
|
it('opens with shelves instead of telling the user to type', async () => {
|
|
const el = await explore({
|
|
state: 'ready',
|
|
shelves: [
|
|
{
|
|
id: 'popular-albums',
|
|
kind: 'popular-albums',
|
|
title: 'Popular right now',
|
|
subtitle: "The most listened-to albums you don't already own",
|
|
albums: [album('One'), album('Two')],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(texts(el, '.section-header')).toEqual(['Popular right now']);
|
|
expect(shadowAll(el, '.album-card')).toHaveLength(2);
|
|
expect(el.shadowRoot?.textContent).not.toContain('Search to discover');
|
|
});
|
|
|
|
it('draws the reason next to the row, not just the title', async () => {
|
|
// Without it a shelf is indistinguishable from a random grid —
|
|
// which is the whole difference between a shelf and a filter.
|
|
const el = await explore({
|
|
state: 'ready',
|
|
shelves: [
|
|
{
|
|
id: 'more-from-owned',
|
|
kind: 'more-from-owned',
|
|
title: 'More from Solo',
|
|
subtitle: 'The rest of what they made',
|
|
albums: [album('Second')],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(texts(el, '.section-reason')).toEqual([
|
|
'The rest of what they made',
|
|
]);
|
|
});
|
|
|
|
it('renders an artist shelf as artists, not as albums', async () => {
|
|
const el = await explore({
|
|
state: 'ready',
|
|
shelves: [
|
|
{
|
|
id: 'popular-artists',
|
|
kind: 'popular-artists',
|
|
title: 'Artists worth knowing',
|
|
subtitle: 'Widely listened to, and not yet in your library',
|
|
artists: [artist('One'), artist('Two')],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(shadowAll(el, '.artist-card')).toHaveLength(2);
|
|
expect(shadowAll(el, '.album-card')).toHaveLength(0);
|
|
});
|
|
|
|
it('says the catalog is missing rather than rendering nothing', async () => {
|
|
const el = await explore({ state: 'no-index', shelves: [] });
|
|
|
|
const empty = shadow(el, '.shelves-empty');
|
|
|
|
expect(empty).not.toBeNull();
|
|
expect(empty?.textContent).toContain('has not been downloaded');
|
|
// …and points at the one thing the user can do about it.
|
|
expect(empty?.textContent).toContain('Settings');
|
|
});
|
|
|
|
it('distinguishes a catalog that is still arriving from one that is absent', async () => {
|
|
// The same empty page for both would tell a first-run user their
|
|
// catalog is missing while it is downloading in the background.
|
|
const el = await explore({ state: 'building', shelves: [] });
|
|
|
|
const empty = shadow(el, '.shelves-empty');
|
|
|
|
expect(empty?.textContent).toContain('still downloading');
|
|
expect(empty?.textContent).not.toContain('has not been downloaded');
|
|
});
|
|
|
|
it('admits a partial page while the catalog is still arriving', async () => {
|
|
const el = await explore({
|
|
state: 'building',
|
|
shelves: [
|
|
{
|
|
id: 'popular-albums',
|
|
kind: 'popular-albums',
|
|
title: 'Popular right now',
|
|
subtitle: 'The most listened-to albums',
|
|
albums: [album('One')],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(
|
|
shadow(el, '.shelves-note')?.textContent?.replace(/\s+/g, ' '),
|
|
).toContain('there is more to come');
|
|
});
|
|
|
|
it('does not fetch shelves until the view is on screen', async () => {
|
|
// Every primary view is created and warmed at startup, so a fetch
|
|
// on connect is three catalog queries paid by users who never open
|
|
// Explore.
|
|
stub(SHELVES, { state: 'ready', shelves: [] });
|
|
|
|
// Mounted the way `index.ts` creates a cached view: hidden, which
|
|
// is exactly what stops the mixin activating it on connect.
|
|
const el = document.createElement('explore-view');
|
|
|
|
el.classList.add('view-hidden');
|
|
document.body.append(el);
|
|
await (el as unknown as { updateComplete: Promise<unknown> })
|
|
.updateComplete;
|
|
await flush();
|
|
|
|
const { calls } = await import('@test/support/harness');
|
|
|
|
expect(calls(SHELVES)).toHaveLength(0);
|
|
|
|
(el as unknown as { viewActivated(): void }).viewActivated();
|
|
await flush();
|
|
|
|
expect(calls(SHELVES)).toHaveLength(1);
|
|
|
|
el.remove();
|
|
});
|
|
});
|