feat(discover): snooze affordance on Android + web suggestion cards — #2375
test-web / test (push) Failing after 37s
android / Build + lint + test (push) Failing after 1m42s

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:
2026-08-02 19:03:10 -04:00
co-authored by Claude Opus 5
parent 86af79bd2f
commit 6e39471a70
19 changed files with 1176 additions and 68 deletions
+1
View File
@@ -58,6 +58,7 @@ export const qk = {
smtpConfig: () => ['smtpConfig'] as const,
suggestions: (limit?: number) =>
['suggestions', { limit: limit ?? 12 }] as const,
suggestionSnoozes: () => ['suggestionSnoozes'] as const,
home: () => ['home'] as const,
albumsAlpha: () => ['albumsAlpha'] as const,
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
+61 -3
View File
@@ -1,13 +1,18 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
vi.mock('./client', () => ({
api: { get: vi.fn() }
api: { get: vi.fn(), post: vi.fn(), del: vi.fn() }
}));
import { listSuggestions } from './suggestions';
import {
listSuggestions,
listSnoozes,
snoozeSuggestion,
unsnoozeSuggestion
} from './suggestions';
import { qk } from './queries';
import { api } from './client';
import type { ArtistSuggestion } from './types';
import type { ArtistSuggestion, SuggestionSnooze } from './types';
afterEach(() => vi.clearAllMocks());
@@ -40,3 +45,56 @@ describe('suggestions client', () => {
expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]);
});
});
describe('suggestion snoozes (#2375)', () => {
test('snoozeSuggestion sends the name — the server 400s without it', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
await snoozeSuggestion('mb-1', 'Parked Artist');
expect(api.post).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze', {
name: 'Parked Artist'
});
});
test('snoozeSuggestion sends no days, leaving the default to the server', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
await snoozeSuggestion('mb-1', 'Parked Artist');
const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<string, unknown>;
expect(body).not.toHaveProperty('days');
});
// MBIDs are UUIDs today, but the column is free-text and the value comes
// from an external similarity feed, so it goes through encodeURIComponent.
test('the mbid is URL-encoded into the path', async () => {
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
await snoozeSuggestion('weird/id?x', 'Odd');
expect(api.post).toHaveBeenCalledWith(
'/api/discover/suggestions/weird%2Fid%3Fx/snooze',
{ name: 'Odd' }
);
});
test('unsnoozeSuggestion DELETEs the same path', async () => {
(api.del as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
await unsnoozeSuggestion('mb-1');
expect(api.del).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze');
});
test('listSnoozes hits the snoozes collection', async () => {
const fixture: SuggestionSnooze[] = [
{
mbid: 'mb-1',
name: 'Parked Artist',
snoozed_until: '2026-11-01T00:00:00Z',
created_at: '2026-08-03T00:00:00Z'
}
];
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture);
const got = await listSnoozes();
expect(api.get).toHaveBeenCalledWith('/api/discover/snoozes');
expect(got).toEqual(fixture);
});
test('qk.suggestionSnoozes key shape', () => {
expect(qk.suggestionSnoozes()).toEqual(['suggestionSnoozes']);
});
});
+34 -1
View File
@@ -1,7 +1,7 @@
import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
import { qk } from './queries';
import type { ArtistSuggestion } from './types';
import type { ArtistSuggestion, SuggestionSnooze } from './types';
export async function listSuggestions(limit = 12): Promise<ArtistSuggestion[]> {
return api.get<ArtistSuggestion[]>(`/api/discover/suggestions?limit=${limit}`);
@@ -14,3 +14,36 @@ export function createSuggestionsQuery(limit = 12) {
staleTime: 5 * 60_000 // 5 minutes — see M5c spec §5
});
}
// Parks a suggestion for the server's default period (90 days). `name` is
// REQUIRED by the server and is not optional bookkeeping: candidates are
// out-of-library, so there is no artists row to resolve a display name from
// and the snooze list would have nothing to render. Omitting it is a 400.
//
// No `days` is sent. There is deliberately no duration UI yet — that knob is
// slice 6 (#2377) — and hardcoding a value here would pin the default to the
// client instead of the server that owns it.
export async function snoozeSuggestion(mbid: string, name: string): Promise<void> {
await api.post<null>(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`, { name });
}
// Brings a parked suggestion back immediately. The server 404s an MBID that
// was never snoozed; callers treat that as already-unsnoozed rather than as a
// failure, since the end state the user asked for is the one they get.
export async function unsnoozeSuggestion(mbid: string): Promise<void> {
await api.del(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`);
}
export async function listSnoozes(): Promise<SuggestionSnooze[]> {
return api.get<SuggestionSnooze[]>('/api/discover/snoozes');
}
export function createSnoozesQuery() {
return createQuery({
queryKey: qk.suggestionSnoozes(),
queryFn: listSnoozes
// No staleTime, unlike the suggestions query: this list is the only route
// back to an un-snooze, so it must reflect a snooze made seconds ago
// rather than a cached view of the world.
});
}
+10
View File
@@ -334,6 +334,16 @@ export type ArtistSuggestion = {
image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder
};
// One parked suggestion — "not right now", not a dislike. The server only
// ever returns rows whose snoozed_until is still in the future, so the client
// never has to compare against the clock to decide what to show.
export type SuggestionSnooze = {
mbid: string;
name: string;
snoozed_until: string; // RFC3339
created_at: string;
};
// Mirrors internal/api/types.go HomePayload. All slices are non-null
// per the server contract — empty sections render as [].
export type HomePayload = {
@@ -1,10 +1,14 @@
<script lang="ts" module>
export type DiscoverCardKind = 'artist' | 'album' | 'track';
export type DiscoverCardState = 'requestable' | 'kept' | 'requested';
// 'snoozed' is a transient state the card flips to in place after the user
// parks it, so the disappearance is legible and undoable rather than a card
// silently vanishing from under the cursor (rule #24). The row is gone on
// the next refetch; the persistent way back is the snoozed list.
export type DiscoverCardState = 'requestable' | 'kept' | 'requested' | 'snoozed';
</script>
<script lang="ts">
import { Plus, Disc3, Album, Music2 } from 'lucide-svelte';
import { Plus, Disc3, Album, Music2, Clock } from 'lucide-svelte';
let {
kind,
@@ -14,6 +18,8 @@
state,
attribution,
onRequest,
onSnooze,
onUnsnooze,
}: {
kind: DiscoverCardKind;
title: string;
@@ -22,6 +28,10 @@
state: DiscoverCardState;
attribution?: string;
onRequest?: () => void;
// Omit both to get a card with no snooze affordance — the Lidarr search
// results reuse this component and have nothing to park.
onSnooze?: () => void;
onUnsnooze?: () => void;
} = $props();
const FallbackIcon = $derived(
@@ -65,20 +75,50 @@
<div class="badge-row" data-testid="badge-row">
{#if state === 'kept'}
<span class="kept-pill" role="status">Kept</span>
{:else if state === 'snoozed'}
<span class="snoozed-pill" role="status">Not right now</span>
{/if}
</div>
</div>
<div class="actions pt-3" data-testid="actions">
{#if state === 'requestable'}
{#if state === 'snoozed'}
<button
type="button"
aria-label={`Request ${title}`}
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
onclick={handleRequest}
aria-label={`Bring ${title} back`}
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze?.()}
>
<Plus size={16} strokeWidth={1} /> Request
Undo
</button>
{:else if state === 'requestable'}
<div class="flex items-center gap-2">
<button
type="button"
aria-label={`Request ${title}`}
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
onclick={handleRequest}
>
<Plus size={16} strokeWidth={1} /> Request
</button>
{#if onSnooze}
<!--
Icon-only to keep Request unambiguously the primary action, with
the intent carried by the accessible name. "Not right now" is the
whole point of the wording: this parks a suggestion, it does not
record an opinion about the artist (rule #101).
-->
<button
type="button"
aria-label={`Not right now — hide ${title} for a while`}
title="Not right now"
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onSnooze?.()}
>
<Clock size={16} strokeWidth={1} />
</button>
{/if}
</div>
{:else if state === 'kept'}
<button
type="button"
@@ -131,4 +171,20 @@
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
color: var(--fs-accent);
}
/* Muted rather than accented: a parked card should recede, not compete
with the live suggestions around it. Same geometry as .kept-pill so the
two read as one component in different states. */
.snoozed-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 14px;
background: var(--fs-slate);
color: var(--fs-vellum);
}
.card[data-state='snoozed'] {
opacity: 0.6;
}
</style>
@@ -125,4 +125,47 @@ describe('DiscoverResultCard', () => {
});
expect(screen.queryByTestId('attribution')).not.toBeInTheDocument();
});
// --- snooze affordance (#2375) ---
test('snooze button appears only when onSnooze is supplied', () => {
// The Lidarr search results reuse this card and have nothing to park, so
// the affordance must not appear unconditionally.
render(DiscoverResultCard, {
props: { kind: 'artist', title: 'Outsider', state: 'requestable' }
});
expect(screen.queryByRole('button', { name: /not right now/i })).not.toBeInTheDocument();
});
test('snooze button calls onSnooze and reads as "not right now", never as a dislike', async () => {
const onSnooze = vi.fn();
render(DiscoverResultCard, {
props: { kind: 'artist', title: 'Outsider', state: 'requestable', onSnooze }
});
const btn = screen.getByRole('button', { name: /not right now — hide outsider for a while/i });
// Rule #101: the accessible name must carry no verdict on the music.
expect(btn.getAttribute('aria-label')).not.toMatch(/dislike|not for me|never|hate/i);
await fireEvent.click(btn);
expect(onSnooze).toHaveBeenCalledOnce();
});
test('snoozed state swaps Request for Undo and shows a status pill', async () => {
const onUnsnooze = vi.fn();
const onRequest = vi.fn();
render(DiscoverResultCard, {
props: {
kind: 'artist',
title: 'Outsider',
state: 'snoozed',
onRequest,
onUnsnooze
}
});
expect(screen.queryByRole('button', { name: /request outsider/i })).not.toBeInTheDocument();
const status = screen.getByRole('status');
expect(status.textContent).toMatch(/not right now/i);
await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i }));
expect(onUnsnooze).toHaveBeenCalledOnce();
expect(onRequest).not.toHaveBeenCalled();
});
});
+124 -7
View File
@@ -1,23 +1,49 @@
<script lang="ts">
import { useQueryClient } from '@tanstack/svelte-query';
import { createSuggestionsQuery } from '$lib/api/suggestions';
import {
createSuggestionsQuery,
createSnoozesQuery,
snoozeSuggestion,
unsnoozeSuggestion
} from '$lib/api/suggestions';
import { createRequest } from '$lib/api/requests';
import { qk } from '$lib/api/queries';
import { pushToast } from '$lib/stores/toast.svelte';
import DiscoverResultCard from './DiscoverResultCard.svelte';
import type { ArtistSuggestion, SeedContribution } from '$lib/api/types';
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '$lib/api/types';
const client = useQueryClient();
const queryStore = createSuggestionsQuery();
const query = $derived($queryStore);
const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]);
const snoozeStore = createSnoozesQuery();
const snoozeQuery = $derived($snoozeStore);
const snoozes = $derived((snoozeQuery.data ?? []) as SuggestionSnooze[]);
// Track MBIDs the user just requested so the card flips immediately.
let optimisticRequested = $state(new Set<string>());
// Snoozed-just-now MBIDs. These keep their card in place showing an Undo,
// rather than yanking it out of the grid under the cursor — the card is
// gone on the next refetch, and the snoozed list below is the way back
// after that.
let optimisticSnoozed = $state(new Set<string>());
function visible(s: ArtistSuggestion): boolean {
return !optimisticRequested.has(s.mbid);
}
function cardState(s: ArtistSuggestion): 'requestable' | 'snoozed' {
return optimisticSnoozed.has(s.mbid) ? 'snoozed' : 'requestable';
}
function withMbid(set: Set<string>, mbid: string, present: boolean): Set<string> {
const next = new Set(set);
if (present) next.add(mbid);
else next.delete(mbid);
return next;
}
function attributionText(attribution: SeedContribution[]): string {
if (attribution.length === 0) return '';
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
@@ -32,6 +58,19 @@
return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`;
}
// "in 3 months" / "in 12 days" — a relative phrase, because the exact
// calendar date of a 90-day snooze is noise the user never asked for.
function returnsIn(snoozedUntil: string): string {
const ms = new Date(snoozedUntil).getTime() - Date.now();
if (!Number.isFinite(ms) || ms <= 0) return 'shortly';
const days = Math.round(ms / 86_400_000);
if (days < 1) return 'today';
if (days === 1) return 'tomorrow';
if (days < 45) return `in ${days} days`;
const months = Math.round(days / 30);
return months === 1 ? 'in about a month' : `in about ${months} months`;
}
async function onRequest(s: ArtistSuggestion) {
try {
await createRequest({
@@ -39,9 +78,7 @@
lidarr_artist_mbid: s.mbid,
artist_name: s.name
});
const next = new Set(optimisticRequested);
next.add(s.mbid);
optimisticRequested = next;
optimisticRequested = withMbid(optimisticRequested, s.mbid, true);
// The server-side filter hides this candidate on next refetch.
await client.invalidateQueries({ queryKey: qk.suggestions() });
} catch {
@@ -49,6 +86,39 @@
// stays requestable so the user can retry.
}
}
async function onSnooze(s: ArtistSuggestion) {
// Flip first so the tap feels instant, then reconcile. On failure the
// card goes back to requestable and says so — a snooze that silently
// did nothing would leave the user tapping it again.
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, true);
try {
await snoozeSuggestion(s.mbid, s.name);
await client.invalidateQueries({ queryKey: qk.suggestionSnoozes() });
} catch {
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, false);
pushToast(`Couldn't hide ${s.name}`, 'error');
}
}
async function onUnsnooze(mbid: string, name: string) {
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, false);
try {
await unsnoozeSuggestion(mbid);
} catch (e) {
// 404 means it wasn't snoozed after all — the user's intended end
// state, so it isn't an error worth showing them.
if ((e as { status?: number })?.status !== 404) {
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, true);
pushToast(`Couldn't bring ${name} back`, 'error');
return;
}
}
await Promise.all([
client.invalidateQueries({ queryKey: qk.suggestionSnoozes() }),
client.invalidateQueries({ queryKey: qk.suggestions() })
]);
}
</script>
<div>
@@ -58,7 +128,16 @@
</header>
{#if !query.isPending && suggestions.length === 0}
<p class="text-text-secondary">Listen to something or like an artist to start getting suggestions.</p>
<!--
An empty deck used to mean one thing — no listening signal yet. With
snoozing it can also mean "you parked them all", and telling that user to
go listen to something would be wrong advice.
-->
<p class="text-text-secondary">
{snoozes.length === 0
? 'Listen to something or like an artist to start getting suggestions.'
: "Nothing new right now — the artists you've parked are below."}
</p>
{:else if suggestions.length > 0}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each suggestions.filter(visible) as s (s.mbid)}
@@ -66,11 +145,49 @@
kind="artist"
title={s.name}
imageUrl={s.image_url}
state="requestable"
state={cardState(s)}
attribution={attributionText(s.attribution)}
onRequest={() => onRequest(s)}
onSnooze={() => onSnooze(s)}
onUnsnooze={() => onUnsnooze(s.mbid, s.name)}
/>
{/each}
</div>
{/if}
<!--
The snoozed list is not a nicety: a parked suggestion is by definition
absent from the deck above, so without this there is no way back. Only
rendered when non-empty, so the surface stays quiet for the common case.
-->
{#if snoozes.length > 0}
<section class="mt-8 border-t border-border pt-6" aria-labelledby="snoozed-heading">
<h3 id="snoozed-heading" class="font-display text-lg font-medium text-text-primary">
Not right now
</h3>
<p class="mt-1 text-sm text-text-secondary">
These come back on their own. Nothing here counts against your taste profile.
</p>
<ul class="mt-3 divide-y divide-border">
{#each snoozes as snoozed (snoozed.mbid)}
<li class="flex items-center justify-between gap-4 py-2">
<div class="min-w-0">
<div class="truncate text-sm text-text-primary">{snoozed.name}</div>
<div class="text-xs text-text-secondary">
Back {returnsIn(snoozed.snoozed_until)}
</div>
</div>
<button
type="button"
aria-label={`Bring ${snoozed.name} back now`}
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
onclick={() => onUnsnooze(snoozed.mbid, snoozed.name)}
>
Bring back
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
+142 -20
View File
@@ -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();
});
});