diff --git a/web/package-lock.json b/web/package-lock.json index 9e5f666c..04e47889 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,7 +8,8 @@ "name": "minstrel-web", "version": "0.0.0", "dependencies": { - "@tanstack/svelte-query": "^5.90.2" + "@tanstack/svelte-query": "^5.90.2", + "lucide-svelte": "^1.0.1" }, "devDependencies": { "@sveltejs/adapter-static": "^3.0.6", @@ -2637,6 +2638,15 @@ "dev": true, "license": "ISC" }, + "node_modules/lucide-svelte": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", + "integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==", + "license": "ISC", + "peerDependencies": { + "svelte": "^3 || ^4 || ^5.0.0-next.42" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/web/package.json b/web/package.json index 3c8559f9..450868d4 100644 --- a/web/package.json +++ b/web/package.json @@ -28,6 +28,7 @@ "vitest": "^2.1.4" }, "dependencies": { - "@tanstack/svelte-query": "^5.90.2" + "@tanstack/svelte-query": "^5.90.2", + "lucide-svelte": "^1.0.1" } } diff --git a/web/src/lib/components/DiscoverResultCard.svelte b/web/src/lib/components/DiscoverResultCard.svelte new file mode 100644 index 00000000..e6ae477d --- /dev/null +++ b/web/src/lib/components/DiscoverResultCard.svelte @@ -0,0 +1,128 @@ + + + + +
+
+ {#if imageUrl} + + {:else} + + {/if} +
+ +
+
{title}
+ {#if subtitle} +
{subtitle}
+ {/if} +
+ {#if state === 'kept'} + Kept + {/if} +
+
+ +
+ {#if state === 'requestable'} + + {:else if state === 'kept'} + + {:else} + + {/if} +
+
+ + 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(); + }); +});