Files
minstrel/web/src/lib/components/DiscoverResultCard.test.ts
T
bvandeusenandClaude Opus 5 6e39471a70
test-web / test (push) Failing after 37s
android / Build + lint + test (push) Failing after 1m42s
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>
2026-08-02 19:03:10 -04:00

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();
});
});