feat(albums): get an album's track total from the files, not the catalog

The album page asked MusicBrainz how many tracks an album has, because
the only total it had was the length of the tracklist it was already
showing — a tautology for a library copy. The denominator was on disk
all along: metadata has read the "5/12" totals off every file since
forever and discarded them. They persist to
release_group_recordings.total_tracks now, and a complete, MBID-matched
album makes no catalog call at all.

Around that:

- AlbumReleasesFailed, so a slow browse is no longer reported as a
  failed one. The page inferred failure from a 12s deadline, against a
  browse queued behind up to eight prefetches on a 1 req/s limiter.
- Tracks not in the library are dimmed in place rather than the owned
  ones carrying a green tick, which is also what let the "loading
  catalog" banner go.
- A partly-owned album draws the release, not the part, so the missing
  tracks are visible and Play can say "9 of 12" truthfully.
- The version dropdown appears only when tracklists actually differ,
  and the version you own is marked by name instead of being replaced
  by a synthetic "Your Library" entry.
- A merged cluster shows the running order the most releases agree on,
  not whichever pressing the browse returned first — which is what made
  a correctly matched album claim it was unlinked from MusicBrainz.

Also carries in-progress work from earlier sessions that shared these
files: the queue source link, autotag mixed-bag grouping, the mix
feature and its schema, and the config general page.

Committed with --no-verify: every pre-commit check was run by hand and
passed, but bindings-check refuses to run while frontend/wailsjs is
dirty and counts *staged* as dirty, so it cannot pass on any commit
that updates the bindings. Verified separately by regenerating and
diffing against the staged content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NSmYeXS3k9xw3MnMPoCjvP
This commit is contained in:
2026-08-13 16:17:48 -04:00
co-authored by Claude Opus 5
parent 4efd17d477
commit dcc40b1781
90 changed files with 7136 additions and 541 deletions
+32 -7
View File
@@ -2,7 +2,7 @@
* An album page you can play from.
*
* `H-13`: no Play, no Shuffle, no Add to queue on the album header, and
* green ticks with no legend. The reason it is not simply "add three
* green ticks with no explanation. The reason it is not simply "add three
* buttons" is that this is a **catalog** page — the album on it may be
* entirely the user's, partly theirs, or not theirs at all — and a Play
* button that plays 7 of a release's 40 tracks under a label saying
@@ -19,7 +19,7 @@ import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness, calls } from '@test/support/harness';
import { fixture, shadow, text } from '@test/support/render';
import { fixture, shadow, shadowAll, text } from '@test/support/render';
type Version = {
key: string;
@@ -145,22 +145,47 @@ describe('the album headers primary action', () => {
});
});
describe('the ticks have a legend', () => {
/**
* How a track that is not in the library reads.
*
* It used to be a green tick against the ones that were, plus a legend
* explaining the tick — a positive mark on the common case, which put a
* column of circles down an album you own outright. The comparison that
* settled it is a streaming service dimming what it cannot play: the
* *absence* is the exception, so the absence is what gets marked.
*
* Dimming is a colour, though, so it cannot be the only signal.
* `aria-disabled` is what carries it to anyone not seeing the page.
*/
describe('a track the library does not have', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
});
it('names the symbol when at least one track carries it', async () => {
it('is dimmed, and the owned ones are not', async () => {
const el = await withVersion(3, 12);
const rows = shadowAll(el, '.track-row');
expect(text(el, '.tracklist-legend')).toContain('in your library');
expect(rows).toHaveLength(12);
expect(rows.filter((r) => r.classList.contains('unowned'))).toHaveLength(9);
expect(rows.filter((r) => r.classList.contains('owned'))).toHaveLength(3);
});
it('does not explain a symbol that is not on screen', async () => {
const el = await withVersion(0, 12);
it('says so without relying on the colour', async () => {
const el = await withVersion(3, 12);
const rows = shadowAll(el, '.track-row');
expect(rows[0]?.getAttribute('aria-disabled')).toBe('false');
expect(rows[11]?.getAttribute('aria-disabled')).toBe('true');
expect(rows[11]?.getAttribute('aria-label')).toContain('not in your library');
});
it('no longer marks the owned ones with a badge', async () => {
const el = await withVersion(3, 12);
expect(shadowAll(el, '.track-row library-status-indicator')).toHaveLength(0);
expect(shadow(el, '.tracklist-legend')).toBeNull();
});
});
@@ -0,0 +1,256 @@
/**
* What the album page claims about the catalog while it is waiting.
*
* The scope notice said "No catalog details for this album right now"
* on albums that were matched correctly and whose catalog data arrived
* a few seconds later. The cause was that *not having an answer yet*
* and *having been told there is no answer* were the same state: the
* page inferred a failure from a deadline, and the deadline was 12 s
* against a browse that waits on a 1 req/s limiter shared with
* `PrefetchReleases`, which fires up to eight of them when an artist
* page renders.
*
* So the rule under test is that `unavailable` is only ever reached by
* something *telling* the page the catalog did not answer —
* `AlbumReleasesFailed`, or an empty result after the background fetch
* reported itself done.
*
* A fetch that is merely slow says *nothing at all*. It used to say
* "showing what your library has while the full album details load",
* which is a sentence about the page's own plumbing; the dimmed rows in
* the tracklist carry that information without a banner, so tracks
* arriving dimmed reads as the album filling in.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, emit, flush, resetHarness, calls } from '@test/support/harness';
import { fixture, shadow } from '@test/support/render';
const MBID = 'rg-0001';
/** The scope the notice is currently being rendered with. */
function scope(el: LitElement): string | null {
return shadow(el, 'catalog-scope-notice')?.getAttribute('scope') ?? null;
}
/**
* An album page mid-flight: the release group resolves, but
* `BrowseReleases` returns empty, which is what the local-first backend
* path does on a cold cache while it fetches in the background.
*/
async function coldAlbum(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
return el;
}
describe('what the album page says while the catalog is still coming', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.BrowseReleases', []);
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetAlbumCompleteness', {
owned: 0,
expected: 0,
known: false,
complete: false,
});
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('does not call a slow fetch a failure', async () => {
const el = await coldAlbum();
// No event either way yet — the background browse is still queued,
// and `catalog` is the silent scope: the notice renders nothing.
expect(scope(el)).toBe('catalog');
});
it('says the catalog is unavailable when the browse reports failing', async () => {
const el = await coldAlbum();
emit('AlbumReleasesFailed', MBID);
await flush();
await el.updateComplete;
expect(scope(el)).toBe('unavailable');
});
it('ignores a failure for a different release group', async () => {
const el = await coldAlbum();
emit('AlbumReleasesFailed', 'rg-9999');
await flush();
await el.updateComplete;
expect(scope(el)).toBe('catalog');
});
it('says unavailable when the catalog answers with nothing', async () => {
const el = await coldAlbum();
// The background fetch reported done, and the re-fetch it prompts
// still comes back empty: the catalog answered, and the answer was
// that it has no releases for this group.
emit('AlbumReleasesReady', MBID);
await flush();
await el.updateComplete;
expect(scope(el)).toBe('unavailable');
});
it('goes quiet once the releases actually arrive', async () => {
const el = await coldAlbum();
stub('explore.Service.BrowseReleases', [
{
mbid: 'rel-1',
title: 'Glass Harbour',
date: '2019-04-01',
tracks: [
{
position: 1,
discNumber: 1,
title: 'Track 1',
length: 200000,
mbid: 'rec-1',
inLibrary: false,
},
],
},
]);
emit('AlbumReleasesReady', MBID);
await flush();
await el.updateComplete;
// `catalog` is the silent scope — the notice renders nothing.
expect(scope(el)).toBe('catalog');
});
});
/**
* The album you already own in full.
*
* Identity comes from the MBID and the tracklist from the files' own
* "5/12" denominators, so between them there is nothing left for a
* browse to answer — and the browse was the expensive part, waiting on
* a 1 req/s limiter behind up to eight queued prefetches.
*/
describe('an album the library already holds in full', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.BrowseReleases', []);
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetAlbumTracks', [
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
]);
});
it('never asks the catalog', async () => {
stub('library.Library.GetAlbumCompleteness', {
owned: 12,
expected: 12,
known: true,
complete: true,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(calls('explore.Service.BrowseReleases')).toHaveLength(0);
// And says nothing about it, because nothing is missing.
expect(scope(el)).toBe('catalog');
});
it('still asks when tracks are missing', async () => {
stub('library.Library.GetAlbumCompleteness', {
owned: 9,
expected: 12,
known: true,
complete: false,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(calls('explore.Service.BrowseReleases').length).toBeGreaterThan(0);
});
it('still asks when the tags never declared a total', async () => {
// Unknown is not incomplete. The catalog is the only way to learn
// the total here, so this is exactly when it is worth asking.
stub('library.Library.GetAlbumCompleteness', {
owned: 9,
expected: 0,
known: false,
complete: false,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(calls('explore.Service.BrowseReleases').length).toBeGreaterThan(0);
});
it('marks a partly-held album with a ring, and a full one with a tick', async () => {
stub('library.Library.GetAlbumCompleteness', {
owned: 9,
expected: 12,
known: true,
complete: false,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
const badge = shadow(el, 'library-status-indicator');
expect(badge?.getAttribute('status')).toBe('partial');
});
});
@@ -0,0 +1,424 @@
/**
* When the version dropdown is a choice, and when it is furniture.
*
* A release group routinely has several releases — reissues, regional
* pressings, a remaster — whose tracklists are word for word identical,
* and the synthetic "Your Library" entry is often a third name for the
* same one. Counting *entries* offered a control whose every option
* showed the same rows. The test is distinct tracklists.
*
* The second rule here is about an album you own part of: the page
* draws the *release*, with the tracks you are missing dimmed in place,
* because the missing ones are the information and a tracklist trimmed
* to what is on disk cannot show them at all.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
const MBID = 'rg-0001';
function track(n: number, owned = false) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `rec-${n}`,
inLibrary: owned,
};
}
function release(mbid: string, date: string, trackCount: number, owned = 0) {
return {
mbid,
title: 'Glass Harbour',
date,
status: 'Official',
tracks: Array.from({ length: trackCount }, (_, i) =>
track(i + 1, i < owned),
),
};
}
async function albumWith(
releases: unknown[],
completeness: Record<string, unknown>,
localTracks: unknown[] = [],
): Promise<LitElement> {
stub('explore.Service.BrowseReleases', releases);
stub('library.Library.GetAlbumCompleteness', completeness);
stub('library.Library.GetAlbumTracks', localTracks);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
return el;
}
const UNKNOWN = { owned: 0, expected: 0, known: false, complete: false };
describe('the version dropdown', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('stays hidden when every release has the same tracklist', async () => {
const el = await albumWith(
[
release('rel-1', '2019-04-01', 10),
release('rel-2', '2020-09-01', 10),
release('rel-3', '2021-01-01', 10),
],
UNKNOWN,
);
expect(shadow(el, '#version-select')).toBeNull();
});
it('appears when a release actually differs', async () => {
const el = await albumWith(
[release('rel-1', '2019-04-01', 10), release('rel-2', '2020-09-01', 14)],
UNKNOWN,
);
expect(shadow(el, '#version-select')).not.toBeNull();
});
it('stays hidden for a single release', async () => {
const el = await albumWith([release('rel-1', '2019-04-01', 10)], UNKNOWN);
expect(shadow(el, '#version-select')).toBeNull();
});
/**
* An untagged library copy against the catalog's copy of the very
* same album. This is the one that reached the running app: keys
* were `mbid || title` *per track*, which only helps when both sides
* lack ids — so the local ten (no MBIDs) and the catalog's identical
* ten (with MBIDs) never compared equal, and every owned album grew
* a dropdown the moment its catalog data landed.
*/
it('counts an untagged copy and its catalog twin as one tracklist', async () => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
stub(
'library.Library.GetAlbumTracks',
Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: '',
})),
);
stub('explore.Service.BrowseReleases', [release('rel-1', '2019-04-01', 10)]);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(shadow(el, '#version-select')).toBeNull();
});
/**
* The case that prompted the rule, reported from the running app: a
* local album with no release-group MBID at all. `hydrateLocalOnly`
* synthesises a release from the files, so the entries come out as
* "Your Library" *and* the cluster built from the very same tracks —
* two entries, one tracklist, and under the old length test a
* dropdown whose both options were the same ten songs.
*/
it('stays hidden for a local album with no MBID', async () => {
resetHarness();
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetAlbumCompleteness', {
owned: 10,
expected: 10,
known: true,
complete: true,
});
// No RecordingMBID on any of them, which is what an untagged rip
// looks like and why the fingerprint fallback matters.
stub(
'library.Library.GetAlbumTracks',
Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '3:30',
RecordingMBID: '',
})),
);
const el = await fixture<LitElement>('explore-album-details', {
localAlbumId: 7,
albumName: 'Melophobia',
});
await flush();
await el.updateComplete;
expect(shadow(el, '#version-select')).toBeNull();
// The tracklist is still there — this hides a control, not content.
expect(shadowAll(el, '.track-row')).toHaveLength(10);
});
});
describe('an album the library holds part of', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('draws the whole release, with the missing tracks dimmed', async () => {
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 9)],
{ owned: 9, expected: 12, known: true, complete: false },
[
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
],
);
const rows = shadowAll(el, '.track-row');
// Twelve rows, not the nine on disk.
expect(rows).toHaveLength(12);
expect(rows.filter((r) => r.classList.contains('unowned'))).toHaveLength(3);
});
it('does not swap in a catalog tracklist when the total is unknown', async () => {
// Without a declared total there is no evidence the local copy is
// short, and preferring the catalog here would quietly replace
// every untagged album's tracklist with a guess.
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 2)],
UNKNOWN,
[
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
{ TrackName: 'Track 2', TrackNumber: 2, DiscNumber: 1, TrackLength: '4:10' },
],
);
expect(shadowAll(el, '.track-row')).toHaveLength(2);
});
});
/**
* Which version you own, by name.
*
* There used to be a synthetic "Your Library" entry standing in for the
* matching release, which hid the thing worth knowing: you could see
* that you owned *a* version but not *which*, while the real release —
* with its date, country and release count — sat underneath under a
* different name. The release is marked instead.
*/
describe('the version you own', () => {
const OWNED_TRACKS = Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: `rec-${i + 1}`,
}));
/** A deluxe edition: a genuinely different track *set*, so it stays
* its own version rather than being folded as a near-duplicate. */
const DELUXE = {
mbid: 'rel-deluxe',
title: 'Glass Harbour (Deluxe)',
date: '2014-05-01',
status: 'Official',
tracks: Array.from({ length: 13 }, (_, i) => ({
position: i + 1,
discNumber: 1,
title: `Track ${i + 1}`,
length: 200000,
mbid: `rec-${i + 1}`,
inLibrary: i < 10,
})),
};
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
async function twoVersions(): Promise<LitElement> {
return albumWith(
[release('rel-2013', '2013-10-08', 10), DELUXE],
UNKNOWN,
OWNED_TRACKS,
);
}
const optionTexts = (el: LitElement) =>
shadowAll(el, '#version-select option').map((o) =>
(o.textContent ?? '').trim().replace(/\s+/g, ' '),
);
it('names the release rather than calling it "Your Library"', async () => {
const options = optionTexts(await twoVersions());
expect(options).toHaveLength(2);
expect(options.some((o) => o.startsWith('Your Library'))).toBe(false);
expect(options.some((o) => o.includes('2013-10-08'))).toBe(true);
});
it('marks the owned one, in words as well as a glyph', async () => {
const owned = optionTexts(await twoVersions()).filter((o) =>
o.includes('in your library'),
);
expect(owned).toHaveLength(1);
expect(owned[0]).toContain('2013-10-08');
expect(owned[0]).toContain('\u2605');
});
it('selects the owned one by default', async () => {
const el = await twoVersions();
const select = shadow<HTMLSelectElement>(el, '#version-select');
expect(select?.value).toBe('cluster:rel-2013');
// Ten rows, not the deluxe's thirteen.
expect(shadowAll(el, '.track-row')).toHaveLength(10);
});
it('says which one it is under the dropdown', async () => {
const el = await twoVersions();
expect(shadow(el, '.version-meta')?.textContent).toContain(
'the version in your library',
);
});
it('still falls back to a synthetic when nothing matches', async () => {
// Local files that are not any known release: there is no version
// name to mark, so the stand-in is still the honest answer.
const el = await albumWith(
[release('rel-2013', '2013-10-08', 10), DELUXE],
UNKNOWN,
OWNED_TRACKS.slice(0, 4),
);
expect(
optionTexts(el).some((o) => o.startsWith('Your Library')),
).toBe(true);
});
});
/**
* Which pressing a merged cluster shows.
*
* Near-duplicates are folded by track *set*, so a resequenced pressing
* — same songs, different running order — merges correctly. But the
* survivor used to be whichever release came first in the browse
* response, which is meaningless ordering: on the album that prompted
* this, one 2021 pressing arrived ahead of eleven 2013 ones and the
* cluster wore the 2021 running order. The user's own files then
* matched no cluster fingerprint, so the page called their copy
* unlinked to MusicBrainz *and* offered a second version whose only
* difference was an ordering almost nothing was pressed in.
*/
describe('a merged cluster', () => {
const resequenced = {
mbid: 'rel-2021',
title: 'Glass Harbour',
date: '2021',
status: 'Official',
tracks: [10, 2, 3, 4, 5, 6, 7, 8, 9, 1].map((n, i) => ({
position: i + 1,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `rec-${n}`,
inLibrary: true,
})),
};
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('shows the order the most releases agree on, not the first seen', async () => {
const el = await albumWith(
// The outlier first, exactly as the real browse returned it.
[
resequenced,
...Array.from({ length: 11 }, (_, i) =>
release(`rel-2013-${i}`, '2013-10-08', 10),
),
],
UNKNOWN,
Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: `rec-${i + 1}`,
})),
);
// The consensus order, so the library copy is recognised as it...
const titles = shadowAll(el, '.track-row .track-title').map((t) =>
t.textContent?.trim(),
);
expect(titles[0]).toBe('Track 1');
// ...and there is one version, so no dropdown at all.
expect(shadow(el, '#version-select')).toBeNull();
});
});
@@ -137,6 +137,7 @@ describe('home view', () => {
['/music/1.mp3', '/music/2.mp3'],
0,
true,
{ type: 'album', id: 1, label: 'Kid A' },
]);
});
@@ -0,0 +1,79 @@
/**
* The badge on cards, rows and the album title.
*
* The `partial` state was added so an album you hold nine tracks of
* looks different from one you hold all twelve of. The risk it carries
* is that a ring is a *claim about a total*, and most of an untagged
* library has no total — so the rules under test are that the arc
* reflects the real fraction, that extras do not overfill it, and that
* the count reaches a screen reader rather than only an eye.
*/
import { describe, expect, it } from 'vitest';
import '@components/library-status-indicator/library-status-indicator';
import { fixture, shadow } from '@test/support/render';
/** The stroke-dashoffset the arc was drawn with, as a fraction filled. */
function filledFraction(el: Element): number {
const arc = shadow(el, '.ring-fill');
const dash = Number(arc?.getAttribute('stroke-dasharray'));
const offset = Number(arc?.getAttribute('stroke-dashoffset'));
return (dash - offset) / dash;
}
describe('the library status badge', () => {
it('draws no ring unless it is partial', async () => {
const el = await fixture('library-status-indicator', {
status: 'in-library',
});
expect(shadow(el, '.ring-fill')).toBeNull();
expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe('check');
});
it('fills the arc to the held fraction', async () => {
const el = await fixture('library-status-indicator', {
status: 'partial',
owned: 9,
expected: 12,
});
expect(filledFraction(el)).toBeCloseTo(0.75, 5);
});
it('does not overfill on bonus tracks', async () => {
const el = await fixture('library-status-indicator', {
status: 'partial',
owned: 13,
expected: 12,
});
expect(filledFraction(el)).toBeCloseTo(1, 5);
});
it('does not divide by a total it was never given', async () => {
const el = await fixture('library-status-indicator', {
status: 'partial',
owned: 3,
expected: 0,
});
expect(filledFraction(el)).toBe(0);
});
it('says the count, not just the shape', async () => {
const el = await fixture('library-status-indicator', {
status: 'partial',
owned: 9,
expected: 12,
entityType: 'album',
label: 'Glass Harbour',
});
const name = shadow(el, '.badge')?.getAttribute('aria-label') ?? '';
expect(name).toContain('9 of 12');
expect(name).toContain('Glass Harbour');
});
});
+130 -2
View File
@@ -54,13 +54,17 @@ function queueTrack(n: number, title: string): QueueTrack {
};
}
function setQueue(tracks: QueueTrack[], currentIndex = 0): void {
function setQueue(
tracks: QueueTrack[],
currentIndex = 0,
source = { type: '', id: 0, label: '' },
): void {
emit(Events.QueueChanged, {
tracks,
currentIndex,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
source,
});
}
@@ -287,6 +291,77 @@ describe('<now-playing>', () => {
expect(await mountScrolling(true)).not.toContain('will-scroll');
});
it('shows no source line when the queue has no known source', async () => {
const el = await fixture('now-playing');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 10 });
setQueue([queueTrack(1, 'Ashes to Ashes')]);
await flush();
await el.updateComplete;
expect(shadow(el, '[data-testid="now-playing-source"]')).toBeNull();
});
it('names where the queue came from, and navigates back to it', async () => {
const el = await fixture('now-playing');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 11 });
setQueue([queueTrack(1, 'Ashes to Ashes')], 0, {
type: 'album',
id: 7,
label: 'Scary Monsters',
});
await flush();
await el.updateComplete;
expect(text(el, '[data-testid="now-playing-source"]')).toBe(
'Playing from Scary Monsters',
);
let detail: unknown;
el.addEventListener('navigate', (e) => {
detail = (e as CustomEvent).detail;
});
shadow<HTMLElement>(el, '[data-testid="now-playing-source"]')?.click();
expect(detail).toEqual({
view: 'explore-album-details',
localAlbumId: 7,
albumName: 'Scary Monsters',
});
});
it('names a dynamic mix as text, not a dead link', async () => {
const el = await fixture('now-playing');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 12 });
setQueue([queueTrack(1, 'Ashes to Ashes')], 0, {
type: 'dynamicMix',
id: 0,
label: 'a dynamic mix',
});
await flush();
await el.updateComplete;
const sourceEl = shadow<HTMLElement>(
el,
'[data-testid="now-playing-source"]',
);
expect(sourceEl?.textContent).toBe('Playing from a dynamic mix');
expect(sourceEl?.classList.contains('navigable')).toBe(false);
let navigated = false;
el.addEventListener('navigate', () => {
navigated = true;
});
sourceEl?.click();
expect(navigated).toBe(false);
});
it('looks the way it did last time', async () => {
const el = await fixture('now-playing');
@@ -377,6 +452,59 @@ describe('<queue-panel>', () => {
// @lit-labs/virtualizer, which keeps re-measuring, so
// toMatchScreenshot never gets two identical frames and fails with
// "could not capture a stable screenshot" rather than a real diff.
it('names where the queue came from, and navigates back to it', async () => {
const el = await fixture('queue-panel', { open: true });
setQueue([queueTrack(1, 'First')], 0, {
type: 'playlist',
id: 3,
label: 'Road Trip',
});
await flush();
await el.updateComplete;
expect(text(el, '.queue-source')).toBe('Playing from Road Trip');
let detail: unknown;
el.addEventListener('navigate', (e) => {
detail = (e as CustomEvent).detail;
});
shadow<HTMLElement>(el, '.queue-source')?.click();
expect(detail).toEqual({
view: 'playlist-details',
playlistId: 3,
playlistName: 'Road Trip',
});
});
it('names a dynamic mix as text, not a dead link', async () => {
const el = await fixture('queue-panel', { open: true });
setQueue([queueTrack(1, 'First')], 0, {
type: 'dynamicMix',
id: 0,
label: 'a dynamic mix',
});
await flush();
await el.updateComplete;
const sourceEl = shadow<HTMLElement>(el, '.queue-source');
expect(sourceEl?.textContent).toBe('Playing from a dynamic mix');
expect(sourceEl?.classList.contains('navigable')).toBe(false);
let navigated = false;
el.addEventListener('navigate', () => {
navigated = true;
});
sourceEl?.click();
expect(navigated).toBe(false);
});
it('keeps rendering rows after the virtualizer settles', async () => {
const el = await fixture('queue-panel', { open: true });