a11y.22, WCAG 1.4.1: `.track-row.active` was a background tint and a text colour, and the row markup carried no aria-current either — so a colour-blind user could not find the playing row and AT had no signal at all. The queue panel had aria-current from Phase 1 and the same colour-only visual. A triangle drawn in each row's own left padding by `::before`. It is a shape that is present or absent, and it costs no layout: track-list's grid columns are computed from the host width, so a marker in the flow would move every cell on the playing row and nothing else. Both directions are asserted in both tiers. A marker that renders on every row satisfies "the playing row has one" for free, which is this plan's oldest rule. And one thing the reproduction found: a track started from the *list* leaves the queue's currentIndex at -1, so the panel has no current row in that flow at all. Pre-existing, and the reason this looked broken the first time it was checked in the running app.
179 lines
6.0 KiB
TypeScript
179 lines
6.0 KiB
TypeScript
import {
|
|
test,
|
|
expect,
|
|
callBinding,
|
|
resetEvents,
|
|
waitForEvent,
|
|
LONG_TRACK,
|
|
} from '../support/fixtures.js';
|
|
|
|
/** The one fixture long enough to still be playing on the next line. */
|
|
const longRow = (app: import('@playwright/test').Page) =>
|
|
app.getByTestId('track-row').filter({ hasText: LONG_TRACK }).first();
|
|
|
|
/**
|
|
* Playback and the queue, driven through the UI and asserted on the
|
|
* events the backend actually emits.
|
|
*
|
|
* Audio really is initialised here: under `dbus-run-session` + Xvfb the
|
|
* PulseAudio socket in /run/user is untouched, so InitSpeaker succeeds
|
|
* and these tracks genuinely play. A CI container without /run/user
|
|
* needs a null sink; everything except the audio itself still works
|
|
* without one.
|
|
*/
|
|
test.describe('playback', () => {
|
|
test.beforeEach(async ({ app }) => {
|
|
// The app lands on Home now (H-8), so the track list is a
|
|
// navigation away rather than the first thing on screen.
|
|
await app.getByTestId('nav-tracks').click();
|
|
await callBinding(app, 'queue.Queue.Clear').catch(() => {
|
|
/* older builds may not expose Clear; the specs below do not need it */
|
|
});
|
|
await resetEvents(app);
|
|
});
|
|
|
|
test('double-clicking a track plays it', async ({ app }) => {
|
|
await longRow(app).dblclick();
|
|
|
|
const changed = await waitForEvent(app, 'TrackChanged');
|
|
|
|
expect(changed.data[0]).toBeTruthy();
|
|
|
|
// The transport flips to Pause, which is the only place the UI
|
|
// states "we are playing" in a way a user can see. `exact` is not
|
|
// optional: "Add queue to playlist" also matches /play/i.
|
|
await expect(
|
|
app.getByRole('button', { name: 'Pause', exact: true }),
|
|
).toBeVisible();
|
|
|
|
await expect(app.getByTestId('now-playing-title')).toContainText(
|
|
LONG_TRACK,
|
|
);
|
|
});
|
|
|
|
test('the elapsed time advances', async ({ app }) => {
|
|
await longRow(app).dblclick();
|
|
await waitForEvent(app, 'TrackChanged');
|
|
|
|
// Not a fixed sleep on a fixed value: assert the observable
|
|
// outcome, which is that the clock is no longer at zero.
|
|
await expect(app.getByTestId('elapsed-time')).not.toHaveText('--:--');
|
|
await expect(app.getByTestId('elapsed-time')).not.toHaveText('00:00', {
|
|
timeout: 15_000,
|
|
});
|
|
});
|
|
|
|
test('pause and play round-trip through the backend', async ({ app }) => {
|
|
await longRow(app).dblclick();
|
|
await waitForEvent(app, 'TrackChanged');
|
|
|
|
await resetEvents(app);
|
|
await app.getByRole('button', { name: 'Pause', exact: true }).click();
|
|
await waitForEvent(app, 'PlaybackStateChanged');
|
|
|
|
await expect(
|
|
app.getByRole('button', { name: 'Play', exact: true }),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test('volume changes are pushed back from Go', async ({ app }) => {
|
|
await resetEvents(app);
|
|
await callBinding(app, 'player.Player.SetVolume', [55]);
|
|
|
|
const ev = await waitForEvent(app, 'VolumeChanged');
|
|
|
|
expect(ev.data).toEqual([55]);
|
|
});
|
|
});
|
|
|
|
test.describe('queue', () => {
|
|
test.beforeEach(async ({ app }) => {
|
|
// The app lands on Home now (H-8).
|
|
await app.getByTestId('nav-tracks').click();
|
|
});
|
|
|
|
test('playing a track populates the queue panel', async ({ app }) => {
|
|
await resetEvents(app);
|
|
await longRow(app).dblclick();
|
|
await waitForEvent(app, 'QueueChanged');
|
|
|
|
// The panel has to be open to have rows. A closed one is `width: 0`
|
|
// and now renders no list at all (perf.m7) — before that it kept a
|
|
// virtualizer measuring its window on every queue change, and this
|
|
// assertion passed against a panel nobody could see.
|
|
const queueToggle = app.getByRole('button', { name: 'Toggle queue' });
|
|
|
|
await queueToggle.click();
|
|
|
|
await expect(app.getByTestId('queue-row')).toHaveCount(1);
|
|
|
|
const state = await callBinding<{ tracks: unknown[] }>(
|
|
app,
|
|
'queue.Queue.GetState',
|
|
);
|
|
|
|
expect(state.tracks).toHaveLength(1);
|
|
|
|
// Shut it again, and wait until it really is shut. These specs
|
|
// share one backend process in file order, the panel's width is
|
|
// animated, and the transport slides while it closes — a click
|
|
// issued during that lands on whichever button has moved under the
|
|
// pointer, which for the very next test was Repeat rather than
|
|
// Shuffle. Both emit QueueModeChanged, so it failed on the
|
|
// assertion rather than on the wait, one run in two.
|
|
//
|
|
// Waiting on the row count rather than a timeout is also the m7
|
|
// assertion: a closed panel renders no list at all.
|
|
await queueToggle.click();
|
|
await expect(app.getByTestId('queue-row')).toHaveCount(0);
|
|
});
|
|
|
|
test('shuffle and repeat toggles report their state', async ({ app }) => {
|
|
const shuffle = app.getByRole('button', { name: 'Shuffle' });
|
|
|
|
await resetEvents(app);
|
|
await shuffle.click();
|
|
await waitForEvent(app, 'QueueModeChanged');
|
|
|
|
await expect(shuffle).toHaveAttribute('aria-pressed', 'true');
|
|
|
|
await shuffle.click();
|
|
await expect(shuffle).toHaveAttribute('aria-pressed', 'false');
|
|
});
|
|
});
|
|
|
|
test.describe('the playing row is findable without colour vision', () => {
|
|
test('the queue marks its current row with a shape and aria-current', async ({
|
|
app,
|
|
}) => {
|
|
await app.getByTestId('nav-tracks').click();
|
|
await app.getByTestId('track-row').first().dblclick();
|
|
|
|
const queueToggle = app.locator('#queue-button');
|
|
|
|
await queueToggle.click();
|
|
|
|
const row = app.getByTestId('queue-row').first();
|
|
|
|
await expect(row).toBeVisible();
|
|
|
|
// Played *from the queue*, because a track started from the list
|
|
// leaves `currentIndex` at -1 — so the panel has no current row at
|
|
// all in that flow, which is what made this marker look broken the
|
|
// first time it was checked.
|
|
await row.dblclick();
|
|
await expect(row).toHaveAttribute('aria-current', 'true');
|
|
|
|
const marker = await row.evaluate(
|
|
(el) => getComputedStyle(el, '::before').borderLeftWidth,
|
|
);
|
|
|
|
// `a11y.22`: a background tint and a text colour were the only two
|
|
// signals, and both are hue (WCAG 1.4.1).
|
|
expect(parseFloat(marker)).toBeGreaterThan(0);
|
|
|
|
await queueToggle.click();
|
|
await expect(app.getByTestId('queue-row')).toHaveCount(0);
|
|
});
|
|
});
|