import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import type { TrackRef } from '$lib/api/types'; import { emptyLikesMock } from '../../test-utils/mocks/likes'; import { emptyQuarantineMock } from '../../test-utils/mocks/quarantine'; // Mutable state the mocked store reads from. const state = vi.hoisted(() => ({ current: undefined as TrackRef | undefined, queue: [] as TrackRef[], index: 0, state: 'idle' as 'idle' | 'loading' | 'playing' | 'paused' | 'error', position: 0, duration: 0, volume: 1, shuffle: false, repeat: 'off' as 'off' | 'all' | 'one', error: null as string | null, queueDrawerOpen: false })); vi.mock('$lib/player/store.svelte', () => ({ player: { get queue() { return state.queue; }, get index() { return state.index; }, get current() { return state.current; }, get state() { return state.state; }, get isPlaying() { return state.state === 'playing'; }, get position() { return state.position; }, get duration() { return state.duration; }, get volume() { return state.volume; }, get shuffle() { return state.shuffle; }, get repeat() { return state.repeat; }, get error() { return state.error; }, get queueDrawerOpen() { return state.queueDrawerOpen; } }, togglePlay: vi.fn(), skipNext: vi.fn(), skipPrev: vi.fn(), seekTo: vi.fn(), setVolume: vi.fn(), toggleShuffle: vi.fn(), cycleRepeat: vi.fn(), playQueue: vi.fn(), toggleQueueDrawer: vi.fn() })); vi.mock('$lib/api/likes', () => emptyLikesMock()); vi.mock('$lib/api/quarantine', () => emptyQuarantineMock()); import PlayerBar from './PlayerBar.svelte'; import { togglePlay, skipNext, skipPrev, seekTo, setVolume, toggleShuffle, cycleRepeat, playQueue, toggleQueueDrawer } from '$lib/player/store.svelte'; function track(): TrackRef { return { id: 't1', title: 'So What', album_id: 'xyz', album_title: 'Kind of Blue', artist_id: 'md', artist_name: 'Miles Davis', track_number: 1, disc_number: 1, duration_sec: 545, stream_url: '/api/tracks/t1/stream' }; } beforeEach(() => { state.queue = [track()]; state.current = state.queue[0]; state.index = 0; state.state = 'paused'; state.position = 42; state.duration = 545; state.volume = 0.8; state.shuffle = false; state.repeat = 'off'; state.error = null; state.queueDrawerOpen = false; }); afterEach(() => vi.clearAllMocks()); describe('PlayerBar', () => { test('renders title, linked artist, linked cover', () => { render(PlayerBar); expect(screen.getByText('So What')).toBeInTheDocument(); expect(screen.getByRole('link', { name: /Miles Davis/ })).toHaveAttribute('href', '/artists/md'); const cover = screen.getByRole('link', { name: /open album/i }); expect(cover).toHaveAttribute('href', '/albums/xyz'); }); test('play button click calls togglePlay', async () => { render(PlayerBar); // Anchored regex — "Player options" (the new overflow ⋮) also matches // /play|pause/i otherwise. await fireEvent.click(screen.getByRole('button', { name: /^(play|pause)$/i })); expect(togglePlay).toHaveBeenCalledTimes(1); }); test('skip-next click calls skipNext; skip-prev click calls skipPrev', async () => { render(PlayerBar); await fireEvent.click(screen.getByRole('button', { name: /next/i })); await fireEvent.click(screen.getByRole('button', { name: /previous/i })); expect(skipNext).toHaveBeenCalledTimes(1); expect(skipPrev).toHaveBeenCalledTimes(1); }); test('skip-prev disabled when index=0 AND position<3', () => { state.index = 0; state.position = 1; render(PlayerBar); expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled(); }); test('skip-next disabled at end with repeat=off', () => { state.queue = [track()]; state.index = 0; state.repeat = 'off'; render(PlayerBar); expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); }); test('seek slider input calls seekTo(value)', async () => { render(PlayerBar); const slider = screen.getByLabelText(/seek/i) as HTMLInputElement; await fireEvent.input(slider, { target: { value: '120' } }); expect(seekTo).toHaveBeenCalledWith(120); }); test('volume slider input calls setVolume(value)', async () => { render(PlayerBar); const slider = screen.getByLabelText(/volume/i) as HTMLInputElement; await fireEvent.input(slider, { target: { value: '0.3' } }); expect(setVolume).toHaveBeenCalledWith(0.3); }); test('shuffle button click calls toggleShuffle; active class reflects shuffle', () => { state.shuffle = true; render(PlayerBar); const btn = screen.getByRole('button', { name: /shuffle/i }); expect(btn.getAttribute('aria-pressed')).toBe('true'); }); test('repeat button aria-label reflects current mode', () => { state.repeat = 'one'; render(PlayerBar); expect(screen.getByRole('button', { name: /repeat one/i })).toBeInTheDocument(); }); test('loading state shows spinner in place of play icon', () => { state.state = 'loading'; render(PlayerBar); expect(screen.getByTestId('play-spinner')).toBeInTheDocument(); }); test('error state renders retry card; retry click calls playQueue(queue, index)', async () => { state.state = 'error'; state.error = 'Playback failed.'; render(PlayerBar); expect(screen.getByText(/playback failed/i)).toBeInTheDocument(); await fireEvent.click(screen.getByRole('button', { name: /try again/i })); expect(playQueue).toHaveBeenCalledWith(state.queue, state.index); }); test('elapsed and total render formatted duration', () => { state.position = 65; state.duration = 245; render(PlayerBar); expect(screen.getByText('1:05')).toBeInTheDocument(); expect(screen.getByText('4:05')).toBeInTheDocument(); }); test('renders the TrackMenu kebab button when a track is current', () => { render(PlayerBar); expect( screen.getByRole('button', { name: /track actions for/i }) ).toBeInTheDocument(); }); describe('queue toggle button', () => { test('clicking the queue toggle calls toggleQueueDrawer', async () => { render(PlayerBar); const btn = screen.getByLabelText(/(open|close) queue/i); await fireEvent.click(btn); expect(toggleQueueDrawer).toHaveBeenCalled(); }); test('aria-pressed reflects queueDrawerOpen state', () => { state.queueDrawerOpen = true; render(PlayerBar); const btn = screen.getByLabelText(/close queue/i); expect(btn).toHaveAttribute('aria-pressed', 'true'); }); }); describe('"Up next" line', () => { test('renders the next track title when index + 1 < queue.length', () => { state.queue = [ { id: 'a', title: 'Track A', artist_name: 'Art A' } as TrackRef, { id: 'b', title: 'Track B', artist_name: 'Art B' } as TrackRef ]; state.index = 0; state.current = state.queue[0]; render(PlayerBar); expect(screen.getByText(/track b/i)).toBeInTheDocument(); }); test('omits the line on the last track in queue', () => { state.queue = [ { id: 'a', title: 'Track A', artist_name: 'Art A' } as TrackRef ]; state.index = 0; state.current = state.queue[0]; render(PlayerBar); expect(screen.queryByText(/^Next ·/i)).not.toBeInTheDocument(); }); test('omits the line when queue is empty', () => { state.queue = []; state.current = undefined; state.index = 0; render(PlayerBar); expect(screen.queryByText(/^Next ·/i)).not.toBeInTheDocument(); }); }); });