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 { readable } from 'svelte/store'; // 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 })); 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; } }, togglePlay: vi.fn(), skipNext: vi.fn(), skipPrev: vi.fn(), seekTo: vi.fn(), setVolume: vi.fn(), toggleShuffle: vi.fn(), cycleRepeat: vi.fn(), playQueue: vi.fn() })); vi.mock('$lib/api/likes', () => ({ createLikedIdsQuery: () => readable({ data: { track_ids: [], album_ids: [], artist_ids: [] }, isPending: false, isError: false }), likeEntity: vi.fn(), unlikeEntity: vi.fn() })); vi.mock('$lib/api/quarantine', () => ({ flagTrack: vi.fn(), unflagTrack: vi.fn(), listMyQuarantine: vi.fn().mockResolvedValue([]), createMyQuarantineQuery: vi.fn() })); vi.mock('@tanstack/svelte-query', async (orig) => { const actual = (await orig()) as Record; return { ...actual, useQueryClient: () => ({}) }; }); import PlayerBar from './PlayerBar.svelte'; import { togglePlay, skipNext, skipPrev, seekTo, setVolume, toggleShuffle, cycleRepeat, playQueue } 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; }); 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); 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(); }); });