diff --git a/web/src/lib/components/SuggestionFeed.svelte b/web/src/lib/components/SuggestionFeed.svelte new file mode 100644 index 00000000..6f16b641 --- /dev/null +++ b/web/src/lib/components/SuggestionFeed.svelte @@ -0,0 +1,75 @@ + + +
+
+

Suggested for you

+

Out-of-library artists drawn from what you've liked and played.

+
+ + {#if !query.isPending && suggestions.length === 0} +

Listen to something or like an artist to start getting suggestions.

+ {:else if suggestions.length > 0} +
+ {#each suggestions.filter(visible) as s (s.mbid)} + onRequest(s)} + /> + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/SuggestionFeed.test.ts b/web/src/lib/components/SuggestionFeed.test.ts new file mode 100644 index 00000000..0e6fdb43 --- /dev/null +++ b/web/src/lib/components/SuggestionFeed.test.ts @@ -0,0 +1,109 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockQuery } from '../../test-utils/query'; + +const invalidateMock = vi.fn(); +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({ invalidateQueries: invalidateMock }) }; +}); + +vi.mock('$lib/api/suggestions', () => ({ + createSuggestionsQuery: vi.fn() +})); + +vi.mock('$lib/api/requests', () => ({ + createRequest: vi.fn().mockResolvedValue({}) +})); + +import SuggestionFeed from './SuggestionFeed.svelte'; +import { createSuggestionsQuery } from '$lib/api/suggestions'; +import { createRequest } from '$lib/api/requests'; +import type { ArtistSuggestion } from '$lib/api/types'; + +const oneSeed: ArtistSuggestion = { + mbid: 'mb1', + name: 'Outsider', + score: 1.0, + attribution: [ + { artist_id: 'a1', name: 'Seed', contribution: 0.9, is_liked: true, play_count: 0 } + ] +}; + +const twoSeeds: ArtistSuggestion = { + mbid: 'mb2', + name: 'Outsider Two', + score: 2.0, + attribution: [ + { artist_id: 'a1', name: 'A', contribution: 0.8, is_liked: true, play_count: 0 }, + { artist_id: 'a2', name: 'B', contribution: 0.5, is_liked: false, play_count: 3 } + ] +}; + +const threeSeeds: ArtistSuggestion = { + mbid: 'mb3', + name: 'Outsider Three', + score: 3.0, + attribution: [ + { artist_id: 'a1', name: 'X', contribution: 0.9, is_liked: true, play_count: 0 }, + { artist_id: 'a2', name: 'Y', contribution: 0.6, is_liked: false, play_count: 5 }, + { artist_id: 'a3', name: 'Z', contribution: 0.3, is_liked: false, play_count: 1 } + ] +}; + +afterEach(() => vi.clearAllMocks()); + +describe('SuggestionFeed', () => { + test('renders one card per suggestion', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [oneSeed, twoSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText('Outsider')).toBeInTheDocument(); + expect(screen.getByText('Outsider Two')).toBeInTheDocument(); + }); + + test('attribution copy: 1 seed → "Because you liked X."', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [oneSeed] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument(); + }); + + test('attribution copy: 2 seeds → "Because you liked A and played B."', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [twoSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument(); + }); + + test('attribution copy: 3 seeds → Oxford comma', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [threeSeeds] }) + ); + render(SuggestionFeed); + expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument(); + }); + + test('Request button calls createRequest with artist-kind body', async () => { + (createSuggestionsQuery as ReturnType).mockReturnValue( + mockQuery({ data: [oneSeed] }) + ); + render(SuggestionFeed); + await fireEvent.click(screen.getByRole('button', { name: /request outsider/i })); + expect(createRequest).toHaveBeenCalledWith({ + kind: 'artist', + lidarr_artist_mbid: 'mb1', + artist_name: 'Outsider' + }); + expect(invalidateMock).toHaveBeenCalled(); + }); + + test('empty state when data is []', () => { + (createSuggestionsQuery as ReturnType).mockReturnValue(mockQuery({ data: [] })); + render(SuggestionFeed); + expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument(); + }); +}); diff --git a/web/src/routes/discover/+page.svelte b/web/src/routes/discover/+page.svelte index e75f71de..dac4c2b5 100644 --- a/web/src/routes/discover/+page.svelte +++ b/web/src/routes/discover/+page.svelte @@ -3,6 +3,7 @@ import { createRequest } from '$lib/api/requests'; import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte'; import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte'; + import SuggestionFeed from '$lib/components/SuggestionFeed.svelte'; import type { LidarrRequestKind, LidarrSearchResult @@ -114,15 +115,6 @@
-
-

- 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.

+ {#if debouncedQ === ''} + {:else} -
- {#each results as r (r.mbid)} - handleRequestClick(r)} - /> - {/each} -
+
+

+ Add music to the library +

+

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

+
+ + + + {#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}
diff --git a/web/src/routes/discover/discover.test.ts b/web/src/routes/discover/discover.test.ts index 60e29e67..ba9bd13b 100644 --- a/web/src/routes/discover/discover.test.ts +++ b/web/src/routes/discover/discover.test.ts @@ -10,6 +10,10 @@ vi.mock('$lib/api/lidarr', () => ({ createLidarrSearchQuery: vi.fn() })); +vi.mock('$lib/api/suggestions', () => ({ + createSuggestionsQuery: vi.fn() +})); + vi.mock('$lib/api/requests', () => ({ createRequest: vi.fn().mockResolvedValue({ id: 'r1' }) })); @@ -25,9 +29,11 @@ vi.mock('@tanstack/svelte-query', async (orig) => { import DiscoverPage from './+page.svelte'; import { createLidarrSearchQuery } from '$lib/api/lidarr'; +import { createSuggestionsQuery } from '$lib/api/suggestions'; import { createRequest } from '$lib/api/requests'; const mockedCreateQuery = createLidarrSearchQuery as ReturnType; +const mockedCreateSuggestionsQuery = createSuggestionsQuery as ReturnType; const mockedCreateRequest = createRequest as ReturnType; function result(over: Partial = {}): LidarrSearchResult { @@ -47,6 +53,9 @@ function result(over: Partial = {}): LidarrSearchResult { beforeEach(() => { // Default: empty results, non-pending. Tests override per-case. mockedCreateQuery.mockReturnValue(mockQuery({ data: [] })); + // Default: empty suggestion feed so its empty-state copy renders without + // interfering with search-mode tests. + mockedCreateSuggestionsQuery.mockReturnValue(mockQuery({ data: [] })); }); afterEach(() => { @@ -54,11 +63,31 @@ afterEach(() => { }); describe('Discover page', () => { - test('initial state (no query) shows the search prompt copy', () => { + test('initial state (no query) shows the suggestion feed', () => { render(DiscoverPage); + expect(screen.getByText(/suggested for you/i)).toBeInTheDocument(); + }); + + test('empty input shows the suggestion feed', () => { + render(DiscoverPage); + expect(screen.getByText(/suggested for you/i)).toBeInTheDocument(); + // Kind tabs should NOT be visible when input is empty. expect( - screen.getByText(/search lidarr for music to add/i) - ).toBeInTheDocument(); + screen.queryByRole('button', { name: 'Artists' }) + ).not.toBeInTheDocument(); + }); + + test('typing replaces feed with search', async () => { + vi.useFakeTimers(); + render(DiscoverPage); + const input = screen.getByLabelText(/search lidarr/i); + await fireEvent.input(input, { target: { value: 'miles' } }); + await vi.advanceTimersByTimeAsync(250); + expect(screen.queryByText(/suggested for you/i)).not.toBeInTheDocument(); + expect(screen.getByText(/add music to the library/i)).toBeInTheDocument(); + // Kind tabs visible when searching. + expect(screen.getByRole('button', { name: 'Artists' })).toBeInTheDocument(); + vi.useRealTimers(); }); test('debounced input fires query factory with typed value after 250ms', async () => { @@ -165,11 +194,13 @@ describe('Discover page', () => { }); mockedCreateQuery.mockReturnValue(mockQuery({ data: [r] })); render(DiscoverPage); - // Switch to track kind first. - await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); + // Type first so the kind tabs become visible (empty-input mode shows the + // suggestion feed and hides tabs). const input = screen.getByLabelText(/search lidarr/i); await fireEvent.input(input, { target: { value: 'roy' } }); await vi.advanceTimersByTimeAsync(250); + // Switch to track kind. + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); vi.useRealTimers(); const requestBtn = await screen.findByRole('button', { @@ -204,10 +235,10 @@ describe('Discover page', () => { }); 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); + await fireEvent.click(screen.getByRole('button', { name: 'Tracks' })); vi.useRealTimers(); const requestBtn = await screen.findByRole('button', {