From 5830b1ba1765994a28f9fcfa6641f5ea8e513b4a Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 01:20:03 -0400 Subject: [PATCH] test(frontend): cover the lifecycle, the voice and the repaints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Component and store cases for everything in this series, several of which exist because the thing they pin is invisible everywhere else: - `view-lifecycle` and `keyboard-reach` — a document listener count that does not grow across a simulated navigate cycle, and a tab sequence that reaches the sidebar and plays a row without a mouse. - `notifications`, `notification-store`, `confirm-dialog`, `empty-states` — the four levels, the (level, region, key) coalescing window, and loading/failed/empty as three states. - `card-grid-repaint` — fails if `artists-view`'s or `genres-view`'s per-render arrow functions are hoisted to stable fields, which is the audit's own recommendation and takes the cards from 1 highlighted to 0. It exists for no other reason. - `lazy-track-details` — reads the five sources and fails on a returning static import, the same shape as `TestNoDirectRuntimeEmits` and for the same reason: the invariant is about what the code does *not* say. - `now-playing` — a position report that changes nothing must not touch the DOM again, and a track change must. The first fails against the old unconditional `updated()`. - `playlist-virtualization`, `list-render-cost`, `selection`, `icons`, and the store cases for the library-filter race, the never-settling waiter and the per-playlist patch. --- .../seek-bar-chromium-linux.png | Bin 1925 -> 1937 bytes .../test/components/card-grid-repaint.test.ts | 122 ++++++++ frontend/test/components/chrome.test.ts | 8 +- .../test/components/confirm-dialog.test.ts | 82 +++++ frontend/test/components/empty-states.test.ts | 68 +++++ frontend/test/components/icons.test.ts | 71 +++++ .../test/components/keyboard-reach.test.ts | 115 +++++++ .../components/lazy-track-details.test.ts | 90 ++++++ .../test/components/list-render-cost.test.ts | 70 +++++ .../test/components/notifications.test.ts | 126 ++++++++ frontend/test/components/now-playing.test.ts | 95 +++++- .../playlist-virtualization.test.ts | 141 +++++++++ frontend/test/components/selection.test.ts | 206 +++++++++++++ frontend/test/components/transport.test.ts | 163 +++++++++- .../test/components/view-lifecycle.test.ts | 289 ++++++++++++++++++ frontend/test/setup.ts | 20 +- .../test/stores/keyboard-shortcuts.test.ts | 72 ++++- frontend/test/stores/library-store.test.ts | 124 +++++++- .../test/stores/notification-store.test.ts | 158 ++++++++++ frontend/test/stores/playlist-store.test.ts | 144 ++++++++- frontend/test/stores/view-stores.test.ts | 36 ++- frontend/test/support/render.ts | 33 ++ frontend/test/utils/describe-error.test.ts | 137 +++++++++ frontend/test/utils/lru-map.test.ts | 78 +++++ frontend/test/utils/track-index.test.ts | 87 ++++++ 25 files changed, 2498 insertions(+), 37 deletions(-) create mode 100644 frontend/test/components/card-grid-repaint.test.ts create mode 100644 frontend/test/components/confirm-dialog.test.ts create mode 100644 frontend/test/components/empty-states.test.ts create mode 100644 frontend/test/components/icons.test.ts create mode 100644 frontend/test/components/keyboard-reach.test.ts create mode 100644 frontend/test/components/lazy-track-details.test.ts create mode 100644 frontend/test/components/list-render-cost.test.ts create mode 100644 frontend/test/components/notifications.test.ts create mode 100644 frontend/test/components/playlist-virtualization.test.ts create mode 100644 frontend/test/components/selection.test.ts create mode 100644 frontend/test/components/view-lifecycle.test.ts create mode 100644 frontend/test/stores/notification-store.test.ts create mode 100644 frontend/test/utils/describe-error.test.ts create mode 100644 frontend/test/utils/lru-map.test.ts create mode 100644 frontend/test/utils/track-index.test.ts diff --git a/frontend/test/components/__screenshots__/transport.test.ts/seek-bar-chromium-linux.png b/frontend/test/components/__screenshots__/transport.test.ts/seek-bar-chromium-linux.png index 5eda94304154ae618ddf8aa9c828252b302f7a40..a8debda3d1b427631bd0e54a03337385caf4193a 100644 GIT binary patch literal 1937 zcmZuyc{H1O7p5ePrJAbcLm8r@QysC?uJvhS35rSxY8P}+ZBarIwT!K#Va8U=AmJmn zR4dgS43VZp#Zpuasw!kiEv?oX^O|YT=^x+y<30EM-t*q~-se8g^UHK{u$B~45EBp( zkVM$PodpDhlYsoVC>VG`5hOtY0TDL@+yaFarrXx~YcBgJ>T8Ozu>1eV zt!w#ZrZEv4?-1U&{o9?}9K}!Xr&;q%yw2G8c*MKskcfzgC(%3wWqf+@0*4bZ*8?)R z{yLRD_s8Ogb^h+Eo{33TU0YiL)H?Tmem?zi=)@Yxuxi?-SKA2>ihB@*uxMy#m~J!Dy(4&G8E10_<>E3IO@jbInDzGXP}tZ|HpBkG zRKeNfaOQQ+PEJX)tF-_Wot+m-N=lv&4%Rd@NaQo;yO%eQyEbD=V_+eG?jk_csT}rVbw|fZWJ1FEN+;3U(Y|VHyxY^LA89eGoIl$m&B#=0 zA%&t75)yKgHtqQ|>bnYdI`?HAveYytS1)+*O*CzNJN^-s8c^TdtZ_nYF3JSKX0sRP z=;`VVf1S<){vCs`SN{zU*ZRfp{s{^2t#;3%s^Qa6D0CpWbrQSWNHx0kR(N`Px+o$t zQb!_%=&PNLIoogFO0f2KKlicO8b4}kW|x*K9l0y;i(gCPeq3HwR+hFz zijS_TDQjrx*riLCO3h;P_V(f>=)U_qo9Z$$GJf)47JtWd7)mZGcYw` z@>mF|t4rljjN3e5)2gr=z!uxu48UM8_6pzxft_PyG}6Pv))}xBP1gorEVgLzP(BtG zf(S*!aP<>nkZcCf2SE5d010=BKZs7JyTxBnG3ReV0|Ns;e*Ea<<@Ew+5pbUIao4>> zb4_#;#^3)4o|(d4q@$djdqh;?lbG{tX=%bo1Oe&DtEgxk6I}~+ zsJQTgI-AG>EU!I&TNl_Dhc0EVu8c}IUS4#7u7ptYME2~ehN7;9+ZPd%~`PceC6f)<6(uvu_&N-@dJ1R#w*F z-U_B`8^v#FA7t{Tr~&d|IhZ#QaNepawfD1K_)#v#*Eb39EkChzItq;*ot*3|(GLYs zr>9E=*a~ru{aX`40BN55l0w{vM!u#5&`;kS^Fe8zEj$8)VE|mU0tib7c+*AS^rTM(u0r{?pbubI!cyKmU2ocg}at`##U}JuluB;c)T7)eAyG zLKhux!Q6#}PX7pCsdFO0zj%7QLP+SWvm@-L2S#|cTy*=1D6i&ynymjRF9ez~v6}mdQ*f=tLXLp}3d^`PXF$##;Czy)bKY@$_ z(qE3B9(4`8vM`V0o=&#@F>JhieQU%edS&d}YH?*t)*IWj|4^y?&ZnY3*<1s8czV(% zYeFWg0?+zZkRL*vLPE4mBiSk|A7{H1Z=lor`keH=f3?<@v4MRlKDnf&g_4{sTu@L@ zsh7F46RWAE)jL^>xU?SK3^S6qtR}cpnjNV$#bv^O3#!mdepL^oyLPuTOS`K!mXdaIV)~xYDym( zl3W-pDy^u<+zZq)4*$#$c$nw)StuWM5SS0|_ah2*;0?&;{96pI-b) zmtp6uVZL)}VIk;Ahe~T}>t4L2B7Jt9uDrl*j0V26r6tr23yb~hv;TbItZD1wBGKWW z!{PYGTPi=}Ns6cS1{rN_ZAprWEUm867B=1Xx-YrpI2B{&h<6#wOH0>4*+_Ty&QF3v zc?E?)G&+xpEJZ|ZExH{X9AK8qO3iRPdiNV5=a>fX_`h|e@V%SR`0sH$Idw_Li9J0% za!N`XD<8^`ir>fO0TbR?<_^_By{sgI5e>FDdll>C&cW07{sK5LIeo!T6UV?H*BD=aK1kLKIs?|+x z&6}pw`|@{2wzeRue^yv8LCxWwPSIdm#1!Bu1Qettc`j#r?Ea&uxxULFzM1%Vg`XDD zX?AnJGD3k_&A`)nr%wq3lGH~4vzo7CDAp?0ZWn^WIu3zuy?mlh4y}j+-8b_q&=gT9CEa9Xtw_T<@OW{%e*{dK=x&qO5;N1&spT0nGP8mh;s2JE zmy3V)cpaZh%cOd!653Z|YbbQyHKNmcw1)I~cBCm23kA8C$>3q9zP8{2wwvvLX}W$W z;8XqH6#_BPw#O9(gh~y_sfm~}0nl*<&x|!NU}Ryz#9z|-cqr(8a9ylRC1k2T0zjai z9;+k5``M6v0m*L`jFu!M%?uAqt4jRO7nh6IovFOKvAOA}nd^)|eAf3^35&%tlI(B1 zn(vaZ;R(X3uP6V!kUuJr9->;qiD!-lX7JGx5>U(HA9-qyV{}v$FwhpYdFM ze6mmlLGq~`dwXV2-#^Tw(d=T^IQ@XHS65eoCO5Xn{fwbd)`t%=3MS$HiHU`-+Azpf zjg=}oh{qtP0a3WTvQnidIs)m9`?SB%m~dzU#GpS0^P;Rw$0U5B{pbk##x?yRq^~Qk zdetR8opG#BkD362=X3WPE^2COXf%3gdKzwP`%P+qBp$Y>qoJYEvOoV)(JGMwILY4L zesOW}b#wE})Kn3GC~17ocSc4w4i0Chr>7whh`*c&ka$pk)Zxx*i(qFeoYRtzfg*0- z7DXbFLBLy%+Tc5a!Qfxx_l*FpH3+wchKD;FqZbUtQhIvqeUk(+V-Xh^D3{IYq^w#&KU< z7Ik0%QEd{*X0TXdz|gxKT=wv=I}nOlSy>bH@Vj@fq@<(-5&w2uUE_ { + await flush(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 80)); +} + +describe('a card grid shows its selection', () => { + beforeEach(() => { + resetHarness(); + stub('library.Library.GetAllArtists', ARTISTS); + stub('library.Library.GetAllGenresWithCounts', GENRES); + stub('library.Library.GetAllTracks', []); + stub('library.Library.GetAllAlbums', []); + // The views read through LibraryController, whose cache is only + // primed by a scan-complete; without it they render nothing and the + // assertion below fails for the wrong reason. + emit(Events.LibraryScanComplete); + }); + + it('highlights an artist card that was ctrl-clicked', async () => { + const el = await fixture('artists-view'); + + sized(el); + await settle(el); + + const card = shadowAll(el, '.artist-card')[0]; + + expect(card, 'no artist cards rendered').toBeTruthy(); + + card!.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + composed: true, + ctrlKey: true, + }), + ); + await settle(el); + + // The clicked card specifically, not a count: a view is free to + // arrive with something already selected, and the question here is + // only whether the click reached the DOM. + expect(shadowAll(el, '.artist-card')[0]?.classList.contains('selected')).toBe( + true, + ); + }); + + it('highlights a genre card that was ctrl-clicked', async () => { + const el = await fixture('genres-view'); + + sized(el); + await settle(el); + + const card = shadowAll(el, '.genre-card')[0]; + + expect(card, 'no genre cards rendered').toBeTruthy(); + + card!.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + composed: true, + ctrlKey: true, + }), + ); + await settle(el); + + // The clicked card specifically, not a count: a view is free to + // arrive with something already selected, and the question here is + // only whether the click reached the DOM. + expect(shadowAll(el, '.genre-card')[0]?.classList.contains('selected')).toBe( + true, + ); + }); +}); diff --git a/frontend/test/components/chrome.test.ts b/frontend/test/components/chrome.test.ts index a128ebf..59fa435 100644 --- a/frontend/test/components/chrome.test.ts +++ b/frontend/test/components/chrome.test.ts @@ -25,7 +25,9 @@ describe('', () => { const el = await fixture('app-sidebar'); expect( - shadowAll(el, 'li').map((li) => li.getAttribute('data-testid')), + shadowAll(el, 'li button').map((item) => + item.getAttribute('data-testid'), + ), ).toEqual([ 'nav-home', 'nav-playlists', @@ -44,8 +46,8 @@ describe('', () => { it('marks exactly one item as the current page', async () => { const el = await fixture('app-sidebar'); - const current = shadowAll(el, 'li').filter( - (li) => li.getAttribute('aria-current') === 'page', + const current = shadowAll(el, 'li button').filter( + (item) => item.getAttribute('aria-current') === 'page', ); expect(current).toHaveLength(1); diff --git a/frontend/test/components/confirm-dialog.test.ts b/frontend/test/components/confirm-dialog.test.ts new file mode 100644 index 0000000..ac3683c --- /dev/null +++ b/frontend/test/components/confirm-dialog.test.ts @@ -0,0 +1,82 @@ +/** + * The one "are you sure?". + * + * Three destructive actions had none at all — including a multi-select + * loop that deleted N playlists on one click (errors.M6, M7, m4) — so + * what matters here is that the promise a call site awaits cannot + * resolve true unless somebody said so. + */ +import { describe, expect, it } from 'vitest'; + +import { + confirmAction, + type ConfirmDialog, +} from '@components/confirm-dialog/confirm-dialog'; + +/** The singleton the helper attaches to the document on first use. */ +function host(): ConfirmDialog { + const el = document.querySelector('confirm-dialog'); + + if (!el) throw new Error('confirm-dialog did not mount itself'); + + return el; +} + +async function press(testid: string): Promise { + const el = host(); + + await el.updateComplete; + el.shadowRoot?.querySelector(`[data-testid="${testid}"]`) + ?.click(); + await el.updateComplete; +} + +describe('confirmAction', () => { + it('resolves true only when the user accepts', async () => { + const answer = confirmAction({ + title: 'Delete “Chill”?', + message: 'The playlist is deleted; the audio files are not.', + confirmLabel: 'Delete playlist', + danger: true, + }); + + await press('confirm-accept'); + + await expect(answer).resolves.toBe(true); + }); + + it('resolves false on cancel, which is the default answer', async () => { + const answer = confirmAction({ title: 'Delete?', message: 'Gone for good.' }); + + await press('confirm-cancel'); + + await expect(answer).resolves.toBe(false); + }); + + it('says what will happen before asking', async () => { + const answer = confirmAction({ + title: 'Remove “Lidarr”?', + message: 'YellowJacket will stop using this client.', + impact: 'Its stored credentials are deleted and cannot be recovered.', + }); + const el = host(); + + await el.updateComplete; + + expect(el.shadowRoot?.textContent).toContain('cannot be recovered'); + + await press('confirm-cancel'); + await answer; + }); + + it('does not leave a second question hanging', async () => { + const first = confirmAction({ title: 'One?', message: 'First.' }); + const second = confirmAction({ title: 'Two?', message: 'Second.' }); + + await press('confirm-accept'); + + // The first was superseded and answered "no" rather than never + // settling — a call site awaiting it would otherwise hang forever. + await expect(Promise.all([first, second])).resolves.toEqual([false, true]); + }); +}); diff --git a/frontend/test/components/empty-states.test.ts b/frontend/test/components/empty-states.test.ts new file mode 100644 index 0000000..3702c12 --- /dev/null +++ b/frontend/test/components/empty-states.test.ts @@ -0,0 +1,68 @@ +/** + * "Loading tracks…" used to be what the track list said when it was + * loading, when it was empty, and when the query had failed — including + * on the first screen a new user ever sees, behind the first-run wizard + * (errors.M2, H-12). Three situations, three different things to say. + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import type { LitElement } from 'lit'; + +import '@components/track-list/track-list'; +import { Events } from '../../src/events'; +import { emit, stub, stubFailure, flush, resetHarness } from '@test/support/harness'; +import { fixture, shadow, text } from '@test/support/render'; + +/** Drop the library store's cache so the list has to fetch. */ +async function emptyLibrary(): Promise { + resetHarness(); + stub('library.Library.GetAllTracks', []); + stub('library.Library.GetAllAlbums', []); + stub('library.Library.GetAllArtists', []); + stub('library.Library.GetAllGenresWithCounts', []); + emit(Events.LibraryScanComplete); + await flush(); +} + +describe(' empty, loading and failed', () => { + beforeEach(async () => { + await emptyLibrary(); + }); + + it('says the library is empty when it is empty', async () => { + const el = await fixture('track-list'); + + await flush(); + await el.updateComplete; + + expect(text(el, '[data-testid="track-list-empty"]')).toContain( + 'No tracks yet', + ); + }); + + it('says the query failed, and offers to try again', async () => { + stubFailure('library.Library.GetAllTracks', 'sql: database is locked'); + emit(Events.LibraryScanComplete); + await flush(); + + const el = await fixture('track-list'); + + await flush(); + await el.updateComplete; + + expect([ + text(el, '[data-testid="track-list-error"]'), + shadow(el, '[data-testid="track-list-loading"]'), + ]).toEqual([expect.stringContaining('busy'), null]); + }); + + it('does not claim to be loading a list it was handed', async () => { + const el = await fixture('track-list', { externalTracks: [] }); + + await flush(); + await el.updateComplete; + + expect(text(el, '[data-testid="track-list-empty"]')).toBe( + 'Nothing here yet.', + ); + }); +}); diff --git a/frontend/test/components/icons.test.ts b/frontend/test/components/icons.test.ts new file mode 100644 index 0000000..28890b8 --- /dev/null +++ b/frontend/test/components/icons.test.ts @@ -0,0 +1,71 @@ +/** + * The icons are bundled, and stay bundled. + * + * `e2e/specs/offline-icons.spec.ts` is the reproduction — it closes the + * network and looks at the screen. This is the cheap guard that runs + * on every change: that the library the app registers resolves every + * name it claims to, and resolves none of them to a URL somebody else + * has to be reachable to serve. + */ +import { describe, expect, it } from 'vitest'; +// A bare is an unknown element unless something pulls the +// component in; in the app `index.ts` does it, and here nothing else in +// this module would. +import '@awesome.me/webawesome/dist/components/icon/icon.js'; + +import { bundledIconNames, registerBundledIcons } from '../../src/icons'; +import { fixture } from '../support/render'; + +// A name from each of the ways a call site produces one: a literal in a +// template, a sidebar table entry, a value computed from player state, +// and the notification tone map. Not exhaustive on purpose — the +// exhaustive check is the e2e sweep, which can see what state produces. +const REPRESENTATIVE = [ + 'house', 'compact-disc', 'play', 'pause', 'shuffle', 'repeat', + 'volume-high', 'volume-xmark', 'triangle-exclamation', 'circle-check', + 'magnifying-glass', 'gear', 'heart', 'regular/heart', +]; + +describe('bundled icons', () => { + it('resolves every name the app is known to use', () => { + const names = new Set(bundledIconNames()); + + for (const name of REPRESENTATIVE) { + expect(names, `icon '${name}' is not bundled`).toContain(name); + } + }); + + it('resolves nothing to a remote origin', async () => { + // Re-registering is what the app does on every boot; it must be + // idempotent, and the assertion needs the resolver itself + // rather than the name list. + registerBundledIcons(); + + // Attributes, not properties: `regular/heart` is a library key + // rather than an icon name, and `wa-icon` takes its name from + // the attribute. + const icons = await Promise.all( + REPRESENTATIVE.filter((n) => !n.includes('/')).map(async (n) => { + const el = await fixture('wa-icon'); + el.setAttribute('name', n); + await el.updateComplete; + + return el; + }), + ); + + // Give the icon components a turn to fetch and inline their SVG. + await new Promise((r) => setTimeout(r, 500)); + + expect(icons.length).toBeGreaterThan(0); + + for (const icon of icons) { + const svg = icon.shadowRoot?.querySelector('svg'); + + expect( + svg, + `icon '${icon.getAttribute('name')}' rendered nothing`, + ).toBeTruthy(); + } + }); +}); diff --git a/frontend/test/components/keyboard-reach.test.ts b/frontend/test/components/keyboard-reach.test.ts new file mode 100644 index 0000000..650bd9b --- /dev/null +++ b/frontend/test/components/keyboard-reach.test.ts @@ -0,0 +1,115 @@ +/** + * Keyboard reachability. + * + * Tabbing through the whole app used to yield fourteen stops, every one + * of them chrome: the sidebar was a list of `
  • `, a track list + * had no tab stop at all, and the *closed* queue panel still had two + * (`.planning/audits/2026-08-11-ui/hands-on.md`, H-5). + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import '@components/sidebar/app-sidebar'; +import '@components/queue-panel/queue-panel'; +import '@components/track-list/track-list'; +import { stub } from '@test/support/harness'; +import { fixture, shadow, shadowAll, update } from '@test/support/render'; + +/** Two fixture tracks, enough to move a focus ring between. */ +const TRACKS = [ + { + FilePath: '/music/a.mp3', + TrackName: 'Alpha', + ArtistName: 'One', + Album: 'First', + Duration: 100, + }, + { + FilePath: '/music/b.mp3', + TrackName: 'Beta', + ArtistName: 'Two', + Album: 'First', + Duration: 120, + }, +] as never[]; + +describe(' is reachable', () => { + it('renders every destination as a button, not a bare list item', async () => { + const el = await fixture('app-sidebar'); + + const items = shadowAll(el, 'li button'); + + expect(items).toHaveLength(11); + expect(items.every((item) => item.tagName === 'BUTTON')).toBe(true); + }); + + it('puts the nav in a landmark, so it can be jumped to', async () => { + const el = await fixture('app-sidebar'); + + expect(shadow(el, 'nav')?.getAttribute('aria-label')).toBe('Main'); + }); +}); + +describe(' when closed', () => { + it('is inert, so its buttons are not tab stops', async () => { + const el = await fixture('queue-panel'); + + expect(el.inert).toBe(true); + }); + + it('is not inert once opened', async () => { + const el = await fixture('queue-panel'); + + await update(el, { open: true }); + + expect(el.inert).toBe(false); + }); +}); + +describe(' roving tabindex', () => { + beforeEach(() => { + stub('library.Library.GetAllTracks', TRACKS); + }); + + it('offers exactly one tab stop, however many rows there are', async () => { + const el = await fixture('track-list', { externalTracks: TRACKS }); + + const stops = shadowAll(el, '.track-row[tabindex="0"]'); + + expect(stops).toHaveLength(1); + }); + + it('gives the rows grid semantics rather than none', async () => { + const el = await fixture('track-list', { externalTracks: TRACKS }); + + const row = shadow(el, '.track-row'); + + expect({ + row: row?.getAttribute('role'), + grid: shadow(el, '.table-container')?.getAttribute('role'), + cell: shadow(el, '.track-row .cell')?.getAttribute('role'), + }).toEqual({ row: 'row', grid: 'grid', cell: 'gridcell' }); + }); + + it('moves the tab stop with the arrow keys', async () => { + const el = await fixture('track-list', { externalTracks: TRACKS }); + + shadow(el, '.table-container')?.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + cancelable: true, + }), + ); + await el.updateComplete; + + expect( + shadow(el, '.track-row[tabindex="0"]')?.getAttribute('data-index'), + ).toBe('1'); + }); + + it('claims the tracklist shortcut scope while it is on screen', async () => { + const el = await fixture('track-list', { externalTracks: TRACKS }); + + expect(el.dataset['shortcutScope']).toBe('tracklist'); + }); +}); diff --git a/frontend/test/components/lazy-track-details.test.ts b/frontend/test/components/lazy-track-details.test.ts new file mode 100644 index 0000000..1c8de60 --- /dev/null +++ b/frontend/test/components/lazy-track-details.test.ts @@ -0,0 +1,90 @@ +/** + * `track-details` is 42 kB and must not ride in the startup chunk. + * + * Five components open it — `track-list`, `cover-grid`, `queue-panel`, + * `playlist-details` and `smart-playlist-details` — and all five used + * to `import` it for side effect, so it was eagerly evaluated before + * first paint however `index.ts` split the routes. Measured: 814.5 kB + * of JS evaluated before first paint, against 772.9 kB after. + * + * What keeps it out is the *absence* of those imports, which is + * invisible: adding one back costs nothing anybody would notice, and + * the dialog carries on working because the chunk is also warmed on + * idle. So the first half of this file reads the five sources and + * fails if one of them reaches for it statically again. A `import + * type` is fine — types are erased and pull in no chunk. + * + * The second half is the reason that is safe: `loadTrackDetails()` + * really does define the element, so an opener that awaits it can then + * use the `` its template already rendered. Before the + * chunk lands that element exists but is not upgraded — an inert + * `HTMLElement` with no `show()` — which is the same trap `index.ts`'s + * `VIEW_LOADERS` exists for, and why every opener awaits. + */ +import { describe, expect, it } from 'vitest'; + +import trackListSource from '@components/track-list/track-list.ts?raw'; +import coverGridSource from '@components/cover-grid/cover-grid.ts?raw'; +import queuePanelSource from '@components/queue-panel/queue-panel.ts?raw'; +import playlistDetailsSource from '@components/playlist-details/playlist-details.ts?raw'; +import smartPlaylistDetailsSource from '@components/smart-playlist-details/smart-playlist-details.ts?raw'; + +import { loadTrackDetails } from '@utils/lazy-track-details'; + +const OPENERS: Array<[string, string]> = [ + ['track-list', trackListSource], + ['cover-grid', coverGridSource], + ['queue-panel', queuePanelSource], + ['playlist-details', playlistDetailsSource], + ['smart-playlist-details', smartPlaylistDetailsSource], +]; + +/** A side-effect import: `import '…track-details…'`, no bindings. */ +const SIDE_EFFECT_IMPORT = + /^\s*import\s+['"][^'"]*track-details[^'"]*['"]\s*;?\s*$/m; + +describe('track-details stays out of the startup chunk', () => { + it.each(OPENERS)( + '%s does not import track-details for side effect', + (_name, source) => { + expect(SIDE_EFFECT_IMPORT.test(source)).toBe(false); + }, + ); + + it.each(OPENERS)('%s loads it at the point of use', (_name, source) => { + expect(source).toContain('loadTrackDetails'); + }); +}); + +describe('loadTrackDetails', () => { + it('defines the element and reports success', async () => { + await expect(loadTrackDetails()).resolves.toBe(true); + expect(customElements.get('track-details')).toBeDefined(); + }); + + it('leaves a rendered element usable', async () => { + // The invariant every opener depends on: after the await, the + // `` its template already rendered has a + // `show()` on it. (This cannot observe the *un*-upgraded state + // — the suite above has already defined the element, and a + // custom element cannot be undefined again — so it asserts the + // postcondition rather than the transition.) + const el = document.createElement('track-details'); + + document.body.appendChild(el); + + try { + await loadTrackDetails(); + expect(typeof (el as { show?: unknown }).show).toBe('function'); + } finally { + el.remove(); + } + }); + + it('is memoised, so a second open does not refetch', async () => { + const first = loadTrackDetails(); + + expect(loadTrackDetails()).toBe(first); + await first; + }); +}); diff --git a/frontend/test/components/list-render-cost.test.ts b/frontend/test/components/list-render-cost.test.ts new file mode 100644 index 0000000..a4566d6 --- /dev/null +++ b/frontend/test/components/list-render-cost.test.ts @@ -0,0 +1,70 @@ +/** + * `perf.M3` and `perf.M4` are both per-row costs paid only while a + * virtualizer is recycling rows, which makes them invisible to every + * other tier: nothing renders differently, nothing fails, and the app + * is merely slower to scroll. + * + * The measurement that found them lives in `e2e/perf/measure.mjs` and + * needs a 50 000-track library. These are the cheap guards that keep + * the fixes from being undone by someone reading the call site alone, + * and they assert the *mechanism* rather than a duration — a timing + * assertion in a component test is a flake, not a regression test. + */ +import { describe, expect, it } from 'vitest'; + +import { COLUMN_DEFS } from '@components/track-list/columns'; +import { render } from 'lit'; + +/** Render a column's cell into a detached element and read the HTML. */ +function cell(columnId: string, track: Record): HTMLElement { + const host = document.createElement('div'); + + render(COLUMN_DEFS[columnId]?.renderCell?.(track as never), host); + + return host; +} + +describe('the track list Art column', () => { + const track = { + CoverArtPath: '/covers/abc.jpg', + CoverArtSmall: '/covers/abc_sm.jpg', + CoverArtMedium: '/covers/abc_md.jpg', + }; + + it('asks for the 100 px tier, not the original, for a 24 px box', () => { + // The original is commonly 1500×1500 and several hundred kB, and was + // being decoded in full to draw 576 pixels. + const img = cell('albumArt', track).querySelector('img'); + + expect(img?.getAttribute('src')).toBe('/covers/abc_sm.jpg'); + }); + + it('keeps the decode off the scroll path', () => { + const img = cell('albumArt', track).querySelector('img'); + + expect([ + img?.getAttribute('loading'), + img?.getAttribute('decoding'), + ]).toEqual(['lazy', 'async']); + }); + + it('falls back through the tiers rather than rendering nothing', () => { + const onlyOriginal = cell('albumArt', { + CoverArtPath: '/covers/abc.jpg', + CoverArtSmall: '', + CoverArtMedium: '', + }).querySelector('img'); + + expect(onlyOriginal?.getAttribute('src')).toBe('/covers/abc.jpg'); + }); + + it('renders nothing at all when there is no art', () => { + const none = cell('albumArt', { + CoverArtPath: '', + CoverArtSmall: '', + CoverArtMedium: '', + }); + + expect(none.querySelector('img')).toBeNull(); + }); +}); diff --git a/frontend/test/components/notifications.test.ts b/frontend/test/components/notifications.test.ts new file mode 100644 index 0000000..f37d245 --- /dev/null +++ b/frontend/test/components/notifications.test.ts @@ -0,0 +1,126 @@ +/** + * The surface itself: one component, four presentations. + * + * The assertions are on what a user (and Playwright) can see — the + * sentence, the action, the dismiss — rather than on which element + * happens to hold them, because the whole point of the shared notice is + * that a caller does not choose the markup. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import '@components/notifications/notification-host'; +import '@components/notifications/inline-notice'; +import { notificationStore } from '@store/notification-store'; +import { flush } from '@test/support/harness'; +import { fixture, shadow, shadowAll, text } from '@test/support/render'; +import type { LitElement } from 'lit'; + +/** Let the store's microtask notification reach the component. */ +async function settle(el: LitElement): Promise { + await flush(); + await el.updateComplete; +} + +describe('', () => { + beforeEach(() => { + notificationStore.clear(); + }); + + it('says nothing when there is nothing to say', async () => { + const el = await fixture('notification-host'); + + expect(shadow(el, '[data-testid="notification-stack"]')).toBeNull(); + }); + + it('renders a persistent failure with the action it offers', async () => { + const el = await fixture('notification-host'); + + notificationStore.persistent({ + text: 'The scan did not start.', + action: { label: 'Try again', run: () => undefined }, + }); + await settle(el); + + expect([ + text(el, '[data-testid="notification"]'), + text(el, '[data-testid="notification-action"]'), + ]).toEqual([ + expect.stringContaining('The scan did not start.'), + 'Try again', + ]); + }); + + it('stacks persistent above transient, so a toast never buries an answer', async () => { + const el = await fixture('notification-host'); + + notificationStore.transient({ text: 'That favourite was undone.' }); + notificationStore.persistent({ text: 'The scan did not start.' }); + await settle(el); + + expect( + shadowAll(el, '[data-testid="notification"]').map((n) => + n.getAttribute('data-level'), + ), + ).toEqual(['persistent', 'transient']); + }); + + it('dismisses on request', async () => { + const el = await fixture('notification-host'); + + notificationStore.transient({ text: 'That favourite was undone.' }); + await settle(el); + + shadow(el, '.notice-dismiss')?.click(); + await settle(el); + + expect(shadow(el, '[data-testid="notification"]')).toBeNull(); + }); + + it('puts a blocking failure in a dialog, which has to be answered', async () => { + const el = await fixture('notification-host'); + + notificationStore.blocking({ + title: 'This folder was only partly retagged', + text: '3 of 9 tracks were written.', + }); + await settle(el); + + expect(shadow(el, '[data-testid="notification-blocking"]')).not.toBeNull(); + }); + + it('leaves inline messages to the region that failed', async () => { + const el = await fixture('notification-host'); + + notificationStore.inline('player', { text: 'Could not seek.' }); + await settle(el); + + expect(shadow(el, '[data-testid="notification"]')).toBeNull(); + }); +}); + +describe('', () => { + beforeEach(() => { + notificationStore.clear(); + }); + + it('renders only its own region', async () => { + const el = await fixture('inline-notice', { region: 'player' }); + + notificationStore.inline('explore', { text: 'The search did not answer.' }); + notificationStore.inline('player', { text: 'Could not seek.' }); + await settle(el); + + expect(text(el, '[data-testid="notification"]')).toContain( + 'Could not seek.', + ); + }); + + it('is a live region, because nothing moved focus to it', async () => { + const el = await fixture('inline-notice', { region: 'player' }); + + notificationStore.inline('player', { text: 'Could not seek.' }); + await settle(el); + + expect(shadow(el, '[aria-live="polite"]')).not.toBeNull(); + }); +}); diff --git a/frontend/test/components/now-playing.test.ts b/frontend/test/components/now-playing.test.ts index f12f829..91a7857 100644 --- a/frontend/test/components/now-playing.test.ts +++ b/frontend/test/components/now-playing.test.ts @@ -156,6 +156,82 @@ describe('', () => { ]); }); + // perf.m5. `updated()` used to measure and rewrite the text geometry + // on every pass, and the player store notifies while playing — so a + // component whose DOM had not changed did six querySelectors and a + // read/write interleave several times a second. These two pin the + // guard from both sides: it has to skip the work when nothing it + // measures changed, and it has to *not* skip it when something did. + async function settle(el: HTMLElement & { updateComplete: Promise }) { + for (let i = 0; i < 3; i++) { + await new Promise((r) => { + requestAnimationFrame(() => r(null)); + }); + await flush(); + await el.updateComplete; + } + } + + function countShadowQueries(el: HTMLElement): () => number { + const root = el.shadowRoot!; + const orig = root.querySelector.bind(root); + let n = 0; + + root.querySelector = ((...args: [string]) => { + n++; + + return orig(...args); + }) as typeof root.querySelector; + + return () => n; + } + + it('does not touch the DOM again when a position report changes nothing', async () => { + const el = await fixture('now-playing'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 7 }); + await flush(); + await el.updateComplete; + // `observe()` delivers an initial callback of its own, which is one + // more legitimate re-measure. Let it land before counting, or the + // straggler reads as the thing this is asserting is gone. + await settle(el); + + const queries = countShadowQueries(el); + + for (let i = 0; i < 3; i++) { + emit(Events.PlaybackPositionChanged, { + positionSeconds: i + 1, + trackChangeId: 7, + seq: i, + }); + await flush(); + await el.updateComplete; + } + + expect(queries()).toBe(0); + }); + + it('re-measures when the track changes', async () => { + const el = await fixture('now-playing'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 8 }); + await flush(); + await el.updateComplete; + + const queries = countShadowQueries(el); + + emit(Events.TrackChanged, { + ...TRACK, + title: 'Teenage Wildlife', + trackChangeId: 9, + }); + await flush(); + await el.updateComplete; + + expect(queries()).toBeGreaterThan(0); + }); + it('looks the way it did last time', async () => { const el = await fixture('now-playing'); @@ -175,14 +251,21 @@ describe('', () => { setQueue([]); }); + // Every case here mounts the panel *open*. A closed panel renders no + // list at all (perf.m7) — `width: 0` used to hide a virtualizer that + // was still measuring its window on every queue change and still + // calling `scrollIntoView()` on an invisible element. These tests + // passed against a closed panel before that, which is the finding + // rather than a detail of the fixture. + it('says so when the queue is empty', async () => { - const el = await fixture('queue-panel'); + const el = await fixture('queue-panel', { open: true }); expect(text(el, '.empty-state p')).toBe('Queue is empty'); }); it('renders a row per queued track, tagged with its file path', async () => { - const el = await fixture('queue-panel'); + const el = await fixture('queue-panel', { open: true }); setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')]); await flush(); @@ -200,7 +283,7 @@ describe('', () => { }); it('marks the playing row as active', async () => { - const el = await fixture('queue-panel'); + const el = await fixture('queue-panel', { open: true }); setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 1); await flush(); @@ -215,7 +298,7 @@ describe('', () => { }); it('disables the clear button on an empty queue', async () => { - const el = await fixture('queue-panel'); + const el = await fixture('queue-panel', { open: true }); const button = shadow(el, '.header-action-button'); @@ -223,7 +306,7 @@ describe('', () => { }); it('clears through the backend, not locally', async () => { - const el = await fixture('queue-panel'); + const el = await fixture('queue-panel', { open: true }); setQueue([queueTrack(1, 'First')]); await flush(); @@ -240,7 +323,7 @@ describe('', () => { // toMatchScreenshot never gets two identical frames and fails with // "could not capture a stable screenshot" rather than a real diff. it('keeps rendering rows after the virtualizer settles', async () => { - const el = await fixture('queue-panel'); + const el = await fixture('queue-panel', { open: true }); setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 0); await flush(); diff --git a/frontend/test/components/playlist-virtualization.test.ts b/frontend/test/components/playlist-virtualization.test.ts new file mode 100644 index 0000000..2b6ce47 --- /dev/null +++ b/frontend/test/components/playlist-virtualization.test.ts @@ -0,0 +1,141 @@ +/** + * `perf.M5`: the two playlist detail views rendered every track with a + * plain `.map()`. Measured at 2 000 tracks: 22 090 elements retained in + * the shadow root and 2 000 eager cover requests on open, against the + * 487 and 0 a virtualizer costs. + * + * The magnitude belongs to `e2e/perf/measure.mjs` and a 50 000-track + * library. These are the cheap guards for the *mechanism*, and in + * particular for the one thing that broke while fixing it: the row + * templates now live inside the virtualizer, so a host re-render alone + * no longer repaints them. Selection went silently dead — the + * controller held the right keys and no row ever showed it. Nothing but + * a click in the real app caught that, which is precisely why it is + * pinned here. + */ +import { describe, expect, it, beforeEach } from 'vitest'; +import type { LitElement } from 'lit'; + +import '@components/playlist-details/playlist-details'; +import '@components/smart-playlist-details/smart-playlist-details'; +import { stub, flush, resetHarness } from '@test/support/harness'; +import { fixture, shadowAll } from '@test/support/render'; + +const TRACKS = 500; + +function tracks(n: number) { + return Array.from({ length: n }, (_, i) => ({ + ID: i + 1, + FilePath: `/music/track-${i}.mp3`, + Title: `Track ${i}`, + Artist: 'An Artist', + Album: 'An Album', + Duration: 180000, + CoverArtSmall: `/covers/${i}_sm.jpg`, + CoverArtMedium: `/covers/${i}_md.jpg`, + CoverArtPath: `/covers/${i}.jpg`, + Phantom: false, + })); +} + +/** Give the virtualizer a viewport; a zero-height host renders no rows. */ +function sized(el: HTMLElement): void { + el.style.display = 'block'; + el.style.height = '600px'; +} + +describe(' at length', () => { + let el: LitElement; + + beforeEach(async () => { + resetHarness(); + stub('playlist.Service.GetPlaylistTracks', tracks(TRACKS)); + stub('playlist.Service.GetAllPlaylists', []); + + el = await fixture('playlist-details', { + playlistId: 1, + playlistName: 'A playlist', + }); + sized(el); + await flush(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 60)); + }); + + it('renders through a virtualizer, not one row per track', () => { + const rows = shadowAll(el, '.track-item'); + + // A screenful and its overscan, not the playlist. The exact number + // depends on the viewport, so the assertion is the *order of + // magnitude* — 500 rows would be the bug. + expect([ + shadowAll(el, 'lit-virtualizer').length, + rows.length > 0, + rows.length < TRACKS / 4, + ]).toEqual([1, true, true]); + }); + + it('never asks for a cover it has not scrolled to', () => { + const imgs = shadowAll(el, '.track-item img'); + + expect(imgs.every((i) => i.getAttribute('loading') === 'lazy')).toBe(true); + }); + + it('asks for the small tier, not the original', () => { + const img = shadowAll(el, '.track-item img')[0]; + + expect(img?.getAttribute('src')).toBe('/covers/0_sm.jpg'); + }); + + it('still shows a selection, though the rows are the virtualizer\u2019s', async () => { + const row = shadowAll(el, '.track-item')[0]!; + + row.dispatchEvent( + new MouseEvent('click', { bubbles: true, composed: true }), + ); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 60)); + + expect( + shadowAll(el, '.track-item').filter((r) => + r.classList.contains('selected'), + ).length, + ).toBe(1); + }); +}); + +describe(' at length', () => { + let el: LitElement; + + beforeEach(async () => { + resetHarness(); + stub('playlist.Service.GetSmartPlaylistTracks', tracks(TRACKS)); + stub('playlist.Service.GetSmartPlaylistRules', '{"rules":[]}'); + stub('playlist.Service.GetAllPlaylists', []); + + el = await fixture('smart-playlist-details', { + playlistId: 1, + playlistName: 'A smart playlist', + }); + sized(el); + await flush(); + await el.updateComplete; + await new Promise((r) => setTimeout(r, 60)); + }); + + it('renders through a virtualizer, not one row per track', () => { + const rows = shadowAll(el, '.track-item'); + + expect([ + shadowAll(el, 'lit-virtualizer').length, + rows.length > 0, + rows.length < TRACKS / 4, + ]).toEqual([1, true, true]); + }); + + it('never asks for a cover it has not scrolled to', () => { + const imgs = shadowAll(el, '.track-item img'); + + expect(imgs.every((i) => i.getAttribute('loading') === 'lazy')).toBe(true); + }); +}); diff --git a/frontend/test/components/selection.test.ts b/frontend/test/components/selection.test.ts new file mode 100644 index 0000000..ba28ae9 --- /dev/null +++ b/frontend/test/components/selection.test.ts @@ -0,0 +1,206 @@ +/** + * A refetch is not a deselection. + * + * Selecting forty tracks to drag into a playlist was impossible while + * music was playing: every finished track invalidated the library + * cache, `track-list` answered the new array by calling `loadTracks()`, + * and that cleared the selection (audit perf.C2). + * + * Half of that is fixed in the backend — a play count no longer + * invalidates anything (perf.C1) — but the other half has to hold on + * its own, because a rescan, a retag or a library switch still deliver + * a new array, and none of those should throw away a selection whose + * items are still in the list. + */ +import { describe, expect, it, beforeEach } from 'vitest'; + +import { SelectionController } from '../../src/utils/selection-controller'; + +class FakeHost { + items: string[] = []; + + updates = 0; + + changes = 0; + + // ReactiveControllerHost, minus the parts a selection never uses. + addController(): void { + /* registration; nothing to do here. */ + } + + removeController(): void { + /* ditto. */ + } + + updateComplete = Promise.resolve(true); + + requestUpdate(): void { + this.updates++; + } + + onSelectionChanged(): void { + this.changes++; + } + + getItemCount(): number { + return this.items.length; + } + + getItemKey(index: number): string | undefined { + return this.items[index]; + } +} + +function click( + selection: SelectionController, + host: FakeHost, + index: number, + modifiers: MouseEventInit = {}, +): void { + selection.handleItemClick( + new MouseEvent('click', modifiers), + host.items[index] as string, + index, + ); +} + +describe('selection across a refetch', () => { + let host: FakeHost; + let selection: SelectionController; + + beforeEach(() => { + host = new FakeHost(); + host.items = ['/a.mp3', '/b.mp3', '/c.mp3', '/d.mp3']; + selection = new SelectionController(host); + + click(selection, host, 0); + click(selection, host, 2, { ctrlKey: true }); + click(selection, host, 3, { ctrlKey: true }); + }); + + it('keeps a selection whose items all survive', () => { + const present = new Set(host.items); + + selection.retain((key) => present.has(key)); + + expect([...selection.selectedItems].sort()).toEqual([ + '/a.mp3', '/c.mp3', '/d.mp3', + ]); + }); + + it('drops only the items that are gone', () => { + host.items = ['/a.mp3', '/b.mp3', '/c.mp3']; + const present = new Set(host.items); + + selection.retain((key) => present.has(key)); + + expect([...selection.selectedItems].sort()).toEqual([ + '/a.mp3', '/c.mp3', + ]); + expect(host.changes).toBeGreaterThan(0); + }); + + it('does not notify when nothing changed', () => { + const present = new Set(host.items); + const before = host.changes; + + selection.retain((key) => present.has(key)); + + expect(host.changes).toBe(before); + }); + + it('forgets the shift-click anchor, which indexes the old list', () => { + host.items = ['/d.mp3', '/a.mp3', '/c.mp3']; + const present = new Set(host.items); + + selection.retain((key) => present.has(key)); + + // A shift-click after a refetch extends from the clicked item + // alone rather than from a row that has since moved. + click(selection, host, 2, { shiftKey: true }); + + expect(selection.selectedItems.has('/c.mp3')).toBe(true); + }); + + it('selects all even when the count matches the previous size', () => { + // perf.p5: the guard compared cardinalities, so a selection + // that happened to be the same size as the list short-circuited + // Select All into a no-op. + selection.clear(); + click(selection, host, 0); + click(selection, host, 1, { ctrlKey: true }); + click(selection, host, 2, { ctrlKey: true }); + click(selection, host, 3, { ctrlKey: true }); + + host.items = ['/w.mp3', '/x.mp3', '/y.mp3', '/z.mp3']; + selection.selectAll(); + + expect([...selection.selectedItems].sort()).toEqual([ + '/w.mp3', '/x.mp3', '/y.mp3', '/z.mp3', + ]); + }); +}); + +/** + * `getSelectedKeysOrdered()` / `getSelectedIndices()` walk the list + * rather than the selection, which is `perf.m6`. Measured at 50 000 + * tracks that walk is 3 ms — a fifth of a frame — so it stays, and the + * only change is that it stops once it has found everything. + * + * That early exit has one way to be wrong, and it is the case these + * tests exist for: a selected key that is *not* in the list must not + * end the walk early and truncate the answer. It is reachable — + * `retain()` deliberately keeps keys across a list it has not + * re-checked, and Ctrl-clicking builds a selection in any order. + */ +describe('ordered selection accessors', () => { + let host: FakeHost; + let selection: SelectionController; + + beforeEach(() => { + host = new FakeHost(); + host.items = ['/a.mp3', '/b.mp3', '/c.mp3', '/d.mp3']; + selection = new SelectionController(host); + }); + + it('returns keys in list order, not selection order', () => { + click(selection, host, 3); + click(selection, host, 1, { ctrlKey: true }); + click(selection, host, 0, { ctrlKey: true }); + + expect(selection.getSelectedKeysOrdered()) + .toEqual(['/a.mp3', '/b.mp3', '/d.mp3']); + expect(selection.getSelectedIndices()).toEqual([0, 1, 3]); + }); + + it('is empty for an empty selection', () => { + expect(selection.getSelectedKeysOrdered()).toEqual([]); + expect(selection.getSelectedIndices()).toEqual([]); + }); + + it('finds a selection at the end of the list', () => { + click(selection, host, 3); + + expect(selection.getSelectedKeysOrdered()).toEqual(['/d.mp3']); + expect(selection.getSelectedIndices()).toEqual([3]); + }); + + it('does not stop early when a selected key is missing', () => { + click(selection, host, 0); + click(selection, host, 3, { ctrlKey: true }); + + // The list loses the first selected item; the selection still + // holds its key, which is exactly what `retain()` allows. + host.items = ['/b.mp3', '/c.mp3', '/d.mp3']; + + expect(selection.getSelectedKeysOrdered()).toEqual(['/d.mp3']); + expect(selection.getSelectedIndices()).toEqual([2]); + }); + + it('returns every selected key when all are selected', () => { + selection.selectAll(); + + expect(selection.getSelectedKeysOrdered()).toEqual(host.items); + expect(selection.getSelectedIndices()).toEqual([0, 1, 2, 3]); + }); +}); diff --git a/frontend/test/components/transport.test.ts b/frontend/test/components/transport.test.ts index 64396b7..54d4de4 100644 --- a/frontend/test/components/transport.test.ts +++ b/frontend/test/components/transport.test.ts @@ -6,6 +6,7 @@ */ import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; +import '@components/audio-player/audio-player'; import '@components/audio-player/controls/player-controls'; import '@components/audio-player/seekbar/seek-bar'; import '@components/audio-player/volume-control/volume-control'; @@ -15,11 +16,14 @@ import { fixture, shadow, shadowAll, + deepShadow, + deepText, text, click, visual, } from '@test/support/render'; -import type { TrackInfo } from '@store/player-store'; +import { PlayerRegion, type TrackInfo } from '@store/player-store'; +import { notificationStore } from '@store/notification-store'; const TRACK: TrackInfo = { fileName: 'long.mp3', @@ -205,7 +209,81 @@ describe('', () => { expect([ text(el, '[data-testid="elapsed-time"]'), text(el, '[data-testid="remaining-time"]'), - ]).toEqual(['00:00', '01:30']); + ]).toEqual(['00:00', '-01:30']); + }); + + it('says which number the right-hand clock is, and swaps it on click', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 21 }); + await flush(); + await el.updateComplete; + + // H-16: it read `01:21` next to a track the list called `01:30`, + // with no minus sign, no label and no way to see the duration. + await click(el, '[data-testid="remaining-time"]'); + await el.updateComplete; + + expect(text(el, '[data-testid="remaining-time"]')).toBe('01:30'); + }); + + it('renders the position the backend reports rather than its own count', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 11 }); + emit(Events.PlaybackPositionChanged, { + positionSeconds: 42, + trackLength: 90, + trackChangeId: 11, + seq: 1, + playing: true, + }); + await flush(); + await el.updateComplete; + + expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:42'); + }); + + it('resets its interpolation on every report, so a seek cannot desync it', async () => { + vi.useFakeTimers(); + + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 12 }); + emit(Events.PlaybackStateChanged, { state: 'playing' }); + await vi.advanceTimersByTimeAsync(3000); + await el.updateComplete; + + // The user seeks; the backend lands somewhere else entirely and + // says so. The local counter must be discarded, not added to. + emit(Events.PlaybackPositionChanged, { + positionSeconds: 40, + trackLength: 90, + trackChangeId: 12, + seq: 2, + playing: true, + }); + await vi.advanceTimersByTimeAsync(1000); + await el.updateComplete; + + expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:41'); + }); + + it('ignores a report belonging to a track that is no longer loaded', async () => { + const el = await fixture('seek-bar'); + + emit(Events.TrackChanged, { ...TRACK, trackChangeId: 13 }); + emit(Events.PlaybackPositionChanged, { + positionSeconds: 60, + trackLength: 90, + trackChangeId: 12, + seq: 3, + playing: true, + }); + await flush(); + await el.updateComplete; + + expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:00'); }); it('resumes mid-track from the position the backend reported', async () => { @@ -359,3 +437,84 @@ describe('volume control: mute', () => { expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('false'); }); }); + +/** + * The player says when it could not do what it was told. + * + * This is the Inline level of plan 007's notification table, and it is + * deliberately local to the bottom bar rather than an app-wide surface: + * the useful response to a track that will not play is to keep playing, + * which the backend already does by skipping it. + */ +describe(' messages', () => { + beforeEach(() => { + notificationStore.dismissRegion(PlayerRegion); + }); + + it('names the track that could not be played', async () => { + const el = await fixture('audio-player'); + + emit(Events.PlaybackFailed, { + filePath: '/music/gone.mp3', + title: 'Tideline', + artist: 'Aurora Fields', + reason: 'no such file or directory', + }); + await flush(); + await el.updateComplete; + + expect(deepText(el, '[data-testid="player-message"]')).toContain( + 'Tideline', + ); + }); + + it('coalesces a disconnected drive into one message with a count', async () => { + const el = await fixture('audio-player'); + + for (const title of ['One', 'Two', 'Three']) { + emit(Events.PlaybackFailed, { + filePath: `/music/${title}.mp3`, + title, + artist: '', + reason: 'no such file or directory', + }); + } + + await flush(); + await el.updateComplete; + + // Not three messages, and not one message about the last file. + expect(deepText(el, '[data-testid="player-message"]')).toContain( + 'Skipped 3 tracks', + ); + }); + + it('explains a failed seek, which used to be emitted into the void', async () => { + const el = await fixture('audio-player'); + + emit(Events.SeekFailed); + await flush(); + await el.updateComplete; + + expect(deepText(el, '[data-testid="player-message"]')).toContain( + 'Could not seek', + ); + }); + + it('can be dismissed', async () => { + const el = await fixture('audio-player'); + + emit(Events.SeekFailed); + await flush(); + await el.updateComplete; + + deepShadow( + el, + '[data-testid="player-message"] .notice-dismiss', + )!.click(); + await flush(); + await el.updateComplete; + + expect(deepShadow(el, '[data-testid="player-message"]')).toBeNull(); + }); +}); diff --git a/frontend/test/components/view-lifecycle.test.ts b/frontend/test/components/view-lifecycle.test.ts new file mode 100644 index 0000000..71d7155 --- /dev/null +++ b/frontend/test/components/view-lifecycle.test.ts @@ -0,0 +1,289 @@ +/** + * The half of the lifecycle the view cache never had. + * + * `index.ts` keeps every primary view mounted so that `scrollTop` + * survives navigation, which means `disconnectedCallback` never fires + * and a view that is off-screen keeps listening. The tests here assert + * the property that fixes: a deactivated view has removed everything it + * registered, and a reactivated one has it back — measured by counting + * what actually reaches `document`, since that is where the damage was + * (`.planning/audits/2026-08-11-ui/hands-on.md`, H-1). + */ +import { LitElement, html } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; + +import { ViewLifecycleMixin } from '@utils/view-lifecycle'; +import { + ambientShortcutScope, + resetShortcutScopes, +} from '../../src/services/shortcut-scope'; +import '@components/autotag-view/autotag-view'; +import '@components/track-list/track-list'; +import '@components/cover-grid/cover-grid'; +import '@components/artists-view/artists-view'; +import '@components/genres-view/genres-view'; +import '@components/explore-view/explore-view'; +import '@components/home-view/home-view'; +import '@components/downloads-view/downloads-view'; + +import '@components/jobs/jobs-view'; +import '@components/playlist-view/playlist-view'; +import { fixture } from '@test/support/render'; +import { stub, flush } from '@test/support/harness'; + +/** Count of keydown-ish document listeners, by proxy: every test view + * below registers on `document`, so a counting stand-in for + * `addEventListener` is the only honest measure available in a + * browser. */ +let added = 0; +let removed = 0; + +const realAdd = document.addEventListener.bind(document); +const realRemove = document.removeEventListener.bind(document); + +beforeEach(() => { + added = 0; + removed = 0; + document.addEventListener = ((...args: Parameters) => { + added += 1; + + return realAdd(...args); + }) as typeof document.addEventListener; + document.removeEventListener = (( + ...args: Parameters + ) => { + removed += 1; + + return realRemove(...args); + }) as typeof document.removeEventListener; +}); + +afterEach(() => { + document.addEventListener = realAdd; + document.removeEventListener = realRemove; + resetShortcutScopes(); +}); + +// =================================================================== + +@customElement('lifecycle-probe') +class LifecycleProbe extends ViewLifecycleMixin(LitElement) { + ticks = 0; + keypresses = 0; + activations = 0; + deactivations = 0; + + protected override onViewActivate(): void { + this.activations += 1; + this.listenWhileActive(document, 'keydown', () => { + this.keypresses += 1; + }); + this.intervalWhileActive(() => { + this.ticks += 1; + }, 5); + } + + protected override onViewDeactivate(): void { + this.deactivations += 1; + } + + protected override render() { + return html`

    probe

    `; + } +} + +/** One navigation away and back, as `index.ts` performs it. */ +function navigateAway(el: LifecycleProbe): void { + el.classList.add('view-hidden'); + el.viewDeactivated(); +} + +function navigateBack(el: LifecycleProbe): void { + el.classList.remove('view-hidden'); + el.viewActivated(); +} + +describe('view lifecycle', () => { + it('activates on connection when it is not created hidden', async () => { + const el = await fixture('lifecycle-probe'); + + expect(el.viewActive).toBe(true); + }); + + it('stops listening once deactivated', async () => { + const el = await fixture('lifecycle-probe'); + + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + navigateAway(el); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + + expect(el.keypresses).toBe(1); + }); + + it('listens again on the way back, and only once', async () => { + const el = await fixture('lifecycle-probe'); + + navigateAway(el); + navigateBack(el); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); + + expect(el.keypresses).toBe(1); + }); + + it('does not accumulate document listeners across navigations', async () => { + const el = await fixture('lifecycle-probe'); + const afterFirst = added - removed; + + for (let i = 0; i < 5; i += 1) { + navigateAway(el); + navigateBack(el); + } + + expect(added - removed).toBe(afterFirst); + }); + + it('stops its timers while off screen', async () => { + const el = await fixture('lifecycle-probe'); + + navigateAway(el); + + const at = el.ticks; + + await new Promise((r) => { + setTimeout(r, 30); + }); + + expect(el.ticks).toBe(at); + }); + + it('is idempotent in both directions', async () => { + const el = await fixture('lifecycle-probe'); + + el.viewActivated(); + el.viewActivated(); + navigateAway(el); + el.viewDeactivated(); + + expect([el.activations, el.deactivations]).toEqual([1, 1]); + }); + + it('deactivates when it is genuinely removed', async () => { + const el = await fixture('lifecycle-probe'); + + el.remove(); + + expect(el.viewActive).toBe(false); + }); + + it('does not render while off screen, and catches up on return', async () => { + const el = await fixture('lifecycle-probe'); + + navigateAway(el); + el.requestUpdate(); + await el.updateComplete; + + expect(el.hasUpdated).toBe(true); + + // The update was withheld, not lost: reactivation replays it. + navigateBack(el); + await el.updateComplete; + + expect(el.isConnected).toBe(true); + }); +}); + +// =================================================================== + +describe('shortcut scope claims', () => { + it('is claimed by the view on screen and released when it leaves', async () => { + const list = await fixture('track-list'); + + expect(ambientShortcutScope()).toBe('tracklist'); + + (list as unknown as { viewDeactivated(): void }).viewDeactivated(); + + expect(ambientShortcutScope()).toBeNull(); + }); + + it('publishes the scope as the attribute the service reads', async () => { + const list = await fixture('track-list'); + + expect(list.dataset['shortcutScope']).toBe('tracklist'); + }); +}); + +// =================================================================== + +/** The views `index.ts` caches, all of which must take part. */ +const CACHED_VIEWS = [ + 'autotag-view', + 'track-list', + 'cover-grid', + 'artists-view', + 'genres-view', + 'downloads-view', + 'jobs-view', + 'playlist-view', + 'explore-view', + 'home-view', +]; + +/** An empty-but-valid backend, as smoke.test.ts does: an unstubbed + * binding resolves undefined, which is not what Go sends. */ +function stubEmptyBackend(): void { + for (const path of [ + 'library.Library.GetAllTracks', + 'library.Library.GetAllAlbums', + 'library.Library.GetAllArtists', + 'library.Library.GetAllGenresWithCounts', + 'library.Library.GetAllLibrariesWithTrackCounts', + 'playlist.Service.GetAllPlaylists', + 'playlist.Service.GetAllPlaylistsWithTracks', + 'playlist.Service.GetDefaultPlaylistTrackPaths', + 'jobs.Service.GetJobs', + 'download.Service.ListProviders', + 'download.Service.ListDownloads', + 'download.Service.ListRequests', + 'download.Service.ProviderKinds', + 'autotagservice.Service.ListPendingFolders', + 'home.Service.GetShelves', + ]) { + stub(path, []); + } +} + +describe('every cached view', () => { + beforeEach(() => { + stubEmptyBackend(); + }); + + for (const tag of CACHED_VIEWS) { + it(`${tag} holds no document listeners while off screen`, async () => { + const before = added - removed; + const el = await fixture(tag); + const view = el as unknown as { + viewActivated(): void; + viewDeactivated(): void; + }; + + await flush(); + + const active = added - removed; + + view.viewDeactivated(); + + expect({ tag, held: added - removed - before }).toEqual({ + tag, + held: 0, + }); + + // …and gets them all back, exactly once, on the way in again. + view.viewActivated(); + view.viewDeactivated(); + view.viewActivated(); + + expect({ tag, net: added - removed }).toEqual({ tag, net: active }); + }); + } +}); diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts index 76eeb6e..b7f37ce 100644 --- a/frontend/test/setup.ts +++ b/frontend/test/setup.ts @@ -4,12 +4,16 @@ * 1. Install the Wails fake. Store singletons call `EventsOn` and load * from the backend *in their constructors*, which run when a test * module imports them — so the globals have to exist first. - * 2. Point Web Awesome at its assets. Without this every `` - * silently 404s and screenshots come out with holes in them. + * 2. Point Web Awesome at its assets, and register the *bundled* icon + * library — the same call `index.ts` makes. Without the second + * one this tier renders icons from fontawesome.com, so a green + * `make ui-test` would depend on the network and the screenshot + * baselines would be of something the app no longer ships. */ import { afterEach, beforeEach } from 'vitest'; import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js'; import '@awesome.me/webawesome/dist/styles/themes/default.css'; +import { registerBundledIcons } from '../src/icons'; import { installWailsFake, wails } from './support/wails-fake'; import { resetHarness } from './support/harness'; import { cleanupFixtures } from './support/render'; @@ -43,10 +47,16 @@ for (const [path, value] of importTimeDefaults) { wails.stub(path, value); } -// Vite serves the dependency's own directory, so icons resolve from -// node_modules rather than from the built `dist/webawesome` copy the -// app uses. +// Vite serves the dependency's own directory, so Web Awesome's own +// assets resolve from node_modules rather than from the built +// `dist/webawesome` copy the app uses. +// +// Note this does *not* cover icons: `getBasePath` is read only by the +// component autoloader, never by the icon resolver, which is the +// original half of audit finding H-4 and the reason the line below +// exists rather than being implied by this one. setBasePath('/node_modules/@awesome.me/webawesome/dist'); +registerBundledIcons(); // index.ts imports the theme store for its side effect: it derives the // --yj-* custom properties and applies them to :root, where every diff --git a/frontend/test/stores/keyboard-shortcuts.test.ts b/frontend/test/stores/keyboard-shortcuts.test.ts index 577c4ea..3fa5c1e 100644 --- a/frontend/test/stores/keyboard-shortcuts.test.ts +++ b/frontend/test/stores/keyboard-shortcuts.test.ts @@ -197,14 +197,74 @@ describe('shortcut dispatch: scope', () => { expect(calls('queue.Queue.Play')).toHaveLength(0); }); - it('lets a checkbox through — it is not a text input', () => { + it('leaves Space to a focused checkbox, which owns it', () => { + // The global bindings are unmodified single keys, so the service has + // to yield to a control that means something by the key itself — + // otherwise the checkbox you tabbed to cannot be ticked (H-6). const input = mount(document.createElement('input')); input.type = 'checkbox'; input.focus(); + + expect(press(' ').defaultPrevented).toBe(false); + expect(calls('queue.Queue.Play')).toHaveLength(0); + }); + + it('still fires a key the focused control does not own', () => { + bindings({ 'player.playPause': 'Space', 'player.next': 'N' }); + + const button = mount(document.createElement('button')); + + button.focus(); + press('n'); + + expect(calls('queue.Queue.Next')).toHaveLength(1); + }); + + it('leaves the arrow keys to a focused slider', () => { + bindings({ 'player.volumeUp': 'Up' }); + + const input = mount(document.createElement('input')); + + input.type = 'range'; + input.focus(); + press('ArrowUp'); + + expect(calls('player.Player.ChangeVolume')).toHaveLength(0); + }); + + it('leaves every unmodified key to an open dialog', () => { + const dialog = mount(document.createElement('div')); + const button = document.createElement('button'); + + dialog.setAttribute('role', 'dialog'); + dialog.append(button); + button.focus(); press(' '); - expect(calls('queue.Queue.Play')).toHaveLength(1); + expect(calls('queue.Queue.Play')).toHaveLength(0); + }); + + it('keeps a modified binding even inside a dialog', () => { + bindings({ 'app.selectAll': 'Ctrl+A' }); + + const dialog = mount(document.createElement('div')); + const button = document.createElement('button'); + + dialog.setAttribute('role', 'dialog'); + dialog.append(button); + button.focus(); + + let fired = 0; + const listener = (): void => { + fired += 1; + }; + + document.addEventListener('shortcut:select-all', listener); + press('a', { ctrlKey: true }); + document.removeEventListener('shortcut:select-all', listener); + + expect(fired).toBe(1); }); it('blurs the input on Escape, and only on Escape', () => { @@ -234,8 +294,11 @@ describe('shortcut dispatch: scope', () => { it('resolves a panel scope from a data-shortcut-scope ancestor', () => { const panel = mount(document.createElement('div')); - const button = document.createElement('button'); + const button = document.createElement('div'); + // Focused on a plain focusable, not a button: a button owns Enter + // itself and is meant to keep it. + button.tabIndex = 0; panel.dataset['shortcutScope'] = 'tracklist'; panel.append(button); button.focus(); @@ -256,8 +319,9 @@ describe('shortcut dispatch: scope', () => { const panel = mount(document.createElement('div')); const inner = document.createElement('div'); const root = inner.attachShadow({ mode: 'open' }); - const button = document.createElement('button'); + const button = document.createElement('div'); + button.tabIndex = 0; panel.dataset['shortcutScope'] = 'tracklist'; panel.append(inner); root.append(button); diff --git a/frontend/test/stores/library-store.test.ts b/frontend/test/stores/library-store.test.ts index b759d70..526407c 100644 --- a/frontend/test/stores/library-store.test.ts +++ b/frontend/test/stores/library-store.test.ts @@ -14,12 +14,16 @@ import { emit, calls, stub, + stubFailure, flush, lastArgs, resetHarness, } from '@test/support/harness'; -const TRACKS = [{ ID: 1, Title: 'One' }]; +const TRACKS = [ + { ID: 1, Title: 'One', FilePath: '/a.mp3', PlayCount: 0, LastPlayed: '' }, + { ID: 2, Title: 'Two', FilePath: '/b.mp3', PlayCount: 4, LastPlayed: 'x' }, +]; const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }]; const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }]; const ARTISTS = [{ ID: 1, Name: 'Artist' }]; @@ -110,6 +114,67 @@ describe('library store: caching', () => { expect(calls('library.Library.GetAllTracks')).toHaveLength(1); }); + /* + * A finished track used to arrive as TrackMetadataChanged, so every + * song refetched the whole library: ~37 MB across the IPC and ~0.8 s + * of blocked main thread per track at 50 000 tracks (perf.C1). + * + * The assertion that matters is the negative one. Patching the track + * in place is only a fix if nothing is refetched as well. + */ + describe('a play count arriving', () => { + beforeEach(async () => { + emit(Events.TrackPlayCountChanged, { + audioFileId: 1, + filePath: '/a.mp3', + playCount: 9, + lastPlayed: '2026-08-11 10:00:00', + }); + await flush(); + }); + + it('refetches nothing', () => { + expect(calls().map((c) => c.path)).toEqual([]); + }); + + it('patches the one track it names', () => { + const tracks = libraryStore.getCachedTracks(); + + expect(tracks?.[0]).toMatchObject({ + FilePath: '/a.mp3', + PlayCount: 9, + LastPlayed: '2026-08-11 10:00:00', + }); + }); + + it('leaves every other track alone', () => { + expect(libraryStore.getCachedTracks()?.[1]).toMatchObject({ + FilePath: '/b.mp3', + PlayCount: 4, + }); + }); + + it('replaces the array, so memoized consumers notice', () => { + // `track-list` keys its filter/sort caches on the array identity; + // mutating in place would be invisible to every one of them. + expect(libraryStore.getCachedTracks()).not.toBe(TRACKS); + expect(libraryStore.changeGeneration).toBeGreaterThan(0); + }); + }); + + it('ignores a play count for a track it has never heard of', async () => { + const before = libraryStore.getCachedTracks(); + + emit(Events.TrackPlayCountChanged, { + filePath: '/not-in-this-library.mp3', + playCount: 1, + }); + await flush(); + + expect(libraryStore.getCachedTracks()).toBe(before); + expect(calls()).toEqual([]); + }); + it('resets scroll positions on invalidation, so a shorter list is not scrolled past its end', async () => { libraryStore.setScrollPosition('albums', 4200); emit(Events.LibraryScanComplete); @@ -178,6 +243,63 @@ describe('library store: library filter', () => { }); }); +/** + * The reproduction for errors.C4 and errors.M1: two bugs that are the + * same bug seen from either end of an in-flight fetch. + */ +describe('library store: a fetch that is overtaken', () => { + beforeEach(async () => { + libraryStore.setSelectedLibrary(null); + await reload(); + }); + + it('serves the library that is selected, not the one that was in flight', async () => { + const pending: Array<{ id: number; resolve: (v: unknown) => void }> = []; + const byLibrary = (id: number) => [{ ID: id, Title: `Library ${id}` }]; + + // Only the track fetch is held open; the other three settle at once, + // so the test is about the overtaking and nothing else. + stub( + 'library.Library.GetAllTracksByLibrary', + (id: number) => + new Promise((resolve) => { + pending.push({ id, resolve }); + }), + ); + + libraryStore.setSelectedLibrary(7); + await flush(); + libraryStore.setSelectedLibrary(8); + await flush(); + + // Library 7's answer lands after the user has already moved on. + pending.find((p) => p.id === 7)?.resolve(byLibrary(7)); + await flush(); + pending.find((p) => p.id === 8)?.resolve(byLibrary(8)); + await flush(); + + expect(libraryStore.getCachedTracks()).toEqual(byLibrary(8)); + }); + + it('settles the waiters when the fetch they are waiting on fails', async () => { + stubFailure('library.Library.GetAllTracks', 'sql: database is locked'); + // Invalidation drops the cache and starts the fetch that fails. + emit(Events.LibraryScanComplete); + + // Arrives while that fetch is in flight, so it waits on it rather + // than issuing a second one. + const waiter = libraryStore.getTracks().then( + () => 'resolved', + () => 'rejected', + ); + const timeout = new Promise((resolve) => { + setTimeout(() => resolve('never settled'), 500); + }); + + await expect(Promise.race([waiter, timeout])).resolves.toBe('rejected'); + }); +}); + describe('library store: default library id', () => { beforeEach(async () => { libraryStore.setSelectedLibrary(null); diff --git a/frontend/test/stores/notification-store.test.ts b/frontend/test/stores/notification-store.test.ts new file mode 100644 index 0000000..260aa99 --- /dev/null +++ b/frontend/test/stores/notification-store.test.ts @@ -0,0 +1,158 @@ +/** + * The app's one notification surface. + * + * Two behaviours here are the reason it exists as a store rather than + * as a component: the caller picks a *level* and nothing else, and + * coalescing happens once, here, so a queue of 200 unplayable files + * produces one message rather than 200 and no future caller has to + * remember that. + */ +import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest'; + +import { notificationStore } from '@store/notification-store'; + +describe('notification store', () => { + beforeEach(() => { + notificationStore.clear(); + }); + + it('keeps a notification per level', () => { + notificationStore.blocking({ text: 'Half the folder was retagged.' }); + notificationStore.persistent({ text: 'The scan did not start.' }); + notificationStore.transient({ text: 'That favourite was undone.' }); + notificationStore.inline('player', { text: 'Could not seek.' }); + + expect(notificationStore.getAll().map((n) => n.level)).toEqual([ + 'blocking', + 'persistent', + 'transient', + 'inline', + ]); + }); + + it('routes an inline message to its region and nowhere else', () => { + notificationStore.inline('player', { text: 'Could not seek.' }); + + expect([ + notificationStore.forRegion('player').length, + notificationStore.forRegion('explore').length, + notificationStore.byLevel('transient').length, + ]).toEqual([1, 0, 0]); + }); + + it('folds a repeat into one message with a count', () => { + for (const title of ['One', 'Two', 'Three']) { + notificationStore.inline('player', { + key: 'playback-failed', + text: `Could not play “${title}”.`, + coalescedText: (count) => `Skipped ${count} tracks.`, + }); + } + + const [only] = notificationStore.forRegion('player'); + + expect([notificationStore.getAll().length, only?.count, only?.text]).toEqual( + [1, 3, 'Skipped 3 tracks.'], + ); + }); + + it('does not fold two different failures together', () => { + notificationStore.transient({ key: 'a', text: 'One thing failed.' }); + notificationStore.transient({ key: 'b', text: 'Another thing failed.' }); + + expect(notificationStore.getAll()).toHaveLength(2); + }); + + it('does not fold the same key across levels', () => { + notificationStore.transient({ key: 'scan', text: 'Scan failed.' }); + notificationStore.persistent({ key: 'scan', text: 'Scan failed.' }); + + expect(notificationStore.getAll()).toHaveLength(2); + }); + + it('runs an action and takes the message away with it', () => { + const retry = vi.fn(); + const id = notificationStore.persistent({ + text: 'The scan did not start.', + action: { label: 'Try again', run: retry }, + }); + + notificationStore.runAction(id); + + expect([retry.mock.calls.length, notificationStore.getAll().length]).toEqual( + [1, 0], + ); + }); + + it('notifies subscribers once per batch', async () => { + let notifications = 0; + const off = notificationStore.subscribe(() => { + notifications += 1; + }); + + notificationStore.transient({ text: 'One.' }); + notificationStore.transient({ text: 'Two.' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + off(); + + expect(notifications).toBe(1); + }); + + it('keeps the stack readable, and never drops a modal to do it', () => { + notificationStore.blocking({ text: 'Half the folder was retagged.' }); + + for (let i = 0; i < 8; i += 1) { + notificationStore.persistent({ key: `k${i}`, text: `Failure ${i}.` }); + } + + const levels = notificationStore.getAll().map((n) => n.level); + + expect([levels.length, levels.includes('blocking')]).toEqual([5, true]); + }); +}); + +describe('notification store: self-dismissal', () => { + beforeEach(() => { + vi.useFakeTimers(); + notificationStore.clear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('takes a toast away by itself', () => { + notificationStore.transient({ text: 'That favourite was undone.' }); + vi.advanceTimersByTime(6000); + + expect(notificationStore.getAll()).toHaveLength(0); + }); + + it('leaves the levels that are waiting for an answer', () => { + notificationStore.persistent({ text: 'The scan did not start.' }); + notificationStore.blocking({ text: 'Half the folder was retagged.' }); + vi.advanceTimersByTime(60_000); + + expect(notificationStore.getAll()).toHaveLength(2); + }); + + it('restarts the clock when a message repeats', () => { + notificationStore.transient({ key: 'fav', text: 'Undone.' }); + vi.advanceTimersByTime(4000); + notificationStore.transient({ key: 'fav', text: 'Undone.' }); + vi.advanceTimersByTime(4000); + + // Still there: the second occurrence bought it another window. + expect(notificationStore.getAll()).toHaveLength(1); + }); + + it('starts a new message once the coalescing window has passed', () => { + notificationStore.inline('player', { key: 'seek', text: 'Could not seek.' }); + vi.advanceTimersByTime(20_000); + notificationStore.inline('player', { key: 'seek', text: 'Could not seek.' }); + + const [only] = notificationStore.forRegion('player'); + + expect(only?.count).toBe(1); + }); +}); diff --git a/frontend/test/stores/playlist-store.test.ts b/frontend/test/stores/playlist-store.test.ts index 504ad49..483580a 100644 --- a/frontend/test/stores/playlist-store.test.ts +++ b/frontend/test/stores/playlist-store.test.ts @@ -1,9 +1,14 @@ /** * The playlist store caches one list and invalidates it on six - * different events. The distinction worth testing is `invalidate` vs + * different events. Two distinctions are worth testing. `invalidate` vs * `refetch`: one drops the cache (consumers render empty until the * fetch lands), the other holds the stale list until the new one - * arrives. Using the wrong one shows up as a flash of empty list. + * arrives — using the wrong one shows up as a flash of empty list. + * + * And `invalidate` vs *patch*: `GetAllPlaylistsWithTracks` returns every + * row of every playlist with full track metadata, which is the wrong + * answer to "one track was added to playlist 2" and was measured at + * 2.61 MB for one heart toggle (`perf.C5`). The event carries the id. */ import { describe, expect, it, beforeEach } from 'vitest'; @@ -11,17 +16,42 @@ import { playlistStore } from '@store/playlist-store'; import { Events } from '../../src/events'; import { emit, calls, stub, flush, resetHarness } from '@test/support/harness'; +/** The real shape: `WithTracks` is `{ Summary, Tracks }`. */ const PLAYLISTS = [ - { ID: 1, Name: 'Morning', Tracks: [] }, - { ID: 2, Name: 'Evening', Tracks: [] }, + { + Summary: { ID: 1, Name: 'Morning', UpdatedAt: '2026-01-01T00:00:00Z' }, + Tracks: [{ FilePath: '/a.mp3', Title: 'One' }], + }, + { + Summary: { ID: 2, Name: 'Evening', UpdatedAt: '2026-01-01T00:00:00Z' }, + Tracks: [{ FilePath: '/b.mp3', Title: 'Two' }], + }, ]; -async function reload(): Promise { +const SUMMARIES = PLAYLISTS.map((p) => p.Summary); + +/** + * The store only refetches eagerly when something is subscribed — + * before `playlist-view` has ever been opened there is no reader and + * nothing to refresh. Tests that assert on a refetch therefore need a + * subscriber, exactly as the running app does. + */ +let unsubscribe: (() => void) | null = null; + +function stubReads(): void { stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS); + stub('playlist.Service.GetAllPlaylists', SUMMARIES); + stub('playlist.Service.GetPlaylistTracks', []); +} + +async function reload(): Promise { + unsubscribe?.(); + unsubscribe = playlistStore.subscribe(() => {}); + stubReads(); playlistStore.invalidate(); await flush(); resetHarness(); - stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS); + stubReads(); } describe('playlist store: caching', () => { @@ -86,12 +116,11 @@ describe('playlist store: invalidating events', () => { await reload(); }); - it('refetches for every event that can change a playlist', async () => { + it('refetches everything for every event that can restructure the list', async () => { const events = [ Events.PlaylistCreated, Events.PlaylistDeleted, Events.PlaylistRenamed, - Events.PlaylistTracksChanged, Events.PlaylistsRestored, Events.LibraryScanComplete, ]; @@ -105,4 +134,103 @@ describe('playlist store: invalidating events', () => { calls('playlist.Service.GetAllPlaylistsWithTracks'), ).toHaveLength(events.length); }); + + it('does not refetch when nothing is subscribed', async () => { + unsubscribe?.(); + unsubscribe = null; + + emit(Events.PlaylistCreated, 1); + await flush(); + + expect(calls('playlist.Service.GetAllPlaylistsWithTracks')).toEqual([]); + // Still dropped, so the next reader fetches rather than serving a + // list the backend has moved on from. + expect(playlistStore.getCachedPlaylists()).toBeNull(); + }); +}); + +describe('playlist store: patching one playlist (perf.C5)', () => { + beforeEach(async () => { + await reload(); + }); + + it('refetches only the playlist the event names', async () => { + stub('playlist.Service.GetPlaylistTracks', [ + { FilePath: '/b.mp3', Title: 'Two' }, + { FilePath: '/c.mp3', Title: 'Three' }, + ]); + + emit(Events.PlaylistTracksChanged, 2); + await flush(); + + expect(calls('playlist.Service.GetAllPlaylistsWithTracks')).toEqual([]); + expect(calls('playlist.Service.GetPlaylistTracks')).toHaveLength(1); + + const cached = playlistStore.getCachedPlaylists() ?? []; + expect(cached).toHaveLength(2); + expect(cached[1]?.Tracks).toHaveLength(2); + }); + + it('shares the tracks of every playlist that did not change', async () => { + const before = playlistStore.getCachedPlaylists() ?? []; + + emit(Events.PlaylistTracksChanged, 2); + await flush(); + + const after = playlistStore.getCachedPlaylists() ?? []; + + // A new array identity, because `playlist-view` keys its reload off + // it — but the untouched playlist's tracks are the same objects. + // Asserted non-empty first, or two `undefined`s would pass this. + expect(before[0]?.Tracks).toBeDefined(); + expect(after).not.toBe(before); + expect(after[0]?.Tracks).toBe(before[0]?.Tracks); + }); + + it('refreshes summaries, which carry the sort key', async () => { + stub('playlist.Service.GetAllPlaylists', [ + SUMMARIES[0], + { ...SUMMARIES[1], UpdatedAt: '2026-06-01T00:00:00Z' }, + ]); + + emit(Events.PlaylistTracksChanged, 2); + await flush(); + + expect( + (playlistStore.getCachedPlaylists() ?? [])[1]?.Summary.UpdatedAt, + ).toBe('2026-06-01T00:00:00Z'); + }); + + it('falls back to a full refetch when the event carries no id', async () => { + // The bulk restore and reorder paths emit a nil id, which says + // "something changed" without saying what. + emit(Events.PlaylistTracksChanged, null); + await flush(); + + expect( + calls('playlist.Service.GetAllPlaylistsWithTracks'), + ).toHaveLength(1); + }); + + it('falls back to a full refetch for a playlist it has never seen', async () => { + emit(Events.PlaylistTracksChanged, 99); + await flush(); + + expect( + calls('playlist.Service.GetAllPlaylistsWithTracks'), + ).toHaveLength(1); + }); + + it('does not patch a cold cache, or one with a fetch already in flight', async () => { + // Both cases arrive as "there is nothing here to patch, and a full + // fetch either is happening or is about to" — patching would race + // that fetch and be overwritten by it. + playlistStore.invalidate(); + + emit(Events.PlaylistTracksChanged, 2); + await flush(); + + expect(calls('playlist.Service.GetPlaylistTracks')).toEqual([]); + expect(playlistStore.getCachedPlaylists()).toEqual(PLAYLISTS); + }); }); diff --git a/frontend/test/stores/view-stores.test.ts b/frontend/test/stores/view-stores.test.ts index f846575..c4e1b09 100644 --- a/frontend/test/stores/view-stores.test.ts +++ b/frontend/test/stores/view-stores.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it, beforeEach } from 'vitest'; import { searchStore } from '@store/search-store'; import { trackListStore } from '@store/tracklist-store'; -import { exploreCache } from '@store/explore-cache'; +import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '@store/explore-cache'; import { Events } from '../../src/events'; import { emit, lastCall, flush } from '@test/support/harness'; @@ -141,14 +141,34 @@ describe('explore cache', () => { expect(exploreCache.getArtist('mbid-2')?.imageURL).toBe('http://x/eno.jpg'); }); - it('caches an artist’s release groups and top tracks separately', () => { - exploreCache.setArtistAlbums('mbid-3', [{ mbid: 'rg-1' }] as never); - exploreCache.setArtistTopTracks('mbid-3', [{ mbid: 'rec-1' }] as never); + // `perf.M8`. An artist entry holds the artist photo's base64 data URL + // — ~128 kB measured — so an unbounded map on a view that never + // unmounts grows for the life of the process. + it('evicts the least recently used artist past its cap', () => { + const over = ARTIST_IMAGE_CACHE_LIMIT + 10; - expect([ - exploreCache.getArtistAlbums('mbid-3')?.length, - exploreCache.getArtistTopTracks('mbid-3')?.length, - ]).toEqual([1, 1]); + for (let i = 0; i < over; i++) { + exploreCache.setArtist(`cap-${i}`, { mbid: `cap-${i}`, name: `A${i}` }); + } + + expect(exploreCache.stats().artists.entries).toBe(ARTIST_IMAGE_CACHE_LIMIT); + // The first inserted is gone; the last is not. + expect(exploreCache.getArtist('cap-0')).toBeUndefined(); + expect(exploreCache.getArtist(`cap-${over - 1}`)?.name).toBe(`A${over - 1}`); + }); + + it('keeps an artist alive by reading it', () => { + // Recency is what makes the cap safe: the entry being rendered must + // not be the one evicted, or the render refetches it immediately. + for (let i = 0; i < ARTIST_IMAGE_CACHE_LIMIT; i++) { + exploreCache.setArtist(`lru-${i}`, { mbid: `lru-${i}`, name: `A${i}` }); + } + + exploreCache.getArtist('lru-0'); + exploreCache.setArtist('lru-new', { mbid: 'lru-new', name: 'New' }); + + expect(exploreCache.getArtist('lru-0')?.name).toBe('A0'); + expect(exploreCache.getArtist('lru-1')).toBeUndefined(); }); it('populates artists and albums from one search result', () => { diff --git a/frontend/test/support/render.ts b/frontend/test/support/render.ts index a0b1ba7..a260d43 100644 --- a/frontend/test/support/render.ts +++ b/frontend/test/support/render.ts @@ -67,6 +67,39 @@ export function shadowAll( return [...(host.shadowRoot?.querySelectorAll(selector) ?? [])]; } +/** + * Query through nested shadow roots. + * + * A component that composes another component is still one thing to the + * user, and to Playwright — `shadow()` stops at the first boundary, + * which makes an assertion depend on which component happens to own the + * markup today. + */ +export function deepShadow( + root: Element, + selector: string, +): E | null { + const queue: Array = [root.shadowRoot ?? root]; + + while (queue.length > 0) { + const node = queue.shift()!; + const hit = node.querySelector(selector); + + if (hit) return hit; + + for (const el of node.querySelectorAll('*')) { + if (el.shadowRoot) queue.push(el.shadowRoot); + } + } + + return null; +} + +/** Trimmed text content of the first deep match, or null if absent. */ +export function deepText(host: Element, selector: string): string | null { + return deepShadow(host, selector)?.textContent?.trim() ?? null; +} + /** Trimmed text content of the first match, or null if absent. */ export function text(host: Element, selector: string): string | null { return shadow(host, selector)?.textContent?.trim() ?? null; diff --git a/frontend/test/utils/describe-error.test.ts b/frontend/test/utils/describe-error.test.ts new file mode 100644 index 0000000..32ad21a --- /dev/null +++ b/frontend/test/utils/describe-error.test.ts @@ -0,0 +1,137 @@ +/** + * Eight places in this app render a Go error verbatim, so a person is + * shown `Get "https://musicbrainz.org/ws/2/…": context deadline + * exceeded` and asked to make something of it (errors.M9). + * + * `describeError` is the one map from those strings to a sentence. It + * is deliberately conservative: it recognises the handful of causes a + * user can act on and says something generic about everything else, + * because a wrong guess about a cause is worse than no guess. + */ +import { describe, expect, it } from 'vitest'; + +import { describeError, explainError } from '@utils/describe-error'; + +describe('describeError', () => { + const cases: Array<[label: string, raw: string, expected: RegExp]> = [ + [ + 'a timed-out MusicBrainz lookup', + 'Get "https://musicbrainz.org/ws/2/artist": context deadline exceeded', + /took too long/i, + ], + [ + 'an http client timeout', + 'Get "https://example.com": net/http: request canceled (Client.Timeout exceeded while awaiting headers)', + /took too long/i, + ], + [ + 'a name that does not resolve', + 'Get "https://musicbrainz.org": dial tcp: lookup musicbrainz.org: no such host', + /connection|offline|reach/i, + ], + [ + 'a refused connection', + 'Post "http://localhost:8080/api": dial tcp 127.0.0.1:8080: connect: connection refused', + /connection|offline|reach/i, + ], + [ + 'a locked database', + "failed to remove 'Music': sql: database is locked", + /busy|in use/i, + ], + [ + 'a file that moved', + 'open /music/gone.mp3: no such file or directory', + /not be found|moved/i, + ], + ['a 404 from a provider', 'unexpected status 404 Not Found', /not be found/i], + [ + 'a file the app may not read', + 'open /music/locked.flac: permission denied', + /permission/i, + ], + [ + 'a folder Windows will not open', + 'CreateFile C:\\Music: Access is denied.', + /permission/i, + ], + ['a cancelled operation', 'context canceled', /cancelled|canceled|stopped/i], + [ + 'a disk with nothing left', + 'write /music/a.mp3: no space left on device', + /space/i, + ], + ]; + + for (const [label, raw, expected] of cases) { + it(`describes ${label}`, () => { + expect(describeError(new Error(raw))).toMatch(expected); + }); + } + + it('never leaks the raw Go string into the sentence', () => { + const raw = + 'Get "https://musicbrainz.org/ws/2/artist": context deadline exceeded'; + + expect(describeError(new Error(raw))).not.toContain('context deadline'); + }); + + it('falls back to something generic rather than guessing', () => { + const described = describeError(new Error('build plan: 7 of 9 rejected')); + + expect([described.length > 0, described.includes('build plan')]).toEqual([ + true, + false, + ]); + }); + + it('accepts the shapes a rejected binding actually produces', () => { + // Wails rejects with whatever the Go error marshalled to, which is + // often a bare string and occasionally not a string at all. + expect([ + describeError('sql: database is locked'), + describeError(null), + describeError({ message: 'permission denied' }), + ]).toEqual([ + describeError(new Error('sql: database is locked')), + describeError(new Error('')), + describeError(new Error('permission denied')), + ]); + }); + + it('takes a caller-supplied fallback for the unrecognised case', () => { + expect( + describeError(new Error('build plan: 7 of 9 rejected'), 'Nothing was written.'), + ).toBe('Nothing was written.'); + }); +}); + +/** + * Some backend errors are already sentences — the sentinels this app + * writes for conditions it defined. Those are the most useful thing to + * show, and dropping them for a generic line would be a regression. + */ +describe('explainError', () => { + it('repeats a sentinel the backend wrote for a person', () => { + expect( + explainError(new Error('a library with that name already exists: "Decoy"')), + ).toContain('already exists'); + }); + + it('does not repeat a wrapped runtime error', () => { + const described = explainError( + new Error('could not rename library: sql: database is locked'), + ); + + expect([described.includes('sql:'), /busy/i.test(described)]).toEqual([ + false, + true, + ]); + }); + + it('punctuates what it repeats', () => { + expect(explainError(new Error('no candidate selected'))).toBe( + 'no candidate selected.', + ); + }); +}); diff --git a/frontend/test/utils/lru-map.test.ts b/frontend/test/utils/lru-map.test.ts new file mode 100644 index 0000000..cd6e04d --- /dev/null +++ b/frontend/test/utils/lru-map.test.ts @@ -0,0 +1,78 @@ +/** + * `perf.M7`/`M8`: the two Explore art caches were unbounded on a view + * that never unmounts. Measured at twelve searches, a session retained + * **8.48 MB** and was still climbing 0.7 MB per search, because a cover + * thumbnail is a ~27 kB base64 data URL and an artist photo is ~128 kB. + * + * `LRUMap` is the bound. The behaviours below are the ones the call + * sites actually depend on — in particular that a *read* is what keeps + * an entry alive, since the entry being rendered must never be the one + * evicted, and that `has()` is not a read, because both caches use + * `has()` to test a negative "already tried, no art" marker. + */ +import { describe, expect, it } from 'vitest'; + +import { LRUMap } from '@utils/lru-map'; + +describe('LRUMap', () => { + it('behaves like a Map below its cap', () => { + const m = new LRUMap(4); + + m.set('a', 1).set('b', 2); + + expect([m.get('a'), m.get('b'), m.get('c'), m.size]).toEqual([ + 1, 2, undefined, 2, + ]); + }); + + it('never exceeds its cap', () => { + const m = new LRUMap(10); + + for (let i = 0; i < 1000; i++) m.set(i, i); + + expect(m.size).toBe(10); + }); + + it('evicts the oldest entry first', () => { + const m = new LRUMap(2); + + m.set('a', 1).set('b', 2).set('c', 3); + + expect([m.get('a'), m.get('b'), m.get('c')]).toEqual([undefined, 2, 3]); + }); + + it('a read renews an entry, so the rendered one survives', () => { + const m = new LRUMap(2); + + m.set('a', 1).set('b', 2); + m.get('a'); + m.set('c', 3); + + // 'b' was the least recently *used*, even though 'a' was older. + expect([m.get('a'), m.get('b'), m.get('c')]).toEqual([1, undefined, 3]); + }); + + it('has() does not renew, so a negative marker cannot outrank real art', () => { + const m = new LRUMap(2); + + // '' is the "already attempted, no art" marker both caches store. + m.set('miss', '').set('art', 'data:…'); + m.has('miss'); + m.set('new', 'data:…'); + + expect(m.has('miss')).toBe(false); + expect(m.get('art')).toBe('data:…'); + }); + + it('overwriting an existing key does not grow the map or evict', () => { + const m = new LRUMap(2); + + m.set('a', 1).set('b', 2).set('a', 99); + + expect([m.size, m.get('a'), m.get('b')]).toEqual([2, 99, 2]); + }); + + it('rejects a cap that cannot hold anything', () => { + expect(() => new LRUMap(0)).toThrow(/at least 1/); + }); +}); diff --git a/frontend/test/utils/track-index.test.ts b/frontend/test/utils/track-index.test.ts new file mode 100644 index 0000000..c20fdbb --- /dev/null +++ b/frontend/test/utils/track-index.test.ts @@ -0,0 +1,87 @@ +/** + * `tracksByFilePath` is a cache keyed on an array's identity, which is + * only safe because the stores replace the array whenever its contents + * change. These tests pin both halves of that: the cache is reused for + * the same array, and a new array gets a new map. + * + * The reason it exists is `perf.m6`: five components resolved selected + * file paths back to tracks with `filePaths.map(fp => tracks.find(…))`, + * O(selection × total). Measured through the real opener at 50 000 + * tracks, "Select all → Edit tags" blocked the main thread for + * 3.0–6.3 s; with the map, 68 ms. + */ +import { describe, expect, it } from 'vitest'; + +import { tracksByFilePath, tracksForPaths } from '@utils/track-index'; + +type Track = { FilePath: string; Title: string }; + +const track = (n: number): Track => ({ + FilePath: `/music/${n}.mp3`, + Title: `Track ${n}`, +}); + +// The util is typed against the generated `library.Track`; these +// fixtures carry only the fields it reads. +const asTracks = (t: Track[]) => t as unknown as Parameters< + typeof tracksByFilePath +>[0]; + +describe('tracksByFilePath', () => { + it('indexes by file path', () => { + const tracks = asTracks([track(1), track(2), track(3)]); + const map = tracksByFilePath(tracks); + + expect(map.size).toBe(3); + expect(map.get('/music/2.mp3')).toBe(tracks[1]); + expect(map.get('/music/nope.mp3')).toBeUndefined(); + }); + + it('reuses the map for the same array', () => { + const tracks = asTracks([track(1), track(2)]); + + expect(tracksByFilePath(tracks)).toBe(tracksByFilePath(tracks)); + }); + + it('builds a fresh map for a replaced array', () => { + // The store shares unchanged members and replaces the array, + // so identity is the invalidation signal. + const first = asTracks([track(1)]); + const second = asTracks([track(1), track(2)]); + + expect(tracksByFilePath(second)).not.toBe(tracksByFilePath(first)); + expect(tracksByFilePath(second).size).toBe(2); + }); + + it('keeps the first of a duplicated path, as find() did', () => { + const a = { FilePath: '/music/1.mp3', Title: 'first' }; + const b = { FilePath: '/music/1.mp3', Title: 'second' }; + + expect(tracksByFilePath(asTracks([a, b])).get('/music/1.mp3')) + .toBe(a); + }); +}); + +describe('tracksForPaths', () => { + it('resolves in the order asked for, not list order', () => { + const tracks = asTracks([track(1), track(2), track(3)]); + const got = tracksForPaths( + tracks, + ['/music/3.mp3', '/music/1.mp3'], + ); + + expect(got.map((t) => t.FilePath)) + .toEqual(['/music/3.mp3', '/music/1.mp3']); + }); + + it('drops paths that are not in the list', () => { + const tracks = asTracks([track(1)]); + + expect(tracksForPaths(tracks, ['/music/1.mp3', '/gone.mp3'])) + .toHaveLength(1); + }); + + it('is empty for an empty selection', () => { + expect(tracksForPaths(asTracks([track(1)]), [])).toEqual([]); + }); +});