Compare commits
4
Commits
d33dfb2264
...
979c6e83ed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
979c6e83ed | ||
|
|
48f7795687 | ||
|
|
c400f681c2 | ||
|
|
451b46e63c |
No files matched your search
@@ -2196,3 +2196,71 @@ Six more things worth keeping:
|
|||||||
hook* is what raises it — and a spec that spends the shared database
|
hook* is what raises it — and a spec that spends the shared database
|
||||||
has to give it back, since the 90 specs share one backend in file
|
has to give it back, since the 90 specs share one backend in file
|
||||||
order.
|
order.
|
||||||
|
|
||||||
|
## A state nothing produces is a state nobody has checked
|
||||||
|
|
||||||
|
Plan 009 phase 1: `library-status-indicator`'s third state, wired.
|
||||||
|
|
||||||
|
The generalisation: **an enum whose last value is never constructed is
|
||||||
|
not unfinished, it is wrong** — because everything around it has been
|
||||||
|
written, reviewed and tested against the two values that do occur, and
|
||||||
|
the code reads as complete from every angle except the one that
|
||||||
|
produces the third. `LibraryStatus` has had `queued` since it was
|
||||||
|
written: styled amber, given an hourglass, given the sentence "… is
|
||||||
|
queued for download". All eight call sites were a two-way ternary. So
|
||||||
|
an album on the request list rendered a plus and announced "is not in
|
||||||
|
your library" — on the same page, forty pixels from a filled button
|
||||||
|
reading "Wanted".
|
||||||
|
|
||||||
|
Nothing was going to find that. `make ui-test` and `make e2e` both
|
||||||
|
covered the badge; both asserted the states it produced. 007 phase 6
|
||||||
|
had rewritten this exact component, and the note it left behind
|
||||||
|
("when the download-client integration lands…") was itself the reason
|
||||||
|
nobody looked: it names a *future* condition for work that was already
|
||||||
|
possible, since `backend/download` was 16 541 lines and 20 bound
|
||||||
|
methods on the day it was written. **A written-down reason not to look
|
||||||
|
ages worse than the code it is about.**
|
||||||
|
|
||||||
|
Six more things worth keeping:
|
||||||
|
|
||||||
|
- **The second bug was in the screenshot of the first.** The "Wanted"
|
||||||
|
button rendered a question mark — the missing-icon fallback —
|
||||||
|
because `bookmark-check` is Font Awesome **Pro** and has never been
|
||||||
|
bundled. `offline-icons.spec.ts` asserts `__yjIconMisses` is empty
|
||||||
|
and passed the whole time: no spec had ever put the app in a state
|
||||||
|
where an album was requested. The bundled-icon design anticipated
|
||||||
|
exactly this ("twenty call sites compute their icon name from
|
||||||
|
state") and the *sweep* still could not see it, because a sweep only
|
||||||
|
sees the states it visits. Seventh regression in five plans that
|
||||||
|
only a PNG has caught, and the first found in a PNG taken of a
|
||||||
|
different bug.
|
||||||
|
- **A property that does not change does not re-render a child.**
|
||||||
|
`top-results-row` reads the request list, and its host handing back
|
||||||
|
the same `results` array means Lit stops at the property — the row
|
||||||
|
keeps its old badges while the store holds the right answer. The
|
||||||
|
virtualizer rule (`requestUpdate()` on host state) one level milder,
|
||||||
|
and the same fix: subscribe where the state is *read*.
|
||||||
|
- **A spec that gives state back has to be run twice to know it did.**
|
||||||
|
The `afterAll` cleanup called `callBinding`, which goes through
|
||||||
|
`window.__yjEvents` — installed by the `app` fixture, not by a bare
|
||||||
|
`browser.newPage()`. It threw where nothing was watching, left the
|
||||||
|
request behind and failed the *next* run with a stale `queued`. One
|
||||||
|
run proves the assertions; the second proves the teardown.
|
||||||
|
- **A freshly launched app cannot search its own catalog for ~40 s.**
|
||||||
|
The core artifact merge has to land (`core artifact: merge complete`
|
||||||
|
in `.dev/app.log`), and until it does Explore's search returns
|
||||||
|
nothing at all — *including for rows staged directly into
|
||||||
|
`explore_index` a moment earlier*, which makes it look like the
|
||||||
|
staging failed. Cost a cycle here reading as a failure of the neuter
|
||||||
|
the run was under. Budget 60 s, or wait for the log line.
|
||||||
|
- **The neuter has to be per line, not per feature.** Two fixes landed
|
||||||
|
together and each got its own one-line neuter, which is what made
|
||||||
|
the two failures distinguishable: one spec reported the wrong badge
|
||||||
|
status, the other reported `["bookmark-check"]`. Neutered together
|
||||||
|
they would both have failed and either could have been decorative.
|
||||||
|
- **The fix is where the rule is, and the rule was in eight places.**
|
||||||
|
Every one of the eight sites was individually reasonable; the third
|
||||||
|
state was missing from all of them because no site owns the
|
||||||
|
question. Same shape as `getCoverUrl()`, `track-index.ts` and
|
||||||
|
`page-header` — when a rule is written per call site, the call sites
|
||||||
|
do not disagree, they are all incomplete in the same way.
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# 009 — The badge that cannot act, and the state it already had
|
||||||
|
|
||||||
|
**Status:** active — Phase 1 shipped; Phase 2 is a decision, not written yet.
|
||||||
|
**Branch:** main
|
||||||
|
**Created:** 2026-08-13
|
||||||
|
**Follows:** 008-the-last-audit
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
007 phase 6 turned `library-status-indicator` from a `<button>` that did
|
||||||
|
nothing into a `role="img"` badge, on the rule that **a control which
|
||||||
|
cannot act is worse than none**, and wrote down what would change the
|
||||||
|
answer: *"when the download-client integration lands, the right change
|
||||||
|
is to make it a `<button>` again with a handler."*
|
||||||
|
|
||||||
|
Two things about that are wrong, and both were found by reading the code
|
||||||
|
and then the running app rather than the note.
|
||||||
|
|
||||||
|
**The download client has largely already landed.** `backend/download`
|
||||||
|
is 16 541 lines: a durable request model with four entity types
|
||||||
|
(`artist` / `release-group` / `release` / `recording`, `request.go`), a
|
||||||
|
reconciler, a staging importer, six provider adapters, 20 bound methods,
|
||||||
|
`downloads-view`, the `download-picker` dialog, and a working **"Want
|
||||||
|
this"** toggle on `explore-album-details`. What has not landed is the
|
||||||
|
badge.
|
||||||
|
|
||||||
|
**And the badge is not merely inert — it is wrong.** `LibraryStatus`
|
||||||
|
declares, styles and labels a third state, `queued` ("… is queued for
|
||||||
|
download"). **Zero of the eight call sites ever produce it**
|
||||||
|
(`explore-view:1839,1877`, `explore-artist-details:2116,2228,2323`,
|
||||||
|
`explore-album-details:1641,2283`, `top-results-row:258` — every one is
|
||||||
|
a two-way ternary). So an album the user has *already requested*
|
||||||
|
displays a plus and says it is not in their library.
|
||||||
|
|
||||||
|
### Reproduced, 2026-08-13, before anything was written
|
||||||
|
|
||||||
|
Against `SEED=default` with the real 900 000-row catalog:
|
||||||
|
`AddRequest({mbid: e51c54ea…, entity: 'release-group'})` for *GOLDEN* by
|
||||||
|
Jung Kook, then Explore → search "GOLDEN":
|
||||||
|
|
||||||
|
```
|
||||||
|
status not-in-library
|
||||||
|
icon plus
|
||||||
|
aria Album "GOLDEN" is not in your library
|
||||||
|
```
|
||||||
|
|
||||||
|
and on the album's **own detail page**, forty pixels apart in the same
|
||||||
|
screenshot: the button reads **"Wanted"** (filled) and the badge beside
|
||||||
|
the title reads **plus / "is not in your library"**. One component,
|
||||||
|
two surfaces, opposite answers. This is the header-badge-contradicting-
|
||||||
|
Settings failure again, and again only a PNG showed it.
|
||||||
|
|
||||||
|
The same PNG showed a second one, which is why it is in this plan:
|
||||||
|
**`bookmark-check` is not a bundled icon.** `window.__yjIconMisses`
|
||||||
|
reports exactly `["bookmark-check"]`, so the "Wanted" button renders the
|
||||||
|
fallback question-mark glyph. `e2e/specs/offline-icons.spec.ts` asserts
|
||||||
|
that array is empty and passes, because no spec has ever put the app in
|
||||||
|
a state where an album is requested — precisely the "twenty call sites
|
||||||
|
compute their icon name from state" case `names.txt` exists for.
|
||||||
|
|
||||||
|
## Ordering principle
|
||||||
|
|
||||||
|
By **what is a fact and what is a decision**.
|
||||||
|
|
||||||
|
Phase 1 is a bug: the badge contradicts the app's own state, and fixing
|
||||||
|
it needs no interaction design at all. It also produces the evidence
|
||||||
|
Phase 2 needs — once the badge can say "requested", whether it must also
|
||||||
|
*become* requestable is a question that can be looked at rather than
|
||||||
|
assumed.
|
||||||
|
|
||||||
|
Phase 2 is a decision made before any code, in the shape 008 phase 4
|
||||||
|
used, because one 20 px circle would otherwise mean three different
|
||||||
|
commitments: on an artist card a **discography subscription**
|
||||||
|
(`scope: 'future'`, `Expands()`, never satisfied), on an album a
|
||||||
|
release-group request, on a track row a recording request.
|
||||||
|
|
||||||
|
Phase 3 is whatever Phase 2 leaves. **"Album only" is a legitimate
|
||||||
|
outcome** and shrinks this plan rather than inventing work for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — the badge tells the truth
|
||||||
|
|
||||||
|
**Ships:**
|
||||||
|
|
||||||
|
- `utils/library-status.ts` — one definition of the rule, since the
|
||||||
|
reason all eight sites are two-state is that the rule is written at
|
||||||
|
all eight. Owning something outranks wanting it, so `in-library` wins
|
||||||
|
over `queued`.
|
||||||
|
- The eight call sites using it.
|
||||||
|
- `explore-view` gaining the `downloadStore` subscription both detail
|
||||||
|
views already have (`init()` + `subscribe()`), through
|
||||||
|
`view-lifecycle` — it is a **cached primary view**, so a raw
|
||||||
|
`connectedCallback` subscription would live for the session.
|
||||||
|
- `bookmark-check` in `src/icons/names.txt`, and an e2e case that
|
||||||
|
reaches the state that exposes it.
|
||||||
|
|
||||||
|
**The badge stays `role="img"`.** Telling the truth is not acting.
|
||||||
|
|
||||||
|
**Watch for:** `downloadStore.init()` fetches providers, descriptors,
|
||||||
|
downloads *and* requests, so this warms a singleton on a page that
|
||||||
|
previously did not construct it — "a store with no subscriber fetches
|
||||||
|
nothing" cuts the other way here, and the cost belongs in the note.
|
||||||
|
|
||||||
|
### Phase 1 — what actually shipped
|
||||||
|
|
||||||
|
Three landings. `make ui-test` 677 → **685**; `make e2e` 90 → **92**.
|
||||||
|
|
||||||
|
- **The rule, written once.** `utils/library-status.ts`, the eight call
|
||||||
|
sites, and `explore-view`'s subscription.
|
||||||
|
- **The Pro icon.** `regular/bookmark` / `solid/bookmark`, vendored.
|
||||||
|
- **`e2e/specs/requested-badge.spec.ts`**, which is also the first spec
|
||||||
|
that reaches the state the icon sweep needed.
|
||||||
|
|
||||||
|
Pinned by `library-status.test.ts` (8) and `requested-badge.spec.ts`
|
||||||
|
(2). Both e2e cases were watched failing on the pre-fix build by
|
||||||
|
neutering one line each — the badge reported `not-in-library` where
|
||||||
|
`queued` was expected, and the sweep returned `["bookmark-check"]`.
|
||||||
|
|
||||||
|
#### Where the plan was wrong — Phase 1
|
||||||
|
|
||||||
|
Six things, and the first is the plan's own framing.
|
||||||
|
|
||||||
|
- **"When the download client lands" had already half happened, and
|
||||||
|
the note that said otherwise was written before it.** 007 phase 6
|
||||||
|
left a condition ("make it a button *with* a handler") that reads as
|
||||||
|
future work; `backend/download` was 16 541 lines and 20 bound methods
|
||||||
|
at the time it was written. The badge was not waiting on the download
|
||||||
|
client. It was waiting on somebody looking.
|
||||||
|
- **The bug was one layer below the one in the plan.** The plan says
|
||||||
|
the badge cannot act. What the reproduction says is that it could not
|
||||||
|
even *report* — three states declared, two produced, at eight sites
|
||||||
|
none of which knew about the third. "A control that cannot act" and
|
||||||
|
"a control that is wrong" are different faults and only the second
|
||||||
|
one is a lie.
|
||||||
|
- **The second bug was in the screenshot of the first.** The "Wanted"
|
||||||
|
button rendered a question mark, which is the missing-icon fallback:
|
||||||
|
`bookmark-check` is a **Pro** name. It has been that way for as long
|
||||||
|
as anything could be requested, and `offline-icons.spec.ts` — which
|
||||||
|
exists to assert exactly this — passed throughout, because it never
|
||||||
|
reached a state where an album was requested. Seventh regression in
|
||||||
|
five plans that only a PNG has caught, and the first one caught in a
|
||||||
|
PNG taken of a *different* bug.
|
||||||
|
- **A sibling component does not hear its host re-render.**
|
||||||
|
`top-results-row` takes `results` as a property; `explore-view`
|
||||||
|
re-rendering hands back the same array, so Lit stops at the property
|
||||||
|
and the row keeps its old badges. Same shape as the virtualizer rule
|
||||||
|
one level milder, and the fix is the same: subscribe where the state
|
||||||
|
is read.
|
||||||
|
- **The cleanup ran on a page that could not run it.** `afterAll` used
|
||||||
|
`callBinding`, which goes through `window.__yjEvents` — installed by
|
||||||
|
the `app` fixture and not by `browser.newPage()`. It threw where
|
||||||
|
nothing was watching, left the request behind, and failed the *next*
|
||||||
|
run of the same spec with a stale `queued`. A spec that gives state
|
||||||
|
back has to be checked by running it twice, which is what found this.
|
||||||
|
- **A freshly launched app cannot search its own catalog for ~40 s.**
|
||||||
|
The core artifact merge (`core artifact: merge complete` in
|
||||||
|
`.dev/app.log`) has to land first, and until it does Explore's search
|
||||||
|
returns nothing — *including for rows staged directly into
|
||||||
|
`explore_index` a moment earlier*, which is what makes it look like a
|
||||||
|
staging bug. It cost a cycle here reading as a failure of the neuter
|
||||||
|
it was run under.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — what a badge click means, per entity
|
||||||
|
|
||||||
|
*(decision, before code — not started)*
|
||||||
|
|
||||||
|
What Phase 1 leaves for it, now as observations rather than guesses:
|
||||||
|
|
||||||
|
- On the album page the badge and the "Want this" button now say the
|
||||||
|
same thing twice, four centimetres apart. That is an argument for the
|
||||||
|
badge being **read-only there** and clickable only where there is no
|
||||||
|
button — or for the button going.
|
||||||
|
- A requested album shows an amber hourglass while every track in its
|
||||||
|
tracklist shows a plus, which is correct per the rule and reads as
|
||||||
|
busy. Worth deciding whether a track inside a requested album should
|
||||||
|
render *nothing* rather than a plus.
|
||||||
|
- An artist badge would mean a discography subscription, which is the
|
||||||
|
heaviest commitment in the download subsystem behind the smallest
|
||||||
|
control in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — the button
|
||||||
|
|
||||||
|
*(scope depends on Phase 2)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deliberately not in this plan
|
||||||
|
|
||||||
|
- **Deleting the file from disk** (008 phase 4's explicit sequel). Not
|
||||||
|
refused — mis-ordered. 008's own notes record that the *reversible*
|
||||||
|
option shipped with **nothing implementing its reversibility**:
|
||||||
|
`excluded_paths` has no management surface, and "a full rescan clears
|
||||||
|
it" is the escape hatch. Shipping an irreversible delete beside a
|
||||||
|
reversible one that cannot yet be undone is backwards, and the
|
||||||
|
platform trash is a new cross-platform dependency besides.
|
||||||
|
- **`a11y.20`, deriving `_itemSize` from a measured row.** Real and
|
||||||
|
confirmed in code — `.track-row` is `height: 33px; contain: strict`
|
||||||
|
with a `rem` font size, so text scales and the box does not, across
|
||||||
|
four lists (33 / 49 / 45 / 45 px). It waits because its only honest
|
||||||
|
verification does not exist yet: both surviving comments
|
||||||
|
(`track-list.ts:349`, `queue-panel.ts:179`) say a wrong `_itemSize`
|
||||||
|
desynchronises the **native scrollbar at 20k+ rows**, and `make perf`
|
||||||
|
has no scroll-fidelity row. That measurement is its own first phase
|
||||||
|
and belongs to a plan that is about it.
|
||||||
|
|
||||||
|
## First step
|
||||||
|
|
||||||
|
Phase 1, and within it the helper rather than the call sites — the
|
||||||
|
reproduction above is already the failing case, and the point of the
|
||||||
|
helper is that there is one place for the next state to be added.
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { test, expect, callBinding } from '../support/fixtures.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The badge says an album is requested, and the button beside it agrees.
|
||||||
|
*
|
||||||
|
* `library-status-indicator` has had three states since it was written
|
||||||
|
* and produced two: every one of the eight call sites was a two-way
|
||||||
|
* ternary, so an album already on the request list showed a plus and
|
||||||
|
* said "is not in your library" — on the same page, forty pixels from a
|
||||||
|
* filled button reading "Wanted".
|
||||||
|
*
|
||||||
|
* This spec exists at this tier rather than only in the component one
|
||||||
|
* because of what it drags in with it: reaching the requested state is
|
||||||
|
* also the only way to render the requested *icon*, and the requested
|
||||||
|
* icon was `bookmark-check`, a Font Awesome **Pro** name that has never
|
||||||
|
* been bundled. `offline-icons.spec.ts` asserts `__yjIconMisses` is
|
||||||
|
* empty and passed anyway, because no spec had ever put the app in this
|
||||||
|
* state. A name computed from state is only checkable from the state.
|
||||||
|
*
|
||||||
|
* It gives back what it spends: the request is removed in `afterAll`,
|
||||||
|
* and the staged catalog rows are `INSERT OR IGNORE`d so a second run
|
||||||
|
* against the same backend is a no-op.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A release group that exists whether or not this environment has a
|
||||||
|
* catalog — CI's `YJ_CORE_INDEX_URL` is deliberately dead. */
|
||||||
|
const MBID = 'e2e-rg-badge-0001';
|
||||||
|
const TITLE = 'Requested Album';
|
||||||
|
const ARTIST = 'Badge Artist';
|
||||||
|
|
||||||
|
let requestId = 0;
|
||||||
|
|
||||||
|
test.describe('the requested badge', () => {
|
||||||
|
test.beforeAll(async ({ browser, baseURL }) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
await page.goto(baseURL!);
|
||||||
|
|
||||||
|
const res = await page.evaluate(
|
||||||
|
async (row) => {
|
||||||
|
const r = await fetch('/__test/sql', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
sql: `INSERT OR IGNORE INTO explore_index
|
||||||
|
(entity_type, mbid, title, artist_name, artist_mbid,
|
||||||
|
popularity, listener_count, primary_type)
|
||||||
|
VALUES ('release_group', ?, ?, ?, 'e2e-ar-badge', 10, 10, 'Album')`,
|
||||||
|
args: [row.mbid, row.title, row.artist],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: r.status, body: await r.text() };
|
||||||
|
},
|
||||||
|
{ mbid: MBID, title: TITLE, artist: ARTIST },
|
||||||
|
);
|
||||||
|
|
||||||
|
// A setup step whose failure is not checked is not setup.
|
||||||
|
expect(res.status, `staging failed: ${res.body}`).toBe(200);
|
||||||
|
|
||||||
|
// A previous run that died between adding and removing would leave
|
||||||
|
// this album requested, and the first assertion here is that it is
|
||||||
|
// not — so start from a known state rather than from the last run's
|
||||||
|
// luck. The 90 specs share one backend in file order.
|
||||||
|
await clearRequest(page);
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ browser, baseURL }) => {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
await page.goto(baseURL!);
|
||||||
|
await clearRequest(page);
|
||||||
|
await page.close();
|
||||||
|
requestId = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a requested album is queued, not absent', async ({ app }) => {
|
||||||
|
const libraryId = 1;
|
||||||
|
|
||||||
|
// Before: the badge for an unrequested album.
|
||||||
|
await app.getByTestId('nav-explore').click();
|
||||||
|
await search(app, TITLE);
|
||||||
|
|
||||||
|
expect(await badgeStatus(app, TITLE)).toBe('not-in-library');
|
||||||
|
|
||||||
|
requestId = await addRequest(app, libraryId);
|
||||||
|
|
||||||
|
expect(requestId).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The badge is told by the store, which is told by the backend —
|
||||||
|
// no navigation, no reload.
|
||||||
|
await expect
|
||||||
|
.poll(() => badgeStatus(app, TITLE), { timeout: 10_000 })
|
||||||
|
.toBe('queued');
|
||||||
|
|
||||||
|
// And it says so where it counts. The label is the whole point:
|
||||||
|
// the plus used to be accompanied by "is not in your library".
|
||||||
|
expect(await badgeLabel(app, TITLE)).toContain('queued for download');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the requested state renders a real icon', async ({ app }) => {
|
||||||
|
// `bookmark-check` is Pro, so the button rendered the fallback
|
||||||
|
// glyph for as long as anything had been requested. The sweep in
|
||||||
|
// offline-icons.spec.ts could not see it: it never reached here.
|
||||||
|
// Runs after the test above in file order, which is where the
|
||||||
|
// request comes from — but a spec that only passes as part of a
|
||||||
|
// sequence is a spec that lies when it is run alone.
|
||||||
|
if (!requestId) requestId = await addRequest(app, 1);
|
||||||
|
|
||||||
|
await app.getByTestId('nav-explore').click();
|
||||||
|
await search(app, TITLE);
|
||||||
|
await openFirstAlbum(app);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
app.evaluate(() => {
|
||||||
|
const ds = document.querySelector('explore-album-details')
|
||||||
|
?.shadowRoot;
|
||||||
|
const btn = [...(ds?.querySelectorAll('wa-button') ?? [])].find(
|
||||||
|
(b) => /Wanted/.test(b.textContent ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
return btn?.querySelector('wa-icon')?.getAttribute('name') ?? '';
|
||||||
|
}),
|
||||||
|
{ timeout: 10_000 },
|
||||||
|
)
|
||||||
|
.not.toBe('');
|
||||||
|
|
||||||
|
const misses = await app.evaluate(
|
||||||
|
() =>
|
||||||
|
(window as unknown as { __yjIconMisses?: string[] }).__yjIconMisses ??
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(misses, 'an icon name that is not bundled').toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function addRequest(
|
||||||
|
app: import('@playwright/test').Page,
|
||||||
|
libraryId: number,
|
||||||
|
): Promise<number> {
|
||||||
|
return callBinding<number>(app, 'download.Service.AddRequest', [
|
||||||
|
{
|
||||||
|
mbid: MBID,
|
||||||
|
entity: 'release-group',
|
||||||
|
libraryId,
|
||||||
|
artist: ARTIST,
|
||||||
|
title: TITLE,
|
||||||
|
scope: 'future',
|
||||||
|
secondary: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop any request for this album, through the raw binding.
|
||||||
|
*
|
||||||
|
* Deliberately not `callBinding`: that goes through `window.__yjEvents`,
|
||||||
|
* which only exists on a page the `app` fixture created — a bare
|
||||||
|
* `browser.newPage()` has no init script, so the bridge is undefined and
|
||||||
|
* the cleanup throws where nobody is looking. The first version of this
|
||||||
|
* did exactly that and left the request behind, which failed the *next*
|
||||||
|
* run of this same spec.
|
||||||
|
*/
|
||||||
|
async function clearRequest(page: import('@playwright/test').Page) {
|
||||||
|
await page.evaluate(async (mbid) => {
|
||||||
|
const go = (
|
||||||
|
window as unknown as {
|
||||||
|
go?: {
|
||||||
|
download?: {
|
||||||
|
Service?: {
|
||||||
|
ListRequests(): Promise<{ id: number; mbid: string }[]>;
|
||||||
|
RemoveRequest(id: number): Promise<void>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).go;
|
||||||
|
|
||||||
|
const svc = go?.download?.Service;
|
||||||
|
|
||||||
|
if (!svc) return;
|
||||||
|
|
||||||
|
const rows = (await svc.ListRequests()) ?? [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.mbid?.toLowerCase() === mbid.toLowerCase()) {
|
||||||
|
await svc.RemoveRequest(row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, MBID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Type into Explore's own search box and wait for it to settle. */
|
||||||
|
async function search(app: import('@playwright/test').Page, term: string) {
|
||||||
|
// A view is a chunk, and `document.createElement` on a tag that has
|
||||||
|
// not loaded yet yields an inert element rather than throwing — so
|
||||||
|
// the box being missing reads exactly like a selector bug.
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() =>
|
||||||
|
app.evaluate(
|
||||||
|
() =>
|
||||||
|
!!document
|
||||||
|
.querySelector('explore-view')
|
||||||
|
?.shadowRoot?.querySelector('input'),
|
||||||
|
),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
|
||||||
|
await app.evaluate((t) => {
|
||||||
|
const sr = document.querySelector('explore-view')?.shadowRoot;
|
||||||
|
const input = sr?.querySelector('input');
|
||||||
|
|
||||||
|
if (!input) throw new Error('explore search box not found');
|
||||||
|
|
||||||
|
input.value = t;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}, term);
|
||||||
|
|
||||||
|
// 60 s, and it is not paranoia. A freshly launched app spends its
|
||||||
|
// first ~40 s merging the core catalog artifact, and until that lands
|
||||||
|
// Explore's search returns nothing at all — including for rows staged
|
||||||
|
// directly into `explore_index` a moment ago. A shorter budget fails
|
||||||
|
// here for a reason that has nothing to do with what is being tested,
|
||||||
|
// which is how this spec first "failed" on a build that was fine.
|
||||||
|
await expect
|
||||||
|
.poll(() => cardTitles(app), { timeout: 60_000 })
|
||||||
|
.toContain(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardTitles(app: import('@playwright/test').Page): Promise<string[]> {
|
||||||
|
return app.evaluate(() =>
|
||||||
|
[
|
||||||
|
...(document
|
||||||
|
.querySelector('explore-view')
|
||||||
|
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
|
||||||
|
].map((b) => b.getAttribute('label') ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeStatus(
|
||||||
|
app: import('@playwright/test').Page,
|
||||||
|
label: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return app.evaluate((wanted) => {
|
||||||
|
const badge = [
|
||||||
|
...(document
|
||||||
|
.querySelector('explore-view')
|
||||||
|
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
|
||||||
|
].find((b) => b.getAttribute('label') === wanted);
|
||||||
|
|
||||||
|
return badge?.getAttribute('status') ?? '(no badge)';
|
||||||
|
}, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeLabel(
|
||||||
|
app: import('@playwright/test').Page,
|
||||||
|
label: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return app.evaluate((wanted) => {
|
||||||
|
const badge = [
|
||||||
|
...(document
|
||||||
|
.querySelector('explore-view')
|
||||||
|
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
|
||||||
|
].find((b) => b.getAttribute('label') === wanted);
|
||||||
|
|
||||||
|
return (
|
||||||
|
badge?.shadowRoot?.querySelector('.badge')?.getAttribute('aria-label') ??
|
||||||
|
'(no badge)'
|
||||||
|
);
|
||||||
|
}, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openFirstAlbum(app: import('@playwright/test').Page) {
|
||||||
|
await app.evaluate((wanted) => {
|
||||||
|
const badge = [
|
||||||
|
...(document
|
||||||
|
.querySelector('explore-view')
|
||||||
|
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
|
||||||
|
].find((b) => b.getAttribute('label') === wanted);
|
||||||
|
|
||||||
|
(badge?.closest('.album-card') as HTMLElement | null)?.click();
|
||||||
|
}, TITLE);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => app.evaluate(() => !!document.querySelector('explore-album-details')),
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L320 0c35.3 0 64 28.7 64 64l0 417.1c0 25.6-28.5 40.8-49.8 26.6L192 412.8 49.8 507.7C28.5 521.9 0 506.6 0 481.1L0 64zM64 48c-8.8 0-16 7.2-16 16l0 387.2 117.4-78.2c16.1-10.7 37.1-10.7 53.2 0L336 451.2 336 64c0-8.8-7.2-16-16-16L64 48z"/></svg>
|
||||||
|
After Width: | Height: | Size: 566 B |
@@ -24,6 +24,8 @@ import { EventsOn } from '@runtime/runtime';
|
|||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '../library-status-indicator/library-status-indicator.js';
|
import '../library-status-indicator/library-status-indicator.js';
|
||||||
|
import { libraryStatusFor } from '@utils/library-status';
|
||||||
|
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator';
|
||||||
import '../catalog-scope-notice/catalog-scope-notice.js';
|
import '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||||
@@ -1443,7 +1445,7 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
* - cachedAlbums has MBID match → owned
|
* - cachedAlbums has MBID match → owned
|
||||||
* - any selected version has → owned (covers local-only albums
|
* - any selected version has → owned (covers local-only albums
|
||||||
* a track marked inLibrary where releaseGroup may be null)
|
* a track marked inLibrary where releaseGroup may be null)
|
||||||
* - else → not owned
|
* - else → whatever the request list says
|
||||||
*
|
*
|
||||||
* Four different claims of decreasing confidence, OR'd together and
|
* Four different claims of decreasing confidence, OR'd together and
|
||||||
* reported as one tick — the last of which fires when a *single*
|
* reported as one tick — the last of which fires when a *single*
|
||||||
@@ -1452,10 +1454,11 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
* actions key off; this stays as it was, because the indicator's
|
* actions key off; this stays as it was, because the indicator's
|
||||||
* job is "is any of this yours" and that is what it answers.
|
* job is "is any of this yours" and that is what it answers.
|
||||||
*
|
*
|
||||||
* No queued state for now — that's reserved for future
|
* When none of them hold the answer is not automatically "no":
|
||||||
* download-client integration.
|
* the album may be on the request list, which the button directly
|
||||||
|
* below this badge has reported as "Wanted" all along.
|
||||||
*/
|
*/
|
||||||
private albumLibraryStatus(): 'in-library' | 'not-in-library' {
|
private albumLibraryStatus(): LibraryStatus {
|
||||||
if (this.localAlbumId > 0) return 'in-library';
|
if (this.localAlbumId > 0) return 'in-library';
|
||||||
|
|
||||||
if (this.releaseGroup?.inLibrary) return 'in-library';
|
if (this.releaseGroup?.inLibrary) return 'in-library';
|
||||||
@@ -1477,7 +1480,11 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'not-in-library';
|
// None of the five ownership claims held, so the badge falls
|
||||||
|
// through to the one thing this page already knew and never
|
||||||
|
// said: whether the album is on the request list. The button
|
||||||
|
// below it has read "Wanted" all along.
|
||||||
|
return libraryStatusFor(false, this.releaseGroupMBID);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1882,9 +1889,14 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
appearance=${this.isRequested ? 'filled' : 'outlined'}
|
appearance=${this.isRequested ? 'filled' : 'outlined'}
|
||||||
@click=${() => void this.toggleRequested(request?.id)}
|
@click=${() => void this.toggleRequested(request?.id)}
|
||||||
>
|
>
|
||||||
|
<!-- The requested state used to ask for bookmark-check,
|
||||||
|
which is a Font Awesome *Pro* name: never bundled,
|
||||||
|
so this button has rendered the missing-icon
|
||||||
|
fallback in that state ever since. Outline and solid
|
||||||
|
of the same Free glyph carry the toggle instead. -->
|
||||||
<wa-icon
|
<wa-icon
|
||||||
slot="start"
|
slot="start"
|
||||||
name=${this.isRequested ? 'bookmark-check' : 'bookmark'}
|
name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'}
|
||||||
></wa-icon>
|
></wa-icon>
|
||||||
${this.isRequested ? 'Wanted' : 'Want this'}
|
${this.isRequested ? 'Wanted' : 'Want this'}
|
||||||
</wa-button>
|
</wa-button>
|
||||||
@@ -2280,7 +2292,7 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
)}</span
|
)}</span
|
||||||
>
|
>
|
||||||
<library-status-indicator
|
<library-status-indicator
|
||||||
status=${track.inLibrary ? 'in-library' : 'not-in-library'}
|
status=${libraryStatusFor(Boolean(track.inLibrary), track.mbid)}
|
||||||
entity-type="track"
|
entity-type="track"
|
||||||
label=${track.title}
|
label=${track.title}
|
||||||
></library-status-indicator>
|
></library-status-indicator>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { EventsOn } from '@runtime/runtime';
|
|||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '../library-status-indicator/library-status-indicator.js';
|
import '../library-status-indicator/library-status-indicator.js';
|
||||||
|
import { libraryStatusFor } from '@utils/library-status';
|
||||||
import '../catalog-scope-notice/catalog-scope-notice.js';
|
import '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
|
||||||
|
|
||||||
@@ -2113,7 +2114,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
${formatListenCount(t.totalListenCount)} plays
|
${formatListenCount(t.totalListenCount)} plays
|
||||||
</span>
|
</span>
|
||||||
<library-status-indicator
|
<library-status-indicator
|
||||||
status=${t.inLibrary || t.localId ? 'in-library' : 'not-in-library'}
|
status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)}
|
||||||
entity-type="track"
|
entity-type="track"
|
||||||
label=${t.trackName}
|
label=${t.trackName}
|
||||||
></library-status-indicator>
|
></library-status-indicator>
|
||||||
@@ -2225,7 +2226,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
|
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
<library-status-indicator
|
<library-status-indicator
|
||||||
status=${rg.inLibrary || rg.localId ? 'in-library' : 'not-in-library'}
|
status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)}
|
||||||
entity-type="album"
|
entity-type="album"
|
||||||
label=${rg.title}
|
label=${rg.title}
|
||||||
size="18"
|
size="18"
|
||||||
@@ -2320,9 +2321,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
const artURL = this.thumbnailURLs.get(rg.mbid) || '';
|
const artURL = this.thumbnailURLs.get(rg.mbid) || '';
|
||||||
const year = extractYear(rg.firstReleaseDate);
|
const year = extractYear(rg.firstReleaseDate);
|
||||||
const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary);
|
const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary);
|
||||||
const status: 'in-library' | 'not-in-library' = inLibrary
|
const status = libraryStatusFor(inLibrary, rg.mbid);
|
||||||
? 'in-library'
|
|
||||||
: 'not-in-library';
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { avatarBackground } from '@utils/avatar-color';
|
import { avatarBackground } from '@utils/avatar-color';
|
||||||
|
import { libraryStatusFor } from '@utils/library-status';
|
||||||
|
import { downloadStore } from '@store/download-store';
|
||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
||||||
import '@components/page-header/page-header';
|
import '@components/page-header/page-header';
|
||||||
@@ -724,6 +726,19 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
|||||||
this.cancelIndexStatus = EventsOn(Events.IndexStatusChanged, () => {
|
this.cancelIndexStatus = EventsOn(Events.IndexStatusChanged, () => {
|
||||||
if (this.shelves?.state !== 'ready') void this.loadShelves();
|
if (this.shelves?.state !== 'ready') void this.loadShelves();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The badges on every result card say whether something is
|
||||||
|
// already requested, which a background reconcile pass changes
|
||||||
|
// without this page doing anything. Registered `whileActive`
|
||||||
|
// rather than on connect: this view is cached and never
|
||||||
|
// unmounts, so a connect-time subscription would run for the
|
||||||
|
// life of the session from pages it is not on.
|
||||||
|
//
|
||||||
|
// `init()` is four fetches, and it happens on arrival for the
|
||||||
|
// same reason `loadShelves()` does — a user who never opens
|
||||||
|
// Explore should not pay for it.
|
||||||
|
this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
|
||||||
|
void downloadStore.init().then(() => this.requestUpdate());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A debounced search that lands after the user has left the page is
|
/** A debounced search that lands after the user has left the page is
|
||||||
@@ -1836,7 +1851,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
|||||||
${year ? html`<span>${year}</span>` : nothing}
|
${year ? html`<span>${year}</span>` : nothing}
|
||||||
</div>
|
</div>
|
||||||
<library-status-indicator
|
<library-status-indicator
|
||||||
status=${this.libraryMBIDs.has(rg.mbid) || rg.inLibrary ? 'in-library' : 'not-in-library'}
|
status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)}
|
||||||
entity-type="album"
|
entity-type="album"
|
||||||
label=${rg.title}
|
label=${rg.title}
|
||||||
></library-status-indicator>
|
></library-status-indicator>
|
||||||
@@ -1874,7 +1889,7 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
|
|||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
<library-status-indicator
|
<library-status-indicator
|
||||||
status=${this.libraryMBIDs.has(r.mbid) || r.inLibrary ? 'in-library' : 'not-in-library'}
|
status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)}
|
||||||
entity-type="track"
|
entity-type="track"
|
||||||
label=${r.title}
|
label=${r.title}
|
||||||
></library-status-indicator>
|
></library-status-indicator>
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
import '../library-status-indicator/library-status-indicator.js';
|
import '../library-status-indicator/library-status-indicator.js';
|
||||||
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
|
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
|
||||||
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
|
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
|
||||||
|
import { libraryStatusFor } from '../../utils/library-status';
|
||||||
|
import { downloadStore } from '../../store/download-store';
|
||||||
|
|
||||||
/** Format milliseconds as m:ss. */
|
/** Format milliseconds as m:ss. */
|
||||||
function formatDuration(ms: number | undefined): string {
|
function formatDuration(ms: number | undefined): string {
|
||||||
@@ -50,6 +52,28 @@ export class TopResultsRow extends LitElement {
|
|||||||
// Per-card state: cover images.
|
// Per-card state: cover images.
|
||||||
private images = new Map<string, string>();
|
private images = new Map<string, string>();
|
||||||
|
|
||||||
|
private unsubRequests?: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The badges here say whether something is already requested, and
|
||||||
|
* this row will not hear about a change from its host: `explore-view`
|
||||||
|
* re-rendering sets the same `results` array back, so Lit stops at
|
||||||
|
* the property and never updates this element. One subscription for
|
||||||
|
* the row, not one per card.
|
||||||
|
*/
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
this.unsubRequests = downloadStore.subscribe(() =>
|
||||||
|
this.requestUpdate(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback(): void {
|
||||||
|
this.unsubRequests?.();
|
||||||
|
this.unsubRequests = undefined;
|
||||||
|
super.disconnectedCallback();
|
||||||
|
}
|
||||||
|
|
||||||
static override styles = [
|
static override styles = [
|
||||||
designTokens,
|
designTokens,
|
||||||
exploreLinkStyles,
|
exploreLinkStyles,
|
||||||
@@ -255,7 +279,10 @@ export class TopResultsRow extends LitElement {
|
|||||||
? r.year || ''
|
? r.year || ''
|
||||||
: formatDuration(r.length) || '';
|
: formatDuration(r.length) || '';
|
||||||
|
|
||||||
const status: LibraryStatus = r.inLibrary ? 'in-library' : 'not-in-library';
|
const status: LibraryStatus = libraryStatusFor(
|
||||||
|
Boolean(r.inLibrary),
|
||||||
|
r.mbid,
|
||||||
|
);
|
||||||
const entityType: 'artist' | 'album' | 'track' =
|
const entityType: 'artist' | 'album' | 'track' =
|
||||||
r.entityType === 'artist'
|
r.entityType === 'artist'
|
||||||
? 'artist'
|
? 'artist'
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ solid/arrow-rotate-right
|
|||||||
solid/arrows-rotate
|
solid/arrows-rotate
|
||||||
solid/arrow-up-short-wide
|
solid/arrow-up-short-wide
|
||||||
solid/backward-step
|
solid/backward-step
|
||||||
|
regular/bookmark
|
||||||
solid/bookmark
|
solid/bookmark
|
||||||
solid/box-open
|
solid/box-open
|
||||||
solid/check
|
solid/check
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { downloadStore } from '@store/download-store';
|
||||||
|
import type { LibraryStatus } from '../components/library-status-indicator/library-status-indicator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the tick/hourglass/plus badge should say about one entity.
|
||||||
|
*
|
||||||
|
* This exists because the rule was written at all eight call sites and
|
||||||
|
* so none of them had the whole of it: every one was a two-way ternary
|
||||||
|
* between `in-library` and `not-in-library`, and the badge's third
|
||||||
|
* state — `queued`, styled and labelled since it was written — was
|
||||||
|
* produced by nothing. An album the user had already asked for through
|
||||||
|
* the "Want this" button showed a plus and said it was not in their
|
||||||
|
* library, on the same page, forty pixels from a filled button reading
|
||||||
|
* "Wanted".
|
||||||
|
*
|
||||||
|
* Two rules decide the answer, and both are about honesty rather than
|
||||||
|
* precedence for its own sake:
|
||||||
|
*
|
||||||
|
* - **Owning outranks wanting.** A request that has been satisfied by
|
||||||
|
* any route — downloaded here, ripped, bought elsewhere — is not
|
||||||
|
* news; what the user has is.
|
||||||
|
* - **A request is by MBID, and the badge answers about the entity it
|
||||||
|
* is on.** A track inside a requested album is not itself requested,
|
||||||
|
* so it stays a plus. Saying otherwise would promise that clicking
|
||||||
|
* it later would find *that* recording.
|
||||||
|
*
|
||||||
|
* A `satisfied` request is deliberately not `queued`: nothing is coming.
|
||||||
|
* A `paused` one is, because the user did ask for it and it is still on
|
||||||
|
* the list — "queued" is a slight overstatement of a paused request and
|
||||||
|
* a much smaller one than "not in your library".
|
||||||
|
*/
|
||||||
|
export function libraryStatusFor(
|
||||||
|
owned: boolean,
|
||||||
|
mbid?: string | null,
|
||||||
|
): LibraryStatus {
|
||||||
|
if (owned) return 'in-library';
|
||||||
|
|
||||||
|
if (!mbid) return 'not-in-library';
|
||||||
|
|
||||||
|
const request = downloadStore.requestFor(mbid);
|
||||||
|
|
||||||
|
if (request && request.state !== 'satisfied') return 'queued';
|
||||||
|
|
||||||
|
return 'not-in-library';
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Plan 009 phase 1: the badge tells the truth.
|
||||||
|
*
|
||||||
|
* `library-status-indicator` has had three states since it was written
|
||||||
|
* — a tick, an hourglass and a plus — and the hourglass was produced by
|
||||||
|
* nothing. All eight call sites were a two-way ternary, so an album the
|
||||||
|
* user had already asked for through "Want this" showed a plus and said
|
||||||
|
* it was not in their library, on the same page as a filled button
|
||||||
|
* reading "Wanted".
|
||||||
|
*
|
||||||
|
* Two tiers of assertion here, and the second is the one that would
|
||||||
|
* have failed:
|
||||||
|
*
|
||||||
|
* - the rule itself, which is now written once, and
|
||||||
|
* - a rendered Explore result whose release group is requested,
|
||||||
|
* because a helper nobody calls is a rule nobody follows.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import '@components/explore-view/explore-view';
|
||||||
|
import type { Request } from '@store/download-store';
|
||||||
|
import { libraryStatusFor } from '@utils/library-status';
|
||||||
|
import { Events } from '../../src/events';
|
||||||
|
import { emit, flush, stub } from '@test/support/harness';
|
||||||
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||||
|
|
||||||
|
const SEARCH = 'explore.Service.SearchLocal';
|
||||||
|
|
||||||
|
function request(overrides: Partial<Request>): Request {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
mbid: 'rg-wanted',
|
||||||
|
entity: 'release-group',
|
||||||
|
libraryId: 1,
|
||||||
|
artist: 'An Artist',
|
||||||
|
title: 'Wanted Album',
|
||||||
|
scope: 'future',
|
||||||
|
secondary: false,
|
||||||
|
state: 'wanted',
|
||||||
|
attempts: 0,
|
||||||
|
...overrides,
|
||||||
|
} as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Put a request list into the store the way the backend does. */
|
||||||
|
async function withRequests(rows: Request[]): Promise<void> {
|
||||||
|
stub('download.Service.ListRequests', rows);
|
||||||
|
emit(Events.RequestsChanged);
|
||||||
|
await flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseGroup = (mbid: string, title: string) => ({
|
||||||
|
mbid,
|
||||||
|
title,
|
||||||
|
artistCredit: 'An Artist',
|
||||||
|
artistMbid: 'ar-1',
|
||||||
|
primaryType: 'Album',
|
||||||
|
firstReleaseDate: '1994-05-01',
|
||||||
|
popularity: 100,
|
||||||
|
listenerCount: 10,
|
||||||
|
inLibrary: false,
|
||||||
|
secondaryTypes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('libraryStatusFor', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await withRequests([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says nothing about an entity with no MBID to ask about', () => {
|
||||||
|
expect(libraryStatusFor(false, '')).toBe('not-in-library');
|
||||||
|
expect(libraryStatusFor(false, undefined)).toBe('not-in-library');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports a request as queued', async () => {
|
||||||
|
await withRequests([request({ mbid: 'rg-wanted' })]);
|
||||||
|
|
||||||
|
expect(libraryStatusFor(false, 'rg-wanted')).toBe('queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets owning outrank wanting', async () => {
|
||||||
|
// Both are true of an album that has arrived but whose request has
|
||||||
|
// not been retired yet. What the user has is not news; what they
|
||||||
|
// have is.
|
||||||
|
await withRequests([request({ mbid: 'rg-wanted' })]);
|
||||||
|
|
||||||
|
expect(libraryStatusFor(true, 'rg-wanted')).toBe('in-library');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call a satisfied request queued', async () => {
|
||||||
|
// Nothing is coming: the request is history. An unowned entity with
|
||||||
|
// a satisfied request is a stale row, not a download in flight.
|
||||||
|
await withRequests([request({ mbid: 'rg-done', state: 'satisfied' })]);
|
||||||
|
|
||||||
|
expect(libraryStatusFor(false, 'rg-done')).toBe('not-in-library');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does count a paused request, which the user did ask for', async () => {
|
||||||
|
await withRequests([request({ mbid: 'rg-paused', state: 'paused' })]);
|
||||||
|
|
||||||
|
expect(libraryStatusFor(false, 'rg-paused')).toBe('queued');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers about the entity it is on, not the one containing it', () => {
|
||||||
|
// A request is by MBID. A track inside a requested album is not
|
||||||
|
// itself requested, and saying otherwise promises that clicking it
|
||||||
|
// would find that recording.
|
||||||
|
expect(libraryStatusFor(false, 'recording-inside-rg-wanted')).toBe(
|
||||||
|
'not-in-library',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('<explore-view> badges', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
stub('explore.Service.GetThumbnails', []);
|
||||||
|
stub('explore.Service.GetThumbnail', '');
|
||||||
|
stub('explore.Service.GetArtistImageURL', '');
|
||||||
|
stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' });
|
||||||
|
stub('library.Library.GetAllAlbums', []);
|
||||||
|
stub('library.Library.GetAllTracks', []);
|
||||||
|
await withRequests([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function searchFor(rows: ReturnType<typeof releaseGroup>[]) {
|
||||||
|
stub(SEARCH, {
|
||||||
|
artists: [],
|
||||||
|
releaseGroups: rows,
|
||||||
|
recordings: [],
|
||||||
|
topResults: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const el = await fixture('explore-view');
|
||||||
|
(el as unknown as { viewActivated(): void }).viewActivated();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const input = shadow<HTMLInputElement>(el, 'input');
|
||||||
|
if (input) {
|
||||||
|
input.value = 'anything';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The search box debounces, so waiting a frame measures the input
|
||||||
|
// echoing its own character.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
await flush();
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows a requested album as queued rather than as absent', async () => {
|
||||||
|
await withRequests([request({ mbid: 'rg-wanted' })]);
|
||||||
|
|
||||||
|
const el = await searchFor([
|
||||||
|
releaseGroup('rg-wanted', 'Wanted Album'),
|
||||||
|
releaseGroup('rg-other', 'Some Other Album'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const badges = shadowAll(el, 'library-status-indicator');
|
||||||
|
|
||||||
|
expect(badges.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(badges.map((b) => b.getAttribute('status'))).toEqual([
|
||||||
|
'queued',
|
||||||
|
'not-in-library',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-renders when the request list changes underneath it', async () => {
|
||||||
|
// A background reconcile pass expands an artist or retires a want
|
||||||
|
// without this page doing anything, so the badge has to be told.
|
||||||
|
const el = await searchFor([releaseGroup('rg-wanted', 'Wanted Album')]);
|
||||||
|
|
||||||
|
expect(shadow(el, 'library-status-indicator')?.getAttribute('status')).toBe(
|
||||||
|
'not-in-library',
|
||||||
|
);
|
||||||
|
|
||||||
|
await withRequests([request({ mbid: 'rg-wanted' })]);
|
||||||
|
await el.updateComplete;
|
||||||
|
|
||||||
|
expect(shadow(el, 'library-status-indicator')?.getAttribute('status')).toBe(
|
||||||
|
'queued',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user