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