feat(explore): offer the autotag match on the album page
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m49s
CI / e2e (pull_request) Successful in 6m12s

The complaint was having to notice the metadata was missing, then go
and hunt the album down on the Autotag page. The album page now says it
while you are looking at the thing: "MusicBrainz has a match for this
album: <release> by <artist>", with Apply tags and Review in Autotag.

Four things about it are load-bearing.

**Applying is offered only where it would do the whole album.** A
tagging group is a folder, so a multi-disc album is several, and one
button that applied to the best-scoring group would leave the album
holding a mix of old and new tags — the exact case the app's Blocking
notification level exists for. `groupCount` is the test, and the answer
there is review rather than apply.

**It rewrites files, so it asks.** `confirmAction()` with an impact
line that says it cannot be undone and that nothing is moved or
deleted, because "rewrites your files" reads worse than it is. The
apply goes through `ApplyAsync`, the registered-job path, so progress
belongs to the jobs indicator and this page does not grow a second one
— what it owes the user is the acknowledgement, because the button is
here. The suggestion clears itself on success rather than inviting a
second click while the job runs.

**The banner does not quote a percentage.** The backend has a score and
deliberately keeps it out of the sentence: 0.95 reads as a probability
and is not one. Which release it is, is the part a person can judge.

**"Review in Autotag" lands on that album.** The queue is sorted by
score so the intended folder is often near the top, and "often" is a
link that sometimes opens a different album. Autotag is a cached
primary view, so there is no construction to hand a payload to: the
request goes on as an attribute and the view *consumes* it, or every
later visit would reopen a folder the user finished with long ago.

`ICON_AUTOTAG` joins the vocabulary at the same time, on the rule
`ICON_PLAYLIST` was chosen by — an icon names the noun it acts on, so a
suggestion pointing at Autotag wears the Autotag destination's own
mark. It was written inline in the sidebar; two call sites is where a
name stops being one component's detail, so the sweep governs it now.

Verified against the running app with a staged match: the banner, the
confirm dialog's wording, and the navigation landing on the right
folder with the attribute consumed.

Closes #28
This commit is contained in:
2026-08-19 02:34:11 -04:00
parent 9118c16fe3
commit b5d70ac1cd
8 changed files with 706 additions and 3 deletions
+12
View File
@@ -308,6 +308,18 @@ async function handleNavigate(
deactivateView(currentViewEl);
}
target.classList.remove('view-hidden');
// A primary view is cached, so there is no construction to
// hand a payload to the way a detail view gets one below. The
// one navigation that carries something is the album page's
// "Review in Autotag", which has to land on *that* album: the
// request goes on as an attribute and `autotag-view` consumes
// it (removes it) once acted on, or every later visit would
// reopen a folder the user finished with long ago.
if (view === 'autotag' && typeof detail.groupKey === 'string') {
target.setAttribute('group-key', detail.groupKey);
}
// A freshly created view was appended hidden, so it did not
// self-activate on connection; a cached one was deactivated on
// the way out. Either way this is the call that starts it.
@@ -1315,13 +1315,40 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
// the page only needs the folder list, which is local and may
// have moved while the page was away.
if (this.queueStarted) {
void this.loadFolders();
void this.loadFolders().then(() => this.openRequestedFolder());
} else {
this.queueStarted = true;
void this.startQueue();
void this.startQueue().then(() => this.openRequestedFolder());
}
}
/**
* Open the folder somebody navigated here to look at.
*
* The album page's "Review in Autotag" has to land on *that*
* album. The queue is sorted by score so the intended folder is
* often near the top, but "often" is a link that sometimes opens
* the wrong album, which is worse than no link.
*
* It is an attribute rather than a property because this is a
* **cached primary view**: `index.ts` creates it once and reuses
* it, so there is no construction to pass a value to. Which is
* also why the request is *consumed* — the attribute is removed
* once acted on, or every later visit to Autotag would reopen an
* album the user finished with three navigations ago.
*/
private openRequestedFolder(): void {
const requested = this.getAttribute('group-key');
if (!requested) return;
this.removeAttribute('group-key');
if (this.current?.groupKey === requested) return;
void this.selectFolder(requested);
}
protected override onViewDeactivate(): void {
this.unsubscribeLibraryStore?.();
this.unsubscribeLibraryStore = undefined;
@@ -30,12 +30,16 @@ import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import { libraryStatusFor } from '@utils/library-status';
import { ICON_AUTOTAG } from '@utils/icon-language';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
import '../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 '../download-picker/download-picker';
import { downloadStore } from '../../store/download-store';
import { MatchForAlbum, ApplyAsync } from '@go/autotagservice/service.js';
import type * as autotagservice from '@go/autotagservice/models.js';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { queueStore } from '../../store/queue-store';
import type { QueueSource } from '../../store/queue-store';
import { notificationStore } from '../../store/notification-store';
@@ -175,6 +179,19 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
@property({ type: Number, attribute: 'local-album-id' })
localAlbumId = 0;
/**
* A confident autotag match for this album, or null when there is
* none worth mentioning.
*
* The tier behind "confident" is `autotag.ConfidentTier`, decided
* in the backend so this page and strict auto-accept cannot
* disagree about what it means (#28, #90).
*/
@state() private autotagMatch: autotagservice.AlbumMatchView | null = null;
/** True while an apply started from this page is in flight. */
@state() private applyingTags = false;
/* ── Internal state ── */
@state() private releaseGroup: MBReleaseGroup | null = null;
@@ -545,6 +562,57 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
flex-shrink: 0;
}
/* ── The autotag suggestion ── */
.autotag-match {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 12px;
margin-bottom: 12px;
padding: 10px 14px;
border: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.08));
border-radius: 8px;
background: var(--yj-surface-1, rgba(255, 255, 255, 0.04));
}
.autotag-match > wa-icon {
flex-shrink: 0;
font-size: var(--yj-icon-sm);
color: var(--yj-text-secondary, #b3b3b3);
}
.autotag-match-text {
margin: 0;
flex: 1;
/* The suggestion sits in a flex row beside its buttons,
* and a grid/flex item's implicit minimum is its
* content — without this a long release title pushes
* the actions off the end at phone width. */
min-width: 0;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
}
.autotag-match-text strong {
color: var(--yj-text-primary, #fff);
font-weight: 600;
}
.autotag-match-note {
display: block;
margin-top: 2px;
font-size: var(--yj-text-xs);
color: var(--yj-text-tertiary, #888);
}
.autotag-match-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
/* ── Other versions (a disclosure, below the tracklist) ── */
.versions {
margin-top: 24px;
@@ -1062,6 +1130,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
this.localTracks = [];
this.filePaths = new Map();
this.askedFor = new Set();
this.autotagMatch = null;
// Not awaited: the banner is a bonus and the page must not
// wait on it. It is also the *most* useful on an untagged
// album, which is exactly the page that has least else to
// show, so it is asked for on both branches below rather than
// only the catalog one.
void this.loadAutotagMatch();
// Local-only album (no MBID) — populate entirely from library.
if (!mbid && this.localAlbumId) {
@@ -1254,6 +1330,31 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return this.completeness;
}
/**
* Ask whether the autotagger already has a confident match here.
*
* It answers from what the background prefetch has already scored
* and makes no MusicBrainz request, so this is safe on page load —
* see `MatchForAlbum`. A folder nobody has reached yet answers
* `null`, which is the same as "nothing to say": the banner is a
* bonus, so a failure is a missing suggestion rather than an error
* the user can act on, and it stays in the console.
*/
private async loadAutotagMatch(): Promise<void> {
if (this.localAlbumId <= 0) {
this.autotagMatch = null;
return;
}
try {
this.autotagMatch = await MatchForAlbum(this.localAlbumId);
} catch (err) {
console.error('[explore-album] autotag match lookup failed', err);
this.autotagMatch = null;
}
}
/**
* Fetch and set `localTracks` directly by local album id — the
* definite source of truth, used when nothing else has already
@@ -2415,6 +2516,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
entity-type="album"
@catalog-retry=${this.retryCatalog}
></catalog-scope-notice>
${this.renderAutotagMatch()}
${this.renderChosenVersion()}
${this.renderTracklistScope()}
${this.renderTracklist()}
@@ -3069,6 +3171,155 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
/* ── Version Selector (R025, R026, R027) ── */
/**
* "MusicBrainz has a match for this album."
*
* The complaint this answers is that the user had to notice the
* metadata was missing, then go and hunt the album down on the
* Autotag page — so the point is to say it *here*, while they are
* looking at the thing, with something to do about it.
*
* Three things about it are load-bearing.
*
* **Applying is offered only when it would do the whole album.** A
* tagging group is a folder, so a multi-disc album is several, and
* one button that applied to the best-scoring one would leave the
* album holding a mix of old and new tags — the exact case the
* app's Blocking notification level exists for. `groupCount` is
* the test, and the answer there is review, not apply.
*
* **The confirm is not a formality.** This rewrites tags on disk
* and cannot be undone, so it goes through `confirmAction()` with
* an impact line that says so in those words.
*
* **The banner does not claim a percentage.** The backend has a
* score and deliberately does not put it in the sentence: 0.95
* reads as a probability and is not one. What the user needs is
* which release it is, which is what the release title and artist
* are for.
*/
private renderAutotagMatch() {
const match = this.autotagMatch;
if (!match) return nothing;
const wholeAlbum = match.groupCount === 1;
return html`
<div class="autotag-match" role="status">
<wa-icon name=${ICON_AUTOTAG} aria-hidden="true"></wa-icon>
<p class="autotag-match-text">
MusicBrainz has a match for this album:
<strong>${match.title}</strong>
${match.artistCredit ? html` by ${match.artistCredit}` : nothing}.
${wholeAlbum
? nothing
: html`<span class="autotag-match-note"
>It is filed as ${match.groupCount} folders here, so
tagging it is a review rather than one
step.</span
>`}
</p>
<div class="autotag-match-actions">
${wholeAlbum
? html`<wa-button
size="small"
variant="brand"
?disabled=${this.applyingTags}
@click=${this.onApplyAutotagMatch}
>Apply tags</wa-button
>`
: nothing}
<wa-button
size="small"
appearance="outlined"
@click=${this.onReviewAutotagMatch}
>Review in Autotag</wa-button
>
</div>
</div>
`;
}
/** Hand the group over to the Autotag page and go there. */
private onReviewAutotagMatch = () => {
const match = this.autotagMatch;
if (!match) return;
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'autotag', groupKey: match.groupKey },
}),
);
};
/**
* Apply the match, after asking.
*
* `ApplyAsync` is the registered-job path, so the work is visible
* in the jobs indicator and cancellable there like every other
* long-running operation — this page does not grow a second
* progress surface for it. What it does own is the *acknowledgement*
* that the request was accepted, because the button is here.
*
* The page is not refreshed on completion either: rewriting tags
* emits `TrackMetadataChanged`, which `library-store` answers by
* discarding every cached collection, and this page reloads from
* that like everything else.
*/
private onApplyAutotagMatch = async () => {
const match = this.autotagMatch;
if (!match || this.applyingTags) return;
const ok = await confirmAction({
title: `Tag this album as “${match.title}”?`,
message:
`The ${match.trackCount} files of this album are rewritten to` +
` match the MusicBrainz release${
match.artistCredit ? ` by ${match.artistCredit}` : ''
}.`,
impact:
'This edits the tags in the files on disk and cannot be' +
' undone. Nothing is moved or deleted.',
confirmLabel: 'Apply tags',
});
if (!ok) return;
this.applyingTags = true;
try {
await ApplyAsync(match.groupKey, match.releaseMbid);
// The suggestion has been acted on, so it stops being a
// suggestion immediately rather than sitting there inviting
// a second click while the job runs.
this.autotagMatch = null;
notificationStore.transient({
text: 'Tagging this album — progress is in the jobs indicator.',
});
} catch (err) {
console.error('[explore-album] autotag apply failed', err);
// Persistent rather than transient: the user asked for
// something that did not happen, and retrying is meaningful.
notificationStore.persistent({
text: describeError(
err,
'Those tags could not be applied.',
),
tone: 'error',
});
} finally {
this.applyingTags = false;
}
};
/**
* Which pressing is on screen — said only when the user chose it.
*
@@ -6,6 +6,7 @@ import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
import {
ICON_PLAYLIST,
ICON_AUTOTAG,
ICON_REQUESTED,
} from '@utils/icon-language';
@@ -208,7 +209,7 @@ export class AppSidebar extends LitElement {
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'downloads', label: 'Downloads', icon: ICON_REQUESTED },
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
{ id: 'autotag', label: 'Autotag', icon: ICON_AUTOTAG },
{ id: 'jobs', label: 'Jobs', icon: 'list-check' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
];
+11
View File
@@ -80,6 +80,17 @@ export const ICON_REQUESTED = 'solid/bookmark';
*/
export const ICON_IN_LIBRARY = 'check';
/**
* The autotagger, and a match it is offering.
*
* The same icon as the Autotag destination in the sidebar, on the rule
* `ICON_PLAYLIST` was chosen by: an icon names the noun it acts on, so
* a suggestion on the album page wears the mark of the page it would
* send you to. Governed from the moment there were two call sites,
* which is when a name stops being a detail of one component.
*/
export const ICON_AUTOTAG = 'tag';
/**
* Something is being fetched right now.
*
@@ -0,0 +1,286 @@
/**
* Being told about a match while looking at the album.
*
* The complaint (#28) is that the user had to notice their metadata
* was missing and then go and hunt the album down on the Autotag page.
* So the suggestion is drawn here, with something to do about it — and
* the something rewrites files on disk, which is what most of this
* file is about.
*
* The confidence tier behind "MusicBrainz has a match" is decided in
* the backend (`autotag.ConfidentTier`) so this page and strict
* auto-accept cannot disagree about what it means; what is pinned here
* is only what the page does with the answer.
*/
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, calls } from '@test/support/harness';
import { notificationStore } from '@store/notification-store';
import '@components/notifications/notification-host';
import { fixture, shadow, shadowAll } from '@test/support/render';
const MATCH = 'autotagservice.Service.MatchForAlbum';
const APPLY = 'autotagservice.Service.ApplyAsync';
function match(over: Record<string, unknown> = {}) {
return {
groupKey: 'grp-1',
recommendation: 'strong',
score: 0.95,
releaseMbid: 'rel-1',
title: 'Glass Harbour',
artistCredit: 'Tideline',
trackCount: 10,
groupCount: 1,
...over,
};
}
async function albumPage(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
localAlbumId: 7,
});
await flush();
await el.updateComplete;
return el;
}
/** The confirm dialog attaches itself to the document on first use. */
function confirmHost(): (LitElement & { shadowRoot: ShadowRoot | null }) | null {
return document.querySelector('confirm-dialog');
}
/** Press one of the dialog's own buttons, the way a person would. */
async function pressConfirm(testid: string): Promise<void> {
const host = confirmHost();
if (!host) throw new Error('confirm-dialog did not mount itself');
await host.updateComplete;
host.shadowRoot
?.querySelector<HTMLButtonElement>(`[data-testid="${testid}"]`)
?.click();
await host.updateComplete;
await flush();
}
/** Click one of the banner's buttons by its label. */
async function pressBanner(el: LitElement, label: string): Promise<void> {
shadowAll<HTMLElement>(el, '.autotag-match-actions wa-button')
.find((b) => b.textContent?.includes(label))
?.click();
await flush();
}
beforeEach(() => {
resetHarness();
notificationStore.clear();
stub('explore.Service.BrowseReleases', []);
stub('explore.Service.LookupReleaseGroup', null);
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAlbumCompleteness', {
owned: 0,
expected: 0,
known: false,
complete: false,
});
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub(MATCH, null);
});
describe('the autotag suggestion', () => {
it('says nothing when the backend has nothing confident', async () => {
const el = await albumPage();
expect(shadow(el, '.autotag-match')).toBeNull();
});
/**
* A pure catalog page has no files to retag, so the question is not
* asked at all — this runs on every album open and a call that
* cannot have an answer is a call not worth making.
*/
it('is not even asked about an album with no local files', async () => {
stub(MATCH, match());
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
releaseGroupMBID: 'rg-1',
});
await flush();
await el.updateComplete;
expect(calls(MATCH)).toHaveLength(0);
expect(shadow(el, '.autotag-match')).toBeNull();
});
/**
* The banner names the release rather than quoting a number: 0.95
* reads as a probability and is not one, and which release it is, is
* the thing the user can actually judge.
*/
it('names the release it is offering', async () => {
stub(MATCH, match());
const el = await albumPage();
const text = shadow(el, '.autotag-match')?.textContent ?? '';
expect(text).toContain('MusicBrainz has a match');
expect(text).toContain('Glass Harbour');
expect(text).toContain('Tideline');
expect(text).not.toContain('95');
});
it('offers both an apply and a review', async () => {
stub(MATCH, match());
await albumPage();
await expect
.element(page.getByRole('button', { name: 'Apply tags' }))
.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Review in Autotag' }))
.toBeInTheDocument();
});
/**
* A tagging group is a folder, so a multi-disc album is several. One
* button that applied to the best-scoring one would leave the album
* holding a mix of old and new tags — which is the case the app's
* Blocking notification level exists for, and is worth not creating.
*/
it('will not apply to an album filed as several folders', async () => {
stub(MATCH, match({ groupCount: 2 }));
const el = await albumPage();
const labels = shadowAll(el, '.autotag-match-actions wa-button').map(
(b) => b.textContent?.trim(),
);
expect(labels).toEqual(['Review in Autotag']);
expect(shadow(el, '.autotag-match')?.textContent).toContain('2 folders');
});
it('navigates to Autotag carrying the group key', async () => {
stub(MATCH, match());
const el = await albumPage();
const seen: CustomEvent[] = [];
el.addEventListener('navigate', (e) => seen.push(e as CustomEvent));
await pressBanner(el, 'Review');
expect(seen).toHaveLength(1);
expect(seen[0]?.detail).toMatchObject({
view: 'autotag',
groupKey: 'grp-1',
});
});
});
describe('applying from the album page', () => {
/**
* This rewrites tags in files on disk and cannot be undone, so it
* asks first — and cancelling has to be a true no-op, not a
* confirmation that fires the call anyway.
*/
it('asks before it writes, and cancelling writes nothing', async () => {
stub(MATCH, match());
stub(APPLY, null);
const el = await albumPage();
await pressBanner(el, 'Apply');
expect(confirmHost()).not.toBeNull();
expect(calls(APPLY)).toHaveLength(0);
await pressConfirm('confirm-cancel');
await el.updateComplete;
expect(calls(APPLY)).toHaveLength(0);
expect(shadow(el, '.autotag-match')).not.toBeNull();
});
/**
* The impact line has to say the thing that cannot be taken back, in
* those words — "cannot be undone" — and that nothing is deleted,
* because "rewrites your files" reads worse than it is.
*/
it('says what cannot be undone', async () => {
stub(MATCH, match());
const el = await albumPage();
await pressBanner(el, 'Apply');
const text = confirmHost()?.shadowRoot?.textContent ?? '';
expect(text).toContain('cannot be');
expect(text).toContain('undone');
expect(text.toLowerCase()).toContain('nothing is moved or deleted');
});
/**
* `ApplyAsync` is the registered-job path, so progress belongs to
* the jobs indicator and this page does not grow a second one. What
* it owes the user is an acknowledgement, because the button is
* here — and the suggestion has to stop inviting a second click.
*/
it('hands the work to the job registry and stands down', async () => {
stub(MATCH, match());
stub(APPLY, null);
const el = await albumPage();
await pressBanner(el, 'Apply');
await pressConfirm('confirm-accept');
await el.updateComplete;
expect(calls(APPLY)).toHaveLength(1);
// The release is passed explicitly: a rescore between the page
// rendering and the click must not swap the album out from under
// a button the user has already read.
expect(calls(APPLY)[0]?.args).toEqual(['grp-1', 'rel-1']);
expect(shadow(el, '.autotag-match')).toBeNull();
});
/**
* A failure is Persistent, not Transient: the user asked for
* something that did not happen and retrying is meaningful, which is
* the notification store's own rule for choosing the level.
*/
it('keeps a failure on screen', async () => {
stub(MATCH, match());
stubFailure(APPLY, 'the tag writer refused');
const el = await albumPage();
await pressBanner(el, 'Apply');
await pressConfirm('confirm-accept');
await el.updateComplete;
// Read it the way a person would: the app's one notification
// surface, rendered.
const host = await fixture<LitElement>('notification-host');
await host.updateComplete;
const shown = shadowAll(host, '[data-testid="notification"]').map(
(n) => n.textContent ?? '',
);
expect(shown.join(' ')).toContain('could not be');
});
});
@@ -0,0 +1,114 @@
/**
* "Review in Autotag" has to land on *that* album.
*
* The album page can now say the autotagger has a match for what you
* are looking at (#28), and the review link is only worth having if it
* opens the same album. The queue is sorted by score, so the intended
* folder is often near the top — but "often" is a link that sometimes
* opens a different album, which is worse than no link.
*
* Autotag is a **cached primary view**: `index.ts` creates it once and
* reuses it, so there is no construction to pass a value to. The
* request arrives as an attribute, which is why the interesting part
* is that it is *consumed* — an attribute left on a cached element
* would reopen the same folder on every later visit to the page.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/autotag-view/autotag-view';
import { flush, resetHarness, stub } from '@test/support/harness';
import { fixture } from '@test/support/render';
const FOLDERS = 'autotagservice.Service.ListPendingFolders';
const CANDIDATES = 'autotagservice.Service.GetCandidates';
function folder(groupKey: string, album: string) {
return {
groupKey,
libraryId: 0,
libraryName: 'Test',
folderSubPath: album,
trackCount: 10,
albumName: album,
albumArtist: 'Tideline',
discNumber: 0,
status: 'pending',
score: groupKey === 'grp-top' ? 0.99 : 0.5,
bestMatchReleaseMbid: 'rel-1',
synthetic: false,
likelyMixedBag: false,
};
}
/** Mount the view and run its activation, as navigation would. */
async function autotag(groupKey?: string): Promise<LitElement> {
const el = await fixture<LitElement>('autotag-view');
if (groupKey !== undefined) el.setAttribute('group-key', groupKey);
(el as unknown as { onViewActivate: () => void }).onViewActivate();
await flush();
await el.updateComplete;
await flush();
await el.updateComplete;
return el;
}
/** The folder the view has selected. */
function selected(el: LitElement): string | undefined {
return (el as unknown as { current?: { groupKey: string } }).current
?.groupKey;
}
beforeEach(() => {
resetHarness();
stub('autotagservice.Service.StartAutotagQueue', null);
stub('autotagservice.Service.GetLocalCoverArt', '');
stub('autotagservice.Service.AckLibraryWarning', null);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub(FOLDERS, [folder('grp-top', 'Loudest Match'), folder('grp-asked', 'Glass Harbour')]);
stub(CANDIDATES, {
groupKey: 'grp-asked',
recommendation: 'strong',
localTracks: [],
candidates: [],
synthetic: false,
mixedBag: false,
});
});
describe('arriving at Autotag from an album page', () => {
it('opens the folder that was asked for, not the top of the queue', async () => {
const el = await autotag('grp-asked');
expect(selected(el)).toBe('grp-asked');
});
it('still lands on the best pending folder when nothing was asked', async () => {
const el = await autotag();
expect(selected(el)).toBe('grp-top');
});
/**
* The view is cached and never unmounts, so an attribute left behind
* is a standing instruction: every later visit to Autotag would
* reopen an album the user finished with three navigations ago.
*/
it('consumes the request rather than remembering it', async () => {
const el = await autotag('grp-asked');
expect(el.hasAttribute('group-key')).toBe(false);
// A second visit, with no new request: whatever the user had
// selected stays selected.
(el as unknown as { onViewActivate: () => void }).onViewActivate();
await flush();
await el.updateComplete;
expect(selected(el)).toBe('grp-asked');
});
});
@@ -43,6 +43,7 @@ const GOVERNED = [
'solid/bookmark',
'regular/bookmark',
'bars-staggered',
'tag',
];
/** The one file allowed to say them, plus its own test. */