From b290b4ff0ba850eaec4baeb31daad36c43c84daf Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 4 May 2026 19:42:30 -0400 Subject: [PATCH] feat(web/m7-380): animate pending placeholder + add play overlay to PlaylistCard --- web/src/lib/components/PlaylistCard.svelte | 98 ++++++++++++++----- web/src/lib/components/PlaylistCard.test.ts | 81 ++++++++++++++- .../components/PlaylistPlaceholderCard.svelte | 7 +- .../PlaylistPlaceholderCard.test.ts | 6 +- 4 files changed, 156 insertions(+), 36 deletions(-) diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte index f3af7dd2..c1a90946 100644 --- a/web/src/lib/components/PlaylistCard.svelte +++ b/web/src/lib/components/PlaylistCard.svelte @@ -1,38 +1,82 @@ - - - +
+
{playlist.name}
+
+ {playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'} + {#if !isOwn} + · by {playlist.owner_username} + {/if} +
+
+
+ + + diff --git a/web/src/lib/components/PlaylistCard.test.ts b/web/src/lib/components/PlaylistCard.test.ts index 03700750..5aae566f 100644 --- a/web/src/lib/components/PlaylistCard.test.ts +++ b/web/src/lib/components/PlaylistCard.test.ts @@ -1,13 +1,48 @@ -import { describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; import PlaylistCard from './PlaylistCard.svelte'; -import type { Playlist } from '$lib/api/types'; +import type { Playlist, PlaylistDetail } from '$lib/api/types'; vi.mock('$lib/auth/store.svelte', () => ({ user: { value: { id: 'u-self', username: 'me', is_admin: false } } })); -vi.mock('$app/navigation', () => ({ goto: vi.fn() })); +vi.mock('$lib/api/playlists', () => ({ + getPlaylist: vi.fn().mockResolvedValue({ + id: 'p-1', + user_id: 'u-self', + owner_username: 'me', + name: 'Saturday morning', + description: '', + is_public: false, + kind: 'user', + system_variant: null, + seed_artist_id: null, + cover_url: '', + track_count: 1, + duration_sec: 60, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + tracks: [ + { + position: 0, + track_id: 't-1', + album_id: 'al-1', + artist_id: 'ar-1', + title: 'Test Track', + artist_name: 'Test Artist', + album_title: 'Test Album', + duration_sec: 60, + stream_url: '/s/t-1', + added_at: '2026-01-01T00:00:00Z' + } + ] + } as PlaylistDetail) +})); + +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn() +})); const base: Playlist = { id: 'p-1', @@ -27,6 +62,10 @@ const base: Playlist = { }; describe('PlaylistCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + test('renders own playlist without owner attribution', () => { render(PlaylistCard, { props: { playlist: base } }); expect(screen.getByText('Saturday morning')).toBeInTheDocument(); @@ -34,6 +73,12 @@ describe('PlaylistCard', () => { expect(screen.queryByText(/by /)).not.toBeInTheDocument(); }); + test('navigates to playlist when clicking the card', () => { + render(PlaylistCard, { props: { playlist: base } }); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/playlists/p-1'); + }); + test('renders cover image when cover_url is set', () => { // The has alt="" (decorative — the playlist name is in the // sibling text below, so duplicating it for screen readers would be @@ -60,4 +105,32 @@ describe('PlaylistCard', () => { render(PlaylistCard, { props: { playlist: { ...base, track_count: 1 } } }); expect(screen.getByText(/1\s+track\b/i)).toBeInTheDocument(); }); + + test('play button exists with correct aria label', () => { + render(PlaylistCard, { props: { playlist: base } }); + const playButton = screen.getByLabelText(/Play Saturday morning/i); + expect(playButton).toBeInTheDocument(); + }); + + test('play button is disabled when track_count is 0', () => { + render(PlaylistCard, { props: { playlist: { ...base, track_count: 0 } } }); + const playButton = screen.getByLabelText(/Play Saturday morning/i); + expect(playButton).toBeDisabled(); + }); + + test('play button calls playQueue with tracks when clicked', async () => { + const { getPlaylist } = await import('$lib/api/playlists'); + const { playQueue } = await import('$lib/player/store.svelte'); + + render(PlaylistCard, { props: { playlist: base } }); + const playButton = screen.getByLabelText(/Play Saturday morning/i); + + await fireEvent.click(playButton); + + // Allow promises to resolve + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(getPlaylist).toHaveBeenCalledWith('p-1'); + expect(playQueue).toHaveBeenCalled(); + }); }); diff --git a/web/src/lib/components/PlaylistPlaceholderCard.svelte b/web/src/lib/components/PlaylistPlaceholderCard.svelte index 9fc28abb..090b8539 100644 --- a/web/src/lib/components/PlaylistPlaceholderCard.svelte +++ b/web/src/lib/components/PlaylistPlaceholderCard.svelte @@ -20,12 +20,13 @@ } }); - // animate-pulse on the cover area only when in_flight (variant='building'). - // Other variants are static so failure/empty states don't read as "active". + // animate-pulse on the cover area when variant is 'building' or 'pending'. + // 'pending' shows the animation since it's awaiting results within 24h. + // 'failed' and 'seed-needed' are static terminal states. const coverClasses = $derived( 'aspect-square w-full overflow-hidden rounded-md bg-surface-hover ' + 'flex items-center justify-center text-text-muted ' + - (variant === 'building' ? 'animate-pulse' : '') + (variant === 'building' || variant === 'pending' ? 'animate-pulse' : '') ); diff --git a/web/src/lib/components/PlaylistPlaceholderCard.test.ts b/web/src/lib/components/PlaylistPlaceholderCard.test.ts index b7123d7d..1d92a4cb 100644 --- a/web/src/lib/components/PlaylistPlaceholderCard.test.ts +++ b/web/src/lib/components/PlaylistPlaceholderCard.test.ts @@ -27,8 +27,10 @@ describe('PlaylistPlaceholderCard', () => { expect(screen.getByText(/Listen to more variety/i)).toBeInTheDocument(); }); - test('pending variant (default) shows the within-24h copy', () => { - render(PlaylistPlaceholderCard, { props: { label: 'For You' } }); + test('pending variant (default) shows within-24h copy and pulses', () => { + const { container } = render(PlaylistPlaceholderCard, { props: { label: 'For You' } }); expect(screen.getByText(/within 24h/i)).toBeInTheDocument(); + const cover = container.querySelector('[data-variant="pending"] .aspect-square'); + expect(cover?.className).toMatch(/animate-pulse/); }); });