Files
yellowjacket/frontend/test/components/explore-shelves.test.ts
T
logan cad673ee3d
Build & publish Arch package / arch-package (push) Successful in 2m2s
CI / check (push) Successful in 3m8s
Search index maintenance / maintain-index (push) Successful in 7s
CI / e2e (push) Canceled after 18s
feat(explore): open the page with shelves instead of a search box
`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.
2026-08-12 18:13:28 -04:00

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();
});
});