/** * 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 { const el = await fixture('explore-album-details', { albumName: 'Glass Harbour', }); const tracks = Array.from({ length: total }, (_, i) => albumTrack(i + 1, ownedMbids.includes(`mbid-${i + 1}`)), ); const paths: Record = {}; 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('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('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 { 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(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', ]); }); });