-
{playlist.name}
-
- {playlist.track_count} {playlist.track_count === 1 ? 'track' : 'tracks'}
- {#if !isOwn}
- · by {playlist.owner_username}
+
-
+
+
{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/);
});
});