From 0eb346e0c6b5b47139c4c5e1591b515012f18f42 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 3 May 2026 11:14:30 -0400 Subject: [PATCH] feat(web): PlaylistCard component for M7 #352 slice 1 Square card with cover (or "No tracks yet" glyph fallback), name, track count, and owner attribution when the playlist isn't the current user's. Click navigates to /playlists/{id}. --- web/src/lib/components/PlaylistCard.svelte | 38 +++++++++++++++ web/src/lib/components/PlaylistCard.test.ts | 54 +++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 web/src/lib/components/PlaylistCard.svelte create mode 100644 web/src/lib/components/PlaylistCard.test.ts diff --git a/web/src/lib/components/PlaylistCard.svelte b/web/src/lib/components/PlaylistCard.svelte new file mode 100644 index 00000000..f3af7dd2 --- /dev/null +++ b/web/src/lib/components/PlaylistCard.svelte @@ -0,0 +1,38 @@ + + + diff --git a/web/src/lib/components/PlaylistCard.test.ts b/web/src/lib/components/PlaylistCard.test.ts new file mode 100644 index 00000000..95258924 --- /dev/null +++ b/web/src/lib/components/PlaylistCard.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import PlaylistCard from './PlaylistCard.svelte'; +import type { Playlist } 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() })); + +const base: Playlist = { + id: 'p-1', + user_id: 'u-self', + owner_username: 'me', + name: 'Saturday morning', + description: '', + is_public: false, + cover_url: '', + track_count: 12, + duration_sec: 0, + created_at: '', + updated_at: '' +}; + +describe('PlaylistCard', () => { + test('renders own playlist without owner attribution', () => { + render(PlaylistCard, { props: { playlist: base } }); + expect(screen.getByText('Saturday morning')).toBeInTheDocument(); + expect(screen.getByText(/12\s+tracks/i)).toBeInTheDocument(); + expect(screen.queryByText(/by /)).not.toBeInTheDocument(); + }); + + test('renders cover image when cover_url is set', () => { + render(PlaylistCard, { props: { playlist: { ...base, cover_url: '/api/playlists/p-1/cover' } } }); + const img = screen.getByRole('img'); + expect(img.getAttribute('src')).toBe('/api/playlists/p-1/cover'); + }); + + test('renders glyph fallback when cover_url is empty', () => { + render(PlaylistCard, { props: { playlist: base } }); + expect(screen.getByText(/no tracks yet/i)).toBeInTheDocument(); + }); + + test("attributes other users' playlists to the owner", () => { + render(PlaylistCard, { props: { playlist: { ...base, user_id: 'u-bob', owner_username: 'bob' } } }); + expect(screen.getByText(/by bob/i)).toBeInTheDocument(); + }); + + test('singular "track" when count is 1', () => { + render(PlaylistCard, { props: { playlist: { ...base, track_count: 1 } } }); + expect(screen.getByText(/1\s+track\b/i)).toBeInTheDocument(); + }); +});