diff --git a/web/src/routes/artists/[id]/+page.svelte b/web/src/routes/artists/[id]/+page.svelte new file mode 100644 index 00000000..292c2286 --- /dev/null +++ b/web/src/routes/artists/[id]/+page.svelte @@ -0,0 +1,51 @@ + + +
+ ← Library + + {#if notFound} + + {:else if query.isError} + + {:else if showSkeleton.value && !query.data} + + {:else if query.data} + {@const detail = query.data} +
+

{detail.name}

+

+ {detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'} +

+
+ + {#if detail.albums.length === 0} +

This artist has no albums in the library.

+ {:else} +
+ {#each detail.albums as album (album.id)} + + {/each} +
+ {/if} + {/if} +
diff --git a/web/src/routes/artists/[id]/artist.test.ts b/web/src/routes/artists/[id]/artist.test.ts new file mode 100644 index 00000000..3d3381b3 --- /dev/null +++ b/web/src/routes/artists/[id]/artist.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { flushSync } from 'svelte'; +import { mockQuery } from '../../../test-utils/query'; +import type { ArtistDetail, AlbumRef } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageParams: { id: 'abc' } as Record })); + +vi.mock('$app/state', () => ({ + page: { + get params() { return state.pageParams; }, + get url() { return new URL('http://localhost/artists/' + state.pageParams.id); } + } +})); + +vi.mock('$lib/api/queries', () => ({ + qk: { artist: (id: string) => ['artist', id] }, + createArtistQuery: vi.fn() +})); + +import ArtistPage from './+page.svelte'; +import { createArtistQuery } from '$lib/api/queries'; + +function album(id: string, title: string, year?: number): AlbumRef { + return { + id, title, + artist_id: 'abc', artist_name: 'Alice', + year, track_count: 10, duration_sec: 2400, + cover_url: `/api/albums/${id}/cover` + }; +} + +afterEach(() => { + vi.clearAllMocks(); + state.pageParams = { id: 'abc' }; +}); + +describe('artist detail page', () => { + test('renders artist name, subtitle, and one AlbumCard per album', () => { + const detail: ArtistDetail = { + id: 'abc', name: 'Alice', album_count: 2, + albums: [album('a1', 'First', 2020), album('a2', 'Second')] + }; + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ data: detail })); + render(ArtistPage); + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Alice'); + expect(screen.getByText(/2 albums/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /First/ })).toHaveAttribute('href', '/albums/a1'); + expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2'); + }); + + test('back link points to Library', () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ + data: { id: 'abc', name: 'Alice', album_count: 0, albums: [] } + })); + render(ArtistPage); + const back = screen.getByRole('link', { name: /library/i }); + expect(back).toHaveAttribute('href', '/'); + }); + + test('404 renders non-retryable "Artist not found" with back link', () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ + isError: true, + error: { code: 'not_found', message: 'nope', status: 404 } + })); + render(ArtistPage); + expect(screen.getByText(/artist not found/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /try again/i })).not.toBeInTheDocument(); + }); + + test('non-404 error renders the retry banner', () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ + isError: true, + error: { code: 'server_error', message: 'boom', status: 500 } + })); + render(ArtistPage); + expect(screen.getByRole('alert')).toHaveTextContent('boom'); + expect(screen.getByRole('button', { name: /try again/i })).toBeInTheDocument(); + }); + + test('pending (after delay) renders the grid skeleton', () => { + (createArtistQuery as ReturnType).mockReturnValue(mockQuery({ isPending: true })); + vi.useFakeTimers(); + try { + render(ArtistPage); + vi.advanceTimersByTime(200); + flushSync(); + expect(screen.getByTestId('skeleton-grid')).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); +});