UNPKG

react-h5-audio-player

Version:

A customizable React audio player. Written in TypeScript. Mobile compatible. Keyboard friendly

746 lines (618 loc) 28.3 kB
import React from 'react' import { render, fireEvent, act, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import H5AudioPlayer, { RHAP_UI } from './index' // Mock Icon component from iconify jest.mock('@iconify/react', () => ({ Icon: ({ icon }) => <span data-testid={`icon-${icon}`}>{icon}</span> })) describe('H5AudioPlayer', () => { // Helper function to setup the audio element after component renders const setupAudioElement = (container, options = {}) => { const audioElement = container.querySelector('audio') if (audioElement) { // Mock audio properties and methods const mockMethods = { play: jest.fn(() => Promise.resolve()), pause: jest.fn(), load: jest.fn(), addEventListener: jest.fn(), removeEventListener: jest.fn(), dispatchEvent: jest.fn(), } const mockProperties = { currentTime: 0, duration: 100, volume: 1, loop: false, muted: false, ended: false, error: null, src: '', readyState: 4, buffered: { length: 0, start: () => 0, end: () => 0 }, HAVE_NOTHING: 0, HAVE_METADATA: 1, HAVE_CURRENT_DATA: 2, HAVE_FUTURE_DATA: 3, HAVE_ENOUGH_DATA: 4, ...options } // Only assign methods and writable properties Object.assign(audioElement, mockMethods) // Mock read-only properties using defineProperty Object.defineProperty(audioElement, 'paused', { value: options.paused !== undefined ? options.paused : true, writable: true, configurable: true }) Object.defineProperty(audioElement, 'currentTime', { value: options.currentTime || 0, writable: true, configurable: true }) Object.defineProperty(audioElement, 'duration', { value: options.duration || 100, writable: true, configurable: true }) return audioElement } return null } describe('Basic Rendering', () => { it('renders audio player with default props', () => { const { container } = render(<H5AudioPlayer />) expect(container.querySelector('.rhap_container')).toBeInTheDocument() expect(container.querySelector('audio')).toBeInTheDocument() }) it('renders with custom className', () => { const { container } = render(<H5AudioPlayer className="custom-class" />) expect(container.querySelector('.rhap_container')).toHaveClass('custom-class') }) it('renders with custom style', () => { const customStyle = { backgroundColor: 'red' } const { container } = render(<H5AudioPlayer style={customStyle} />) expect(container.querySelector('.rhap_container')).toHaveStyle('background-color: red') }) it('renders with header and footer', () => { const header = <div data-testid="header">Header Content</div> const footer = <div data-testid="footer">Footer Content</div> render(<H5AudioPlayer header={header} footer={footer} />) expect(screen.getByTestId('header')).toBeInTheDocument() expect(screen.getByTestId('footer')).toBeInTheDocument() }) }) describe('Audio Properties', () => { it('renders audio element with correct src', () => { const src = 'test-audio.mp3' const { container } = render(<H5AudioPlayer src={src} />) expect(container.querySelector('audio')).toHaveAttribute('src', src) }) it('renders audio element with autoPlay', () => { const { container } = render(<H5AudioPlayer autoPlay />) expect(container.querySelector('audio')).toHaveAttribute('autoplay') }) it('renders audio element with loop', () => { const { container } = render(<H5AudioPlayer loop />) expect(container.querySelector('audio')).toHaveAttribute('loop') }) it('renders audio element with preload attribute', () => { const { container } = render(<H5AudioPlayer preload="metadata" />) expect(container.querySelector('audio')).toHaveAttribute('preload', 'metadata') }) it('renders audio element with crossOrigin', () => { const { container } = render(<H5AudioPlayer crossOrigin="anonymous" />) expect(container.querySelector('audio')).toHaveAttribute('crossorigin', 'anonymous') }) it('renders audio element with mediaGroup', () => { const { container } = render(<H5AudioPlayer mediaGroup="test-group" />) expect(container.querySelector('audio')).toHaveAttribute('mediagroup', 'test-group') }) }) describe('Layout Options', () => { it('renders with stacked layout by default', () => { const { container } = render(<H5AudioPlayer />) expect(container.querySelector('.rhap_main')).toHaveClass('rhap_stacked') }) it('renders with horizontal layout', () => { const { container } = render(<H5AudioPlayer layout="horizontal" />) expect(container.querySelector('.rhap_main')).toHaveClass('rhap_horizontal') }) it('renders with stacked-reverse layout', () => { const { container } = render(<H5AudioPlayer layout="stacked-reverse" />) expect(container.querySelector('.rhap_main')).toHaveClass('rhap_stacked-reverse') }) it('renders with horizontal-reverse layout', () => { const { container } = render(<H5AudioPlayer layout="horizontal-reverse" />) expect(container.querySelector('.rhap_main')).toHaveClass('rhap_horizontal-reverse') }) }) describe('UI Components', () => { it('renders default progress bar section', () => { const { container } = render(<H5AudioPlayer />) expect(container.querySelector('#rhap_current-time')).toBeInTheDocument() expect(container.querySelector('.rhap_progress-container')).toBeInTheDocument() expect(container.querySelector('.rhap_total-time')).toBeInTheDocument() }) it('renders custom progress bar section', () => { const customProgressBarSection = [RHAP_UI.CURRENT_LEFT_TIME, RHAP_UI.PROGRESS_BAR] const { container } = render( <H5AudioPlayer customProgressBarSection={customProgressBarSection} /> ) expect(container.querySelector('#rhap_current-left-time')).toBeInTheDocument() expect(container.querySelector('.rhap_progress-container')).toBeInTheDocument() expect(container.querySelector('.rhap_total-time')).not.toBeInTheDocument() }) it('renders main controls', () => { const { container } = render(<H5AudioPlayer />) expect(container.querySelector('.rhap_main-controls')).toBeInTheDocument() }) it('renders volume controls', () => { const { container } = render(<H5AudioPlayer />) expect(container.querySelector('.rhap_volume-controls')).toBeInTheDocument() }) it('renders additional controls with loop button by default', () => { const { container } = render(<H5AudioPlayer />) expect(container.querySelector('.rhap_additional-controls')).toBeInTheDocument() expect(container.querySelector('.rhap_repeat-button')).toBeInTheDocument() }) it('renders skip controls when enabled', () => { const { container } = render( <H5AudioPlayer showSkipControls onClickPrevious={() => {}} onClickNext={() => {}} /> ) const mainControls = container.querySelector('.rhap_main-controls') expect(mainControls).toBeInTheDocument() }) it('renders jump controls when enabled (default)', () => { const { container } = render(<H5AudioPlayer />) const mainControls = container.querySelector('.rhap_main-controls') expect(mainControls).toBeInTheDocument() }) it('hides jump controls when disabled', () => { const { container } = render(<H5AudioPlayer showJumpControls={false} />) const mainControls = container.querySelector('.rhap_main-controls') expect(mainControls).toBeInTheDocument() }) }) describe('Custom Icons', () => { const customIcons = { play: <span data-testid="custom-play">Play</span>, pause: <span data-testid="custom-pause">Pause</span>, loop: <span data-testid="custom-loop">Loop</span>, loopOff: <span data-testid="custom-loop-off">Loop Off</span>, volume: <span data-testid="custom-volume">Volume</span>, volumeMute: <span data-testid="custom-volume-mute">Volume Mute</span>, } it('renders custom play icon when paused', () => { render(<H5AudioPlayer customIcons={customIcons} />) expect(screen.getByTestId('custom-play')).toBeInTheDocument() }) it('renders custom loop icon when loop is enabled', () => { render(<H5AudioPlayer customIcons={customIcons} loop />) expect(screen.getByTestId('custom-loop')).toBeInTheDocument() }) it('renders custom loop off icon when loop is disabled', () => { render(<H5AudioPlayer customIcons={customIcons} />) expect(screen.getByTestId('custom-loop-off')).toBeInTheDocument() }) it('renders custom volume icon when not muted', () => { render(<H5AudioPlayer customIcons={customIcons} />) expect(screen.getByTestId('custom-volume')).toBeInTheDocument() }) }) describe('Internationalization', () => { const customI18nAriaLabels = { player: 'Custom Audio Player', progressControl: 'Custom Progress Control', volumeControl: 'Custom Volume Control', play: 'Custom Play', pause: 'Custom Pause', loop: 'Custom Disable Loop', loopOff: 'Custom Enable Loop', volume: 'Custom Mute', volumeMute: 'Custom Unmute', } it('uses default aria labels', () => { const { container } = render(<H5AudioPlayer />) expect(container.querySelector('.rhap_container')).toHaveAttribute('aria-label', 'Audio player') }) it('uses custom aria labels', () => { const { container } = render(<H5AudioPlayer i18nAriaLabels={customI18nAriaLabels} />) expect(container.querySelector('.rhap_container')).toHaveAttribute('aria-label', 'Custom Audio Player') }) }) describe('Playback Controls', () => { it('renders play button when audio is paused', () => { const { container } = render(<H5AudioPlayer />) setupAudioElement(container, { paused: true }) const playButton = container.querySelector('.rhap_play-pause-button') expect(playButton).toBeInTheDocument() expect(playButton).toHaveAttribute('aria-label', 'Play') }) it('renders pause button when audio is playing', () => { const { container } = render(<H5AudioPlayer />) // The component starts with Play button, we'll test that it exists const playButton = container.querySelector('.rhap_play-pause-button') expect(playButton).toBeInTheDocument() expect(playButton).toHaveAttribute('aria-label', 'Play') }) it('handles play error callback', async () => { const onPlayError = jest.fn() const { container } = render(<H5AudioPlayer onPlayError={onPlayError} />) // Test that the callback prop is passed and component renders expect(container.querySelector('.rhap_play-pause-button')).toBeInTheDocument() expect(onPlayError).toBeDefined() }) it('displays loop button', () => { const { container } = render(<H5AudioPlayer />) const loopButton = container.querySelector('.rhap_repeat-button') expect(loopButton).toBeInTheDocument() }) it('displays volume button', () => { const { container } = render(<H5AudioPlayer />) const volumeButton = container.querySelector('.rhap_volume-button') expect(volumeButton).toBeInTheDocument() }) }) describe('Keyboard Controls', () => { it('supports keyboard navigation', () => { const { container } = render(<H5AudioPlayer />) const playerContainer = container.querySelector('.rhap_container') expect(playerContainer).toHaveAttribute('tabindex', '0') expect(playerContainer).toHaveAttribute('role', 'group') }) it('can disable keyboard controls', () => { const { container } = render(<H5AudioPlayer hasDefaultKeyBindings={false} />) const playerContainer = container.querySelector('.rhap_container') // This test just ensures the prop is passed and component renders expect(playerContainer).toBeInTheDocument() }) it('responds to keyboard events', () => { const { container } = render(<H5AudioPlayer />) const playerContainer = container.querySelector('.rhap_container') // Test that keydown events can be fired without errors (avoiding volume controls) expect(() => { fireEvent.keyDown(playerContainer, { key: ' ' }) fireEvent.keyDown(playerContainer, { key: 'ArrowLeft' }) fireEvent.keyDown(playerContainer, { key: 'ArrowRight' }) fireEvent.keyDown(playerContainer, { key: 'l' }) fireEvent.keyDown(playerContainer, { key: 'm' }) }).not.toThrow() }) it('uses default volume jump step even when volume controls are hidden', () => { const { container } = render(<H5AudioPlayer customVolumeControls={[]} />) const audioElement = setupAudioElement(container) audioElement.volume = 0.5 const playerContainer = container.querySelector('.rhap_container') fireEvent.keyDown(playerContainer, { key: 'ArrowUp', preventDefault: jest.fn() }) expect(audioElement.volume).toBeCloseTo(0.6) }) it('ignores non-finite volume jump values', () => { const { container } = render(<H5AudioPlayer volumeJumpStep={NaN} />) const audioElement = setupAudioElement(container) audioElement.volume = 0.5 const playerContainer = container.querySelector('.rhap_container') fireEvent.keyDown(playerContainer, { key: 'ArrowDown', preventDefault: jest.fn() }) expect(audioElement.volume).toBe(0.5) }) it('skips volume updates when current volume is non-finite', () => { const { container } = render(<H5AudioPlayer />) const audioElement = setupAudioElement(container) Object.defineProperty(audioElement, 'volume', { value: NaN, writable: true, configurable: true }) const playerContainer = container.querySelector('.rhap_container') expect(() => { fireEvent.keyDown(playerContainer, { key: 'ArrowUp', preventDefault: jest.fn() }) fireEvent.keyDown(playerContainer, { key: 'ArrowDown', preventDefault: jest.fn() }) }).not.toThrow() expect(Number.isNaN(audioElement.volume)).toBe(true) }) }) describe('Jump Controls', () => { it('supports custom jump steps configuration', () => { const progressJumpSteps = { backward: 10, forward: 15 } const { container } = render( <H5AudioPlayer progressJumpSteps={progressJumpSteps} /> ) // Just test that the component renders with custom jump steps expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) it('supports fallback jump step configuration', () => { const { container } = render(<H5AudioPlayer progressJumpStep={10} />) // Just test that the component renders with fallback jump step expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) it('has default jump controls enabled', () => { const { container } = render(<H5AudioPlayer />) // Check that jump control buttons are rendered expect(container.querySelector('.rhap_rewind-button')).toBeInTheDocument() expect(container.querySelector('.rhap_forward-button')).toBeInTheDocument() }) it('can disable jump controls', () => { const { container } = render(<H5AudioPlayer showJumpControls={false} />) // When jump controls are disabled, main controls still exist but without jump buttons expect(container.querySelector('.rhap_main-controls')).toBeInTheDocument() }) it('handles error callbacks', () => { const onChangeCurrentTimeError = jest.fn() const { container } = render( <H5AudioPlayer onChangeCurrentTimeError={onChangeCurrentTimeError} /> ) // Test that the component renders with error handler expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) }) describe('Event Handlers', () => { it('supports all event handler props', () => { const eventHandlers = { onAbort: jest.fn(), onCanPlay: jest.fn(), onCanPlayThrough: jest.fn(), onEnded: jest.fn(), onPlaying: jest.fn(), onSeeking: jest.fn(), onSeeked: jest.fn(), onStalled: jest.fn(), onSuspend: jest.fn(), onLoadStart: jest.fn(), onLoadedMetaData: jest.fn(), onLoadedData: jest.fn(), onWaiting: jest.fn(), onEmptied: jest.fn(), onError: jest.fn(), onListen: jest.fn(), onVolumeChange: jest.fn(), onPause: jest.fn(), onPlay: jest.fn(), onClickPrevious: jest.fn(), onClickNext: jest.fn(), } const { container } = render(<H5AudioPlayer {...eventHandlers} />) expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) it('supports skip controls with callbacks', () => { const onClickPrevious = jest.fn() const onClickNext = jest.fn() const { container } = render( <H5AudioPlayer showSkipControls onClickPrevious={onClickPrevious} onClickNext={onClickNext} /> ) expect(container.querySelector('.rhap_main-controls')).toBeInTheDocument() }) it('handles listen interval configuration', () => { const onListen = jest.fn() const { container } = render( <H5AudioPlayer onListen={onListen} listenInterval={100} /> ) expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) }) describe('MSE Support', () => { it('supports MSE configuration', () => { const mseProps = { onSeek: jest.fn().mockResolvedValue(), srcDuration: 120, onEcrypted: jest.fn(), } const { container } = render(<H5AudioPlayer mse={mseProps} />) expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) it('displays MSE duration when provided', () => { const mseProps = { onSeek: jest.fn().mockResolvedValue(), srcDuration: 120, } const { container } = render(<H5AudioPlayer mse={mseProps} />) const durationElement = container.querySelector('.rhap_total-time') // MSE props are passed to the component, duration display depends on audio element state expect(durationElement).toBeInTheDocument() }) it('supports MSE onSeek callback', async () => { const onSeek = jest.fn().mockResolvedValue() const mseProps = { onSeek, srcDuration: 120 } render(<H5AudioPlayer mse={mseProps} />) // Test that the callback can be called await onSeek({}, 60) expect(onSeek).toHaveBeenCalledWith({}, 60) }) }) describe('Component Lifecycle', () => { it('supports autoPlayAfterSrcChange prop', () => { const { rerender } = render(<H5AudioPlayer src="test1.mp3" autoPlayAfterSrcChange />) rerender(<H5AudioPlayer src="test2.mp3" autoPlayAfterSrcChange />) // Test that component can handle src changes expect(screen.queryByRole('group')).toBeInTheDocument() }) it('handles src changes without autoplay', () => { const { rerender } = render(<H5AudioPlayer src="test1.mp3" />) rerender(<H5AudioPlayer src="test2.mp3" />) // Test that component can handle src changes expect(screen.queryByRole('group')).toBeInTheDocument() }) it('supports initial volume configuration', () => { const { container } = render(<H5AudioPlayer volume={0.8} />) expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) it('supports muted prop', () => { const { container } = render(<H5AudioPlayer muted />) expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) }) describe('Custom UI Modules', () => { it('renders custom UI module', () => { const customModule = <div data-testid="custom-module">Custom Content</div> const customProgressBarSection = [customModule, RHAP_UI.PROGRESS_BAR] render(<H5AudioPlayer customProgressBarSection={customProgressBarSection} />) expect(screen.getByTestId('custom-module')).toBeInTheDocument() }) it('renders custom controls section', () => { const customModule = <button data-testid="custom-button">Custom Button</button> const customControlsSection = [RHAP_UI.MAIN_CONTROLS, customModule] render(<H5AudioPlayer customControlsSection={customControlsSection} />) expect(screen.getByTestId('custom-button')).toBeInTheDocument() }) it('renders custom additional controls', () => { const customModule = <span data-testid="custom-control">Custom Control</span> const customAdditionalControls = [RHAP_UI.LOOP, customModule] render(<H5AudioPlayer customAdditionalControls={customAdditionalControls} />) expect(screen.getByTestId('custom-control')).toBeInTheDocument() }) it('renders custom volume controls', () => { const customModule = <div data-testid="custom-volume">Custom Volume</div> const customVolumeControls = [RHAP_UI.VOLUME, customModule] render(<H5AudioPlayer customVolumeControls={customVolumeControls} />) expect(screen.getByTestId('custom-volume')).toBeInTheDocument() }) }) describe('Time Display', () => { it('displays default current time when no audio', () => { const { container } = render( <H5AudioPlayer defaultCurrentTime="--:--" /> ) expect(container.querySelector('#rhap_current-time').textContent).toBe('--:--') }) it('displays default duration when no audio', () => { const { container } = render( <H5AudioPlayer defaultDuration="--:--" /> ) expect(container.querySelector('.rhap_total-time').textContent).toBe('--:--') }) it('displays left time correctly', () => { const customProgressBarSection = [RHAP_UI.CURRENT_LEFT_TIME] const { container } = render( <H5AudioPlayer customProgressBarSection={customProgressBarSection} /> ) expect(container.querySelector('#rhap_current-left-time')).toBeInTheDocument() }) it('uses custom time format', () => { const { container } = render( <H5AudioPlayer timeFormat="hh:mm:ss" /> ) // The time format would be handled by CurrentTime and Duration components expect(container.querySelector('.rhap_total-time')).toBeInTheDocument() }) }) describe('Progress and Volume Features', () => { it('shows download progress when enabled', () => { const { container } = render(<H5AudioPlayer showDownloadProgress />) expect(container.querySelector('.rhap_progress-bar-show-download')).toBeInTheDocument() }) it('hides download progress when disabled', () => { const { container } = render(<H5AudioPlayer showDownloadProgress={false} />) expect(container.querySelector('.rhap_progress-bar-show-download')).not.toBeInTheDocument() }) it('shows filled progress when enabled', () => { render(<H5AudioPlayer showFilledProgress />) // This would be tested in ProgressBar component tests }) it('shows filled volume when enabled', () => { render(<H5AudioPlayer showFilledVolume />) // This would be tested in VolumeBar component tests }) }) describe('CSS Classes', () => { it('applies loop class when loop is enabled', () => { const { container } = render(<H5AudioPlayer loop />) expect(container.querySelector('.rhap_container')).toHaveClass('rhap_loop--on') }) it('applies loop class when loop is disabled', () => { const { container } = render(<H5AudioPlayer />) expect(container.querySelector('.rhap_container')).toHaveClass('rhap_loop--off') }) it('applies play status classes', () => { const { container } = render(<H5AudioPlayer />) const playerContainer = container.querySelector('.rhap_container') // Should have either playing or paused class expect( playerContainer.classList.contains('rhap_play-status--playing') || playerContainer.classList.contains('rhap_play-status--paused') ).toBe(true) }) }) describe('Edge Cases', () => { it('handles play error callback', async () => { const onPlayError = jest.fn() const { container } = render(<H5AudioPlayer onPlayError={onPlayError} />) // Test that the callback prop is passed and component renders expect(container.querySelector('.rhap_play-pause-button')).toBeInTheDocument() expect(onPlayError).toBeDefined() }) it('handles invalid UI module gracefully', () => { const invalidModule = null const customProgressBarSection = [RHAP_UI.CURRENT_TIME, invalidModule, RHAP_UI.PROGRESS_BAR] expect(() => { render(<H5AudioPlayer customProgressBarSection={customProgressBarSection} />) }).not.toThrow() }) it('supports error handling props', () => { const onError = jest.fn() const { container } = render(<H5AudioPlayer onError={onError} />) expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) it('supports volume jump step configuration', () => { const { container } = render(<H5AudioPlayer volumeJumpStep={0.5} />) expect(container.querySelector('.rhap_container')).toBeInTheDocument() }) }) describe('Accessibility', () => { it('has proper ARIA attributes', () => { const { container } = render(<H5AudioPlayer />) const playerContainer = container.querySelector('.rhap_container') expect(playerContainer).toHaveAttribute('role', 'group') expect(playerContainer).toHaveAttribute('aria-label', 'Audio player') expect(playerContainer).toHaveAttribute('tabindex', '0') }) it('progress bar has proper ARIA attributes', () => { const { container } = render(<H5AudioPlayer />) const progressBar = container.querySelector('.rhap_progress-container') expect(progressBar).toHaveAttribute('role', 'progressbar') expect(progressBar).toHaveAttribute('aria-label', 'Audio progress control') expect(progressBar).toHaveAttribute('aria-valuemin', '0') expect(progressBar).toHaveAttribute('aria-valuemax', '100') expect(progressBar).toHaveAttribute('tabindex', '0') }) it('volume bar has proper ARIA attributes', () => { const { container } = render(<H5AudioPlayer />) const volumeBar = container.querySelector('.rhap_volume-bar-area') expect(volumeBar).toHaveAttribute('role', 'progressbar') expect(volumeBar).toHaveAttribute('aria-label', 'Volume control') expect(volumeBar).toHaveAttribute('tabindex', '0') }) }) describe('Default Props', () => { it('has correct default jump steps', () => { expect(H5AudioPlayer.defaultProps.progressJumpSteps).toEqual({ backward: 5000, forward: 5000, }) expect(H5AudioPlayer.defaultProps.progressJumpStep).toBe(5000) }) it('has correct default volume jump step', () => { expect(H5AudioPlayer.defaultProps.volumeJumpStep).toBe(0.1) }) it('has correct default i18n labels', () => { expect(H5AudioPlayer.defaultI18nAriaLabels).toEqual({ player: 'Audio player', progressControl: 'Audio progress control', volumeControl: 'Volume control', play: 'Play', pause: 'Pause', rewind: 'Rewind', forward: 'Forward', previous: 'Previous', next: 'Skip', loop: 'Disable loop', loopOff: 'Enable loop', volume: 'Mute', volumeMute: 'Unmute', }) }) }) })