mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
261 lines
9.3 KiB
TypeScript
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')
|
|
}
|
|
})
|
|
})
|