feat: navidrome deep link replaces in-app player
This commit is contained in:
@@ -3,7 +3,6 @@ import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||
import ItemPage from '../src/pages/ItemPage.js'
|
||||
import { PlayerProvider } from '../src/player/PlayerContext.js'
|
||||
import type { ItemDetail, MatchedAlbum } from '../src/types.js'
|
||||
|
||||
vi.mock('../src/api.js', async (importOriginal) => {
|
||||
@@ -17,8 +16,6 @@ vi.mock('../src/api.js', async (importOriginal) => {
|
||||
setMatch: vi.fn(),
|
||||
deleteItem: vi.fn(),
|
||||
searchAlbums: vi.fn(),
|
||||
getAlbumTracks: vi.fn(),
|
||||
markPlayed: vi.fn(),
|
||||
lendItem: vi.fn(),
|
||||
returnLoan: vi.fn(),
|
||||
},
|
||||
@@ -48,20 +45,21 @@ const item: ItemDetail = {
|
||||
loan: null,
|
||||
}
|
||||
|
||||
const matched: MatchedAlbum = { id: 77, subsonicId: 'alb-1', lastPlayedAt: '2026-09-01T10:00:00Z' }
|
||||
const matched: MatchedAlbum = {
|
||||
id: 77,
|
||||
subsonicId: 'alb-1',
|
||||
lastPlayedAt: '2026-09-01T10:00:00Z',
|
||||
webUrl: 'http://navidrome.local/app/#/album/alb-1',
|
||||
}
|
||||
const rippedItem: ItemDetail = { ...item, ripStatus: 'ripped', matchedAlbum: matched }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'pause').mockReturnValue(undefined)
|
||||
vi.mocked(api.getItem).mockReset()
|
||||
vi.mocked(api.getItem).mockResolvedValue(item as never)
|
||||
vi.mocked(api.setRip).mockReset()
|
||||
vi.mocked(api.setMatch).mockReset()
|
||||
vi.mocked(api.deleteItem).mockReset()
|
||||
vi.mocked(api.searchAlbums).mockReset()
|
||||
vi.mocked(api.getAlbumTracks).mockReset()
|
||||
vi.mocked(api.markPlayed).mockReset()
|
||||
vi.mocked(api.lendItem).mockReset()
|
||||
vi.mocked(api.returnLoan).mockReset()
|
||||
})
|
||||
@@ -69,12 +67,10 @@ beforeEach(() => {
|
||||
function renderItem() {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/item/1']}>
|
||||
<PlayerProvider>
|
||||
<Routes>
|
||||
<Route path="/item/:id" element={<ItemPage />} />
|
||||
<Route path="/library" element={<p>library</p>} />
|
||||
</Routes>
|
||||
</PlayerProvider>
|
||||
<Routes>
|
||||
<Route path="/item/:id" element={<ItemPage />} />
|
||||
<Route path="/library" element={<p>library</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
@@ -119,7 +115,7 @@ describe('ItemPage', () => {
|
||||
await waitFor(() => expect(api.setMatch).toHaveBeenCalledWith(1, 77))
|
||||
})
|
||||
|
||||
it('re-match refreshes the play button state', async () => {
|
||||
it('re-match refreshes the link state', async () => {
|
||||
vi.mocked(api.getItem)
|
||||
.mockResolvedValueOnce({ ...rippedItem, matchedAlbum: null } as never)
|
||||
.mockResolvedValue({ ...rippedItem } as never)
|
||||
@@ -129,7 +125,7 @@ describe('ItemPage', () => {
|
||||
vi.mocked(api.setMatch).mockResolvedValue({ ...item } as never)
|
||||
renderItem()
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
|
||||
expect(screen.queryByRole('button', { name: /play album/i })).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /re-match/i }))
|
||||
await userEvent.click(screen.getByRole('button', { name: /^search$/i }))
|
||||
@@ -137,7 +133,7 @@ describe('ItemPage', () => {
|
||||
await userEvent.click(albumRadio)
|
||||
await userEvent.click(screen.getByRole('button', { name: /^link$/i }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /play album/i })).toBeTruthy())
|
||||
await waitFor(() => expect(screen.getByRole('link', { name: /listen in navidrome/i })).toBeTruthy())
|
||||
})
|
||||
|
||||
it('unlink clears the match', async () => {
|
||||
@@ -189,21 +185,19 @@ describe('ItemPage', () => {
|
||||
await waitFor(() => expect(screen.getByText(/didn't work/i)).toBeTruthy())
|
||||
})
|
||||
|
||||
it('shows Play for a ripped item with a matched album and loads the player', async () => {
|
||||
it('shows Listen in Navidrome link for a ripped item with a matched album', async () => {
|
||||
vi.mocked(api.getItem).mockResolvedValue(rippedItem as never)
|
||||
vi.mocked(api.getAlbumTracks).mockResolvedValue({ id: 'alb-1', title: 'Motion', artist: 'TCO', tracks: [] } as never)
|
||||
vi.mocked(api.markPlayed).mockResolvedValue({ ok: true } as never)
|
||||
renderItem()
|
||||
const play = await screen.findByRole('button', { name: /play album/i })
|
||||
await userEvent.click(play)
|
||||
await waitFor(() => expect(api.getAlbumTracks).toHaveBeenCalledWith('alb-1'))
|
||||
await waitFor(() => expect(api.markPlayed).toHaveBeenCalledWith('alb-1'))
|
||||
const link = await screen.findByRole('link', { name: /listen in navidrome/i })
|
||||
expect(link.getAttribute('href')).toBe('http://navidrome.local/app/#/album/alb-1')
|
||||
expect(link.getAttribute('target')).toBe('_blank')
|
||||
})
|
||||
|
||||
it('hides Play when unmatched or not ripped', async () => {
|
||||
it('hides the link when matchedAlbum is null even if ripped', async () => {
|
||||
vi.mocked(api.getItem).mockResolvedValue({ ...rippedItem, matchedAlbum: null } as never)
|
||||
renderItem()
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Motion' })).toBeTruthy())
|
||||
expect(screen.queryByRole('button', { name: /play album/i })).toBeNull()
|
||||
expect(screen.queryByRole('link', { name: /listen in navidrome/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('shows last played under the rip banner', async () => {
|
||||
|
||||
Reference in New Issue
Block a user