GetTracks answered with one JSON object per track: 20.5 MB at 26k tracks, ~350 bytes a row of repeated key names, four cover URLs that are identical across an album, and the album, artist and genre strings repeated on every track. Encoding it was ~170 MB of transient Go allocation, and parsing it the WebView's memory peak. GetTrackTable replaces it: one array per column, every repeated string stored once and sent as an index, genre lists interned, and only the fields the Tracks view reads. LastPlayed and the three larger cover tiers are left to the details dialog, which now fetches whole tracks by path. Each row still goes through trackFromRow, so this is an encoding of the one projection, not a second one. 167 bytes a track against 929 in the test library; a Go test holds it under a quarter of the object encoding, and a Vitest test reads the Go columns and the generated Track interface and fails if they drift. An empty library is now an empty table rather than the "no tracks in library" error the old binding returned. Closes #281
183 lines
6.1 KiB
TypeScript
183 lines
6.1 KiB
TypeScript
/**
|
|
* Expanding an album shows its tracks.
|
|
*
|
|
* `perf.p2` files `cover-grid`'s `renderSplitGrid` as dead code carried
|
|
* in the bundle. It is not dead — it is a **missing feature**, and one
|
|
* whose data path was already working: pressing Enter on an album card
|
|
* fetched that album's tracks over the IPC, ran the whole split state
|
|
* machine (measured in the running app: `splitMode: true`,
|
|
* `splitIndex: 90` against a real container) and then drew the single
|
|
* grid regardless, because `render()` never consulted `splitMode`.
|
|
* `connectedCallback` referenced the method solely to satisfy
|
|
* `noUnusedLocals`.
|
|
*
|
|
* It matters because it is the only route from the albums grid to a
|
|
* *track*: a plain click on a card navigates to the catalog page, so
|
|
* without the dropdown the view could not show what is on an album.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest';
|
|
import type { LitElement } from 'lit';
|
|
|
|
import '@components/cover-grid/cover-grid';
|
|
import { emit, stub, flush, resetHarness } from '@test/support/harness';
|
|
import { Events } from '../../src/events';
|
|
import { fixture, shadow, shadowAll } from '@test/support/render';
|
|
import { trackTable } from '@test/support/track-table';
|
|
|
|
/**
|
|
* Enough albums to fill more than one row.
|
|
*
|
|
* The dropdown is drawn *after the row the expanded album is in*, so a
|
|
* library that fits on one row puts every album in the "before" half
|
|
* and renders no "after" grid at all — which is correct, and is not the
|
|
* arrangement this is checking.
|
|
*/
|
|
const ALBUMS = Array.from({ length: 24 }, (_, i) => ({
|
|
ID: i + 1,
|
|
Name: `Album ${i + 1}`,
|
|
ArtistName: 'Aurora Fields',
|
|
Year: 2019,
|
|
}));
|
|
|
|
const TRACKS = [
|
|
{ ID: 11, TrackName: 'Salt Air', FilePath: '/m/1.mp3', TrackNumber: 1 },
|
|
{ ID: 12, TrackName: 'Tideline', FilePath: '/m/2.mp3', TrackNumber: 2 },
|
|
];
|
|
|
|
/** Give the virtualizer a viewport; a zero-height host renders nothing. */
|
|
function sized(el: HTMLElement): void {
|
|
el.style.display = 'block';
|
|
el.style.height = '600px';
|
|
el.style.width = '900px';
|
|
}
|
|
|
|
async function settle(el: LitElement): Promise<void> {
|
|
await flush();
|
|
await el.updateComplete;
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
}
|
|
|
|
/**
|
|
* Open the dropdown the way the app does.
|
|
*
|
|
* A plain *click* navigates to the catalog page — Enter (or Space) on a
|
|
* focused card is the only thing that expands one, which is why the
|
|
* feature could be missing without anyone tripping over it.
|
|
*/
|
|
async function expandFirstCard(el: LitElement): Promise<void> {
|
|
const card = shadowAll(el, '.album-card')[0] as HTMLElement;
|
|
|
|
card.focus();
|
|
card.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: 'Enter',
|
|
bubbles: true,
|
|
composed: true,
|
|
}),
|
|
);
|
|
|
|
await settle(el);
|
|
}
|
|
|
|
describe('the album dropdown', () => {
|
|
beforeEach(() => {
|
|
resetHarness();
|
|
stub('library.Library.GetAlbums', ALBUMS);
|
|
stub('library.Library.GetTrackTable', trackTable([]));
|
|
stub('library.Library.GetAlbumTracks', TRACKS);
|
|
stub('library.Library.GetAlbumTracks', TRACKS);
|
|
emit(Events.LibraryScanComplete);
|
|
});
|
|
|
|
it('draws the tracks it fetches, between two halves of the grid', async () => {
|
|
const el = await fixture<LitElement>('cover-grid');
|
|
|
|
sized(el);
|
|
await settle(el);
|
|
|
|
expect(shadow(el, 'album-dropdown')).toBeNull();
|
|
|
|
await expandFirstCard(el);
|
|
|
|
// The state machine was always correct; what was missing was the
|
|
// render. Assert on what is *drawn*, so a future `render()` that
|
|
// stops consulting `splitMode` fails here rather than silently
|
|
// going back to fetching tracks nobody sees.
|
|
const dropdown = shadow(el, 'album-dropdown');
|
|
|
|
expect(dropdown).toBeTruthy();
|
|
expect(
|
|
dropdown!.shadowRoot!.querySelectorAll('.track-row'),
|
|
).toHaveLength(TRACKS.length);
|
|
|
|
const ids = shadowAll(el, 'lit-virtualizer').map((v) => v.id);
|
|
|
|
expect(ids).toEqual(['grid-before', 'grid-after']);
|
|
expect(shadow(el, '#grid-single')).toBeNull();
|
|
});
|
|
|
|
it('keeps the listbox semantics on both halves', async () => {
|
|
// The single grid became a `listbox` of `option`s in the ARIA pass;
|
|
// the split path predates it. They are one control to the user, and
|
|
// a selection spanning the dropdown has to be announced the same
|
|
// way on either side of it.
|
|
const el = await fixture<LitElement>('cover-grid');
|
|
|
|
sized(el);
|
|
await settle(el);
|
|
await expandFirstCard(el);
|
|
|
|
for (const v of shadowAll(el, 'lit-virtualizer')) {
|
|
expect(v.getAttribute('role')).toBe('listbox');
|
|
expect(v.getAttribute('aria-multiselectable')).toBe('true');
|
|
expect(v.getAttribute('aria-label')).toBeTruthy();
|
|
}
|
|
});
|
|
|
|
it('closes back to the single grid', async () => {
|
|
const el = await fixture<LitElement>('cover-grid');
|
|
|
|
sized(el);
|
|
await settle(el);
|
|
await expandFirstCard(el);
|
|
expect(shadow(el, 'album-dropdown')).toBeTruthy();
|
|
|
|
// Enter on the same card again is the toggle.
|
|
await expandFirstCard(el);
|
|
|
|
expect(shadow(el, 'album-dropdown')).toBeNull();
|
|
expect(shadowAll(el, 'lit-virtualizer').map((v) => v.id)).toEqual([
|
|
'grid-single',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('the albums grid scrolls', () => {
|
|
beforeEach(() => {
|
|
resetHarness();
|
|
stub('library.Library.GetAlbums', ALBUMS);
|
|
stub('library.Library.GetTrackTable', trackTable([]));
|
|
emit(Events.LibraryScanComplete);
|
|
});
|
|
|
|
it('gives its scroll container an overflow', async () => {
|
|
// `cover-grid` carried the same `.grid-scroll-container` markup as
|
|
// `artists-view` and `genres-view` with **no rule for the class**,
|
|
// so the container grew to its full content height inside an
|
|
// `overflow: hidden` host and nothing scrolled: measured at 5 000
|
|
// albums, 186 984 px of content in a 772 px box, unreachable by
|
|
// wheel, keyboard or scrollbar. Invisible on an eight-album fixture,
|
|
// which is why a component test asserts the rule rather than the
|
|
// symptom.
|
|
const el = await fixture<LitElement>('cover-grid');
|
|
|
|
sized(el);
|
|
await settle(el);
|
|
|
|
const container = shadow(el, '.grid-scroll-container')!;
|
|
|
|
expect(container).toBeTruthy();
|
|
expect(getComputedStyle(container).overflowY).toBe('auto');
|
|
});
|
|
});
|