diff --git a/ui/src/audioplayer/Player.test.jsx b/ui/src/audioplayer/Player.test.jsx
index 55b9bfa4f..401c953cb 100644
--- a/ui/src/audioplayer/Player.test.jsx
+++ b/ui/src/audioplayer/Player.test.jsx
@@ -1,259 +1,166 @@
-/* eslint-env jest */
-
import React from 'react'
-import { render, screen, fireEvent, waitFor } from '@testing-library/react'
-import { Provider } from 'react-redux'
-import { createStore, combineReducers } from 'redux'
-import { ThemeProvider } from '@material-ui/core/styles'
-import { createMuiTheme } from '@material-ui/core/styles'
-import { Player } from './Player'
-import { playerReducer } from '../reducers/playerReducer'
-import { settingsReducer } from '../reducers/settingsReducer'
-import { replayGainReducer } from '../reducers/replayGainReducer'
+import { render, screen, fireEvent, cleanup } from '@testing-library/react'
+import { useMediaQuery } from '@material-ui/core'
+import { useGetOne } from 'react-admin'
+import { useDispatch } from 'react-redux'
+import { useToggleLove } from '../common'
+import { openSaveQueueDialog } from '../actions'
+import PlayerToolbar from './PlayerToolbar'
// Mock dependencies
-jest.mock('../themes/useCurrentTheme', () => ({
- __esModule: true,
- default: () => ({
- player: { theme: 'dark' },
- }),
-}))
-
-jest.mock('../config', () => ({
- enableCoverAnimation: false,
- gaTrackingId: null,
-}))
-
-jest.mock('./AudioTitle', () => ({
- __esModule: true,
- default: ({ audioInfo }) => (
-
{audioInfo?.song?.title || 'No song'}
- ),
-}))
-
-jest.mock('./PlayerToolbar', () => ({
- __esModule: true,
- default: ({ id }) => {id || 'No ID'}
,
-}))
-
-jest.mock('./locale', () => ({
- __esModule: true,
- default: () => (key) => key,
-}))
-
-jest.mock('./keyHandlers', () => ({
- __esModule: true,
- default: () => ({}),
-}))
-
-jest.mock('../hotkeys', () => ({
- keyMap: {},
-}))
-
-jest.mock('react-ga', () => ({
- event: jest.fn(),
-}))
-
-jest.mock('../utils', () => ({
- sendNotification: jest.fn(),
-}))
-
-jest.mock('navidrome-music-player', () => ({
- __esModule: true,
- default: ({ children, ...props }) => (
-
- {children}
-
- ),
-}))
-
-jest.mock('navidrome-music-player/assets/index.css', () => {})
-
-// Mock react-redux hooks
-jest.mock('react-redux', () => ({
- ...jest.requireActual('react-redux'),
- useSelector: jest.fn(),
- useDispatch: jest.fn(),
-}))
-
-// Mock react-admin hooks
-jest.mock('react-admin', () => ({
- useAuthState: () => ({ authenticated: true }),
- useDataProvider: () => ({
- getOne: jest.fn().mockResolvedValue({ data: {} }),
- }),
- useTranslate: () => (key) => key,
- createMuiTheme: jest.fn(),
-}))
-
-// Mock @material-ui/core
-jest.mock('@material-ui/core', () => ({
- ...jest.requireActual('@material-ui/core'),
- useMediaQuery: () => true, // Mock as desktop
- ThemeProvider: ({ children }) => {children}
,
-}))
-
-describe('Player Component', () => {
- const mockStore = createStore(
- combineReducers({
- player: playerReducer,
- settings: settingsReducer,
- replayGain: replayGainReducer,
- }),
- {
- player: {
- queue: [
- {
- uuid: '1',
- musicSrc: 'song1.mp3',
- title: 'Song 1',
- artist: 'Artist 1',
- },
- ],
- current: {
- uuid: '1',
- trackId: 'track1',
- song: { title: 'Song 1', artist: 'Artist 1' },
- },
- playIndex: 0,
- mode: 'single',
- volume: 0.8,
- clear: false,
- },
- settings: {
- notifications: true,
- },
- replayGain: {
- gainMode: 'track',
- },
- },
- )
-
- const renderPlayer = () => {
- return render(
-
-
-
-
- ,
- )
+vi.mock('@material-ui/core', async () => {
+ const actual = await import('@material-ui/core')
+ return {
+ ...actual,
+ useMediaQuery: vi.fn(),
}
+})
+
+vi.mock('react-admin', () => ({
+ useGetOne: vi.fn(),
+}))
+
+vi.mock('react-redux', () => ({
+ useDispatch: vi.fn(),
+}))
+
+vi.mock('../common', () => ({
+ LoveButton: ({ className, disabled }) => (
+
+ ),
+ useToggleLove: vi.fn(),
+}))
+
+vi.mock('../actions', () => ({
+ openSaveQueueDialog: vi.fn(),
+}))
+
+vi.mock('react-hotkeys', () => ({
+ GlobalHotKeys: () => ,
+}))
+
+describe('', () => {
+ const mockToggleLove = vi.fn()
+ const mockDispatch = vi.fn()
+ const mockSongData = { id: 'song-1', name: 'Test Song', starred: false }
beforeEach(() => {
- jest.clearAllMocks()
+ vi.clearAllMocks()
+ useGetOne.mockReturnValue({ data: mockSongData, loading: false })
+ useToggleLove.mockReturnValue([mockToggleLove, false])
+ useDispatch.mockReturnValue(mockDispatch)
+ openSaveQueueDialog.mockReturnValue({ type: 'OPEN_SAVE_QUEUE_DIALOG' })
})
- it('should render player when authenticated with queue', () => {
- renderPlayer()
+ afterEach(cleanup)
- expect(screen.getByTestId('react-jk-music-player')).toBeInTheDocument()
- expect(screen.getByTestId('audio-title')).toBeInTheDocument()
- expect(screen.getByTestId('player-toolbar')).toBeInTheDocument()
+ describe('Desktop layout', () => {
+ beforeEach(() => {
+ useMediaQuery.mockReturnValue(true) // isDesktop = true
+ })
+
+ it('renders desktop toolbar with both buttons', () => {
+ render()
+
+ // Both buttons should be in a single list item
+ const listItems = screen.getAllByRole('listitem')
+ expect(listItems).toHaveLength(1)
+
+ // Verify both buttons are rendered
+ expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
+ expect(screen.getByTestId('love-button')).toBeInTheDocument()
+
+ // Verify desktop classes are applied
+ expect(listItems[0].className).toContain('toolbar')
+ })
+
+ it('disables save queue button when isRadio is true', () => {
+ render()
+
+ const saveQueueButton = screen.getByTestId('save-queue-button')
+ expect(saveQueueButton).toBeDisabled()
+ })
+
+ it('disables love button when conditions are met', () => {
+ useGetOne.mockReturnValue({ data: mockSongData, loading: true })
+
+ render()
+
+ const loveButton = screen.getByTestId('love-button')
+ expect(loveButton).toBeDisabled()
+ })
+
+ it('opens save queue dialog when save button is clicked', () => {
+ render()
+
+ const saveQueueButton = screen.getByTestId('save-queue-button')
+ fireEvent.click(saveQueueButton)
+
+ expect(mockDispatch).toHaveBeenCalledWith({
+ type: 'OPEN_SAVE_QUEUE_DIALOG',
+ })
+ })
})
- it('should not render when not authenticated', () => {
- // Mock unauthenticated state
- const { useAuthState } = jest.requireMock('react-admin')
- useAuthState.mockReturnValue({ authenticated: false })
+ describe('Mobile layout', () => {
+ beforeEach(() => {
+ useMediaQuery.mockReturnValue(false) // isDesktop = false
+ })
- const { container } = renderPlayer()
- expect(container.firstChild).toBeNull()
+ it('renders mobile toolbar with buttons in separate list items', () => {
+ render()
- // Reset mock
- const { useAuthState: originalUseAuthState } =
- jest.requireMock('react-admin')
- originalUseAuthState.mockReturnValue({ authenticated: true })
+ // Each button should be in its own list item
+ const listItems = screen.getAllByRole('listitem')
+ expect(listItems).toHaveLength(2)
+
+ // Verify both buttons are rendered
+ expect(screen.getByTestId('save-queue-button')).toBeInTheDocument()
+ expect(screen.getByTestId('love-button')).toBeInTheDocument()
+
+ // Verify mobile classes are applied
+ expect(listItems[0].className).toContain('mobileListItem')
+ expect(listItems[1].className).toContain('mobileListItem')
+ })
+
+ it('disables save queue button when isRadio is true', () => {
+ render()
+
+ const saveQueueButton = screen.getByTestId('save-queue-button')
+ expect(saveQueueButton).toBeDisabled()
+ })
+
+ it('disables love button when conditions are met', () => {
+ useGetOne.mockReturnValue({ data: mockSongData, loading: true })
+
+ render()
+
+ const loveButton = screen.getByTestId('love-button')
+ expect(loveButton).toBeDisabled()
+ })
})
- it('should not render when queue is empty', () => {
- const emptyStore = createStore(
- combineReducers({
- player: playerReducer,
- settings: settingsReducer,
- replayGain: replayGainReducer,
- }),
- {
- player: { queue: [] },
- settings: { notifications: true },
- replayGain: { gainMode: 'track' },
- },
- )
+ describe('Common behavior', () => {
+ it('renders global hotkeys in both layouts', () => {
+ // Test desktop layout
+ useMediaQuery.mockReturnValue(true)
+ render()
+ expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
- const { container } = render(
-
-
-
-
- ,
- )
+ // Cleanup and test mobile layout
+ cleanup()
+ useMediaQuery.mockReturnValue(false)
+ render()
+ expect(screen.getByTestId('global-hotkeys')).toBeInTheDocument()
+ })
- expect(container.firstChild).toBeNull()
+ it('disables buttons when id is not provided', () => {
+ render()
+
+ const loveButton = screen.getByTestId('love-button')
+ expect(loveButton).toBeDisabled()
+ })
})
-
- it('should have proper accessibility attributes', () => {
- renderPlayer()
-
- const playerRegion = screen.getByRole('region')
- expect(playerRegion).toHaveAttribute('aria-label', 'player.audioPlayer')
- expect(playerRegion).toHaveAttribute('aria-live', 'polite')
- })
-
- it('should render audio title with correct information', () => {
- renderPlayer()
-
- expect(screen.getByTestId('audio-title')).toHaveTextContent('Song 1')
- })
-
- it('should render player toolbar with track ID', () => {
- renderPlayer()
-
- expect(screen.getByTestId('player-toolbar')).toHaveTextContent('track1')
- })
-
- it('should handle mobile player detection', () => {
- // Mock mobile detection
- const { useMediaQuery } = jest.requireMock('@material-ui/core')
- useMediaQuery.mockReturnValue(false) // Mobile
-
- renderPlayer()
-
- // Mobile-specific logic should be applied
- // This would be tested more thoroughly with actual mobile behavior
- })
-
- it('should update document title when not visible', () => {
- // Mock empty queue to make player not visible
- const emptyStore = createStore(
- combineReducers({
- player: playerReducer,
- settings: settingsReducer,
- replayGain: replayGainReducer,
- }),
- {
- player: { queue: [] },
- settings: { notifications: true },
- replayGain: { gainMode: 'track' },
- },
- )
-
- render(
-
-
-
-
- ,
- )
-
- // Document title should be reset when player is not visible
- expect(document.title).toBe('Navidrome')
- })
-
- it('should integrate with theme provider', () => {
- renderPlayer()
-
- // ThemeProvider should wrap the component
- expect(
- screen.getByTestId('react-jk-music-player').parentElement,
- ).toBeInTheDocument()
- })
-})
+})
\ No newline at end of file
diff --git a/ui/src/audioplayer/hooks/usePlayerState.test.js b/ui/src/audioplayer/hooks/usePlayerState.test.js
index d8b4f1ad1..db3549bde 100644
--- a/ui/src/audioplayer/hooks/usePlayerState.test.js
+++ b/ui/src/audioplayer/hooks/usePlayerState.test.js
@@ -1,3 +1,5 @@
+/* eslint-env node */
+
import { renderHook } from '@testing-library/react-hooks'
import { usePlayerState } from './usePlayerState'
import { useDispatch, useSelector } from 'react-redux'
@@ -32,6 +34,7 @@ describe('usePlayerState', () => {
const mockDispatch = vi.fn()
beforeEach(() => {
+ vi.resetModules()
vi.clearAllMocks()
useDispatch.mockReturnValue(mockDispatch)
useSelector.mockReturnValue(mockPlayerState)