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.
120 lines
4.2 KiB
TypeScript
120 lines
4.2 KiB
TypeScript
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;
|
|
}
|