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:
2026-04-29 20:27:58 -04:00
co-authored by Claude Opus 4.7
parent ce7424219c
commit c8bd19d6f1
4 changed files with 256 additions and 2 deletions
@@ -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();
});
});