Files
feedBack/tests/js/v3_az_rail.test.js
T
a791a0d8fe feat(v3): DOM-virtualize the Songs grid (#636 item 3 stage 2) (#643)
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>
2026-06-29 12:26:29 +02:00

95 lines
4.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Pins the v3 Songs AZ jump rail wiring in static/v3/songs.js.
//
// The rail lets a user jump the library grid to artists/titles starting with a
// letter (Plex/Radarr/iOS-contacts pattern). With the windowed grid (#636 item 3
// stage 2) the jump seeks DIRECTLY: the sort_letters song-counts give the first
// card's absolute index (cumulative of prior buckets), which converts to a
// scrollTop — no page-through. The rail only offers letters the server reports
// present for the active sort+filter (so a tap always lands on a real card). It
// is shown only for the grid view + alphabetical (artist/title) sorts.
//
// Source-level only — same strategy as tests/js/highway_3d_camera_framing.test.js.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const src = fs.readFileSync(SONGS_JS, 'utf8');
test('the rail is context-gated to grid view + alphabetical sorts', () => {
// railSortColumn returns the active alpha column or null (recent/year/tuning).
assert.match(src, /function\s+railSortColumn\s*\(\)/);
assert.match(src, /state\.sort === 'artist'[\s\S]*?return 'artist'/);
assert.match(src, /state\.sort === 'title'[\s\S]*?return 'title'/);
assert.match(
src,
/function\s+railVisible\s*\(\)\s*\{\s*return\s+state\.view === 'grid'\s*&&\s*!!railSortColumn\(\)/,
'the rail must be visible only for the grid view + an alphabetical sort',
);
});
test('cards carry a data-letter bucket and non-AZ buckets under #', () => {
assert.match(src, /data-letter="'\s*\+\s*esc\(songBucket\(song\)\)/,
'each card must tag its sort-letter bucket via songBucket(song)');
assert.match(
src,
/function\s+songBucket[\s\S]*?\(ch >= 'A' && ch <= 'Z'\)\s*\?\s*ch\s*:\s*'#'/,
'songBucket must bucket non-AZ first chars under "#"',
);
});
test('refreshRail reads present letters from the stats endpoint (sort-aware)', () => {
assert.match(src, /\/api\/library\/stats\?'\s*\+\s*queryParams/,
'refreshRail must query /api/library/stats with the active filter params');
// Opts into the active-sort breakdown so non-rail callers skip the scan.
assert.match(src, /queryParams\(\{\s*sort_letters:\s*1\s*\}\)/,
'refreshRail must request the sort_letters breakdown');
assert.match(src, /letters\s*=\s*stats\s*&&\s*stats\.sort_letters/,
'refreshRail must prefer the active-sort breakdown (sort_letters)');
// The legacy artist `letters` is only a valid fallback for an artist sort;
// a title sort with no sort_letters hides the rail rather than mislabel it.
assert.match(src, /col === 'artist'[\s\S]*?stats\.letters/,
'refreshRail must only fall back to letters for an artist sort');
// Absent letters are disabled (non-interactive), not just dimmed.
assert.match(src, /present\s*\?\s*''\s*:\s*' disabled'/);
});
test('reload() refreshes the rail', () => {
assert.match(src, /function reload\s*\([\s\S]*?refreshRail\(\)/,
'reload() must call refreshRail() so the rail tracks filter/sort/view changes');
});
test('the rail + drag bubble are rendered in the Songs markup', () => {
assert.match(src, /id="v3-songs-azrail"[\s\S]*?aria-label="Jump to letter"/);
assert.match(src, /id="v3-songs-azbubble"/);
});
test('jumpToLetter seeks directly via sort_letters cumulative (no page-through)', () => {
// The cumulative-count seek: sum the song-counts of buckets ordered before
// the target to get its first row's absolute index.
assert.match(src, /function\s+_letterStartIndex\s*\(letter\)/,
'jumpToLetter must derive the target index from sort_letters counts');
assert.match(
src,
/async function\s+jumpToLetter[\s\S]*?_letterStartIndex\(letter\)[\s\S]*?scrollTo/,
'jumpToLetter must compute the target index then scrollTo (no _loadNextAwait page-through)',
);
// It pre-fetches the destination window so cards are ready when the scroll lands.
assert.match(src, /async function\s+jumpToLetter[\s\S]*?ensureWindow\(/,
'jumpToLetter must pre-fetch the destination window before scrolling');
// The old forward-paging helper is gone (the seek is O(1)).
assert.doesNotMatch(src, /_loadNextAwait/,
'the page-through helper must be removed under the windowed grid');
// A token still guards overlapping jumps (drag scrubbing) — newest wins.
assert.match(src, /_jumpToken\s*!==\s*myToken/);
});
test('the rail supports pointer drag-scrub + keyboard arrows', () => {
assert.match(src, /addEventListener\('pointerdown'/);
assert.match(src, /addEventListener\('pointermove'/);
assert.match(src, /ArrowUp'[\s\S]*?ArrowDown'|ArrowDown'[\s\S]*?ArrowUp'/,
'arrow keys must move between present letters');
});