feat(database): shape the library like files, and shrink the catalog
Plans 013 and 014, the album page that prompted them, and the smaller fixes they turned up. Changelog, largest first. ## The local library is shaped like files, not like MusicBrainz `audio_files` carries its own tags and points at `albums` and `artists`; `file_genres` is the one real many-to-many. `recordings`, `release_group_recordings`, `artist_credit`, `artist_credit_artist`, `recording_genres`, `release_groups` and `release_to_rg` are gone from the local side, and with them a six-way join in every read, a `MIN(release_group_id)` subquery in eleven queries and a first-credited-artist subquery in nine. Measured on a real 25,966-file library, every many-to-many that model expressed was 1:1 in the data. - Ownership is a file. `GetFilePathsByRecordingMBIDs`, `LibraryMBIDIndex.CheckMBIDs`, `collectLibraryEntities` and `pruneStaleLocalCrossReferences` all join `audio_files`, so the 812 orphaned recordings, 216 release groups and 260 artists that library carried are now structurally impossible. - One projection: every track query selects from the `track_metadata` view, one row type, one mapper. Nine hand-rolled copies had drifted far enough to report different years on different screens. - `library_id = 0` means every library, so each list query exists once instead of scoped and unscoped with a branch at every call site. - No migration chain. `sql/schemas/` is the one description of the shape; `sql/migrations/`, `applyMigrations` and `schema_migrations` are squashed away, along with the drift between them that had sqlc generating against a stale schema. - `database.InsertTestTrack` is the one test seeder; twenty test files had been assembling the old FK chain each in its own order. ## The catalog stores its ids as bytes `explore_index`'s three 36-char MBID columns and its entity-type text are 16 raw bytes and a small integer. The table and its six indexes go 780 MB to 405 MB on a real 2,052,200-row catalog, which is why a fresh install is ~0.6 GB rather than ~1.0 GB. - `backend/explore/mbid.go` is the only place the encoding is known; everything above it speaks dashed strings. - `CHECK(length(mbid) = 16)` makes a stringly write fail at the insert rather than silently returning no rows, since SQLite does not coerce between TEXT and BLOB. - The importer asks the artifact what encoding it carries and converts on the way in, so the artifact already published keeps working and no format bump is needed. - `indexRowColumns`/`scanIndexRow` replace four copies of a 22-column list, and `TestStoredEncodingRoundTrips` sweeps every read path. ## An album page that says how much of the album is yours - One question, asked once: is there a file. `filePaths` is filled by a single batched lookup when the tracklist settles, and the badge, the Play count, the dimmed rows and every menu item read it — replacing four claims of decreasing confidence that could show a green tick on an album whose every action did nothing. - Play, Play 7 of 12, or no play button at all. - `total_tracks` on `explore_index` (~2 bytes over 400,677 release groups) and on `audio_files` from tags that have always carried it: a complete MBID-matched album now makes no catalog call at all, where it used to spend the most expensive request the app makes. - A merged cluster shows the running order the most releases agree on, and the version list marks the release you own rather than standing a synthetic entry in for it. - `AlbumReleasesFailed`: a slow fetch is no longer reported as a failed one by a 12-second timer. - Rows not in the library are dimmed in place (with `aria-disabled`) instead of the owned ones wearing a green tick and a legend. ## Caches and cover art get ceilings - Only the three tiers of a cover are stored; the full-resolution copy nothing rendered was 1,134 MB of a 1.4 GB covers directory. - One artist portrait is downloaded and the rest are remembered as URLs — 4.1 GB of a 5.3 GB cache was candidates no code path reads. - `browsedArtBudget` and `httpCacheBudget` bound what an age cannot: the same install held art for 5,770 artists in a 1,301-artist library. - `OrphanedArtistImagesJob` joined a bare MBID onto a sharded directory, so it deleted the rows that were the only record of the files it left behind. `explore.ArtistImageDir` is that layout's one definition now. ## The autotag queue asks whether there is work `tagging_items` was a row per album folder, not a queue, and no query read the `tag_status` column that held the answer. The four queue queries ask the files, which matters most where it is least visible: `startPrefetch` was scoring every album in a tagged library against MusicBrainz. ## Phantom playlist tracks resolve in place An M3U8 imported before its files leaves phantom rows; they now match by path and fall back to position, keep their place in the playlist when resolved, and pair best-first so two phantoms cannot claim the same file. ## Playing a track plays the list it is in Double-click, and Play on a single row's menu, queue the list as displayed with `startIndex` on that row — the album page and the track list used to queue one track and discard the album around it. A multi-row selection still plays exactly itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AfVYUVExXsx1nSWrXN8mAh
This commit is contained in:
@@ -18,7 +18,7 @@ import { describe, expect, it, beforeEach } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/explore-album-details/explore-album-details';
|
||||
import { stub, flush, resetHarness, calls } from '@test/support/harness';
|
||||
import { stub, flush, resetHarness, calls, lastArgs } from '@test/support/harness';
|
||||
import { fixture, shadow, shadowAll, text } from '@test/support/render';
|
||||
|
||||
type Version = {
|
||||
@@ -52,6 +52,12 @@ function track(n: number, owned: boolean) {
|
||||
* The component builds its versions from fetched releases; this reaches
|
||||
* past that and sets the state the header actually reads, which is the
|
||||
* only part under test here.
|
||||
*
|
||||
* Owning a track means the library has a *file* for it — the page
|
||||
* resolves the displayed tracklist's paths once and every action, badge
|
||||
* and dimmed row reads that one answer. So the fixture says which
|
||||
* tracks have files rather than setting an `inLibrary` flag, which is
|
||||
* what used to be able to claim ownership of something unplayable.
|
||||
*/
|
||||
async function withVersion(
|
||||
owned: number,
|
||||
@@ -61,11 +67,20 @@ async function withVersion(
|
||||
albumName: 'Glass Harbour',
|
||||
});
|
||||
|
||||
const tracks = Array.from({ length: total }, (_, i) => track(i + 1, i < owned));
|
||||
|
||||
const paths: Record<string, string[]> = {};
|
||||
for (const t of tracks.filter((t) => t.inLibrary)) {
|
||||
paths[t.mbid] = [`/music/${t.mbid}.mp3`];
|
||||
}
|
||||
|
||||
stub('library.Library.GetFilePathsByRecordingMBIDs', paths);
|
||||
|
||||
const version: Version = {
|
||||
key: 'v1',
|
||||
label: '2019',
|
||||
sublabel: `${total} tracks`,
|
||||
tracks: Array.from({ length: total }, (_, i) => track(i + 1, i < owned)),
|
||||
tracks,
|
||||
};
|
||||
|
||||
Object.assign(el, {
|
||||
@@ -125,23 +140,28 @@ describe('the album header’s primary action', () => {
|
||||
expect(shadow(el, '[data-testid="album-queue"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('asks for the owned tracks’ paths once, by the key it owns them by', async () => {
|
||||
// `perf.m2`'s rule: ask for what the caller uses, once. The caller
|
||||
// here uses file paths and knows its tracks only as recording
|
||||
// MBIDs — `MBTrack.localId` is declared and never written by
|
||||
// anything in the backend.
|
||||
it('asks for the tracklist’s paths once, on load, and not on click', async () => {
|
||||
// `perf.m2`'s rule — ask for what the caller uses, once — and the
|
||||
// ownership rule with it. The page asks about the *whole* displayed
|
||||
// tracklist when it settles, because whether a track is owned is
|
||||
// that query's answer and not something to be inferred first. Every
|
||||
// action, badge and dimmed row then reads the one result, so a
|
||||
// click asks nothing and cannot fail.
|
||||
const el = await withVersion(7, 12);
|
||||
|
||||
const onLoad = calls('library.Library.GetFilePathsByRecordingMBIDs');
|
||||
|
||||
expect(onLoad).toHaveLength(1);
|
||||
expect(onLoad[0]!.args[0]).toHaveLength(12);
|
||||
// No empty MBID — an empty string matches every untagged recording
|
||||
// in the library.
|
||||
expect(onLoad[0]!.args[0]).not.toContain('');
|
||||
|
||||
shadow<HTMLElement>(el, '[data-testid="album-play"]')!.click();
|
||||
await flush();
|
||||
|
||||
const asked = calls('library.Library.GetFilePathsByRecordingMBIDs');
|
||||
|
||||
expect(asked).toHaveLength(1);
|
||||
// Only the owned ones, and no empty MBID — an empty string matches
|
||||
// every untagged recording in the library.
|
||||
expect(asked[0]!.args[0]).toHaveLength(7);
|
||||
expect(asked[0]!.args[0]).not.toContain('');
|
||||
expect(calls('library.Library.GetFilePathsByRecordingMBIDs')).toHaveLength(1);
|
||||
expect(lastArgs('queue.Queue.SetQueue')?.[0]).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -164,8 +164,17 @@ describe('an album the library already holds in full', () => {
|
||||
stub('explore.Service.GetThumbnail', '');
|
||||
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
|
||||
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
|
||||
// A local album's rows carry their file paths, and that is what
|
||||
// "the library holds this" means now — the page records them as it
|
||||
// maps the tracks, so nothing has to be asked again later.
|
||||
stub('library.Library.GetAlbumTracks', [
|
||||
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
|
||||
{
|
||||
TrackName: 'Track 1',
|
||||
TrackNumber: 1,
|
||||
DiscNumber: 1,
|
||||
TrackLength: '3:20',
|
||||
FilePath: '/music/glass-harbour/01.mp3',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -253,4 +262,72 @@ describe('an album the library already holds in full', () => {
|
||||
const badge = shadow(el, 'library-status-indicator');
|
||||
expect(badge?.getAttribute('status')).toBe('partial');
|
||||
});
|
||||
|
||||
/**
|
||||
* The denominator the files could not supply.
|
||||
*
|
||||
* A great deal of any library declares no track total at all, and
|
||||
* "unknown" is a third state that must render as neither complete nor
|
||||
* incomplete — so an album like this used to wear a plain tick no
|
||||
* matter how much of it was missing. The catalog carries a per-
|
||||
* release-group total in the artifact for about two bytes a row, and
|
||||
* that is what fills the gap: the numerator stays local (how many
|
||||
* distinct track numbers are on disk), only the denominator is
|
||||
* borrowed.
|
||||
*/
|
||||
it('borrows the catalog total when the tags declared none', async () => {
|
||||
stub('explore.Service.LookupReleaseGroup', {
|
||||
mbid: MBID,
|
||||
title: 'Glass Harbour',
|
||||
artistCredit: 'Tideline',
|
||||
totalTracks: 12,
|
||||
});
|
||||
stub('library.Library.GetAlbumCompleteness', {
|
||||
owned: 9,
|
||||
expected: 0,
|
||||
known: false,
|
||||
complete: false,
|
||||
});
|
||||
|
||||
const el = await fixture<LitElement>('explore-album-details', {
|
||||
releaseGroupMBID: MBID,
|
||||
localAlbumId: 7,
|
||||
albumName: 'Glass Harbour',
|
||||
});
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const badge = shadow(el, 'library-status-indicator');
|
||||
expect(badge?.getAttribute('status')).toBe('partial');
|
||||
expect((badge as unknown as { expected: number }).expected).toBe(12);
|
||||
expect((badge as unknown as { owned: number }).owned).toBe(9);
|
||||
});
|
||||
|
||||
/**
|
||||
* And when neither side can total it, nothing is invented: zero means
|
||||
* "the catalog does not say", which is the same third state the local
|
||||
* answer has, so the badge stays a plain tick.
|
||||
*/
|
||||
it('draws no ring when neither the tags nor the catalog say', async () => {
|
||||
stub('library.Library.GetAlbumCompleteness', {
|
||||
owned: 9,
|
||||
expected: 0,
|
||||
known: false,
|
||||
complete: false,
|
||||
});
|
||||
|
||||
const el = await fixture<LitElement>('explore-album-details', {
|
||||
releaseGroupMBID: MBID,
|
||||
localAlbumId: 7,
|
||||
albumName: 'Glass Harbour',
|
||||
});
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
expect(
|
||||
shadow(el, 'library-status-indicator')?.getAttribute('status'),
|
||||
).toBe('in-library');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,10 +81,10 @@ async function expandFirstCard(el: LitElement): Promise<void> {
|
||||
describe('the album dropdown', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetAllAlbums', ALBUMS);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAlbums', ALBUMS);
|
||||
stub('library.Library.GetTracks', []);
|
||||
stub('library.Library.GetAlbumTracks', TRACKS);
|
||||
stub('library.Library.GetAlbumTracks', TRACKS);
|
||||
stub('library.Library.GetAlbumTracksByLibrary', TRACKS);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
@@ -154,8 +154,8 @@ describe('the album dropdown', () => {
|
||||
describe('the albums grid scrolls', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetAllAlbums', ALBUMS);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAlbums', ALBUMS);
|
||||
stub('library.Library.GetTracks', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
|
||||
@@ -210,6 +210,17 @@ describe('an album the library holds part of', () => {
|
||||
});
|
||||
|
||||
it('draws the whole release, with the missing tracks dimmed', async () => {
|
||||
// Ownership is a *file*, not the catalog row's `inLibrary` flag —
|
||||
// nine of the twelve recordings resolve to a path, so three rows
|
||||
// dim. Stating it as flags is what let the page claim an album it
|
||||
// could not play a note of.
|
||||
stub(
|
||||
'library.Library.GetFilePathsByRecordingMBIDs',
|
||||
Object.fromEntries(
|
||||
Array.from({ length: 9 }, (_, i) => [`rec-${i + 1}`, [`/music/0${i + 1}.mp3`]]),
|
||||
),
|
||||
);
|
||||
|
||||
const el = await albumWith(
|
||||
[release('rel-1', '2019-04-01', 12, 9)],
|
||||
{ owned: 9, expected: 12, known: true, complete: false },
|
||||
|
||||
@@ -76,8 +76,8 @@ describe('the track list says how it is sorted', () => {
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
@@ -128,11 +128,11 @@ describe('the track list has a voice for its own state', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
});
|
||||
|
||||
it('announces the result of a search that matches nothing', async () => {
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
emit(Events.LibraryScanComplete);
|
||||
|
||||
const el = await fixture<LitElement>('track-list');
|
||||
@@ -161,10 +161,10 @@ describe('a selectable grid is a listbox, not a row of buttons', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllArtists', ARTISTS);
|
||||
stub('library.Library.GetAllGenresWithCounts', GENRES);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetArtists', ARTISTS);
|
||||
stub('library.Library.GetGenres', GENRES);
|
||||
stub('library.Library.GetTracks', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
@@ -197,8 +197,8 @@ describe('a clipped value is readable somewhere', () => {
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
@@ -240,8 +240,8 @@ describe('the playing row is more than a colour', () => {
|
||||
beforeEach(async () => {
|
||||
resetHarness();
|
||||
searchStore.setTerm('');
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
});
|
||||
|
||||
|
||||
@@ -56,10 +56,10 @@ async function settle(el: LitElement): Promise<void> {
|
||||
describe('a card grid shows its selection', () => {
|
||||
beforeEach(() => {
|
||||
resetHarness();
|
||||
stub('library.Library.GetAllArtists', ARTISTS);
|
||||
stub('library.Library.GetAllGenresWithCounts', GENRES);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetArtists', ARTISTS);
|
||||
stub('library.Library.GetGenres', GENRES);
|
||||
stub('library.Library.GetTracks', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
// The views read through LibraryController, whose cache is only
|
||||
// primed by a scan-complete; without it they render nothing and the
|
||||
// assertion below fails for the wrong reason.
|
||||
|
||||
@@ -128,7 +128,7 @@ describe('<library-filter>', () => {
|
||||
select?.dispatchEvent(new Event('change'));
|
||||
await flush();
|
||||
|
||||
expect(lastArgs('library.Library.GetAllTracksByLibrary')).toEqual([8]);
|
||||
expect(lastArgs('library.Library.GetTracks')).toEqual([8]);
|
||||
});
|
||||
|
||||
it('picks up a library added while it was on screen', async () => {
|
||||
|
||||
@@ -15,10 +15,10 @@ import { fixture, shadow, text } from '@test/support/render';
|
||||
/** Drop the library store's cache so the list has to fetch. */
|
||||
async function emptyLibrary(): Promise<void> {
|
||||
resetHarness();
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetAllArtists', []);
|
||||
stub('library.Library.GetAllGenresWithCounts', []);
|
||||
stub('library.Library.GetTracks', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
stub('library.Library.GetArtists', []);
|
||||
stub('library.Library.GetGenres', []);
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
}
|
||||
@@ -40,7 +40,7 @@ describe('<track-list> empty, loading and failed', () => {
|
||||
});
|
||||
|
||||
it('says the query failed, and offers to try again', async () => {
|
||||
stubFailure('library.Library.GetAllTracks', 'sql: database is locked');
|
||||
stubFailure('library.Library.GetTracks', 'sql: database is locked');
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* "Track Details" and "Add to Playlist" on Explore's owned tracks.
|
||||
*
|
||||
* The library-side lists have had this item for as long as the dialog
|
||||
* has existed; Explore's tracklists — the album page's and the artist
|
||||
* page's top tracks — had Play, Add to Queue and Play Next and stopped
|
||||
* there. The reason it is worth a test rather than being one more
|
||||
* `<wa-dropdown-item>` is that the two sides hold different things: a
|
||||
* library row *is* a `library.Track`, and an Explore row is the
|
||||
* catalog's, which can name a file only through its recording MBID.
|
||||
*
|
||||
* So what this pins is the join. Both items appear only for a track the
|
||||
* user owns (an unowned one has no file, and both are about a file):
|
||||
* Track Details resolves MBID → path → the library's own track and
|
||||
* hands *that* to the dialog, and Add to Playlist resolves the same
|
||||
* path and hands it to the shared picker.
|
||||
*/
|
||||
import type { LitElement } from 'lit';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import '@components/explore-album-details/explore-album-details';
|
||||
import { emit, flush, stub } from '@test/support/harness';
|
||||
import { Events } from '../../src/events';
|
||||
import { fixture, shadow, shadowAll } from '@test/support/render';
|
||||
import { showTrackDetailsForPath } from '@utils/track-details-opener';
|
||||
import type { TrackDetails } from '@components/track-details/track-details';
|
||||
|
||||
const ALBUM_TRACKS = 'library.Library.GetAlbumTracks';
|
||||
const COMPLETENESS = 'library.Library.GetAlbumCompleteness';
|
||||
const FILE_PATHS = 'library.Library.GetFilePathsByRecordingMBIDs';
|
||||
const ALL_TRACKS = 'library.Library.GetTracks';
|
||||
const LOOKUP_RG = 'explore.Service.LookupReleaseGroup';
|
||||
const BROWSE_RELEASES = 'explore.Service.BrowseReleases';
|
||||
|
||||
const PATH = '/music/an-album/01.flac';
|
||||
const MBID = 'rec-1';
|
||||
|
||||
/** One row as `GetAlbumTracks` returns it. */
|
||||
const albumTrack = {
|
||||
ID: 1,
|
||||
FilePath: PATH,
|
||||
TrackName: 'A Song',
|
||||
TrackNumber: 1,
|
||||
DiscNumber: 1,
|
||||
TrackLength: '3:00',
|
||||
RecordingMBID: MBID,
|
||||
};
|
||||
|
||||
/** The same track as the library's own model, which is what the dialog wants. */
|
||||
const libraryTrack = {
|
||||
ID: 1,
|
||||
FilePath: PATH,
|
||||
Title: 'A Song',
|
||||
Artist: 'An Artist',
|
||||
Album: 'An Album',
|
||||
CoverArtPath: '/covers/a.jpg',
|
||||
CoverArtSmall: '/covers/a-64.jpg',
|
||||
CoverArtMedium: '/covers/a-256.jpg',
|
||||
CoverArtLarge: '/covers/a-512.jpg',
|
||||
};
|
||||
|
||||
/**
|
||||
* Mount the album page as a library-only album, which is the cheapest
|
||||
* route to a rendered tracklist: no MBID means it hydrates entirely
|
||||
* from `GetAlbumTracks` and asks the catalog nothing.
|
||||
*/
|
||||
async function albumPage() {
|
||||
return fixture('explore-album-details', { localAlbumId: 7 });
|
||||
}
|
||||
|
||||
/** Open the context menu on the first track row and return its items. */
|
||||
async function openTrackMenu(el: LitElement) {
|
||||
const row = shadow(el, '.track-row');
|
||||
|
||||
expect(row, 'a track row is rendered').not.toBeNull();
|
||||
|
||||
row!.dispatchEvent(
|
||||
new MouseEvent('contextmenu', { bubbles: true, cancelable: true }),
|
||||
);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
return shadowAll(el, '.context-menu-panel wa-dropdown-item');
|
||||
}
|
||||
|
||||
/** The track the page's `<track-details>` was opened on, once it has one. */
|
||||
async function dialogTrack(
|
||||
el: LitElement,
|
||||
attempts = 100,
|
||||
): Promise<{ FilePath: string } | null> {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
const dialog = shadow<TrackDetails>(el, 'track-details') as unknown as {
|
||||
track?: { FilePath: string } | null;
|
||||
} | null;
|
||||
|
||||
if (dialog?.track) return dialog.track;
|
||||
|
||||
await flush();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The file paths handed to the playlist picker, once it is mounted. */
|
||||
async function pickerPaths(
|
||||
el: LitElement,
|
||||
attempts = 100,
|
||||
): Promise<string[] | null> {
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
const picker = shadow(el, 'playlist-picker') as unknown as {
|
||||
filePaths?: string[];
|
||||
} | null;
|
||||
|
||||
if (picker?.filePaths?.length) return picker.filePaths;
|
||||
|
||||
await flush();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const labels = (items: Element[]) =>
|
||||
items.map((i) => (i.textContent ?? '').trim());
|
||||
|
||||
describe('Explore track details', () => {
|
||||
beforeEach(() => {
|
||||
stub(ALBUM_TRACKS, [albumTrack]);
|
||||
stub(COMPLETENESS, { known: true, complete: true, owned: 1, expected: 1 });
|
||||
stub(FILE_PATHS, { [MBID]: [PATH] });
|
||||
stub(ALL_TRACKS, [libraryTrack]);
|
||||
});
|
||||
|
||||
/**
|
||||
* `libraryStore` fetches at import and caches the empty list the
|
||||
* shared setup stubs, for the life of the browser session — so a test
|
||||
* that wants tracks in it has to say so. A scan-complete event is how
|
||||
* the app itself invalidates that cache.
|
||||
*/
|
||||
async function primeLibrary() {
|
||||
emit(Events.LibraryScanComplete);
|
||||
await flush();
|
||||
}
|
||||
|
||||
it('offers Track Details on an owned track', async () => {
|
||||
const el = await albumPage();
|
||||
const items = await openTrackMenu(el);
|
||||
|
||||
expect(labels(items)).toContain('Track Details');
|
||||
});
|
||||
|
||||
it('does not offer it on a track the library does not have', async () => {
|
||||
// A catalog album the user owns nothing of: the tracklist renders
|
||||
// from the browse, and every row is unowned.
|
||||
stub(ALBUM_TRACKS, []);
|
||||
stub(LOOKUP_RG, {
|
||||
mbid: 'rg-1',
|
||||
title: 'An Album',
|
||||
artistCredit: 'An Artist',
|
||||
firstReleaseDate: '1994',
|
||||
primaryType: 'Album',
|
||||
});
|
||||
stub(BROWSE_RELEASES, [
|
||||
{
|
||||
mbid: 'rel-1',
|
||||
title: 'An Album',
|
||||
date: '1994',
|
||||
country: 'GB',
|
||||
tracks: [
|
||||
{
|
||||
mbid: 'rec-2',
|
||||
title: 'Another Song',
|
||||
position: 1,
|
||||
length: 180000,
|
||||
discNumber: 1,
|
||||
inLibrary: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const el = await fixture<LitElement>('explore-album-details', {
|
||||
releaseGroupMBID: 'rg-1',
|
||||
});
|
||||
const items = await openTrackMenu(el);
|
||||
|
||||
expect(labels(items)).not.toContain('Track Details');
|
||||
expect(
|
||||
labels(items).some((l) => l.startsWith('Add to Playlist')),
|
||||
'nothing to add to a playlist when there is no file',
|
||||
).toBe(false);
|
||||
// The one item a track nobody owns still has, which is what makes
|
||||
// the assertion above about the gate rather than about an empty
|
||||
// menu that never opened.
|
||||
expect(labels(items)).toContain('View on MusicBrainz');
|
||||
});
|
||||
|
||||
it('opens the dialog on the library track behind the row', async () => {
|
||||
await primeLibrary();
|
||||
|
||||
const el = await albumPage();
|
||||
const items = await openTrackMenu(el);
|
||||
const details = labels(items).indexOf('Track Details');
|
||||
|
||||
items[details]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
// Polled rather than counted: the opener is three awaits deep — the
|
||||
// path lookup, the store's tracks, and the dynamic `import()` of
|
||||
// the dialog chunk — and a chunk fetch is the one of the three
|
||||
// whose cost depends on what else the suite is doing.
|
||||
const shown = await dialogTrack(el);
|
||||
|
||||
expect(shown?.FilePath).toBe(PATH);
|
||||
});
|
||||
|
||||
it('opens the playlist submenu on the row’s own file', async () => {
|
||||
const el = await albumPage();
|
||||
const items = await openTrackMenu(el);
|
||||
// Its label carries the submenu arrow, so match the prefix.
|
||||
const add = labels(items).findIndex((l) => l.startsWith('Add to Playlist'));
|
||||
|
||||
expect(add, 'the submenu item is in the menu').toBeGreaterThan(-1);
|
||||
|
||||
items[add]!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
||||
// Resolved by MBID at the moment the submenu opens, so the picker
|
||||
// is not there on the first tick the way a library list's is.
|
||||
const picker = await pickerPaths(el);
|
||||
|
||||
expect(picker).toEqual([PATH]);
|
||||
});
|
||||
|
||||
it('reports a path with no library track rather than opening empty', async () => {
|
||||
stub(ALL_TRACKS, []);
|
||||
await primeLibrary();
|
||||
|
||||
const outcome = await showTrackDetailsForPath(
|
||||
() => undefined,
|
||||
PATH,
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect(outcome).toBe('not-in-library');
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ describe('home view', () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1]);
|
||||
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1, 0]);
|
||||
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
|
||||
['/music/1.mp3', '/music/2.mp3'],
|
||||
0,
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('<queue-panel> when closed', () => {
|
||||
|
||||
describe('<track-list> roving tabindex', () => {
|
||||
beforeEach(() => {
|
||||
stub('library.Library.GetAllTracks', TRACKS);
|
||||
stub('library.Library.GetTracks', TRACKS);
|
||||
});
|
||||
|
||||
it('offers exactly one tab stop, however many rows there are', async () => {
|
||||
|
||||
@@ -132,8 +132,8 @@ describe('<explore-view> badges', () => {
|
||||
stub('explore.Service.GetThumbnail', '');
|
||||
stub('explore.Service.GetArtistImageURL', '');
|
||||
stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' });
|
||||
stub('library.Library.GetAllAlbums', []);
|
||||
stub('library.Library.GetAllTracks', []);
|
||||
stub('library.Library.GetAlbums', []);
|
||||
stub('library.Library.GetTracks', []);
|
||||
await withRequests([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* The phantom resolver showed the same library track twice.
|
||||
*
|
||||
* Its right-hand panel renders two lists one under the other — the
|
||||
* scored candidates and the library search results — and a search for
|
||||
* the obvious title returns exactly what scoring already found. So the
|
||||
* track appeared once with a score and once without, and double-clicking
|
||||
* either did the same thing.
|
||||
*
|
||||
* The second half is what a match *means*: one library file cannot stand
|
||||
* in for two unmatched tracks, or applying adds it to the playlist
|
||||
* twice. `FindPhantomMatches` has always claimed candidates on the
|
||||
* auto-match path; the manual path had no such rule.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import type { LitElement } from 'lit';
|
||||
|
||||
import '@components/phantom-resolver/phantom-resolver';
|
||||
import { flush, stub } from '@test/support/harness';
|
||||
import { fixture } from '@test/support/render';
|
||||
|
||||
interface Candidate {
|
||||
FilePath: string;
|
||||
Title: string;
|
||||
Artist: string;
|
||||
Album: string;
|
||||
Duration: string;
|
||||
Score: number;
|
||||
}
|
||||
|
||||
function candidate(
|
||||
path: string,
|
||||
title: string,
|
||||
score = 0.5,
|
||||
): Candidate {
|
||||
return {
|
||||
FilePath: path,
|
||||
Title: title,
|
||||
Artist: 'An Artist',
|
||||
Album: 'An Album',
|
||||
Duration: '200000',
|
||||
Score: score,
|
||||
};
|
||||
}
|
||||
|
||||
const PHANTOM_A = '/music/gone/one.mp3';
|
||||
const PHANTOM_B = '/music/gone/two.mp3';
|
||||
|
||||
/** Mount the dialog with two unmatched tracks and no auto-matches. */
|
||||
async function open(
|
||||
candidates: Candidate[],
|
||||
searchResults: Candidate[] = [],
|
||||
): Promise<HTMLElement & { updateComplete: Promise<unknown> }> {
|
||||
stub('playlist.Service.FindPhantomMatches', {
|
||||
AutoMatched: [],
|
||||
Unmatched: [PHANTOM_A, PHANTOM_B],
|
||||
});
|
||||
stub('playlist.Service.GetPhantomCandidates', candidates);
|
||||
stub('playlist.Service.SearchLibrary', searchResults);
|
||||
|
||||
const el = await fixture<
|
||||
LitElement & { show(id: number, tracks: unknown[]): void }
|
||||
>('phantom-resolver');
|
||||
|
||||
el.show(1, [
|
||||
{ FilePath: PHANTOM_A, Title: 'One', Phantom: true },
|
||||
{ FilePath: PHANTOM_B, Title: 'Two', Phantom: true },
|
||||
]);
|
||||
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function candidateRows(el: HTMLElement): HTMLElement[] {
|
||||
return [
|
||||
...(el.shadowRoot?.querySelectorAll<HTMLElement>('.candidate-item') ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/** The dialog renders the file path as each row's `title`. */
|
||||
function rowPaths(el: HTMLElement): string[] {
|
||||
return candidateRows(el).map((r) => r.getAttribute('title') ?? '');
|
||||
}
|
||||
|
||||
describe('the phantom resolver', () => {
|
||||
beforeEach(() => {
|
||||
stub('playlist.Service.ResolvePhantomTracks', null);
|
||||
stub('playlist.Service.RemovePhantomTracks', null);
|
||||
});
|
||||
|
||||
it('lists a search result the candidates already show only once', async () => {
|
||||
const shared = candidate('/music/have/one.mp3', 'One', 0.7);
|
||||
const el = await open([shared], [shared, candidate('/music/have/x.mp3', 'X', 0)]);
|
||||
|
||||
// Type into the search box and let the debounce fire.
|
||||
const input = el.shadowRoot?.querySelector<HTMLInputElement>(
|
||||
'.search-input',
|
||||
);
|
||||
|
||||
expect(input, 'the search box is rendered').toBeTruthy();
|
||||
|
||||
input!.value = 'one';
|
||||
input!.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const paths = rowPaths(el);
|
||||
|
||||
expect(paths.filter((p) => p === shared.FilePath)).toHaveLength(1);
|
||||
expect(paths).toContain('/music/have/x.mp3');
|
||||
});
|
||||
|
||||
it('matches a candidate from the keyboard, not only a double-click', async () => {
|
||||
const el = await open([candidate('/music/have/one.mp3', 'One', 0.7)]);
|
||||
const row = candidateRows(el)[0];
|
||||
|
||||
expect(row, 'a candidate row is rendered').toBeTruthy();
|
||||
expect(row!.getAttribute('role')).toBe('button');
|
||||
expect(row!.getAttribute('tabindex')).toBe('0');
|
||||
|
||||
row!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
|
||||
// Matching the first phantom advances to the second, whose row
|
||||
// shows the check mark for the one just confirmed.
|
||||
const matched = el.shadowRoot?.querySelectorAll('.phantom-item.matched');
|
||||
|
||||
expect(matched).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('will not spend one library file on two unmatched tracks', async () => {
|
||||
const only = candidate('/music/have/one.mp3', 'One', 0.7);
|
||||
const el = await open([only]);
|
||||
|
||||
candidateRows(el)[0]!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
// The second phantom is selected now and offered the same file,
|
||||
// which is already standing in for the first.
|
||||
const row = candidateRows(el)[0];
|
||||
|
||||
expect(row!.classList.contains('claimed')).toBe(true);
|
||||
expect(row!.getAttribute('aria-disabled')).toBe('true');
|
||||
|
||||
row!.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
|
||||
);
|
||||
await el.updateComplete;
|
||||
|
||||
// Still one confirmed match, not two.
|
||||
expect(
|
||||
el.shadowRoot?.querySelectorAll('.phantom-item.matched'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('gives the auto-match disclosure a keyboard-reachable control', async () => {
|
||||
stub('playlist.Service.FindPhantomMatches', {
|
||||
AutoMatched: [
|
||||
{
|
||||
PhantomPath: PHANTOM_A,
|
||||
PhantomTitle: 'One',
|
||||
Candidate: candidate('/music/have/one.mp3', 'One', 0.95),
|
||||
},
|
||||
],
|
||||
Unmatched: [PHANTOM_B],
|
||||
});
|
||||
stub('playlist.Service.GetPhantomCandidates', []);
|
||||
|
||||
const el = await fixture<
|
||||
LitElement & { show(id: number, tracks: unknown[]): void }
|
||||
>('phantom-resolver');
|
||||
|
||||
el.show(1, [{ FilePath: PHANTOM_A, Title: 'One', Phantom: true }]);
|
||||
await flush();
|
||||
await el.updateComplete;
|
||||
|
||||
const header = el.shadowRoot?.querySelector('.auto-match-header');
|
||||
|
||||
expect(header?.tagName).toBe('BUTTON');
|
||||
expect(header?.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
// aria-controls has to name an element that is in the DOM, so the
|
||||
// list renders collapsed rather than not at all.
|
||||
const controls = header?.getAttribute('aria-controls');
|
||||
|
||||
expect(controls).toBeTruthy();
|
||||
expect(el.shadowRoot?.getElementById(controls!)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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 });
|
||||
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');
|
||||
|
||||
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 row’s 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -117,10 +117,10 @@ const TAGS = [
|
||||
*/
|
||||
function stubEmptyBackend(): void {
|
||||
const emptyLists = [
|
||||
'library.Library.GetAllTracks',
|
||||
'library.Library.GetAllAlbums',
|
||||
'library.Library.GetAllArtists',
|
||||
'library.Library.GetAllGenresWithCounts',
|
||||
'library.Library.GetTracks',
|
||||
'library.Library.GetAlbums',
|
||||
'library.Library.GetArtists',
|
||||
'library.Library.GetGenres',
|
||||
'library.Library.GetAllLibrariesWithTrackCounts',
|
||||
'playlist.Service.GetAllPlaylists',
|
||||
'playlist.Service.GetAllPlaylistsWithTracks',
|
||||
|
||||
@@ -233,10 +233,10 @@ const CACHED_VIEWS = [
|
||||
* binding resolves undefined, which is not what Go sends. */
|
||||
function stubEmptyBackend(): void {
|
||||
for (const path of [
|
||||
'library.Library.GetAllTracks',
|
||||
'library.Library.GetAllAlbums',
|
||||
'library.Library.GetAllArtists',
|
||||
'library.Library.GetAllGenresWithCounts',
|
||||
'library.Library.GetTracks',
|
||||
'library.Library.GetAlbums',
|
||||
'library.Library.GetArtists',
|
||||
'library.Library.GetGenres',
|
||||
'library.Library.GetAllLibrariesWithTrackCounts',
|
||||
'playlist.Service.GetAllPlaylists',
|
||||
'playlist.Service.GetAllPlaylistsWithTracks',
|
||||
|
||||
Reference in New Issue
Block a user