From e16d18241761d3921b4f5a60d4a444d790194b35 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 13:17:15 -0400 Subject: [PATCH 1/4] fix(web): drop the shelf-header accent rule Tailwind 4 started drawing (#5021) The rule after each shelf title used `after:bg-accent/60`. Tailwind 3 cannot put an opacity modifier on a `var()` colour, so it emitted nothing for that class and the rule never showed. Tailwind 4 mixes the opacity in with color-mix, so the rule appeared after the upgrade. Removed, so the headers look as they did before. Co-Authored-By: Claude Opus 5.5 --- web/src/lib/components/HorizontalScrollRow.svelte | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/web/src/lib/components/HorizontalScrollRow.svelte b/web/src/lib/components/HorizontalScrollRow.svelte index a188af37..b1944227 100644 --- a/web/src/lib/components/HorizontalScrollRow.svelte +++ b/web/src/lib/components/HorizontalScrollRow.svelte @@ -62,14 +62,8 @@
{#if title} - -

{title}

+ +

{title}

{:else} {/if} From b7f50c42a9e97c4b9f5afccc7ec46e9113029da0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 8 Oct 2026 14:14:06 -0400 Subject: [PATCH 2/4] feat(web): one track-list row shape, and drag reorder that snaps (M494, #5393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every track list (album, Liked, Search, Search → Tracks, playlist, History) now draws its rows with TrackListRow inside a TrackList box, in place of four row components that had drifted apart: - TrackListRow owns the look: lead column, title over "artist · album", like, radio, add to queue, menu, duration, then a trailing slot. It also owns the stripe, hover, the now-playing band and the unavailable state. The stripe is dropped on now-playing and selected rows, where Tailwind would otherwise let `odd:` win. - TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only their behaviour: track number and multi-select; grip, remove and dead-entry states; cover and timestamp. Playlist and History rows gain radio, add and the menu, so every list offers the same actions. - The radio control is Lucide's Radio icon, not the 📻 emoji. - Enter on a control inside a row now belongs to that control. The old TrackRow played the track when Enter was pressed on "add to queue". - TrackList has no fill, so the stripe shows on playlists too. Drag reorder (#5393): neodrag kept the drop offset, so after the list re-rendered the dragged row sat between two slots. reorderDrag, shared by the playlist and queue rows: - snaps the row to whole rows while dragging; - resets it to its slot on drop; - on playlists, gives the touch gesture to the grip only, so the list still scrolls by touch. The playlist page reorders optimistically and rolls back on error. Co-Authored-By: Claude Opus 5.5 --- web/src/lib/components/HistoryRow.svelte | 44 ++--- web/src/lib/components/HistoryRow.test.ts | 26 ++- .../lib/components/PlaylistTrackRow.svelte | 161 +++++++----------- web/src/lib/components/QueueTrackRow.svelte | 24 +-- web/src/lib/components/TrackList.svelte | 12 ++ web/src/lib/components/TrackListRow.svelte | 151 ++++++++++++++++ web/src/lib/components/TrackListRow.test.ts | 104 +++++++++++ web/src/lib/components/TrackRow.svelte | 110 +++++------- web/src/lib/components/queue-row-math.ts | 7 +- web/src/lib/components/reorder-drag.test.ts | 85 +++++++++ web/src/lib/components/reorder-drag.ts | 98 +++++++++++ web/src/routes/albums/[id]/+page.svelte | 7 +- web/src/routes/library/history/+page.svelte | 5 +- web/src/routes/library/history/page.test.ts | 13 +- web/src/routes/library/liked/+page.svelte | 5 +- web/src/routes/library/liked/liked.test.ts | 2 +- web/src/routes/playlists/[id]/+page.svelte | 40 +++-- .../routes/playlists/[id]/playlist.test.ts | 1 + web/src/routes/search/+page.svelte | 5 +- web/src/routes/search/tracks/+page.svelte | 7 +- 20 files changed, 666 insertions(+), 241 deletions(-) create mode 100644 web/src/lib/components/TrackList.svelte create mode 100644 web/src/lib/components/TrackListRow.svelte create mode 100644 web/src/lib/components/TrackListRow.test.ts create mode 100644 web/src/lib/components/reorder-drag.test.ts create mode 100644 web/src/lib/components/reorder-drag.ts diff --git a/web/src/lib/components/HistoryRow.svelte b/web/src/lib/components/HistoryRow.svelte index e2e9803d..15690739 100644 --- a/web/src/lib/components/HistoryRow.svelte +++ b/web/src/lib/components/HistoryRow.svelte @@ -2,6 +2,7 @@ import type { HistoryEvent } from '#lib/api/history.js'; import { playQueue } from '#lib/player/store.svelte.js'; import { coverUrl } from '#lib/media/covers.js'; + import TrackListRow from './TrackListRow.svelte'; let { event } = $props<{ event: HistoryEvent }>(); @@ -40,25 +41,26 @@ const tooltip = $derived(new Date(event.played_at).toISOString()); - + {#snippet lead()} + + {/snippet} + + {#snippet trailing()} + + {timestamp} + + {/snippet} + diff --git a/web/src/lib/components/HistoryRow.test.ts b/web/src/lib/components/HistoryRow.test.ts index 52145e0c..5dfcbdaf 100644 --- a/web/src/lib/components/HistoryRow.test.ts +++ b/web/src/lib/components/HistoryRow.test.ts @@ -3,13 +3,30 @@ import { render, screen, fireEvent } from '@testing-library/svelte'; import HistoryRow from './HistoryRow.svelte'; import type { HistoryEvent } from '#lib/api/history.js'; import { makeTrack } from '#test-utils/fixtures/track.js'; +import { emptyLikesMock } from '../../test-utils/mocks/likes'; +import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine'; const playQueue = vi.fn(); +// The row is a TrackListRow, so LikeButton and TrackMenu render too. vi.mock('#lib/player/store.svelte.js', () => ({ - playQueue: (...args: unknown[]) => playQueue(...args) + playQueue: (...args: unknown[]) => playQueue(...args), + playNext: vi.fn(), + enqueueTrack: vi.fn(), + playRadio: vi.fn(), + player: { current: undefined } })); +vi.mock('#lib/auth/store.svelte.js', () => ({ + user: { value: { id: 'u1', username: 'me', is_admin: false } } +})); + +vi.mock('$app/navigation', () => ({ goto: vi.fn() })); + +vi.mock('#lib/api/likes.js', () => emptyLikesMock()); + +vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock()); + const sampleTrack = makeTrack({ title: 'Song Title', album_title: 'Album', @@ -41,6 +58,13 @@ describe('HistoryRow', () => { expect(playQueue).toHaveBeenCalledWith([sampleTrack], 0); }); + it('carries the shared row controls: like, radio, add and the menu', () => { + render(HistoryRow, { props: { event: sampleEvent } }); + expect(screen.getByRole('button', { name: /play radio/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /add song title to queue/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /track actions for/i })).toBeInTheDocument(); + }); + it('renders a relative timestamp for events under 1 hour ago', () => { render(HistoryRow, { props: { event: sampleEvent } }); expect(screen.getByText(/\d+m ago/)).toBeInTheDocument(); diff --git a/web/src/lib/components/PlaylistTrackRow.svelte b/web/src/lib/components/PlaylistTrackRow.svelte index a2b5271e..cddf0c4b 100644 --- a/web/src/lib/components/PlaylistTrackRow.svelte +++ b/web/src/lib/components/PlaylistTrackRow.svelte @@ -1,12 +1,10 @@ -
onPlay(row.position)} > - {#if isOwner} - - {/if} + {#snippet lead()} + {#if isOwner} + + {/if} + {/snippet} - + {#snippet note()} + {#if isMissingFile} + · File missing + {/if} + {/snippet} - {format(row.duration_sec)} - - {#if !isUnavailable && liveTrack} - - - {/if} - - {#if isOwner} - - {/if} -
+ {#snippet trailing()} + {#if isOwner} + + {/if} + {/snippet} + diff --git a/web/src/lib/components/QueueTrackRow.svelte b/web/src/lib/components/QueueTrackRow.svelte index 17c5e3a7..edcb1521 100644 --- a/web/src/lib/components/QueueTrackRow.svelte +++ b/web/src/lib/components/QueueTrackRow.svelte @@ -1,10 +1,9 @@
moveQueueItem(index, index + delta) }} class="flex items-center gap-2 border-b border-border px-3 py-2 h-16 - {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}" + [&.neodrag-dragging]:relative [&.neodrag-dragging]:z-10 [&.neodrag-dragging]:shadow-lg + {isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : 'bg-background'}" > + {#snippet lead()} + {track.track_number ?? '—'} - - - {track.title} - - - - - {formatDuration(track.duration_sec)} -
+ {/snippet} + diff --git a/web/src/lib/components/queue-row-math.ts b/web/src/lib/components/queue-row-math.ts index 44cfa154..a1b06ef1 100644 --- a/web/src/lib/components/queue-row-math.ts +++ b/web/src/lib/components/queue-row-math.ts @@ -1,7 +1,8 @@ // Translate a vertical drag offset (px) into an integer row-delta, -// given the measured row height (px). Used by QueueTrackRow when a -// neodrag drop completes — we round to the nearest row boundary so -// the user's drop intent maps to a clean queue index. +// given the measured row height (px). Used by reorderDrag both to snap +// the row while it is dragged and to report where it was dropped — it +// rounds to the nearest row boundary, so the slot the row shows is the +// slot it lands in. export function offsetToDelta(offsetY: number, rowHeight: number): number { if (rowHeight <= 0) return 0; // `|| 0` normalizes -0 to +0. Math.round(-0.5) === -0; downstream diff --git a/web/src/lib/components/reorder-drag.test.ts b/web/src/lib/components/reorder-drag.test.ts new file mode 100644 index 00000000..a4660d66 --- /dev/null +++ b/web/src/lib/components/reorder-drag.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { reorderDrag } from './reorder-drag'; + +// jsdom lays nothing out, so the row and its list get explicit geometry: +// a 560px list of 56px rows. neodrag divides by offsetWidth / rect.width +// to undo CSS scaling, so offsetWidth has to agree with the rect. +function rect(height: number): DOMRect { + return { + top: 0, left: 0, right: 300, bottom: height, width: 300, height, + x: 0, y: 0, toJSON: () => ({}) + } as DOMRect; +} + +function mount() { + const list = document.createElement('div'); + const row = document.createElement('div'); + const handle = document.createElement('button'); + handle.setAttribute('data-reorder-handle', ''); + row.appendChild(handle); + list.appendChild(row); + document.body.appendChild(list); + list.getBoundingClientRect = () => rect(560); + row.getBoundingClientRect = () => rect(56); + Object.defineProperty(row, 'offsetWidth', { value: 300 }); + return { list, row, handle }; +} + +function pointer(target: EventTarget, type: string, clientY: number) { + target.dispatchEvent(new MouseEvent(type, { bubbles: true, clientX: 10, clientY, button: 0 })); +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('reorderDrag', () => { + test('a drag past half a row moves one slot, and the row lands back in place', () => { + const { row } = mount(); + const onMove = vi.fn(); + const action = reorderDrag(row, { onMove }); + + pointer(row, 'pointerdown', 10); + pointer(row, 'pointermove', 40); + // 30px of a 56px row snaps to the next slot while dragging. + expect(row.style.transform).toContain('56px'); + pointer(row, 'pointerup', 40); + + expect(onMove).toHaveBeenCalledWith(1); + expect(row.style.transform).not.toContain('56px'); + action.destroy(); + }); + + test('a drag of under half a row stays in its slot and reports nothing', () => { + const { row } = mount(); + const onMove = vi.fn(); + const action = reorderDrag(row, { onMove }); + + pointer(row, 'pointerdown', 10); + pointer(row, 'pointermove', 30); + pointer(row, 'pointerup', 30); + + expect(onMove).not.toHaveBeenCalled(); + action.destroy(); + }); + + test('with a handle, only the handle starts a drag and takes the touch gesture', () => { + const { row, handle } = mount(); + const onMove = vi.fn(); + const action = reorderDrag(row, { handle: '[data-reorder-handle]', onMove }); + + expect(row.style.touchAction).toBe(''); + expect(handle.style.touchAction).toBe('none'); + + pointer(row, 'pointerdown', 10); + pointer(row, 'pointermove', 80); + pointer(row, 'pointerup', 80); + expect(onMove).not.toHaveBeenCalled(); + + pointer(handle, 'pointerdown', 10); + pointer(handle, 'pointermove', 80); + pointer(handle, 'pointerup', 80); + expect(onMove).toHaveBeenCalledWith(1); + action.destroy(); + }); +}); diff --git a/web/src/lib/components/reorder-drag.ts b/web/src/lib/components/reorder-drag.ts new file mode 100644 index 00000000..bbb168e1 --- /dev/null +++ b/web/src/lib/components/reorder-drag.ts @@ -0,0 +1,98 @@ +// Drag-to-reorder for a row in a vertical list, shared by the playlist and +// queue rows (#5393). Wraps @neodrag/svelte with the three things a list +// needs that neodrag does not do on its own: +// +// - The row moves in whole-row steps, so it visibly snaps into the slot it +// will land in. neodrag's own `grid` rounds with Math.ceil, which jumps a +// full row down on a 1px drag but needs a full row to move up, so the +// snapping is done here in `transform` with offsetToDelta's rounding. +// - The row goes back to translate 0 on drop. Left uncontrolled, neodrag +// keeps the drop offset, and once the list re-renders in its new order +// that offset leaves the row sitting between two slots. +// - Only the handle takes the touch gesture. neodrag sets touch-action:none +// on the whole node, which stops a touch screen scrolling the list from +// any row it is dragged from; with a handle, the row scrolls normally. + +import { draggable, type DragOptions } from '@neodrag/svelte'; +import { offsetToDelta } from './queue-row-math'; + +export type ReorderDragOptions = { + disabled?: boolean; + /** Selector for the drag handle inside the row. Omit to drag from anywhere. */ + handle?: string; + /** Called on drop with the whole number of rows moved (never 0). */ + onMove: (delta: number) => void; +}; + +const FALLBACK_ROW_HEIGHT = 56; + +export function reorderDrag(node: HTMLElement, initial: ReorderDragOptions | null) { + let opts = initial; + let rowHeight = FALLBACK_ROW_HEIGHT; + let instance: ReturnType | null = null; + + function handleEl(): HTMLElement | undefined { + if (!opts?.handle) return undefined; + return node.querySelector(opts.handle) ?? undefined; + } + + function build(): DragOptions { + return { + axis: 'y', + bounds: 'parent', + disabled: opts?.disabled ?? false, + handle: handleEl(), + // Controlled at {0, 0}: every update() puts the row back in its slot. + position: { x: 0, y: 0 }, + transform: ({ offsetY }) => + `translate3d(0, ${offsetToDelta(offsetY, rowHeight) * rowHeight}px, 0)`, + onDragStart: ({ rootNode }) => { + const h = rootNode.getBoundingClientRect().height; + if (h > 0) rowHeight = h; + }, + onDragEnd: ({ offsetY }) => { + const delta = offsetToDelta(offsetY, rowHeight); + instance?.update?.(build()); + if (delta !== 0) opts?.onMove(delta); + } + }; + } + + function touchToHandle() { + const handle = handleEl(); + if (!handle) return; + node.style.touchAction = ''; + handle.style.touchAction = 'none'; + } + + function attach() { + if (!opts || instance) return; + instance = draggable(node, build()); + touchToHandle(); + } + + attach(); + + return { + update(next: ReorderDragOptions | null) { + opts = next; + if (!opts) { + instance?.destroy?.(); + instance = null; + node.style.touchAction = ''; + node.style.transform = ''; + return; + } + if (instance) { + instance.update?.(build()); + touchToHandle(); + } else { + attach(); + } + }, + destroy() { + instance?.destroy?.(); + instance = null; + } + }; +} diff --git a/web/src/routes/albums/[id]/+page.svelte b/web/src/routes/albums/[id]/+page.svelte index 72625ea1..aa856e7b 100644 --- a/web/src/routes/albums/[id]/+page.svelte +++ b/web/src/routes/albums/[id]/+page.svelte @@ -5,6 +5,7 @@ import { qk } from '#lib/api/queries.js'; import { pageTitle } from '#lib/branding.js'; import TrackRow from '#lib/components/TrackRow.svelte'; + import TrackList from '#lib/components/TrackList.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import LikeButton from '#lib/components/LikeButton.svelte'; @@ -199,11 +200,11 @@ {#if album.tracks.length === 0}

This album has no tracks.

{:else} -
+ {#each album.tracks as track, i (track.id)} - + {/each} -
+ {/if} {/if}
diff --git a/web/src/routes/library/history/+page.svelte b/web/src/routes/library/history/+page.svelte index cc417bfc..5622c4ba 100644 --- a/web/src/routes/library/history/+page.svelte +++ b/web/src/routes/library/history/+page.svelte @@ -3,6 +3,7 @@ import { createHistoryQuery } from '#lib/api/history.js'; import { groupByDay } from '#lib/utils/dayGroup.js'; import HistoryRow from '#lib/components/HistoryRow.svelte'; + import TrackList from '#lib/components/TrackList.svelte'; import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; @@ -62,11 +63,11 @@

{group.label}

-
+ {#each group.events as event (event.id)} {/each} -
+ {/each} diff --git a/web/src/routes/library/history/page.test.ts b/web/src/routes/library/history/page.test.ts index 550cf6e4..9777edcf 100644 --- a/web/src/routes/library/history/page.test.ts +++ b/web/src/routes/library/history/page.test.ts @@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/svelte'; import HistoryPage from './+page.svelte'; import type { HistoryEvent, HistoryResponse } from '#lib/api/history.js'; import { makeTrack } from '#test-utils/fixtures/track.js'; +import { emptyLikesMock } from '../../../test-utils/mocks/likes'; +import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine'; const mkTrack = (id: string) => makeTrack({ id: `track-${id}`, title: `Song ${id}` }); @@ -44,10 +46,19 @@ vi.mock('#lib/api/history.js', async () => { }; }); +// Rows are TrackListRows, so LikeButton and TrackMenu render too. vi.mock('#lib/player/store.svelte.js', () => ({ - playQueue: vi.fn() + playQueue: vi.fn(), + playNext: vi.fn(), + enqueueTrack: vi.fn(), + playRadio: vi.fn(), + player: { current: undefined } })); +vi.mock('#lib/api/likes.js', () => emptyLikesMock()); + +vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock()); + vi.mock('#lib/branding.js', () => ({ pageTitle: (s: string) => `Minstrel · ${s}` })); diff --git a/web/src/routes/library/liked/+page.svelte b/web/src/routes/library/liked/+page.svelte index 8339e46f..096cc0e9 100644 --- a/web/src/routes/library/liked/+page.svelte +++ b/web/src/routes/library/liked/+page.svelte @@ -8,6 +8,7 @@ import ArtistCard from '#lib/components/ArtistCard.svelte'; import AlbumCard from '#lib/components/AlbumCard.svelte'; import TrackRow from '#lib/components/TrackRow.svelte'; + import TrackList from '#lib/components/TrackList.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte'; import EmptyState from '#lib/components/EmptyState.svelte'; @@ -157,11 +158,11 @@ {/snippet} {:else} -
+ {#each fTracks as t, i (t.id)} {/each} -
+ {#if tracksQuery?.hasNextPage} + + {#if children}{@render children()}{/if} + diff --git a/web/src/lib/utils/shuffle.ts b/web/src/lib/utils/shuffle.ts new file mode 100644 index 00000000..3f5e9e7e --- /dev/null +++ b/web/src/lib/utils/shuffle.ts @@ -0,0 +1,14 @@ +/** + * A shuffled copy of `items` (Fisher–Yates). The input is not touched. + * + * The one copy for every Shuffle control: the album and playlist headers + * and the artist card's play button each had their own before this. + */ +export function shuffled(items: readonly T[]): T[] { + const arr = items.slice(); + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} diff --git a/web/src/routes/albums/[id]/+page.svelte b/web/src/routes/albums/[id]/+page.svelte index aa856e7b..264d8035 100644 --- a/web/src/routes/albums/[id]/+page.svelte +++ b/web/src/routes/albums/[id]/+page.svelte @@ -1,17 +1,18 @@ @@ -108,16 +103,15 @@ {/each} {/if} +
+ +
- {#if detail.albums.length === 0} diff --git a/web/src/routes/artists/[id]/artist.test.ts b/web/src/routes/artists/[id]/artist.test.ts index 31e38463..640348e2 100644 --- a/web/src/routes/artists/[id]/artist.test.ts +++ b/web/src/routes/artists/[id]/artist.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import { flushSync } from 'svelte'; import { mockQuery } from '../../../test-utils/query'; import { emptyLikesMock } from '../../../test-utils/mocks/likes'; +import { apiClientMock } from '../../../test-utils/mocks/client'; +import { makeTracks } from '#test-utils/fixtures/track.js'; import type { ArtistDetail, AlbumRef } from '#lib/api/types.js'; const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record })); @@ -23,7 +25,20 @@ vi.mock('#lib/api/queries.js', () => ({ vi.mock('#lib/api/likes.js', () => emptyLikesMock()); +vi.mock('#lib/api/client.js', () => apiClientMock()); + +vi.mock('#lib/player/store.svelte.js', () => ({ + playQueue: vi.fn(), + enqueueTrack: vi.fn(), + enqueueTracks: vi.fn(), + playRadio: vi.fn(), + playNext: vi.fn(), + player: { current: undefined } +})); + import ArtistPage from './+page.svelte'; +import { api } from '#lib/api/client.js'; +import { playQueue } from '#lib/player/store.svelte.js'; import { createArtistQuery, createSimilarArtistsQuery, @@ -132,4 +147,33 @@ describe('artist detail page', () => { vi.useRealTimers(); } }); + + // Play and Shuffle are the shared PlayActions (milestone 494). Play keeps + // the server's order; before, the artist's only button was "Play" and it + // shuffled. + describe('Play and Shuffle', () => { + const detail: ArtistDetail = { + id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '', + albums: [album('a1', 'First', 2020)], genres: [] + }; + const tracks = makeTracks(5); + + test('Play queues the artist in the server order', async () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + (api.get as ReturnType).mockResolvedValue(tracks); + render(ArtistPage); + await fireEvent.click(screen.getByRole('button', { name: 'Play Alice' })); + await waitFor(() => expect(playQueue).toHaveBeenCalledWith(tracks, 0)); + }); + + test('Shuffle queues every track', async () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + (api.get as ReturnType).mockResolvedValue(tracks); + render(ArtistPage); + await fireEvent.click(screen.getByRole('button', { name: 'Shuffle Alice' })); + await waitFor(() => expect(playQueue).toHaveBeenCalled()); + const [queued] = (playQueue as ReturnType).mock.calls[0]; + expect(queued.map((t: { id: string }) => t.id).sort()).toEqual(tracks.map((t) => t.id).sort()); + }); + }); }); diff --git a/web/src/routes/library/history/+page.svelte b/web/src/routes/library/history/+page.svelte index 5622c4ba..44893c19 100644 --- a/web/src/routes/library/history/+page.svelte +++ b/web/src/routes/library/history/+page.svelte @@ -1,4 +1,5 @@