From 5174b093b9e22d2893805a1922efe2bf9a2a4ff8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 23 Apr 2026 19:43:37 -0400 Subject: [PATCH] feat(web): replace / placeholder with real artists list Uses createArtistsQuery (infinite), URL-driven sort dropdown, Load more pagination, delayed skeleton, and shared error banner. Also introduces the test-utils/query.ts helpers for subsequent page tests. --- web/src/routes/+page.svelte | 85 +++++++++++++++++++++- web/src/routes/artists.test.ts | 124 +++++++++++++++++++++++++++++++++ web/src/test-utils/query.ts | 46 ++++++++++++ 3 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 web/src/routes/artists.test.ts create mode 100644 web/src/test-utils/query.ts diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index b4d4e63b..83417fa8 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,2 +1,83 @@ -

Library

-

Library views land in a subsequent plan.

+ + +
+
+
+

Library

+ {#if !query.isPending && !query.isError} +

+ {total} {total === 1 ? 'artist' : 'artists'} +

+ {/if} +
+ +
+ + {#if query.isError} + + {:else if showSkeleton.value && artists.length === 0} + + {:else if !query.isPending && total === 0} +

+ No artists yet — scan a library folder via the server's config. +

+ {:else} +
+ {#each artists as a (a.id)} + + {/each} +
+ + {#if query.hasNextPage} + + {:else if artists.length > 0} +

End of library

+ {/if} + {/if} +
diff --git a/web/src/routes/artists.test.ts b/web/src/routes/artists.test.ts new file mode 100644 index 00000000..cc005579 --- /dev/null +++ b/web/src/routes/artists.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { flushSync } from 'svelte'; +import { mockInfiniteQuery } from '../test-utils/query'; +import type { ArtistRef, Page } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/') })); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/api/queries', () => ({ + qk: { artists: (sort: string) => ['artists', { sort }] }, + createArtistsQuery: vi.fn() +})); + +import ArtistsPage from './+page.svelte'; +import { createArtistsQuery } from '$lib/api/queries'; +import { goto } from '$app/navigation'; + +function page(items: T[], total: number, offset = 0, limit = 50): Page { + return { items, total, offset, limit }; +} + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/'); +}); + +describe('artists list page', () => { + test('renders a row per artist', () => { + const alice: ArtistRef = { id: 'a', name: 'Alice', album_count: 3 }; + const bob: ArtistRef = { id: 'b', name: 'Bob', album_count: 1 }; + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([alice, bob], 2)] }) + ); + render(ArtistsPage); + expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a'); + expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b'); + }); + + test('renders the total count from the first page', () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 1247)] }) + ); + render(ArtistsPage); + expect(screen.getByText(/1247 artists/i)).toBeInTheDocument(); + }); + + test('Load more button calls fetchNextPage when hasNextPage', async () => { + const fetchNextPage = vi.fn(); + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + pages: [page([], 100)], + hasNextPage: true, + fetchNextPage + }) + ); + render(ArtistsPage); + await fireEvent.click(screen.getByRole('button', { name: /load more/i })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + test('"End of library" when hasNextPage is false and at least one page loaded', () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) + ); + render(ArtistsPage); + expect(screen.getByText(/end of library/i)).toBeInTheDocument(); + }); + + test('empty total renders empty-library message', () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + render(ArtistsPage); + expect(screen.getByText(/no artists yet/i)).toBeInTheDocument(); + }); + + test('changing the sort dropdown calls goto with ?sort=newest', async () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + render(ArtistsPage); + const select = screen.getByLabelText(/sort/i) as HTMLSelectElement; + await fireEvent.change(select, { target: { value: 'newest' } }); + expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true }); + }); + + test('pending state renders the list skeleton after the useDelayed window', () => { + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ isPending: true }) + ); + vi.useFakeTimers(); + try { + render(ArtistsPage); + vi.advanceTimersByTime(200); + flushSync(); // flush the $effect that reads delayed.value + expect(screen.getByTestId('skeleton-list')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + test('error state renders the error banner with retry', async () => { + const refetch = vi.fn(); + (createArtistsQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + isError: true, + error: { code: 'server_error', message: 'db down', status: 500 }, + refetch + }) + ); + render(ArtistsPage); + expect(screen.getByRole('alert')).toHaveTextContent('db down'); + await fireEvent.click(screen.getByRole('button', { name: /try again/i })); + expect(refetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/test-utils/query.ts b/web/src/test-utils/query.ts new file mode 100644 index 00000000..1bf0ec70 --- /dev/null +++ b/web/src/test-utils/query.ts @@ -0,0 +1,46 @@ +import { readable } from 'svelte/store'; +import type { Page } from '$lib/api/types'; + +// Synthetic infinite-query object shape matching what the SPA reads from +// @tanstack/svelte-query. Enough surface area for component tests; +// real behavior is covered by the TanStack library's own tests. +// +// Returns a Svelte readable store (matching CreateInfiniteQueryResult) so +// the component can subscribe with the $store rune. +export function mockInfiniteQuery(opts: { + pages?: Page[]; + isPending?: boolean; + isError?: boolean; + error?: unknown; + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + fetchNextPage?: () => void; + refetch?: () => void; +} = {}) { + return readable({ + data: { pages: opts.pages ?? [] }, + isPending: opts.isPending ?? false, + isError: opts.isError ?? false, + error: opts.error, + hasNextPage: opts.hasNextPage ?? false, + isFetchingNextPage: opts.isFetchingNextPage ?? false, + fetchNextPage: opts.fetchNextPage ?? (() => {}), + refetch: opts.refetch ?? (() => {}) + }); +} + +export function mockQuery(opts: { + data?: T; + isPending?: boolean; + isError?: boolean; + error?: unknown; + refetch?: () => void; +} = {}) { + return readable({ + data: opts.data, + isPending: opts.isPending ?? false, + isError: opts.isError ?? false, + error: opts.error, + refetch: opts.refetch ?? (() => {}) + }); +}