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
119 lines
4.1 KiB
TypeScript
119 lines
4.1 KiB
TypeScript
import {
|
|
libraryTracks,
|
|
test,
|
|
expect,
|
|
callBinding,
|
|
NO_QUEUE_SOURCE,
|
|
} from '../support/fixtures.js';
|
|
import type { Page } from '@playwright/test';
|
|
|
|
/**
|
|
* `a11y.11` — the queue's order can be changed without a mouse.
|
|
*
|
|
* The component tier pins the arithmetic against a faked binding. This
|
|
* one is here because the arithmetic is only half of it: `toIndex` is
|
|
* interpreted by `Queue.MoveQueueTracks`, whose contiguous-block guard
|
|
* turns the plausible-looking `i + 1` into a silent no-op. Nothing but
|
|
* the real backend can say whether the order actually moved.
|
|
*
|
|
* Reproduced first: with a row focused, Alt/Ctrl/Shift/Meta + arrows all
|
|
* left the order untouched.
|
|
*/
|
|
|
|
/** The queue's order, asked of the backend rather than of the DOM. */
|
|
async function order(app: Page): Promise<string[]> {
|
|
const state = await callBinding<{ tracks: { title: string }[] }>(
|
|
app,
|
|
'queue.Queue.GetState',
|
|
);
|
|
|
|
return state.tracks.map((t) => t.title);
|
|
}
|
|
|
|
async function queueFourAndOpen(app: Page): Promise<string[]> {
|
|
const paths = (await libraryTracks(app)).slice(0, 4).map((t) => t.FilePath);
|
|
|
|
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
|
|
|
|
// A closed panel renders no list at all, so there is no row to focus.
|
|
await app.locator('#queue-button').click();
|
|
await expect(app.locator('queue-panel .track-item').first()).toBeVisible();
|
|
|
|
return order(app);
|
|
}
|
|
|
|
test.describe('reordering the queue from the keyboard', () => {
|
|
// The 36 specs share one backend process in file order, and these
|
|
// leave two things behind that outlive the page: a reordered queue
|
|
// and an open panel. Both are put back, because a spec that spends
|
|
// state fails the *next* one, in a list that reads like a regression
|
|
// in whatever you are holding.
|
|
test.afterEach(async ({ app }) => {
|
|
await callBinding(app, 'queue.Queue.Clear').catch(() => {
|
|
/* nothing queued is the state we wanted anyway */
|
|
});
|
|
|
|
const open = await app.locator('queue-panel[open]').count();
|
|
|
|
if (open > 0) await app.locator('#queue-button').click();
|
|
});
|
|
|
|
test('Alt+Arrow moves the focused row, and puts it back', async ({ app }) => {
|
|
const start = await queueFourAndOpen(app);
|
|
|
|
expect(start.length).toBe(4);
|
|
|
|
await app.locator('queue-panel .track-item').nth(1).focus();
|
|
await app.keyboard.press('Alt+ArrowUp');
|
|
await expect.poll(() => order(app)).toEqual([start[1], start[0], ...start.slice(2)]);
|
|
|
|
// Down is the direction the obvious index arithmetic gets wrong: it
|
|
// has to ask for i + 2, because i + 1 is a no-op once the row's own
|
|
// removal is accounted for. A spec that only moved up would pass
|
|
// against a build where down does nothing.
|
|
await app.keyboard.press('Alt+ArrowDown');
|
|
await expect.poll(() => order(app)).toEqual(start);
|
|
});
|
|
|
|
test('says where the row went', async ({ app }) => {
|
|
await queueFourAndOpen(app);
|
|
|
|
await app.locator('queue-panel .track-item').nth(1).focus();
|
|
await app.keyboard.press('Alt+ArrowUp');
|
|
|
|
await expect(
|
|
app.locator('queue-panel [role="status"]'),
|
|
).toHaveText(/Moved to position 1 of 4/);
|
|
});
|
|
|
|
test('refuses at the ends without reordering anything', async ({ app }) => {
|
|
const start = await queueFourAndOpen(app);
|
|
|
|
await app.locator('queue-panel .track-item').first().focus();
|
|
await app.keyboard.press('Alt+ArrowUp');
|
|
|
|
await expect(
|
|
app.locator('queue-panel [role="status"]'),
|
|
).toHaveText(/Already first/);
|
|
expect(await order(app)).toEqual(start);
|
|
});
|
|
|
|
// The plain arrows belong to the roving tab stop, and must not reach
|
|
// the global volume binding from a focused row.
|
|
test('leaves the unmodified arrows roving', async ({ app }) => {
|
|
const start = await queueFourAndOpen(app);
|
|
|
|
await app.locator('queue-panel .track-item').first().focus();
|
|
await app.keyboard.press('ArrowDown');
|
|
|
|
const focused = await app.evaluate(
|
|
() =>
|
|
document
|
|
.querySelector('queue-panel')
|
|
?.shadowRoot?.activeElement?.getAttribute('data-index') ?? null,
|
|
);
|
|
|
|
expect([focused, await order(app)]).toEqual(['1', start]);
|
|
});
|
|
});
|