Files
yellowjacket/e2e/specs/queue-reorder.spec.ts
T
yonlu 7a1412383f perf(library): send the track list as a dictionary-encoded column table
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
2026-10-05 23:30:37 -04:00

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]);
});
});