import { describe, expect, test, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; import PlaylistCard from './PlaylistCard.svelte'; 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('$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), refreshDiscover: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5 }), refreshForYou: vi.fn().mockResolvedValue({ playlist_id: 'p-new', track_count: 25, track_ids: ['t-1'] }) })); vi.mock('$lib/player/store.svelte', () => ({ playQueue: vi.fn() })); vi.mock('$lib/api/queries', () => ({ qk: { playlist: (id: string) => ['playlist', id], playlists: (kind?: string) => ['playlists', { kind: kind ?? 'user' }] } })); const base: Playlist = { 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: 12, duration_sec: 0, created_at: '', updated_at: '' }; describe('PlaylistCard', () => { beforeEach(() => { vi.clearAllMocks(); }); 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('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 // noise). Empty alt makes Testing Library expose role="presentation" // instead of role="img"; query the DOM directly so the assertion // doesn't fight the (correct) a11y choice. const { container } = render(PlaylistCard, { props: { playlist: { ...base, cover_url: '/api/playlists/p-1/cover' } } }); const img = container.querySelector('img'); expect(img).not.toBeNull(); 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(); }); 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(); }); test('shows kebab button only on Discover playlists', () => { const discoverPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'discover', name: 'Discover' }; render(PlaylistCard, { props: { playlist: discoverPlaylist } }); expect(screen.getByLabelText(/playlist actions/i)).toBeInTheDocument(); }); test('does not show kebab button on for_you playlists', () => { const forYouPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'for_you', name: 'For You' }; render(PlaylistCard, { props: { playlist: forYouPlaylist } }); expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument(); }); test('does not show kebab button on user playlists', () => { render(PlaylistCard, { props: { playlist: base } }); expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument(); }); test('Refresh Discover item appears in kebab menu when opened', async () => { const discoverPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'discover', name: 'Discover' }; render(PlaylistCard, { props: { playlist: discoverPlaylist } }); const kebabBtn = screen.getByLabelText(/playlist actions/i); await fireEvent.click(kebabBtn); expect(screen.getByRole('menuitem', { name: /refresh discover/i })).toBeInTheDocument(); }); test('Refresh Discover item is absent from for_you card kebab', () => { const forYouPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'for_you', name: 'For You' }; render(PlaylistCard, { props: { playlist: forYouPlaylist } }); expect(screen.queryByRole('menuitem', { name: /refresh discover/i })).not.toBeInTheDocument(); }); test('clicking Refresh Discover calls refreshDiscover', async () => { const { refreshDiscover } = await import('$lib/api/playlists'); const discoverPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'discover', name: 'Discover' }; render(PlaylistCard, { props: { playlist: discoverPlaylist } }); const kebabBtn = screen.getByLabelText(/playlist actions/i); await fireEvent.click(kebabBtn); const refreshItem = screen.getByRole('menuitem', { name: /refresh discover/i }); await fireEvent.click(refreshItem); await waitFor(() => expect(refreshDiscover).toHaveBeenCalled()); }); test('For-You play button calls refreshForYou before getPlaylist', async () => { const { refreshForYou, getPlaylist } = await import('$lib/api/playlists'); const { playQueue } = await import('$lib/player/store.svelte'); const forYouPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'for_you', name: 'For You', track_count: 25 }; // getPlaylist should return a detail with the NEW id (getPlaylist as ReturnType).mockResolvedValueOnce({ id: 'p-new', user_id: 'u-self', owner_username: 'me', name: 'For You', description: '', is_public: false, kind: 'system', system_variant: 'for_you', 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); render(PlaylistCard, { props: { playlist: forYouPlaylist } }); const playButton = screen.getByLabelText(/Play For You/i); await fireEvent.click(playButton); await new Promise(resolve => setTimeout(resolve, 10)); expect(refreshForYou).toHaveBeenCalled(); expect(getPlaylist).toHaveBeenCalledWith('p-new'); expect(playQueue).toHaveBeenCalled(); }); test('For-You play with empty-library response does not call playQueue', async () => { const { refreshForYou } = await import('$lib/api/playlists'); const { playQueue } = await import('$lib/player/store.svelte'); (refreshForYou as ReturnType).mockResolvedValueOnce({ playlist_id: null, track_count: 0, track_ids: [] }); const forYouPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'for_you', name: 'For You', track_count: 1 }; render(PlaylistCard, { props: { playlist: forYouPlaylist } }); const playButton = screen.getByLabelText(/Play For You/i); await fireEvent.click(playButton); await new Promise(resolve => setTimeout(resolve, 10)); expect(refreshForYou).toHaveBeenCalled(); expect(playQueue).not.toHaveBeenCalled(); }); test('non-For-You play button does NOT call refreshForYou', async () => { const { refreshForYou, 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); await new Promise(resolve => setTimeout(resolve, 10)); expect(refreshForYou).not.toHaveBeenCalled(); expect(getPlaylist).toHaveBeenCalledWith('p-1'); expect(playQueue).toHaveBeenCalled(); }); });