Files
yellowjacket/frontend/test/components/play-in-context.test.ts
T
logan 772c71c49f
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m29s
CI / e2e (pull_request) Successful in 9m41s
test(ui): clear localStorage between component tests
A test file does not get its own origin. `@vitest/browser-playwright`
opens one BrowserContext per session and runs several files in it, one
after another, so everything a component persists survives from file to
file. `track-list` restores its sort in `connectedCallback` and
`aria-tail.test.ts` activates the Title column header, so any file that
mounts a track list later in that tab opens sorted by title — where
`track-11` precedes `track-3`, which is #138's failure exactly.

Nothing about it is specific to that pair: a probe that throws when a
test starts with a non-empty `localStorage` failed 24 test-starts in one
full run (11 with the two sort keys, 8 with `cover-grid-size`, 5 with
`track-list-column-widths`) and cascaded into 248 failures. Which files
share a tab, and in what order, changes run to run, which is the whole
of why this reads as a 1-in-3 flake and passes in isolation.

The clear belongs in `setup.ts` rather than in the specs that write,
because the spec that reads is never the one that knows — and it is
safe for the same reason the leak exists: files within a session are
sequential, so it cannot wipe storage a concurrent file is using.

The spec now also states the order it asserts rather than inheriting a
default, and checks the row it is about to double-click carries the path
it expects, so a stray sort fails by naming itself instead of as an
off-by-eight file path.

Closes #138
2026-08-24 06:47:05 -04:00

285 lines
8.9 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.
/**
* Playing a track plays the list it is in.
*
* Double-clicking a row — or picking Play from its context menu — used
* to call `SetQueue([thatOnePath], 0)` on the album page, in the track
* list and in the two playlist views' menus: the queue became one
* track, the rest of the album was discarded, and playback stopped at
* the end of it. What a player means by activating a row is "start
* here", and the here is a position in the list on screen.
*
* So the queue is the *displayed* list and `startIndex` is the row.
* Two rules ride along and are what these tests are mostly for:
*
* - The album page's list is the tracks it has files for, so the index
* is into that and not into the tracklist that includes the dimmed
* rows — off-by-however-many-you-do-not-own is silent, it just plays
* the wrong song.
* - A menu asks how much is selected. One row means "from here"; an
* explicit multi-row selection means play exactly those, which is
* the one case where a queue of the selection is what was asked for.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import '@components/track-list/track-list';
import '@components/playlist-details/playlist-details';
import { stub, flush, resetHarness, lastArgs } from '@test/support/harness';
import { fixture, shadowAll } from '@test/support/render';
/** What `Queue.SetQueue` was last asked to play, and from where. */
function queued(): { paths: string[]; startIndex: number } {
const args = lastArgs('queue.Queue.SetQueue');
if (!args) throw new Error('nothing was queued');
return {
paths: args[0] as string[],
startIndex: args[1] as number,
};
}
function dblclick(el: Element): void {
el.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, composed: true }),
);
}
// =====================================================================
// The album page
// =====================================================================
function albumTrack(n: number, owned: boolean) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `mbid-${n}`,
inLibrary: owned,
};
}
/**
* A release on the page without the network, owned every `nth` track.
* The fixture says which tracks have *files*, because that is the one
* question the page asks about ownership.
*/
async function album(total: number, ownedMbids: string[]): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
albumName: 'Glass Harbour',
});
const tracks = Array.from({ length: total }, (_, i) =>
albumTrack(i + 1, ownedMbids.includes(`mbid-${i + 1}`)),
);
const paths: Record<string, string[]> = {};
for (const mbid of ownedMbids) paths[mbid] = [`/music/${mbid}.mp3`];
stub('library.Library.GetFilePathsByRecordingMBIDs', paths);
Object.assign(el, {
versionEntries: [
{ key: 'v1', label: '2019', sublabel: `${total} tracks`, tracks },
],
selectedVersionKey: 'v1',
loadingReleases: false,
loadingInfo: false,
});
el.requestUpdate();
await flush();
await el.updateComplete;
return el;
}
describe('double-clicking a track on the album page', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetFilePathsByAlbums', {});
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
});
it('queues the album and starts on that track', async () => {
const el = await album(6, ['mbid-1', 'mbid-2', 'mbid-3', 'mbid-4', 'mbid-5', 'mbid-6']);
dblclick(shadowAll(el, '.track-row')[2]!);
await flush();
expect(queued()).toEqual({
paths: [1, 2, 3, 4, 5, 6].map((n) => `/music/mbid-${n}.mp3`),
startIndex: 2,
});
});
it('indexes into what is owned, not into the rows on screen', async () => {
// Tracks 2, 5 and 6 have files; the other three rows are dimmed and
// are not in the queue at all. Row 5 is therefore the *second*
// thing that will play, and an index taken from the row would start
// this album past its end.
const el = await album(6, ['mbid-2', 'mbid-5', 'mbid-6']);
dblclick(shadowAll(el, '.track-row')[4]!);
await flush();
expect(queued()).toEqual({
paths: ['/music/mbid-2.mp3', '/music/mbid-5.mp3', '/music/mbid-6.mp3'],
startIndex: 1,
});
});
it('does nothing at all on a row with no file behind it', async () => {
const el = await album(6, ['mbid-2']);
dblclick(shadowAll(el, '.track-row')[0]!);
await flush();
expect(lastArgs('queue.Queue.SetQueue')).toBeUndefined();
});
});
// =====================================================================
// The track list
// =====================================================================
const LIST = Array.from({ length: 12 }, (_, i) => ({
FilePath: `/music/track-${i}.mp3`,
TrackName: `Track ${i}`,
ArtistName: 'An Artist',
Album: 'An Album',
Duration: 180,
})) as never[];
describe('double-clicking a row in the track list', () => {
let el: LitElement;
beforeEach(async () => {
resetHarness();
localStorage.removeItem('track-list-column-widths');
el = await fixture<LitElement>('track-list', { externalTracks: LIST });
// Say which order is being asserted rather than inheriting one.
// `restoreSortPreferences()` runs in `connectedCallback`, so the
// list opens in whatever sort was last persisted -- and
// `track-11` sorts before `track-3` by title, which is the shape
// of #138. The row below is the fixture's third track only while
// nothing is sorting the list.
(el as unknown as { sortField: string | null }).sortField = null;
el.style.display = 'block';
el.style.height = '600px';
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 60));
});
it('queues the list as displayed and starts on that row', async () => {
const rows = shadowAll(el, '.track-row');
const row = rows.find((r) => r.getAttribute('data-index') === '3');
// Stated first, so a list that is not in the order this asserts
// fails by saying so rather than as an off-by-eight file path.
expect(row?.getAttribute('data-file-path')).toBe('/music/track-3.mp3');
dblclick(row!);
await flush();
const { paths, startIndex } = queued();
expect([paths.length, paths[startIndex]]).toEqual([
12,
'/music/track-3.mp3',
]);
});
});
// =====================================================================
// A playlist's context menu
// =====================================================================
function playlistTracks(n: number) {
return Array.from({ length: n }, (_, i) => ({
ID: i + 1,
FilePath: `/music/track-${i}.mp3`,
Title: `Track ${i}`,
Artist: 'An Artist',
Album: 'An Album',
Duration: 180000,
Phantom: false,
}));
}
describe('Play from a playlist rows context menu', () => {
let el: LitElement;
beforeEach(async () => {
resetHarness();
stub('playlist.Service.GetPlaylistTracks', playlistTracks(8));
stub('playlist.Service.GetAllPlaylists', []);
el = await fixture<LitElement>('playlist-details', {
playlistId: 1,
playlistName: 'A playlist',
});
el.style.display = 'block';
el.style.height = '600px';
await flush();
await el.updateComplete;
await new Promise((r) => setTimeout(r, 60));
});
/** Right-click a row, then click the menu's Play item. */
async function playFromMenu(index: number): Promise<void> {
const row = shadowAll(el, '.track-item').find(
(r) => r.getAttribute('data-index') === String(index),
);
row!.dispatchEvent(
new MouseEvent('contextmenu', { bubbles: true, composed: true }),
);
await el.updateComplete;
const items = shadowAll<HTMLElement>(el, 'wa-dropdown-item');
const play = items.find((i) => i.textContent?.trim().startsWith('Play') &&
!i.textContent.includes('Next'));
play!.click();
await flush();
}
it('starts the playlist from the row that was clicked', async () => {
await playFromMenu(5);
const { paths, startIndex } = queued();
expect([paths.length, startIndex]).toEqual([8, 5]);
});
it('plays only the selection when several rows are selected', async () => {
// The one case where a queue of the selection is what was asked
// for: the user said which tracks, not where to start.
const rows = shadowAll(el, '.track-item');
const click = (i: number, modifiers: MouseEventInit) =>
rows
.find((r) => r.getAttribute('data-index') === String(i))!
.dispatchEvent(
new MouseEvent('click', { bubbles: true, composed: true, ...modifiers }),
);
click(1, {});
click(4, { ctrlKey: true });
await el.updateComplete;
await playFromMenu(4);
expect(queued().paths).toEqual([
'/music/track-1.mp3',
'/music/track-4.mp3',
]);
});
});