diff --git a/frontend/index.ts b/frontend/index.ts index 2431050..fee2280 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -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. diff --git a/frontend/src/components/autotag-view/autotag-view.ts b/frontend/src/components/autotag-view/autotag-view.ts index 405bfd3..6d4f6e6 100644 --- a/frontend/src/components/autotag-view/autotag-view.ts +++ b/frontend/src/components/autotag-view/autotag-view.ts @@ -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; diff --git a/frontend/src/components/explore-album-details/explore-album-details.ts b/frontend/src/components/explore-album-details/explore-album-details.ts index acd5605..5106123 100644 --- a/frontend/src/components/explore-album-details/explore-album-details.ts +++ b/frontend/src/components/explore-album-details/explore-album-details.ts @@ -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 { + 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} > + ${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` +
+ +

+ MusicBrainz has a match for this album: + ${match.title} + ${match.artistCredit ? html` by ${match.artistCredit}` : nothing}. + ${wholeAlbum + ? nothing + : html`It is filed as ${match.groupCount} folders here, so + tagging it is a review rather than one + step.`} +

+
+ ${wholeAlbum + ? html`Apply tags` + : nothing} + Review in Autotag +
+
+ `; + } + + /** 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. * diff --git a/frontend/src/components/sidebar/app-sidebar.ts b/frontend/src/components/sidebar/app-sidebar.ts index 797cd1d..1c3c56f 100644 --- a/frontend/src/components/sidebar/app-sidebar.ts +++ b/frontend/src/components/sidebar/app-sidebar.ts @@ -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' }, ]; diff --git a/frontend/src/utils/icon-language.ts b/frontend/src/utils/icon-language.ts index 28ffc94..e18a646 100644 --- a/frontend/src/utils/icon-language.ts +++ b/frontend/src/utils/icon-language.ts @@ -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. * diff --git a/frontend/test/components/album-autotag-match.test.ts b/frontend/test/components/album-autotag-match.test.ts new file mode 100644 index 0000000..efe6705 --- /dev/null +++ b/frontend/test/components/album-autotag-match.test.ts @@ -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 = {}) { + 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 { + const el = await fixture('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 { + const host = confirmHost(); + + if (!host) throw new Error('confirm-dialog did not mount itself'); + + await host.updateComplete; + host.shadowRoot + ?.querySelector(`[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 { + shadowAll(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('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('notification-host'); + + await host.updateComplete; + + const shown = shadowAll(host, '[data-testid="notification"]').map( + (n) => n.textContent ?? '', + ); + + expect(shown.join(' ')).toContain('could not be'); + }); +}); diff --git a/frontend/test/components/autotag-requested-folder.test.ts b/frontend/test/components/autotag-requested-folder.test.ts new file mode 100644 index 0000000..5aa7af5 --- /dev/null +++ b/frontend/test/components/autotag-requested-folder.test.ts @@ -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 { + const el = await fixture('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'); + }); +}); diff --git a/frontend/test/components/icon-language.test.ts b/frontend/test/components/icon-language.test.ts index 1c14fe5..cf8367f 100644 --- a/frontend/test/components/icon-language.test.ts +++ b/frontend/test/components/icon-language.test.ts @@ -43,6 +43,7 @@ const GOVERNED = [ 'solid/bookmark', 'regular/bookmark', 'bars-staggered', + 'tag', ]; /** The one file allowed to say them, plus its own test. */