import { afterEach, beforeEach, 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(), createSnoozesQuery: vi.fn(), snoozeSuggestion: vi.fn().mockResolvedValue(undefined), unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined) })); vi.mock('$lib/api/requests', () => ({ createRequest: vi.fn().mockResolvedValue({}) })); const pushToastMock = vi.fn(); vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...args: unknown[]) => pushToastMock(...args) })); import SuggestionFeed from './SuggestionFeed.svelte'; import { createSuggestionsQuery, createSnoozesQuery, snoozeSuggestion, unsnoozeSuggestion } from '$lib/api/suggestions'; import { createRequest } from '$lib/api/requests'; import type { ArtistSuggestion, SuggestionSnooze } 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 } ] }; /** Days from now as an RFC3339 string, for snooze fixtures. */ function inDays(n: number): string { return new Date(Date.now() + n * 86_400_000).toISOString(); } function setSuggestions(data: ArtistSuggestion[]) { (createSuggestionsQuery as ReturnType).mockReturnValue(mockQuery({ data })); } function setSnoozes(data: SuggestionSnooze[]) { (createSnoozesQuery as ReturnType).mockReturnValue(mockQuery({ data })); } beforeEach(() => setSnoozes([])); afterEach(() => vi.clearAllMocks()); describe('SuggestionFeed', () => { test('renders one card per suggestion', () => { setSuggestions([oneSeed, twoSeeds]); render(SuggestionFeed); expect(screen.getByText('Outsider')).toBeInTheDocument(); expect(screen.getByText('Outsider Two')).toBeInTheDocument(); }); test('attribution copy: 1 seed → "Because you liked X."', () => { setSuggestions([oneSeed]); render(SuggestionFeed); expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument(); }); test('attribution copy: 2 seeds → "Because you liked A and played B."', () => { setSuggestions([twoSeeds]); render(SuggestionFeed); expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument(); }); test('attribution copy: 3 seeds → Oxford comma', () => { setSuggestions([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 () => { setSuggestions([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 []', () => { setSuggestions([]); render(SuggestionFeed); expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument(); }); }); describe('SuggestionFeed snooze (#2375)', () => { test('snooze sends BOTH mbid and name — the server 400s without the name', async () => { setSuggestions([oneSeed]); render(SuggestionFeed); await fireEvent.click(screen.getByRole('button', { name: /not right now/i })); expect(snoozeSuggestion).toHaveBeenCalledWith('mb1', 'Outsider'); }); test('the card stays in place showing Undo, rather than vanishing', async () => { setSuggestions([oneSeed]); render(SuggestionFeed); await fireEvent.click(screen.getByRole('button', { name: /not right now/i })); // Still on screen — the disappearance happens on refetch, not under the // cursor (rule #24). expect(screen.getByText('Outsider')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /bring outsider back/i })).toBeInTheDocument(); expect(screen.getByRole('status')).toHaveTextContent('Not right now'); }); test('a failed snooze reverts the card and says so', async () => { (snoozeSuggestion as ReturnType).mockRejectedValueOnce(new Error('offline')); setSuggestions([oneSeed]); render(SuggestionFeed); await fireEvent.click(screen.getByRole('button', { name: /not right now/i })); // Back to requestable — a snooze that silently did nothing would leave // the user tapping it again. expect(screen.getByRole('button', { name: /request outsider/i })).toBeInTheDocument(); expect(pushToastMock).toHaveBeenCalledWith("Couldn't hide Outsider", 'error'); }); test('undo on the card calls unsnoozeSuggestion', async () => { setSuggestions([oneSeed]); render(SuggestionFeed); await fireEvent.click(screen.getByRole('button', { name: /not right now/i })); await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i })); expect(unsnoozeSuggestion).toHaveBeenCalledWith('mb1'); }); test('the snoozed list is the way back once the card is gone', async () => { // Deck empty, one parked artist: exactly the state after a refetch. setSuggestions([]); setSnoozes([ { mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) } ]); render(SuggestionFeed); expect(screen.getByRole('heading', { name: /not right now/i })).toBeInTheDocument(); expect(screen.getByText('Parked')).toBeInTheDocument(); await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i })); expect(unsnoozeSuggestion).toHaveBeenCalledWith('mbX'); }); test('a 404 from unsnooze is not surfaced as an error', async () => { (unsnoozeSuggestion as ReturnType).mockRejectedValueOnce({ status: 404 }); setSuggestions([]); setSnoozes([ { mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) } ]); render(SuggestionFeed); await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i })); // Already-unsnoozed IS the end state the user asked for. expect(pushToastMock).not.toHaveBeenCalled(); }); test('a non-404 unsnooze failure does surface', async () => { (unsnoozeSuggestion as ReturnType).mockRejectedValueOnce({ status: 500 }); setSuggestions([]); setSnoozes([ { mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) } ]); render(SuggestionFeed); await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i })); expect(pushToastMock).toHaveBeenCalledWith("Couldn't bring Parked back", 'error'); }); test('return time reads as a relative phrase, not a calendar date', () => { setSuggestions([]); setSnoozes([ { mbid: 'a', name: 'Quarter', snoozed_until: inDays(90), created_at: inDays(0) }, { mbid: 'b', name: 'Fortnight', snoozed_until: inDays(14), created_at: inDays(0) } ]); render(SuggestionFeed); expect(screen.getByText(/back in about 3 months/i)).toBeInTheDocument(); expect(screen.getByText(/back in 14 days/i)).toBeInTheDocument(); }); // Pins the days→months seam. The singular branch was originally dead code // here too: the threshold (45) sat above the divisor (30), so no day count // could round to one month without hitting the days branch first. Kept in // lockstep with SuggestionSnoozeRefTest on Android. test('the days-to-months boundary sits at 30 days, so "a month" is reachable', () => { setSuggestions([]); setSnoozes([ { mbid: 'a', name: 'JustUnder', snoozed_until: inDays(29), created_at: inDays(0) }, { mbid: 'b', name: 'JustOver', snoozed_until: inDays(30), created_at: inDays(0) } ]); render(SuggestionFeed); expect(screen.getByText(/back in 29 days/i)).toBeInTheDocument(); expect(screen.getByText(/back in about a month/i)).toBeInTheDocument(); }); test('no snoozed section when nothing is parked', () => { setSuggestions([oneSeed]); setSnoozes([]); render(SuggestionFeed); expect(screen.queryByRole('heading', { name: /not right now/i })).not.toBeInTheDocument(); }); // An empty deck has two causes now, and the advice differs. Telling someone // who parked everything to go listen to music would be wrong. test('empty-deck copy distinguishes "no signal" from "you parked them all"', () => { setSuggestions([]); setSnoozes([ { mbid: 'a', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) } ]); render(SuggestionFeed); expect(screen.getByText(/nothing new right now/i)).toBeInTheDocument(); expect(screen.queryByText(/listen to something or like an artist/i)).not.toBeInTheDocument(); }); }); describe('SuggestionFeed taste-tag reason (#2377)', () => { const tagged = (over: Partial = {}): ArtistSuggestion => ({ ...oneSeed, mbid: 'mbT', name: 'Tag Match', ...over }); test('matched tags replace seed attribution as the reason line', () => { setSuggestions([tagged({ matched_tags: ['shoegaze', 'dream pop'] })]); render(SuggestionFeed); expect( screen.getByText(/matches your taste in shoegaze and dream pop\./i) ).toBeInTheDocument(); // The graph-adjacency line is superseded — describing the music beats // describing the graph when we can do both. expect(screen.queryByText(/because you liked/i)).not.toBeInTheDocument(); }); test('one matched tag reads in the singular', () => { setSuggestions([tagged({ matched_tags: ['shoegaze'] })]); render(SuggestionFeed); expect(screen.getByText(/matches your taste in shoegaze\./i)).toBeInTheDocument(); }); test('three matched tags use an Oxford comma, matching the seed copy', () => { setSuggestions([tagged({ matched_tags: ['a', 'b', 'c'] })]); render(SuggestionFeed); expect(screen.getByText(/matches your taste in a, b, and c\./i)).toBeInTheDocument(); }); // The common case: most candidates have no cached tags, and the card must // still explain itself rather than going blank. test('no matched tags falls back to seed attribution', () => { setSuggestions([oneSeed]); render(SuggestionFeed); expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument(); }); test('an empty matched_tags array also falls back, not to an empty line', () => { setSuggestions([tagged({ matched_tags: [] })]); render(SuggestionFeed); expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument(); }); });