From c8bd19d6f1462d7a73ba046349405bd74bd3a561 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 20:27:58 -0400 Subject: [PATCH] feat(web): add DiscoverResultCard with reserved badge slot + anchored button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 diff --git a/web/src/lib/components/DiscoverResultCard.test.ts b/web/src/lib/components/DiscoverResultCard.test.ts new file mode 100644 index 00000000..6672349c --- /dev/null +++ b/web/src/lib/components/DiscoverResultCard.test.ts @@ -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 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 ) 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 ; verify its presence as a proxy for "fallback rendered" + expect(container.querySelector('svg')).toBeInTheDocument(); + }); +});