import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor, act } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { PlayerProvider, usePlayer, playerReducer, type PlayerState } from '../src/player/PlayerContext.js' import type { Track } from '../src/types.js' vi.mock('../src/api.js', async (importOriginal) => { const actual = await importOriginal() return { ...actual, api: { ...actual.api, getAlbumTracks: vi.fn(), markPlayed: vi.fn() } } }) import { api } from '../src/api.js' beforeEach(() => { vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue() vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined) }) const tracks: Track[] = [ { id: 's1', title: 'Overture', duration: 200, track: 1 }, { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 }, ] function Probe() { const { state, load, toggle, next, prev, close } = usePlayer() return (
phase:{state.album ? (state.playing ? 'playing' : 'paused') : 'empty'}
track:{state.tracks[state.index]?.title ?? 'none'}
) } function renderPlayer() { return render( ) } describe('PlayerProvider', () => { it('loads a queue, stamps played, starts at track 1', async () => { vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never) vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never) renderPlayer() await userEvent.click(screen.getByRole('button', { name: 'load' })) await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy()) expect(screen.getByText('track:Overture')).toBeTruthy() expect(api.getAlbumTracks).toHaveBeenCalledWith('a1') expect(api.markPlayed).toHaveBeenCalledWith('a1') }) it('toggle pauses and resumes', async () => { vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never) vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never) renderPlayer() await userEvent.click(screen.getByRole('button', { name: 'load' })) await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy()) await userEvent.click(screen.getByRole('button', { name: 'toggle' })) expect(screen.getByText('phase:paused')).toBeTruthy() await userEvent.click(screen.getByRole('button', { name: 'toggle' })) expect(screen.getByText('phase:playing')).toBeTruthy() }) it('next/prev move through the queue and stop at the edges', async () => { vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never) vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never) renderPlayer() await userEvent.click(screen.getByRole('button', { name: 'load' })) await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy()) await userEvent.click(screen.getByRole('button', { name: 'next' })) expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy() await userEvent.click(screen.getByRole('button', { name: 'next' })) expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy() // last track: no advance await userEvent.click(screen.getByRole('button', { name: 'prev' })) expect(screen.getByText('track:Overture')).toBeTruthy() await userEvent.click(screen.getByRole('button', { name: 'prev' })) expect(screen.getByText('track:Overture')).toBeTruthy() // first track: no rewind }) it('close empties the player', async () => { vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never) vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never) renderPlayer() await userEvent.click(screen.getByRole('button', { name: 'load' })) await waitFor(() => expect(screen.getByText('phase:playing')).toBeTruthy()) await userEvent.click(screen.getByRole('button', { name: 'close' })) expect(screen.getByText('phase:empty')).toBeTruthy() }) it('audio error marks the track failed and skips to the next', async () => { vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'a1', title: 'Motion', artist: 'TCO', tracks } as never) vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never) renderPlayer() await userEvent.click(screen.getByRole('button', { name: 'load' })) await waitFor(() => expect(screen.getByText('track:Overture')).toBeTruthy()) act(() => { document.querySelector('audio')!.dispatchEvent(new Event('error')) }) await waitFor(() => expect(screen.getByText('track:Theme de Yoyo')).toBeTruthy()) }) }) const twoTracks: Track[] = [ { id: 's1', title: 'Overture', duration: 200, track: 1 }, { id: 's2', title: 'Theme de Yoyo', duration: 300, track: 2 }, ] describe('playerReducer edges', () => { const loaded: PlayerState = playerReducer( { album: null, tracks: [], index: 0, playing: false, failed: [] }, { type: 'LOAD', album: { id: 'a1', title: 'Motion', artist: 'TCO' }, tracks: twoTracks } ) it('LOAD with empty tracks does not play', () => { const s = playerReducer(loaded, { type: 'LOAD', album: loaded.album!, tracks: [] }) expect(s.playing).toBe(false) }) it('TRACK_ERROR at last track pauses without advancing, records failure once', () => { const atLast = playerReducer(loaded, { type: 'NEXT' }) const err = playerReducer(atLast, { type: 'TRACK_ERROR' }) expect(err.index).toBe(1) expect(err.playing).toBe(false) expect(err.failed).toEqual([1]) const again = playerReducer(err, { type: 'TRACK_ERROR' }) expect(again.failed).toEqual([1]) }) })