Files
yellowjacket/e2e/specs/play-count.spec.ts
T
logan 1ac919d228 test(e2e): freeze the four reproductions this work started from
Each was written first and watched fail first:

- `view-lifecycle` — pressing `s` on Settings must not skip an album
  out of the Autotag queue, and on Autotag the same key must not also
  toggle shuffle.
- `player-truth` — the elapsed clock tracks the backend through steady
  playback and four keyboard seeks (the measurement that failed by
  30 s), a finished queue keeps the track on the bar at 0:00, and
  auto-advance skips a missing file and reaches the next track.
- `offline-icons` — blocks every non-local request and asserts on the
  <svg> *inside* each icon's shadow root, since asserting the element
  exists would have passed before the fix too. Verified red: 24 empty
  icons.
- `failure-voice` — induces a real binding failure through /__test/sql
  and asserts a sentence appears. Its first version renamed the decoy
  library to its own name, which the backend accepts: it failed at the
  right assertion while never inducing the failure, so it now picks the
  row by the seeded library's name from /__test/health.
- `play-count` — a finished track does not refetch the library.

The queue spec now opens the panel before asserting on its rows and
waits for it to close again: the panel's width is animated and the
transport slides with it, so a click issued during the close lands on
whichever button moved under the pointer.
2026-08-12 01:20:03 -04:00

167 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, resetEvents, callBinding } from '../support/fixtures.js';
/**
* Finishing a track is cheap, and does not disturb the user.
*
* `recordPlay` used to emit `TrackMetadataChanged` — the event that
* means "the tags on disk were rewritten" — so the library store threw
* away its whole cache and refetched tracks, albums, artists and genres
* once per song. Measured on a 50 000-track library that is ~37 MB
* across the IPC and ~0.8 s of blocked main thread *per track*
* (audit perf.C1), and `track-list` answered the new array by clearing
* the selection, which made selecting forty tracks to drag into a
* playlist impossible while music played (perf.C2).
*
* Both halves are asserted here, and both assertions are negative:
* what makes this a fix is the work that no longer happens.
*/
/** Long enough for a fixture track (26 s) to finish by itself. */
const FINISH_TIMEOUT = 60_000;
/** Instrument the library bindings so "was anything refetched" is a fact. */
const COUNT_LIBRARY_CALLS = `(() => {
const w = window;
if (w.__yjCalls) { w.__yjCalls.length = 0; return; }
w.__yjCalls = [];
const lib = w.go.library.Library;
for (const key of Object.keys(lib)) {
const fn = lib[key];
if (typeof fn !== 'function' || fn.__counted) continue;
const wrapped = function (...args) {
w.__yjCalls.push(key);
return fn.apply(this, args);
};
wrapped.__counted = true;
lib[key] = wrapped;
}
})()`;
/**
* Select rows by dispatching on the row rather than clicking it.
*
* A real click in the middle of a row lands on the track *title*, and
* a title navigates — that is `utils/explore-link.ts` working as
* designed, not a bug. Which pixel selects a row is not what this spec
* is about; that the selection survives a track change is.
*/
const selectRows = (indices: number[]): void => {
const list = document.querySelector('track-list');
const rows = Array.from(
list?.shadowRoot?.querySelectorAll('[data-testid="track-row"]') ?? [],
);
indices.forEach((i, n) => {
rows[i]?.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true, ctrlKey: n > 0,
}));
});
};
/** The file paths of every row currently showing as selected. */
const selectedPaths = (): string[] => Array.from(
document.querySelectorAll('track-list'),
).flatMap((l) => Array.from(
l.shadowRoot?.querySelectorAll('[aria-selected="true"]') ?? [],
)).map((r) => r.getAttribute('data-file-path') ?? '');
/** The first n file paths in the list. */
const firstPaths = (n: number): string[] => Array.from(
document.querySelector('track-list')
?.shadowRoot?.querySelectorAll('[data-file-path]') ?? [],
).map((r) => r.getAttribute('data-file-path') ?? '').slice(0, n);
test.describe('a finished track', () => {
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('track-row').first()).toBeVisible();
});
test('reports a play count, not a metadata change', async ({ app }) => {
const paths = await app.evaluate(firstPaths, 2);
expect(paths.length).toBeGreaterThanOrEqual(2);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await app.evaluate(COUNT_LIBRARY_CALLS);
await resetEvents(app);
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
const event = await app.evaluate(
(ms) => window.__yjEvents.wait('TrackPlayCountChanged', {
timeoutMs: ms,
}),
FINISH_TIMEOUT,
);
// Enough for a refetch, if one were going to happen, to be recorded.
await app.waitForTimeout(2500);
const payload = (event.data as Array<Record<string, unknown>>)[0]!;
expect(payload['filePath']).toBe(paths[0]);
expect(typeof payload['playCount']).toBe('number');
expect(payload['playCount']).toBeGreaterThan(0);
const names = await app.evaluate(() => window.__yjEvents.names());
expect(
names['TrackMetadataChanged'] ?? 0,
'a play emitted the retag event, which invalidates every cache',
).toBe(0);
const refetched = await app.evaluate(
() => (window as unknown as { __yjCalls: string[] }).__yjCalls,
);
expect(
refetched.filter((c) => c.startsWith('GetAll')),
'a play refetched a collection',
).toEqual([]);
await callBinding(app, 'player.Player.Pause', []);
});
test('leaves the track-list selection alone', async ({ app }) => {
// Rows away from the top, so the tracks selected are not the ones
// playing and the assertion is about the selection rather than
// about what happens to be on screen.
await app.evaluate(selectRows, [3, 4, 5]);
await app.waitForTimeout(300);
const selectedBefore = await app.evaluate(selectedPaths);
expect(selectedBefore).toHaveLength(3);
const paths = await app.evaluate(firstPaths, 2);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await resetEvents(app);
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
// `PlaybackFinished`, deliberately, for two reasons. Starting
// playback emits `TrackChanged` immediately, so waiting for that
// would assert about a click rather than about a track *finishing*
// — the only transition that used to clear the selection. And
// unlike `TrackPlayCountChanged` it exists on both sides of this
// fix, so reverting the fix makes this spec fail by reporting a
// cleared selection rather than by timing out on an event that was
// never introduced.
await app.evaluate(
(ms) => window.__yjEvents.wait('PlaybackFinished', { timeoutMs: ms }),
FINISH_TIMEOUT,
);
await app.waitForTimeout(2000);
const selectedAfter = await app.evaluate(selectedPaths);
expect(
selectedAfter,
'the selection was cleared by a track finishing',
).toEqual(selectedBefore);
await callBinding(app, 'player.Player.Pause', []);
});
});