feat(discover): snooze affordance on Android + web suggestion cards — #2375
Completes the snooze from slice 3 (#2374), so it's now touchable on both clients (rule #27 — the server side alone was never shippable). Copy is "Not right now" everywhere, never a dislike (rule #101). The parked list even says so out loud: "Nothing here counts against your taste profile." Both clients flip the card in place to a "Not right now" state with an Undo, rather than yanking it out of the grid under the cursor. The row leaves on the next refetch; the persistent way back is a parked-list section below the deck. That list isn't optional garnish — a snoozed candidate is by definition absent from the deck, so without it the DELETE endpoint is unreachable. Android routes the write through the offline MutationQueue per rule #100, as ONE toggle kind (SUGGESTION_SNOOZE_TOGGLE) carrying the desired state rather than two action kinds. That reuses the LIKE_TOGGLE collapse: a queued snooze the user has since undone is dropped unsent instead of replaying after the undo and re-hiding an artist they asked to see. The collapse helper is now a pure top-level function so that rule is unit tested rather than inferred. The repository does NOT enqueue on a 4xx — a permanent rejection would replay to the same failure and would raise a misleading "will sync when online" hint. The common case is a 404 from un-snoozing a row that already lapsed, which is the user's intended end state anyway. Also: an empty deck used to have one meaning (no listening signal yet). It can now also mean "you parked them all", so the empty copy branches — telling that user to go listen to something would be wrong advice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import { mockQuery } from '../../test-utils/query';
|
||||
|
||||
@@ -9,17 +9,30 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/suggestions', () => ({
|
||||
createSuggestionsQuery: vi.fn()
|
||||
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 } from '$lib/api/suggestions';
|
||||
import {
|
||||
createSuggestionsQuery,
|
||||
createSnoozesQuery,
|
||||
snoozeSuggestion,
|
||||
unsnoozeSuggestion
|
||||
} from '$lib/api/suggestions';
|
||||
import { createRequest } from '$lib/api/requests';
|
||||
import type { ArtistSuggestion } from '$lib/api/types';
|
||||
import type { ArtistSuggestion, SuggestionSnooze } from '$lib/api/types';
|
||||
|
||||
const oneSeed: ArtistSuggestion = {
|
||||
mbid: 'mb1',
|
||||
@@ -51,46 +64,50 @@ const threeSeeds: ArtistSuggestion = {
|
||||
]
|
||||
};
|
||||
|
||||
/** 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', () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [oneSeed, twoSeeds] })
|
||||
);
|
||||
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."', () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [oneSeed] })
|
||||
);
|
||||
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."', () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [twoSeeds] })
|
||||
);
|
||||
setSuggestions([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<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [threeSeeds] })
|
||||
);
|
||||
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 () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
mockQuery({ data: [oneSeed] })
|
||||
);
|
||||
setSuggestions([oneSeed]);
|
||||
render(SuggestionFeed);
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request outsider/i }));
|
||||
expect(createRequest).toHaveBeenCalledWith({
|
||||
@@ -102,8 +119,113 @@ describe('SuggestionFeed', () => {
|
||||
});
|
||||
|
||||
test('empty state when data is []', () => {
|
||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user