a11y.11: the queue's order could not be changed without a mouse. Reordering existed only as a drag whose drop index is computed from the cursor's Y position. Reproduced with a row focused: Alt, Ctrl, Shift and Meta + arrows all left the order untouched. Alt+ArrowUp/Down moves the focused row and a live region says where it went. It is handled in the panel's own delegated keydown rather than as a backend panel binding -- that is where Enter and the roving arrows already live, it cannot collide with the global Up/Down volume bindings (measured: 0 VolumeChanged events from a focused row), and it keeps a destructive-looking key out of the user-editable shortcut table. Two things the finding did not contain. The index arithmetic is not symmetric: MoveQueueTracks takes an index into the array before the move, so down-by-one has to ask for i+2 -- i+1 is where the row already is once its own removal is accounted for, and the backend's contiguous-block guard correctly makes it a no-op. Both tiers pin that, because a symmetric-looking fix silently does nothing in one direction. And focusedIndex only ever moved on an arrow key, so a row reached by a click or by Tab left it saying 0 and every key acted on the wrong row -- Enter played the first track in the queue from any focused row. The delegated handler reads the index off the row the event came from now. Pre-existing; visible only once a key moved something.
121 lines
4.2 KiB
TypeScript
121 lines
4.2 KiB
TypeScript
import { test, expect, callBinding } 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: string[] = await app.evaluate(async () => {
|
|
const tracks = await window.__yjEvents.call(
|
|
'library.Library.GetAllTracks',
|
|
[],
|
|
10_000,
|
|
);
|
|
|
|
return (tracks as { FilePath: string }[]).slice(0, 4).map((t) => t.FilePath);
|
|
});
|
|
|
|
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
|
|
|
|
// 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]);
|
|
});
|
|
});
|