"Matches your taste in shoegaze and dream pop." replaces the seed
attribution when the candidate's own tags overlap the taste profile.
The preference order is the point of slice 6: the tag reason describes the
MUSIC ("sounds like what you like"), while seed attribution describes the
graph ("adjacent to something you played"). When we can say the former, it
is strictly the better explanation. When we can't — the common case, since
tag coverage for out-of-library artists is partial by nature (#2376) — the
card falls back to attribution rather than going blank.
Both clients share the wording, Oxford comma included, and both have tests
asserting the exact strings. That's deliberate: identical copy across two
codebases silently diverges unless something fails when it does.
Android caps at 3 tags client-side even though the server already does.
The server contract could widen; a run-on subtitle shouldn't be how we
find out.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
293 lines
11 KiB
TypeScript
293 lines
11 KiB
TypeScript
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<string, unknown>;
|
|
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<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
|
|
}
|
|
|
|
function setSnoozes(data: SuggestionSnooze[]) {
|
|
(createSnoozesQuery as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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> = {}): 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();
|
|
});
|
|
});
|