Merge remote-tracking branch 'origin/feat/264-explore-cards' into batch/258-272
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Every album card is the same size, and its artwork is a square.
|
||||
*
|
||||
* The size came apart because `explore-view` clamped its cards to a
|
||||
* 130–150px range, so two cards in one row could be different widths —
|
||||
* and since the artwork is square, different *heights* as well. A row
|
||||
* of covers with ragged bottoms is what that looks like.
|
||||
*
|
||||
* What makes the fix hold is that the lines below the art each reserve
|
||||
* their own space (`album-card.css.ts`), so an album with no year, no
|
||||
* release type or a one-character title is not shorter than its
|
||||
* neighbour. This measures that rather than trusting it, because the
|
||||
* next component to format a card is the way it comes back.
|
||||
*
|
||||
* The artwork half is the other change: the container was already
|
||||
* square but the image was `object-fit: cover`, so a non-square cover
|
||||
* was cropped to it. It is `contain` now, and the container has no
|
||||
* background of its own, so a tall cover is inset with the page
|
||||
* showing through beside it.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/explore-view/explore-view';
|
||||
import { flush, stub, resetHarness } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||
import { completenessStore } from '@store/completeness-store';
|
||||
|
||||
const SEARCH = 'explore.Service.SearchLocal';
|
||||
const SHELVES = 'explore.Service.GetExploreShelves';
|
||||
|
||||
/** A 1x1 transparent gif, so the `<img>` branch renders. */
|
||||
const TINY_IMAGE =
|
||||
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
|
||||
/** Release groups chosen so every optional line is present on one and
|
||||
* absent on another — that is what a size regression hides behind. */
|
||||
const ALBUMS = [
|
||||
{
|
||||
mbid: 'rg-1',
|
||||
title: 'A',
|
||||
artistCredit: '',
|
||||
artistMbid: 'ar-1',
|
||||
primaryType: '',
|
||||
firstReleaseDate: '',
|
||||
popularity: 1,
|
||||
listenerCount: 1,
|
||||
secondaryTypes: [],
|
||||
inLibrary: false,
|
||||
localId: 0,
|
||||
},
|
||||
{
|
||||
mbid: 'rg-2',
|
||||
title: 'A Very Long Album Name That Will Certainly Be Truncated By The Card',
|
||||
artistCredit: 'An Artist With A Long Name',
|
||||
artistMbid: 'ar-2',
|
||||
primaryType: 'Album',
|
||||
firstReleaseDate: '1994-05-01',
|
||||
popularity: 1,
|
||||
listenerCount: 1,
|
||||
secondaryTypes: [],
|
||||
inLibrary: false,
|
||||
localId: 0,
|
||||
},
|
||||
{
|
||||
mbid: 'rg-3',
|
||||
title: 'Three',
|
||||
artistCredit: 'Another',
|
||||
artistMbid: 'ar-3',
|
||||
primaryType: 'EP',
|
||||
firstReleaseDate: '2001-01-01',
|
||||
popularity: 1,
|
||||
listenerCount: 1,
|
||||
secondaryTypes: [],
|
||||
inLibrary: false,
|
||||
localId: 0,
|
||||
},
|
||||
];
|
||||
|
||||
async function exploreWithAlbums(): Promise<LitElement> {
|
||||
stub(SHELVES, { shelves: [], state: 'ready' });
|
||||
stub(SEARCH, {
|
||||
artists: [],
|
||||
releaseGroups: ALBUMS,
|
||||
recordings: [],
|
||||
});
|
||||
stub('explore.Service.GetThumbnails', Object.fromEntries(
|
||||
ALBUMS.map((a) => [a.mbid, TINY_IMAGE]),
|
||||
));
|
||||
stub('explore.Service.GetThumbnail', TINY_IMAGE);
|
||||
|
||||
const el = await fixture<LitElement>('explore-view');
|
||||
|
||||
(el as unknown as { onViewActivate: () => void }).onViewActivate?.();
|
||||
await update(el, {
|
||||
results: { artists: [], releaseGroups: ALBUMS, recordings: [] },
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetAlbumsCompleteness', {});
|
||||
completenessStore.invalidate();
|
||||
});
|
||||
|
||||
describe('the album card size', () => {
|
||||
it('is the same width and height for every card in a row', async () => {
|
||||
const el = await exploreWithAlbums();
|
||||
const cards = shadowAll(el, '.album-card');
|
||||
|
||||
expect(cards.length).toBe(ALBUMS.length);
|
||||
|
||||
const boxes = cards.map((c) => c.getBoundingClientRect());
|
||||
|
||||
// The first card is the reference; every other one must match it.
|
||||
for (const box of boxes) {
|
||||
expect(box.width).toBe(boxes[0]!.width);
|
||||
expect(box.height).toBe(boxes[0]!.height);
|
||||
}
|
||||
|
||||
// …and the reference is a real box, or the loop above is vacuous.
|
||||
expect(boxes[0]!.width).toBeGreaterThan(0);
|
||||
expect(boxes[0]!.height).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('keeps the artwork square', async () => {
|
||||
const el = await exploreWithAlbums();
|
||||
|
||||
for (const art of shadowAll(el, '.album-art-container')) {
|
||||
const box = art.getBoundingClientRect();
|
||||
|
||||
expect(Math.round(box.width)).toBe(Math.round(box.height));
|
||||
}
|
||||
});
|
||||
|
||||
it('insets a non-square cover rather than cropping it', async () => {
|
||||
const el = await exploreWithAlbums();
|
||||
|
||||
// Read from the parsed stylesheet rather than from a rendered
|
||||
// `<img>`: the search path is what calls `loadThumbnails`, and
|
||||
// setting `results` directly skips it, so there is no image to
|
||||
// measure. The regression worth catching is the rule going back to
|
||||
// `cover`, which is a stylesheet fact.
|
||||
const rules = (el.shadowRoot?.adoptedStyleSheets ?? []).flatMap((sheet) =>
|
||||
Array.from(sheet.cssRules).map((rule) => rule.cssText),
|
||||
);
|
||||
const art = rules.find(
|
||||
(text) =>
|
||||
text.startsWith('.album-art-container img') &&
|
||||
text.includes('object-fit'),
|
||||
);
|
||||
|
||||
expect(art, 'no object-fit rule for the cover image').toBeDefined();
|
||||
expect(art).toContain('object-fit: contain');
|
||||
});
|
||||
|
||||
it('draws the badge over the artwork, and not in the metadata line', async () => {
|
||||
const el = await exploreWithAlbums();
|
||||
const card = shadow(el, '.album-card')!;
|
||||
|
||||
const badge = card.querySelector('.album-art-container .album-card-badge');
|
||||
|
||||
expect(badge).not.toBeNull();
|
||||
// The badge is positioned inside the art box, so its parent is the
|
||||
// square rather than the row underneath it.
|
||||
expect(badge?.parentElement?.classList.contains('album-art-container')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* The artist page's header and its top tracks.
|
||||
*
|
||||
* Two cleanups, asserted together because they are one screen:
|
||||
*
|
||||
* - the Play/Shuffle pair became one split button ("Play" with the
|
||||
* words on its title, Shuffle behind the caret), the Follow button
|
||||
* moved onto the same line, and the name and listen count went up a
|
||||
* size;
|
||||
* - a top track's play/request affordance moved onto its artwork,
|
||||
* where a hover reveals it, instead of a badge at the end of the
|
||||
* row beside a row that already plays on a double-click.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/explore-artist-details/explore-artist-details';
|
||||
import { stub, flush, emit, resetHarness } from '@test/support/harness';
|
||||
import { Events } from '../../src/events';
|
||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
|
||||
const ARTIST = 'artist-0001';
|
||||
|
||||
const track = (name: string, localId = 0) => ({
|
||||
recordingMbid: `rec-${name}`,
|
||||
artistName: 'Tideline',
|
||||
trackName: name,
|
||||
totalListenCount: 100,
|
||||
caaReleaseMbid: '',
|
||||
releaseName: 'Foreshore',
|
||||
releaseGroupMbid: 'rg-owned',
|
||||
length: 200000,
|
||||
inLibrary: localId > 0,
|
||||
localId,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
|
||||
stub('explore.Service.LookupArtist', {
|
||||
mbid: ARTIST,
|
||||
name: 'Tideline',
|
||||
popularity: 1200,
|
||||
type: 'Group',
|
||||
country: 'GB',
|
||||
});
|
||||
stub('explore.Service.TopReleaseGroupsForArtist', []);
|
||||
stub('explore.Service.TopRecordingsForArtist', [
|
||||
track('Owned Song', 7),
|
||||
track('Absent Song'),
|
||||
]);
|
||||
stub('explore.Service.SimilarArtists', []);
|
||||
stub('explore.Service.PrefetchReleases', undefined);
|
||||
stub('explore.Service.BrowseReleaseGroups', [
|
||||
{
|
||||
mbid: 'rg-owned',
|
||||
title: 'Foreshore',
|
||||
artistCredit: 'Tideline',
|
||||
primaryType: 'Album',
|
||||
inLibrary: true,
|
||||
localId: 7,
|
||||
},
|
||||
]);
|
||||
stub('library.Library.GetAlbumsCompleteness', {});
|
||||
stub('download.Service.ListRequests', []);
|
||||
});
|
||||
|
||||
async function mount(): Promise<LitElement> {
|
||||
const el = await fixture<LitElement>('explore-artist-details', {
|
||||
artistMBID: ARTIST,
|
||||
artistName: 'Tideline',
|
||||
});
|
||||
|
||||
await flush();
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('the artist header', () => {
|
||||
it('offers Play, with Shuffle behind its caret', async () => {
|
||||
const el = await mount();
|
||||
const play = shadow<HTMLElement>(el, '[data-testid="artist-play-library"]')!;
|
||||
|
||||
// The words moved to the title, which is where "Play library
|
||||
// tracks" can still be read without taking the width of a button.
|
||||
expect(play.textContent?.trim()).toBe('Play');
|
||||
expect(play.getAttribute('title')).toBe('Play library tracks');
|
||||
|
||||
const menuButton = shadow(el, '[data-testid="artist-play-menu"]');
|
||||
|
||||
expect(menuButton).not.toBeNull();
|
||||
|
||||
const menu = shadow(el, '#artist-play-menu');
|
||||
|
||||
expect(menu?.textContent).toContain('Shuffle');
|
||||
});
|
||||
|
||||
it('puts Follow on the same line as Play', async () => {
|
||||
const el = await mount();
|
||||
const actions = shadow(el, '.artist-actions')!;
|
||||
|
||||
expect(actions.querySelector('[data-testid="artist-play-library"]')).not.toBeNull();
|
||||
|
||||
const follow = actions.querySelector('[data-testid="artist-follow"]') as HTMLElement;
|
||||
|
||||
expect(follow).not.toBeNull();
|
||||
expect(follow.textContent?.trim()).toBe('Follow');
|
||||
});
|
||||
|
||||
it('says Following once the artist is on the request list', async () => {
|
||||
const el = await mount();
|
||||
|
||||
// The store is a singleton and caches its list, so the change is
|
||||
// announced the way the backend announces one.
|
||||
stub('download.Service.ListRequests', [
|
||||
{ id: 3, mbid: ARTIST, state: 'queued' },
|
||||
]);
|
||||
emit(Events.RequestsChanged);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const follow = shadow<HTMLElement>(el, '[data-testid="artist-follow"]')!;
|
||||
|
||||
expect(follow.textContent?.trim()).toBe('Following');
|
||||
});
|
||||
|
||||
it('sizes the name and the listen count above the metadata line', async () => {
|
||||
const el = await mount();
|
||||
|
||||
const title = shadow<HTMLElement>(el, '.artist-title')!;
|
||||
const listens = shadow<HTMLElement>(el, '.artist-listens')!;
|
||||
const meta = shadow<HTMLElement>(el, '.artist-meta')!;
|
||||
|
||||
expect(listens.textContent).toContain('plays on ListenBrainz');
|
||||
|
||||
const titleSize = parseFloat(getComputedStyle(title).fontSize);
|
||||
const listensSize = parseFloat(getComputedStyle(listens).fontSize);
|
||||
const metaSize = parseFloat(getComputedStyle(meta).fontSize);
|
||||
|
||||
expect(titleSize).toBeGreaterThan(24);
|
||||
expect(listensSize).toBeGreaterThan(metaSize);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a top track’s affordance', () => {
|
||||
it('plays from the artwork when it is owned', async () => {
|
||||
const el = await mount();
|
||||
const rows = shadowAll<HTMLElement>(el, '.track-item');
|
||||
|
||||
const owned = rows.find((r) => r.textContent?.includes('Owned Song'))!;
|
||||
|
||||
expect(owned.querySelector('.track-art-overlay .track-art-play')).not.toBeNull();
|
||||
// Nothing beside the row any more.
|
||||
expect(owned.querySelector(':scope > library-status-indicator')).toBeNull();
|
||||
});
|
||||
|
||||
it('requests from the artwork when it is not', async () => {
|
||||
const el = await mount();
|
||||
const rows = shadowAll<HTMLElement>(el, '.track-item');
|
||||
|
||||
const absent = rows.find((r) => r.textContent?.includes('Absent Song'))!;
|
||||
|
||||
expect(
|
||||
absent.querySelector('.track-art-overlay library-status-indicator'),
|
||||
).not.toBeNull();
|
||||
expect(absent.querySelector('.track-art-overlay .track-art-play')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,9 @@ const ARTIST = 'artist-0001';
|
||||
|
||||
/** The labels of the open menu's items, trimmed. */
|
||||
function menuItems(el: LitElement): string[] {
|
||||
const panel = shadow(el, '.context-menu-panel');
|
||||
// Scoped to the context menu: the artist page also has a Play/Shuffle
|
||||
// dropdown, and its panel carries the same class.
|
||||
const panel = shadow(el, '#context-menu .context-menu-panel');
|
||||
|
||||
if (!panel) return [];
|
||||
|
||||
@@ -100,7 +102,7 @@ describe('the context menu on an artist page release', () => {
|
||||
|
||||
await openMenuOnAlbum(el, 0);
|
||||
|
||||
const panel = shadow(el, '.context-menu-panel');
|
||||
const panel = shadow(el, '#context-menu .context-menu-panel');
|
||||
|
||||
expect(panel).toBeTruthy();
|
||||
// The panel is shared with the track menu, so a label that does not
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* A horizontally scrolling row can be moved without a wheel.
|
||||
*
|
||||
* Until this existed the only way to see the cards past the fold on the
|
||||
* shelves, the search results and the artist page's discography was a
|
||||
* mousewheel or a trackpad gesture — which is not an affordance. A
|
||||
* mouse with no horizontal wheel simply could not reach them.
|
||||
*
|
||||
* What is asserted here is the state that makes the arrows honest: an
|
||||
* arrow is `hidden` at the end it cannot move from, because a control
|
||||
* that cannot act is worse than none, and an invisible one still holds
|
||||
* a hit area and a tab stop.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/scroll-row/scroll-row';
|
||||
import { fixture } from '@test/support/render';
|
||||
|
||||
/** Six 100px cards in a 320px row — comfortably overflowing. */
|
||||
function content(el: Element): void {
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const card = document.createElement('div');
|
||||
|
||||
card.style.cssText = 'flex: 0 0 100px; height: 40px';
|
||||
card.textContent = String(i);
|
||||
el.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
function arrows(el: LitElement): { prev: HTMLButtonElement; next: HTMLButtonElement } {
|
||||
const root = el.shadowRoot!;
|
||||
|
||||
return {
|
||||
prev: root.querySelector('.arrow.prev') as HTMLButtonElement,
|
||||
next: root.querySelector('.arrow.next') as HTMLButtonElement,
|
||||
};
|
||||
}
|
||||
|
||||
function viewport(el: LitElement): HTMLElement {
|
||||
return el.shadowRoot!.querySelector('.viewport') as HTMLElement;
|
||||
}
|
||||
|
||||
async function row(): Promise<LitElement> {
|
||||
const el = await fixture<LitElement>('scroll-row');
|
||||
|
||||
el.style.display = 'block';
|
||||
el.style.width = '320px';
|
||||
content(el);
|
||||
await el.updateComplete;
|
||||
// The observer reports on a later frame than a microtask drain.
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('<scroll-row>', () => {
|
||||
beforeEach(() => {
|
||||
document.body.style.margin = '0';
|
||||
});
|
||||
|
||||
it('draws an arrow for each direction it can still move', async () => {
|
||||
const el = await row();
|
||||
const { prev, next } = arrows(el);
|
||||
|
||||
expect(prev).not.toBeNull();
|
||||
expect(next).not.toBeNull();
|
||||
|
||||
// At the start there is nothing behind, so only the forward arrow is
|
||||
// offered.
|
||||
expect(prev.hasAttribute('hidden')).toBe(true);
|
||||
expect(next.hasAttribute('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('offers the way back once the row has moved', async () => {
|
||||
const el = await row();
|
||||
const vp = viewport(el);
|
||||
|
||||
vp.scrollLeft = 120;
|
||||
vp.dispatchEvent(new Event('scroll'));
|
||||
await el.updateComplete;
|
||||
|
||||
expect(arrows(el).prev.hasAttribute('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('stands the forward arrow down at the end', async () => {
|
||||
const el = await row();
|
||||
const vp = viewport(el);
|
||||
|
||||
vp.scrollLeft = vp.scrollWidth;
|
||||
vp.dispatchEvent(new Event('scroll'));
|
||||
await el.updateComplete;
|
||||
|
||||
expect(arrows(el).next.hasAttribute('hidden')).toBe(true);
|
||||
expect(arrows(el).prev.hasAttribute('hidden')).toBe(false);
|
||||
});
|
||||
|
||||
it('moves the row when the arrow is pressed', async () => {
|
||||
const el = await row();
|
||||
const vp = viewport(el);
|
||||
|
||||
expect(vp.scrollLeft).toBe(0);
|
||||
|
||||
arrows(el).next.click();
|
||||
|
||||
await expect.poll(() => vp.scrollLeft).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows nothing to scroll when the content fits', async () => {
|
||||
const el = await fixture<LitElement>('scroll-row');
|
||||
|
||||
el.style.cssText = 'display: block; width: 320px';
|
||||
|
||||
const only = document.createElement('div');
|
||||
|
||||
only.style.cssText = 'flex: 0 0 100px; height: 40px';
|
||||
only.textContent = 'one';
|
||||
el.append(only);
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
||||
await el.updateComplete;
|
||||
|
||||
const { prev, next } = arrows(el);
|
||||
|
||||
expect(prev.hasAttribute('hidden')).toBe(true);
|
||||
expect(next.hasAttribute('hidden')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,28 @@
|
||||
/**
|
||||
* Owned is plain; unowned is what gets marked.
|
||||
* The catalog's cards are not dimmed; the badge is the mark.
|
||||
*
|
||||
* `explore-album-details` had this right for one tracklist and nothing
|
||||
* else did: Explore's cards, the top-results row and the artist page's
|
||||
* three card shapes all mixed owned and unowned with a small badge as
|
||||
* the only difference — and drew a green tick on the *common* case,
|
||||
* which is the treatment the album page's own green ticks were removed
|
||||
* for.
|
||||
* The rule this replaced had every unowned card dimmed *and* badged,
|
||||
* which on a shelf of mostly-unowned covers read as a page that had
|
||||
* failed to load rather than a page of things you could ask for. So the
|
||||
* dimming is gone from the catalog surfaces and the badge carries the
|
||||
* whole statement — over the artwork, on hover, drawn for owned and
|
||||
* unowned alike.
|
||||
*
|
||||
* What is pinned here is the rule rather than any one surface, because
|
||||
* the fault this replaced was eight call sites each holding their own
|
||||
* version of it:
|
||||
* What is still pinned here is the half that was never about dimming:
|
||||
*
|
||||
* - an owned thing draws **no badge at all**;
|
||||
* - an unowned one is dimmed *and* says so in its accessible name,
|
||||
* because dimming is a colour and cannot be the only signal;
|
||||
* - ownership is a **file** (`localId`), never the catalog's
|
||||
* `inLibrary` ratchet, which is a flag that happens to agree;
|
||||
* - and a partly-held album says *how* partly, which is the one thing
|
||||
* a tick cannot.
|
||||
* - a row that cannot be played is `aria-disabled`, while a card that
|
||||
* still navigates is not;
|
||||
* - a partly-held album says *how* partly, which is the one thing a
|
||||
* tick cannot;
|
||||
* - and an unowned thing still says so in its accessible name, because
|
||||
* with the dimming gone that name is the whole signal for anyone not
|
||||
* seeing the badge.
|
||||
*
|
||||
* The album page's *tracklist* still dims unowned rows — a different
|
||||
* statement about a different thing — and is covered by
|
||||
* `album-request-badge-visibility.test.ts`.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
@@ -26,7 +30,7 @@ import { page } from 'vitest/browser';
|
||||
import '@components/explore-view/explore-view';
|
||||
import '@components/top-results-row/top-results-row';
|
||||
import { flush, stub, resetHarness } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll, update } from '@test/support/render';
|
||||
import { fixture, shadow, update } from '@test/support/render';
|
||||
import { completenessStore } from '@store/completeness-store';
|
||||
|
||||
const SEARCH = 'explore.Service.SearchLocal';
|
||||
@@ -105,27 +109,44 @@ beforeEach(() => {
|
||||
// absent one — which is the point, or 87% of a grid re-asks forever.
|
||||
// Two tests in one file are two sessions as far as it is concerned,
|
||||
// so a stale entry from the test above would otherwise decide the
|
||||
// one below. Found by writing the assertion the wrong way round.
|
||||
// one below.
|
||||
completenessStore.invalidate();
|
||||
});
|
||||
|
||||
describe('an owned thing is plain', () => {
|
||||
it('draws no badge on an album card it has files for', async () => {
|
||||
describe('an unowned card is marked by its badge alone', () => {
|
||||
it('does not dim the artwork', async () => {
|
||||
const el = await exploreShowing({
|
||||
releaseGroups: [album('Absent', {})],
|
||||
});
|
||||
|
||||
const art = shadow(el, '.album-card .album-art-container')!;
|
||||
|
||||
// The dimming was an opacity on this box. With it gone the cover is
|
||||
// at full strength, and the badge is what says the card is not
|
||||
// yours.
|
||||
expect(getComputedStyle(art).opacity).toBe('1');
|
||||
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('still says so in the name the browser computes', async () => {
|
||||
await exploreShowing({ releaseGroups: [album('Absent', {})] });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Absent — not in your library/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('an owned card is plain except for its badge', () => {
|
||||
it('draws the in-library badge rather than nothing', async () => {
|
||||
const el = await exploreShowing({
|
||||
releaseGroups: [album('Held', { localId: 7 })],
|
||||
});
|
||||
|
||||
expect(shadowAll(el, '.album-card')).toHaveLength(1);
|
||||
expect(shadow(el, '.album-card library-status-indicator')).toBeNull();
|
||||
});
|
||||
const badge = shadow(el, '.album-card library-status-indicator');
|
||||
|
||||
it('draws no badge on a track row it has a file for', async () => {
|
||||
const el = await exploreShowing({
|
||||
recordings: [recording('Held', { localId: 9 })],
|
||||
});
|
||||
|
||||
expect(shadowAll(el, '.track-item')).toHaveLength(1);
|
||||
expect(shadow(el, '.track-item library-status-indicator')).toBeNull();
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge?.getAttribute('status')).toBe('in-library');
|
||||
});
|
||||
|
||||
it('does not dim it', async () => {
|
||||
@@ -139,56 +160,6 @@ describe('an owned thing is plain', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('an unowned thing is marked', () => {
|
||||
it('dims the card and keeps its request badge', async () => {
|
||||
const el = await exploreShowing({
|
||||
releaseGroups: [album('Absent', {})],
|
||||
});
|
||||
|
||||
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(true);
|
||||
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The name is the half of this that reaches anyone not seeing the
|
||||
* dimming, so it has to be the browser's own answer — a shadow-root
|
||||
* query cannot compute a name, and this repo has shipped a nameless
|
||||
* control three times.
|
||||
*/
|
||||
it('says so in the name the browser computes', async () => {
|
||||
await exploreShowing({ releaseGroups: [album('Absent', {})] });
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: /Absent — not in your library/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
/**
|
||||
* A track row is `aria-disabled` and a card is not, and the
|
||||
* difference is not cosmetic: activating an unowned row does nothing
|
||||
* (`onRecordingRowDblClick` returns early), while a card navigates to
|
||||
* the catalog page for it, which is a perfectly good thing to do with
|
||||
* something you do not own.
|
||||
*/
|
||||
it('marks a row that cannot be played as disabled', async () => {
|
||||
const el = await exploreShowing({
|
||||
recordings: [recording('Absent', {})],
|
||||
});
|
||||
|
||||
expect(shadow(el, '.track-item')?.getAttribute('aria-disabled')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a card that still navigates enabled', async () => {
|
||||
const el = await exploreShowing({
|
||||
releaseGroups: [album('Absent', {})],
|
||||
});
|
||||
|
||||
expect(shadow(el, '.album-card')?.getAttribute('aria-disabled')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The decision this issue turned on.
|
||||
*
|
||||
@@ -206,7 +177,9 @@ describe('ownership is a file, not a flag', () => {
|
||||
});
|
||||
|
||||
expect(shadow(el, '.album-card')?.classList.contains('unowned')).toBe(true);
|
||||
expect(shadow(el, '.album-card library-status-indicator')).not.toBeNull();
|
||||
expect(
|
||||
shadow(el, '.album-card library-status-indicator')?.getAttribute('status'),
|
||||
).not.toBe('in-library');
|
||||
});
|
||||
|
||||
it('does the same for a track row', async () => {
|
||||
@@ -220,6 +193,26 @@ describe('ownership is a file, not a flag', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a track row that cannot be played is disabled', () => {
|
||||
it('marks an unowned row', async () => {
|
||||
const el = await exploreShowing({
|
||||
recordings: [recording('Absent', {})],
|
||||
});
|
||||
|
||||
expect(shadow(el, '.track-item')?.getAttribute('aria-disabled')).toBe(
|
||||
'true',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a card that still navigates enabled', async () => {
|
||||
const el = await exploreShowing({
|
||||
releaseGroups: [album('Absent', {})],
|
||||
});
|
||||
|
||||
expect(shadow(el, '.album-card')?.getAttribute('aria-disabled')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The count, which is what `#16`'s deferred third step asked for: an
|
||||
* album held 2 tracks of 10 wore the same green tick as one held whole,
|
||||
@@ -248,9 +241,13 @@ describe('a partly-held album says how partly', () => {
|
||||
|
||||
// A partly-held album is *actionable* — it has three tracks left to
|
||||
// ask for — so the badge is a button, and the name has to carry the
|
||||
// action and the count. Naming it after the action alone left the
|
||||
// one state the ring exists for as the one state whose name did not
|
||||
// mention it.
|
||||
// action and the count. The badge is revealed by the card's focus
|
||||
// (`:focus-within`), and `visibility: hidden` is what takes it out
|
||||
// of the accessibility tree until then, so the card is focused
|
||||
// first — which is exactly the route a keyboard user takes.
|
||||
shadow<HTMLElement>(el, '.album-card')?.focus();
|
||||
await el.updateComplete;
|
||||
|
||||
await expect
|
||||
.element(
|
||||
page.getByRole('button', {
|
||||
@@ -266,7 +263,7 @@ describe('a partly-held album says how partly', () => {
|
||||
* state, and a ring drawn from its absence would mark all of it
|
||||
* incomplete on no evidence. That is the rule `Known` exists for.
|
||||
*/
|
||||
it('says nothing when the total was never declared', async () => {
|
||||
it('falls back to the plain in-library badge when the total was never declared', async () => {
|
||||
stub(COMPLETENESS, {
|
||||
'7': { owned: 3, expected: 0, known: false, complete: false },
|
||||
});
|
||||
@@ -279,7 +276,9 @@ describe('a partly-held album says how partly', () => {
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, '.album-card library-status-indicator')).toBeNull();
|
||||
expect(
|
||||
shadow(el, '.album-card library-status-indicator')?.getAttribute('status'),
|
||||
).toBe('in-library');
|
||||
});
|
||||
|
||||
it('asks about the owned albums only, in one call', async () => {
|
||||
@@ -329,11 +328,13 @@ describe('the top-results row follows the same rule', () => {
|
||||
query: 'held',
|
||||
});
|
||||
|
||||
// A top-result card is a mixed bag — artist, album or track — and
|
||||
// its badge is a corner mark rather than the cover overlay the
|
||||
// album cards grew, so an owned one stays plain.
|
||||
expect(shadow(el, '.card library-status-indicator')).toBeNull();
|
||||
expect(shadow(el, '.card')?.classList.contains('unowned')).toBe(false);
|
||||
});
|
||||
|
||||
it('dims and names something it does not', async () => {
|
||||
it('names something it does not own', async () => {
|
||||
const el = await fixture('top-results-row', {
|
||||
results: [result('Absent', 'release_group')],
|
||||
query: 'absent',
|
||||
@@ -349,7 +350,7 @@ describe('the top-results row follows the same rule', () => {
|
||||
/**
|
||||
* An artist card has never had a badge — a discography subscription
|
||||
* is the artist page's Follow button, which can say what it commits
|
||||
* to — so the dimming and the name are the whole signal there.
|
||||
* to — so the name is the whole signal there.
|
||||
*/
|
||||
it('marks an unowned artist without offering a request', async () => {
|
||||
const el = await fixture('top-results-row', {
|
||||
|
||||
Reference in New Issue
Block a user