import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, beforeEach, vi } from 'vitest'
import { Provider } from 'react-redux'
import { createStore, combineReducers } from 'redux'
import { activityReducer } from '../reducers'
import NowPlayingPanel from './NowPlayingPanel'
import subsonic from '../subsonic'
vi.mock('../subsonic', () => ({
default: {
getNowPlaying: vi.fn(),
getAvatarUrl: vi.fn(() => '/avatar'),
getCoverArtUrl: vi.fn(() => '/cover'),
},
}))
// Create a mock for useMediaQuery
const mockUseMediaQuery = vi.fn()
vi.mock('react-admin', async (importOriginal) => {
const actual = await importOriginal()
const redux = await import('react-redux')
return {
...actual,
useTranslate: () => (x) => x,
useSelector: redux.useSelector,
useDispatch: redux.useDispatch,
Link: ({ to, children, onClick, ...props }) => (
{
e.preventDefault() // Prevent navigation in tests
if (onClick) onClick(e)
}}
{...props}
>
{children}
),
}
})
// Mock the specific Material-UI hooks we need
vi.mock('@material-ui/core/useMediaQuery', () => ({
default: () => mockUseMediaQuery(),
}))
vi.mock('@material-ui/core/styles/useTheme', () => ({
default: () => ({
breakpoints: {
down: () => '(max-width:959.95px)', // Mock breakpoint string
},
}),
}))
describe('', () => {
const createMockStore = (overrides = {}) => {
const defaultState = {
activity: {
nowPlayingCount: 1,
serverStart: { startTime: Date.now() }, // Server is up by default
streamReconnected: 0,
...overrides,
},
}
return createStore(
combineReducers({ activity: activityReducer }),
defaultState,
)
}
afterEach(() => {
vi.useRealTimers()
})
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockUseMediaQuery.mockReturnValue(false) // Default to large screen
subsonic.getNowPlaying.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
nowPlaying: {
entry: [
{
playerId: 1,
username: 'u1',
playerName: 'Chrome Browser',
title: 'Song',
albumArtist: 'Artist',
albumId: 'album1',
albumArtistId: 'artist1',
minutesAgo: 2,
},
],
},
},
},
})
})
it('fetches and displays entries when opened', async () => {
const store = createMockStore()
render(
,
)
// Advance past debounce and flush promises
await vi.advanceTimersByTimeAsync(500)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('Artist')).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Artist' })).toHaveAttribute(
'href',
'/artist/artist1/show',
)
})
})
it('displays player name after username', async () => {
const store = createMockStore()
render(
,
)
await vi.advanceTimersByTimeAsync(500)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('u1 (Chrome Browser)')).toBeInTheDocument()
})
})
it('handles entries without player name', async () => {
subsonic.getNowPlaying.mockResolvedValue({
json: {
'subsonic-response': {
status: 'ok',
nowPlaying: {
entry: [
{
playerId: 1,
username: 'u1',
title: 'Song',
albumArtist: 'Artist',
albumId: 'album1',
albumArtistId: 'artist1',
minutesAgo: 2,
},
],
},
},
},
})
const store = createMockStore()
render(
,
)
await vi.advanceTimersByTimeAsync(500)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('u1')).toBeInTheDocument()
})
})
it('shows empty message when no entries', async () => {
subsonic.getNowPlaying.mockResolvedValue({
json: {
'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } },
},
})
const store = createMockStore({ nowPlayingCount: 0 })
render(
,
)
await vi.advanceTimersByTimeAsync(500)
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('nowPlaying.empty')).toBeInTheDocument()
})
})
it('does not close panel when artist link is clicked on large screens', async () => {
mockUseMediaQuery.mockReturnValue(false) // Simulate large screen
const store = createMockStore()
render(
,
)
await vi.advanceTimersByTimeAsync(500)
// Open the panel
fireEvent.click(screen.getByRole('button'))
await waitFor(() => {
expect(screen.getByText('Artist')).toBeInTheDocument()
})
// Check that the popover is open
expect(screen.getByRole('presentation')).toBeInTheDocument()
// Click the artist link
fireEvent.click(screen.getByRole('link', { name: 'Artist' }))
// Panel should remain open (popover should still be in document)
expect(screen.getByRole('presentation')).toBeInTheDocument()
expect(screen.getByText('Artist')).toBeInTheDocument()
})
it('does not fetch on mount when server is down', () => {
const store = createMockStore({
nowPlayingCount: 1,
serverStart: { startTime: null }, // Server is down
})
render(
,
)
// Should not have made initial fetch request due to server being down
expect(subsonic.getNowPlaying).not.toHaveBeenCalled()
})
it('does not fetch on stream reconnection when server is down', () => {
const store = createMockStore({
nowPlayingCount: 1,
serverStart: { startTime: null }, // Server is down
streamReconnected: Date.now(), // Stream reconnected
})
render(
,
)
// Should not have made fetch request due to server being down
expect(subsonic.getNowPlaying).not.toHaveBeenCalled()
})
it('does not double-fetch on server reconnection', async () => {
vi.useFakeTimers()
const initialStore = createMockStore({
nowPlayingCount: 1,
serverStart: { startTime: null }, // Server initially down
streamReconnected: 0,
})
const { rerender } = render(
,
)
// Clear initial (empty) calls
vi.clearAllMocks()
// Simulate server coming back up with stream reconnection (both state changes happen)
const reconnectedStore = createMockStore({
nowPlayingCount: 1,
serverStart: { startTime: Date.now() }, // Server back up
streamReconnected: Date.now(), // Stream reconnected
})
rerender(
,
)
// Advance past the debounce window
vi.advanceTimersByTime(500)
// Should only make one call despite both serverUp and streamReconnected changing
expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('skips polling when server is down', () => {
vi.useFakeTimers()
const store = createMockStore({
nowPlayingCount: 1,
serverStart: { startTime: null }, // Server is down
})
render(
,
)
// Clear initial mount fetch
vi.clearAllMocks()
// Advance time by 70 seconds to trigger polling interval
vi.advanceTimersByTime(70000)
// Should not have made any additional requests due to server being down
expect(subsonic.getNowPlaying).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('resumes polling when server comes back up', () => {
vi.useFakeTimers()
const store = createMockStore({
nowPlayingCount: 1,
serverStart: { startTime: null }, // Server is down
})
const { rerender } = render(
,
)
// Clear initial mount fetch
vi.clearAllMocks()
// Advance time - should not poll when server is down
vi.advanceTimersByTime(70000)
expect(subsonic.getNowPlaying).not.toHaveBeenCalled()
// Update state to indicate server is back up
const updatedStore = createMockStore({
nowPlayingCount: 1,
serverStart: { startTime: Date.now() }, // Server is back up
})
rerender(
,
)
// Clear the fetch that happens due to initial mount of rerender
vi.clearAllMocks()
// Advance time again - should now poll since server is up
vi.advanceTimersByTime(70000)
expect(subsonic.getNowPlaying).toHaveBeenCalled()
vi.useRealTimers()
})
})