2025-05-30 17:26:35 -08:00
|
|
|
import React from 'react'
|
|
|
|
|
import { render, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
|
|
|
import { TestContext } from 'ra-test'
|
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
|
import { SongContextMenu } from './SongContextMenu'
|
2026-01-25 12:16:43 -09:00
|
|
|
import subsonic from '../subsonic'
|
2025-05-30 17:26:35 -08:00
|
|
|
|
|
|
|
|
vi.mock('../dataProvider', () => ({
|
|
|
|
|
httpClient: vi.fn(),
|
|
|
|
|
}))
|
|
|
|
|
|
2026-01-25 12:16:43 -09:00
|
|
|
vi.mock('../subsonic', () => ({
|
|
|
|
|
default: { getSimilarSongs2: vi.fn() },
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
vi.mock('../config', () => ({
|
|
|
|
|
default: {
|
|
|
|
|
enableDownloads: true,
|
|
|
|
|
enableFavourites: true,
|
|
|
|
|
enableSharing: true,
|
|
|
|
|
enableExternalServices: true,
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
const mockDispatch = vi.fn()
|
|
|
|
|
vi.mock('react-redux', () => ({ useDispatch: () => mockDispatch }))
|
2025-05-30 17:26:35 -08:00
|
|
|
|
2025-07-17 07:00:12 -08:00
|
|
|
const getPlaylistsMock = vi.fn()
|
2026-01-25 12:16:43 -09:00
|
|
|
const mockNotify = vi.fn()
|
2025-07-17 07:00:12 -08:00
|
|
|
|
2025-05-30 17:26:35 -08:00
|
|
|
vi.mock('react-admin', async (importOriginal) => {
|
|
|
|
|
const actual = await importOriginal()
|
|
|
|
|
return {
|
|
|
|
|
...actual,
|
2026-01-25 12:16:43 -09:00
|
|
|
useNotify: () => mockNotify,
|
2025-05-30 17:26:35 -08:00
|
|
|
useRedirect: () => (url) => {
|
|
|
|
|
window.location.hash = `#${url}`
|
|
|
|
|
},
|
|
|
|
|
useDataProvider: () => ({
|
2025-07-17 07:00:12 -08:00
|
|
|
getPlaylists: getPlaylistsMock,
|
2025-05-30 17:26:35 -08:00
|
|
|
inspect: vi.fn().mockResolvedValue({
|
|
|
|
|
data: { rawTags: {} },
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('SongContextMenu', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks()
|
|
|
|
|
window.location.hash = ''
|
2025-07-17 07:00:12 -08:00
|
|
|
getPlaylistsMock.mockResolvedValue({
|
|
|
|
|
data: [{ id: 'pl1', name: 'Pl 1' }],
|
|
|
|
|
})
|
2026-01-25 12:16:43 -09:00
|
|
|
subsonic.getSimilarSongs2.mockResolvedValue({
|
|
|
|
|
json: {
|
|
|
|
|
'subsonic-response': {
|
|
|
|
|
status: 'ok',
|
|
|
|
|
similarSongs2: { song: [{ id: 's1' }] },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
2025-05-30 17:26:35 -08:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('navigates to playlist when selected', async () => {
|
|
|
|
|
render(
|
|
|
|
|
<TestContext>
|
|
|
|
|
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
|
|
|
|
|
</TestContext>,
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getAllByRole('button')[1])
|
|
|
|
|
await waitFor(() =>
|
|
|
|
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(
|
|
|
|
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
|
|
|
|
)
|
|
|
|
|
await waitFor(() => screen.getByText('Pl 1'))
|
|
|
|
|
fireEvent.click(screen.getByText('Pl 1'))
|
|
|
|
|
expect(window.location.hash).toBe('#/playlist/pl1/show')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('stops event propagation when playlist submenu is closed', async () => {
|
|
|
|
|
const mockOnClick = vi.fn()
|
|
|
|
|
render(
|
|
|
|
|
<TestContext>
|
|
|
|
|
<div onClick={mockOnClick}>
|
|
|
|
|
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
|
|
|
|
|
</div>
|
|
|
|
|
</TestContext>,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Open main menu
|
|
|
|
|
fireEvent.click(screen.getAllByRole('button')[1])
|
|
|
|
|
await waitFor(() =>
|
|
|
|
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Open playlist submenu
|
|
|
|
|
fireEvent.click(
|
|
|
|
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
|
|
|
|
)
|
|
|
|
|
await waitFor(() => screen.getByText('Pl 1'))
|
|
|
|
|
|
|
|
|
|
// Click outside the playlist submenu (should close it without triggering parent click)
|
|
|
|
|
fireEvent.click(document.body)
|
|
|
|
|
|
|
|
|
|
expect(mockOnClick).not.toHaveBeenCalled()
|
|
|
|
|
})
|
2025-07-17 07:00:12 -08:00
|
|
|
|
|
|
|
|
it('does nothing when "Show in Playlist" is disabled', async () => {
|
|
|
|
|
getPlaylistsMock.mockResolvedValue({ data: [] })
|
|
|
|
|
const mockOnClick = vi.fn()
|
|
|
|
|
render(
|
|
|
|
|
<TestContext>
|
|
|
|
|
<div onClick={mockOnClick}>
|
|
|
|
|
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
|
|
|
|
|
</div>
|
|
|
|
|
</TestContext>,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getAllByRole('button')[1])
|
|
|
|
|
await waitFor(() =>
|
|
|
|
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fireEvent.click(
|
|
|
|
|
screen.getByText(/resources\.song\.actions\.showInPlaylist/),
|
|
|
|
|
)
|
|
|
|
|
expect(mockOnClick).not.toHaveBeenCalled()
|
|
|
|
|
})
|
2026-01-25 12:16:43 -09:00
|
|
|
|
|
|
|
|
describe('Instant Mix action', () => {
|
|
|
|
|
it('calls getSimilarSongs2 with song id and shows loading notification', async () => {
|
|
|
|
|
render(
|
|
|
|
|
<TestContext>
|
|
|
|
|
<SongContextMenu record={{ id: 'song1', size: 1 }} resource="song" />
|
|
|
|
|
</TestContext>,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getAllByRole('button')[1])
|
|
|
|
|
await waitFor(() =>
|
|
|
|
|
screen.getByText(/resources\.song\.actions\.instantMix/),
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/))
|
|
|
|
|
|
|
|
|
|
// Verify loading notification is shown
|
|
|
|
|
expect(mockNotify).toHaveBeenCalledWith('message.startingInstantMix', {
|
|
|
|
|
type: 'info',
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await waitFor(() =>
|
|
|
|
|
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith('song1', 100),
|
|
|
|
|
)
|
|
|
|
|
expect(mockDispatch).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('plays seed song first followed by similar songs', async () => {
|
|
|
|
|
const seedRecord = { id: 'song1', title: 'Seed Song', size: 1 }
|
|
|
|
|
render(
|
|
|
|
|
<TestContext>
|
|
|
|
|
<SongContextMenu record={seedRecord} resource="song" />
|
|
|
|
|
</TestContext>,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getAllByRole('button')[1])
|
|
|
|
|
await waitFor(() =>
|
|
|
|
|
screen.getByText(/resources\.song\.actions\.instantMix/),
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(mockDispatch).toHaveBeenCalled())
|
|
|
|
|
|
|
|
|
|
// Verify dispatch was called with playTracks action
|
|
|
|
|
const dispatchCall = mockDispatch.mock.calls.find(
|
|
|
|
|
(call) => call[0]?.type === 'PLAYER_PLAY_TRACKS',
|
|
|
|
|
)
|
|
|
|
|
expect(dispatchCall).toBeDefined()
|
|
|
|
|
|
|
|
|
|
// Verify seed song is first (id property contains the first song to play)
|
|
|
|
|
const { id, data } = dispatchCall[0]
|
|
|
|
|
expect(id).toBe('song1')
|
|
|
|
|
// Verify seed song data is included
|
|
|
|
|
expect(data['song1']).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses mediaFileId when available (playlist context)', async () => {
|
|
|
|
|
render(
|
|
|
|
|
<TestContext>
|
|
|
|
|
<SongContextMenu
|
|
|
|
|
record={{
|
|
|
|
|
id: 'playlistTrackId',
|
|
|
|
|
mediaFileId: 'actualSongId',
|
|
|
|
|
size: 1,
|
|
|
|
|
}}
|
|
|
|
|
resource="song"
|
|
|
|
|
/>
|
|
|
|
|
</TestContext>,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getAllByRole('button')[1])
|
|
|
|
|
await waitFor(() =>
|
|
|
|
|
screen.getByText(/resources\.song\.actions\.instantMix/),
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(screen.getByText(/resources\.song\.actions\.instantMix/))
|
|
|
|
|
|
|
|
|
|
await waitFor(() =>
|
|
|
|
|
expect(subsonic.getSimilarSongs2).toHaveBeenCalledWith(
|
|
|
|
|
'actualSongId',
|
|
|
|
|
100,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(mockDispatch).toHaveBeenCalled())
|
|
|
|
|
|
|
|
|
|
// Verify the mediaFileId is used as the seed song id
|
|
|
|
|
const dispatchCall = mockDispatch.mock.calls.find(
|
|
|
|
|
(call) => call[0]?.type === 'PLAYER_PLAY_TRACKS',
|
|
|
|
|
)
|
|
|
|
|
expect(dispatchCall).toBeDefined()
|
|
|
|
|
const { id, data } = dispatchCall[0]
|
|
|
|
|
expect(id).toBe('actualSongId')
|
|
|
|
|
// Verify seed song data is included
|
|
|
|
|
expect(data['actualSongId']).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
})
|
2025-05-30 17:26:35 -08:00
|
|
|
})
|