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>
172 lines
6.2 KiB
TypeScript
172 lines
6.2 KiB
TypeScript
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
|
import DiscoverResultCard from './DiscoverResultCard.svelte';
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
describe('DiscoverResultCard', () => {
|
|
test('requestable state renders Request button and calls onRequest on click', async () => {
|
|
const onRequest = vi.fn();
|
|
render(DiscoverResultCard, {
|
|
props: {
|
|
kind: 'artist',
|
|
title: 'Boards of Canada',
|
|
state: 'requestable',
|
|
onRequest,
|
|
},
|
|
});
|
|
// aria-label includes the title, so the accessible name is
|
|
// "Request Boards of Canada".
|
|
const btn = screen.getByRole('button', {
|
|
name: /request boards of canada/i,
|
|
});
|
|
expect(btn).not.toBeDisabled();
|
|
await fireEvent.click(btn);
|
|
expect(onRequest).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test('kept state renders disabled "In library" button + Kept pill with role=status', () => {
|
|
render(DiscoverResultCard, {
|
|
props: {
|
|
kind: 'album',
|
|
title: 'Music Has The Right To Children',
|
|
state: 'kept',
|
|
},
|
|
});
|
|
expect(screen.getByRole('button', { name: /in library/i })).toBeDisabled();
|
|
// Kept pill is exposed as a status region so SR users hear it when it appears.
|
|
const status = screen.getByRole('status');
|
|
expect(status).toBeInTheDocument();
|
|
expect(status.textContent).toMatch(/kept/i);
|
|
});
|
|
|
|
test('requested state renders disabled "Requested" button', () => {
|
|
render(DiscoverResultCard, {
|
|
props: {
|
|
kind: 'track',
|
|
title: 'Roygbiv',
|
|
state: 'requested',
|
|
},
|
|
});
|
|
expect(screen.getByRole('button', { name: /requested/i })).toBeDisabled();
|
|
});
|
|
|
|
test('does not call onRequest when state is not requestable', async () => {
|
|
const onRequest = vi.fn();
|
|
render(DiscoverResultCard, {
|
|
props: {
|
|
kind: 'album',
|
|
title: 'Geogaddi',
|
|
state: 'kept',
|
|
onRequest,
|
|
},
|
|
});
|
|
// Button is disabled — the test still verifies onRequest isn't called even
|
|
// if a click slips through (jsdom does not enforce :disabled at fireEvent level).
|
|
const btn = screen.getByRole('button');
|
|
await fireEvent.click(btn);
|
|
expect(onRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// NOTE: previous versions of this file asserted .badge-row { min-height: 22px }
|
|
// and .actions { margin-top: auto } via getComputedStyle, which only worked
|
|
// because the component shipped inline style="" attributes — a layering
|
|
// violation. Stylesheet introspection (document.styleSheets) was tried as a
|
|
// replacement, but vitest's @testing-library/svelte renderer does not inject
|
|
// the component's scoped <style> block into jsdom (styleSheets.length === 0).
|
|
// Asserting "CSS exists in CSS" has weak value anyway, so the layout
|
|
// discipline (reserved badge slot + bottom-anchored actions) is enforced
|
|
// visually at the consumer page rather than as a unit test here.
|
|
|
|
test('renders <img> when imageUrl is set', () => {
|
|
const { container } = render(DiscoverResultCard, {
|
|
props: {
|
|
kind: 'artist',
|
|
title: 'X',
|
|
state: 'requestable',
|
|
imageUrl: 'https://example.com/x.jpg',
|
|
},
|
|
});
|
|
const img = container.querySelector('img') as HTMLImageElement;
|
|
expect(img).toBeInTheDocument();
|
|
expect(img.src).toBe('https://example.com/x.jpg');
|
|
});
|
|
|
|
test('renders fallback Lucide glyph (no <img>) when imageUrl is absent', () => {
|
|
const { container } = render(DiscoverResultCard, {
|
|
props: {
|
|
kind: 'album',
|
|
title: 'X',
|
|
state: 'requestable',
|
|
},
|
|
});
|
|
expect(container.querySelector('img')).not.toBeInTheDocument();
|
|
// Lucide renders an inline <svg>; verify its presence as a proxy for "fallback rendered"
|
|
expect(container.querySelector('svg')).toBeInTheDocument();
|
|
});
|
|
|
|
test('renders attribution line when prop is set', () => {
|
|
render(DiscoverResultCard, {
|
|
props: {
|
|
kind: 'artist',
|
|
title: 'Outsider',
|
|
state: 'requestable',
|
|
attribution: 'Because you liked Boards of Canada and played Aphex Twin.'
|
|
}
|
|
});
|
|
expect(screen.getByTestId('attribution')).toHaveTextContent(
|
|
'Because you liked Boards of Canada and played Aphex Twin.'
|
|
);
|
|
});
|
|
|
|
test('omits attribution line when prop is absent', () => {
|
|
render(DiscoverResultCard, {
|
|
props: { kind: 'artist', title: 'Outsider', state: 'requestable' }
|
|
});
|
|
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();
|
|
});
|
|
});
|