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)) }) })