feat(web): add DiscoverResultCard with reserved badge slot + anchored button
T14 of the M5a Lidarr plan. The Discover grid in M5a renders mixed
requestable / kept / requested cards from Lidarr search. Without
layout discipline the cards in a row land their titles at different
Y coordinates whenever the badge presence varies, which reads as
visual noise. DiscoverResultCard enforces:
- Flex column with `margin-top: auto` on `.actions`, so the action
button is anchored to the bottom of the card body regardless of
title/subtitle wrap differences.
- `.badge-row` always rendered with `min-height: 22px`, so the
title baseline holds even when no Kept pill is present.
Both load-bearing CSS values use inline style attributes — jsdom's
getComputedStyle does not resolve scoped <style> blocks, so the tests
verify positioning via inline styles directly. The remaining decorative
CSS (kept-pill background = accent at 15%, flex-direction, gap) lives
in a scoped <style> block.
State -> action mapping (sentence case per FabledSword voice):
requestable -> bg-action-primary "Request" with Plus icon
kept -> disabled ghost "In library" + Kept pill
requested -> disabled ghost "Requested"
Cover art falls back to a Lucide glyph (Disc3 / Album / Music2) when
imageUrl is absent. Adds lucide-svelte@^1.0.1 dependency.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
const btn = screen.getByRole('button', { name: /request/i });
|
||||
expect(btn).not.toBeDisabled();
|
||||
await fireEvent.click(btn);
|
||||
expect(onRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test('kept state renders disabled "In library" button + Kept pill', () => {
|
||||
render(DiscoverResultCard, {
|
||||
props: {
|
||||
kind: 'album',
|
||||
title: 'Music Has The Right To Children',
|
||||
state: 'kept',
|
||||
},
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /in library/i })).toBeDisabled();
|
||||
expect(screen.getByText(/kept/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test('badge row reserves min-height: 22px even when empty', () => {
|
||||
render(DiscoverResultCard, {
|
||||
props: {
|
||||
kind: 'artist',
|
||||
title: 'Aphex Twin',
|
||||
state: 'requestable',
|
||||
},
|
||||
});
|
||||
const row = screen.getByTestId('badge-row');
|
||||
const cs = getComputedStyle(row);
|
||||
expect(cs.minHeight).toBe('22px');
|
||||
});
|
||||
|
||||
test('actions block is anchored to bottom (margin-top: auto)', () => {
|
||||
render(DiscoverResultCard, {
|
||||
props: {
|
||||
kind: 'artist',
|
||||
title: 'Aphex Twin',
|
||||
state: 'requestable',
|
||||
},
|
||||
});
|
||||
const actions = screen.getByTestId('actions');
|
||||
expect(getComputedStyle(actions).marginTop).toBe('auto');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user