mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-17 14:08:22 +00:00
Fix list/tree view: select mode, parts visibility, song actions (#585)
* Fix list/tree view: select mode, parts visibility, song actions Bring the v3 list/tree view to parity with the grid card: - Select mode now renders a per-row checkbox + selected-ring, preserves expanded artist groups across re-render, and a capture-phase guard makes a row/chip click select the song instead of starting playback. - Always-on favourite / save-for-later / overflow-menu cluster on each row, same actions as the grid card. Rebuild static/tailwind.min.css so the new utilities are compiled in - notably .sm:flex behind the arrangement chips' "hidden sm:flex" wrapper. Without it the chips (and #582's badges) render display:none on the Docker build, which serves the committed CSS; the desktop build looked fine only because it rebuilds Tailwind from source at bundle time. Signed-off-by: Sin <deathlysin@outlook.com> * fix(v3): regenerate tailwind.min.css from source + add tree select tests + CHANGELOG The committed tailwind.min.css was over-built: 135,578 bytes / 1,428 selectors, with 294 selectors (accent-amber-400, bg-cyan-500, animate-spin, after:bg-gray-400, …) used in zero core source files — bloat from a local build scanning outside the repo's content globs. It would fail CI's rebuild-and-diff and violates the byte-stable rule in scripts/build-tailwind.sh. Regenerate via `scripts/build-tailwind.sh` (pinned tailwindcss@3.4.19): 111,491 bytes / 1,134 selectors, byte-identical to a clean rebuild, still containing the .sm\:flex fix plus every new tree class (ring-fb-primary, accent-fb-primary, pointer-events-none, …). Docker chips now render and CI stays green. Add tests/browser/v3-tree-select.spec.ts: - select mode keeps expanded artist groups open across the tree re-render (fails without loadTree's openArtists capture/restore) - clicking a row in select mode selects instead of playing Record the fix under CHANGELOG [Unreleased] -> Fixed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Sin <deathlysin@outlook.com> Co-authored-by: byrongamatos <xasiklas@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
byrongamatos
parent
82db8e56b1
commit
8b4c9b0050
@@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
- **v3 library: exact artist/album filters + scroll/page-depth restore** (feedBack#857). The v3 Songs toolbar gains Artist and Album dropdowns (Album populates from the selected artist and stays disabled until one is chosen), backed by new exact, case-insensitive (`COLLATE NOCASE`) `artist` / `album` query params threaded through `MetadataDB._build_where` → `query_page` / `query_artists` / `query_stats` and the `/api/library`, `/api/library/artists`, `/api/library/stats` endpoints (the free-text `q` search stays fuzzy and composes with the exact filters). The artist/album catalog is fetched independently of the active artist/album selection so the dropdowns always list the full set for the current provider/search. The toolbar is now sticky so filter controls stay reachable when browsing deep libraries, and returning from the player restores the previous scroll position **and** the loaded infinite-scroll page depth via a `sessionStorage` snapshot keyed by a filter/sort/view state hash (invalidated whenever those change, so a filter change still resets to the top). Tests: `tests/test_library_filters.py` (backend artist/album filters), `tests/js/v3_songs_scroll.test.js` (state-hash + snapshot helpers).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **v3 list/tree view brought to parity with the grid: select mode, parts chips, and song actions — plus a stale-CSS Docker fix.** Re-lands a previously-reverted change. **Frontend (`static/v3/songs.js`):** entering select mode no longer collapses the tree — `loadTree()` now captures the expanded artist groups (`details[open]` keyed by `data-artist`) before the "Loading…" wipe and restores them on rebuild, so toggling select mode (which re-renders via `reload()`) keeps groups open and selection usable; tree rows gain a display-only checkbox + selection ring, the same fav / save-for-later / overflow-menu cluster as the grid card (always shown, all bound by `wireCards()`), and a capture-phase select guard mirroring the grid so clicking a row or arrangement chip in select mode selects instead of playing (`<summary>` headers sit outside `[data-fn]`, so native expand/collapse is untouched). **Docker fix (`static/tailwind.min.css`):** the committed Tailwind stylesheet was stale — `.sm\:flex` (and the other utilities behind #582's `hidden sm:flex` arrangement chips and the new action cluster) were never compiled in, so they rendered `display:none` on the Docker build (which serves the committed CSS as-is; Desktop rebuilds from source so it looked fine). Regenerated with the pinned `tailwindcss@3.4.19` via `scripts/build-tailwind.sh` so Docker matches Desktop and #582's chips render on every Docker deploy. Regression tests: `tests/browser/v3-tree-select.spec.ts`.
|
||||||
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked` → `_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
|
- **Space bar now plays/pauses on the player screen even when a sidebar nav link or rail button has focus.** When any `<button>` in the player rail (viz, audio, mixer, lyrics, plugins, advanced), a sidebar nav link, or a popover control held keyboard focus, pressing Space was swallowed by `_shortcutDispatchBlocked` → `_isInsideInteractiveControl` (which treats `BUTTON`/`A` as interactive), so the Space shortcut never reached the dispatcher and `togglePlay()` never ran. `_shortcutDispatchBlocked` (`static/app.js`) now extends the same carve-out already used for the Section Practice bar: while the player screen is active, Space is always routed through the shortcut system — the dispatcher calls `e.preventDefault()` before invoking the handler, so the focused element does not also activate. Text inputs (`_isTextInput`) remain exempted first, so typing space in a search/input field still works normally, and focus inside a true modal dialog (`role="dialog" aria-modal="true"` / `.feedBack-modal`) layered over the player is also exempted so Space reaches the modal's focused control (e.g. its Close button) instead of toggling playback behind it — non-modal player popovers/toasts (loop A/B, arrangement pin) stay covered. Regression tests in `tests/browser/keyboard-shortcuts.spec.ts` cover the focused-rail-button play/pause, the text-input exemption, and the modal-dialog exemption.
|
||||||
- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced.
|
- **A song's accuracy badge now updates on its library card right after you play it — no restart needed.** The v3 library (`static/v3/songs.js`) loaded the best-accuracy map (`/api/stats/best`) once into `state.accuracy` at render time and only ever refreshed it on a full re-render; the play→return flow takes the screen-entry fast-path that restores the cached grid DOM without re-fetching, so a just-earned score stayed invisible until the next restart re-ran `render()`. The `stats-recorder` now emits a `stats:recorded` event (carrying `filename`/`arrangement`) once the scored `POST /api/stats` resolves on the server — the correct moment, since `song:stop` fires before the POST completes. `songs.js` listens: if the library is the active screen it re-fetches `/api/stats/best` and patches the affected card/row badge in place; otherwise it marks the filename dirty and `onV3SongsScreenEnter` applies it on return (a failed fetch keeps the entry dirty to retry). Badge markup was factored into a shared `accuracyBadge(filename, variant)` (grid pill + tree-row percentage, both tagged `.fb-acc-badge`) so the in-place `repaintAccuracy` can find and replace them without a full list re-render (scroll/pagination preserved). The old empty `song:stop` "refresh lazily next render" placeholder is replaced.
|
||||||
- **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior.
|
- **Changing Settings → 3D Highway → Fret spacing no longer ejects you to the home screen.** The `highway_3d` plugin's `h3dSetFretSpacing` was the lone 3D-highway setting that called `location.reload()` to apply — and since the SPA boots with `#home` as the active screen (`index.html` `.screen.active`), the reload dropped the user out of Settings onto the homescreen. It now applies live like every other 3D-highway setting: it rebinds the module-scope `_h3dFretUniform` flag (so panels mounted later this session pick up the new mode), recomputes the two `fretX`-derived scalars that were baked at init (`_fretLabelScaleRefW` for fret-label sprite scaling, `FRET_WIDTH_MID` for camera hysteresis), and broadcasts a `fretSpacing` change over the existing `_bgEmitChange` pub-sub so every mounted panel rebuilds its board via `buildBoard()`. Per-frame note geometry already reads `fretX` live and needs no rebuild. No page reload, so the Settings screen stays put. Source-level regression tests in `tests/js/highway_3d_fret_spacing.test.js` now pin the no-reload / live-rebuild behavior.
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+39
-5
@@ -710,6 +710,10 @@
|
|||||||
async function loadTree() {
|
async function loadTree() {
|
||||||
const host = document.getElementById('v3-songs-tree');
|
const host = document.getElementById('v3-songs-tree');
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
// Capture expanded groups BEFORE the "Loading…" wipe below, so a reload
|
||||||
|
// (e.g. toggling select mode) restores them instead of collapsing all.
|
||||||
|
const openArtists = new Set(
|
||||||
|
[...host.querySelectorAll('details[open]')].map((d) => d.getAttribute('data-artist')));
|
||||||
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
||||||
// Page through ALL artists — the endpoint clamps size to 100, so a
|
// Page through ALL artists — the endpoint clamps size to 100, so a
|
||||||
// single request would silently truncate libraries with >100 artists.
|
// single request would silently truncate libraries with >100 artists.
|
||||||
@@ -726,18 +730,33 @@
|
|||||||
if (!artists.length) { host.innerHTML = '<p class="text-fb-textDim text-sm">Nothing here.</p>'; return; }
|
if (!artists.length) { host.innerHTML = '<p class="text-fb-textDim text-sm">Nothing here.</p>'; return; }
|
||||||
artists.forEach((a) => (a.albums || []).forEach((al) => (al.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; })));
|
artists.forEach((a) => (a.albums || []).forEach((al) => (al.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; })));
|
||||||
host.innerHTML = artists.map((a) =>
|
host.innerHTML = artists.map((a) =>
|
||||||
'<details class="border-b border-fb-border/40"><summary class="cursor-pointer py-2 text-fb-text flex items-center justify-between">' +
|
'<details data-artist="' + esc(a.name) + '"' + (openArtists.has(a.name) ? ' open' : '') + ' class="border-b border-fb-border/40"><summary class="cursor-pointer py-2 text-fb-text flex items-center justify-between">' +
|
||||||
'<span>' + esc(a.name) + '</span><span class="text-xs text-fb-textDim">' + esc(a.song_count) + '</span></summary>' +
|
'<span>' + esc(a.name) + '</span><span class="text-xs text-fb-textDim">' + esc(a.song_count) + '</span></summary>' +
|
||||||
'<div class="pl-3 pb-2 space-y-2">' + (a.albums || []).map((al) =>
|
'<div class="pl-3 pb-2 space-y-2">' + (a.albums || []).map((al) =>
|
||||||
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' +
|
'<div><div class="text-xs uppercase tracking-wider text-fb-textDim/70 mt-2 mb-1">' + esc(al.name || 'Unknown') + '</div>' +
|
||||||
(al.songs || []).map((s) => { const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); return (
|
(al.songs || []).map((s) => {
|
||||||
'<div class="flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
const k = cardKey(s); const fl = fmtLabel(s); const chips = arrChipsHtml(s); const sel = state.selected.has(k);
|
||||||
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
|
// Display-only checkbox (pointer-events-none); the row's
|
||||||
|
// capture-phase select handler (render()) owns the toggle.
|
||||||
|
const checkbox = state.selectMode
|
||||||
|
? '<input type="checkbox" data-select class="shrink-0 w-5 h-5 accent-fb-primary pointer-events-none"' + (sel ? ' checked' : '') + '>'
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
'<div class="relative flex items-center gap-2 py-1 group" data-fn="' + esc(k) + '" data-library-song="' + esc(songId(s)) + '" data-library-provider="' + esc(state.provider) + '">' +
|
||||||
|
checkbox +
|
||||||
|
'<img src="' + esc(artUrl(s)) + '" alt="" loading="lazy" decoding="async" class="w-8 h-8 rounded object-cover bg-fb-card cursor-pointer' + (sel ? ' ring-2 ring-fb-primary' : '') + '" data-v3-play onerror="this.style.visibility=\'hidden\'">' +
|
||||||
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
|
'<span class="flex-1 min-w-0 cursor-pointer" data-v3-play><span class="block text-sm text-fb-text truncate">' + esc(s.title) + '</span></span>' +
|
||||||
(chips ? '<span class="hidden sm:flex items-center gap-1 shrink-0">' + chips + '</span>' : '') +
|
(chips ? '<span class="hidden sm:flex items-center gap-1 shrink-0">' + chips + '</span>' : '') +
|
||||||
(fl ? '<span class="text-[9px] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
|
(fl ? '<span class="text-[9px] font-bold px-1 py-0.5 rounded shrink-0 ' + (fl === 'FEEDPAK' ? 'bg-fb-primary/20 text-fb-primary' : 'bg-fb-card text-fb-textDim') + '">' + fl + '</span>' : '') +
|
||||||
accuracyBadge(k, 'tree') +
|
accuracyBadge(k, 'tree') +
|
||||||
'<button data-fav class="opacity-0 group-hover:opacity-100 px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
|
// Same fav / save-for-later / overflow-menu cluster as the grid
|
||||||
|
// card. Always shown (like the arrangement chips), not hover-
|
||||||
|
// revealed. wireCards() binds all three for any [data-fn].
|
||||||
|
'<div class="flex items-center gap-0.5 shrink-0">' +
|
||||||
|
'<button data-fav title="Favorite" aria-label="Favorite" aria-pressed="' + (s.favorite ? 'true' : 'false') + '" class="px-1 ' + (s.favorite ? 'text-fb-accent' : 'text-fb-textDim') + '">' + (s.favorite ? '♥' : '♡') + '</button>' +
|
||||||
|
'<button data-save title="Save for later" aria-label="Save for later" class="px-1 text-fb-textDim hover:text-fb-text">🔖</button>' +
|
||||||
|
'<button data-menu title="More" aria-label="More actions" class="px-1 text-fb-textDim hover:text-fb-text leading-none">⋮</button>' +
|
||||||
|
'</div>' +
|
||||||
'</div>'); }).join('') + '</div>').join('') + '</div></details>').join('');
|
'</div>'); }).join('') + '</div>').join('') + '</div></details>').join('');
|
||||||
wireCards(host);
|
wireCards(host);
|
||||||
}
|
}
|
||||||
@@ -945,6 +964,21 @@
|
|||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
toggleSelect(card.getAttribute('data-fn'), card);
|
toggleSelect(card.getAttribute('data-fn'), card);
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
|
// Same bulletproof guard for the list/tree view. Without it, clicking a
|
||||||
|
// song row (or its arrangement chip) in select mode falls through to the
|
||||||
|
// per-card play handler and starts playback instead of selecting. The
|
||||||
|
// <summary> group headers sit OUTSIDE any [data-fn], so closest() is null
|
||||||
|
// for them and their native expand/collapse is left untouched.
|
||||||
|
const treeEl = byId('v3-songs-tree');
|
||||||
|
if (treeEl) treeEl.addEventListener('click', (e) => {
|
||||||
|
if (!state.selectMode) return;
|
||||||
|
const card = e.target.closest('[data-fn]');
|
||||||
|
if (!card || !treeEl.contains(card)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
toggleSelect(card.getAttribute('data-fn'), card);
|
||||||
|
}, true);
|
||||||
const setView = (v) => {
|
const setView = (v) => {
|
||||||
state.view = v;
|
state.view = v;
|
||||||
byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// Regression coverage for the list/tree view select-mode fix (PR #585, which
|
||||||
|
// re-lands a change that was reverted). The core bug: entering select mode
|
||||||
|
// re-renders the tree (setSelectMode -> reload -> loadTree), and the rebuild
|
||||||
|
// wiped every expanded <details>, collapsing the tree and making selection
|
||||||
|
// unusable. The fix captures the open artist groups before the wipe and
|
||||||
|
// restores them. We also cover: clicking a row in select mode selects instead
|
||||||
|
// of playing.
|
||||||
|
//
|
||||||
|
// Navigation uses programmatic element.click() rather than Playwright's
|
||||||
|
// actionability-gated click: this screen briefly re-renders its toolbar and
|
||||||
|
// the harness can show transient overlays, but element.click() still
|
||||||
|
// dispatches a real bubbling event through the capture-phase select handler.
|
||||||
|
|
||||||
|
const ARTISTS = {
|
||||||
|
artists: [
|
||||||
|
{
|
||||||
|
name: 'Alpha Band',
|
||||||
|
song_count: 2,
|
||||||
|
albums: [{ name: 'First Album', songs: [
|
||||||
|
{ filename: 'alpha/one.sloppak', title: 'Alpha One', artist: 'Alpha Band', album: 'First Album' },
|
||||||
|
{ filename: 'alpha/two.sloppak', title: 'Alpha Two', artist: 'Alpha Band', album: 'First Album' },
|
||||||
|
] }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Beta Crew',
|
||||||
|
song_count: 1,
|
||||||
|
albums: [{ name: 'Beta LP', songs: [
|
||||||
|
{ filename: 'beta/solo.sloppak', title: 'Beta Solo', artist: 'Beta Crew', album: 'Beta LP' },
|
||||||
|
] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total_artists: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Paged artists endpoint (used by both the tree and the artist catalog):
|
||||||
|
// page 0 returns data, later pages return empty so the paging loop ends.
|
||||||
|
await page.route('**/api/library/artists**', async route => {
|
||||||
|
const pageNum = Number(new URL(route.request().url()).searchParams.get('page') || '0');
|
||||||
|
await route.fulfill({ json: pageNum === 0 ? ARTISTS : { artists: [], total_artists: 2 } });
|
||||||
|
});
|
||||||
|
await page.route('**/api/library/providers', route => route.fulfill({ json: { providers: [{ id: 'local', label: 'My Library' }] } }));
|
||||||
|
await page.route('**/api/library/tuning-names**', route => route.fulfill({ json: { tunings: [] } }));
|
||||||
|
await page.route('**/api/stats/best', route => route.fulfill({ json: {} }));
|
||||||
|
await page.route('**/api/library?**', route => route.fulfill({ json: { songs: [], total: 0, page: 0, size: 60 } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Programmatic click — fires a real bubbling click through capture-phase
|
||||||
|
// handlers without Playwright's actionability gate.
|
||||||
|
async function clickSel(page, selector: string) {
|
||||||
|
await page.evaluate((s) => {
|
||||||
|
const el = document.querySelector(s) as HTMLElement | null;
|
||||||
|
if (!el) throw new Error('not found: ' + s);
|
||||||
|
el.click();
|
||||||
|
}, selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTree(page) {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||||||
|
await page.evaluate(() => {
|
||||||
|
// @ts-ignore — record playback so an accidental row-click is detectable.
|
||||||
|
window.__played = 0;
|
||||||
|
// @ts-ignore
|
||||||
|
window.playSong = () => { window.__played++; return Promise.resolve(); };
|
||||||
|
// @ts-ignore
|
||||||
|
window.showScreen('v3-songs');
|
||||||
|
});
|
||||||
|
await page.waitForSelector('#v3-songs-tree-btn', { state: 'attached', timeout: 8000 });
|
||||||
|
await clickSel(page, '#v3-songs-tree-btn');
|
||||||
|
await page.waitForSelector('#v3-songs-tree details', { state: 'attached', timeout: 8000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the <details> whose <summary> names the given artist.
|
||||||
|
function group(page, artist: string) {
|
||||||
|
return page.locator('#v3-songs-tree details', { has: page.locator('summary', { hasText: artist }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('select mode keeps expanded artist groups open across the tree re-render (#585)', async ({ page }) => {
|
||||||
|
await openTree(page);
|
||||||
|
|
||||||
|
// Expand Alpha (the precondition the bug used to destroy on re-render).
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const d = [...document.querySelectorAll('#v3-songs-tree details')]
|
||||||
|
.find((el) => el.querySelector('summary')?.textContent?.includes('Alpha Band')) as HTMLDetailsElement;
|
||||||
|
d.open = true;
|
||||||
|
});
|
||||||
|
await expect(group(page, 'Alpha Band')).toHaveAttribute('open', '');
|
||||||
|
|
||||||
|
// Enter select mode → triggers the full tree re-render.
|
||||||
|
await clickSel(page, '#v3-songs-select');
|
||||||
|
await page.waitForSelector('#v3-songs-tree input[data-select]', { state: 'attached', timeout: 8000 });
|
||||||
|
|
||||||
|
// The bug: Alpha collapses after the rebuild. The fix restores it.
|
||||||
|
await expect(group(page, 'Alpha Band')).toHaveAttribute('open', '');
|
||||||
|
// Beta was never opened — it must stay collapsed (no false restore).
|
||||||
|
await expect(group(page, 'Beta Crew')).not.toHaveAttribute('open', '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a tree row in select mode selects it instead of playing (#585)', async ({ page }) => {
|
||||||
|
await openTree(page);
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const d = [...document.querySelectorAll('#v3-songs-tree details')]
|
||||||
|
.find((el) => el.querySelector('summary')?.textContent?.includes('Alpha Band')) as HTMLDetailsElement;
|
||||||
|
d.open = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await clickSel(page, '#v3-songs-select');
|
||||||
|
await page.waitForSelector('#v3-songs-tree input[data-select]', { state: 'attached', timeout: 8000 });
|
||||||
|
|
||||||
|
await clickSel(page, '#v3-songs-tree [data-fn="alpha/one.sloppak"]');
|
||||||
|
|
||||||
|
await expect(page.locator('#v3-songs-tree [data-fn="alpha/one.sloppak"] input[data-select]')).toBeChecked();
|
||||||
|
expect(await page.evaluate(() => (window as any).__played)).toBe(0);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user