diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte index fb9a0a90..b25c5f31 100644 --- a/web/src/lib/components/Shell.svelte +++ b/web/src/lib/components/Shell.svelte @@ -25,6 +25,7 @@ { href: '/', label: 'Library' }, { href: '/library/liked', label: 'Liked' }, { href: '/search', label: 'Search' }, + { href: '/discover', label: 'Discover' }, { href: '/playlists', label: 'Playlists' }, { href: '/settings', label: 'Settings' } ]; diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts index 1ad5a48d..cae5eed2 100644 --- a/web/src/lib/components/Shell.test.ts +++ b/web/src/lib/components/Shell.test.ts @@ -28,10 +28,11 @@ describe('Shell', () => { expect(screen.getByText('alice')).toBeInTheDocument(); }); - test('renders three nav items: Library, Search, Playlists', () => { + test('renders nav items including Discover between Search and Playlists', () => { render(Shell); expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); + expect(screen.getByRole('link', { name: 'Discover' })).toHaveAttribute('href', '/discover'); expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); }); diff --git a/web/src/routes/discover/+page.svelte b/web/src/routes/discover/+page.svelte new file mode 100644 index 00000000..4ec63f06 --- /dev/null +++ b/web/src/routes/discover/+page.svelte @@ -0,0 +1,222 @@ + + +
+
+

+ Add music to the library +

+

+ Search Lidarr to add new artists, albums, or tracks. +

+
+ + + + + + {#if !debouncedQ} +

+ Search Lidarr for music to add to the library. +

+ {:else if query.isError} + + {:else if query.isPending} +

Searching…

+ {:else if results.length === 0} +

Nothing to add for that search yet.

+ {:else} +
+ {#each results as r (r.mbid)} + handleRequestClick(r)} + /> + {/each} +
+ {/if} +
+ +{#if modalResult} + + +
+ +
+{/if} diff --git a/web/src/routes/discover/discover.test.ts b/web/src/routes/discover/discover.test.ts new file mode 100644 index 00000000..60e29e67 --- /dev/null +++ b/web/src/routes/discover/discover.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; +import type { LidarrSearchResult } from '$lib/api/types'; + +// Lidarr search query factory and createRequest are mocked at the module +// level so each test can shape what the page sees without standing up a +// real QueryClient + network. +vi.mock('$lib/api/lidarr', () => ({ + createLidarrSearchQuery: vi.fn() +})); + +vi.mock('$lib/api/requests', () => ({ + createRequest: vi.fn().mockResolvedValue({ id: 'r1' }) +})); + +vi.mock('$lib/api/client', () => ({ + api: { get: vi.fn(), post: vi.fn(), put: vi.fn(), del: vi.fn() } +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + +import DiscoverPage from './+page.svelte'; +import { createLidarrSearchQuery } from '$lib/api/lidarr'; +import { createRequest } from '$lib/api/requests'; + +const mockedCreateQuery = createLidarrSearchQuery as ReturnType; +const mockedCreateRequest = createRequest as ReturnType; + +function result(over: Partial = {}): LidarrSearchResult { + return { + mbid: 'mbid-1', + name: 'Boards of Canada', + secondary_text: 'Electronic', + image_url: '', + artist_mbid: 'art-1', + album_mbid: '', + in_library: false, + requested: false, + ...over + }; +} + +beforeEach(() => { + // Default: empty results, non-pending. Tests override per-case. + mockedCreateQuery.mockReturnValue(mockQuery({ data: [] })); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Discover page', () => { + test('initial state (no query) shows the search prompt copy', () => { + render(DiscoverPage); + expect( + screen.getByText(/search lidarr for music to add/i) + ).toBeInTheDocument(); + }); + + test('debounced input fires query factory with typed value after 250ms', async () => { + vi.useFakeTimers(); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'boards' } }); + // Before the timer elapses, the factory was called only with the + // initial empty string — never with 'boards'. + expect(mockedCreateQuery).not.toHaveBeenCalledWith('boards', 'artist'); + await vi.advanceTimersByTimeAsync(250); + expect(mockedCreateQuery).toHaveBeenLastCalledWith('boards', 'artist'); + vi.useRealTimers(); + }); + + test('switching tabs refetches with the new kind', async () => { + vi.useFakeTimers(); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'boards' } }); + await vi.advanceTimersByTimeAsync(250); + // Switch to Albums. + await fireEvent.click(screen.getByRole('button', { name: 'Albums' })); + expect(mockedCreateQuery).toHaveBeenLastCalledWith('boards', 'album'); + // Then to Tracks. + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); + expect(mockedCreateQuery).toHaveBeenLastCalledWith('boards', 'track'); + vi.useRealTimers(); + }); + + test('empty results state renders the voice-rule copy', async () => { + vi.useFakeTimers(); + mockedCreateQuery.mockReturnValue(mockQuery({ data: [] })); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'zzz' } }); + await vi.advanceTimersByTimeAsync(250); + await waitFor(() => + expect( + screen.getByText(/nothing to add for that search yet\./i) + ).toBeInTheDocument() + ); + vi.useRealTimers(); + }); + + test('artist-kind Request click calls createRequest immediately (no modal)', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'art-mbid', + artist_mbid: 'art-mbid', + name: 'Boards of Canada' + }); + mockedCreateQuery.mockReturnValue(mockQuery({ data: [r] })); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'boards' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + const requestBtn = await screen.findByRole('button', { + name: /request boards of canada/i + }); + await fireEvent.click(requestBtn); + expect(mockedCreateRequest).toHaveBeenCalledTimes(1); + expect(mockedCreateRequest).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'artist' }) + ); + // Modal must not be present for non-track kinds. + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + test('requestable card flips to "Requested" after a successful request', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'art-mbid', + artist_mbid: 'art-mbid', + name: 'Boards of Canada' + }); + mockedCreateQuery.mockReturnValue(mockQuery({ data: [r] })); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'boards' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + const requestBtn = await screen.findByRole('button', { + name: /request boards of canada/i + }); + await fireEvent.click(requestBtn); + await waitFor(() => { + const flipped = screen.getByRole('button', { name: /already requested/i }); + expect(flipped).toBeDisabled(); + }); + }); + + test('track-kind Request click opens confirm modal; Confirm calls createRequest', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'tr-mbid', + artist_mbid: 'art-mbid', + album_mbid: 'al-mbid', + name: 'Roygbiv', + secondary_text: 'Music Has The Right To Children · Boards of Canada' + }); + mockedCreateQuery.mockReturnValue(mockQuery({ data: [r] })); + render(DiscoverPage); + // Switch to track kind first. + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'roy' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + const requestBtn = await screen.findByRole('button', { + name: /request roygbiv/i + }); + await fireEvent.click(requestBtn); + // Modal opens with the explanation copy. + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog.textContent).toMatch(/continue/i); + // Confirm fires createRequest with kind=track. + await fireEvent.click(screen.getByRole('button', { name: /add the album/i })); + expect(mockedCreateRequest).toHaveBeenCalledTimes(1); + expect(mockedCreateRequest).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'track', + lidarr_track_mbid: 'tr-mbid', + lidarr_album_mbid: 'al-mbid', + lidarr_artist_mbid: 'art-mbid' + }) + ); + }); + + test('track-kind modal Cancel does not call createRequest', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'tr-mbid', + artist_mbid: 'art-mbid', + album_mbid: 'al-mbid', + name: 'Roygbiv', + secondary_text: 'Music Has The Right To Children · Boards of Canada' + }); + mockedCreateQuery.mockReturnValue(mockQuery({ data: [r] })); + render(DiscoverPage); + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'roy' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + const requestBtn = await screen.findByRole('button', { + name: /request roygbiv/i + }); + await fireEvent.click(requestBtn); + await screen.findByRole('dialog'); + await fireEvent.click(screen.getByRole('button', { name: /^cancel$/i })); + expect(mockedCreateRequest).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + test('in_library result renders kept state regardless of requested', async () => { + vi.useFakeTimers(); + const r = result({ + mbid: 'kept-1', + name: 'Kind of Blue', + in_library: true, + requested: true + }); + mockedCreateQuery.mockReturnValue(mockQuery({ data: [r] })); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'kind' } }); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /in library/i })).toBeDisabled(); + }); + }); +});