Compare commits

...
Author SHA1 Message Date
logan 905654cc84 feat(explore): demote the album page's version selector to a disclosure
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 5m59s
Choosing which pressing you are looking at is an advanced,
metadata-repair task, and it sat directly above the tracklist with a
heading, a `<select>` and a paragraph explaining how our clustering
picks a "standard version" by weighing release count, status and date.
That is a sentence about our own heuristic in the most valuable space
on the page.

It is now "Other versions of this album (N)" below the tracklist: a
real `<button aria-expanded aria-controls>` inside the heading that
names the section, with the body rendered unconditionally and toggled
with `hidden`, because `aria-controls` has to name an element that is
in the DOM. Both rules are `config-section`'s rather than new ones.
It is demoted, not removed — matching the wrong release is a real
problem and this is how it gets fixed.

**Two more blocks shared that slot and neither was guarded.** The
selector at least had `distinctTracklistCount() <= 1`; the
`Versions / Loading releases…` spinner and the `Versions / <error>`
block did not, so both took the primary position on every album
regardless of whether there was ever going to be a choice. The spinner
said what `renderTracklist` was already saying about the same fetch, so
it is gone. The error was the one `catalog-scope-notice` shows at the
top of the page with a retry — every path that sets `errorReleases`
also sets `catalogFailed`, the only route to `unavailable`.

That error is what made this a rewrite rather than a move.
`renderTracklist` returned `nothing` on `errorReleases` and leaned on
the selector's own block to have said it, and a control inside a
collapsed disclosure cannot be a page's error surface. The failure
belongs to the list that is missing because of it, so that is where it
is drawn.

**What must not be lost is which version is on screen.** The default is
what the header already describes, so saying it on every album would be
this issue's own complaint one size smaller. `defaultVersionKey` is the
test: a line appears above the tracklist only once someone has chosen
another, naming it and offering the way back. The ★ and the words "in
your library" survive unchanged inside the panel, and the panel does
not close when the selection changes — a panel that shuts on use cannot
be used twice.

The `<select>` also loses an `aria-label` of "Select release version"
that outranked its own visible `<label>Version</label>`, which is a
label not in the name.

Verified against the running app as well as the suite: the collapsed
page, the open panel, a chosen version and 390px width all read
correctly, and the shell still measures 390 in a 390 viewport.

Closes #17
2026-08-19 01:24:49 -04:00
logan 219fa3c615 Merge pull request 'Make it obvious everywhere when you are looking at things you do not own' (#117) from feat/38-ownership-visibility into main
CI / check (push) Successful in 2m28s
CI / e2e (push) Successful in 6m3s
Owned is plain; unowned is dimmed, named and requestable; a partly-held
album says how partly. Ownership is a file (`localId`), never the
`in_library` ratchet.

Closes #38
2026-08-19 05:11:43 +00:00
logan c4e055ce51 docs: write down which of the two ownership columns to read
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m26s
CI / e2e (pull_request) Successful in 6m15s
The `localId` / `inLibrary` choice outlives #38 — every future catalog
surface has to make it, and the code read them as an OR at eight call
sites precisely because nothing said they were different kinds of
thing. CLAUDE.md gets the rule and its four load-bearing details;
NOTES.md gets the measurement, the card that used both answers at once,
and the alternative that was rejected.
2026-08-19 00:50:53 -04:00
4 changed files with 638 additions and 48 deletions
+46
View File
@@ -3534,3 +3534,49 @@ silent.** `artifactHasTotals` and `artifactHasCredits` are both correct
and both mean a feature can ship, pass every test, and produce nothing
for anybody without a single failure anywhere. Checking the *published
file* is one query and is not implied by any tick in CI.
## "Do I own this" has two answers in the schema, and one of them is a flag (2026-08-19)
Decided while doing #38, and it outlives it because every future
catalog surface has to pick one.
`explore_index` carries both `in_library` and `local_artist_id` /
`local_release_group_id` / `local_recording_id`. They are written by
the same pass (`collectLibraryEntities`), so on a healthy database they
agree, and the code read them as an OR — `inLibrary || localId > 0` —
at eight call sites.
They are not the same kind of thing:
- **`local_*_id` is a fact with an owner.** Every query that sets one
joins `audio_files`, and `pruneStaleLocalCrossReferences` clears it
with an existence test that is a file test in all three cases. It is
the same rule `explore-album-details`'s `filePaths` implements, one
layer down and computed once per scan.
- **`in_library` is a ratchet.** `upsertBatch` raises it with
`MAX(in_library, excluded.in_library)` and the prune is the only
thing that lowers it — gated on the local id being non-null, so a row
holding the flag *without* an id is a fixed point nothing can clear.
Filed as #118; it still drives search scoring, the popularity-floor
bypass and two Explore shelves, so routing the UI around it was not a
fix.
What made the choice concrete rather than theoretical: on
`explore-artist-details` the *same card* used both. The context menu
gated Play on `localId > 0`; the badge used `inLibrary`. An album with
the flag and no local row drew a green tick saying it was in your
library, offered no Play, and — the request item being gated on *not*
owned — offered no way to ask for it either.
The rejected alternative is worth keeping: batching a real file lookup
per screenful, the way `credit-store` coalesces. It would have answered
for **recordings** (`GetFilePathsByRecordingMBIDs`) and most of the
cards on these surfaces are release groups, so it would have made track
rows strong, left album cards exactly where they were, and cost a new
store. The batch that *was* worth adding is a different question —
`GetAlbumsCompleteness`, "how much of this album is here", which no
per-card flag can answer at all.
The general point: **two columns that agree today are not one column.**
Which of them a new surface reads should be decided by which one has
something that can un-set it.
+53
View File
@@ -1658,6 +1658,59 @@ not about plumbing — it says rows may be missing from the page
altogether, which nothing on screen can show. (`explore-artist-details`
still uses `loading`; it has no equivalent per-row signal.)
**And that treatment is the app's, not the page's.**
`utils/ownership.ts` is the rule written once, because it was written
at eight call sites and so none of them had the whole of it: Explore's
cards, `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
the badge on the *owned* ones was a green tick — the mark on the common
case this tracklist removed. Owned is plain and draws no badge at all;
unowned is dimmed, says so in its accessible name, and keeps its
request affordance; a partly-held album says how partly.
Four things about it are load-bearing.
**Ownership is `localId`, and `inLibrary` is deliberately not
consulted.** The album page answers with `filePaths`, a real file per
displayed track, and a card grid cannot afford that — but it does not
need to, because `explore_index.local_*_id` is built by
`collectLibraryEntities` from queries that every one join `audio_files`
and cleared by `pruneStaleLocalCrossReferences`, whose existence test
is a file test in all three cases. That is the same "ownership is a
file" rule computed once per scan instead of once per screenful.
`in_library` is written by the same pass, so the two agree in a healthy
database, but it is a one-way ratchet
(`MAX(in_library, excluded.in_library)`) whose only clearing pass is
gated on a non-null local id: it cannot be un-set on its own (#118).
One is a fact with an owner; the other is a flag that happens to agree.
Both `explore-view` and `explore-artist-details` additionally kept a
`libraryMBIDs` set that accumulated every MBID ever seen with the flag
and cleared it never, in views that never unmount; both are gone.
**The two answers used to sit on one card.**
`renderReleaseMenuItems` gates Play on `release.localId > 0` while the
badge used `inLibrary`, so an album with the flag and no local row drew
a tick saying it was in your library, offered no Play, and — the
request item being gated on *not* owned — offered no way to ask for it
either. Any new surface that asks the question twice will reproduce it.
**`aria-disabled` goes on rows and not on cards.** An unowned *row*
cannot be activated; an unowned *card* still navigates to the catalog
page for it, which is a perfectly good thing to do with something you
do not own. The accessible name carries the state either way, which is
why it is one helper and not a class.
**The count is batched, not looked up.** `store/completeness-store.ts`
is `credit-store` one question over: `request()` is per-card and
coalesces a screenful into one `GetAlbumsCompleteness`, absence is
cached as an answer (or the albums with no totals re-ask forever), and
the whole cache is dropped on a scan, a retag or a removal rather than
aged. `library-status.ts`'s `albumBadgeFor` is where that meets
`Known`: a total that was never declared is a plain `in-library`, never
a ring at 0%. One consequence in the badge itself — a `partial` badge
is *actionable*, and a control named after its action alone dropped the
count from the one state the ring exists for, so its name is both.
**A partly-owned album draws the release, not the part.** Once the tags
say nine of twelve, `buildLibraryEntry` shows the *catalog's* twelve
with three dimmed, rather than the nine on disk — the missing tracks
@@ -198,6 +198,28 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@state() private versionEntries: VersionEntry[] = [];
/** Currently-selected dropdown entry (by VersionEntry.key). */
@state() private selectedVersionKey: string = '';
/**
* The key `buildClusters` defaulted to, kept so the page can tell
* "this is what we picked for you" from "you went and chose this".
*
* Only the second needs saying out loud. With the selector demoted
* to a disclosure below the tracklist, a chosen version is the one
* case where the list on screen is not the one the header
* describes, and nothing else on the page would say so.
*/
@state() private defaultVersionKey: string = '';
/**
* Whether the "Other versions" disclosure is open.
*
* Collapsed by default — choosing which pressing you are looking at
* is a metadata-repair task and does not belong above the
* tracklist. It is deliberately *not* closed when the selection
* changes: the user opened it to change something, and a panel that
* shuts on use cannot be used twice.
*/
@state() private versionsOpen = false;
@state() private coverArtURL = '';
/**
@@ -523,7 +545,93 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
flex-shrink: 0;
}
/* ── Version selector ── */
/* ── Other versions (a disclosure, below the tracklist) ── */
.versions {
margin-top: 24px;
border-top: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.08));
padding-top: 8px;
}
/* The heading exists so the section is reachable by heading
* navigation; the button inside it is the control. Its own
* type scale is the section header's, reduced — this is a
* footnote to the page, not a peer of the tracklist. */
.versions-heading {
margin: 0;
font-size: var(--yj-text-sm);
font-weight: 500;
}
.versions-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 2px;
background: none;
border: none;
color: var(--yj-text-secondary, #b3b3b3);
font: inherit;
text-align: left;
cursor: pointer;
}
.versions-toggle:hover {
color: var(--yj-text-primary, #fff);
}
.versions-toggle:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: 2px;
border-radius: 4px;
}
.versions-toggle wa-icon {
font-size: var(--yj-icon-xs, 11px);
transition: transform 0.2s ease;
}
.versions-toggle[aria-expanded='false'] wa-icon {
transform: rotate(-90deg);
}
.versions-intro {
margin: 0 0 10px;
font-size: var(--yj-text-xs);
color: var(--yj-text-tertiary, #888);
line-height: 1.4;
}
/* A line above the tracklist, and only after a deliberate
* choice — see renderChosenVersion. */
.chosen-version {
margin: 0 0 10px;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
}
.chosen-version strong {
color: var(--yj-text-primary, #fff);
font-weight: 600;
}
.chosen-version-reset {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--yj-accent-text, #ffd43b);
text-decoration: underline;
cursor: pointer;
}
.chosen-version-reset:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: 2px;
border-radius: 2px;
}
.version-selector {
display: flex;
flex-direction: column;
@@ -948,6 +1056,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
this.releases = [];
this.versionEntries = [];
this.selectedVersionKey = '';
this.defaultVersionKey = '';
this.versionsOpen = false;
this.showFullTracklist = null;
this.localTracks = [];
this.filePaths = new Map();
@@ -1639,6 +1749,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
|| standardEntry?.key
|| this.versionEntries[0]?.key
|| '';
// Recorded here rather than derived later: this is the one
// place that knows what "the version we picked" means, and
// recomputing the preference order at the render site would be
// a second copy of it. `handleTracklistScopeChange` rebuilds
// through here too, so the switch does not read as a choice of
// version.
this.defaultVersionKey = this.selectedVersionKey;
}
/**
@@ -2297,9 +2415,10 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
entity-type="album"
@catalog-retry=${this.retryCatalog}
></catalog-scope-notice>
${this.renderVersionSelector()}
${this.renderChosenVersion()}
${this.renderTracklistScope()}
${this.renderTracklist()}
${this.renderVersionSelector()}
</div>
<track-details></track-details>
`;
@@ -2950,26 +3069,81 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/* ── Version Selector (R025, R026, R027) ── */
/**
* Which pressing is on screen — said only when the user chose it.
*
* The selector is a disclosure below the tracklist now, so nothing
* above the list names the version it came from. That is right for
* the default, which is what the header already describes; it is
* wrong the moment someone picks a different one, because then the
* tracklist and the page disagree and the control that explains it
* is off the bottom of the screen.
*
* `defaultVersionKey` is the whole test. A quiet line that appears
* on every album would be the thing this issue removed, one size
* smaller.
*/
private renderChosenVersion() {
if (this.loadingReleases || this.errorReleases) return nothing;
if (!this.selectedVersionKey) return nothing;
if (this.selectedVersionKey === this.defaultVersionKey) return nothing;
const current = this.currentVersion();
if (!current) return nothing;
return html`
<p class="chosen-version">
Showing <strong>${current.label}</strong> —
${current.sublabel}.
<button
type="button"
class="chosen-version-reset"
@click=${this.resetVersion}
>
Use the default version
</button>
</p>
`;
}
/** Back to what `buildClusters` picked, without opening the panel. */
private resetVersion = () => {
if (!this.defaultVersionKey) return;
this.selectedVersionKey = this.defaultVersionKey;
};
/**
* "Other versions" — a disclosure, below the tracklist.
*
* Choosing which pressing you are looking at is an advanced,
* metadata-repair task, and it used to sit directly above the
* tracklist with a heading and a paragraph of prose explaining our
* clustering heuristic. It is not removed — matching the wrong
* release is a real problem and this is how it gets fixed — it is
* demoted (#17).
*
* Two things about the shape are load-bearing, and both are
* `config-section`'s rules rather than new ones. The header is a
* real `<button aria-expanded aria-controls>` inside the heading
* that names the section, so it is reachable by Tab and by heading
* navigation alike. And the body **renders unconditionally and is
* toggled with `hidden`**, because `aria-controls` has to name an
* element that is in the DOM.
*
* The loading and error states this used to own are gone rather
* than moved. Both were unguarded, so they took the primary slot on
* every album regardless of whether there was ever going to be a
* choice: the spinner said the same thing `renderTracklist` was
* already saying about the same fetch, and the error is the one
* `catalog-scope-notice` shows at the top of the page with a retry
* — every path that sets `errorReleases` also sets `catalogFailed`,
* which is the only route to `unavailable`. What the tracklist does
* with a failure is now the tracklist's own business.
*/
private renderVersionSelector() {
if (this.loadingReleases) {
return html`
<section>
<h3 class="section-header">Versions</h3>
<div class="section-loading">Loading releases\u2026</div>
</section>
`;
}
if (this.errorReleases) {
return html`
<section>
<h3 class="section-header">Versions</h3>
<div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon>
${this.errorReleases}
</div>
</section>
`;
}
if (this.loadingReleases || this.errorReleases) return nothing;
// A dropdown is only a choice if the choices differ. Counting
// *entries* is the wrong test: a release group routinely has
@@ -2980,7 +3154,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
//
// Distinct *tracklists* is the real question, and it is already
// computed: clusters are keyed by tracklist fingerprint.
if (this.distinctTracklistCount() <= 1) return nothing;
const choices = this.distinctTracklistCount();
if (choices <= 1) return nothing;
const aggregateEntries = this.versionEntries.filter(
(e) => e.group === 'aggregate',
@@ -2990,35 +3166,59 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
);
return html`
<div class="version-selector">
<div class="version-selector-row">
<label for="version-select">Version</label>
<select
id="version-select"
@change=${this.handleVersionChange}
aria-label="Select release version"
<section class="versions">
<h3 class="versions-heading">
<button
type="button"
class="versions-toggle"
aria-expanded=${this.versionsOpen ? 'true' : 'false'}
aria-controls="versions-body"
@click=${this.toggleVersions}
>
${aggregateEntries.length > 0
? html`
<optgroup label="Aggregate">
${aggregateEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
`
: nothing}
<optgroup label="Versions">
${clusterEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
</select>
<wa-icon name="chevron-down" aria-hidden="true"></wa-icon>
Other versions of this album (${choices})
</button>
</h3>
<div id="versions-body" ?hidden=${!this.versionsOpen}>
<p class="versions-intro">
A release group can have several pressings with
different tracklists. Pick another if the one
above does not match your copy.
</p>
<div class="version-selector">
<div class="version-selector-row">
<label for="version-select">Version</label>
<select
id="version-select"
@change=${this.handleVersionChange}
>
${aggregateEntries.length > 0
? html`
<optgroup label="Aggregate">
${aggregateEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
`
: nothing}
<optgroup label="Versions">
${clusterEntries.map((e) =>
this.renderVersionOption(e),
)}
</optgroup>
</select>
</div>
${this.renderVersionMeta()}
</div>
</div>
${this.renderVersionMeta()}
</div>
</section>
`;
}
private toggleVersions = () => {
this.versionsOpen = !this.versionsOpen;
};
/**
* One option in the version list.
*
@@ -3197,8 +3397,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
`;
}
if (this.errorReleases) {
// Error already shown in version selector section
return nothing;
// The failure belongs to the list that is missing because
// of it. This used to return `nothing` and lean on the
// version selector's own error block to have said it, which
// is precisely the coupling that made demoting the selector
// a rewrite rather than a move: a control in a collapsed
// disclosure cannot be the page's error surface.
return html`
<section>
<h3 class="sr-only">Tracklist</h3>
<div class="section-error">
<wa-icon name="triangle-exclamation"></wa-icon>
${this.errorReleases}
</div>
</section>
`;
}
const current = this.currentVersion();
if (!current) {
@@ -0,0 +1,278 @@
/**
* Choosing a pressing is a repair job, not the album page's headline.
*
* The version selector sat directly above the tracklist with a heading,
* a `<select>` and a paragraph explaining how our clustering picks a
* "standard version" — the most valuable space on the page spent on a
* control a normal user never touches (#17). Two more blocks shared
* that slot and were not even guarded by "is there a choice": a
* `Versions / Loading releases…` spinner about the same fetch
* `renderTracklist` was already reporting, and a `Versions / <error>`
* block duplicating what `catalog-scope-notice` shows at the top of the
* page with a retry.
*
* What is pinned here is the demotion and the three things that must
* survive it: the control is still reachable, the page still says which
* version you are looking at once you have chosen one, and a failed
* fetch still says so somewhere a collapsed panel is not.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import { page } from 'vitest/browser';
import '@components/explore-album-details/explore-album-details';
import { stub, stubFailure, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
const MBID = 'rg-0001';
function track(n: number) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `rec-${n}`,
inLibrary: false,
};
}
function release(mbid: string, date: string, trackCount: number) {
return {
mbid,
title: 'Glass Harbour',
date,
status: 'Official',
tracks: Array.from({ length: trackCount }, (_, i) => track(i + 1)),
};
}
const UNKNOWN = { owned: 0, expected: 0, known: false, complete: false };
/** Two releases whose tracklists genuinely differ, so there is a choice. */
const TWO = [release('rel-1', '2019-04-01', 10), release('rel-2', '2020-09-01', 14)];
async function album(releases: unknown[] = TWO): Promise<LitElement> {
stub('explore.Service.BrowseReleases', releases);
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
stub('library.Library.GetAlbumTracks', []);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
return el;
}
/** Positions of two selectors within the shadow root, in document order. */
function order(el: Element, first: string, second: string): [number, number] {
const all = [...(el.shadowRoot?.querySelectorAll('*') ?? [])];
const a = all.findIndex((n) => n.matches(first));
const b = all.findIndex((n) => n.matches(second));
return [a, b];
}
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', {});
});
describe('the version selector is no longer the headline', () => {
it('renders after the tracklist, not before it', async () => {
const el = await album();
const [tracklist, versions] = order(el, '.tracklist', '.versions');
expect(tracklist).toBeGreaterThan(-1);
expect(versions).toBeGreaterThan(tracklist);
});
it('starts collapsed', async () => {
const el = await album();
expect(shadow(el, '#versions-body')?.hasAttribute('hidden')).toBe(true);
});
/**
* `aria-controls` has to name an element that is in the DOM, so the
* body renders unconditionally and is toggled with `hidden` — the
* rule `config-section` states and the reason a conditional body
* would be wrong here too.
*/
it('keeps the panel in the DOM while it is shut', async () => {
const el = await album();
expect(shadow(el, '#versions-body')).not.toBeNull();
expect(
shadow(el, '.versions-toggle')?.getAttribute('aria-controls'),
).toBe('versions-body');
});
/**
* The browser's own answer: a disclosure that cannot be tabbed to is
* the fault `config-section` shipped for every setting in the app,
* and a shadow-root query cannot tell you a control has a name.
*/
it('is a named, expandable button', async () => {
await album();
await expect
.element(
page.getByRole('button', { name: /Other versions of this album \(2\)/ }),
)
.toBeInTheDocument();
});
it('opens when the button is pressed', async () => {
const el = await album();
const toggle = shadow<HTMLButtonElement>(el, '.versions-toggle');
expect(toggle?.getAttribute('aria-expanded')).toBe('false');
toggle?.click();
await el.updateComplete;
expect(toggle?.getAttribute('aria-expanded')).toBe('true');
expect(shadow(el, '#versions-body')?.hasAttribute('hidden')).toBe(false);
});
it('says nothing at all when there is only one tracklist', async () => {
const el = await album([release('rel-1', '2019-04-01', 10)]);
expect(shadow(el, '.versions')).toBeNull();
});
});
describe('the two blocks that shared that slot', () => {
/**
* The spinner was unguarded, so `Versions / Loading releases…` took
* the primary position on *every* album load — including the ones
* that would never offer a choice — beside `renderTracklist`'s own
* "Loading tracks…" about the same fetch.
*/
it('no longer reports the same fetch twice while loading', async () => {
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
stub('library.Library.GetAlbumTracks', []);
stub('explore.Service.BrowseReleases', () => new Promise(() => {}));
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
albumName: 'Glass Harbour',
});
const loading = shadowAll(el, '.section-loading');
expect(loading).toHaveLength(1);
expect(loading[0]?.textContent).toContain('Loading tracks');
});
/**
* A failed browse must still be visible, and it cannot be visible
* from inside a collapsed disclosure. It belongs to the list that is
* missing because of it — `renderTracklist` used to return `nothing`
* here and lean on the selector's own error block, which is exactly
* the coupling that made this a rewrite rather than a move.
*/
it('reports a failed fetch in the tracklist, once', async () => {
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
stub('library.Library.GetAlbumTracks', []);
stubFailure('explore.Service.BrowseReleases', 'the catalog said no');
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
const errors = shadowAll(el, '.section-error');
expect(errors).toHaveLength(1);
expect(errors[0]?.textContent).toContain('versions');
expect(shadow(el, '.versions')).toBeNull();
});
});
describe('which version is on screen', () => {
/**
* The default is what the header already describes, so a line saying
* so on every album would be the thing this issue removed, one size
* smaller.
*/
it('is not stated while the page picked it', async () => {
const el = await album();
expect(shadow(el, '.chosen-version')).toBeNull();
});
/**
* The moment someone chooses another, the tracklist and the header
* disagree — and the control that explains it is now off the bottom
* of the page.
*/
it('is stated above the tracklist once the user chooses', async () => {
const el = await album();
const select = shadow<HTMLSelectElement>(el, '#version-select')!;
const other = [...select.options].find((o) => o.value !== select.value)!;
select.value = other.value;
select.dispatchEvent(new Event('change'));
await el.updateComplete;
const line = shadow(el, '.chosen-version');
expect(line).not.toBeNull();
const [chosen, tracklist] = order(el, '.chosen-version', '.tracklist');
expect(chosen).toBeGreaterThan(-1);
expect(tracklist).toBeGreaterThan(chosen);
});
it('offers a way back, which clears the line', async () => {
const el = await album();
const select = shadow<HTMLSelectElement>(el, '#version-select')!;
const first = select.value;
const other = [...select.options].find((o) => o.value !== first)!;
select.value = other.value;
select.dispatchEvent(new Event('change'));
await el.updateComplete;
shadow<HTMLButtonElement>(el, '.chosen-version-reset')?.click();
await el.updateComplete;
expect(shadow<HTMLSelectElement>(el, '#version-select')?.value).toBe(first);
expect(shadow(el, '.chosen-version')).toBeNull();
});
/** A panel that shuts on use cannot be used twice. */
it('leaves the disclosure open after a choice', async () => {
const el = await album();
shadow<HTMLButtonElement>(el, '.versions-toggle')?.click();
await el.updateComplete;
const select = shadow<HTMLSelectElement>(el, '#version-select')!;
const other = [...select.options].find((o) => o.value !== select.value)!;
select.value = other.value;
select.dispatchEvent(new Event('change'));
await el.updateComplete;
expect(shadow(el, '#versions-body')?.hasAttribute('hidden')).toBe(false);
});
});