diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 83417fa8..de76c01f 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,83 +1,112 @@ -
-
-
-

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} -
+ {:else if showSkeleton.value && !data} +

Loading…

+ {:else if data} + +
+
+

Recently added

+
+ {#if data.recently_added_albums.length === 0} +

Nothing added yet. Scan a folder via the server's config.

+ {:else} + {#each chunk(data.recently_added_albums, 25) as row, i (i)} + + {#snippet item(album: import('$lib/api/types').AlbumRef)} +
+ {/snippet} +
+ {/each} + {/if} +
- {#if query.hasNextPage} - - {:else if artists.length > 0} -

End of library

- {/if} + +
+
+

Rediscover

+
+ {#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0} +

No forgotten favourites yet. Like some albums or artists to fill this in.

+ {:else} + {#if data.rediscover_albums.length > 0} + + {#snippet item(album: import('$lib/api/types').AlbumRef)} +
+ {/snippet} +
+ {/if} + {#if data.rediscover_artists.length > 0} + + {#snippet item(artist: import('$lib/api/types').ArtistRef)} +
+ {/snippet} +
+ {/if} + {/if} +
+ + +
+
+

Most played

+
+ {#if data.most_played_tracks.length === 0} +

No plays to draw from. Listen to something.

+ {:else} + {#each chunk(data.most_played_tracks, 25) as row, i (i)} + + {#snippet item(track: import('$lib/api/types').TrackRef, idx: number)} + + {/snippet} + + {/each} + {/if} +
+ + +
+
+

Last played

+
+ {#if data.last_played_artists.length === 0} +

No recent plays.

+ {:else} + + {#snippet item(artist: import('$lib/api/types').ArtistRef)} +
+ {/snippet} +
+ {/if} +
{/if}
diff --git a/web/src/routes/artists.test.ts b/web/src/routes/artists.test.ts deleted file mode 100644 index 2ce6c39c..00000000 --- a/web/src/routes/artists.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { afterEach, describe, expect, test, vi } from 'vitest'; -import { render, screen, fireEvent } from '@testing-library/svelte'; -import { flushSync } from 'svelte'; -import { readable } from 'svelte/store'; -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() -})); - -vi.mock('$lib/api/likes', () => ({ - createLikedIdsQuery: () => readable({ - data: { track_ids: [], album_ids: [], artist_ids: [] }, - isPending: false, - isError: false - }), - likeEntity: vi.fn(), - unlikeEntity: vi.fn() -})); - -vi.mock('@tanstack/svelte-query', async (orig) => { - const actual = (await orig()) as Record; - return { ...actual, useQueryClient: () => ({}) }; -}); - -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', sort_name: 'Alice', album_count: 3, cover_url: '' }; - const bob: ArtistRef = { id: 'b', name: 'Bob', sort_name: 'Bob', album_count: 1, cover_url: '' }; - (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', sort_name: 'A', album_count: 1, cover_url: '' }], 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); - }); -});