feat(explore): give the album page a primary action that tells the truth
H-13: no Play, no Shuffle, no Add to queue on the album header. The reason it is not just three buttons is that explore-album-details is a catalog page — there is no library-side album detail page at all — so the album shown may be wholly the user's, partly theirs, or not theirs. A Play button that plays 7 of a 40-track release under a label saying 'Play' is the page lying about what is owned, so the button says which: 'Play' when all of it is owned, 'Play 7 of 12' when some is, and no play button at all when none is. albumLibraryStatus() stays as it was — four claims of decreasing confidence OR'd into one tick, the weakest firing when a single recording matches. That is a fine answer to 'is any of this mine' and a useless basis for a button, so ownership() counts the displayed tracklist instead. GetFilePathsByRecordingMBIDs is the catalog-side sibling of GetFilePathsByAlbums: one query, paths only, grouped so the caller keeps the tracklist's order. It is keyed on recording MBID because that is how the backend decides a track is inLibrary, and because MBTrack.LocalID is declared and never written by anything. The local album id is preferred where there is one — a library-only album has no MBIDs at all, and keying on them alone queued nothing. The ticks also get the legend H-13 asks for. They were never unlabelled — the indicator has carried a title and aria-label all along — but a sighted user got a column of green circles and no key.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
import { test, expect, callBinding } from '../support/fixtures.js';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Plan 007 phase 5: `H-13` — the album page can be played from.
|
||||
*
|
||||
* The page is `explore-album-details`; there is no library-side album
|
||||
* detail page at all, so this catalog page is where a Play button has
|
||||
* to live, and what it can honestly claim depends on how much of the
|
||||
* album the user owns.
|
||||
*
|
||||
* What this tier adds over the component tests is that the paths
|
||||
* resolve to something the queue accepts. The first version of this
|
||||
* feature keyed the lookup on recording MBIDs — which is how the
|
||||
* backend decides a track is `inLibrary` — and a library-only album has
|
||||
* none, so Play was wired, labelled correctly, clicked cleanly and
|
||||
* queued **nothing**. Every component test still passed.
|
||||
*/
|
||||
test.describe('playing an album from its page', () => {
|
||||
test.beforeEach(async ({ app }) => {
|
||||
await openFirstAlbum(app);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ app }) => {
|
||||
// The suite shares one backend process in file order, and a queue
|
||||
// left full is state the next spec did not ask for.
|
||||
await callBinding(app, 'queue.Queue.Clear', []);
|
||||
await app.getByTestId('nav-tracks').click();
|
||||
});
|
||||
|
||||
test('Play queues what the user owns of it', async ({ app }) => {
|
||||
const play = app
|
||||
.locator('explore-album-details')
|
||||
.locator('[data-testid="album-play"]');
|
||||
|
||||
// The fixture library is untagged, so this album is wholly local
|
||||
// and the button carries no count.
|
||||
await expect(play).toContainText('Play');
|
||||
|
||||
await play.click();
|
||||
|
||||
await expect.poll(() => queueLength(app)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('Add to queue appends rather than replacing', async ({ app }) => {
|
||||
const details = app.locator('explore-album-details');
|
||||
|
||||
await details.locator('[data-testid="album-play"]').click();
|
||||
await expect.poll(() => queueLength(app)).toBeGreaterThan(0);
|
||||
|
||||
const before = await queueLength(app);
|
||||
|
||||
await details.locator('[data-testid="album-queue"]').click();
|
||||
|
||||
await expect.poll(() => queueLength(app)).toBe(before * 2);
|
||||
});
|
||||
|
||||
test('the ticks against the tracks have a legend', async ({ app }) => {
|
||||
// `H-13` calls them unexplained. They were never *unlabelled* — the
|
||||
// indicator has carried a title and an aria-label reading
|
||||
// "Track “X” is in your library" all along — but a sighted user
|
||||
// scanning the page got a column of green circles and no key.
|
||||
await expect(
|
||||
app.locator('explore-album-details').locator('.tracklist-legend'),
|
||||
).toContainText('in your library');
|
||||
});
|
||||
});
|
||||
|
||||
/** Albums → click the second card, which navigates to the album page. */
|
||||
async function openFirstAlbum(app: Page): Promise<void> {
|
||||
await app.getByTestId('nav-albums').click();
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'albums',
|
||||
);
|
||||
|
||||
// The cards come from a virtualizer, so they are not there when the
|
||||
// view is: a click dispatched into an empty grid hits nothing and
|
||||
// silently leaves the app on Albums, which reads as a broken
|
||||
// navigation rather than a race.
|
||||
await expect.poll(() => cardCount(app)).toBeGreaterThan(1);
|
||||
|
||||
// A plain click on a card navigates here; Enter expands the dropdown
|
||||
// instead. Dispatched rather than clicked because the card lives in a
|
||||
// virtualizer inside a shadow root.
|
||||
await app.evaluate(() => {
|
||||
document
|
||||
.querySelector('cover-grid')
|
||||
?.shadowRoot?.querySelectorAll('.album-card')[1]
|
||||
?.dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, composed: true }),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
||||
'data-active-view',
|
||||
'explore-album-details',
|
||||
);
|
||||
await expect(
|
||||
app.locator('explore-album-details').locator('[data-testid="album-play"]'),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function cardCount(app: Page): Promise<number> {
|
||||
return app.evaluate(
|
||||
() =>
|
||||
document
|
||||
.querySelector('cover-grid')
|
||||
?.shadowRoot?.querySelectorAll('.album-card').length ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
async function queueLength(app: Page): Promise<number> {
|
||||
const state = (await callBinding(app, 'queue.Queue.GetState', [])) as {
|
||||
tracks?: unknown[];
|
||||
};
|
||||
|
||||
return state?.tracks?.length ?? 0;
|
||||
}
|
||||
Reference in New Issue
Block a user