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, refreshable: false, 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), systemShuffle: vi.fn().mockResolvedValue({ id: 'p-sys', user_id: 'u-self', owner_username: 'me', name: 'For You', description: '', is_public: false, kind: 'system', system_variant: 'for_you', refreshable: true, 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-9', album_id: 'al-1', artist_id: 'ar-1', title: 'Rotation Track', artist_name: 'Test Artist', album_title: 'Test Album', duration_sec: 60, stream_url: '/s/t-9', added_at: '2026-01-01T00:00:00Z' } ] } as PlaylistDetail), refreshSystem: vi.fn().mockResolvedValue({ playlist_id: 'p-1', track_count: 5, 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, refreshable: false, 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 on refreshable (singleton system) playlists', () => { const discoverPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'discover', refreshable: true, name: 'Discover' }; render(PlaylistCard, { props: { playlist: discoverPlaylist } }); expect(screen.getByLabelText(/playlist actions/i)).toBeInTheDocument(); }); test('does not show kebab on user playlists', () => { render(PlaylistCard, { props: { playlist: base } }); expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument(); }); test('does not show kebab on a non-refreshable system playlist', () => { // e.g. songs_like_artist — system but multi-per-user, server // reports refreshable:false. const songsLike: Playlist = { ...base, kind: 'system', system_variant: 'songs_like_artist', refreshable: false, name: 'Songs like X' }; render(PlaylistCard, { props: { playlist: songsLike } }); expect(screen.queryByLabelText(/playlist actions/i)).not.toBeInTheDocument(); }); test('Refresh item appears in the kebab, labelled by name', async () => { const discoverPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'discover', refreshable: true, name: 'Discover' }; render(PlaylistCard, { props: { playlist: discoverPlaylist } }); await fireEvent.click(screen.getByLabelText(/playlist actions/i)); expect(screen.getByRole('menuitem', { name: /refresh discover/i })).toBeInTheDocument(); }); test('clicking Refresh calls refreshSystem with the raw variant', async () => { const { refreshSystem } = await import('$lib/api/playlists'); const discoverPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'discover', refreshable: true, name: 'Discover' }; render(PlaylistCard, { props: { playlist: discoverPlaylist } }); await fireEvent.click(screen.getByLabelText(/playlist actions/i)); await fireEvent.click(screen.getByRole('menuitem', { name: /refresh discover/i })); await waitFor(() => expect(refreshSystem).toHaveBeenCalledWith('discover')); }); test('For-You play calls systemShuffle, not getPlaylist', async () => { const { getPlaylist, systemShuffle } = await import('$lib/api/playlists'); const { playQueue } = await import('$lib/player/store.svelte'); const forYouPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'for_you', refreshable: true, name: 'For You', track_count: 25 }; render(PlaylistCard, { props: { playlist: forYouPlaylist } }); const playButton = screen.getByLabelText(/Play For You/i); await fireEvent.click(playButton); await new Promise(resolve => setTimeout(resolve, 10)); // #415/#411: system play hits the rotation-aware shuffle endpoint // (raw variant), not the cached detail GET, and never refreshes. expect(systemShuffle).toHaveBeenCalledWith('for_you'); expect(getPlaylist).not.toHaveBeenCalled(); expect(playQueue).toHaveBeenCalled(); }); test('For-You play passes source:for_you to playQueue (no client shuffle)', async () => { const { playQueue } = await import('$lib/player/store.svelte'); const forYouPlaylist: Playlist = { ...base, kind: 'system', system_variant: 'for_you', name: 'For You' }; 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(playQueue).toHaveBeenCalledWith( expect.any(Array), 0, expect.objectContaining({ source: 'for_you' }) ); }); test('user-playlist play uses getPlaylist + plain playQueue (no source)', async () => { const { getPlaylist, systemShuffle } = 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(systemShuffle).not.toHaveBeenCalled(); expect(getPlaylist).toHaveBeenCalledWith('p-1'); expect(playQueue).toHaveBeenCalledWith(expect.any(Array), 0); }); test('user-playlist play does not refresh anything', async () => { const { refreshSystem, 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(refreshSystem).not.toHaveBeenCalled(); expect(getPlaylist).toHaveBeenCalledWith('p-1'); expect(playQueue).toHaveBeenCalled(); }); });