diff --git a/web/src/routes/search/albums/+page.svelte b/web/src/routes/search/albums/+page.svelte new file mode 100644 index 00000000..d45f85f2 --- /dev/null +++ b/web/src/routes/search/albums/+page.svelte @@ -0,0 +1,54 @@ + + +
+
+ + ← Back to search + +

Albums matching '{q}'

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

{total} {total === 1 ? 'album' : 'albums'}

+ {/if} +
+ + {#if !q} +

No query — return to search to start typing.

+ {:else if query?.isError} + + {:else if showSkeleton.value && albums.length === 0} + + {:else if total === 0} +

No album matches.

+ {:else} +
+ {#each albums as a (a.id)} + + {/each} +
+ {#if query?.hasNextPage} + + {/if} + {/if} +
diff --git a/web/src/routes/search/albums/albums.test.ts b/web/src/routes/search/albums/albums.test.ts new file mode 100644 index 00000000..f48b8781 --- /dev/null +++ b/web/src/routes/search/albums/albums.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockInfiniteQuery } from '../../../test-utils/query'; +import type { AlbumRef, Page } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/albums?q=miles') })); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$lib/api/queries', () => ({ + createSearchAlbumsInfiniteQuery: vi.fn() +})); + +vi.mock('$lib/api/client', () => ({ + api: { get: vi.fn() } +})); +vi.mock('$lib/player/store.svelte', () => ({ + enqueueTracks: vi.fn() +})); + +import AlbumsOverflow from './+page.svelte'; +import { createSearchAlbumsInfiniteQuery } from '$lib/api/queries'; + +function page(items: T[], total: number, offset = 0, limit = 50): Page { + return { items, total, offset, limit }; +} + +const album: AlbumRef = { + id: 'al1', title: 'Kind of Blue', artist_id: 'a1', artist_name: 'Miles Davis', + year: 1959, track_count: 5, duration_sec: 2630, cover_url: '/api/albums/al1/cover' +}; + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/search/albums?q=miles'); +}); + +describe('search albums overflow', () => { + test('renders an AlbumCard per album', () => { + (createSearchAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([album], 1)] }) + ); + render(AlbumsOverflow); + expect(screen.getByRole('link', { name: /Kind of Blue/ })).toHaveAttribute('href', '/albums/al1'); + }); + + test('Load more calls fetchNextPage', async () => { + const fetchNextPage = vi.fn(); + (createSearchAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + pages: [page([], 100)], + hasNextPage: true, + fetchNextPage + }) + ); + render(AlbumsOverflow); + await fireEvent.click(screen.getByRole('button', { name: /load more/i })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + test('empty q shows a prompt', () => { + state.pageUrl = new URL('http://localhost/search/albums'); + render(AlbumsOverflow); + expect(screen.getByText(/no query/i)).toBeInTheDocument(); + }); +}); diff --git a/web/src/routes/search/artists/+page.svelte b/web/src/routes/search/artists/+page.svelte new file mode 100644 index 00000000..dea1fc5b --- /dev/null +++ b/web/src/routes/search/artists/+page.svelte @@ -0,0 +1,54 @@ + + +
+
+ + ← Back to search + +

Artists matching '{q}'

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

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

+ {/if} +
+ + {#if !q} +

No query — return to search to start typing.

+ {:else if query?.isError} + + {:else if showSkeleton.value && artists.length === 0} + + {:else if total === 0} +

No artist matches.

+ {:else} +
+ {#each artists as a (a.id)} + + {/each} +
+ {#if query?.hasNextPage} + + {/if} + {/if} +
diff --git a/web/src/routes/search/artists/artists.test.ts b/web/src/routes/search/artists/artists.test.ts new file mode 100644 index 00000000..607ed39e --- /dev/null +++ b/web/src/routes/search/artists/artists.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockInfiniteQuery } from '../../../test-utils/query'; +import type { ArtistRef, Page } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/artists?q=miles') })); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$lib/api/queries', () => ({ + createSearchArtistsInfiniteQuery: vi.fn() +})); + +import ArtistsOverflow from './+page.svelte'; +import { createSearchArtistsInfiniteQuery } from '$lib/api/queries'; + +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/search/artists?q=miles'); +}); + +describe('search artists overflow', () => { + test('renders a row per artist', () => { + const a1: ArtistRef = { id: 'a1', name: 'Miles Davis', album_count: 12 }; + const a2: ArtistRef = { id: 'a2', name: 'Miles Mosley', album_count: 1 }; + (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([a1, a2], 2)] }) + ); + render(ArtistsOverflow); + expect(screen.getByRole('link', { name: /Miles Davis/ })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Miles Mosley/ })).toBeInTheDocument(); + }); + + test('Load more calls fetchNextPage when hasNextPage', async () => { + const fetchNextPage = vi.fn(); + (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + pages: [page([], 100)], + hasNextPage: true, + fetchNextPage + }) + ); + render(ArtistsOverflow); + await fireEvent.click(screen.getByRole('button', { name: /load more/i })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + test('Load more is hidden when hasNextPage is false', () => { + (createSearchArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) + ); + render(ArtistsOverflow); + expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument(); + }); + + test('empty q shows a prompt and fires no query', () => { + state.pageUrl = new URL('http://localhost/search/artists'); + render(ArtistsOverflow); + expect(screen.getByText(/no query/i)).toBeInTheDocument(); + expect(createSearchArtistsInfiniteQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/routes/search/tracks/+page.svelte b/web/src/routes/search/tracks/+page.svelte new file mode 100644 index 00000000..b40d164d --- /dev/null +++ b/web/src/routes/search/tracks/+page.svelte @@ -0,0 +1,60 @@ + + +
+
+ + ← Back to search + +

Tracks matching '{q}'

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

{total} {total === 1 ? 'track' : 'tracks'}

+ {/if} +
+ + {#if !q} +

No query — return to search to start typing.

+ {:else if query?.isError} + + {:else if showSkeleton.value && tracks.length === 0} + + {:else if total === 0} +

No track matches.

+ {:else} +
+ {#each tracks as t, i (t.id)} + + {/each} +
+ {#if query?.hasNextPage} + + {/if} + {/if} +
diff --git a/web/src/routes/search/tracks/tracks.test.ts b/web/src/routes/search/tracks/tracks.test.ts new file mode 100644 index 00000000..5eb9c300 --- /dev/null +++ b/web/src/routes/search/tracks/tracks.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockInfiniteQuery } from '../../../test-utils/query'; +import type { TrackRef, Page } from '$lib/api/types'; + +const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/search/tracks?q=miles') })); + +vi.mock('$app/state', () => ({ + page: { get url() { return state.pageUrl; } } +})); + +vi.mock('$lib/api/queries', () => ({ + createSearchTracksInfiniteQuery: vi.fn() +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), + playRadio: vi.fn(), + enqueueTrack: vi.fn() +})); + +import TracksOverflow from './+page.svelte'; +import { createSearchTracksInfiniteQuery } from '$lib/api/queries'; +import { playRadio } from '$lib/player/store.svelte'; + +function page(items: T[], total: number, offset = 0, limit = 50): Page { + return { items, total, offset, limit }; +} + +const track: TrackRef = { + id: 't1', title: 'So What', + album_id: 'al1', album_title: 'Kind of Blue', + artist_id: 'a1', artist_name: 'Miles Davis', + track_number: 1, disc_number: 1, duration_sec: 545, + stream_url: '/api/tracks/t1/stream' +}; + +afterEach(() => { + vi.clearAllMocks(); + state.pageUrl = new URL('http://localhost/search/tracks?q=miles'); +}); + +describe('search tracks overflow', () => { + test('renders a TrackRow per track', () => { + (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([track], 1)] }) + ); + render(TracksOverflow); + expect(screen.getByRole('button', { name: /So What/ })).toBeInTheDocument(); + }); + + test('clicking a track row triggers playRadio with the track id', async () => { + (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([track], 1)] }) + ); + render(TracksOverflow); + await fireEvent.click(screen.getByRole('button', { name: /So What/ })); + expect(playRadio).toHaveBeenCalledWith('t1'); + }); + + test('Load more calls fetchNextPage', async () => { + const fetchNextPage = vi.fn(); + (createSearchTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ + pages: [page([], 100)], + hasNextPage: true, + fetchNextPage + }) + ); + render(TracksOverflow); + await fireEvent.click(screen.getByRole('button', { name: /load more/i })); + expect(fetchNextPage).toHaveBeenCalledTimes(1); + }); + + test('empty q shows a prompt', () => { + state.pageUrl = new URL('http://localhost/search/tracks'); + render(TracksOverflow); + expect(screen.getByText(/no query/i)).toBeInTheDocument(); + }); +});