Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
905654cc84 | ||
|
|
219fa3c615 | ||
|
|
c4e055ce51 |
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user