feat(harness): agent-drivable dev harness and CI that gates
A coding agent could develop this repo's Go packages and could not develop the application: every path to running YellowJacket ended in a blocking GTK window, so 265 bound methods, 46 events, 33 component directories and 13 stores had exactly one form of verification available — `tsc --noEmit`. The unlock is that `wails dev`'s dev server on :34115 serves the real frontend with the real generated bindings against the same Go backend a desktop window attaches to, so a plain Chromium under Xvfb gets a fully functional app. Four test tiers now exist, cheapest first: - `make ui-test` — 313 Vitest tests in a real browser in ~2 s, no app, no backend, no display. Works because `frontend/wailsjs/` is a pure passthrough to `window.go`/`window.runtime`, so faking just those two globals runs the real bindings and the real store code. - `make test` — services in-process, asserting on the payload the frontend would receive, via a new `events.Emit` wrapper. - `make dev-headless` + `playwright-cli` — the real app, driven interactively, with an event bridge on `window.__yjEvents` and a dev-only control surface at `/__test/`. - `make e2e` — 19 of those flows frozen as Playwright specs. `events.Emit(ctx, …)` replaces all 35 direct `runtime.EventsEmit` call sites: wails' `getEvents` `log.Fatalf`s on any context without its runtime, so those paths could not run under test and a background worker could take the app down. Four packages had each hand-rolled the same guard; nine more guarded on `ctx != nil`, which does not help. `TestNoDirectRuntimeEmits` fails the build on a new one. Fixtures are generated, not committed (`make testdata`), and seeds are built by *running the app* — never by hand-writing config and DB rows, which would be a second description of a valid YJ_HOME. `.gitea/workflows/ci.yml` is the first workflow here that tests anything; the other three only package, so `gitea_ci` reported only packaging jobs and misled anyone asking whether a push was healthy. Both jobs were prototyped to green in a bare ubuntu:24.04 container before the YAML was written, which immediately caught `make lint` linting three configurations that nothing builds: all three passes omitted `webkit2_41`, so wails resolved webkit2gtk-4.0 — which Arch still ships and Ubuntu 24.04 dropped. Operational instructions live in `.pi/skills/yellowjacket-dev/`, measured discoveries in `.planning/NOTES.md`, and architecture in `CLAUDE.md` — split by tense, not by topic, because a topical split gives every new fact two plausible homes. `make skill-check` fails a commit if the skill cites a make target that does not exist.
This commit is contained in:
+2
-2
@@ -23,7 +23,7 @@
|
||||
<app-sidebar></app-sidebar>
|
||||
</div>
|
||||
<div class="content-area">
|
||||
<main class="main-panel" id="main-content">
|
||||
<main class="main-panel" data-active-view="tracks" data-testid="main-content" id="main-content">
|
||||
<track-list></track-list>
|
||||
</main>
|
||||
<queue-panel id="queue-panel"></queue-panel>
|
||||
@@ -31,7 +31,7 @@
|
||||
<footer class="bottom-bar">
|
||||
<now-playing></now-playing>
|
||||
<audio-player></audio-player>
|
||||
<button id="queue-button">
|
||||
<button aria-label="Toggle queue" id="queue-button">
|
||||
<wa-icon name="list"></wa-icon>
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@@ -98,6 +98,11 @@ document.addEventListener('navigate', (e: Event) => {
|
||||
|
||||
searchStore.setCurrentView(view);
|
||||
|
||||
// Which view is showing is otherwise only inferable from which of
|
||||
// the cached children lacks .view-hidden. Publishing it as an
|
||||
// attribute keeps e2e selectors semantic instead of structural.
|
||||
mainContent.dataset.activeView = view;
|
||||
|
||||
// --- Primary (cacheable) views ----------------------------------------
|
||||
if (view in VIEW_TAGS) {
|
||||
// Navigating to a primary view clears the history stack.
|
||||
|
||||
@@ -11,12 +11,16 @@
|
||||
"lit": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/browser": "^4.1.10",
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"playwright": "^1.62.1",
|
||||
"stylelint-config-standard": "^40.0.0",
|
||||
"ts-lit-plugin": "^2.0.2",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-lit-html-plugin": "^0.9.0",
|
||||
"vite": "^7.0.0",
|
||||
"vite-plugin-static-copy": "^3.0.0"
|
||||
"vite-plugin-static-copy": "^3.0.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"stylelint": {
|
||||
"extends": [
|
||||
|
||||
Generated
+400
@@ -21,6 +21,15 @@ importers:
|
||||
specifier: ^3.2.1
|
||||
version: 3.3.2
|
||||
devDependencies:
|
||||
'@vitest/browser':
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(vite@7.3.1)(vitest@4.1.10)
|
||||
'@vitest/browser-playwright':
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(playwright@1.62.1)(vite@7.3.1)(vitest@4.1.10)
|
||||
playwright:
|
||||
specifier: ^1.62.1
|
||||
version: 1.62.1
|
||||
stylelint-config-standard:
|
||||
specifier: ^40.0.0
|
||||
version: 40.0.0(stylelint@17.3.0(typescript@5.9.3))
|
||||
@@ -39,6 +48,9 @@ importers:
|
||||
vite-plugin-static-copy:
|
||||
specifier: ^3.0.0
|
||||
version: 3.2.0(vite@7.3.1)
|
||||
vitest:
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(@vitest/browser-playwright@4.1.10)(vite@7.3.1)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -58,6 +70,9 @@ packages:
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@blazediff/core@1.9.1':
|
||||
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
|
||||
|
||||
'@cacheable/memory@2.0.7':
|
||||
resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==}
|
||||
|
||||
@@ -275,6 +290,9 @@ packages:
|
||||
'@floating-ui/utils@0.2.10':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@keyv/bigmap@1.3.1':
|
||||
resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -313,6 +331,9 @@ packages:
|
||||
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.57.1':
|
||||
resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==}
|
||||
cpu: [arm]
|
||||
@@ -347,66 +368,79 @@ packages:
|
||||
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
|
||||
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.57.1':
|
||||
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
|
||||
@@ -448,6 +482,15 @@ packages:
|
||||
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -457,6 +500,46 @@ packages:
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@vitest/browser-playwright@4.1.10':
|
||||
resolution: {integrity: sha512-nMoXGEiRpT7m3W7NsbvrM2aKNwiNHZf+zEpUCvMteGjZFvfT96Q9fh7QyB98dvDWXiKvrLxA7bJ1mCOOv+JQPw==}
|
||||
peerDependencies:
|
||||
playwright: '*'
|
||||
vitest: 4.1.10
|
||||
|
||||
'@vitest/browser@4.1.10':
|
||||
resolution: {integrity: sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng==}
|
||||
peerDependencies:
|
||||
vitest: 4.1.10
|
||||
|
||||
'@vitest/expect@4.1.10':
|
||||
resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==}
|
||||
|
||||
'@vitest/mocker@4.1.10':
|
||||
resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.10':
|
||||
resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==}
|
||||
|
||||
'@vitest/runner@4.1.10':
|
||||
resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==}
|
||||
|
||||
'@vitest/snapshot@4.1.10':
|
||||
resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==}
|
||||
|
||||
'@vitest/spy@4.1.10':
|
||||
resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==}
|
||||
|
||||
'@vitest/utils@4.1.10':
|
||||
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
||||
|
||||
'@vscode/web-custom-data@0.4.13':
|
||||
resolution: {integrity: sha512-2ZUIRfhofZ/npLlf872EBnPmn27Kt4M2UssmQIfnJvgGgMYZJ5fvtHEDnttBBf2hnVtBgNCqZMVHJA+wsFVqTA==}
|
||||
|
||||
@@ -486,6 +569,10 @@ packages:
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
astral-regex@2.0.0:
|
||||
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -509,6 +596,10 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@2.4.2:
|
||||
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -542,6 +633,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@floating-ui/utils': ^0.2.5
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cosmiconfig@9.0.0:
|
||||
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -590,6 +684,9 @@ packages:
|
||||
error-ex@1.3.4:
|
||||
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
|
||||
|
||||
es-module-lexer@2.3.1:
|
||||
resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
|
||||
|
||||
esbuild@0.27.3:
|
||||
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -603,6 +700,13 @@ packages:
|
||||
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
|
||||
engines: {node: '>=0.8.0'}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expect-type@1.4.0:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -642,6 +746,11 @@ packages:
|
||||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -797,6 +906,9 @@ packages:
|
||||
lodash.truncate@4.4.2:
|
||||
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mathml-tag-names@4.0.0:
|
||||
resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==}
|
||||
|
||||
@@ -815,6 +927,10 @@ packages:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -832,6 +948,10 @@ packages:
|
||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
obug@2.1.4:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
p-map@7.0.4:
|
||||
resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -847,6 +967,9 @@ packages:
|
||||
parse5@5.1.0:
|
||||
resolution: {integrity: sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -858,6 +981,20 @@ packages:
|
||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
playwright-core@1.62.1:
|
||||
resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.62.1:
|
||||
resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==}
|
||||
engines: {node: '>=20'}
|
||||
hasBin: true
|
||||
|
||||
pngjs@7.0.0:
|
||||
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
|
||||
engines: {node: '>=14.19.0'}
|
||||
|
||||
postcss-safe-parser@7.0.1:
|
||||
resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==}
|
||||
engines: {node: '>=18.0'}
|
||||
@@ -913,6 +1050,9 @@ packages:
|
||||
run-parallel@1.2.0:
|
||||
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
signal-exit@4.1.0:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -920,6 +1060,10 @@ packages:
|
||||
signal-polyfill@0.2.2:
|
||||
resolution: {integrity: sha512-p63Y4Er5/eMQ9RHg0M0Y64NlsQKpiu6MDdhBXpyywRuWiPywhJTpKJ1iB5K2hJEbFZ0BnDS7ZkJ+0AfTuL37Rg==}
|
||||
|
||||
sirv@3.0.2:
|
||||
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
slash@5.1.0:
|
||||
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
|
||||
engines: {node: '>=14.16'}
|
||||
@@ -932,6 +1076,12 @@ packages:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -984,14 +1134,29 @@ packages:
|
||||
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.3.0:
|
||||
resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.1.1:
|
||||
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
totalist@3.0.1:
|
||||
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
ts-lit-plugin@2.0.2:
|
||||
resolution: {integrity: sha512-DPXlVxhjWHxg8AyBLcfSYt2JXgpANV1ssxxwjY98o26gD8MzeiM68HFW9c2VeDd1CjoR3w7B/6/uKxwBQe+ioA==}
|
||||
|
||||
@@ -1074,6 +1239,47 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.10:
|
||||
resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.10
|
||||
'@vitest/browser-preview': 4.1.10
|
||||
'@vitest/browser-webdriverio': 4.1.10
|
||||
'@vitest/coverage-istanbul': 4.1.10
|
||||
'@vitest/coverage-v8': 4.1.10
|
||||
'@vitest/ui': 4.1.10
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
vscode-css-languageservice@3.0.13:
|
||||
resolution: {integrity: sha512-RWkO/c/A7iXhHEy3OuEqkCqavDjpD4NF2Ca8vjai+ZtEYNeHrm1ybTnBYLP4Ft1uXvvaaVtYA9HrDjD6+CUONg==}
|
||||
|
||||
@@ -1116,6 +1322,11 @@ packages:
|
||||
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1124,6 +1335,18 @@ packages:
|
||||
resolution: {integrity: sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
ws@8.21.3:
|
||||
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
y18n@5.0.8:
|
||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1163,6 +1386,8 @@ snapshots:
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@blazediff/core@1.9.1': {}
|
||||
|
||||
'@cacheable/memory@2.0.7':
|
||||
dependencies:
|
||||
'@cacheable/utils': 2.3.4
|
||||
@@ -1294,6 +1519,8 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@keyv/bigmap@1.3.1(keyv@5.6.0)':
|
||||
dependencies:
|
||||
hashery: 1.4.0
|
||||
@@ -1334,6 +1561,8 @@ snapshots:
|
||||
'@nodelib/fs.scandir': 2.1.5
|
||||
fastq: 1.20.1
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.57.1':
|
||||
optional: true
|
||||
|
||||
@@ -1415,6 +1644,15 @@ snapshots:
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/react@19.2.14':
|
||||
@@ -1423,6 +1661,77 @@ snapshots:
|
||||
|
||||
'@types/trusted-types@2.0.7': {}
|
||||
|
||||
'@vitest/browser-playwright@4.1.10(playwright@1.62.1)(vite@7.3.1)(vitest@4.1.10)':
|
||||
dependencies:
|
||||
'@vitest/browser': 4.1.10(vite@7.3.1)(vitest@4.1.10)
|
||||
'@vitest/mocker': 4.1.10(vite@7.3.1)
|
||||
playwright: 1.62.1
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.10(@vitest/browser-playwright@4.1.10)(vite@7.3.1)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/browser@4.1.10(vite@7.3.1)(vitest@4.1.10)':
|
||||
dependencies:
|
||||
'@blazediff/core': 1.9.1
|
||||
'@vitest/mocker': 4.1.10(vite@7.3.1)
|
||||
'@vitest/utils': 4.1.10
|
||||
magic-string: 0.30.21
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.10(@vitest/browser-playwright@4.1.10)(vite@7.3.1)
|
||||
ws: 8.21.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
- utf-8-validate
|
||||
- vite
|
||||
|
||||
'@vitest/expect@4.1.10':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/mocker@4.1.10(vite@7.3.1)':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.10
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 7.3.1
|
||||
|
||||
'@vitest/pretty-format@4.1.10':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vitest/runner@4.1.10':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.10
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.10':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.10': {}
|
||||
|
||||
'@vitest/utils@4.1.10':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
'@vscode/web-custom-data@0.4.13': {}
|
||||
|
||||
ajv@8.18.0:
|
||||
@@ -1451,6 +1760,8 @@ snapshots:
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
astral-regex@2.0.0: {}
|
||||
|
||||
balanced-match@3.0.1: {}
|
||||
@@ -1471,6 +1782,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk@2.4.2:
|
||||
dependencies:
|
||||
ansi-styles: 3.2.1
|
||||
@@ -1513,6 +1826,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cosmiconfig@9.0.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
@@ -1551,6 +1866,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-arrayish: 0.2.1
|
||||
|
||||
es-module-lexer@2.3.1: {}
|
||||
|
||||
esbuild@0.27.3:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.3
|
||||
@@ -1584,6 +1901,12 @@ snapshots:
|
||||
|
||||
escape-string-regexp@1.0.5: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-glob@3.3.3:
|
||||
@@ -1622,6 +1945,9 @@ snapshots:
|
||||
|
||||
flatted@3.3.3: {}
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -1757,6 +2083,10 @@ snapshots:
|
||||
|
||||
lodash.truncate@4.4.2: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mathml-tag-names@4.0.0: {}
|
||||
|
||||
mdn-data@2.12.2: {}
|
||||
@@ -1770,6 +2100,8 @@ snapshots:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
@@ -1778,6 +2110,8 @@ snapshots:
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
p-map@7.0.4: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
@@ -1793,12 +2127,24 @@ snapshots:
|
||||
|
||||
parse5@5.1.0: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
|
||||
playwright-core@1.62.1: {}
|
||||
|
||||
playwright@1.62.1:
|
||||
dependencies:
|
||||
playwright-core: 1.62.1
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
pngjs@7.0.0: {}
|
||||
|
||||
postcss-safe-parser@7.0.1(postcss@8.5.6):
|
||||
dependencies:
|
||||
postcss: 8.5.6
|
||||
@@ -1871,10 +2217,18 @@ snapshots:
|
||||
dependencies:
|
||||
queue-microtask: 1.2.3
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
signal-polyfill@0.2.2: {}
|
||||
|
||||
sirv@3.0.2:
|
||||
dependencies:
|
||||
'@polka/url': 1.0.0-next.29
|
||||
mrmime: 2.0.1
|
||||
totalist: 3.0.1
|
||||
|
||||
slash@5.1.0: {}
|
||||
|
||||
slice-ansi@4.0.0:
|
||||
@@ -1885,6 +2239,10 @@ snapshots:
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@4.2.0: {}
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -1979,15 +2337,23 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.3.0: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tinyrainbow@3.1.1: {}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
ts-lit-plugin@2.0.2:
|
||||
dependencies:
|
||||
lit-analyzer: 2.0.3
|
||||
@@ -2040,6 +2406,33 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
vitest@4.1.10(@vitest/browser-playwright@4.1.10)(vite@7.3.1):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(vite@7.3.1)
|
||||
'@vitest/pretty-format': 4.1.10
|
||||
'@vitest/runner': 4.1.10
|
||||
'@vitest/snapshot': 4.1.10
|
||||
'@vitest/spy': 4.1.10
|
||||
'@vitest/utils': 4.1.10
|
||||
es-module-lexer: 2.3.1
|
||||
expect-type: 1.4.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.4
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 4.2.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.3.0
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.1.1
|
||||
vite: 7.3.1
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@vitest/browser-playwright': 4.1.10(playwright@1.62.1)(vite@7.3.1)(vitest@4.1.10)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vscode-css-languageservice@3.0.13:
|
||||
dependencies:
|
||||
vscode-languageserver-types: 3.17.5
|
||||
@@ -2094,6 +2487,11 @@ snapshots:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wrap-ansi@7.0.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
@@ -2105,6 +2503,8 @@ snapshots:
|
||||
imurmurhash: 0.1.4
|
||||
signal-exit: 4.1.0
|
||||
|
||||
ws@8.21.3: {}
|
||||
|
||||
y18n@5.0.8: {}
|
||||
|
||||
yargs-parser@21.1.1: {}
|
||||
|
||||
@@ -119,19 +119,29 @@ export class PlayerControls extends LitElement {
|
||||
|
||||
return html`
|
||||
<div id="player-control-buttons">
|
||||
<button class=${shuffleClass} @click=${this.handleShuffleClick}>
|
||||
<button
|
||||
class=${shuffleClass}
|
||||
aria-label="Shuffle"
|
||||
aria-pressed=${this.shuffleMode}
|
||||
@click=${this.handleShuffleClick}
|
||||
>
|
||||
<wa-icon name="shuffle"></wa-icon>
|
||||
</button>
|
||||
<button @click=${this.handlePreviousClick}>
|
||||
<button aria-label="Previous track" @click=${this.handlePreviousClick}>
|
||||
<wa-icon name="backward-step"></wa-icon>
|
||||
</button>
|
||||
<button @click="${playOrPauseHandler}">
|
||||
<button aria-label=${this.player.isPlaying ? 'Pause' : 'Play'} @click="${playOrPauseHandler}">
|
||||
<wa-icon name=${playOrPauseIcon}></wa-icon>
|
||||
</button>
|
||||
<button @click=${this.handleNextClick}>
|
||||
<button aria-label="Next track" @click=${this.handleNextClick}>
|
||||
<wa-icon name="forward-step"></wa-icon>
|
||||
</button>
|
||||
<button class=${repeatClasses} @click=${this.handleRepeatClick}>
|
||||
<button
|
||||
class=${repeatClasses}
|
||||
aria-label=${`Repeat: ${repeatMode}`}
|
||||
aria-pressed=${repeatMode !== 'off'}
|
||||
@click=${this.handleRepeatClick}
|
||||
>
|
||||
<wa-icon name="repeat"></wa-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -155,8 +155,9 @@ export class SeekBar extends LitElement {
|
||||
|
||||
return html`
|
||||
<div id="seek-bar-container">
|
||||
<small>${elapsedTime}</small>
|
||||
<small data-testid="elapsed-time">${elapsedTime}</small>
|
||||
<wa-slider
|
||||
aria-label="Seek"
|
||||
.value="${this.seekValue}"
|
||||
max="${this.trackLength}"
|
||||
?with-tooltip="${this.hasTrack}"
|
||||
@@ -165,7 +166,7 @@ export class SeekBar extends LitElement {
|
||||
@change="${this.handleChange}"
|
||||
@input="${this.handleInput}"
|
||||
></wa-slider>
|
||||
<small>${remainingTime}</small>
|
||||
<small data-testid="remaining-time">${remainingTime}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -243,6 +243,12 @@ export class ScrollManager {
|
||||
): void {
|
||||
// Guard against stacked observers.
|
||||
this.resizeObserver?.disconnect();
|
||||
|
||||
// An empty library renders no scroll container, so the caller's
|
||||
// query returns undefined and observe() throws — asynchronously,
|
||||
// out of loadAlbums, where nothing catches it.
|
||||
if (!container) return;
|
||||
|
||||
this.currentColumnCount =
|
||||
this.getColumnCount(container);
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ export class NowPlaying extends LitElement {
|
||||
<div class="track-info">
|
||||
<span
|
||||
class="track-title ${titleScrolling ? 'will-scroll' : ''} ${this.titleScrolling ? 'scrolling' : ''}"
|
||||
data-testid="now-playing-title"
|
||||
@mouseenter=${this.handleTitleMouseEnter}
|
||||
@mouseleave=${this.handleTitleMouseLeave}
|
||||
@transitionend=${() => this.onScrollCycleEnd('title')}
|
||||
@@ -347,6 +348,7 @@ export class NowPlaying extends LitElement {
|
||||
</span>
|
||||
<span
|
||||
class="track-artist ${artistScrolling ? 'will-scroll' : ''} ${this.artistScrolling ? 'scrolling' : ''}"
|
||||
data-testid="now-playing-artist"
|
||||
@mouseenter=${this.handleArtistMouseEnter}
|
||||
@mouseleave=${this.handleArtistMouseLeave}
|
||||
@transitionend=${() => this.onScrollCycleEnd('artist')}
|
||||
|
||||
@@ -1412,6 +1412,8 @@ export class QueuePanel
|
||||
'drop-after': showAfter,
|
||||
})}
|
||||
data-index=${index}
|
||||
data-testid="queue-row"
|
||||
data-file-path=${track.filePath}
|
||||
draggable="true"
|
||||
>
|
||||
<span class="track-position">
|
||||
|
||||
@@ -215,6 +215,10 @@ export class AppSidebar extends LitElement {
|
||||
return html`
|
||||
<li
|
||||
class=${classes}
|
||||
data-testid="nav-${item.id}"
|
||||
aria-current=${this.activeView === item.id
|
||||
? 'page'
|
||||
: 'false'}
|
||||
@click=${() =>
|
||||
this.navigate(item.id)}
|
||||
@dragover=${(e: DragEvent) =>
|
||||
|
||||
@@ -1740,6 +1740,8 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
||||
})}
|
||||
draggable="true"
|
||||
data-index=${index}
|
||||
data-testid="track-row"
|
||||
data-file-path=${track.FilePath}
|
||||
>
|
||||
<div
|
||||
class=${classMap({
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 558 B |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* The app chrome: sidebar navigation, the library filter dropdown and
|
||||
* the tri-state library status indicator. All three are small, all
|
||||
* three are on screen constantly, and the sidebar's testids and
|
||||
* aria-current are what the e2e tier navigates by.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
import '@components/sidebar/app-sidebar';
|
||||
import '@components/library-filter/library-filter';
|
||||
import '@components/library-status-indicator/library-status-indicator';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, stub, flush, calls, lastArgs } from '@test/support/harness';
|
||||
import {
|
||||
fixture,
|
||||
shadow,
|
||||
shadowAll,
|
||||
texts,
|
||||
update,
|
||||
visual,
|
||||
} from '@test/support/render';
|
||||
|
||||
describe('<app-sidebar>', () => {
|
||||
it('renders a testid per destination, which is how e2e navigates', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
|
||||
expect(
|
||||
shadowAll(el, 'li').map((li) => li.getAttribute('data-testid')),
|
||||
).toEqual([
|
||||
'nav-home',
|
||||
'nav-playlists',
|
||||
'nav-artists',
|
||||
'nav-genres',
|
||||
'nav-albums',
|
||||
'nav-tracks',
|
||||
'nav-explore',
|
||||
'nav-downloads',
|
||||
'nav-autotag',
|
||||
'nav-jobs',
|
||||
'nav-settings',
|
||||
]);
|
||||
});
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
expect(current).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('announces a navigation as a composed event, so the shell hears it through the shadow root', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
const seen: string[] = [];
|
||||
|
||||
document.addEventListener('navigate', (e) => {
|
||||
seen.push((e as CustomEvent<{ view: string }>).detail.view);
|
||||
}, { once: true });
|
||||
|
||||
shadow<HTMLElement>(el, '[data-testid="nav-artists"]')?.click();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(seen).toEqual(['artists']);
|
||||
});
|
||||
|
||||
it('moves aria-current to the clicked destination', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
|
||||
shadow<HTMLElement>(el, '[data-testid="nav-genres"]')?.click();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(
|
||||
shadow(el, '[data-testid="nav-genres"]')?.getAttribute('aria-current'),
|
||||
).toBe('page');
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('app-sidebar');
|
||||
|
||||
await visual(el, 'app-sidebar');
|
||||
expect(shadowAll(el, 'li').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<library-filter>', () => {
|
||||
beforeEach(() => {
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', [
|
||||
{ id: 7, name: 'Music' },
|
||||
{ id: 8, name: 'Field Recordings' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers every library plus the merged view', async () => {
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(texts(el, 'option')).toEqual([
|
||||
'All Libraries',
|
||||
'Music',
|
||||
'Field Recordings',
|
||||
]);
|
||||
});
|
||||
|
||||
it('carries an accessible name — it is a bare select otherwise', async () => {
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
expect(shadow(el, 'select')?.getAttribute('aria-label')).toBe(
|
||||
'Library filter',
|
||||
);
|
||||
});
|
||||
|
||||
it('selects a library by id, and the merged view by empty string', async () => {
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const select = shadow<HTMLSelectElement>(el, 'select');
|
||||
|
||||
if (select) select.value = '8';
|
||||
|
||||
select?.dispatchEvent(new Event('change'));
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([8]);
|
||||
});
|
||||
|
||||
it('picks up a library added while it was on screen', async () => {
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', [
|
||||
{ id: 7, name: 'Music' },
|
||||
{ id: 8, name: 'Field Recordings' },
|
||||
{ id: 9, name: 'Podcasts' },
|
||||
]);
|
||||
emit(Events.LibraryAdded, { id: 9 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(texts(el, 'option')).toContain('Podcasts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('<library-status-indicator>', () => {
|
||||
it('defaults to "not in library"', async () => {
|
||||
const el = await fixture('library-status-indicator');
|
||||
|
||||
expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe('plus');
|
||||
});
|
||||
|
||||
it('uses a distinct glyph per state', async () => {
|
||||
const glyphs: (string | null | undefined)[] = [];
|
||||
|
||||
for (const status of ['in-library', 'queued', 'not-in-library']) {
|
||||
const el = await fixture('library-status-indicator', { status });
|
||||
|
||||
glyphs.push(shadow(el, 'wa-icon')?.getAttribute('name'));
|
||||
}
|
||||
|
||||
expect(glyphs).toEqual(['check', 'hourglass-half', 'plus']);
|
||||
});
|
||||
|
||||
it('phrases its label around the entity it describes', async () => {
|
||||
const el = await fixture('library-status-indicator', {
|
||||
status: 'in-library',
|
||||
entityType: 'album',
|
||||
label: 'Abbey Road',
|
||||
});
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
|
||||
'Album "Abbey Road" is in your library',
|
||||
);
|
||||
});
|
||||
|
||||
it('phrases an unowned entity as an invitation', async () => {
|
||||
const el = await fixture('library-status-indicator', {
|
||||
entityType: 'artist',
|
||||
label: 'Eno',
|
||||
});
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
|
||||
'Add artist "Eno" to library',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the quoted name when it has none', async () => {
|
||||
const el = await fixture('library-status-indicator', { status: 'queued' });
|
||||
|
||||
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe(
|
||||
'Track is queued for download',
|
||||
);
|
||||
});
|
||||
|
||||
it('mirrors the label into the tooltip', async () => {
|
||||
const el = await fixture('library-status-indicator', {
|
||||
status: 'in-library',
|
||||
});
|
||||
|
||||
const button = shadow(el, 'button');
|
||||
|
||||
expect(button?.getAttribute('title')).toBe(
|
||||
button?.getAttribute('aria-label'),
|
||||
);
|
||||
});
|
||||
|
||||
it('swallows the click, so it does not navigate the card it sits on', async () => {
|
||||
const el = await fixture('library-status-indicator');
|
||||
let bubbled = 0;
|
||||
|
||||
el.addEventListener('click', () => {
|
||||
bubbled += 1;
|
||||
});
|
||||
|
||||
shadow<HTMLElement>(el, 'button')?.click();
|
||||
|
||||
expect([bubbled, calls()]).toEqual([0, []]);
|
||||
});
|
||||
|
||||
it('swallows Enter and Space for the same reason', async () => {
|
||||
const el = await fixture('library-status-indicator');
|
||||
let bubbled = 0;
|
||||
|
||||
el.addEventListener('keydown', () => {
|
||||
bubbled += 1;
|
||||
});
|
||||
|
||||
for (const key of ['Enter', ' ', 'Tab']) {
|
||||
shadow(el, 'button')?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key, bubbles: true, composed: true }),
|
||||
);
|
||||
}
|
||||
|
||||
// Tab still gets through: it is navigation, not activation.
|
||||
expect(bubbled).toBe(1);
|
||||
});
|
||||
|
||||
it('honours a non-default size', async () => {
|
||||
const el = await fixture('library-status-indicator', { size: 32 });
|
||||
|
||||
expect(el.style.getPropertyValue('--indicator-size')).toBe('32px');
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('library-status-indicator', {
|
||||
status: 'in-library',
|
||||
});
|
||||
|
||||
await update(el, { size: 40 });
|
||||
await visual(el, 'library-status-indicator-in-library');
|
||||
expect(shadow(el, 'button')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<library-filter> resilience', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('renders the merged view even when the library list cannot be loaded', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', () => {
|
||||
throw new Error('database locked');
|
||||
});
|
||||
// The store caches the library list; drop it so the failing stub is
|
||||
// actually reached.
|
||||
emit(Events.LibraryRemoved, { id: 7 });
|
||||
|
||||
const el = await fixture('library-filter');
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(texts(el, 'option')).toEqual(['All Libraries']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* `<now-playing>` and `<queue-panel>` are the two components driven
|
||||
* entirely by store state rather than by their own properties: feeding
|
||||
* them backend events is how they are made to render anything at all.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import '@components/now-playing/now-playing';
|
||||
import '@components/queue-panel/queue-panel';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, calls, stub, flush, lastArgs } from '@test/support/harness';
|
||||
import {
|
||||
fixture,
|
||||
shadow,
|
||||
shadowAll,
|
||||
text,
|
||||
visual,
|
||||
} from '@test/support/render';
|
||||
import type { TrackInfo } from '@store/player-store';
|
||||
import type { QueueTrack } from '@store/queue-store';
|
||||
|
||||
const TRACK: TrackInfo = {
|
||||
fileName: 'ashes.mp3',
|
||||
filePath: '/music/ashes.mp3',
|
||||
trackLength: 215,
|
||||
seekPosition: 0,
|
||||
state: 'playing',
|
||||
title: 'Ashes to Ashes',
|
||||
artist: 'David Bowie',
|
||||
album: 'Scary Monsters',
|
||||
coverArt: '',
|
||||
coverArtSmall: '',
|
||||
coverArtMedium: '',
|
||||
coverArtLarge: '',
|
||||
trackChangeId: 1,
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
|
||||
function queueTrack(n: number, title: string): QueueTrack {
|
||||
return {
|
||||
id: n,
|
||||
audioFileId: n,
|
||||
filePath: `/music/${n}.mp3`,
|
||||
position: n,
|
||||
title,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
coverArtPath: '',
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
}
|
||||
|
||||
function setQueue(tracks: QueueTrack[], currentIndex = 0): void {
|
||||
emit(Events.QueueChanged, {
|
||||
tracks,
|
||||
currentIndex,
|
||||
shuffleMode: false,
|
||||
repeatMode: 'off',
|
||||
sourcePlaylistId: 0,
|
||||
});
|
||||
}
|
||||
|
||||
describe('<now-playing>', () => {
|
||||
beforeEach(() => {
|
||||
emit(Events.TrackChanged, null);
|
||||
});
|
||||
|
||||
it('shows only a placeholder when nothing is loaded', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
expect([
|
||||
shadow(el, '.cover-placeholder'),
|
||||
shadow(el, '[data-testid="now-playing-title"]'),
|
||||
]).toEqual([expect.anything(), null]);
|
||||
});
|
||||
|
||||
it('renders the title and artist of the loaded track', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect([
|
||||
text(el, '[data-testid="now-playing-title"]'),
|
||||
text(el, '[data-testid="now-playing-artist"]'),
|
||||
]).toEqual(['Ashes to Ashes', 'David Bowie']);
|
||||
});
|
||||
|
||||
it('names an artistless track rather than leaving the line blank', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, artist: '', trackChangeId: 2 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="now-playing-artist"]')).toBe(
|
||||
'Unknown Artist',
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers the small cover variant and falls back on error', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, {
|
||||
...TRACK,
|
||||
coverArt: '/covers/big.jpg',
|
||||
coverArtSmall: '/covers/missing.jpg',
|
||||
trackChangeId: 3,
|
||||
});
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const img = shadow<HTMLImageElement>(el, '.cover-art img');
|
||||
const initial = img?.getAttribute('src');
|
||||
|
||||
img?.dispatchEvent(new Event('error'));
|
||||
|
||||
expect([initial, img?.src.endsWith('/covers/big.jpg')]).toEqual([
|
||||
'/covers/missing.jpg',
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers a favourite toggle that names the target playlist', async () => {
|
||||
stub('playlist.Service.GetDefaultPlaylistTrackPaths', []);
|
||||
stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Loved' });
|
||||
emit(Events.PlaylistRenamed, 1);
|
||||
await flush();
|
||||
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 4 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, '.fav-btn')?.getAttribute('title')).toBe('Add to Loved');
|
||||
});
|
||||
|
||||
it('toggles the favourite through the backend', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 5 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
shadow<HTMLElement>(el, '.fav-btn')?.click();
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('playlist.Service.ToggleDefaultPlaylistTrack')).toEqual([
|
||||
'/music/ashes.mp3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('now-playing');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 6 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
await visual(el, 'now-playing');
|
||||
expect(text(el, '[data-testid="now-playing-title"]')).toBe(
|
||||
'Ashes to Ashes',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<queue-panel>', () => {
|
||||
beforeEach(() => {
|
||||
setQueue([]);
|
||||
});
|
||||
|
||||
it('says so when the queue is empty', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
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');
|
||||
|
||||
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')]);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => {
|
||||
requestAnimationFrame(() => r(null));
|
||||
});
|
||||
|
||||
const rows = shadowAll(el, '[data-testid="queue-row"]');
|
||||
|
||||
expect(rows.map((r) => r.getAttribute('data-file-path'))).toEqual([
|
||||
'/music/1.mp3',
|
||||
'/music/2.mp3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks the playing row as active', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 1);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => {
|
||||
requestAnimationFrame(() => r(null));
|
||||
});
|
||||
|
||||
const active = shadowAll(el, '[data-testid="queue-row"].active');
|
||||
|
||||
expect(active.map((r) => r.getAttribute('data-index'))).toEqual(['1']);
|
||||
});
|
||||
|
||||
it('disables the clear button on an empty queue', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
const button = shadow<HTMLButtonElement>(el, '.header-action-button');
|
||||
|
||||
expect(button?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('clears through the backend, not locally', async () => {
|
||||
const el = await fixture('queue-panel');
|
||||
|
||||
setQueue([queueTrack(1, 'First')]);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
shadow<HTMLButtonElement>(el, '.header-action-button')?.click();
|
||||
await flush();
|
||||
|
||||
expect(calls().map((c) => c.path)).toContain('queue.Queue.Clear');
|
||||
});
|
||||
|
||||
// No screenshot for the queue panel: its list is a
|
||||
// @lit-labs/virtualizer, which keeps re-measuring, so
|
||||
// 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');
|
||||
|
||||
setQueue([queueTrack(1, 'First'), queueTrack(2, 'Second')], 0);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await new Promise((r) => {
|
||||
requestAnimationFrame(() => r(null));
|
||||
});
|
||||
|
||||
expect(shadowAll(el, '[data-testid="queue-row"]').length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Every custom element in the tree, mounted against an empty backend.
|
||||
*
|
||||
* This is deliberately shallow: it asserts each component renders
|
||||
* *something* and logs no error, which is the state an agent's change
|
||||
* most often breaks and which nothing else here would notice. Depth
|
||||
* belongs in the per-component specs and in e2e; breadth belongs here,
|
||||
* because 46 elements is more than anyone will write specs for.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// One import per component module, so a component that fails to even
|
||||
// load is a failure here rather than a silent absence.
|
||||
import '@components/artist-details/artist-details';
|
||||
import '@components/artists-view/artists-view';
|
||||
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';
|
||||
import '@components/autotag-view/autotag-view';
|
||||
import '@components/combobox/combobox';
|
||||
import '@components/config-page/config-page';
|
||||
import '@components/config-page/config-field';
|
||||
import '@components/config-page/config-section';
|
||||
import '@components/config-page/download-clients';
|
||||
import '@components/config-page/shortcut-capture';
|
||||
import '@components/cover-grid/cover-grid';
|
||||
import '@components/cover-grid/album-dropdown';
|
||||
import '@components/download-picker/download-picker';
|
||||
import '@components/download-picker/candidate-row';
|
||||
import '@components/downloads-view/downloads-view';
|
||||
import '@components/duplicate-tracks-dialog/duplicate-tracks-dialog';
|
||||
import '@components/explore-album-details/explore-album-details';
|
||||
import '@components/explore-artist-details/explore-artist-details';
|
||||
import '@components/explore-view/explore-view';
|
||||
import '@components/first-run-wizard/first-run-wizard';
|
||||
import '@components/genre-details/genre-details';
|
||||
import '@components/genres-view/genres-view';
|
||||
import '@components/jobs/job-details-drawer';
|
||||
import '@components/jobs/job-indicator';
|
||||
import '@components/jobs/job-log-view';
|
||||
import '@components/jobs/job-row';
|
||||
import '@components/jobs/jobs-view';
|
||||
import '@components/library-filter/library-filter';
|
||||
import '@components/library-status-indicator/library-status-indicator';
|
||||
import '@components/now-playing/now-playing';
|
||||
import '@components/phantom-resolver/phantom-resolver';
|
||||
import '@components/playlist-details/playlist-details';
|
||||
import '@components/playlist-picker/playlist-picker';
|
||||
import '@components/playlist-view/playlist-view';
|
||||
import '@components/queue-panel/queue-panel';
|
||||
import '@components/search-bar/search-bar';
|
||||
import '@components/sidebar/app-sidebar';
|
||||
import '@components/smart-playlist-details/smart-playlist-details';
|
||||
import '@components/smart-playlist-editor/smart-playlist-editor';
|
||||
import '@components/top-results-row/top-results-row';
|
||||
import '@components/track-details/track-details';
|
||||
import '@components/track-info/track-info';
|
||||
import '@components/track-list/track-list';
|
||||
|
||||
import { flush, stub } from '@test/support/harness';
|
||||
import { fixture } from '@test/support/render';
|
||||
|
||||
/** Every element the app registers, in registration order. */
|
||||
const TAGS = [
|
||||
'album-dropdown',
|
||||
'app-sidebar',
|
||||
'artist-details',
|
||||
'artists-view',
|
||||
'audio-player',
|
||||
'autotag-view',
|
||||
'candidate-row',
|
||||
'config-field',
|
||||
'config-page',
|
||||
'config-section',
|
||||
'cover-grid',
|
||||
'download-clients',
|
||||
'download-picker',
|
||||
'downloads-view',
|
||||
'duplicate-tracks-dialog',
|
||||
'explore-album-details',
|
||||
'explore-artist-details',
|
||||
'explore-view',
|
||||
'first-run-wizard',
|
||||
'genre-details',
|
||||
'genres-view',
|
||||
'job-details-drawer',
|
||||
'job-indicator',
|
||||
'job-log-view',
|
||||
'job-row',
|
||||
'jobs-view',
|
||||
'library-filter',
|
||||
'library-status-indicator',
|
||||
'now-playing',
|
||||
'phantom-resolver',
|
||||
'player-controls',
|
||||
'playlist-details',
|
||||
'playlist-picker',
|
||||
'playlist-view',
|
||||
'queue-panel',
|
||||
'search-bar',
|
||||
'seek-bar',
|
||||
'shortcut-capture',
|
||||
'smart-playlist-details',
|
||||
'smart-playlist-editor',
|
||||
'top-results-row',
|
||||
'track-details',
|
||||
'track-info',
|
||||
'track-list',
|
||||
'volume-control',
|
||||
'yj-combobox',
|
||||
];
|
||||
|
||||
/**
|
||||
* An empty-but-valid backend. Unstubbed bindings resolve undefined,
|
||||
* which is not what Go sends — an empty list is.
|
||||
*/
|
||||
function stubEmptyBackend(): void {
|
||||
const emptyLists = [
|
||||
'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',
|
||||
];
|
||||
|
||||
for (const path of emptyLists) stub(path, []);
|
||||
|
||||
stub('config.Config.GetShortcuts', {});
|
||||
stub('config.Config.GetDownloadPreferences', {});
|
||||
stub('config.Config.GetThemeAccentColor', '#ffd43b');
|
||||
stub('config.Config.GetThemeBackgroundShade', 'dark');
|
||||
}
|
||||
|
||||
describe('every component mounts on an empty library', () => {
|
||||
let errors: unknown[][] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
stubEmptyBackend();
|
||||
errors = [];
|
||||
vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
||||
errors.push(args);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('registers every element it defines', () => {
|
||||
const missing = TAGS.filter((tag) => !customElements.get(tag));
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
for (const tag of TAGS) {
|
||||
it(`<${tag}> renders without logging an error`, async () => {
|
||||
const el = await fixture(tag);
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect({ tag, root: el.shadowRoot !== null, errors }).toEqual({
|
||||
tag,
|
||||
root: true,
|
||||
errors: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* `<track-info>` is the shared row renderer: every list that shows a
|
||||
* track goes through it, so its fallbacks (missing title, missing
|
||||
* cover, missing duration) are visible in half the app.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import '@components/track-info/track-info';
|
||||
import { fixture, shadow, text, update, visual } from '@test/support/render';
|
||||
|
||||
describe('<track-info>', () => {
|
||||
it('renders title, artist and album', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'Ashes to Ashes',
|
||||
artist: 'David Bowie',
|
||||
album: 'Scary Monsters',
|
||||
});
|
||||
|
||||
expect([text(el, '.title'), text(el, '.secondary')]).toEqual([
|
||||
'Ashes to Ashes',
|
||||
'David Bowie — Scary Monsters',
|
||||
]);
|
||||
});
|
||||
|
||||
it('joins artist and album with an em dash, and omits the separator when one is missing', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
artist: 'Only Artist',
|
||||
});
|
||||
|
||||
expect(text(el, '.secondary')).toBe('Only Artist');
|
||||
});
|
||||
|
||||
it('omits the secondary line entirely when there is nothing to put in it', async () => {
|
||||
const el = await fixture('track-info', { trackTitle: 'X' });
|
||||
|
||||
expect(shadow(el, '.secondary')).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the filename, without its extension, when untitled', async () => {
|
||||
// WAV tracks currently scan in untitled, so this path is live.
|
||||
const el = await fixture('track-info', {
|
||||
filePath: '/music/field/01 - Dawn Chorus.wav',
|
||||
});
|
||||
|
||||
expect(text(el, '.title')).toBe('01 - Dawn Chorus');
|
||||
});
|
||||
|
||||
it('handles a Windows path in the same fallback', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
filePath: 'C:\\Music\\Album\\Track.mp3',
|
||||
});
|
||||
|
||||
expect(text(el, '.title')).toBe('Track');
|
||||
});
|
||||
|
||||
it('prefers a real title over the filename', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'Real Title',
|
||||
filePath: '/music/whatever.mp3',
|
||||
});
|
||||
|
||||
expect(text(el, '.title')).toBe('Real Title');
|
||||
});
|
||||
|
||||
it('renders no title element at all when it has neither title nor path', async () => {
|
||||
const el = await fixture('track-info', { artist: 'Someone' });
|
||||
|
||||
expect(shadow(el, '.title')).toBeNull();
|
||||
});
|
||||
|
||||
it('formats a duration given in milliseconds', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
duration: '215000',
|
||||
});
|
||||
|
||||
expect(text(el, '.duration')).toBe('03:35');
|
||||
});
|
||||
|
||||
it('shows placeholder dashes for an unparseable duration', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
duration: 'unknown',
|
||||
});
|
||||
|
||||
expect(text(el, '.duration')).toBe('--:--');
|
||||
});
|
||||
|
||||
it('omits the duration column when there is no duration', async () => {
|
||||
const el = await fixture('track-info', { trackTitle: 'X' });
|
||||
|
||||
expect(shadow(el, '.duration')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no cover slot at all unless a cover was supplied', async () => {
|
||||
const el = await fixture('track-info', { trackTitle: 'X' });
|
||||
|
||||
expect(shadow(el, '.cover-art')).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers the small cover variant, which is what a row needs', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
coverArt: '/covers/big.jpg',
|
||||
coverArtSmall: '/covers/small.jpg',
|
||||
});
|
||||
|
||||
expect(shadow<HTMLImageElement>(el, '.cover-art img')?.getAttribute('src')).toBe(
|
||||
'/covers/small.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the full-size cover when the thumbnail fails to load', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
coverArt: '/covers/big.jpg',
|
||||
coverArtSmall: '/covers/missing.jpg',
|
||||
});
|
||||
|
||||
const img = shadow<HTMLImageElement>(el, '.cover-art img');
|
||||
|
||||
img?.dispatchEvent(new Event('error'));
|
||||
|
||||
expect(img?.src).toContain('/covers/big.jpg');
|
||||
});
|
||||
|
||||
it('degrades to the music-note placeholder when both covers fail', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'X',
|
||||
coverArtSmall: '/covers/missing.jpg',
|
||||
});
|
||||
|
||||
const img = shadow<HTMLImageElement>(el, '.cover-art img');
|
||||
|
||||
img?.dispatchEvent(new Event('error'));
|
||||
|
||||
expect(shadow(el, '.cover-placeholder wa-icon')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('re-renders when a property changes', async () => {
|
||||
const el = await fixture('track-info', { trackTitle: 'Before' });
|
||||
|
||||
await update(el, { trackTitle: 'After' });
|
||||
|
||||
expect(text(el, '.title')).toBe('After');
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('track-info', {
|
||||
trackTitle: 'Ashes to Ashes',
|
||||
artist: 'David Bowie',
|
||||
album: 'Scary Monsters',
|
||||
duration: '215000',
|
||||
});
|
||||
|
||||
await visual(el, 'track-info');
|
||||
expect(el.shadowRoot).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* The transport bar: five buttons and a seek bar, all of them driven by
|
||||
* backend push events rather than by their own clicks. These are the
|
||||
* controls the e2e tier drives by accessible name, so the names are as
|
||||
* much of a contract as the behaviour.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
import '@components/audio-player/controls/player-controls';
|
||||
import '@components/audio-player/seekbar/seek-bar';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, calls, lastArgs, flush } from '@test/support/harness';
|
||||
import {
|
||||
fixture,
|
||||
shadow,
|
||||
shadowAll,
|
||||
text,
|
||||
click,
|
||||
visual,
|
||||
} from '@test/support/render';
|
||||
import type { TrackInfo } from '@store/player-store';
|
||||
|
||||
const TRACK: TrackInfo = {
|
||||
fileName: 'long.mp3',
|
||||
filePath: '/music/long.mp3',
|
||||
trackLength: 90,
|
||||
seekPosition: 0,
|
||||
state: 'playing',
|
||||
title: 'Long Player',
|
||||
artist: 'Test Artist',
|
||||
album: 'Fixtures',
|
||||
coverArt: '',
|
||||
coverArtSmall: '',
|
||||
coverArtMedium: '',
|
||||
coverArtLarge: '',
|
||||
trackChangeId: 1,
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
|
||||
/** Reset the backend-owned state both components read from. */
|
||||
function idle(): void {
|
||||
emit(Events.TrackChanged, null);
|
||||
emit(Events.PlaybackStateChanged, { state: 'stopped' });
|
||||
emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'off' });
|
||||
}
|
||||
|
||||
function labelOf(host: Element, index: number): string | null {
|
||||
return shadowAll(host, 'button')[index]?.getAttribute('aria-label') ?? null;
|
||||
}
|
||||
|
||||
describe('<player-controls>', () => {
|
||||
beforeEach(() => {
|
||||
idle();
|
||||
});
|
||||
|
||||
it('names every button, so both a screen reader and a selector can find it', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
expect(shadowAll(el, 'button').map((b) => b.getAttribute('aria-label'))).toEqual(
|
||||
['Shuffle', 'Previous track', 'Play', 'Next track', 'Repeat: off'],
|
||||
);
|
||||
});
|
||||
|
||||
it('becomes a pause button while playing', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect([labelOf(el, 2), shadow(el, 'wa-icon[name="pause"]')]).not.toContain(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('asks the queue to play, not the player — the queue owns what plays next', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
await click(el, 'button[aria-label="Play"]');
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual(['queue.Queue.Play']);
|
||||
});
|
||||
|
||||
it('pauses through the player once playing', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await click(el, 'button[aria-label="Pause"]');
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual(['player.Player.Pause']);
|
||||
});
|
||||
|
||||
it('wires skip forward and back to the queue', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
await click(el, 'button[aria-label="Next track"]');
|
||||
await click(el, 'button[aria-label="Previous track"]');
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual([
|
||||
'queue.Queue.Next',
|
||||
'queue.Queue.Previous',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports shuffle state through aria-pressed, not just colour', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
const before = shadow(el, 'button[aria-label="Shuffle"]')?.getAttribute(
|
||||
'aria-pressed',
|
||||
);
|
||||
|
||||
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'off' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect([
|
||||
before,
|
||||
shadow(el, 'button[aria-label="Shuffle"]')?.getAttribute('aria-pressed'),
|
||||
]).toEqual(['false', 'true']);
|
||||
});
|
||||
|
||||
it('spells the repeat mode into the label, since one icon covers three states', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'one' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(labelOf(el, 4)).toBe('Repeat: one');
|
||||
});
|
||||
|
||||
it('marks repeat-one so its badge renders', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.QueueModeChanged, { shuffleMode: false, repeatMode: 'one' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'button.repeat-one')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not toggle its own state — the backend confirms it', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
await click(el, 'button[aria-label="Shuffle"]');
|
||||
|
||||
expect([
|
||||
calls().map((c) => c.path),
|
||||
shadow(el, 'button[aria-label="Shuffle"]')?.getAttribute('aria-pressed'),
|
||||
]).toEqual([['queue.Queue.ToggleShuffle'], 'false']);
|
||||
});
|
||||
|
||||
it('stops listening to the queue once removed', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
el.remove();
|
||||
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'all' });
|
||||
await flush();
|
||||
|
||||
// A leaked subscription would keep rendering a detached element.
|
||||
expect(el.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('player-controls');
|
||||
|
||||
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'one' });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
await visual(el, 'player-controls');
|
||||
expect(shadowAll(el, 'button')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('<seek-bar>', () => {
|
||||
beforeEach(() => {
|
||||
idle();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('shows placeholder clocks with nothing loaded', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
expect([
|
||||
text(el, '[data-testid="elapsed-time"]'),
|
||||
text(el, '[data-testid="remaining-time"]'),
|
||||
]).toEqual(['--:--', '--:--']);
|
||||
});
|
||||
|
||||
it('shows elapsed and remaining once a track is loaded', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect([
|
||||
text(el, '[data-testid="elapsed-time"]'),
|
||||
text(el, '[data-testid="remaining-time"]'),
|
||||
]).toEqual(['00:00', '01:30']);
|
||||
});
|
||||
|
||||
it('resumes mid-track from the position the backend reported', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, seekPosition: 30, trackChangeId: 2 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:30');
|
||||
});
|
||||
|
||||
it('rewinds when the same file plays again, which only the change id reveals', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, seekPosition: 45, trackChangeId: 3 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, seekPosition: 0, trackChangeId: 4 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:00');
|
||||
});
|
||||
|
||||
it('ticks the clock forward while playing', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await el.updateComplete;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:03');
|
||||
});
|
||||
|
||||
it('stops ticking when paused', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await el.updateComplete;
|
||||
|
||||
emit(Events.PlaybackStateChanged, { state: 'paused' });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await el.updateComplete;
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await el.updateComplete;
|
||||
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:02');
|
||||
});
|
||||
|
||||
it('seeks to the position the slider was dropped at', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const slider = shadow<HTMLElement & { value: number }>(el, 'wa-slider');
|
||||
|
||||
if (slider) slider.value = 42;
|
||||
|
||||
slider?.dispatchEvent(new Event('change'));
|
||||
await el.updateComplete;
|
||||
|
||||
expect(lastArgs('player.Player.Seek')).toEqual([42]);
|
||||
});
|
||||
|
||||
it('bounds the slider by the track length', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(shadow(el, 'wa-slider')?.getAttribute('max')).toBe('90');
|
||||
});
|
||||
|
||||
it('carries an accessible name, since it is otherwise an unlabelled slider', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
expect(shadow(el, 'wa-slider')?.getAttribute('aria-label')).toBe('Seek');
|
||||
});
|
||||
|
||||
it('looks the way it did last time', async () => {
|
||||
const el = await fixture('seek-bar');
|
||||
|
||||
emit(Events.TrackChanged, { ...TRACK, seekPosition: 30, trackChangeId: 9 });
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
await visual(el, 'seek-bar');
|
||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:30');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Self-tests for the component tier, in the spirit of e2e/specs/
|
||||
* harness.spec.ts: prove the rig is what it claims before trusting a
|
||||
* single assertion built on it.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { Events } from '../src/events';
|
||||
import { emit, calls, wails, flush } from '@test/support/harness';
|
||||
// Importing a store must be enough to make it start listening.
|
||||
import { queueStore } from '@store/queue-store';
|
||||
|
||||
describe('component-tier harness', () => {
|
||||
it('runs in a real browser with a real shadow DOM', () => {
|
||||
const host = document.createElement('div');
|
||||
|
||||
host.attachShadow({ mode: 'open' }).innerHTML = '<b>x</b>';
|
||||
|
||||
expect(host.shadowRoot?.querySelector('b')?.textContent).toBe('x');
|
||||
});
|
||||
|
||||
it('routes generated bindings through the fake, not a module mock', async () => {
|
||||
// The import path under test is the real generated stub, which does
|
||||
// window['go']['queue']['Queue']['GetState']().
|
||||
const Queue = await import('@go/queue/Queue');
|
||||
|
||||
wails.stub('queue.Queue.GetState', { currentIndex: 4 });
|
||||
|
||||
await expect(Queue.GetState()).resolves.toEqual({ currentIndex: 4 });
|
||||
expect(calls('queue.Queue.GetState')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resolves an unstubbed binding instead of hanging', async () => {
|
||||
const Queue = await import('@go/queue/Queue');
|
||||
|
||||
// The real trap this pays for is the reverse: a *real* binding
|
||||
// called with wrong argument types never settles. Here, silence is
|
||||
// an immediate undefined so a test fails on the assertion rather
|
||||
// than on a timeout.
|
||||
await expect(Queue.Play()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('registers listeners merely by importing a store', () => {
|
||||
expect(wails.listenerNames()).toContain(Events.QueueChanged);
|
||||
});
|
||||
|
||||
it('delivers events to store listeners with their payload', () => {
|
||||
emit(Events.QueueIndexChanged, { currentIndex: 11 });
|
||||
|
||||
expect(queueStore.getState().currentIndex).toBe(11);
|
||||
});
|
||||
|
||||
it('expires a once-listener after a single delivery', () => {
|
||||
let fired = 0;
|
||||
|
||||
wails.on('SyntheticEvent', () => {
|
||||
fired += 1;
|
||||
}, 1);
|
||||
|
||||
wails.notify('SyntheticEvent', []);
|
||||
wails.notify('SyntheticEvent', []);
|
||||
|
||||
expect(fired).toBe(1);
|
||||
});
|
||||
|
||||
it('notifies local listeners on a frontend-side EventsEmit', async () => {
|
||||
// Wails' own runtime notifies JS listeners before it notifies Go
|
||||
// (desktop/events.js), so a frontend emit is observable in-page.
|
||||
const { EventsEmit } = await import('@runtime/runtime');
|
||||
let seen: unknown;
|
||||
|
||||
wails.on('SyntheticEmit', (data) => {
|
||||
seen = data;
|
||||
}, -1);
|
||||
|
||||
EventsEmit('SyntheticEmit', 42);
|
||||
|
||||
expect(seen).toBe(42);
|
||||
});
|
||||
|
||||
it('flushes the microtask queue stores notify on', async () => {
|
||||
let notified = false;
|
||||
|
||||
const off = queueStore.subscribe(() => {
|
||||
notified = true;
|
||||
});
|
||||
|
||||
emit(Events.QueueIndexChanged, { currentIndex: 1 });
|
||||
const beforeFlush = notified;
|
||||
|
||||
await flush();
|
||||
off();
|
||||
|
||||
expect([beforeFlush, notified]).toEqual([false, true]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Runs before every test module. Two jobs, in this order:
|
||||
*
|
||||
* 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 `<wa-icon>`
|
||||
* silently 404s and screenshots come out with holes in them.
|
||||
*/
|
||||
import { afterEach, beforeEach } from 'vitest';
|
||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||
import { installWailsFake, wails } from './support/wails-fake';
|
||||
import { resetHarness } from './support/harness';
|
||||
import { cleanupFixtures } from './support/render';
|
||||
|
||||
installWailsFake();
|
||||
|
||||
/**
|
||||
* A few stores read config in their constructor, which runs when a test
|
||||
* module imports them — before any test has had a chance to stub. An
|
||||
* unstubbed binding resolves undefined, and `themeStore` in particular
|
||||
* then derives a colour ramp from `undefined` and throws inside its own
|
||||
* failure handler. These defaults keep import-time loads on the happy
|
||||
* path; tests still stub whatever they assert on.
|
||||
*/
|
||||
const importTimeDefaults: Array<[string, unknown]> = [
|
||||
['config.Config.GetThemeAccentColor', '#ffd43b'],
|
||||
['config.Config.GetThemeBackgroundShade', 'dark'],
|
||||
['config.Config.GetShortcuts', {}],
|
||||
// libraryStore and playlistStore fetch eagerly at import. Left
|
||||
// unstubbed they would cache `undefined` — not the empty list Go
|
||||
// sends — and every consumer would then crash on `.length`.
|
||||
['library.Library.GetAllTracks', []],
|
||||
['library.Library.GetAllAlbums', []],
|
||||
['library.Library.GetAllArtists', []],
|
||||
['library.Library.GetAllGenresWithCounts', []],
|
||||
['library.Library.GetAllLibrariesWithTrackCounts', []],
|
||||
['playlist.Service.GetAllPlaylistsWithTracks', []],
|
||||
];
|
||||
|
||||
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.
|
||||
setBasePath('/node_modules/@awesome.me/webawesome/dist');
|
||||
|
||||
// index.ts imports the theme store for its side effect: it derives the
|
||||
// --yj-* custom properties and applies them to :root, where every
|
||||
// shadow root inherits them. Without it a component renders white text
|
||||
// on a white page and screenshots come out blank.
|
||||
await import('@store/theme-store');
|
||||
|
||||
// The app's surface colours come from index.css, whose grid layout is
|
||||
// not wanted here — take the two declarations that matter.
|
||||
document.body.style.backgroundColor = 'var(--yj-bg-base, black)';
|
||||
document.body.style.color = 'var(--yj-text-primary, white)';
|
||||
document.body.style.margin = '0';
|
||||
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanupFixtures();
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* The download store is mostly event-driven refreshes plus a set of
|
||||
* pure formatters the downloads list and the picker share — they exist
|
||||
* so those two views cannot disagree about what a state is called, and
|
||||
* that only holds if both are tested against the same expectations.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import {
|
||||
downloadStore,
|
||||
isDownloadTerminal,
|
||||
stateLabel,
|
||||
scorePercent,
|
||||
candidateSummary,
|
||||
formatBytes,
|
||||
type DownloadView,
|
||||
type DownloadCandidate,
|
||||
type DownloadProvider,
|
||||
type Request,
|
||||
} from '@store/download-store';
|
||||
import { Events } from '../../src/events';
|
||||
import {
|
||||
emit,
|
||||
calls,
|
||||
stub,
|
||||
flush,
|
||||
lastArgs,
|
||||
resetHarness,
|
||||
} from '@test/support/harness';
|
||||
|
||||
function view(id: string, state: string): DownloadView {
|
||||
return { id, state } as DownloadView;
|
||||
}
|
||||
|
||||
function provider(id: number, enabled: boolean): DownloadProvider {
|
||||
return { id, name: `p${id}`, enabled, kind: 'test' } as DownloadProvider;
|
||||
}
|
||||
|
||||
function request(overrides: Partial<Request>): Request {
|
||||
return {
|
||||
id: 1,
|
||||
mbid: 'abc',
|
||||
entity: 'release-group',
|
||||
state: 'wanted',
|
||||
...overrides,
|
||||
} as Request;
|
||||
}
|
||||
|
||||
function candidate(overrides: Partial<DownloadCandidate>): DownloadCandidate {
|
||||
return { id: 'c', totalSize: 0, origin: '', ...overrides } as DownloadCandidate;
|
||||
}
|
||||
|
||||
describe('download formatters', () => {
|
||||
it('treats complete, cancelled and failed as terminal', () => {
|
||||
expect(
|
||||
['complete', 'cancelled', 'failed', 'grabbing'].map((s) =>
|
||||
isDownloadTerminal(view('d', s)),
|
||||
),
|
||||
).toEqual([true, true, true, false]);
|
||||
});
|
||||
|
||||
it('labels every lifecycle state in the user’s terms', () => {
|
||||
expect([
|
||||
stateLabel('found'),
|
||||
stateLabel('grabbing'),
|
||||
stateLabel('importing'),
|
||||
]).toEqual(['Waiting for you to choose', 'Downloading', 'Importing']);
|
||||
});
|
||||
|
||||
it('passes an unknown state through rather than showing a blank', () => {
|
||||
expect(stateLabel('teleporting')).toBe('teleporting');
|
||||
});
|
||||
|
||||
it('rounds a score to whole percent', () => {
|
||||
expect([scorePercent(0), scorePercent(0.876), scorePercent(1)]).toEqual([
|
||||
'0%',
|
||||
'88%',
|
||||
'100%',
|
||||
]);
|
||||
});
|
||||
|
||||
it('scales bytes to the largest unit that fits', () => {
|
||||
expect([
|
||||
formatBytes(512),
|
||||
formatBytes(1024),
|
||||
formatBytes(5.5 * 1024 * 1024),
|
||||
formatBytes(20 * 1024 * 1024 * 1024),
|
||||
]).toEqual(['512 B', '1.0 KB', '5.5 MB', '20 GB']);
|
||||
});
|
||||
|
||||
it('renders no size at all rather than "0 B"', () => {
|
||||
expect([formatBytes(0), formatBytes(-1)]).toEqual(['', '']);
|
||||
});
|
||||
|
||||
it('summarises a single-format candidate', () => {
|
||||
expect(
|
||||
candidateSummary(
|
||||
candidate({
|
||||
files: [
|
||||
{ isAudio: true, format: 'flac' },
|
||||
{ isAudio: true, format: 'flac' },
|
||||
{ isAudio: false, format: 'jpg' },
|
||||
],
|
||||
totalSize: 300 * 1024 * 1024,
|
||||
origin: 'Example',
|
||||
} as Partial<DownloadCandidate>),
|
||||
),
|
||||
).toBe('FLAC · 2 tracks · 300 MB · Example');
|
||||
});
|
||||
|
||||
it('flags a mixed-format candidate instead of naming one format', () => {
|
||||
expect(
|
||||
candidateSummary(
|
||||
candidate({
|
||||
files: [
|
||||
{ isAudio: true, format: 'flac' },
|
||||
{ isAudio: true, format: 'mp3' },
|
||||
],
|
||||
} as Partial<DownloadCandidate>),
|
||||
),
|
||||
).toBe('Mixed formats · 2 tracks');
|
||||
});
|
||||
|
||||
it('singularises a one-track candidate', () => {
|
||||
expect(
|
||||
candidateSummary(
|
||||
candidate({
|
||||
files: [{ isAudio: true, format: 'mp3' }],
|
||||
} as Partial<DownloadCandidate>),
|
||||
),
|
||||
).toBe('MP3 · 1 track');
|
||||
});
|
||||
|
||||
it('survives a candidate with no file list at all', () => {
|
||||
expect(candidateSummary(candidate({}))).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('download store: event-driven refresh', () => {
|
||||
beforeEach(async () => {
|
||||
stub('download.Service.ListProviders', []);
|
||||
stub('download.Service.ListDownloads', []);
|
||||
stub('download.Service.ListRequests', []);
|
||||
stub('download.Service.ProviderKinds', []);
|
||||
await downloadStore.init();
|
||||
await flush();
|
||||
resetHarness();
|
||||
stub('download.Service.ListProviders', [
|
||||
provider(1, true),
|
||||
provider(2, false),
|
||||
]);
|
||||
stub('download.Service.ListDownloads', [
|
||||
view('a', 'grabbing'),
|
||||
view('b', 'complete'),
|
||||
]);
|
||||
stub('download.Service.ListRequests', [
|
||||
request({ id: 1, mbid: 'abc', state: 'wanted' }),
|
||||
request({ id: 2, mbid: 'def', state: 'satisfied' }),
|
||||
request({ id: 3, mbid: 'ghi', entity: 'artist' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reloads providers when the backend says they changed', async () => {
|
||||
emit(Events.DownloadProvidersChanged);
|
||||
await flush();
|
||||
|
||||
expect(downloadStore.providers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reloads downloads on DownloadsChanged', async () => {
|
||||
emit(Events.DownloadsChanged);
|
||||
await flush();
|
||||
|
||||
expect(downloadStore.downloads.map((d) => d.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('reloads requests, which change without the user doing anything', async () => {
|
||||
// A background reconcile pass expands an artist or retires a want,
|
||||
// so the list is push-driven rather than fetched on mount.
|
||||
emit(Events.RequestsChanged);
|
||||
await flush();
|
||||
|
||||
expect(downloadStore.requests).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('offers downloading only when a provider is enabled', async () => {
|
||||
emit(Events.DownloadProvidersChanged);
|
||||
await flush();
|
||||
const withEnabled = downloadStore.available;
|
||||
|
||||
stub('download.Service.ListProviders', [provider(2, false)]);
|
||||
emit(Events.DownloadProvidersChanged);
|
||||
await flush();
|
||||
|
||||
expect([withEnabled, downloadStore.available]).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('separates active downloads from finished ones', async () => {
|
||||
emit(Events.DownloadsChanged);
|
||||
await flush();
|
||||
|
||||
expect(downloadStore.activeDownloads.map((d) => d.id)).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('separates outstanding requests and artist subscriptions', async () => {
|
||||
emit(Events.RequestsChanged);
|
||||
await flush();
|
||||
|
||||
expect({
|
||||
active: downloadStore.activeRequests.map((r) => r.id),
|
||||
subscriptions: downloadStore.subscriptions.map((r) => r.id),
|
||||
}).toEqual({ active: [1, 3], subscriptions: [3] });
|
||||
});
|
||||
|
||||
it('survives a null list, which Go sends when nothing exists', async () => {
|
||||
stub('download.Service.ListDownloads', null);
|
||||
emit(Events.DownloadsChanged);
|
||||
await flush();
|
||||
|
||||
expect(downloadStore.downloads).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the last good list when a refresh fails', async () => {
|
||||
emit(Events.DownloadsChanged);
|
||||
await flush();
|
||||
|
||||
stub('download.Service.ListDownloads', () => {
|
||||
throw new Error('backend down');
|
||||
});
|
||||
emit(Events.DownloadsChanged);
|
||||
await flush();
|
||||
|
||||
expect(downloadStore.downloads).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('coalesces three refreshes into one notification', async () => {
|
||||
let notifications = 0;
|
||||
const off = downloadStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
emit(Events.DownloadProvidersChanged);
|
||||
emit(Events.DownloadsChanged);
|
||||
emit(Events.RequestsChanged);
|
||||
await flush();
|
||||
off();
|
||||
|
||||
expect(notifications).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download store: request lookup', () => {
|
||||
beforeEach(async () => {
|
||||
stub('download.Service.ListRequests', [
|
||||
request({ id: 1, mbid: 'abc-123', state: 'wanted' }),
|
||||
]);
|
||||
emit(Events.RequestsChanged);
|
||||
await flush();
|
||||
resetHarness();
|
||||
stub('download.Service.ListRequests', [
|
||||
request({ id: 1, mbid: 'abc-123', state: 'wanted' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers from the cached list, synchronously enough to render with', () => {
|
||||
expect([
|
||||
downloadStore.isRequested('abc-123'),
|
||||
downloadStore.isRequested('nope'),
|
||||
]).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('normalises case and whitespace in the MBID it is given', () => {
|
||||
expect(downloadStore.isRequested(' ABC-123 ')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the request itself for the caller that needs its state', () => {
|
||||
expect(downloadStore.requestFor('abc-123')?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('download store: writes refresh what they changed', () => {
|
||||
beforeEach(async () => {
|
||||
stub('download.Service.ListProviders', []);
|
||||
stub('download.Service.ListDownloads', []);
|
||||
stub('download.Service.ListRequests', []);
|
||||
await flush();
|
||||
resetHarness();
|
||||
stub('download.Service.ListProviders', []);
|
||||
stub('download.Service.ListDownloads', []);
|
||||
stub('download.Service.ListRequests', []);
|
||||
});
|
||||
|
||||
it('refreshes providers after adding one', async () => {
|
||||
stub('download.Service.AddProvider', 5);
|
||||
|
||||
await expect(
|
||||
downloadStore.addProvider('sab', 'Local', { url: 'http://x' }),
|
||||
).resolves.toBe(5);
|
||||
expect(calls().map((c) => c.path)).toEqual([
|
||||
'download.Service.AddProvider',
|
||||
'download.Service.ListProviders',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refreshes downloads after picking a candidate', async () => {
|
||||
await downloadStore.pick('d1', 'c1');
|
||||
|
||||
expect([
|
||||
lastArgs('download.Service.Pick'),
|
||||
calls('download.Service.ListDownloads'),
|
||||
]).toEqual([['d1', 'c1'], [{ path: 'download.Service.ListDownloads', args: [50] }]]);
|
||||
});
|
||||
|
||||
it('refreshes requests after removing one', async () => {
|
||||
await downloadStore.removeRequest(3);
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual([
|
||||
'download.Service.RemoveRequest',
|
||||
'download.Service.ListRequests',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refreshes both lists after a manual reconcile, since it can start downloads', async () => {
|
||||
stub('download.Service.ReconcileRequests', { added: 1 });
|
||||
|
||||
await downloadStore.reconcileRequests();
|
||||
|
||||
expect(calls().map((c) => c.path).sort()).toEqual([
|
||||
'download.Service.ListDownloads',
|
||||
'download.Service.ListRequests',
|
||||
'download.Service.ReconcileRequests',
|
||||
]);
|
||||
});
|
||||
|
||||
it('propagates a provider test failure, which is the user’s only clue', async () => {
|
||||
stub('download.Service.TestProvider', () => {
|
||||
throw new Error('connection refused');
|
||||
});
|
||||
|
||||
await expect(downloadStore.testProvider(1)).rejects.toThrow(
|
||||
'connection refused',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Favourites is the one store that updates optimistically: the heart
|
||||
* fills before Go has agreed. So the interesting cases are the reverts,
|
||||
* and the set of playlist events that force a reload — a default
|
||||
* playlist edited elsewhere has to show up here.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import { favoritesStore } from '@store/favorites-store';
|
||||
import { Events } from '../../src/events';
|
||||
import {
|
||||
emit,
|
||||
calls,
|
||||
stub,
|
||||
stubFailure,
|
||||
flush,
|
||||
lastArgs,
|
||||
resetHarness,
|
||||
} from '@test/support/harness';
|
||||
|
||||
const PATHS = ['/music/a.mp3', '/music/b.mp3'];
|
||||
|
||||
function stubReads(paths: string[] = PATHS): void {
|
||||
stub('playlist.Service.GetDefaultPlaylistTrackPaths', paths);
|
||||
stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Loved' });
|
||||
stub('config.Config.GetFavoritesPlaylistID', 3);
|
||||
stub('config.Config.GetFavoritesIconStyle', 'star');
|
||||
stub('config.Config.GetPinDefaultPlaylist', false);
|
||||
}
|
||||
|
||||
/** Push a config change and let the reloads it triggers settle. */
|
||||
async function reload(paths: string[] = PATHS): Promise<void> {
|
||||
stubReads(paths);
|
||||
emit(Events.FavoritesConfigChanged, {
|
||||
PlaylistID: 3,
|
||||
IconStyle: 'star',
|
||||
PinDefault: false,
|
||||
});
|
||||
await flush();
|
||||
resetHarness();
|
||||
stubReads(paths);
|
||||
}
|
||||
|
||||
describe('favorites store: cached membership', () => {
|
||||
beforeEach(async () => {
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('reports membership by file path', () => {
|
||||
expect([
|
||||
favoritesStore.isFavorited('/music/a.mp3'),
|
||||
favoritesStore.isFavorited('/music/z.mp3'),
|
||||
]).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('requires every path for a multi-selection to count as favourited', () => {
|
||||
expect([
|
||||
favoritesStore.allFavorited(PATHS),
|
||||
favoritesStore.allFavorited([...PATHS, '/music/z.mp3']),
|
||||
]).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('treats an empty selection as not favourited, so the button is not lit for nothing', () => {
|
||||
expect(favoritesStore.allFavorited([])).toBe(false);
|
||||
});
|
||||
|
||||
it('adopts the config the backend pushed', () => {
|
||||
expect([
|
||||
favoritesStore.getPlaylistId(),
|
||||
favoritesStore.getIconStyle(),
|
||||
favoritesStore.getPinDefault(),
|
||||
]).toEqual([3, 'star', false]);
|
||||
});
|
||||
|
||||
it('resolves the playlist name from the backend', () => {
|
||||
expect(favoritesStore.getPlaylistName()).toBe('Loved');
|
||||
});
|
||||
});
|
||||
|
||||
describe('favorites store: optimistic writes', () => {
|
||||
beforeEach(async () => {
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('fills the heart before the backend answers', () => {
|
||||
void favoritesStore.toggleFavorite('/music/z.mp3');
|
||||
|
||||
expect(favoritesStore.isFavorited('/music/z.mp3')).toBe(true);
|
||||
});
|
||||
|
||||
it('reverts an add the backend rejected', async () => {
|
||||
stubFailure('playlist.Service.ToggleDefaultPlaylistTrack');
|
||||
|
||||
await favoritesStore.toggleFavorite('/music/z.mp3');
|
||||
|
||||
expect(favoritesStore.isFavorited('/music/z.mp3')).toBe(false);
|
||||
});
|
||||
|
||||
it('reverts a removal the backend rejected', async () => {
|
||||
stubFailure('playlist.Service.ToggleDefaultPlaylistTrack');
|
||||
|
||||
await favoritesStore.toggleFavorite('/music/a.mp3');
|
||||
|
||||
expect(favoritesStore.isFavorited('/music/a.mp3')).toBe(true);
|
||||
});
|
||||
|
||||
it('adds a batch optimistically and forwards the whole list', async () => {
|
||||
await favoritesStore.addToFavorites(['/music/y.mp3', '/music/z.mp3']);
|
||||
|
||||
expect([
|
||||
favoritesStore.allFavorited(['/music/y.mp3', '/music/z.mp3']),
|
||||
lastArgs('playlist.Service.AddToDefaultPlaylist'),
|
||||
]).toEqual([true, [['/music/y.mp3', '/music/z.mp3']]]);
|
||||
});
|
||||
|
||||
it('removes a batch optimistically', async () => {
|
||||
await favoritesStore.removeFromFavorites(['/music/a.mp3']);
|
||||
|
||||
expect(favoritesStore.isFavorited('/music/a.mp3')).toBe(false);
|
||||
});
|
||||
|
||||
it('resyncs from the backend when a batch write fails, rather than guessing', async () => {
|
||||
stubFailure('playlist.Service.AddToDefaultPlaylist');
|
||||
|
||||
await favoritesStore.addToFavorites(['/music/z.mp3']);
|
||||
await flush();
|
||||
|
||||
expect(
|
||||
calls('playlist.Service.GetDefaultPlaylistTrackPaths'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('favorites store: reacting to playlist changes', () => {
|
||||
beforeEach(async () => {
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('reloads when the default playlist itself changed', async () => {
|
||||
emit(Events.PlaylistTracksChanged, 3);
|
||||
await flush();
|
||||
|
||||
expect(
|
||||
calls('playlist.Service.GetDefaultPlaylistTrackPaths'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores changes to some other playlist', async () => {
|
||||
emit(Events.PlaylistTracksChanged, 99);
|
||||
await flush();
|
||||
|
||||
expect(
|
||||
calls('playlist.Service.GetDefaultPlaylistTrackPaths'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reloads after a restore, which rewrites every playlist', async () => {
|
||||
emit(Events.PlaylistsRestored);
|
||||
await flush();
|
||||
|
||||
expect(
|
||||
calls('playlist.Service.GetDefaultPlaylistTrackPaths'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('re-reads the name when a playlist is renamed', async () => {
|
||||
stub('playlist.Service.GetDefaultPlaylistInfo', { Name: 'Renamed' });
|
||||
emit(Events.PlaylistRenamed, 3);
|
||||
await flush();
|
||||
|
||||
expect(favoritesStore.getPlaylistName()).toBe('Renamed');
|
||||
});
|
||||
|
||||
it('falls back to "Favorites" when no default playlist is configured', async () => {
|
||||
await favoritesStore.setDefaultPlaylist(0);
|
||||
|
||||
expect(favoritesStore.getPlaylistName()).toBe('Favorites');
|
||||
});
|
||||
|
||||
it('persists a changed icon style', async () => {
|
||||
await favoritesStore.setIconStyle('heart');
|
||||
|
||||
expect([
|
||||
favoritesStore.getIconStyle(),
|
||||
lastArgs('config.Config.SetFavoritesIconStyle'),
|
||||
]).toEqual(['heart', ['heart']]);
|
||||
});
|
||||
|
||||
it('persists the pin setting', async () => {
|
||||
await favoritesStore.setPinDefault(true);
|
||||
|
||||
expect(lastArgs('config.Config.SetPinDefaultPlaylist')).toEqual([true]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* The job store mirrors the backend registry from full snapshots, so
|
||||
* the derivations on top of it — which jobs count as active, whether
|
||||
* the indicator should be up, the linger after the last job finishes —
|
||||
* are where the behaviour lives.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
jobStore,
|
||||
isTerminal,
|
||||
isActive,
|
||||
isIndeterminate,
|
||||
progressFraction,
|
||||
type Job,
|
||||
} from '@store/job-store';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, calls, stub, flush } from '@test/support/harness';
|
||||
|
||||
function job(overrides: Partial<Job> & { id: string }): Job {
|
||||
return {
|
||||
kind: 'library-scan',
|
||||
state: 'running',
|
||||
title: 'Scanning',
|
||||
current: 0,
|
||||
total: 0,
|
||||
...overrides,
|
||||
} as Job;
|
||||
}
|
||||
|
||||
/** Push a full snapshot, which is all the backend ever sends. */
|
||||
function snapshot(jobs: Job[]): void {
|
||||
emit(Events.JobsChanged, jobs);
|
||||
}
|
||||
|
||||
describe('job predicates', () => {
|
||||
it('treats complete, cancelled and error as terminal', () => {
|
||||
const states = ['complete', 'cancelled', 'error'] as const;
|
||||
|
||||
expect(
|
||||
states.map((state) => isTerminal(job({ id: state, state }))),
|
||||
).toEqual([true, true, true]);
|
||||
});
|
||||
|
||||
it('treats every in-flight state, including paused, as active', () => {
|
||||
const states = ['queued', 'running', 'pausing', 'paused', 'cancelling'];
|
||||
|
||||
expect(states.map((state) => isActive(job({ id: state, state })))).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls a job with no denominator indeterminate', () => {
|
||||
expect([
|
||||
isIndeterminate(job({ id: 'a', total: 0 })),
|
||||
isIndeterminate(job({ id: 'b', total: -1 })),
|
||||
isIndeterminate(job({ id: 'c', total: 10 })),
|
||||
]).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it('has no progress fraction when indeterminate', () => {
|
||||
expect(progressFraction(job({ id: 'a', total: 0, current: 5 }))).toBeNull();
|
||||
});
|
||||
|
||||
it('clamps a progress fraction that overshoots its total', () => {
|
||||
expect([
|
||||
progressFraction(job({ id: 'a', current: 5, total: 10 })),
|
||||
progressFraction(job({ id: 'b', current: 30, total: 10 })),
|
||||
progressFraction(job({ id: 'c', current: -5, total: 10 })),
|
||||
]).toEqual([0.5, 1, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job store: snapshots', () => {
|
||||
beforeEach(() => {
|
||||
snapshot([]);
|
||||
});
|
||||
|
||||
it('partitions a snapshot by state', () => {
|
||||
snapshot([
|
||||
job({ id: 'r', state: 'running' }),
|
||||
job({ id: 'q', state: 'queued' }),
|
||||
job({ id: 'p', state: 'paused' }),
|
||||
job({ id: 'e', state: 'error' }),
|
||||
job({ id: 'c', state: 'complete' }),
|
||||
]);
|
||||
|
||||
expect({
|
||||
working: jobStore.workingJobs.map((j) => j.id),
|
||||
paused: jobStore.pausedJobs.map((j) => j.id),
|
||||
failed: jobStore.failedJobs.map((j) => j.id),
|
||||
finished: jobStore.finishedJobs.map((j) => j.id),
|
||||
active: jobStore.activeJobs.map((j) => j.id),
|
||||
}).toEqual({
|
||||
working: ['r', 'q'],
|
||||
paused: ['p'],
|
||||
failed: ['e'],
|
||||
finished: ['e', 'c'],
|
||||
active: ['r', 'q', 'p'],
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a null snapshot, which Go sends for an empty registry', () => {
|
||||
emit(Events.JobsChanged, null);
|
||||
|
||||
expect(jobStore.jobs).toEqual([]);
|
||||
});
|
||||
|
||||
it('replaces rather than merges, so a removed job disappears', () => {
|
||||
snapshot([job({ id: 'a' }), job({ id: 'b' })]);
|
||||
snapshot([job({ id: 'b' })]);
|
||||
|
||||
expect(jobStore.jobs.map((j) => j.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('finds a job by id', () => {
|
||||
snapshot([job({ id: 'a', title: 'Indexing' })]);
|
||||
|
||||
expect(jobStore.getJob('a')?.title).toBe('Indexing');
|
||||
});
|
||||
|
||||
it('fetches the initial snapshot at most once', async () => {
|
||||
stub('jobs.Service.GetJobs', [job({ id: 'a' })]);
|
||||
|
||||
await jobStore.init();
|
||||
await jobStore.init();
|
||||
|
||||
expect(calls('jobs.Service.GetJobs').length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job store: indicator linger', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Emptying the registry is itself "the last job finished", so it
|
||||
// starts a linger; run it out before the test begins.
|
||||
snapshot([]);
|
||||
vi.advanceTimersByTime(4000);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps the indicator up briefly after the last job finishes', () => {
|
||||
snapshot([job({ id: 'a', state: 'running' })]);
|
||||
snapshot([job({ id: 'a', state: 'complete' })]);
|
||||
|
||||
const immediatelyAfter = jobStore.shouldShowIndicator;
|
||||
|
||||
vi.advanceTimersByTime(4000);
|
||||
|
||||
expect([immediatelyAfter, jobStore.shouldShowIndicator]).toEqual([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('cancels the linger when new work starts', () => {
|
||||
snapshot([job({ id: 'a', state: 'running' })]);
|
||||
snapshot([job({ id: 'a', state: 'complete' })]);
|
||||
snapshot([
|
||||
job({ id: 'a', state: 'complete' }),
|
||||
job({ id: 'b', state: 'running' }),
|
||||
]);
|
||||
|
||||
vi.advanceTimersByTime(4000);
|
||||
|
||||
// Still up, because b is still working — the linger timer must not
|
||||
// hide the indicator out from under it.
|
||||
expect(jobStore.shouldShowIndicator).toBe(true);
|
||||
});
|
||||
|
||||
it('does not linger for a snapshot that was never active', () => {
|
||||
snapshot([job({ id: 'a', state: 'complete' })]);
|
||||
|
||||
expect(jobStore.shouldShowIndicator).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job store: logs', () => {
|
||||
beforeEach(() => {
|
||||
snapshot([]);
|
||||
});
|
||||
|
||||
it('is empty until a log is fetched', () => {
|
||||
expect(jobStore.cachedLog('a')).toEqual([]);
|
||||
});
|
||||
|
||||
it('caches a fetched log', async () => {
|
||||
stub('jobs.Service.GetJobLog', [{ level: 'warn', message: 'skipped' }]);
|
||||
snapshot([job({ id: 'a' })]);
|
||||
|
||||
await jobStore.loadLog('a');
|
||||
|
||||
expect(jobStore.cachedLog('a')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns an empty log rather than throwing when the fetch fails', async () => {
|
||||
stub('jobs.Service.GetJobLog', () => {
|
||||
throw new Error('gone');
|
||||
});
|
||||
|
||||
await expect(jobStore.loadLog('a')).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('drops cached logs for jobs the backend has forgotten', async () => {
|
||||
stub('jobs.Service.GetJobLog', [{ level: 'info', message: 'x' }]);
|
||||
snapshot([job({ id: 'a' })]);
|
||||
await jobStore.loadLog('a');
|
||||
|
||||
snapshot([job({ id: 'b' })]);
|
||||
|
||||
expect(jobStore.cachedLog('a')).toEqual([]);
|
||||
});
|
||||
|
||||
it('forgets a dismissed job log without waiting for the next snapshot', async () => {
|
||||
stub('jobs.Service.GetJobLog', [{ level: 'info', message: 'x' }]);
|
||||
snapshot([job({ id: 'a' })]);
|
||||
await jobStore.loadLog('a');
|
||||
|
||||
await jobStore.dismiss('a');
|
||||
|
||||
expect(jobStore.cachedLog('a')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job store: controls', () => {
|
||||
beforeEach(() => {
|
||||
snapshot([]);
|
||||
});
|
||||
|
||||
it('forwards each control to its bound method with the job id', async () => {
|
||||
await jobStore.pause('a');
|
||||
await jobStore.resume('a');
|
||||
await jobStore.cancel('a');
|
||||
|
||||
expect(calls().map((c) => [c.path, c.args])).toEqual([
|
||||
['jobs.Service.PauseJob', ['a']],
|
||||
['jobs.Service.ResumeJob', ['a']],
|
||||
['jobs.Service.CancelJob', ['a']],
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears logs for every finished job when they are cleared', async () => {
|
||||
stub('jobs.Service.GetJobLog', [{ level: 'info', message: 'x' }]);
|
||||
snapshot([job({ id: 'a', state: 'complete' })]);
|
||||
await jobStore.loadLog('a');
|
||||
|
||||
await jobStore.clearFinished();
|
||||
|
||||
expect(jobStore.cachedLog('a')).toEqual([]);
|
||||
});
|
||||
|
||||
it('coalesces a burst of snapshots into one notification', async () => {
|
||||
let notifications = 0;
|
||||
const off = jobStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
snapshot([job({ id: 'a', current: 1, total: 10 })]);
|
||||
snapshot([job({ id: 'a', current: 2, total: 10 })]);
|
||||
snapshot([job({ id: 'a', current: 3, total: 10 })]);
|
||||
await flush();
|
||||
off();
|
||||
|
||||
expect(notifications).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* The keyboard shortcut service and the store behind it. This is the
|
||||
* test that most justifies running in a real browser: the service walks
|
||||
* shadow roots to find the deepest focused element, and no jsdom
|
||||
* approximation of that is worth trusting.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import { buildKeyString } from '../../src/services/keyboard-shortcut-service';
|
||||
import '../../src/services/keyboard-shortcut-service';
|
||||
import { shortcutsStore } from '@store/shortcuts-store';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, calls, lastArgs, stub } from '@test/support/harness';
|
||||
|
||||
/** Install a binding table, as the backend's config push does. */
|
||||
function bindings(table: Record<string, string>): void {
|
||||
emit(Events.ShortcutsConfigChanged, table);
|
||||
}
|
||||
|
||||
/** Send a keydown through the document, where the service listens. */
|
||||
function press(
|
||||
key: string,
|
||||
modifiers: Partial<
|
||||
Record<'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey', boolean>
|
||||
> = {},
|
||||
): KeyboardEvent {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...modifiers,
|
||||
});
|
||||
|
||||
document.dispatchEvent(event);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
const mounted: HTMLElement[] = [];
|
||||
|
||||
/** Append an element to the body and remember to remove it. */
|
||||
function mount<T extends HTMLElement>(el: T): T {
|
||||
document.body.append(el);
|
||||
mounted.push(el);
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) mounted.pop()?.remove();
|
||||
bindings({});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
|
||||
describe('buildKeyString', () => {
|
||||
it('uppercases a bare printable key', () => {
|
||||
expect(buildKeyString(new KeyboardEvent('keydown', { key: 'n' }))).toBe(
|
||||
'N',
|
||||
);
|
||||
});
|
||||
|
||||
it('orders modifiers Ctrl, Alt, Shift regardless of press order', () => {
|
||||
const e = new KeyboardEvent('keydown', {
|
||||
key: 'f',
|
||||
shiftKey: true,
|
||||
altKey: true,
|
||||
ctrlKey: true,
|
||||
});
|
||||
|
||||
expect(buildKeyString(e)).toBe('Ctrl+Alt+Shift+F');
|
||||
});
|
||||
|
||||
it('folds Meta into Ctrl so macOS and Linux share one binding table', () => {
|
||||
const meta = new KeyboardEvent('keydown', { key: 'f', metaKey: true });
|
||||
const ctrl = new KeyboardEvent('keydown', { key: 'f', ctrlKey: true });
|
||||
|
||||
expect(buildKeyString(meta)).toBe(buildKeyString(ctrl));
|
||||
});
|
||||
|
||||
it('aliases arrows and space to their canonical names', () => {
|
||||
const names = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', ' '].map(
|
||||
(key) => buildKeyString(new KeyboardEvent('keydown', { key })),
|
||||
);
|
||||
|
||||
expect(names).toEqual(['Up', 'Down', 'Left', 'Right', 'Space']);
|
||||
});
|
||||
|
||||
it('leaves multi-character named keys alone', () => {
|
||||
expect(
|
||||
buildKeyString(new KeyboardEvent('keydown', { key: 'Escape' })),
|
||||
).toBe('Escape');
|
||||
});
|
||||
|
||||
it('returns nothing for a bare modifier press', () => {
|
||||
const bare = ['Control', 'Alt', 'Shift', 'Meta'].map((key) =>
|
||||
buildKeyString(new KeyboardEvent('keydown', { key })),
|
||||
);
|
||||
|
||||
expect(bare).toEqual(['', '', '', '']);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
|
||||
describe('shortcuts store: lookup', () => {
|
||||
beforeEach(() => {
|
||||
bindings({
|
||||
'player.playPause': 'Space',
|
||||
'player.next': 'Ctrl+Right',
|
||||
'tracklist.play': 'Enter',
|
||||
'tracklist.delete': 'Delete',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves an action to its key', () => {
|
||||
expect(shortcutsStore.getKeyForAction('player.next')).toBe('Ctrl+Right');
|
||||
});
|
||||
|
||||
it('reverse-resolves a key to its global action', () => {
|
||||
expect(shortcutsStore.getActionForKey('Space')).toBe('player.playPause');
|
||||
});
|
||||
|
||||
it('does not resolve a panel binding from global scope', () => {
|
||||
// `tracklist.` is a panel prefix: Enter must do nothing unless the
|
||||
// track list has focus.
|
||||
expect(shortcutsStore.getActionForKey('Enter')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolves a panel binding when the matching scope is supplied', () => {
|
||||
expect(shortcutsStore.getActionForKey('Enter', 'panel:tracklist')).toBe(
|
||||
'tracklist.play',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to global inside a panel scope', () => {
|
||||
expect(shortcutsStore.getActionForKey('Space', 'panel:tracklist')).toBe(
|
||||
'player.playPause',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports a conflict, excluding the action being rebound', () => {
|
||||
expect(
|
||||
shortcutsStore.findConflict('Space', 'global', 'player.next'),
|
||||
).toEqual({ action: 'player.playPause', key: 'Space' });
|
||||
});
|
||||
|
||||
it('does not report an action conflicting with itself', () => {
|
||||
expect(
|
||||
shortcutsStore.findConflict('Space', 'global', 'player.playPause'),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
|
||||
describe('shortcut dispatch: scope', () => {
|
||||
beforeEach(() => {
|
||||
bindings({
|
||||
'player.playPause': 'Space',
|
||||
'tracklist.play': 'Enter',
|
||||
});
|
||||
});
|
||||
|
||||
it('dispatches from global scope', () => {
|
||||
press(' ');
|
||||
|
||||
expect(calls('queue.Queue.Play')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preventDefaults a key it handled', () => {
|
||||
expect(press(' ').defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves an unbound key alone', () => {
|
||||
expect(press('q').defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('suppresses shortcuts while a text input has focus', () => {
|
||||
const input = mount(document.createElement('input'));
|
||||
|
||||
input.type = 'text';
|
||||
input.focus();
|
||||
press(' ');
|
||||
|
||||
expect(calls('queue.Queue.Play')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('suppresses shortcuts inside a contenteditable', () => {
|
||||
const div = mount(document.createElement('div'));
|
||||
|
||||
div.contentEditable = 'true';
|
||||
div.tabIndex = 0;
|
||||
div.focus();
|
||||
press(' ');
|
||||
|
||||
expect(calls('queue.Queue.Play')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('lets a checkbox through — it is not a text input', () => {
|
||||
const input = mount(document.createElement('input'));
|
||||
|
||||
input.type = 'checkbox';
|
||||
input.focus();
|
||||
press(' ');
|
||||
|
||||
expect(calls('queue.Queue.Play')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('blurs the input on Escape, and only on Escape', () => {
|
||||
const input = mount(document.createElement('input'));
|
||||
|
||||
input.type = 'search';
|
||||
input.focus();
|
||||
press('Escape');
|
||||
|
||||
expect(document.activeElement).not.toBe(input);
|
||||
});
|
||||
|
||||
it('finds a text input nested in a shadow root', () => {
|
||||
// document.activeElement stops at the shadow host, so a service
|
||||
// that did not walk the chain would see <div> and fire the shortcut.
|
||||
const host = mount(document.createElement('div'));
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
const input = document.createElement('input');
|
||||
|
||||
input.type = 'text';
|
||||
root.append(input);
|
||||
input.focus();
|
||||
press(' ');
|
||||
|
||||
expect(calls('queue.Queue.Play')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resolves a panel scope from a data-shortcut-scope ancestor', () => {
|
||||
const panel = mount(document.createElement('div'));
|
||||
const button = document.createElement('button');
|
||||
|
||||
panel.dataset['shortcutScope'] = 'tracklist';
|
||||
panel.append(button);
|
||||
button.focus();
|
||||
|
||||
let fired = 0;
|
||||
const listener = (): void => {
|
||||
fired += 1;
|
||||
};
|
||||
|
||||
document.addEventListener('shortcut:tracklist-play', listener);
|
||||
press('Enter');
|
||||
document.removeEventListener('shortcut:tracklist-play', listener);
|
||||
|
||||
expect(fired).toBe(1);
|
||||
});
|
||||
|
||||
it('crosses a shadow boundary looking for the panel scope', () => {
|
||||
const panel = mount(document.createElement('div'));
|
||||
const inner = document.createElement('div');
|
||||
const root = inner.attachShadow({ mode: 'open' });
|
||||
const button = document.createElement('button');
|
||||
|
||||
panel.dataset['shortcutScope'] = 'tracklist';
|
||||
panel.append(inner);
|
||||
root.append(button);
|
||||
button.focus();
|
||||
|
||||
let fired = 0;
|
||||
const listener = (): void => {
|
||||
fired += 1;
|
||||
};
|
||||
|
||||
document.addEventListener('shortcut:tracklist-play', listener);
|
||||
press('Enter');
|
||||
document.removeEventListener('shortcut:tracklist-play', listener);
|
||||
|
||||
expect(fired).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
|
||||
describe('shortcut dispatch: actions', () => {
|
||||
it('toggles between pause and play based on cached player state', () => {
|
||||
bindings({ 'player.playPause': 'Space' });
|
||||
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
press(' ');
|
||||
emit(Events.PlaybackStateChanged, { state: 'paused' });
|
||||
press(' ');
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual([
|
||||
'player.Player.Pause',
|
||||
'queue.Queue.Play',
|
||||
]);
|
||||
});
|
||||
|
||||
it('steps the volume by a fixed amount in each direction', () => {
|
||||
bindings({ 'player.volumeUp': 'Up', 'player.volumeDown': 'Down' });
|
||||
|
||||
press('ArrowUp');
|
||||
press('ArrowDown');
|
||||
|
||||
expect(calls('player.Player.ChangeVolume').map((c) => c.args)).toEqual([
|
||||
[5],
|
||||
[-5],
|
||||
]);
|
||||
});
|
||||
|
||||
it('clamps a forward seek to the track length', async () => {
|
||||
bindings({ 'player.seekForward': 'Right' });
|
||||
stub('player.Player.CurrentPositionSeconds', 98);
|
||||
stub('player.Player.TrackLengthInSeconds', 100);
|
||||
|
||||
press('ArrowRight');
|
||||
await new Promise<void>((r) => {
|
||||
setTimeout(r, 0);
|
||||
});
|
||||
|
||||
expect(lastArgs('player.Player.Seek')).toEqual([100]);
|
||||
});
|
||||
|
||||
it('clamps a backward seek at zero', async () => {
|
||||
bindings({ 'player.seekBack': 'Left' });
|
||||
stub('player.Player.CurrentPositionSeconds', 2);
|
||||
|
||||
press('ArrowLeft');
|
||||
await new Promise<void>((r) => {
|
||||
setTimeout(r, 0);
|
||||
});
|
||||
|
||||
expect(lastArgs('player.Player.Seek')).toEqual([0]);
|
||||
});
|
||||
|
||||
it('toggles the queue panel open and closed', () => {
|
||||
bindings({ 'nav.queue': 'Q' });
|
||||
|
||||
const panel = mount(document.createElement('div'));
|
||||
|
||||
panel.id = 'queue-panel';
|
||||
|
||||
press('q');
|
||||
const opened = panel.hasAttribute('open');
|
||||
|
||||
press('q');
|
||||
|
||||
expect([opened, panel.hasAttribute('open')]).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('broadcasts select-all as a document event', () => {
|
||||
bindings({ 'app.selectAll': 'Ctrl+A' });
|
||||
|
||||
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('ignores an action name the dispatcher does not know', () => {
|
||||
bindings({ 'player.teleport': 'T' });
|
||||
|
||||
press('t');
|
||||
|
||||
expect(calls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ===================================================================
|
||||
|
||||
describe('shortcuts store: writes', () => {
|
||||
it('sends a single rebind to the backend', async () => {
|
||||
await shortcutsStore.updateBinding('player.next', 'Ctrl+N');
|
||||
|
||||
expect(lastArgs('config.Config.SetShortcut')).toEqual([
|
||||
'player.next',
|
||||
'Ctrl+N',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends a whole table at once', async () => {
|
||||
await shortcutsStore.setAll({ 'player.next': 'N' });
|
||||
|
||||
expect(lastArgs('config.Config.SetShortcuts')).toEqual([
|
||||
{ 'player.next': 'N' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not update its cache optimistically', async () => {
|
||||
bindings({ 'player.next': 'Ctrl+Right' });
|
||||
await shortcutsStore.updateBinding('player.next', 'Ctrl+N');
|
||||
|
||||
// The backend is the only writer; the cache waits for the
|
||||
// ShortcutsConfigChanged push.
|
||||
expect(shortcutsStore.getKeyForAction('player.next')).toBe('Ctrl+Right');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* The library store is the frontend's read cache for the whole
|
||||
* catalogue: four collections, each fetched once and held until
|
||||
* something invalidates them. The interesting behaviour is not the
|
||||
* fetching but the caching — deduplicating concurrent readers, throwing
|
||||
* everything away on a rescan, and swapping to the by-library bindings
|
||||
* when a filter is active.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import { libraryStore } from '@store/library-store';
|
||||
import { Events } from '../../src/events';
|
||||
import {
|
||||
emit,
|
||||
calls,
|
||||
stub,
|
||||
flush,
|
||||
lastArgs,
|
||||
resetHarness,
|
||||
} from '@test/support/harness';
|
||||
|
||||
const TRACKS = [{ ID: 1, Title: 'One' }];
|
||||
const ALBUMS = [{ ID: 1, Name: 'Album', ArtistName: 'Artist' }];
|
||||
const OTHER_ALBUMS = [{ ID: 2, Name: 'Other', ArtistName: 'Other Artist' }];
|
||||
const ARTISTS = [{ ID: 1, Name: 'Artist' }];
|
||||
const GENRES = [{ Name: 'Ambient', Count: 3 }];
|
||||
const LIBRARIES = [{ id: 7, name: 'Music' }, { id: 8, name: 'Field' }];
|
||||
|
||||
/** Stub every read binding the store can reach. Unstubbed bindings
|
||||
* resolve undefined, which the store would cache as if it were data. */
|
||||
function stubReads(): void {
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetAllAlbums', ALBUMS);
|
||||
stub('library.Library.GetAllArtists', ARTISTS);
|
||||
stub('library.Library.GetAllGenresWithCounts', GENRES);
|
||||
stub('library.Library.GetAllTracksByLibrary', TRACKS);
|
||||
stub('library.Library.GetAllAlbumsByLibrary', ALBUMS);
|
||||
stub('library.Library.GetAllArtistsByLibrary', ARTISTS);
|
||||
stub('library.Library.GetAllGenresWithCountsByLibrary', GENRES);
|
||||
stub('library.Library.GetAlbumsByArtist', ALBUMS);
|
||||
stub('library.Library.GetAlbumsByArtistByLibrary', ALBUMS);
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', LIBRARIES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cache and let the eager refetch settle, so each test starts
|
||||
* from the same place. The store has no reset of its own; a scan
|
||||
* completing is how the app itself clears it.
|
||||
*/
|
||||
async function reload(): Promise<void> {
|
||||
stubReads();
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
// The eager refetch the invalidation kicks off is recorded like any
|
||||
// other call; clear it, or every count in every test is off by one.
|
||||
resetHarness();
|
||||
stubReads();
|
||||
}
|
||||
|
||||
describe('library store: caching', () => {
|
||||
beforeEach(async () => {
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('serves a second read from cache without touching the backend', async () => {
|
||||
await libraryStore.getTracks();
|
||||
|
||||
expect(calls('library.Library.GetAllTracks')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent first reads into one backend call', async () => {
|
||||
emit(Events.LibraryScanComplete);
|
||||
const [a, b] = await Promise.all([
|
||||
libraryStore.getArtists(),
|
||||
libraryStore.getArtists(),
|
||||
]);
|
||||
|
||||
expect([a, b, calls('library.Library.GetAllArtists').length]).toEqual([
|
||||
ARTISTS,
|
||||
ARTISTS,
|
||||
1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('exposes cached collections synchronously once loaded', () => {
|
||||
expect([
|
||||
libraryStore.getCachedTracks(),
|
||||
libraryStore.getCachedAlbums(),
|
||||
libraryStore.cachedArtists,
|
||||
libraryStore.getCachedGenres(),
|
||||
]).toEqual([TRACKS, ALBUMS, ARTISTS, GENRES]);
|
||||
});
|
||||
|
||||
it('refetches everything when a scan completes', async () => {
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
|
||||
expect(calls().map((c) => c.path).sort()).toEqual([
|
||||
'library.Library.GetAllAlbums',
|
||||
'library.Library.GetAllArtists',
|
||||
'library.Library.GetAllGenresWithCounts',
|
||||
'library.Library.GetAllTracks',
|
||||
]);
|
||||
});
|
||||
|
||||
it('refetches when a track is retagged', async () => {
|
||||
emit(Events.TrackMetadataChanged, { filePath: '/a.mp3' });
|
||||
await flush();
|
||||
|
||||
expect(calls('library.Library.GetAllTracks')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resets scroll positions on invalidation, so a shorter list is not scrolled past its end', async () => {
|
||||
libraryStore.setScrollPosition('albums', 4200);
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
|
||||
expect(libraryStore.getScrollPosition('albums')).toBe(0);
|
||||
});
|
||||
|
||||
it('bumps the change generation for data, not for loading flags', async () => {
|
||||
const before = libraryStore.changeGeneration;
|
||||
|
||||
await libraryStore.getTracks(); // cached: no change
|
||||
const afterCachedRead = libraryStore.changeGeneration;
|
||||
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
|
||||
expect([
|
||||
afterCachedRead === before,
|
||||
libraryStore.changeGeneration > before,
|
||||
]).toEqual([true, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('library store: library filter', () => {
|
||||
beforeEach(async () => {
|
||||
libraryStore.setSelectedLibrary(null);
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('switches to the by-library bindings when a filter is set', async () => {
|
||||
libraryStore.setSelectedLibrary(7);
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([7]);
|
||||
});
|
||||
|
||||
it('ignores a redundant selection instead of invalidating', async () => {
|
||||
libraryStore.setSelectedLibrary(null);
|
||||
await flush();
|
||||
|
||||
expect(calls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('answers the cached artist-albums query only when unfiltered', async () => {
|
||||
const unfiltered = libraryStore.getAlbumsByArtistNameCached('Artist');
|
||||
|
||||
libraryStore.setSelectedLibrary(7);
|
||||
await flush();
|
||||
|
||||
// With a filter active the cache is already library-scoped, so the
|
||||
// store declines to answer and forces a backend query instead.
|
||||
expect([
|
||||
unfiltered,
|
||||
libraryStore.getAlbumsByArtistNameCached('Artist'),
|
||||
]).toEqual([ALBUMS, null]);
|
||||
});
|
||||
|
||||
it('scopes an artist drill-down to the selected library', async () => {
|
||||
libraryStore.setSelectedLibrary(8);
|
||||
await libraryStore.getAlbumsByArtist(3);
|
||||
|
||||
expect(lastArgs('library.Library.GetAlbumsByArtistByLibrary')).toEqual([
|
||||
3, 8,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('library store: default library id', () => {
|
||||
beforeEach(async () => {
|
||||
libraryStore.setSelectedLibrary(null);
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('prefers the active filter', async () => {
|
||||
libraryStore.setSelectedLibrary(8);
|
||||
|
||||
await expect(libraryStore.getDefaultLibraryId()).resolves.toBe(8);
|
||||
});
|
||||
|
||||
it('falls back to the first known library, never to zero', async () => {
|
||||
// id 0 never exists and trips the download_requests foreign key.
|
||||
await expect(libraryStore.getDefaultLibraryId()).resolves.toBe(7);
|
||||
});
|
||||
|
||||
it('returns null when there are no libraries at all', async () => {
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||
emit(Events.LibraryRemoved, { id: 7 });
|
||||
|
||||
await expect(libraryStore.getDefaultLibraryId()).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('caches the library list until a library is added or renamed', async () => {
|
||||
const fetched = (): number =>
|
||||
calls('library.Library.GetAllLibrariesWithTrackCounts').length;
|
||||
|
||||
// The list survives a rescan — only library CRUD changes it.
|
||||
emit(Events.LibraryAdded, { id: 9 });
|
||||
await libraryStore.getLibraries();
|
||||
const afterAdd = fetched();
|
||||
|
||||
await libraryStore.getLibraries();
|
||||
const afterCachedRead = fetched();
|
||||
|
||||
emit(Events.LibraryRenamed, { id: 7, name: 'Renamed' });
|
||||
await libraryStore.getLibraries();
|
||||
|
||||
expect([afterAdd, afterCachedRead, fetched()]).toEqual([1, 1, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('library store: cover size', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem('cover-grid-size');
|
||||
libraryStore.setCoverSize(176);
|
||||
});
|
||||
|
||||
it('clamps below the minimum card width', () => {
|
||||
libraryStore.setCoverSize(10);
|
||||
|
||||
expect(libraryStore.getCoverSize()).toBe(100);
|
||||
});
|
||||
|
||||
it('clamps above the maximum card width', () => {
|
||||
libraryStore.setCoverSize(9000);
|
||||
|
||||
expect(libraryStore.getCoverSize()).toBe(350);
|
||||
});
|
||||
|
||||
it('rounds a fractional size, since it becomes a CSS pixel value', () => {
|
||||
libraryStore.setCoverSize(180.6);
|
||||
|
||||
expect(libraryStore.getCoverSize()).toBe(181);
|
||||
});
|
||||
|
||||
it('persists the size for the next session', () => {
|
||||
libraryStore.setCoverSize(200);
|
||||
|
||||
expect(localStorage.getItem('cover-grid-size')).toBe('200');
|
||||
});
|
||||
|
||||
it('does not notify when the clamped size is unchanged', async () => {
|
||||
libraryStore.setCoverSize(300);
|
||||
await flush();
|
||||
|
||||
let notifications = 0;
|
||||
const off = libraryStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
libraryStore.setCoverSize(400); // clamps back to 350 ≠ 300
|
||||
libraryStore.setCoverSize(9999); // clamps to 350, unchanged
|
||||
await flush();
|
||||
off();
|
||||
|
||||
expect(notifications).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('library store: albums by artist name', () => {
|
||||
beforeEach(async () => {
|
||||
libraryStore.setSelectedLibrary(null);
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('filters the album cache by artist name', () => {
|
||||
stub('library.Library.GetAllAlbums', [...ALBUMS, ...OTHER_ALBUMS]);
|
||||
|
||||
expect(libraryStore.getAlbumsByArtistNameCached('Artist')).toEqual(ALBUMS);
|
||||
});
|
||||
|
||||
it('returns an empty list, not null, for an artist with no albums', () => {
|
||||
expect(libraryStore.getAlbumsByArtistNameCached('Nobody')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The player store is a pure projection of backend push events. Its
|
||||
* whole job is to be a truthful cache, so the tests are about what it
|
||||
* derives (`isPlaying` from a state string) and what it refuses to
|
||||
* invent (it never predicts the result of an action).
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import { playerStore, type TrackInfo } from '@store/player-store';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, calls, lastArgs, flush } from '@test/support/harness';
|
||||
|
||||
const TRACK: TrackInfo = {
|
||||
fileName: 'one.mp3',
|
||||
filePath: '/music/one.mp3',
|
||||
trackLength: 180,
|
||||
seekPosition: 0,
|
||||
state: 'playing',
|
||||
title: 'One',
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
coverArt: '',
|
||||
coverArtSmall: '',
|
||||
coverArtMedium: '',
|
||||
coverArtLarge: '',
|
||||
trackChangeId: 1,
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
|
||||
describe('player store: playback state', () => {
|
||||
beforeEach(() => {
|
||||
emit(Events.PlaybackStateChanged, { state: 'stopped' });
|
||||
emit(Events.TrackChanged, null);
|
||||
});
|
||||
|
||||
it('is playing only for the literal "playing" state', () => {
|
||||
const seen: boolean[] = [];
|
||||
|
||||
for (const state of ['playing', 'paused', 'stopped', 'buffering']) {
|
||||
emit(Events.PlaybackStateChanged, { state });
|
||||
seen.push(playerStore.getState().isPlaying);
|
||||
}
|
||||
|
||||
expect(seen).toEqual([true, false, false, false]);
|
||||
});
|
||||
|
||||
it('stops playing when the track finishes', () => {
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
emit(Events.PlaybackFinished);
|
||||
|
||||
expect(playerStore.getState().isPlaying).toBe(false);
|
||||
});
|
||||
|
||||
it('caches the current track', () => {
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
|
||||
expect(playerStore.getState().currentTrack).toEqual(TRACK);
|
||||
});
|
||||
|
||||
it('normalises an absent track to null rather than undefined', () => {
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
emit(Events.TrackChanged, undefined);
|
||||
|
||||
expect(playerStore.getState().currentTrack).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the cached track across a pause', () => {
|
||||
emit(Events.TrackChanged, TRACK);
|
||||
emit(Events.PlaybackStateChanged, { state: 'paused' });
|
||||
|
||||
expect(playerStore.getState().currentTrack).toEqual(TRACK);
|
||||
});
|
||||
|
||||
it('tracks volume pushed back from Go', () => {
|
||||
emit(Events.VolumeChanged, 42);
|
||||
|
||||
expect(playerStore.getState().volume).toBe(42);
|
||||
});
|
||||
|
||||
it('replaces state rather than mutating it, so a saved reference is stable', () => {
|
||||
emit(Events.VolumeChanged, 10);
|
||||
const before = playerStore.getState();
|
||||
|
||||
emit(Events.VolumeChanged, 20);
|
||||
|
||||
expect(before.volume).toBe(10);
|
||||
});
|
||||
|
||||
it('coalesces a burst into a single notification', async () => {
|
||||
let notifications = 0;
|
||||
const off = playerStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
emit(Events.VolumeChanged, 1);
|
||||
emit(Events.VolumeChanged, 2);
|
||||
emit(Events.PlaybackStateChanged, { state: 'playing' });
|
||||
await flush();
|
||||
off();
|
||||
|
||||
expect(notifications).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('player store: actions', () => {
|
||||
it('forwards each action to its bound method', () => {
|
||||
playerStore.pause();
|
||||
playerStore.loadTrack('/music/one.mp3');
|
||||
playerStore.seek(30);
|
||||
playerStore.setVolume(60);
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual([
|
||||
'player.Player.Pause',
|
||||
'player.Player.LoadFile',
|
||||
'player.Player.Seek',
|
||||
'player.Player.SetVolume',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends the volume as an integer percentage, not a fraction', () => {
|
||||
// player.UserVolume is an int in Go; a float never settles its
|
||||
// callback. See .planning/NOTES.md.
|
||||
playerStore.setVolume(42);
|
||||
|
||||
expect(lastArgs('player.Player.SetVolume')).toEqual([42]);
|
||||
});
|
||||
|
||||
it('does not optimistically change cached volume', () => {
|
||||
emit(Events.VolumeChanged, 50);
|
||||
playerStore.setVolume(80);
|
||||
|
||||
expect(playerStore.getState().volume).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* The playlist store caches one list and invalidates it on six
|
||||
* different events. The distinction worth testing is `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.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import { playlistStore } from '@store/playlist-store';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, calls, stub, flush, resetHarness } from '@test/support/harness';
|
||||
|
||||
const PLAYLISTS = [
|
||||
{ ID: 1, Name: 'Morning', Tracks: [] },
|
||||
{ ID: 2, Name: 'Evening', Tracks: [] },
|
||||
];
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS);
|
||||
playlistStore.invalidate();
|
||||
await flush();
|
||||
resetHarness();
|
||||
stub('playlist.Service.GetAllPlaylistsWithTracks', PLAYLISTS);
|
||||
}
|
||||
|
||||
describe('playlist store: caching', () => {
|
||||
beforeEach(async () => {
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('serves a second read from cache', async () => {
|
||||
await playlistStore.getPlaylists();
|
||||
|
||||
expect(calls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('deduplicates concurrent first reads', async () => {
|
||||
playlistStore.invalidate();
|
||||
await Promise.all([
|
||||
playlistStore.getPlaylists(),
|
||||
playlistStore.getPlaylists(),
|
||||
]);
|
||||
|
||||
expect(calls('playlist.Service.GetAllPlaylistsWithTracks')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('exposes the cache synchronously for render', () => {
|
||||
expect(playlistStore.getCachedPlaylists()).toEqual(PLAYLISTS);
|
||||
});
|
||||
|
||||
it('normalises a null list to an empty one', async () => {
|
||||
stub('playlist.Service.GetAllPlaylistsWithTracks', null);
|
||||
playlistStore.invalidate();
|
||||
await flush();
|
||||
|
||||
expect(playlistStore.getCachedPlaylists()).toEqual([]);
|
||||
});
|
||||
|
||||
it('holds the stale list across a refetch, so the view does not flash empty', async () => {
|
||||
const pending = playlistStore.refetch();
|
||||
const during = playlistStore.getCachedPlaylists();
|
||||
|
||||
await pending;
|
||||
|
||||
expect(during).toEqual(PLAYLISTS);
|
||||
});
|
||||
|
||||
it('drops the cache on invalidate, which is the difference from refetch', () => {
|
||||
playlistStore.invalidate();
|
||||
|
||||
expect(playlistStore.getCachedPlaylists()).toBeNull();
|
||||
});
|
||||
|
||||
it('resets the scroll position when the list is invalidated', async () => {
|
||||
playlistStore.setScrollPosition(900);
|
||||
playlistStore.invalidate();
|
||||
await flush();
|
||||
|
||||
expect(playlistStore.getScrollPosition()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playlist store: invalidating events', () => {
|
||||
beforeEach(async () => {
|
||||
await reload();
|
||||
});
|
||||
|
||||
it('refetches for every event that can change a playlist', async () => {
|
||||
const events = [
|
||||
Events.PlaylistCreated,
|
||||
Events.PlaylistDeleted,
|
||||
Events.PlaylistRenamed,
|
||||
Events.PlaylistTracksChanged,
|
||||
Events.PlaylistsRestored,
|
||||
Events.LibraryScanComplete,
|
||||
];
|
||||
|
||||
for (const name of events) {
|
||||
emit(name, 1);
|
||||
await flush();
|
||||
}
|
||||
|
||||
expect(
|
||||
calls('playlist.Service.GetAllPlaylistsWithTracks'),
|
||||
).toHaveLength(events.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* The queue store's delta reducer is the most intricate pure logic in
|
||||
* the frontend: four mutation actions arriving as events, applied to a
|
||||
* cached array that must stay identical to the Go queue's own. `move` in
|
||||
* particular adjusts its insertion index for elements removed before it,
|
||||
* and gets that wrong silently.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import { queueStore, type QueueTrack } from '@store/queue-store';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, flush, lastArgs, calls } from '@test/support/harness';
|
||||
|
||||
function track(n: number): QueueTrack {
|
||||
return {
|
||||
id: n,
|
||||
audioFileId: n,
|
||||
filePath: `/music/${n}.mp3`,
|
||||
position: n,
|
||||
title: `Track ${n}`,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
coverArtPath: '',
|
||||
artistMbid: '',
|
||||
releaseGroupMbid: '',
|
||||
recordingMbid: '',
|
||||
};
|
||||
}
|
||||
|
||||
/** Titles of the cached queue, the cheapest readable assertion. */
|
||||
function titles(): string[] {
|
||||
return queueStore.getState().tracks.map((t) => t.title);
|
||||
}
|
||||
|
||||
/** Push an authoritative full-state sync, as the backend does on
|
||||
* startup and after SetQueue. */
|
||||
function sync(tracks: QueueTrack[], currentIndex = 0): void {
|
||||
emit(Events.QueueChanged, {
|
||||
tracks,
|
||||
currentIndex,
|
||||
shuffleMode: false,
|
||||
repeatMode: 'off',
|
||||
sourcePlaylistId: 0,
|
||||
});
|
||||
}
|
||||
|
||||
describe('queue store: full-state sync', () => {
|
||||
beforeEach(() => {
|
||||
sync([]);
|
||||
});
|
||||
|
||||
it('replaces cached state wholesale', () => {
|
||||
sync([track(1), track(2)], 1);
|
||||
|
||||
expect(queueStore.getState()).toEqual({
|
||||
tracks: [track(1), track(2)],
|
||||
currentIndex: 1,
|
||||
shuffleMode: false,
|
||||
repeatMode: 'off',
|
||||
sourcePlaylistId: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a null track list, which Go sends for an empty queue', () => {
|
||||
emit(Events.QueueChanged, {
|
||||
tracks: null,
|
||||
currentIndex: -1,
|
||||
shuffleMode: false,
|
||||
repeatMode: 'off',
|
||||
sourcePlaylistId: 0,
|
||||
});
|
||||
|
||||
expect(queueStore.getState().tracks).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue store: track deltas', () => {
|
||||
beforeEach(() => {
|
||||
sync([track(1), track(2), track(3)], 0);
|
||||
});
|
||||
|
||||
it('appends on add', () => {
|
||||
emit(Events.QueueTracksModified, {
|
||||
action: 'add',
|
||||
tracks: [track(4)],
|
||||
index: 0,
|
||||
currentIndex: 0,
|
||||
});
|
||||
|
||||
expect(titles()).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4']);
|
||||
});
|
||||
|
||||
it('splices at the index on insert', () => {
|
||||
emit(Events.QueueTracksModified, {
|
||||
action: 'insert',
|
||||
tracks: [track(9)],
|
||||
index: 1,
|
||||
currentIndex: 0,
|
||||
});
|
||||
|
||||
expect(titles()).toEqual(['Track 1', 'Track 9', 'Track 2', 'Track 3']);
|
||||
});
|
||||
|
||||
it('removes every listed position at once, not one at a time', () => {
|
||||
// Removing 0 then 2 sequentially would take the wrong second track;
|
||||
// the reducer must treat the positions as indices into the original.
|
||||
emit(Events.QueueTracksModified, {
|
||||
action: 'remove',
|
||||
positions: [0, 2],
|
||||
index: 0,
|
||||
currentIndex: 0,
|
||||
});
|
||||
|
||||
expect(titles()).toEqual(['Track 2']);
|
||||
});
|
||||
|
||||
it('adopts the backend current index from every delta', () => {
|
||||
emit(Events.QueueTracksModified, {
|
||||
action: 'remove',
|
||||
positions: [0],
|
||||
index: 0,
|
||||
currentIndex: 1,
|
||||
});
|
||||
|
||||
expect(queueStore.getState().currentIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue store: move', () => {
|
||||
beforeEach(() => {
|
||||
sync([track(1), track(2), track(3), track(4)], 0);
|
||||
});
|
||||
|
||||
it('moves a track forward, compensating for its own removal', () => {
|
||||
// Move index 0 to index 2. After removing it, the target shifts
|
||||
// down by one, so track 1 lands between 2 and 3 — not after 3.
|
||||
emit(Events.QueueTracksModified, {
|
||||
action: 'move',
|
||||
tracks: [track(1)],
|
||||
positions: [0],
|
||||
index: 2,
|
||||
currentIndex: 1,
|
||||
});
|
||||
|
||||
expect(titles()).toEqual(['Track 2', 'Track 1', 'Track 3', 'Track 4']);
|
||||
});
|
||||
|
||||
it('moves a track backward without compensating', () => {
|
||||
emit(Events.QueueTracksModified, {
|
||||
action: 'move',
|
||||
tracks: [track(4)],
|
||||
positions: [3],
|
||||
index: 1,
|
||||
currentIndex: 0,
|
||||
});
|
||||
|
||||
expect(titles()).toEqual(['Track 1', 'Track 4', 'Track 2', 'Track 3']);
|
||||
});
|
||||
|
||||
it('moves a multi-selection, compensating once per element before the target', () => {
|
||||
emit(Events.QueueTracksModified, {
|
||||
action: 'move',
|
||||
tracks: [track(1), track(2)],
|
||||
positions: [0, 1],
|
||||
index: 3,
|
||||
currentIndex: 0,
|
||||
});
|
||||
|
||||
expect(titles()).toEqual(['Track 3', 'Track 1', 'Track 2', 'Track 4']);
|
||||
});
|
||||
|
||||
it('clamps a target past the end of the shortened list', () => {
|
||||
emit(Events.QueueTracksModified, {
|
||||
action: 'move',
|
||||
tracks: [track(1)],
|
||||
positions: [0],
|
||||
index: 99,
|
||||
currentIndex: 3,
|
||||
});
|
||||
|
||||
expect(titles()).toEqual(['Track 2', 'Track 3', 'Track 4', 'Track 1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue store: mode deltas', () => {
|
||||
beforeEach(() => {
|
||||
sync([track(1)], 0);
|
||||
});
|
||||
|
||||
it('applies shuffle and repeat together', () => {
|
||||
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'one' });
|
||||
|
||||
const state = queueStore.getState();
|
||||
|
||||
expect([state.shuffleMode, state.repeatMode]).toEqual([true, 'one']);
|
||||
});
|
||||
|
||||
it('leaves the track list untouched', () => {
|
||||
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'all' });
|
||||
|
||||
expect(titles()).toEqual(['Track 1']);
|
||||
});
|
||||
|
||||
it('applies an index-only delta', () => {
|
||||
emit(Events.QueueIndexChanged, { currentIndex: 7 });
|
||||
|
||||
expect(queueStore.getState().currentIndex).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue store: subscriber notification', () => {
|
||||
beforeEach(() => {
|
||||
sync([]);
|
||||
});
|
||||
|
||||
it('coalesces a burst of events into one notification', async () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = queueStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
emit(Events.QueueIndexChanged, { currentIndex: 1 });
|
||||
emit(Events.QueueIndexChanged, { currentIndex: 2 });
|
||||
emit(Events.QueueIndexChanged, { currentIndex: 3 });
|
||||
await flush();
|
||||
|
||||
unsubscribe();
|
||||
|
||||
expect(notifications).toBe(1);
|
||||
});
|
||||
|
||||
it('stops notifying after unsubscribe', async () => {
|
||||
let notifications = 0;
|
||||
const unsubscribe = queueStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
emit(Events.QueueIndexChanged, { currentIndex: 1 });
|
||||
await flush();
|
||||
|
||||
expect(notifications).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queue store: actions reach the backend', () => {
|
||||
it('forwards setQueue with its default shuffleStart', () => {
|
||||
queueStore.setQueue(['/a.mp3', '/b.mp3'], 1);
|
||||
|
||||
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
|
||||
['/a.mp3', '/b.mp3'],
|
||||
1,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps each mutation onto its own bound method', () => {
|
||||
queueStore.addToQueue('/a.mp3');
|
||||
queueStore.playNext('/b.mp3');
|
||||
queueStore.removeFromQueue(2);
|
||||
queueStore.moveTracksInQueue([0, 1], 3);
|
||||
queueStore.toggleShuffle();
|
||||
queueStore.cycleRepeat();
|
||||
queueStore.clearQueue();
|
||||
|
||||
expect(calls().map((c) => c.path)).toEqual([
|
||||
'queue.Queue.AddTrack',
|
||||
'queue.Queue.InsertNext',
|
||||
'queue.Queue.RemoveTrack',
|
||||
'queue.Queue.MoveQueueTracks',
|
||||
'queue.Queue.ToggleShuffle',
|
||||
'queue.Queue.CycleRepeat',
|
||||
'queue.Queue.Clear',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not optimistically mutate cached state', () => {
|
||||
sync([track(1)], 0);
|
||||
queueStore.clearQueue();
|
||||
|
||||
// The backend is the only writer; the cache waits for the event.
|
||||
expect(titles()).toEqual(['Track 1']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* The theme store is the only store that writes to the document: it
|
||||
* derives a whole custom-property ramp from two settings and applies it
|
||||
* to :root, where every shadow root inherits it. Asserting on the
|
||||
* computed values of the real document is the point of running in a
|
||||
* browser at all.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest';
|
||||
|
||||
import { themeStore } from '@store/theme-store';
|
||||
import { Events } from '../../src/events';
|
||||
import { emit, lastArgs } from '@test/support/harness';
|
||||
|
||||
/** Push a theme, as the backend's config change event does. */
|
||||
function applyTheme(AccentColor: string, BackgroundShade: string): void {
|
||||
emit(Events.ThemeConfigChanged, { AccentColor, BackgroundShade });
|
||||
}
|
||||
|
||||
function cssVar(name: string): string {
|
||||
return document.documentElement.style.getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
describe('theme store: derived variables', () => {
|
||||
beforeEach(() => {
|
||||
applyTheme('#ffd43b', 'dark');
|
||||
});
|
||||
|
||||
it('caches the pushed theme', () => {
|
||||
expect(themeStore.getState()).toEqual({
|
||||
accentColor: '#ffd43b',
|
||||
backgroundShade: 'dark',
|
||||
});
|
||||
});
|
||||
|
||||
it('sets the accent verbatim', () => {
|
||||
expect(cssVar('--yj-accent')).toBe('#ffd43b');
|
||||
});
|
||||
|
||||
it('derives a lighter hover accent and a darker muted one', () => {
|
||||
expect([cssVar('--yj-accent-hover'), cssVar('--yj-accent-muted')]).toEqual([
|
||||
'#ffda58',
|
||||
'#806a1e',
|
||||
]);
|
||||
});
|
||||
|
||||
it('derives translucent accent backgrounds as rgba, for layering', () => {
|
||||
expect(cssVar('--yj-accent-bg')).toBe('rgba(255, 212, 59, 0.1)');
|
||||
});
|
||||
|
||||
it('expands a three-digit hex before deriving from it', () => {
|
||||
applyTheme('#fff', 'dark');
|
||||
|
||||
expect(cssVar('--yj-accent-hover')).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('swaps the whole background ramp with the shade', () => {
|
||||
applyTheme('#ffd43b', 'darker');
|
||||
const darker = cssVar('--yj-bg-surface');
|
||||
|
||||
applyTheme('#ffd43b', 'light');
|
||||
|
||||
expect([darker, cssVar('--yj-bg-surface')]).toEqual(['#121212', '#f8f9fa']);
|
||||
});
|
||||
|
||||
it('keeps semantic colours fixed across shades', () => {
|
||||
const dark = cssVar('--yj-error');
|
||||
|
||||
applyTheme('#ffd43b', 'light');
|
||||
|
||||
expect([dark, cssVar('--yj-error')]).toEqual(['#e03131', '#e03131']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme store: document integration', () => {
|
||||
it('flags dark shades to Web Awesome, which otherwise renders white surfaces', () => {
|
||||
applyTheme('#ffd43b', 'dark');
|
||||
const darkFlagged = document.documentElement.classList.contains('wa-dark');
|
||||
|
||||
applyTheme('#ffd43b', 'light');
|
||||
|
||||
expect([
|
||||
darkFlagged,
|
||||
document.documentElement.classList.contains('wa-dark'),
|
||||
]).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it('sets color-scheme so native controls and scrollbars match', () => {
|
||||
applyTheme('#ffd43b', 'light');
|
||||
const light = document.documentElement.style.colorScheme;
|
||||
|
||||
applyTheme('#ffd43b', 'darker');
|
||||
|
||||
expect([light, document.documentElement.style.colorScheme]).toEqual([
|
||||
'light',
|
||||
'dark',
|
||||
]);
|
||||
});
|
||||
|
||||
it('bridges the surface ramp onto Web Awesome custom properties', () => {
|
||||
applyTheme('#ffd43b', 'darker');
|
||||
|
||||
expect([
|
||||
cssVar('--wa-color-surface-default'),
|
||||
cssVar('--wa-color-surface-raised'),
|
||||
cssVar('--wa-color-surface-lowered'),
|
||||
]).toEqual(['#000000', '#121212', '#1e1e1e']);
|
||||
});
|
||||
|
||||
it('is inherited through a shadow root', () => {
|
||||
applyTheme('#ff0000', 'dark');
|
||||
|
||||
const host = document.createElement('div');
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
const inner = document.createElement('span');
|
||||
|
||||
inner.style.color = 'var(--yj-accent)';
|
||||
root.append(inner);
|
||||
document.body.append(host);
|
||||
|
||||
const colour = getComputedStyle(inner).color;
|
||||
|
||||
host.remove();
|
||||
|
||||
expect(colour).toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme store: writes', () => {
|
||||
it('sends a new accent to the backend rather than applying it locally', async () => {
|
||||
applyTheme('#ffd43b', 'dark');
|
||||
await themeStore.setAccentColor('#00ff00');
|
||||
|
||||
// The backend is the writer; the store waits for ThemeConfigChanged.
|
||||
expect([
|
||||
lastArgs('config.Config.SetThemeAccentColor'),
|
||||
themeStore.getState().accentColor,
|
||||
]).toEqual([['#00ff00'], '#ffd43b']);
|
||||
});
|
||||
|
||||
it('sends a new shade to the backend', async () => {
|
||||
await themeStore.setBackgroundShade('light');
|
||||
|
||||
expect(lastArgs('config.Config.SetThemeBackgroundShade')).toEqual([
|
||||
'light',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* The three small stores behind view chrome: the global search term,
|
||||
* the track list's column set, and the explore cache that keeps detail
|
||||
* pages from re-fetching what a search already returned.
|
||||
*/
|
||||
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 { Events } from '../../src/events';
|
||||
import { emit, lastCall, flush } from '@test/support/harness';
|
||||
|
||||
describe('search store', () => {
|
||||
beforeEach(() => {
|
||||
searchStore.setTerm('');
|
||||
searchStore.setCurrentView('tracks');
|
||||
});
|
||||
|
||||
it('holds the term', () => {
|
||||
searchStore.setTerm('bowie');
|
||||
|
||||
expect(searchStore.getTerm()).toBe('bowie');
|
||||
});
|
||||
|
||||
it('does not notify when the term is unchanged', () => {
|
||||
let notifications = 0;
|
||||
const off = searchStore.subscribe(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
|
||||
searchStore.setTerm('bowie');
|
||||
searchStore.setTerm('bowie');
|
||||
off();
|
||||
|
||||
expect(notifications).toBe(1);
|
||||
});
|
||||
|
||||
it('notifies synchronously — the search box has no batching to hide behind', () => {
|
||||
let notified = false;
|
||||
const off = searchStore.subscribe(() => {
|
||||
notified = true;
|
||||
});
|
||||
|
||||
searchStore.setTerm('x');
|
||||
off();
|
||||
|
||||
expect(notified).toBe(true);
|
||||
});
|
||||
|
||||
it('knows which views the search box applies to', () => {
|
||||
const searchable = [
|
||||
'tracks',
|
||||
'albums',
|
||||
'playlists',
|
||||
'playlist-details',
|
||||
'artists',
|
||||
'genres',
|
||||
].map((view) => {
|
||||
searchStore.setCurrentView(view);
|
||||
|
||||
return searchStore.isSearchableView();
|
||||
});
|
||||
|
||||
expect(searchable.every(Boolean)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the search box on views it cannot filter', () => {
|
||||
const results = ['settings', 'explore', 'jobs', 'downloads'].map((view) => {
|
||||
searchStore.setCurrentView(view);
|
||||
|
||||
return searchStore.isSearchableView();
|
||||
});
|
||||
|
||||
expect(results).toEqual([false, false, false, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('track list store', () => {
|
||||
it('starts from the default column set', () => {
|
||||
expect(trackListStore.getState().columnIds.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('adopts the column order the backend pushes', () => {
|
||||
emit(Events.TrackListConfigChanged, {
|
||||
columns: [{ id: 'title' }, { id: 'artist' }],
|
||||
});
|
||||
|
||||
expect(trackListStore.getState().columnIds).toEqual(['title', 'artist']);
|
||||
});
|
||||
|
||||
it('sends columns back as objects, the shape the Go binding expects', async () => {
|
||||
await trackListStore.setColumns(['title', 'album']);
|
||||
|
||||
// A bare string array would be a type mismatch, and a Wails binding
|
||||
// called with wrong argument types never settles its callback.
|
||||
expect(lastCall('config.Config.SetTrackListColumns')?.args).toEqual([
|
||||
[{ id: 'title' }, { id: 'album' }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not apply a column change until the backend confirms it', async () => {
|
||||
emit(Events.TrackListConfigChanged, { columns: [{ id: 'title' }] });
|
||||
await trackListStore.setColumns(['title', 'album', 'year']);
|
||||
await flush();
|
||||
|
||||
expect(trackListStore.getState().columnIds).toEqual(['title']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('explore cache', () => {
|
||||
it('round-trips an artist by mbid', () => {
|
||||
exploreCache.setArtist('mbid-1', { mbid: 'mbid-1', name: 'Bowie' });
|
||||
|
||||
expect(exploreCache.getArtist('mbid-1')?.name).toBe('Bowie');
|
||||
});
|
||||
|
||||
it('misses cleanly for an unknown mbid', () => {
|
||||
expect(exploreCache.getArtist('nothing-here')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses to key anything under an empty mbid', () => {
|
||||
// An empty key would collide across every unidentified entity.
|
||||
exploreCache.setArtist('', { mbid: '', name: 'Unknown' });
|
||||
exploreCache.setAlbum('', { mbid: '', title: 'X', artistName: 'Y' });
|
||||
|
||||
expect([exploreCache.getArtist(''), exploreCache.getAlbum('')]).toEqual([
|
||||
undefined,
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
|
||||
it('overwrites an entry with richer data from a later fetch', () => {
|
||||
exploreCache.setArtist('mbid-2', { mbid: 'mbid-2', name: 'Eno' });
|
||||
exploreCache.setArtist('mbid-2', {
|
||||
mbid: 'mbid-2',
|
||||
name: 'Eno',
|
||||
imageURL: 'http://x/eno.jpg',
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect([
|
||||
exploreCache.getArtistAlbums('mbid-3')?.length,
|
||||
exploreCache.getArtistTopTracks('mbid-3')?.length,
|
||||
]).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it('populates artists and albums from one search result', () => {
|
||||
exploreCache.populateFromSearch(
|
||||
[{ mbid: 'a-1', name: 'Artist', _imageSmall: 's.jpg' }],
|
||||
[
|
||||
{
|
||||
mbid: 'rg-1',
|
||||
title: 'Album',
|
||||
artistCredit: 'Artist',
|
||||
_coverArt: 'c.jpg',
|
||||
firstReleaseDate: '1977',
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect([
|
||||
exploreCache.getArtist('a-1')?.imageSmall,
|
||||
exploreCache.getAlbum('rg-1')?.year,
|
||||
exploreCache.getAlbum('rg-1')?.artistName,
|
||||
]).toEqual(['s.jpg', '1977', 'Artist']);
|
||||
});
|
||||
|
||||
it('defaults a missing artist credit to empty rather than undefined', () => {
|
||||
exploreCache.populateFromSearch([], [{ mbid: 'rg-2', title: 'Untitled' }]);
|
||||
|
||||
expect(exploreCache.getAlbum('rg-2')?.artistName).toBe('');
|
||||
});
|
||||
|
||||
it('skips search entries that carry no mbid', () => {
|
||||
exploreCache.populateFromSearch(
|
||||
[{ name: 'Nameless' }],
|
||||
[{ title: 'Nameless' }],
|
||||
);
|
||||
|
||||
expect(exploreCache.getArtist('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Helpers on top of the Wails fake: pushing backend events, stubbing
|
||||
* bound methods, and inspecting what the frontend called back.
|
||||
*/
|
||||
import { wails, type BindingCall } from './wails-fake';
|
||||
|
||||
export { wails } from './wails-fake';
|
||||
|
||||
/**
|
||||
* Push a backend event into the page, exactly as `runtime.EventsEmit`
|
||||
* on the Go side would. Extra arguments become the event's data array.
|
||||
*/
|
||||
export function emit(name: string, ...data: unknown[]): void {
|
||||
wails.notify(name, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the return value of a bound method. The path is the one the
|
||||
* generated bindings use — `service.Type.Method`, e.g.
|
||||
* `config.Config.GetShortcuts`.
|
||||
*
|
||||
* A function value is called with the invocation's arguments, so a stub
|
||||
* can vary by input.
|
||||
*/
|
||||
export function stub(path: string, value: unknown): void {
|
||||
wails.stub(path, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a bound method fail, as a Go method returning an error does:
|
||||
* the promise rejects, it does not throw into the caller.
|
||||
*/
|
||||
export function stubFailure(path: string, message = 'backend error'): void {
|
||||
wails.stub(path, () => {
|
||||
throw new Error(message);
|
||||
});
|
||||
}
|
||||
|
||||
/** Every call made to a bound method, in order. */
|
||||
export function calls(path?: string): BindingCall[] {
|
||||
if (path === undefined) return wails.calls.slice();
|
||||
|
||||
return wails.calls.filter((c) => c.path === path);
|
||||
}
|
||||
|
||||
/** The most recent call to `path`, or undefined. */
|
||||
export function lastCall(path: string): BindingCall | undefined {
|
||||
return calls(path).at(-1);
|
||||
}
|
||||
|
||||
/** The argument list of the most recent call to `path`. */
|
||||
export function lastArgs(path: string): unknown[] | undefined {
|
||||
return lastCall(path)?.args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush pending microtasks. Stores coalesce subscriber notification
|
||||
* through `queueMicrotask`, so state is observable immediately but
|
||||
* subscribers are not — anything asserting on a subscriber must await
|
||||
* this first.
|
||||
*/
|
||||
export async function flush(): Promise<void> {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears recorded calls and stubs between tests. */
|
||||
export function resetHarness(): void {
|
||||
wails.reset();
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Mounting helpers for component tests.
|
||||
*
|
||||
* Components are mounted into a real document and queried through their
|
||||
* real (open) shadow roots — nothing here approximates the DOM, which
|
||||
* is the whole reason this tier runs in a browser.
|
||||
*/
|
||||
import type { LitElement } from 'lit';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
const mounted: HTMLElement[] = [];
|
||||
|
||||
/**
|
||||
* Create an element, apply properties, mount it and wait for Lit's
|
||||
* first render. Properties are set as *properties*, not attributes, so
|
||||
* non-string values survive.
|
||||
*/
|
||||
export async function fixture<T extends LitElement>(
|
||||
tag: string,
|
||||
props: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
const el = document.createElement(tag) as T;
|
||||
|
||||
Object.assign(el, props);
|
||||
document.body.append(el);
|
||||
mounted.push(el);
|
||||
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Apply properties to a mounted element and wait for the re-render. */
|
||||
export async function update<T extends LitElement>(
|
||||
el: T,
|
||||
props: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
Object.assign(el, props);
|
||||
el.requestUpdate();
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Remove everything mounted by this module. Called from setup. */
|
||||
export function cleanupFixtures(): void {
|
||||
while (mounted.length > 0) mounted.pop()?.remove();
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// SHADOW DOM QUERIES
|
||||
// ===================================================================
|
||||
|
||||
/** Query one element inside a component's shadow root. */
|
||||
export function shadow<E extends Element = Element>(
|
||||
host: Element,
|
||||
selector: string,
|
||||
): E | null {
|
||||
return host.shadowRoot?.querySelector<E>(selector) ?? null;
|
||||
}
|
||||
|
||||
/** Query all matching elements inside a component's shadow root. */
|
||||
export function shadowAll<E extends Element = Element>(
|
||||
host: Element,
|
||||
selector: string,
|
||||
): E[] {
|
||||
return [...(host.shadowRoot?.querySelectorAll<E>(selector) ?? [])];
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Trimmed text content of every match. */
|
||||
export function texts(host: Element, selector: string): string[] {
|
||||
return shadowAll(host, selector).map((el) => el.textContent?.trim() ?? '');
|
||||
}
|
||||
|
||||
/** The accessible names of every match, for assertions that mirror
|
||||
* what a screen reader — and a Playwright selector — would see. */
|
||||
export function labels(host: Element, selector: string): string[] {
|
||||
return shadowAll(host, selector).map(
|
||||
(el) => el.getAttribute('aria-label') ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/** Click something inside a shadow root and let the update settle. */
|
||||
export async function click(
|
||||
host: LitElement,
|
||||
selector: string,
|
||||
): Promise<void> {
|
||||
const target = shadow<HTMLElement>(host, selector);
|
||||
|
||||
if (!target) throw new Error(`no element matching ${selector}`);
|
||||
|
||||
target.click();
|
||||
await host.updateComplete;
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// VISUAL REGRESSION
|
||||
// ===================================================================
|
||||
|
||||
/**
|
||||
* Visual regression is opt-in: `toMatchScreenshot` baselines depend on
|
||||
* font hinting and compositing, so a baseline taken on one machine
|
||||
* fails on another for reasons that have nothing to do with the
|
||||
* component. `make ui-visual` sets YJ_VISUAL=1; the default run
|
||||
* asserts behaviour only.
|
||||
*/
|
||||
export const visualEnabled = import.meta.env['YJ_VISUAL'] === '1';
|
||||
|
||||
/**
|
||||
* Screenshot a component against its baseline, when visual regression
|
||||
* is enabled. A no-op otherwise — deliberately not a skipped test, so
|
||||
* the behavioural assertions around it still run.
|
||||
*/
|
||||
export async function visual(el: Element, name: string): Promise<void> {
|
||||
if (!visualEnabled) return;
|
||||
|
||||
await expect(el).toMatchScreenshot(name);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* A fake of the two globals the Wails runtime installs: `window.runtime`
|
||||
* and `window.go`.
|
||||
*
|
||||
* Everything in `frontend/wailsjs/` is a pure passthrough — every binding
|
||||
* is `window['go'][svc][Type][Method](args)` and every runtime call is
|
||||
* `window.runtime.X(...)`. So faking the globals means tests exercise the
|
||||
* *real* generated bindings and the *real* store code, and there is no
|
||||
* second description of the Wails layer free to drift from the first.
|
||||
*
|
||||
* The event dispatcher mirrors wails v2's
|
||||
* `internal/frontend/runtime/desktop/events.js` exactly, including
|
||||
* `maxCallbacks` expiry and the fact that `EventsEmit` notifies local JS
|
||||
* listeners *before* it notifies Go.
|
||||
*/
|
||||
|
||||
// ===================================================================
|
||||
// EVENT DISPATCH (mirrors desktop/events.js)
|
||||
// ===================================================================
|
||||
|
||||
type Callback = (...data: unknown[]) => void;
|
||||
|
||||
class Listener {
|
||||
private remaining: number;
|
||||
|
||||
constructor(
|
||||
readonly eventName: string,
|
||||
private readonly callback: Callback,
|
||||
maxCallbacks: number,
|
||||
) {
|
||||
this.remaining = maxCallbacks || -1;
|
||||
}
|
||||
|
||||
/** Invokes the callback; returns true if this listener is spent. */
|
||||
fire(data: unknown[]): boolean {
|
||||
this.callback(...data);
|
||||
|
||||
if (this.remaining === -1) return false;
|
||||
|
||||
this.remaining -= 1;
|
||||
|
||||
return this.remaining === 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Records one bound-method invocation. */
|
||||
export interface BindingCall {
|
||||
/** Dotted path, e.g. `queue.Queue.SetQueue`. */
|
||||
path: string;
|
||||
args: unknown[];
|
||||
}
|
||||
|
||||
type StubValue = unknown | ((...args: unknown[]) => unknown);
|
||||
|
||||
class WailsFake {
|
||||
private listeners = new Map<string, Listener[]>();
|
||||
private stubs = new Map<string, StubValue>();
|
||||
|
||||
/** Every bound-method call made since the last `reset()`. */
|
||||
readonly calls: BindingCall[] = [];
|
||||
|
||||
/** Every runtime (non-binding) call, e.g. `WindowSetTitle`. */
|
||||
readonly runtimeCalls: BindingCall[] = [];
|
||||
|
||||
// -- listener registry --
|
||||
|
||||
on(eventName: string, callback: Callback, maxCallbacks: number): () => void {
|
||||
const listener = new Listener(eventName, callback, maxCallbacks);
|
||||
const existing = this.listeners.get(eventName);
|
||||
|
||||
if (existing) {
|
||||
existing.push(listener);
|
||||
} else {
|
||||
this.listeners.set(eventName, [listener]);
|
||||
}
|
||||
|
||||
return () => this.off(eventName, listener);
|
||||
}
|
||||
|
||||
private off(eventName: string, listener: Listener): void {
|
||||
const list = this.listeners.get(eventName);
|
||||
|
||||
if (!list) return;
|
||||
|
||||
const idx = list.indexOf(listener);
|
||||
|
||||
if (idx >= 0) list.splice(idx, 1);
|
||||
if (list.length === 0) this.listeners.delete(eventName);
|
||||
}
|
||||
|
||||
offNamed(eventName: string, ...more: string[]): void {
|
||||
for (const name of [eventName, ...more]) {
|
||||
this.listeners.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
offAll(): void {
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver an event exactly as the backend push does. Iterates in
|
||||
* reverse and drops spent listeners, like `notifyListeners`.
|
||||
*/
|
||||
notify(eventName: string, data: unknown[]): void {
|
||||
const list = this.listeners.get(eventName);
|
||||
|
||||
if (!list || list.length === 0) return;
|
||||
|
||||
const snapshot = list.slice();
|
||||
|
||||
for (let i = snapshot.length - 1; i >= 0; i -= 1) {
|
||||
const listener = snapshot[i];
|
||||
|
||||
if (!listener) continue;
|
||||
|
||||
if (listener.fire(data)) snapshot.splice(i, 1);
|
||||
}
|
||||
|
||||
if (snapshot.length === 0) {
|
||||
this.listeners.delete(eventName);
|
||||
} else {
|
||||
this.listeners.set(eventName, snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
/** Names with at least one live listener — useful for assertions. */
|
||||
listenerNames(): string[] {
|
||||
return [...this.listeners.keys()].sort();
|
||||
}
|
||||
|
||||
// -- binding stubs --
|
||||
|
||||
stub(path: string, value: StubValue): void {
|
||||
this.stubs.set(path, value);
|
||||
}
|
||||
|
||||
invoke(path: string, args: unknown[]): Promise<unknown> {
|
||||
this.calls.push({ path, args });
|
||||
|
||||
const stub = this.stubs.get(path);
|
||||
|
||||
if (typeof stub === 'function') {
|
||||
// A throwing stub becomes a rejected promise, matching the real
|
||||
// bridge: a Go method returning an error rejects, it does not
|
||||
// throw synchronously into the caller.
|
||||
try {
|
||||
return Promise.resolve(
|
||||
(stub as (...a: unknown[]) => unknown)(...args),
|
||||
);
|
||||
} catch (err) {
|
||||
return Promise.reject(err instanceof Error ? err : new Error(String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(stub);
|
||||
}
|
||||
|
||||
recordRuntime(path: string, args: unknown[]): void {
|
||||
this.runtimeCalls.push({ path, args });
|
||||
}
|
||||
|
||||
/** Clears recorded calls and stubs. Listeners survive — the store
|
||||
* singletons that registered them are never re-imported. */
|
||||
reset(): void {
|
||||
this.calls.length = 0;
|
||||
this.runtimeCalls.length = 0;
|
||||
this.stubs.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// GLOBAL INSTALLATION
|
||||
// ===================================================================
|
||||
|
||||
export const wails = new WailsFake();
|
||||
|
||||
/** A `window.go` that materialises `svc.Type.Method` lazily. */
|
||||
function makeGoProxy(): unknown {
|
||||
const level = (prefix: string): unknown =>
|
||||
new Proxy(function () {} as unknown as Record<string, unknown>, {
|
||||
get(_target, prop: string | symbol) {
|
||||
if (typeof prop !== 'string') return undefined;
|
||||
|
||||
return level(prefix ? `${prefix}.${prop}` : prop);
|
||||
},
|
||||
apply(_target, _thisArg, args: unknown[]) {
|
||||
return wails.invoke(prefix, args);
|
||||
},
|
||||
});
|
||||
|
||||
return level('');
|
||||
}
|
||||
|
||||
/** A `window.runtime` with real event plumbing and recorded no-ops
|
||||
* for everything else (window, clipboard, browser, log). */
|
||||
function makeRuntimeProxy(): unknown {
|
||||
const real: Record<string, unknown> = {
|
||||
EventsOnMultiple: (name: string, cb: Callback, max: number) =>
|
||||
wails.on(name, cb, max),
|
||||
EventsOn: (name: string, cb: Callback) => wails.on(name, cb, -1),
|
||||
EventsOnce: (name: string, cb: Callback) => wails.on(name, cb, 1),
|
||||
EventsOff: (name: string, ...more: string[]) =>
|
||||
wails.offNamed(name, ...more),
|
||||
EventsOffAll: () => wails.offAll(),
|
||||
// The real runtime notifies local JS listeners first, then Go.
|
||||
EventsEmit: (name: string, ...data: unknown[]) => {
|
||||
wails.recordRuntime(`EventsEmit:${name}`, data);
|
||||
wails.notify(name, data);
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy(real, {
|
||||
get(target, prop: string | symbol) {
|
||||
if (typeof prop !== 'string') return undefined;
|
||||
if (prop in target) return target[prop];
|
||||
|
||||
return (...args: unknown[]) => {
|
||||
wails.recordRuntime(prop, args);
|
||||
|
||||
return undefined;
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
go: unknown;
|
||||
runtime: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs the fake. Must run before any module that imports a store,
|
||||
* because the store singletons call `EventsOn` in their constructors at
|
||||
* import time. `setupFiles` runs before test modules, which is exactly
|
||||
* the window we need.
|
||||
*/
|
||||
export function installWailsFake(): void {
|
||||
window.go = makeGoProxy();
|
||||
window.runtime = makeRuntimeProxy();
|
||||
}
|
||||
@@ -31,9 +31,10 @@
|
||||
"@pages/*": ["./src/pages/*"],
|
||||
"@runtime/*": ["./wailsjs/runtime/*"],
|
||||
"@utils/*": ["./src/utils/*"],
|
||||
"@store/*": ["./src/store/*"]
|
||||
"@store/*": ["./src/store/*"],
|
||||
"@test/*": ["./test/*"]
|
||||
},
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "@vitest/browser-playwright"],
|
||||
// needed for Lit Elements
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false, // allows us to define properties as class fields
|
||||
@@ -43,7 +44,7 @@
|
||||
{"name": "typescript-lit-html-plugin"}
|
||||
]
|
||||
},
|
||||
"include": ["src", "wailsjs"],
|
||||
"include": ["src", "wailsjs", "test"],
|
||||
// Wails-generated JS stubs use @ts-check with untyped patterns that
|
||||
// violate strict mode. The companion .d.ts files provide the types.
|
||||
"exclude": ["wailsjs/go/**/*.js"]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import path from 'path';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { defineConfig, mergeConfig } from 'vitest/config';
|
||||
|
||||
import viteConfig from './vite.config.mts';
|
||||
|
||||
// Visual regression is opt-in. `toMatchScreenshot` baselines are
|
||||
// sensitive to font hinting and GPU compositing, so a baseline taken on
|
||||
// one machine fails on another for reasons that have nothing to do with
|
||||
// the component. Behavioural assertions run everywhere; screenshots run
|
||||
// where the baselines were taken (`make ui-visual`, or CI's container).
|
||||
const screenshotsEnabled = process.env['YJ_VISUAL'] === '1';
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
// The Playwright *runner* lives in e2e/, a separate package, so its
|
||||
// specs can never be picked up here and the two Playwright
|
||||
// versions cannot collide.
|
||||
include: ['test/**/*.test.ts'],
|
||||
setupFiles: ['./test/setup.ts'],
|
||||
globals: false,
|
||||
browser: {
|
||||
enabled: true,
|
||||
provider: playwright(),
|
||||
headless: true,
|
||||
// WebKit's Linux build wants Ubuntu libraries Arch does not
|
||||
// have, so it is a CI-only browser. See .planning/NOTES.md.
|
||||
instances: [{ browser: 'chromium' }],
|
||||
viewport: { width: 1280, height: 800 },
|
||||
screenshotFailures: false,
|
||||
expect: {
|
||||
toMatchScreenshot: {
|
||||
comparatorName: 'pixelmatch',
|
||||
comparatorOptions: { allowedMismatchedPixelRatio: 0.02 },
|
||||
},
|
||||
},
|
||||
},
|
||||
expect: { requireAssertions: true },
|
||||
env: { YJ_VISUAL: screenshotsEnabled ? '1' : '' },
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@test': path.resolve(__dirname, 'test'),
|
||||
},
|
||||
},
|
||||
// Pre-bundle what the components pull in, or Vite discovers it
|
||||
// mid-run and reloads the page underneath a running test.
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'lit',
|
||||
'lit/decorators.js',
|
||||
'lit/directives/repeat.js',
|
||||
'@lit-labs/virtualizer',
|
||||
// Web Awesome components are deep imports, one per element;
|
||||
// the glob pre-bundles them all rather than discovering each
|
||||
// one the first time a component under test imports it.
|
||||
'@awesome.me/webawesome/dist/components/*/*.js',
|
||||
'@awesome.me/webawesome/dist/webawesome.js',
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user