Web: lists load as you scroll, Liked tabs, missing files greyed and kept out of the queue #154

Merged
bvandeusen merged 4 commits from dev into main 2026-10-08 20:08:05 -04:00
31 changed files with 683 additions and 348 deletions
+4
View File
@@ -41,6 +41,10 @@ export type TrackRef = {
disc_number?: number; disc_number?: number;
duration_sec: number; duration_sec: number;
stream_url: string; stream_url: string;
// #5418: the file is gone from the library (missing_since is set). Sent on
// direct lookups such as album detail and search; lists that pick tracks
// for playback leave missing files out instead.
unavailable?: boolean;
}; };
export type ArtistDetail = ArtistRef & { export type ArtistDetail = ArtistRef & {
@@ -0,0 +1,49 @@
<script lang="ts">
// The bottom of every paged list (#5416, preference 172): the next page
// loads on its own as the reader nears the end, with no "Load more"
// button. What the button used to give for free is kept here:
//
// - In flight: a "Loading more…" line, announced through aria-live, so the
// list doesn't look finished while it isn't.
// - Finished: an optional quiet end line (`endLabel`).
// - Failed: the error and a Try again button. The sentinel is removed while
// a page has failed, so a dead endpoint isn't re-requested on every scroll.
import InfiniteScrollSentinel from './InfiniteScrollSentinel.svelte';
let {
hasMore,
loading,
failed = false,
onMore,
endLabel
}: {
/** Whether another page exists. */
hasMore: boolean;
/** A next page is in flight. */
loading: boolean;
/** The last next-page fetch failed. */
failed?: boolean;
/** Fetch the next page (TanStack's fetchNextPage, or a page's own loader). */
onMore: () => void;
/** Shown once the list is complete, e.g. "End of history". */
endLabel?: string;
} = $props();
</script>
{#if failed}
<div role="alert" class="flex items-center justify-center gap-2 py-2 text-sm text-error-fg">
Couldn't load more.
<button
type="button"
class="rounded border border-border px-2 py-0.5 text-text-primary hover:bg-surface-hover
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
onclick={onMore}>Try again</button
>
</div>
{:else if hasMore}
<InfiniteScrollSentinel enabled={!loading} onIntersect={onMore} />
{/if}
<p class="py-2 text-center text-sm text-text-secondary empty:hidden" aria-live="polite">
{#if !failed && hasMore && loading}Loading more…{:else if !hasMore && endLabel}{endLabel}{/if}
</p>
@@ -0,0 +1,43 @@
import { describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import ListContinuation from './ListContinuation.svelte';
// InfiniteScrollSentinel renders <div aria-hidden="true" class="h-px">.
const SENTINEL = 'div[aria-hidden="true"].h-px';
describe('ListContinuation', () => {
test('more to come: a sentinel and no button', () => {
const { container } = render(ListContinuation, {
props: { hasMore: true, loading: false, onMore: vi.fn() }
});
expect(container.querySelector(SENTINEL)).not.toBeNull();
expect(screen.queryByRole('button')).not.toBeInTheDocument();
});
test('in flight: announces loading', () => {
render(ListContinuation, { props: { hasMore: true, loading: true, onMore: vi.fn() } });
const status = screen.getByText('Loading more…');
expect(status).toHaveAttribute('aria-live', 'polite');
});
test('finished: the end line, no sentinel', () => {
const { container } = render(ListContinuation, {
props: { hasMore: false, loading: false, onMore: vi.fn(), endLabel: 'End of history' }
});
expect(screen.getByText('End of history')).toBeInTheDocument();
expect(container.querySelector(SENTINEL)).toBeNull();
});
// A failed page must not be re-fetched by the observer on every scroll:
// the sentinel goes away and the reader retries by hand.
test('failed: an error with Try again, and no sentinel', async () => {
const onMore = vi.fn();
const { container } = render(ListContinuation, {
props: { hasMore: true, loading: false, failed: true, onMore }
});
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more.");
expect(container.querySelector(SENTINEL)).toBeNull();
await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(onMore).toHaveBeenCalledTimes(1);
});
});
+47
View File
@@ -0,0 +1,47 @@
<script lang="ts" generics="Id extends string">
// In-page tabs: one strip of buttons switching what a single page shows
// (#5416). The same look as AdminTabs and DiscoverTabs, which are route
// navigation and so stay links; this is for state inside one page, such as
// Liked's Artists | Albums | Tracks or a queue's status filter.
type Item = {
id: Id;
label: string;
/** Shown as a pill after the label. Omit for no pill. */
count?: number;
};
let {
items,
value = $bindable(),
ariaLabel
}: {
items: Item[];
value: Id;
ariaLabel: string;
} = $props();
</script>
<div role="tablist" aria-label={ariaLabel} class="flex gap-2 border-b border-border">
{#each items as item (item.id)}
{@const active = value === item.id}
<button
type="button"
role="tab"
aria-selected={active}
class="border-b-2 px-3 py-2 text-sm transition-colors {active
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
onclick={() => (value = item.id)}
>
{item.label}
{#if item.count !== undefined}
<span
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="tab-count"
>
{item.count}
</span>
{/if}
</button>
{/each}
</div>
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, test } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import TabStrip from './TabStrip.svelte';
const items = [
{ id: 'artists', label: 'Artists', count: 3 },
{ id: 'albums', label: 'Albums' }
];
describe('TabStrip', () => {
test('a tablist with the selected tab marked, and counts where given', () => {
render(TabStrip, { props: { items, value: 'artists', ariaLabel: 'Liked' } });
expect(screen.getByRole('tablist', { name: 'Liked' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /artists/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: /albums/i })).toHaveAttribute('aria-selected', 'false');
expect(screen.getAllByTestId('tab-count').map((c) => c.textContent?.trim())).toEqual(['3']);
});
test('clicking a tab selects it', async () => {
render(TabStrip, { props: { items, value: 'artists', ariaLabel: 'Liked' } });
await fireEvent.click(screen.getByRole('tab', { name: /albums/i }));
expect(screen.getByRole('tab', { name: /albums/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByRole('tab', { name: /artists/i })).toHaveAttribute('aria-selected', 'false');
});
});
+14 -1
View File
@@ -25,6 +25,9 @@
} = $props(); } = $props();
const track = $derived(tracks[index]); const track = $derived(tracks[index]);
// #5418: the file is gone. The row stays, greyed, so an album still reads
// in order, but there is nothing to play, like or queue.
const missing = $derived(!!track.unavailable);
const isSelected = $derived(selection.isSelected(track.id)); const isSelected = $derived(selection.isSelected(track.id));
const selectMode = $derived(selection.active); const selectMode = $derived(selection.active);
@@ -61,9 +64,13 @@
title={track.title} title={track.title}
{subtitle} {subtitle}
durationSec={track.duration_sec} durationSec={track.duration_sec}
{track} track={missing ? null : track}
ariaLabel={track.title} ariaLabel={track.title}
rowTitle={missing
? 'This track’s file is missing from the library, so it can’t be played. It returns automatically if the file comes back.'
: undefined}
{isSelected} {isSelected}
disabled={missing}
onActivate={activate} onActivate={activate}
> >
{#snippet lead()} {#snippet lead()}
@@ -88,4 +95,10 @@
<Check size={14} strokeWidth={2} /> <Check size={14} strokeWidth={2} />
</button> </button>
{/snippet} {/snippet}
{#snippet note()}
{#if missing}
<span class="text-error-fg"> · File missing</span>
{/if}
{/snippet}
</TrackListRow> </TrackListRow>
+14
View File
@@ -89,6 +89,20 @@ describe('TrackRow', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
// #5418: a missing file stays in the list, greyed, with nothing to act on.
test('a missing file is marked, does not play, and offers no actions', async () => {
const withMissing = [{ ...tracks[0], unavailable: true }, tracks[1]];
render(TrackRow, { props: { tracks: withMissing, index: 0 } });
const row = screen.getByRole('button', { name: 'So What' });
expect(row).toHaveAttribute('aria-disabled', 'true');
expect(screen.getByText(/file missing/i)).toBeInTheDocument();
await fireEvent.click(row);
expect(playQueue).not.toHaveBeenCalled();
expect(screen.queryByRole('button', { name: /add .* to queue/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /play radio/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /track actions for/i })).not.toBeInTheDocument();
});
describe('selection mode', () => { describe('selection mode', () => {
test('clicking the checkbox toggles selection without playing', async () => { test('clicking the checkbox toggles selection without playing', async () => {
render(TrackRow, { props: { tracks, index: 0 } }); render(TrackRow, { props: { tracks, index: 0 } });
+19 -1
View File
@@ -123,6 +123,12 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
_audioEl = el; _audioEl = el;
} }
// #5418: a missing file can't stream, so it never enters the queue. Returns
// the same array when nothing is missing.
function playable(tracks: TrackRef[]): TrackRef[] {
return tracks.some((t) => t.unavailable) ? tracks.filter((t) => !t.unavailable) : tracks;
}
export function playQueue( export function playQueue(
tracks: TrackRef[], tracks: TrackRef[],
startIndex = 0, startIndex = 0,
@@ -140,6 +146,13 @@ export function playQueue(
// #968: a fresh play replaces the self-heal closure + resets its budget. // #968: a fresh play replaces the self-heal closure + resets its budget.
_queueRefetch = opts.refetch ?? null; _queueRefetch = opts.refetch ?? null;
_selfHealAttempts = 0; _selfHealAttempts = 0;
// Start on the chosen track's place in the playable list; if the chosen
// one is missing, that place holds the next playable track after it.
const all = tracks;
tracks = playable(all);
if (tracks !== all) {
startIndex = all.slice(0, startIndex).filter((t) => !t.unavailable).length;
}
if (opts.shuffle && tracks.length > 1) { if (opts.shuffle && tracks.length > 1) {
// Fisher-Yates over the whole list. startIndex is ignored — the // Fisher-Yates over the whole list. startIndex is ignored — the
// caller is asking for "random play from this pool," so the first // caller is asking for "random play from this pool," so the first
@@ -362,7 +375,8 @@ function trySelfHeal(): boolean {
_error = null; _error = null;
const refetch = _queueRefetch; const refetch = _queueRefetch;
void refetch() void refetch()
.then((refs) => { .then((fetched) => {
const refs = playable(fetched);
if (refs.length > 0) { if (refs.length > 0) {
// Re-seed in place — keep _queueSource / _queueRefetch so the new // Re-seed in place — keep _queueSource / _queueRefetch so the new
// snapshot can itself self-heal, and don't reset _selfHealAttempts // snapshot can itself self-heal, and don't reset _selfHealAttempts
@@ -391,6 +405,7 @@ function trySelfHeal(): boolean {
export function enqueueTrack(t: TrackRef): void { export function enqueueTrack(t: TrackRef): void {
_radioSeedId = null; // M4c _radioSeedId = null; // M4c
if (t.unavailable) return;
_queue = [..._queue, t]; _queue = [..._queue, t];
if (_state === 'idle') _index = 0; if (_state === 'idle') _index = 0;
} }
@@ -402,6 +417,7 @@ export function enqueueTrack(t: TrackRef): void {
*/ */
export function playNext(t: TrackRef): void { export function playNext(t: TrackRef): void {
_radioSeedId = null; // M4c: any user-driven enqueue clears the radio refresh state _radioSeedId = null; // M4c: any user-driven enqueue clears the radio refresh state
if (t.unavailable) return;
if (_queue.length === 0) { if (_queue.length === 0) {
_queue = [t]; _queue = [t];
_index = 0; _index = 0;
@@ -413,6 +429,7 @@ export function playNext(t: TrackRef): void {
export function enqueueTracks(ts: TrackRef[]): void { export function enqueueTracks(ts: TrackRef[]): void {
_radioSeedId = null; // M4c _radioSeedId = null; // M4c
ts = playable(ts);
if (ts.length === 0) return; if (ts.length === 0) return;
_queue = [..._queue, ...ts]; _queue = [..._queue, ...ts];
if (_state === 'idle') _index = 0; if (_state === 'idle') _index = 0;
@@ -423,6 +440,7 @@ export function enqueueTracks(ts: TrackRef[]): void {
// with the batch at index 0 (mirrors playNext's single-track shape). // with the batch at index 0 (mirrors playNext's single-track shape).
export function playNextMany(ts: TrackRef[]): void { export function playNextMany(ts: TrackRef[]): void {
_radioSeedId = null; _radioSeedId = null;
ts = playable(ts);
if (ts.length === 0) return; if (ts.length === 0) return;
if (_queue.length === 0) { if (_queue.length === 0) {
_queue = [...ts]; _queue = [...ts];
+37
View File
@@ -338,6 +338,7 @@ import {
enqueueTrack, enqueueTrack,
enqueueTracks, enqueueTracks,
playNext, playNext,
playNextMany,
playRadio, playRadio,
moveQueueItem, moveQueueItem,
removeFromQueue, removeFromQueue,
@@ -398,6 +399,42 @@ describe('player store — enqueue + playRadio', () => {
}); });
}); });
// #5418: a missing file can't stream, so no entry point lets one into the queue.
describe('missing files stay out of the queue', () => {
const gone = (id: string) => ({ ...track(id), unavailable: true });
test('playQueue drops them and starts on the chosen track', () => {
playQueue([track('1'), gone('2'), track('3'), track('4')], 2);
expect(player.queue.map((t) => t.id)).toEqual(['1', '3', '4']);
expect(player.current?.id).toBe('3');
});
test('playQueue on a missing track starts on the next playable one', () => {
playQueue([track('1'), gone('2'), track('3')], 1);
expect(player.current?.id).toBe('3');
});
test('playQueue with a missing tail starts on the last playable one', () => {
playQueue([track('1'), gone('2')], 1);
expect(player.current?.id).toBe('1');
});
test('playQueue of only missing files leaves the player idle', () => {
playQueue([gone('1'), gone('2')], 0);
expect(player.queue).toEqual([]);
expect(player.state).toBe('idle');
});
test('enqueue and play-next skip them', () => {
playQueue([track('a')], 0);
enqueueTrack(gone('b'));
playNext(gone('c'));
enqueueTracks([gone('d'), track('e')]);
playNextMany([track('f'), gone('g')]);
expect(player.queue.map((t) => t.id)).toEqual(['a', 'f', 'e']);
});
});
describe('playNext', () => { describe('playNext', () => {
test('splices at _index + 1, leaving _index unchanged', () => { test('splices at _index + 1, leaving _index unchanged', () => {
playQueue([track('a'), track('b')], 0); playQueue([track('a'), track('b')], 0);
+1 -1
View File
@@ -428,7 +428,7 @@
<div class="mt-3 text-xs text-text-muted"> <div class="mt-3 text-xs text-text-muted">
Started {formatTime(scan.started_at)} Started {formatTime(scan.started_at)}
{#if scan.finished_at}· Finished {formatTime(scan.finished_at)}{/if} {#if scan.finished_at}· Finished {formatTime(scan.finished_at)}{/if}
{#if scan.error_message}· <span class="text-oxblood">Error: {scan.error_message}</span>{/if} {#if scan.error_message}· <span class="text-error-fg">Error: {scan.error_message}</span>{/if}
</div> </div>
{/if} {/if}
+1 -1
View File
@@ -191,7 +191,7 @@
Last swept {relativeTime(sweep.finished_at ?? '')}, comparing Last swept {relativeTime(sweep.finished_at ?? '')}, comparing
{(sweep.candidates ?? 0).toLocaleString()} tracks. {(sweep.candidates ?? 0).toLocaleString()} tracks.
{#if sweep.error_message} {#if sweep.error_message}
<span class="text-oxblood">It stopped early: {sweep.error_message}</span> <span class="text-error-fg">It stopped early: {sweep.error_message}</span>
{/if} {/if}
{/if} {/if}
{#if prints && prints.pending > 0} {#if prints && prints.pending > 0}
@@ -12,6 +12,7 @@
import { errMessage } from '#lib/api/errors.js'; import { errMessage } from '#lib/api/errors.js';
import { pushToast } from '#lib/stores/toast.svelte.js'; import { pushToast } from '#lib/stores/toast.svelte.js';
import Modal from '#lib/components/Modal.svelte'; import Modal from '#lib/components/Modal.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import type { AdminPlaybackError, PlaybackErrorResolution } from '#lib/api/types.js'; import type { AdminPlaybackError, PlaybackErrorResolution } from '#lib/api/types.js';
import { relativeTime } from '#lib/utils/relativeTime.js'; import { relativeTime } from '#lib/utils/relativeTime.js';
@@ -175,26 +176,14 @@
<!-- Tab toggle. Resolved / unresolved are two separate query keys so <!-- Tab toggle. Resolved / unresolved are two separate query keys so
the cache holds both lists without re-fetching when you flip. --> the cache holds both lists without re-fetching when you flip. -->
<div role="tablist" aria-label="Resolution status" class="flex gap-2 border-b border-border"> <TabStrip
<button ariaLabel="Resolution status"
type="button" bind:value={() => (resolved ? 'resolved' : 'unresolved'), (v) => (resolved = v === 'resolved')}
role="tab" items={[
aria-selected={!resolved} { id: 'unresolved', label: 'Unresolved' },
onclick={() => { resolved = false; }} { id: 'resolved', label: 'Resolved' }
class="border-b-2 px-3 py-2 text-sm transition-colors {!resolved ]}
? 'border-accent text-text-primary' />
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Unresolved</button>
<button
type="button"
role="tab"
aria-selected={resolved}
onclick={() => { resolved = true; }}
class="border-b-2 px-3 py-2 text-sm transition-colors {resolved
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
>Resolved</button>
</div>
{#if query.isError} {#if query.isError}
<p class="text-error-fg">Couldn't load: {errMessage(query.error)}</p> <p class="text-error-fg">Couldn't load: {errMessage(query.error)}</p>
+10 -28
View File
@@ -3,6 +3,7 @@
import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte'; import { Disc3, Album, Music2, Check, X, SlidersHorizontal } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query'; import { useQueryClient } from '@tanstack/svelte-query';
import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte'; import RowActionsMenu, { type RowAction } from '#lib/components/RowActionsMenu.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import { import {
createAdminRequestsQuery, createAdminRequestsQuery,
createQualityProfilesQuery, createQualityProfilesQuery,
@@ -166,34 +167,15 @@
<p class="text-text-secondary">Approve or set aside what users have asked for.</p> <p class="text-text-secondary">Approve or set aside what users have asked for.</p>
</header> </header>
<nav aria-label="Request status filters" class="border-b border-border"> <TabStrip
<ul class="flex gap-2"> ariaLabel="Request status filters"
{#each tabs as tab (tab.status)} bind:value={activeStatus}
{@const isActive = activeStatus === tab.status} items={tabs.map((tab) => ({
<li> id: tab.status,
<button label: tab.label,
type="button" count: activeStatus === tab.status ? rows.length : undefined
role="tab" }))}
aria-selected={isActive} />
class="border-b-2 px-3 py-2 text-sm transition-colors {isActive
? 'border-accent text-text-primary'
: 'border-transparent text-text-secondary hover:text-text-primary'}"
onclick={() => (activeStatus = tab.status)}
>
{tab.label}
{#if isActive}
<span
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
data-testid="active-tab-count"
>
{rows.length}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</nav>
{#if query.isPending} {#if query.isPending}
<p class="text-text-secondary">Reading the queue…</p> <p class="text-text-secondary">Reading the queue…</p>
@@ -4,7 +4,7 @@
import { createSuspectSourcesQuery } from '#lib/api/admin.js'; import { createSuspectSourcesQuery } from '#lib/api/admin.js';
import { coverUrl } from '#lib/media/covers.js'; import { coverUrl } from '#lib/media/covers.js';
import { formatDuration } from '#lib/media/duration.js'; import { formatDuration } from '#lib/media/duration.js';
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte'; import ListContinuation from '#lib/components/ListContinuation.svelte';
import type { AdminSuspectGroup, AdminSuspectTrack } from '#lib/api/types.js'; import type { AdminSuspectGroup, AdminSuspectTrack } from '#lib/api/types.js';
// Files whose names say they were ripped from a video (#5410): "(Official // Files whose names say they were ripped from a video (#5410): "(Official
@@ -77,7 +77,7 @@
{#if query.isPending} {#if query.isPending}
<p class="text-text-secondary">Reading file names…</p> <p class="text-text-secondary">Reading file names…</p>
{:else if query.isError} {:else if query.isError && !query.isFetchNextPageError}
<p class="text-error-fg">Couldn't load the suspect-sources list.</p> <p class="text-error-fg">Couldn't load the suspect-sources list.</p>
{:else if groups.length === 0} {:else if groups.length === 0}
<div class="rounded-lg border border-border bg-surface p-6 text-center"> <div class="rounded-lg border border-border bg-surface p-6 text-center">
@@ -150,14 +150,11 @@
{/each} {/each}
</ul> </ul>
{#if query.hasNextPage} <ListContinuation
<InfiniteScrollSentinel hasMore={!!query.hasNextPage}
enabled={!query.isFetchingNextPage} loading={query.isFetchingNextPage}
onIntersect={() => query.fetchNextPage()} failed={query.isFetchNextPageError}
onMore={() => query.fetchNextPage()}
/> />
{#if query.isFetchingNextPage}
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
{/if}
{/if}
{/if} {/if}
</div> </div>
+9 -13
View File
@@ -4,7 +4,7 @@
import AlbumCard from '#lib/components/AlbumCard.svelte'; import AlbumCard from '#lib/components/AlbumCard.svelte';
import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte'; import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte'; import ListContinuation from '#lib/components/ListContinuation.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte';
import EmptyState from '#lib/components/EmptyState.svelte'; import EmptyState from '#lib/components/EmptyState.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
@@ -32,7 +32,7 @@
<header class="flex flex-wrap items-end justify-between gap-3"> <header class="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 class="font-display text-2xl font-medium text-text-primary">Albums</h1> <h1 class="font-display text-2xl font-medium text-text-primary">Albums</h1>
{#if !query.isPending && !query.isError} {#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{total} {total === 1 ? 'album' : 'albums'} {total} {total === 1 ? 'album' : 'albums'}
</p> </p>
@@ -43,7 +43,7 @@
{/if} {/if}
</header> </header>
{#if query.isError} {#if query.isError && !query.isFetchNextPageError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} /> <ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && albums.length === 0} {:else if showSkeleton.value && albums.length === 0}
<p class="text-text-secondary">Loading…</p> <p class="text-text-secondary">Loading…</p>
@@ -81,16 +81,12 @@
{/snippet} {/snippet}
</AlphabeticalGrid> </AlphabeticalGrid>
{#if query.hasNextPage} <ListContinuation
<InfiniteScrollSentinel hasMore={!!query.hasNextPage}
enabled={!query.isFetchingNextPage} loading={query.isFetchingNextPage}
onIntersect={() => query.fetchNextPage()} failed={query.isFetchNextPageError}
onMore={() => query.fetchNextPage()}
endLabel="End of library"
/> />
{#if query.isFetchingNextPage}
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
{/if}
{:else if albums.length > 0}
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
{/if}
{/if} {/if}
</div> </div>
+9 -13
View File
@@ -4,7 +4,7 @@
import ArtistCard from '#lib/components/ArtistCard.svelte'; import ArtistCard from '#lib/components/ArtistCard.svelte';
import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte'; import AlphabeticalGrid from '#lib/components/AlphabeticalGrid.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte'; import ListContinuation from '#lib/components/ListContinuation.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte';
import EmptyState from '#lib/components/EmptyState.svelte'; import EmptyState from '#lib/components/EmptyState.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
@@ -30,7 +30,7 @@
<header class="flex flex-wrap items-end justify-between gap-3"> <header class="flex flex-wrap items-end justify-between gap-3">
<div> <div>
<h1 class="font-display text-2xl font-medium text-text-primary">Artists</h1> <h1 class="font-display text-2xl font-medium text-text-primary">Artists</h1>
{#if !query.isPending && !query.isError} {#if !query.isPending && !(query.isError && !query.isFetchNextPageError)}
<p class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary">
{total} {total === 1 ? 'artist' : 'artists'} {total} {total === 1 ? 'artist' : 'artists'}
</p> </p>
@@ -41,7 +41,7 @@
{/if} {/if}
</header> </header>
{#if query.isError} {#if query.isError && !query.isFetchNextPageError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} /> <ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && artists.length === 0} {:else if showSkeleton.value && artists.length === 0}
<p class="text-text-secondary">Loading…</p> <p class="text-text-secondary">Loading…</p>
@@ -79,16 +79,12 @@
{/snippet} {/snippet}
</AlphabeticalGrid> </AlphabeticalGrid>
{#if query.hasNextPage} <ListContinuation
<InfiniteScrollSentinel hasMore={!!query.hasNextPage}
enabled={!query.isFetchingNextPage} loading={query.isFetchingNextPage}
onIntersect={() => query.fetchNextPage()} failed={query.isFetchNextPageError}
onMore={() => query.fetchNextPage()}
endLabel="End of library"
/> />
{#if query.isFetchingNextPage}
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
{/if}
{:else if artists.length > 0}
<p class="py-2 text-center text-sm text-text-secondary">End of library</p>
{/if}
{/if} {/if}
</div> </div>
+18 -16
View File
@@ -11,6 +11,7 @@
import AlbumCard from '#lib/components/AlbumCard.svelte'; import AlbumCard from '#lib/components/AlbumCard.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import EmptyState from '#lib/components/EmptyState.svelte'; import EmptyState from '#lib/components/EmptyState.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte';
import type { AlbumRef } from '#lib/api/types.js'; import type { AlbumRef } from '#lib/api/types.js';
@@ -59,6 +60,9 @@
let total = $state(0); let total = $state(0);
let loading = $state(false); let loading = $state(false);
let failed = $state(false); let failed = $state(false);
// A later page failed: the albums already shown stay, and the list offers a
// retry at its end instead of loading again on every scroll.
let moreFailed = $state(false);
// Plain `let`, deliberately not $state: it's read inside the fetch path, and // Plain `let`, deliberately not $state: it's read inside the fetch path, and
// as reactive state that read would make this effect depend on its own // as reactive state that read would make this effect depend on its own
@@ -76,6 +80,7 @@
albums = []; albums = [];
total = 0; total = 0;
failed = false; failed = false;
moreFailed = false;
if (!genre) return; if (!genre) return;
await fetchPage(genre, 0, requestToken); await fetchPage(genre, 0, requestToken);
} }
@@ -88,13 +93,18 @@
albums = offset === 0 ? p.items : [...albums, ...p.items]; albums = offset === 0 ? p.items : [...albums, ...p.items];
total = p.total; total = p.total;
} catch { } catch {
if (token === requestToken) failed = true; if (token === requestToken) {
if (offset === 0) failed = true;
else moreFailed = true;
}
} finally { } finally {
if (token === requestToken) loading = false; if (token === requestToken) loading = false;
} }
} }
function loadMore() { function loadMore() {
if (loading) return;
moreFailed = false;
void fetchPage(selected, albums.length, requestToken); void fetchPage(selected, albums.length, requestToken);
} }
@@ -154,21 +164,13 @@
<AlbumCard {album} /> <AlbumCard {album} />
{/each} {/each}
</div> </div>
{#if albums.length < total} <ListContinuation
<div class="flex justify-center py-2"> hasMore={albums.length < total}
<button {loading}
type="button" failed={moreFailed}
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover onMore={loadMore}
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50" endLabel="End of genre"
disabled={loading} />
onclick={loadMore}
>
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
</button>
</div>
{:else}
<p class="py-2 text-center text-sm text-text-secondary">End of genre</p>
{/if}
{/if} {/if}
</div> </div>
{:else} {:else}
+37 -5
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte'; import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
import { mockQuery } from '#test-utils/query.js'; import { mockQuery } from '#test-utils/query.js';
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
import { pageUrlModule } from '#test-utils/mocks/appState.js'; import { pageUrlModule } from '#test-utils/mocks/appState.js';
import { apiClientMock } from '#test-utils/mocks/client.js'; import { apiClientMock } from '#test-utils/mocks/client.js';
import { emptyLikesMock } from '#test-utils/mocks/likes.js'; import { emptyLikesMock } from '#test-utils/mocks/likes.js';
@@ -33,6 +34,11 @@ import { createGenresQuery, listAlbumsByGenre } from '#lib/api/browse.js';
const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>; const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
let io: ReturnType<typeof installIntersectionObserverMock>;
beforeEach(() => {
io = installIntersectionObserverMock();
});
function album(id: string, title: string): AlbumRef { function album(id: string, title: string): AlbumRef {
return { return {
id, id,
@@ -187,7 +193,7 @@ describe('/library/genres drill-down', () => {
expect(screen.getByRole('heading', { name: 'Rock/Pop' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Rock/Pop' })).toBeInTheDocument();
}); });
test('load more appends the next page and then reports the end', async () => { test('scrolling to the end loads the next page, then reports the end', async () => {
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock'); pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] })); asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
asMock(listAlbumsByGenre) asMock(listAlbumsByGenre)
@@ -201,10 +207,13 @@ describe('/library/genres drill-down', () => {
render(GenresPage); render(GenresPage);
await screen.findByText('One'); await screen.findByText('One');
const more = await screen.findByRole('button', { name: /Load more \(1 left\)/ }); // No button (#5416): reaching the bottom of the grid loads the next page.
await fireEvent.click(more); expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
await waitFor(() => {
io.scrollToEnd();
expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2);
});
await waitFor(() => expect(listAlbumsByGenre).toHaveBeenLastCalledWith('Rock', 2, 2));
expect(await screen.findByText('Three')).toBeInTheDocument(); expect(await screen.findByText('Three')).toBeInTheDocument();
// Earlier pages are appended, not replaced. // Earlier pages are appended, not replaced.
expect(screen.getByText('One')).toBeInTheDocument(); expect(screen.getByText('One')).toBeInTheDocument();
@@ -219,4 +228,27 @@ describe('/library/genres drill-down', () => {
expect(await screen.findByText(/Couldn't load albums for this genre/i)).toBeInTheDocument(); expect(await screen.findByText(/Couldn't load albums for this genre/i)).toBeInTheDocument();
}); });
// A failed later page keeps what is shown and offers a retry at the end,
// rather than replacing the grid or re-requesting on every scroll.
test('a failed later page keeps the albums and offers Try again', async () => {
pageState.pageUrl = new URL('http://localhost/library/genres?g=Rock');
asMock(createGenresQuery).mockReturnValue(mockQuery({ data: [] }));
asMock(listAlbumsByGenre)
.mockResolvedValueOnce({ items: [album('a1', 'One'), album('a2', 'Two')], total: 3, limit: 2, offset: 0 })
.mockRejectedValueOnce(new Error('nope'))
.mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
render(GenresPage);
await screen.findByText('One');
await waitFor(() => {
io.scrollToEnd();
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more.");
});
expect(screen.getByText('One')).toBeInTheDocument();
expect(screen.queryByText(/Couldn't load albums for this genre/i)).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(await screen.findByText('Three')).toBeInTheDocument();
});
}); });
+8 -12
View File
@@ -5,7 +5,7 @@
import { groupByDay } from '#lib/utils/dayGroup.js'; import { groupByDay } from '#lib/utils/dayGroup.js';
import HistoryRow from '#lib/components/HistoryRow.svelte'; import HistoryRow from '#lib/components/HistoryRow.svelte';
import TrackList from '#lib/components/TrackList.svelte'; import TrackList from '#lib/components/TrackList.svelte';
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte'; import ListContinuation from '#lib/components/ListContinuation.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte';
@@ -40,7 +40,7 @@
{#if query.isPending} {#if query.isPending}
<LibrarySkeleton variant="list" count={12} /> <LibrarySkeleton variant="list" count={12} />
{:else if query.isError} {:else if query.isError && !query.isFetchNextPageError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} /> <ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if flatEvents.length === 0} {:else if flatEvents.length === 0}
<EmptyState <EmptyState
@@ -72,16 +72,12 @@
</section> </section>
{/each} {/each}
{#if query.hasNextPage} <ListContinuation
<InfiniteScrollSentinel hasMore={!!query.hasNextPage}
enabled={!query.isFetchingNextPage} loading={query.isFetchingNextPage}
onIntersect={() => query.fetchNextPage()} failed={query.isFetchNextPageError}
onMore={() => query.fetchNextPage()}
endLabel="End of history"
/> />
{#if query.isFetchingNextPage}
<p class="py-2 text-center text-sm text-text-secondary">Loading more…</p>
{/if}
{:else if flatEvents.length > 0}
<p class="py-2 text-center text-sm text-text-secondary">End of history</p>
{/if}
{/if} {/if}
</PageColumn> </PageColumn>
+50 -58
View File
@@ -12,6 +12,8 @@
import TrackList from '#lib/components/TrackList.svelte'; import TrackList from '#lib/components/TrackList.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte'; import QuickFilter from '#lib/components/QuickFilter.svelte';
import EmptyState from '#lib/components/EmptyState.svelte'; import EmptyState from '#lib/components/EmptyState.svelte';
import TabStrip from '#lib/components/TabStrip.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
const artistsStore = $derived(createLikedArtistsInfiniteQuery()); const artistsStore = $derived(createLikedArtistsInfiniteQuery());
const albumsStore = $derived(createLikedAlbumsInfiniteQuery()); const albumsStore = $derived(createLikedAlbumsInfiniteQuery());
@@ -29,6 +31,14 @@
const albumsTotal = $derived(albumsQuery?.data?.pages?.[0]?.total ?? 0); const albumsTotal = $derived(albumsQuery?.data?.pages?.[0]?.total ?? 0);
const tracksTotal = $derived(tracksQuery?.data?.pages?.[0]?.total ?? 0); const tracksTotal = $derived(tracksQuery?.data?.pages?.[0]?.total ?? 0);
type Tab = 'artists' | 'albums' | 'tracks';
// Opens on the first tab with anything in it; once the reader picks a tab,
// that choice sticks.
let chosenTab = $state<Tab | null>(null);
const tab = $derived<Tab>(
chosenTab ?? (artistsTotal > 0 ? 'artists' : albumsTotal > 0 ? 'albums' : tracksTotal > 0 ? 'tracks' : 'artists')
);
let filter = $state(''); let filter = $state('');
const q = $derived(filter.trim().toLowerCase()); const q = $derived(filter.trim().toLowerCase());
const fArtists = $derived(q ? artists.filter((a) => a.name.toLowerCase().includes(q)) : artists); const fArtists = $derived(q ? artists.filter((a) => a.name.toLowerCase().includes(q)) : artists);
@@ -41,8 +51,10 @@
t.artist_name.toLowerCase().includes(q) || t.artist_name.toLowerCase().includes(q) ||
(t.album_title ?? '').toLowerCase().includes(q)) (t.album_title ?? '').toLowerCase().includes(q))
: tracks); : tracks);
// The filter applies to the open tab's loaded rows.
const filterMatchesNothing = $derived( const filterMatchesNothing = $derived(
!!q && fArtists.length === 0 && fAlbums.length === 0 && fTracks.length === 0 !!q &&
(tab === 'artists' ? fArtists : tab === 'albums' ? fAlbums : fTracks).length === 0
); );
const allEmpty = $derived( const allEmpty = $derived(
!q && artistsTotal === 0 && albumsTotal === 0 && tracksTotal === 0 !q && artistsTotal === 0 && albumsTotal === 0 && tracksTotal === 0
@@ -82,14 +94,22 @@
</EmptyState> </EmptyState>
{/if} {/if}
{#if !allEmpty && (!q || fArtists.length > 0)} {#if !allEmpty}
<section> <!-- One list at a time (#5416): stacked, a long Artists list that loads as
<h2 class="mb-2 text-lg font-semibold">Artists</h2> you scroll would bury Albums and Tracks until every artist had loaded. -->
<TabStrip
bind:value={() => tab, (v) => (chosenTab = v)}
ariaLabel="Liked"
items={[
{ id: 'artists', label: 'Artists', count: artistsTotal },
{ id: 'albums', label: 'Albums', count: albumsTotal },
{ id: 'tracks', label: 'Tracks', count: tracksTotal }
]}
/>
{#if tab === 'artists'}
{#if artistsTotal === 0} {#if artistsTotal === 0}
<EmptyState <EmptyState variant="inline" title="No liked artists yet.">
variant="inline"
title="No liked artists yet."
>
{#snippet actions()} {#snippet actions()}
<a href="/library/artists" class="text-accent-fg hover:underline">Browse artists →</a> <a href="/library/artists" class="text-accent-fg hover:underline">Browse artists →</a>
{/snippet} {/snippet}
@@ -100,28 +120,16 @@
<ArtistCard artist={a} /> <ArtistCard artist={a} />
{/each} {/each}
</div> </div>
{#if artistsQuery?.hasNextPage} <ListContinuation
<button hasMore={!!artistsQuery?.hasNextPage}
type="button" loading={!!artistsQuery?.isFetchingNextPage}
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60" failed={!!artistsQuery?.isFetchNextPageError}
disabled={artistsQuery.isFetchingNextPage} onMore={() => artistsQuery.fetchNextPage()}
onclick={() => artistsQuery.fetchNextPage()} />
>
{artistsQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if} {/if}
{/if} {:else if tab === 'albums'}
</section>
{/if}
{#if !allEmpty && (!q || fAlbums.length > 0)}
<section>
<h2 class="mb-2 text-lg font-semibold">Albums</h2>
{#if albumsTotal === 0} {#if albumsTotal === 0}
<EmptyState <EmptyState variant="inline" title="No liked albums yet.">
variant="inline"
title="No liked albums yet."
>
{#snippet actions()} {#snippet actions()}
<a href="/library/albums" class="text-accent-fg hover:underline">Browse albums →</a> <a href="/library/albums" class="text-accent-fg hover:underline">Browse albums →</a>
{/snippet} {/snippet}
@@ -132,28 +140,16 @@
<AlbumCard album={al} /> <AlbumCard album={al} />
{/each} {/each}
</div> </div>
{#if albumsQuery?.hasNextPage} <ListContinuation
<button hasMore={!!albumsQuery?.hasNextPage}
type="button" loading={!!albumsQuery?.isFetchingNextPage}
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60" failed={!!albumsQuery?.isFetchNextPageError}
disabled={albumsQuery.isFetchingNextPage} onMore={() => albumsQuery.fetchNextPage()}
onclick={() => albumsQuery.fetchNextPage()} />
>
{albumsQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if} {/if}
{/if} {:else}
</section>
{/if}
{#if !allEmpty && (!q || fTracks.length > 0)}
<section>
<h2 class="mb-2 text-lg font-semibold">Tracks</h2>
{#if tracksTotal === 0} {#if tracksTotal === 0}
<EmptyState <EmptyState variant="inline" title="No liked tracks yet.">
variant="inline"
title="No liked tracks yet."
>
{#snippet actions()} {#snippet actions()}
<a href="/" class="text-accent-fg hover:underline">Find something new →</a> <a href="/" class="text-accent-fg hover:underline">Find something new →</a>
{/snippet} {/snippet}
@@ -164,17 +160,13 @@
<TrackRow tracks={fTracks} index={i} /> <TrackRow tracks={fTracks} index={i} />
{/each} {/each}
</TrackList> </TrackList>
{#if tracksQuery?.hasNextPage} <ListContinuation
<button hasMore={!!tracksQuery?.hasNextPage}
type="button" loading={!!tracksQuery?.isFetchingNextPage}
class="mt-2 w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60" failed={!!tracksQuery?.isFetchNextPageError}
disabled={tracksQuery.isFetchingNextPage} onMore={() => tracksQuery.fetchNextPage()}
onclick={() => tracksQuery.fetchNextPage()} />
>
{tracksQuery.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if} {/if}
{/if} {/if}
</section>
{/if} {/if}
</PageColumn> </PageColumn>
+70 -53
View File
@@ -38,82 +38,99 @@ function page<T>(items: T[], total: number, offset = 0, limit = 50): Page<T> {
afterEach(() => vi.clearAllMocks()); afterEach(() => vi.clearAllMocks());
describe('liked library page', () => { const ar: ArtistRef = { id: 'a1', name: 'Miles', sort_name: 'Miles', album_count: 1, cover_url: '' };
test('renders three sections when each has items', () => {
const ar: ArtistRef = { id: 'a1', name: 'X', sort_name: 'X', album_count: 1, cover_url: '' };
const al: AlbumRef = { const al: AlbumRef = {
id: 'al1', title: 'Y', sort_title: 'Y', artist_id: 'a1', artist_name: 'X', id: 'al1', title: 'Kind of Blue', sort_title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles',
year: 2020, track_count: 1, duration_sec: 100, cover_url: '/x', cover_art_source: null year: 1959, track_count: 1, duration_sec: 100, cover_url: '/x', cover_art_source: null
}; };
const tr: TrackRef = makeTrack({ const tr: TrackRef = makeTrack({
title: 'Z', album_id: 'al1', album_title: 'Y', title: 'So What', album_id: 'al1', album_title: 'Kind of Blue',
artist_id: 'a1', artist_name: 'X', duration_sec: 100 artist_id: 'a1', artist_name: 'Miles', duration_sec: 100
});
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page([ar], 1)] })
);
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page([al], 1)] })
);
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page([tr], 1)] })
);
render(LikedPage);
expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument();
}); });
test('all three sections empty renders the whole-tab onboarding card', () => { function setup(opts: {
artists?: Page<ArtistRef>;
albums?: Page<AlbumRef>;
tracks?: Page<TrackRef>;
tracksQuery?: Parameters<typeof mockInfiniteQuery>[0];
}) {
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( (createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] }) mockInfiniteQuery({ pages: [opts.artists ?? page<ArtistRef>([], 0)] })
); );
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( (createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] }) mockInfiniteQuery({ pages: [opts.albums ?? page<AlbumRef>([], 0)] })
); );
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( (createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<TrackRef>([], 0)] }) mockInfiniteQuery({ pages: [opts.tracks ?? page<TrackRef>([], 0)], ...opts.tracksQuery })
); );
}
describe('liked library page', () => {
// #5416: one list at a time. Stacked, a long Artists list that loads as you
// scroll would bury Albums and Tracks.
test('tabs carry each count and show one list at a time', async () => {
setup({ artists: page([ar], 1), albums: page([al], 1), tracks: page([tr], 1) });
render(LikedPage);
const tabs = screen.getAllByRole('tab');
expect(tabs.map((t) => t.textContent?.replace(/\s+/g, ' ').trim())).toEqual([
'Artists 1',
'Albums 1',
'Tracks 1'
]);
expect(screen.getByText('Miles')).toBeInTheDocument();
expect(screen.queryByText('So What')).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('tab', { name: /albums/i }));
expect(screen.getByText('Kind of Blue')).toBeInTheDocument();
await fireEvent.click(screen.getByRole('tab', { name: /tracks/i }));
expect(screen.getByText('So What')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /tracks/i })).toHaveAttribute('aria-selected', 'true');
});
test('opens on the first tab that has likes', () => {
setup({ tracks: page([tr], 1) });
render(LikedPage);
expect(screen.getByRole('tab', { name: /tracks/i })).toHaveAttribute('aria-selected', 'true');
expect(screen.getByText('So What')).toBeInTheDocument();
});
test('all three empty renders the whole-page onboarding card and no tabs', () => {
setup({});
render(LikedPage); render(LikedPage);
// The whole-tab EmptyState replaces the three per-section hints
// when every section is empty — single heading + action affordances.
expect(screen.getByRole('heading', { name: /no likes yet/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /no likes yet/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /explore home/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /explore home/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /browse albums/i })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /browse albums/i })).toBeInTheDocument();
expect(screen.queryByRole('tablist')).not.toBeInTheDocument();
}); });
test('one populated section + two empty subsections renders inline hints', () => { test('an empty tab shows its inline hint', async () => {
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( setup({ artists: page([ar], 1) });
mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a1', name: 'Miles' } as ArtistRef], 1)] })
);
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] })
);
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<TrackRef>([], 0)] })
);
render(LikedPage); render(LikedPage);
await fireEvent.click(screen.getByRole('tab', { name: /albums/i }));
expect(screen.getByText(/no liked albums yet/i)).toBeInTheDocument(); expect(screen.getByText(/no liked albums yet/i)).toBeInTheDocument();
await fireEvent.click(screen.getByRole('tab', { name: /tracks/i }));
expect(screen.getByText(/no liked tracks yet/i)).toBeInTheDocument(); expect(screen.getByText(/no liked tracks yet/i)).toBeInTheDocument();
// Inline variant uses link affordances, not the big card heading.
expect(screen.queryByRole('heading', { name: /no likes yet/i })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: /no likes yet/i })).not.toBeInTheDocument();
}); });
test('Load more calls fetchNextPage on each section that has more', async () => { // Preference 172: the list continues itself; there is no button to press.
const fetchTracks = vi.fn(); test('more tracks load as you scroll, with no Load more button', () => {
(createLikedTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( setup({ tracks: page([tr], 100), tracksQuery: { hasNextPage: true } });
mockInfiniteQuery({ pages: [page<TrackRef>([], 100)], hasNextPage: true, fetchNextPage: fetchTracks }) const { container } = render(LikedPage);
); expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
(createLikedAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( expect(container.querySelector('div[aria-hidden="true"].h-px')).not.toBeNull();
mockInfiniteQuery({ pages: [page<AlbumRef>([], 0)] }) });
);
(createLikedArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( test('a failed page offers Try again, which fetches it', async () => {
mockInfiniteQuery({ pages: [page<ArtistRef>([], 0)] }) const fetchNextPage = vi.fn();
); setup({
tracks: page([tr], 100),
tracksQuery: { hasNextPage: true, isFetchNextPageError: true, fetchNextPage }
});
render(LikedPage); render(LikedPage);
const buttons = screen.getAllByRole('button', { name: /load more/i }); await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(buttons).toHaveLength(1); expect(fetchNextPage).toHaveBeenCalledTimes(1);
await fireEvent.click(buttons[0]);
expect(fetchTracks).toHaveBeenCalledTimes(1);
}); });
}); });
+19 -17
View File
@@ -11,6 +11,7 @@
import AlbumCard from '#lib/components/AlbumCard.svelte'; import AlbumCard from '#lib/components/AlbumCard.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import EmptyState from '#lib/components/EmptyState.svelte'; import EmptyState from '#lib/components/EmptyState.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import type { AlbumRef } from '#lib/api/types.js'; import type { AlbumRef } from '#lib/api/types.js';
const indexStore = createAlbumYearsQuery(); const indexStore = createAlbumYearsQuery();
@@ -48,6 +49,9 @@
let total = $state(0); let total = $state(0);
let loading = $state(false); let loading = $state(false);
let failed = $state(false); let failed = $state(false);
// A later page failed: the albums already shown stay, and the list offers a
// retry at its end instead of loading again on every scroll.
let moreFailed = $state(false);
// Plain `let`, not $state — see the note in the genres page: as reactive // Plain `let`, not $state — see the note in the genres page: as reactive
// state, reading it in the fetch path would make the effect below depend on // state, reading it in the fetch path would make the effect below depend on
@@ -65,6 +69,7 @@
albums = []; albums = [];
total = 0; total = 0;
failed = false; failed = false;
moreFailed = false;
if (year === null) return; if (year === null) return;
await fetchPage(year, 0, requestToken); await fetchPage(year, 0, requestToken);
} }
@@ -77,14 +82,19 @@
albums = offset === 0 ? p.items : [...albums, ...p.items]; albums = offset === 0 ? p.items : [...albums, ...p.items];
total = p.total; total = p.total;
} catch { } catch {
if (token === requestToken) failed = true; if (token === requestToken) {
if (offset === 0) failed = true;
else moreFailed = true;
}
} finally { } finally {
if (token === requestToken) loading = false; if (token === requestToken) loading = false;
} }
} }
function loadMore() { function loadMore() {
if (selected !== null) void fetchPage(selected, albums.length, requestToken); if (selected === null || loading) return;
moreFailed = false;
void fetchPage(selected, albums.length, requestToken);
} }
</script> </script>
@@ -136,21 +146,13 @@
<AlbumCard {album} /> <AlbumCard {album} />
{/each} {/each}
</div> </div>
{#if albums.length < total} <ListContinuation
<div class="flex justify-center py-2"> hasMore={albums.length < total}
<button {loading}
type="button" failed={moreFailed}
class="rounded-md border border-border px-4 py-2 text-sm hover:bg-surface-hover onMore={loadMore}
focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50" endLabel="End of year"
disabled={loading} />
onclick={loadMore}
>
{loading ? 'Loading…' : `Load more (${total - albums.length} left)`}
</button>
</div>
{:else}
<p class="py-2 text-center text-sm text-text-secondary">End of year</p>
{/if}
{/if} {/if}
</div> </div>
{:else} {:else}
+37 -4
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/svelte'; import { render, screen, waitFor, fireEvent } from '@testing-library/svelte';
import { mockQuery } from '#test-utils/query.js'; import { mockQuery } from '#test-utils/query.js';
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
import { pageUrlModule } from '#test-utils/mocks/appState.js'; import { pageUrlModule } from '#test-utils/mocks/appState.js';
import { apiClientMock } from '#test-utils/mocks/client.js'; import { apiClientMock } from '#test-utils/mocks/client.js';
import { emptyLikesMock } from '#test-utils/mocks/likes.js'; import { emptyLikesMock } from '#test-utils/mocks/likes.js';
@@ -33,6 +34,11 @@ import { createAlbumYearsQuery, listAlbumsByYear } from '#lib/api/browse.js';
const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>; const asMock = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
let io: ReturnType<typeof installIntersectionObserverMock>;
beforeEach(() => {
io = installIntersectionObserverMock();
});
function album(id: string, title: string): AlbumRef { function album(id: string, title: string): AlbumRef {
return { return {
id, id,
@@ -146,7 +152,7 @@ describe('/library/years drill-down', () => {
expect(screen.getByRole('heading', { level: 1, name: 'Years' })).toBeInTheDocument(); expect(screen.getByRole('heading', { level: 1, name: 'Years' })).toBeInTheDocument();
}); });
test('load more appends and then reports the end', async () => { test('scrolling to the end loads the next page, then reports the end', async () => {
pageState.pageUrl = new URL('http://localhost/library/years?y=1995'); pageState.pageUrl = new URL('http://localhost/library/years?y=1995');
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] })); asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
asMock(listAlbumsByYear) asMock(listAlbumsByYear)
@@ -160,9 +166,13 @@ describe('/library/years drill-down', () => {
render(YearsPage); render(YearsPage);
await screen.findByText('One'); await screen.findByText('One');
await fireEvent.click(await screen.findByRole('button', { name: /Load more \(1 left\)/ })); // No button (#5416): reaching the bottom of the grid loads the next page.
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
await waitFor(() => {
io.scrollToEnd();
expect(listAlbumsByYear).toHaveBeenLastCalledWith(1995, 2, 2);
});
await waitFor(() => expect(listAlbumsByYear).toHaveBeenLastCalledWith(1995, 2, 2));
expect(await screen.findByText('Three')).toBeInTheDocument(); expect(await screen.findByText('Three')).toBeInTheDocument();
expect(screen.getByText('One')).toBeInTheDocument(); expect(screen.getByText('One')).toBeInTheDocument();
expect(await screen.findByText('End of year')).toBeInTheDocument(); expect(await screen.findByText('End of year')).toBeInTheDocument();
@@ -176,4 +186,27 @@ describe('/library/years drill-down', () => {
expect(await screen.findByText(/Couldn't load albums for 1995/i)).toBeInTheDocument(); expect(await screen.findByText(/Couldn't load albums for 1995/i)).toBeInTheDocument();
}); });
// A failed later page keeps what is shown and offers a retry at the end,
// rather than replacing the grid or re-requesting on every scroll.
test('a failed later page keeps the albums and offers Try again', async () => {
pageState.pageUrl = new URL('http://localhost/library/years?y=1995');
asMock(createAlbumYearsQuery).mockReturnValue(mockQuery({ data: [] }));
asMock(listAlbumsByYear)
.mockResolvedValueOnce({ items: [album('a1', 'One'), album('a2', 'Two')], total: 3, limit: 2, offset: 0 })
.mockRejectedValueOnce(new Error('nope'))
.mockResolvedValueOnce({ items: [album('a3', 'Three')], total: 3, limit: 2, offset: 2 });
render(YearsPage);
await screen.findByText('One');
await waitFor(() => {
io.scrollToEnd();
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more.");
});
expect(screen.getByText('One')).toBeInTheDocument();
expect(screen.queryByText(/Couldn't load albums for 1995/i)).not.toBeInTheDocument();
await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect(await screen.findByText('Three')).toBeInTheDocument();
});
}); });
+9 -12
View File
@@ -5,6 +5,7 @@
import AlbumCard from '#lib/components/AlbumCard.svelte'; import AlbumCard from '#lib/components/AlbumCard.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
const q = $derived((page.url.searchParams.get('q') ?? '').trim()); const q = $derived((page.url.searchParams.get('q') ?? '').trim());
@@ -24,14 +25,14 @@
← Back to search ← Back to search
</a> </a>
<h1 class="mt-2 text-2xl font-semibold">Albums matching '{q}'</h1> <h1 class="mt-2 text-2xl font-semibold">Albums matching '{q}'</h1>
{#if !query?.isPending && !query?.isError && q} {#if q && !query?.isPending && !(query?.isError && albums.length === 0)}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'album' : 'albums'}</p> <p class="text-sm text-text-secondary">{total} {total === 1 ? 'album' : 'albums'}</p>
{/if} {/if}
</header> </header>
{#if !q} {#if !q}
<p class="text-text-secondary">No query — return to search to start typing.</p> <p class="text-text-secondary">No query — return to search to start typing.</p>
{:else if query?.isError} {:else if query?.isError && albums.length === 0}
<ApiErrorBanner error={query.error} onRetry={query.refetch} /> <ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && albums.length === 0} {:else if showSkeleton.value && albums.length === 0}
<LibrarySkeleton variant="grid" count={12} /> <LibrarySkeleton variant="grid" count={12} />
@@ -43,15 +44,11 @@
<AlbumCard album={a} /> <AlbumCard album={a} />
{/each} {/each}
</div> </div>
{#if query?.hasNextPage} <ListContinuation
<button hasMore={!!query?.hasNextPage}
type="button" loading={!!query?.isFetchingNextPage}
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60" failed={!!query?.isFetchNextPageError}
disabled={query.isFetchingNextPage} onMore={() => query?.fetchNextPage()}
onclick={() => query.fetchNextPage()} />
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if}
{/if} {/if}
</div> </div>
+10 -4
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte'; import { render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query'; import { mockInfiniteQuery } from '../../../test-utils/query';
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
import { emptyLikesMock } from '../../../test-utils/mocks/likes'; import { emptyLikesMock } from '../../../test-utils/mocks/likes';
import { apiClientMock } from '../../../test-utils/mocks/client'; import { apiClientMock } from '../../../test-utils/mocks/client';
import { pageUrlModule } from '#test-utils/mocks/appState.js'; import { pageUrlModule } from '#test-utils/mocks/appState.js';
@@ -47,7 +48,9 @@ describe('search albums overflow', () => {
expect(screen.getByRole('link', { name: /Kind of Blue/ })).toHaveAttribute('href', '/albums/al1'); expect(screen.getByRole('link', { name: /Kind of Blue/ })).toHaveAttribute('href', '/albums/al1');
}); });
test('Load more calls fetchNextPage', async () => { // #5416: no button; reaching the end of the list loads the next page.
test('scrolling to the end loads the next page', async () => {
const io = installIntersectionObserverMock();
const fetchNextPage = vi.fn(); const fetchNextPage = vi.fn();
(createSearchAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( (createSearchAlbumsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ mockInfiniteQuery({
@@ -57,8 +60,11 @@ describe('search albums overflow', () => {
}) })
); );
render(AlbumsOverflow); render(AlbumsOverflow);
await fireEvent.click(screen.getByRole('button', { name: /load more/i })); expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
expect(fetchNextPage).toHaveBeenCalledTimes(1); await waitFor(() => {
io.scrollToEnd();
expect(fetchNextPage).toHaveBeenCalled();
});
}); });
test('empty q shows a prompt', () => { test('empty q shows a prompt', () => {
+9 -12
View File
@@ -5,6 +5,7 @@
import ArtistCard from '#lib/components/ArtistCard.svelte'; import ArtistCard from '#lib/components/ArtistCard.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
const q = $derived((page.url.searchParams.get('q') ?? '').trim()); const q = $derived((page.url.searchParams.get('q') ?? '').trim());
@@ -24,14 +25,14 @@
← Back to search ← Back to search
</a> </a>
<h1 class="mt-2 text-2xl font-semibold">Artists matching '{q}'</h1> <h1 class="mt-2 text-2xl font-semibold">Artists matching '{q}'</h1>
{#if !query?.isPending && !query?.isError && q} {#if q && !query?.isPending && !(query?.isError && artists.length === 0)}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'artist' : 'artists'}</p> <p class="text-sm text-text-secondary">{total} {total === 1 ? 'artist' : 'artists'}</p>
{/if} {/if}
</header> </header>
{#if !q} {#if !q}
<p class="text-text-secondary">No query — return to search to start typing.</p> <p class="text-text-secondary">No query — return to search to start typing.</p>
{:else if query?.isError} {:else if query?.isError && artists.length === 0}
<ApiErrorBanner error={query.error} onRetry={query.refetch} /> <ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && artists.length === 0} {:else if showSkeleton.value && artists.length === 0}
<LibrarySkeleton variant="list" count={12} /> <LibrarySkeleton variant="list" count={12} />
@@ -43,15 +44,11 @@
<ArtistCard artist={a} /> <ArtistCard artist={a} />
{/each} {/each}
</div> </div>
{#if query?.hasNextPage} <ListContinuation
<button hasMore={!!query?.hasNextPage}
type="button" loading={!!query?.isFetchingNextPage}
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60" failed={!!query?.isFetchNextPageError}
disabled={query.isFetchingNextPage} onMore={() => query?.fetchNextPage()}
onclick={() => query.fetchNextPage()} />
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if}
{/if} {/if}
</div> </div>
+13 -7
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte'; import { render, screen, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query'; import { mockInfiniteQuery } from '../../../test-utils/query';
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
import { emptyLikesMock } from '../../../test-utils/mocks/likes'; import { emptyLikesMock } from '../../../test-utils/mocks/likes';
import { pageUrlModule } from '#test-utils/mocks/appState.js'; import { pageUrlModule } from '#test-utils/mocks/appState.js';
import type { ArtistRef, Page } from '#lib/api/types.js'; import type { ArtistRef, Page } from '#lib/api/types.js';
@@ -39,7 +40,9 @@ describe('search artists overflow', () => {
expect(screen.getByRole('link', { name: /Miles Mosley/ })).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Miles Mosley/ })).toBeInTheDocument();
}); });
test('Load more calls fetchNextPage when hasNextPage', async () => { // #5416: no button; reaching the end of the list loads the next page.
test('scrolling to the end loads the next page', async () => {
const io = installIntersectionObserverMock();
const fetchNextPage = vi.fn(); const fetchNextPage = vi.fn();
(createSearchArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( (createSearchArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ mockInfiniteQuery({
@@ -49,16 +52,19 @@ describe('search artists overflow', () => {
}) })
); );
render(ArtistsOverflow); render(ArtistsOverflow);
await fireEvent.click(screen.getByRole('button', { name: /load more/i })); expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
expect(fetchNextPage).toHaveBeenCalledTimes(1); await waitFor(() => {
io.scrollToEnd();
expect(fetchNextPage).toHaveBeenCalled();
});
}); });
test('Load more is hidden when hasNextPage is false', () => { test('nothing more to load: no sentinel', () => {
(createSearchArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( (createSearchArtistsInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a', name: 'A', sort_name: 'A', album_count: 1, cover_url: '' }], 1)] }) mockInfiniteQuery({ pages: [page<ArtistRef>([{ id: 'a', name: 'A', sort_name: 'A', album_count: 1, cover_url: '' }], 1)] })
); );
render(ArtistsOverflow); const { container } = render(ArtistsOverflow);
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); expect(container.querySelector('div[aria-hidden="true"].h-px')).toBeNull();
}); });
test('empty q shows a prompt and fires no query', () => { test('empty q shows a prompt and fires no query', () => {
+9 -12
View File
@@ -7,6 +7,7 @@
import TrackList from '#lib/components/TrackList.svelte'; import TrackList from '#lib/components/TrackList.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte'; import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte'; import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import ListContinuation from '#lib/components/ListContinuation.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js'; import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
import { playRadio } from '#lib/player/store.svelte.js'; import { playRadio } from '#lib/player/store.svelte.js';
import type { TrackRef } from '#lib/api/types.js'; import type { TrackRef } from '#lib/api/types.js';
@@ -32,14 +33,14 @@
← Back to search ← Back to search
</a> </a>
<h1 class="mt-2 font-display text-2xl font-medium text-text-primary">Tracks matching '{q}'</h1> <h1 class="mt-2 font-display text-2xl font-medium text-text-primary">Tracks matching '{q}'</h1>
{#if !query?.isPending && !query?.isError && q} {#if q && !query?.isPending && !(query?.isError && tracks.length === 0)}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p> <p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p>
{/if} {/if}
</header> </header>
{#if !q} {#if !q}
<p class="text-text-secondary">No query — return to search to start typing.</p> <p class="text-text-secondary">No query — return to search to start typing.</p>
{:else if query?.isError} {:else if query?.isError && tracks.length === 0}
<ApiErrorBanner error={query.error} onRetry={query.refetch} /> <ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && tracks.length === 0} {:else if showSkeleton.value && tracks.length === 0}
<LibrarySkeleton variant="list" count={12} /> <LibrarySkeleton variant="list" count={12} />
@@ -51,15 +52,11 @@
<TrackRow tracks={tracks} index={i} onPlay={onTrackPlay} /> <TrackRow tracks={tracks} index={i} onPlay={onTrackPlay} />
{/each} {/each}
</TrackList> </TrackList>
{#if query?.hasNextPage} <ListContinuation
<button hasMore={!!query?.hasNextPage}
type="button" loading={!!query?.isFetchingNextPage}
class="w-full rounded bg-surface py-2 text-sm hover:bg-surface-hover disabled:opacity-60" failed={!!query?.isFetchNextPageError}
disabled={query.isFetchingNextPage} onMore={() => query?.fetchNextPage()}
onclick={() => query.fetchNextPage()} />
>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
{/if}
{/if} {/if}
</PageColumn> </PageColumn>
+10 -4
View File
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from 'vitest'; import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import { mockInfiniteQuery } from '../../../test-utils/query'; import { mockInfiniteQuery } from '../../../test-utils/query';
import { installIntersectionObserverMock } from '#test-utils/intersectionObserver.js';
import { emptyLikesMock } from '../../../test-utils/mocks/likes'; import { emptyLikesMock } from '../../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine'; import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine';
import { pageUrlModule } from '#test-utils/mocks/appState.js'; import { pageUrlModule } from '#test-utils/mocks/appState.js';
@@ -59,7 +60,9 @@ describe('search tracks overflow', () => {
expect(playRadio).toHaveBeenCalledWith('t1'); expect(playRadio).toHaveBeenCalledWith('t1');
}); });
test('Load more calls fetchNextPage', async () => { // #5416: no button; reaching the end of the list loads the next page.
test('scrolling to the end loads the next page', async () => {
const io = installIntersectionObserverMock();
const fetchNextPage = vi.fn(); const fetchNextPage = vi.fn();
(createSearchTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue( (createSearchTracksInfiniteQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockInfiniteQuery({ mockInfiniteQuery({
@@ -69,8 +72,11 @@ describe('search tracks overflow', () => {
}) })
); );
render(TracksOverflow); render(TracksOverflow);
await fireEvent.click(screen.getByRole('button', { name: /load more/i })); expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
expect(fetchNextPage).toHaveBeenCalledTimes(1); await waitFor(() => {
io.scrollToEnd();
expect(fetchNextPage).toHaveBeenCalled();
});
}); });
test('empty q shows a prompt', () => { test('empty q shows a prompt', () => {
@@ -0,0 +1,40 @@
import { vi } from 'vitest';
// jsdom doesn't ship IntersectionObserver, so a list that loads as you scroll
// (InfiniteScrollSentinel, ListContinuation) never fires in a test. This
// installs a stand-in that records each observer, so a test can play
// "the reader scrolled to the bottom" with scrollToEnd().
type Observer = { cb: IntersectionObserverCallback; self: IntersectionObserver; connected: boolean };
export function installIntersectionObserverMock() {
const observers: Observer[] = [];
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn(() => {
const o = observers.find((x) => x.self === (this as unknown as IntersectionObserver));
if (o) o.connected = false;
});
takeRecords = vi.fn(() => []);
constructor(cb: IntersectionObserverCallback) {
observers.push({ cb, self: this as unknown as IntersectionObserver, connected: true });
}
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
return {
/** Reports every connected observer's target as on screen. */
scrollToEnd() {
for (const o of observers) {
if (!o.connected) continue;
o.cb([{ isIntersecting: true } as IntersectionObserverEntry], o.self);
}
},
/** How many observers are currently watching. */
connectedCount() {
return observers.filter((o) => o.connected).length;
}
};
}
+2
View File
@@ -14,6 +14,7 @@ export function mockInfiniteQuery<T>(opts: {
error?: unknown; error?: unknown;
hasNextPage?: boolean; hasNextPage?: boolean;
isFetchingNextPage?: boolean; isFetchingNextPage?: boolean;
isFetchNextPageError?: boolean;
fetchNextPage?: () => void; fetchNextPage?: () => void;
refetch?: () => void; refetch?: () => void;
} = {}) { } = {}) {
@@ -24,6 +25,7 @@ export function mockInfiniteQuery<T>(opts: {
error: opts.error, error: opts.error,
hasNextPage: opts.hasNextPage ?? false, hasNextPage: opts.hasNextPage ?? false,
isFetchingNextPage: opts.isFetchingNextPage ?? false, isFetchingNextPage: opts.isFetchingNextPage ?? false,
isFetchNextPageError: opts.isFetchNextPageError ?? false,
fetchNextPage: opts.fetchNextPage ?? (() => {}), fetchNextPage: opts.fetchNextPage ?? (() => {}),
refetch: opts.refetch ?? (() => {}) refetch: opts.refetch ?? (() => {})
}); });