mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-21 12:21:49 +00:00
The v3 Songs grid appended every scrolled page and never released nodes, so card-node count grew unbounded with scroll depth (24 → 624 → 2001 for a 2000-song library). Replace it with a windowed/recycled render: only the visible window (± overscan) is in the DOM while a #v3-songs-gridsizer element sized to ceil(total/cols)*rowH gives the scrollbar full-library geometry; #v3-songs-grid is absolutely positioned to the first visible row. - state.songs is a sparse, absolutely-indexed store filled a page at a time by ensureWindow(): the stage-1 keyset cursor for contiguous forward scroll (O(page)), OFFSET page= for jumps/restore/non-keyset providers. _loadPage shares an in-flight promise per page and an epoch guard discards a stale fetch that lands after a reset. - A–Z rail seeks directly via sort_letters cumulative counts (O(1), no page-through); bounded scan fallback for legacy providers without it. - Snapshot/restore is now scrollTop-based (geometry is stable). Select mode, accuracy badges, ⋮ menu, plugin card actions, and tree/folder coexistence survive cards recycling; renderWindow re-renders when select mode toggles. - Plugins get window.v3Songs.visibleCards() + a v3:library-window-rendered event instead of assuming all cards are present (highway-stutter lesson). Verified in a browser against a seeded 2001-song library: DOM bounded to ~60 nodes while the count reads "2001 songs", rail jump lands on the target row, selection survives recycling, scroll-restore exact. Codex-reviewed (3 findings fixed: stale-fetch epoch guard, await-in-flight page promise, select-mode resync on cached re-entry). Frontend-only. Tests: tests/browser/v3-grid-virtualization.spec.ts pins the bounded-DOM invariant + direct rail jump; tests/js/v3_az_rail.test.js and v3_songs_scroll.test.js updated to the new wiring. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
6.0 KiB
TypeScript
131 lines
6.0 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
|
||
// Pins the bounded-DOM invariant of the windowed v3 Songs grid (#636 item 3
|
||
// stage 2). Before virtualization the grid appended every scrolled page, so for
|
||
// a 2000-song library the card-node count grew unbounded (24 → 624 → 2001).
|
||
// Now only the visible window (± overscan) is ever in the DOM while a sizer
|
||
// element gives the scrollbar the full-library geometry.
|
||
//
|
||
// Route-mocked (same strategy as v3-tree-select.spec.ts) so the invariant is
|
||
// deterministic in CI without a seeded 2000-row library: /api/library serves a
|
||
// synthetic page from the page/after param with total 2001, and the keyset
|
||
// cursor is mocked as the next absolute offset.
|
||
|
||
const TOTAL = 2001;
|
||
const PAGE_SIZE = 24;
|
||
const COLS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||
|
||
// Same bucketing as the seed/server: index % 26 → a first letter, so the A–Z
|
||
// rail has real buckets and a jump has somewhere to land.
|
||
function songAt(i: number) {
|
||
const letter = COLS[i % 26];
|
||
return {
|
||
filename: `seed/${String(i).padStart(5, '0')}.sloppak`,
|
||
title: `Song ${String(i).padStart(4, '0')}`,
|
||
artist: `${letter}Band ${String(i).padStart(4, '0')}`,
|
||
album: `${letter} Album`,
|
||
format: 'sloppak',
|
||
arrangements: [{ index: 0, name: 'Lead' }, { index: 1, name: 'Rhythm' }],
|
||
};
|
||
}
|
||
|
||
// sort_letters song-counts per bucket for index%26 over [0, TOTAL).
|
||
function sortLetters() {
|
||
const m: Record<string, number> = {};
|
||
for (let i = 0; i < TOTAL; i++) { const L = COLS[i % 26]; m[L] = (m[L] || 0) + 1; }
|
||
return m;
|
||
}
|
||
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.route('**/api/library?**', async (route) => {
|
||
const url = new URL(route.request().url());
|
||
const after = url.searchParams.get('after');
|
||
const size = Number(url.searchParams.get('size') || PAGE_SIZE);
|
||
const offset = after != null ? Number(after) : Number(url.searchParams.get('page') || '0') * size;
|
||
const songs = [];
|
||
for (let i = offset; i < Math.min(TOTAL, offset + size); i++) songs.push(songAt(i));
|
||
const nextOffset = offset + size;
|
||
await route.fulfill({
|
||
json: {
|
||
songs, total: TOTAL, page: Math.floor(offset / size), size,
|
||
next_cursor: nextOffset < TOTAL ? String(nextOffset) : null,
|
||
},
|
||
});
|
||
});
|
||
await page.route('**/api/library/stats**', (route) => {
|
||
const url = new URL(route.request().url());
|
||
const body: any = { total_songs: TOTAL, total: TOTAL, letters: {} };
|
||
if (url.searchParams.get('sort_letters')) body.sort_letters = sortLetters();
|
||
return route.fulfill({ json: body });
|
||
});
|
||
await page.route('**/api/library/artists**', (route) => route.fulfill({ json: { artists: [], total_artists: 0 } }));
|
||
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/stats/recent**', (route) => route.fulfill({ json: [] }));
|
||
});
|
||
|
||
async function openSongs(page) {
|
||
await page.goto('/');
|
||
await page.waitForSelector('.screen.active', { timeout: 10000 });
|
||
await page.evaluate(() => {
|
||
// @ts-ignore — neutralize playback so a stray click can't navigate away.
|
||
window.playSong = () => Promise.resolve();
|
||
// @ts-ignore
|
||
window.showScreen('v3-songs');
|
||
});
|
||
await page.waitForSelector('#v3-songs-grid [data-fn]', { state: 'attached', timeout: 10000 });
|
||
}
|
||
|
||
test('the grid keeps a bounded number of card nodes while scrolling a 2001-song library', async ({ page }) => {
|
||
await openSongs(page);
|
||
|
||
// The count reflects the FULL library even though only a window is rendered.
|
||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||
|
||
// The sizer reserves the full scroll height (so the scrollbar is library-wide).
|
||
const scrollHeight = await page.evaluate(() => document.getElementById('v3-main')!.scrollHeight);
|
||
expect(scrollHeight).toBeGreaterThan(20000);
|
||
|
||
// Scroll the whole library; the in-DOM card count must stay bounded throughout.
|
||
const CAP = 150;
|
||
let maxNodes = await page.locator('#v3-songs-grid [data-fn]').count();
|
||
for (let s = 0; s < 50; s++) {
|
||
await page.evaluate(() => { const m = document.getElementById('v3-main')!; m.scrollTop += m.clientHeight * 0.85; });
|
||
await page.waitForTimeout(60);
|
||
const n = await page.locator('#v3-songs-grid [data-fn]').count();
|
||
maxNodes = Math.max(maxNodes, n);
|
||
expect(n).toBeLessThanOrEqual(CAP);
|
||
}
|
||
// Sanity: we actually rendered a window (not zero), and stayed well under the
|
||
// unbounded 2001 the old append-everything grid would have produced.
|
||
expect(maxNodes).toBeGreaterThan(0);
|
||
expect(maxNodes).toBeLessThanOrEqual(CAP);
|
||
|
||
// The count is still correct after scrolling to the end.
|
||
await expect(page.locator('#v3-songs-count')).toHaveText('2001 songs');
|
||
});
|
||
|
||
test('the A–Z rail jumps directly to a letter without loading every page', async ({ page }) => {
|
||
await openSongs(page);
|
||
await page.waitForSelector('.v3-azrail-letter', { state: 'attached', timeout: 10000 });
|
||
|
||
// Jump to 'M'; the window scrolls to the row holding the first 'M' card.
|
||
await page.evaluate(() => {
|
||
const b = [...document.querySelectorAll('.v3-azrail-letter')]
|
||
.find((x) => x.getAttribute('data-letter') === 'M' && !(x as HTMLButtonElement).disabled) as HTMLElement | undefined;
|
||
if (!b) throw new Error('no M rail letter'); b.click();
|
||
});
|
||
|
||
// After the jump+window render, an 'M' card is present near the top of the
|
||
// viewport (the jump is O(1) via sort_letters, not a full page-through).
|
||
await expect.poll(async () => page.evaluate(() => {
|
||
const main = document.getElementById('v3-main')!;
|
||
const top = main.getBoundingClientRect().top + (document.getElementById('v3-songs-toolbar')?.offsetHeight || 0);
|
||
return [...document.querySelectorAll('#v3-songs-grid [data-fn]')].some((c) => {
|
||
const r = c.getBoundingClientRect();
|
||
return c.getAttribute('data-letter') === 'M' && r.top >= top - 4 && r.top < top + 320;
|
||
});
|
||
}), { timeout: 5000 }).toBe(true);
|
||
});
|