teqo-backup/tests/unit/jinglePlayer.unit.spec.tsx

261 lines
9.3 KiB
TypeScript

import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { JinglePlayer } from '@/components/jingles/JinglePlayer'
import type { JingleViewModel } from '@/lib/jingle'
import { JINGLE_PLAY_EVENT, RADIO_PLAY_EVENT } from '@/lib/radio'
const JINGLES: readonly JingleViewModel[] = [
{
id: 1,
title: 'Axé',
coverUrl: '/api/media/file/capa-axe.jpg',
coverAlt: 'Capa do jingle Axé de Jorge Solla 1313',
audioUrl: '/api/media/file/axe.mp3',
downloadFilename: 'jorge-solla-1313-axe.mp3',
},
{
id: 2,
title: 'Forró',
coverUrl: '/api/media/file/capa-forro.jpg',
coverAlt: 'Capa do jingle Forró de Jorge Solla 1313',
audioUrl: '/api/media/file/forro.mp3',
downloadFilename: 'jorge-solla-1313-forro.mp3',
},
]
const playMock = vi.fn(() => Promise.resolve())
const pauseMock = vi.fn()
// S26 — own fixture: the specs above count the "Pronto para tocar" texts of
// the shared array, so the credited title stays out of `JINGLES`.
const FEAT_JINGLE: JingleViewModel = {
id: 9,
title: 'Jorge Solla 1313 (feat. Felipe Forrozeiro)',
coverUrl: '/api/media/file/capa-forro.jpg',
coverAlt: 'Capa do jingle Jorge Solla 1313 (feat. Felipe Forrozeiro) de Jorge Solla 1313',
audioUrl: '/api/media/file/forro.mp3',
downloadFilename: 'jorge-solla-1313-forro.mp3',
}
const cards = () => Array.from(document.querySelectorAll<HTMLElement>('[data-jingle]'))
const cardState = (index: number) => cards()[index]?.getAttribute('data-state')
const playButton = (index: number) =>
cards()[index]?.querySelector<HTMLButtonElement>('button[data-play]') as HTMLButtonElement
const audioOf = (index: number) =>
cards()[index]?.querySelector<HTMLAudioElement>('audio') as HTMLAudioElement
const progressValue = (title: string) =>
document.querySelector<HTMLElement>(`[aria-label="Progresso do jingle ${title}"] > div > div`)
beforeAll(() => {
// jsdom does not implement media playback; the state machine is the contract.
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
configurable: true,
value: () => playMock(),
})
Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
configurable: true,
value: () => pauseMock(),
})
Object.defineProperty(HTMLMediaElement.prototype, 'currentTime', {
configurable: true,
writable: true,
value: 0,
})
})
beforeEach(() => {
playMock.mockClear()
playMock.mockImplementation(() => Promise.resolve())
pauseMock.mockClear()
})
afterEach(() => {
cleanup()
})
describe('JinglePlayer', () => {
it('renders the cards with the download named by slug and lazy audio', () => {
render(<JinglePlayer jingles={JINGLES} />)
const download = screen.getByRole('link', { name: 'Baixar Axé em MP3' })
expect(download.getAttribute('href')).toBe('/api/media/file/axe.mp3')
expect(download.getAttribute('download')).toBe('jorge-solla-1313-axe.mp3')
expect(screen.getByText('jorge-solla-1313-axe.mp3')).toBeDefined()
expect(screen.getByAltText('Capa do jingle Axé de Jorge Solla 1313')).toBeDefined()
expect(screen.getAllByText('Pronto para tocar')).toHaveLength(2)
expect(screen.queryByText('Em reprodução')).toBeNull()
for (const audio of Array.from(document.querySelectorAll('audio'))) {
expect(audio.getAttribute('preload')).toBe('none')
}
})
it('plays on click and pauses on a second click of the same card', () => {
render(<JinglePlayer jingles={JINGLES} />)
// The first play also pauses the sibling card (a no-op with it stopped),
// so the mock is cleared before pinning the pause of the card itself.
fireEvent.click(playButton(0))
expect(playMock).toHaveBeenCalledTimes(1)
expect(cardState(0)).toBe('playing')
expect(playButton(0).getAttribute('aria-pressed')).toBe('true')
expect(playButton(0).getAttribute('aria-label')).toBe('Pausar jingle Axé')
expect(screen.getByText('Em reprodução')).toBeDefined()
pauseMock.mockClear()
fireEvent.click(playButton(0))
expect(pauseMock).toHaveBeenCalledTimes(1)
expect(cardState(0)).toBe('stopped')
expect(playButton(0).getAttribute('aria-pressed')).toBe('false')
})
it('plays one at a time: starting another pauses the previous card', () => {
render(<JinglePlayer jingles={JINGLES} />)
fireEvent.click(playButton(0))
pauseMock.mockClear()
fireEvent.click(playButton(1))
expect(pauseMock).toHaveBeenCalledTimes(1)
expect(cardState(0)).toBe('stopped')
expect(cardState(1)).toBe('playing')
expect(playMock).toHaveBeenCalledTimes(2)
})
it('broadcasts jingle:play when a card starts', () => {
let broadcasts = 0
const listener = () => {
broadcasts += 1
}
window.addEventListener(JINGLE_PLAY_EVENT, listener)
render(<JinglePlayer jingles={JINGLES} />)
fireEvent.click(playButton(0))
window.removeEventListener(JINGLE_PLAY_EVENT, listener)
expect(broadcasts).toBe(1)
})
it('pauses the active card when the radio starts', () => {
render(<JinglePlayer jingles={JINGLES} />)
fireEvent.click(playButton(0))
pauseMock.mockClear()
act(() => {
window.dispatchEvent(new CustomEvent(RADIO_PLAY_EVENT, { detail: { source: 'radio' } }))
})
expect(pauseMock).toHaveBeenCalledTimes(1)
expect(cardState(0)).toBe('stopped')
expect(playButton(0).getAttribute('aria-pressed')).toBe('false')
})
it('ignores radio:play with no active card', () => {
render(<JinglePlayer jingles={JINGLES} />)
act(() => {
window.dispatchEvent(new CustomEvent(RADIO_PLAY_EVENT, { detail: { source: 'radio' } }))
})
expect(pauseMock).not.toHaveBeenCalled()
})
it('reverts the card when the browser refuses to play', async () => {
playMock.mockImplementationOnce(() => Promise.reject(new Error('blocked')))
render(<JinglePlayer jingles={JINGLES} />)
fireEvent.click(playButton(0))
await waitFor(() => {
expect(cardState(0)).toBe('stopped')
})
expect(playButton(0).getAttribute('aria-pressed')).toBe('false')
})
it('does not let a superseded play() rejection stop the card now playing', async () => {
// Switching cards aborts the pending play of the previous one: its catch
// must clear only its own id, never the active card.
let rejectFirstPlay: ((error: Error) => void) | undefined
playMock.mockImplementationOnce(
() =>
new Promise<void>((_resolve, reject) => {
rejectFirstPlay = reject
}),
)
render(<JinglePlayer jingles={JINGLES} />)
fireEvent.click(playButton(0))
fireEvent.click(playButton(1))
await act(async () => {
rejectFirstPlay?.(new Error('The play() request was interrupted'))
})
expect(cardState(1)).toBe('playing')
expect(playButton(1).getAttribute('aria-pressed')).toBe('true')
expect(cardState(0)).toBe('stopped')
})
it('shows the clock and progress from the media events', () => {
render(<JinglePlayer jingles={JINGLES} />)
expect(screen.getAllByText(/Tempo —:—/)).toHaveLength(2)
expect(screen.getAllByText(/Duração —:—/)).toHaveLength(2)
const audio = audioOf(0)
fireEvent.click(playButton(0))
Object.defineProperty(audio, 'duration', { configurable: true, value: 165 })
fireEvent.loadedMetadata(audio)
expect(screen.getByText(/Duração 02:45/)).toBeDefined()
audio.currentTime = 30
fireEvent.timeUpdate(audio)
expect(screen.getByText(/Tempo 00:30/)).toBeDefined()
expect(parseFloat(progressValue('Axé')?.style.width ?? '')).toBeCloseTo(18.18, 1)
fireEvent.ended(audio)
expect(cardState(0)).toBe('stopped')
expect(screen.getAllByText(/Tempo —:—/)).toHaveLength(2)
})
})
describe('JinglePlayer — título e crédito do artista (S26)', () => {
it('shows the base title untruncated and the credit on its own line, stopped and playing', () => {
render(<JinglePlayer jingles={[FEAT_JINGLE]} />)
const heading = screen.getByRole('heading', { level: 2, name: 'Jorge Solla 1313' })
expect(heading.className).not.toContain('truncate')
expect(heading.className).toContain('wrap-anywhere')
expect(screen.getByText('feat. Felipe Forrozeiro')).toBeDefined()
expect(screen.getByText('Pronto para tocar')).toBeDefined()
fireEvent.click(playButton(0))
expect(cardState(0)).toBe('playing')
expect(screen.getByText('Em reprodução')).toBeDefined()
expect(screen.getByText('feat. Felipe Forrozeiro')).toBeDefined()
})
it('keeps the full title in the play, progress and download labels', () => {
render(<JinglePlayer jingles={[FEAT_JINGLE]} />)
const fullTitle = 'Jorge Solla 1313 (feat. Felipe Forrozeiro)'
expect(playButton(0).getAttribute('aria-label')).toBe(`Tocar jingle ${fullTitle}`)
expect(document.querySelector(`[aria-label="Progresso do jingle ${fullTitle}"]`)).not.toBeNull()
expect(screen.getByRole('link', { name: `Baixar ${fullTitle} em MP3` })).toBeDefined()
})
it('reserves no credit line and still drops the truncation without a feat. segment', () => {
render(<JinglePlayer jingles={JINGLES} />)
expect(screen.queryByText(/^feat\./)).toBeNull()
for (const heading of screen.getAllByRole('heading', { level: 2 })) {
expect(heading.className).not.toContain('truncate')
expect(heading.className).toContain('wrap-anywhere')
}
})
})