import React from 'react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import {
fireEvent,
renderWithProviders,
screen,
waitFor,
} from '../../test/utils'
import MediaPlayer from './MediaPlayer'
const SOURCE = 'https://cdn.example.com/preview.mp4'
const POSTER = 'https://cdn.example.com/poster.jpg'
describe('MediaPlayer', () => {
beforeEach(() => {
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined)
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockImplementation(() => {})
vi.stubGlobal(
'requestAnimationFrame',
vi.fn(() => 1)
)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
})
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it('selects audio or video from the mime type and wires media props', () => {
const { container, rerender } = renderWithProviders(
)
const audio = container.querySelector('audio') as HTMLAudioElement
expect(audio).toBeInTheDocument()
expect(audio).toHaveAttribute('src', SOURCE)
expect(audio.loop).toBe(true)
expect(audio.muted).toBe(true)
expect(container.querySelector('video')).toBeNull()
rerender(
)
const video = container.querySelector('video') as HTMLVideoElement
expect(video).toBeInTheDocument()
expect(video).toHaveAttribute('src', SOURCE)
expect(video.loop).toBe(true)
expect(video.muted).toBe(true)
expect(video.playsInline).toBe(true)
expect(container.querySelector('audio')).toBeNull()
})
it('keeps an audio poster after canPlay but hides a video poster', () => {
const audio = renderWithProviders(
)
const audioElement = audio.container.querySelector(
'audio'
) as HTMLAudioElement
expect(audio.container.querySelectorAll('img')).toHaveLength(1)
fireEvent.canPlay(audioElement)
expect(audio.container.querySelectorAll('img')).toHaveLength(1)
audio.unmount()
const video = renderWithProviders(
)
const videoElement = video.container.querySelector(
'video'
) as HTMLVideoElement
expect(video.container.querySelectorAll('img')).toHaveLength(1)
fireEvent.canPlay(videoElement)
expect(video.container.querySelectorAll('img')).toHaveLength(0)
})
it('uses a type icon as the initial placeholder when no poster is provided', async () => {
const { container } = renderWithProviders(
)
const video = container.querySelector('video') as HTMLVideoElement
const placeholderIcon = () =>
Array.from(container.querySelectorAll('svg')).find((svg) =>
svg.classList.contains('size-12')
)
expect(placeholderIcon()).toBeInTheDocument()
fireEvent.canPlay(video)
await waitFor(() => expect(placeholderIcon()).toBeUndefined())
})
it('syncs controlled playback and calls play or pause imperatively', async () => {
const play = vi.mocked(HTMLMediaElement.prototype.play)
const pause = vi.mocked(HTMLMediaElement.prototype.pause)
const { container, rerender } = renderWithProviders(
)
const video = container.querySelector('video') as HTMLVideoElement
expect(pause).toHaveBeenCalled()
rerender(
)
await waitFor(() => expect(play).toHaveBeenCalledWith())
expect(video).toBeInTheDocument()
rerender(
)
await waitFor(() => expect(pause.mock.calls.length).toBeGreaterThan(1))
})
it('shows a manual play overlay after rejected playback and starts from a gesture', async () => {
const play = vi.mocked(HTMLMediaElement.prototype.play)
play.mockRejectedValueOnce(new Error('autoplay blocked'))
const onContainerClick = vi.fn()
renderWithProviders(
)
const overlay = await screen.findByRole('button', { name: 'Play preview' })
expect(play).toHaveBeenCalledTimes(1)
fireEvent.click(overlay)
await waitFor(() => expect(play).toHaveBeenCalledTimes(2))
expect(onContainerClick).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: 'Play preview' })).toBeNull()
})
it('toggles from the container only with controls, while an override owns the click', async () => {
const play = vi.mocked(HTMLMediaElement.prototype.play)
const { container, rerender } = renderWithProviders(
)
const root = container.firstElementChild as HTMLElement
fireEvent.click(root)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
)
fireEvent.click(root)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
)
expect(play).toHaveBeenCalledTimes(1)
const onContainerClick = vi.fn()
rerender(
)
fireEvent.click(root)
expect(onContainerClick).toHaveBeenCalledTimes(1)
expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
fireEvent.keyDown(root, { key: 'Enter' })
expect(onContainerClick).toHaveBeenCalledTimes(2)
})
it('handles Enter and Space but ignores other container keys and prevents defaults', () => {
const { container } = renderWithProviders(
)
const root = container.firstElementChild as HTMLElement
const pause = () => screen.getByRole('button', { name: 'Pause' })
const otherKey = new KeyboardEvent('keydown', {
key: 'ArrowDown',
bubbles: true,
cancelable: true,
})
fireEvent(root, otherKey)
expect(otherKey.defaultPrevented).toBe(false)
expect(screen.queryByRole('button', { name: 'Pause' })).toBeNull()
const enter = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
})
fireEvent(root, enter)
expect(enter.defaultPrevented).toBe(true)
expect(pause()).toBeInTheDocument()
const space = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
})
fireEvent(root, space)
expect(space.defaultPrevented).toBe(true)
expect(screen.getByRole('button', { name: 'Play' })).toBeInTheDocument()
})
it('does not let the controls button bubble into the container toggle', async () => {
const { container } = renderWithProviders(
)
const button = screen.getByRole('button', { name: 'Play' })
fireEvent.click(button)
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument()
)
expect(container.firstElementChild).toBeInTheDocument()
})
it('shows buffering while loading and suppresses it after manual play is required', async () => {
const play = vi.mocked(HTMLMediaElement.prototype.play)
play.mockRejectedValueOnce(new Error('autoplay blocked'))
const { container } = renderWithProviders(
)
const video = container.querySelector('video') as HTMLVideoElement
await screen.findByRole('button', { name: 'Play preview' })
fireEvent.loadStart(video)
expect(container.querySelector('.animate-spin')).toBeNull()
fireEvent.canPlay(video)
expect(container.querySelector('.animate-spin')).toBeNull()
})
it('shows and hides the buffering spinner around media readiness', () => {
const { container } = renderWithProviders(
)
const video = container.querySelector('video') as HTMLVideoElement
fireEvent.loadStart(video)
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
fireEvent.waiting(video)
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
fireEvent.canPlay(video)
expect(container.querySelector('.animate-spin')).toBeNull()
})
it('seeks with keyboard and pointer input and expands the scrubber on hover', () => {
const { container } = renderWithProviders(
)
const video = container.querySelector('video') as HTMLVideoElement
Object.defineProperty(video, 'duration', {
configurable: true,
value: 100,
})
const slider = screen.getByRole('slider', { name: 'Playback position' })
const track = slider.firstElementChild as HTMLElement
const thumb = slider.lastElementChild as HTMLElement
vi.spyOn(slider, 'getBoundingClientRect').mockReturnValue({
left: 100,
right: 300,
top: 0,
bottom: 10,
width: 200,
height: 10,
x: 100,
y: 0,
toJSON: () => ({}),
})
expect(track).toHaveClass('h-1')
expect(thumb).toHaveClass('scale-0', 'opacity-0')
fireEvent.mouseEnter(slider)
expect(track).toHaveClass('h-1.5')
expect(thumb).toHaveClass('scale-100', 'opacity-100')
fireEvent.mouseLeave(slider)
expect(track).toHaveClass('h-1')
fireEvent.mouseDown(slider, { clientX: 150 })
expect(slider).toHaveAttribute('aria-valuenow', '25')
expect(video.currentTime).toBe(25)
expect(track).toHaveClass('h-1.5')
expect(thumb).toHaveClass('scale-100', 'opacity-100')
fireEvent.mouseUp(window, { clientX: 150 })
fireEvent.keyDown(slider, { key: 'ArrowRight' })
expect(video.currentTime).toBe(30)
fireEvent.keyDown(slider, { key: 'ArrowLeft' })
expect(video.currentTime).toBe(20)
})
it('renders the progress-only track without controls when requested', () => {
const { container } = renderWithProviders(
)
expect(
screen.getByRole('slider', { name: 'Playback position' })
).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Play' })).toBeNull()
expect(container.querySelectorAll('[role="slider"]')).toHaveLength(1)
})
it('uses natural video dimensions after metadata and aspect-video before then', () => {
const { container } = renderWithProviders(
)
const root = container.firstElementChild as HTMLElement
const video = container.querySelector('video') as HTMLVideoElement
Object.defineProperties(video, {
videoWidth: { configurable: true, value: 1920 },
videoHeight: { configurable: true, value: 1080 },
})
expect(root).toHaveClass('aspect-video')
expect(root).not.toHaveStyle({ aspectRatio: '1.7777777777777777' })
fireEvent.loadedMetadata(video)
expect(root).not.toHaveClass('aspect-video')
expect(root.style.aspectRatio).toBe(String(1920 / 1080))
})
})