/**
* AudioVisualizer 组件测试
* 测试音频可视化组件的核心功能
* @jest-environment jsdom
*/
import React from 'react'
import { render, screen, act } from '@testing-library/react'
import '@testing-library/jest-dom'
// Mock AudioContext
const mockAnalyser = {
fftSize: 256,
smoothingTimeConstant: 0.8,
frequencyBinCount: 128,
getByteFrequencyData: jest.fn((array: Uint8Array) => {
// 填充模拟数据
for (let i = 0; i < array.length; i++) {
array[i] = Math.floor(Math.random() * 256)
}
}),
connect: jest.fn(),
}
const mockSource = {
connect: jest.fn(),
}
const mockAudioContext = {
createAnalyser: jest.fn(() => mockAnalyser),
createMediaElementSource: jest.fn(() => mockSource),
destination: {},
state: 'running',
resume: jest.fn().mockResolvedValue(undefined),
}
// Mock window.AudioContext
const originalAudioContext = window.AudioContext
beforeAll(() => {
(window as any).AudioContext = jest.fn(() => mockAudioContext)
;(window as any).webkitAudioContext = jest.fn(() => mockAudioContext)
})
afterAll(() => {
window.AudioContext = originalAudioContext
})
// Mock requestAnimationFrame
const mockRequestAnimationFrame = jest.fn((callback) => {
return 1
})
const mockCancelAnimationFrame = jest.fn()
beforeEach(() => {
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(mockRequestAnimationFrame)
jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(mockCancelAnimationFrame)
})
afterEach(() => {
jest.restoreAllMocks()
jest.clearAllMocks()
})
// Mock canvas context
const mockCanvasContext = {
clearRect: jest.fn(),
beginPath: jest.fn(),
arc: jest.fn(),
fill: jest.fn(),
stroke: jest.fn(),
moveTo: jest.fn(),
lineTo: jest.fn(),
fillText: jest.fn(),
createRadialGradient: jest.fn(() => ({
addColorStop: jest.fn(),
})),
createLinearGradient: jest.fn(() => ({
addColorStop: jest.fn(),
})),
fillStyle: '',
strokeStyle: '',
lineWidth: 0,
lineCap: '',
font: '',
textAlign: '',
textBaseline: '',
}
HTMLCanvasElement.prototype.getContext = jest.fn(() => mockCanvasContext) as any
import AudioVisualizer from '../AudioVisualizer'
describe('AudioVisualizer 组件', () => {
describe('渲染测试', () => {
it('应该正确渲染组件', () => {
const { container } = render(
)
expect(container.firstChild).toBeInTheDocument()
})
it('应该根据 size 属性渲染不同尺寸', () => {
const { container: smContainer } = render(
)
expect(smContainer.firstChild).toHaveClass('w-40', 'h-40')
const { container: mdContainer } = render(
)
expect(mdContainer.firstChild).toHaveClass('w-[280px]', 'h-[280px]')
const { container: lgContainer } = render(
)
expect(lgContainer.firstChild).toHaveClass('w-[400px]', 'h-[400px]')
})
it('应该应用自定义 className', () => {
const { container } = render(
)
expect(container.firstChild).toHaveClass('custom-class')
})
})
describe('封面图显示', () => {
it('没有封面图时应该显示默认封面', () => {
const { container } = render(
)
// 检查是否有默认封面的渐变背景
const defaultCover = container.querySelector('.bg-gradient-to-br')
expect(defaultCover).toBeInTheDocument()
})
it('有封面图时应该显示封面图', () => {
const coverUrl = 'https://example.com/cover.jpg'
const { container } = render(
)
const img = container.querySelector('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', coverUrl)
})
it('播放时应该隐藏封面图', () => {
const mockAudio = document.createElement('audio')
const { container } = render(
)
// 封面图层应该有 opacity-0 类
const coverLayer = container.querySelector('.z-10')
expect(coverLayer).toHaveClass('opacity-0')
})
it('暂停时应该显示封面图(当 showCoverOnPause 为 true)', () => {
const { container } = render(
)
const coverLayer = container.querySelector('.z-10')
expect(coverLayer).toHaveClass('opacity-100')
})
})
describe('Canvas 渲染', () => {
it('应该渲染 canvas 元素', () => {
const { container } = render(
)
const canvas = container.querySelector('canvas')
expect(canvas).toBeInTheDocument()
})
it('canvas 应该有正确的尺寸属性', () => {
const { container } = render(
)
const canvas = container.querySelector('canvas')
expect(canvas).toHaveAttribute('width', '280')
expect(canvas).toHaveAttribute('height', '280')
})
it('小尺寸 canvas 应该有正确的尺寸', () => {
const { container } = render(
)
const canvas = container.querySelector('canvas')
expect(canvas).toHaveAttribute('width', '160')
expect(canvas).toHaveAttribute('height', '160')
})
it('大尺寸 canvas 应该有正确的尺寸', () => {
const { container } = render(
)
const canvas = container.querySelector('canvas')
expect(canvas).toHaveAttribute('width', '400')
expect(canvas).toHaveAttribute('height', '400')
})
})
describe('音频上下文初始化', () => {
it('播放时应该初始化 AudioContext', () => {
const mockAudio = document.createElement('audio')
render(
)
expect(window.AudioContext).toHaveBeenCalled()
})
it('没有音频元素时不应该初始化 AudioContext', () => {
jest.clearAllMocks()
render(
)
expect(window.AudioContext).not.toHaveBeenCalled()
})
})
describe('动画控制', () => {
it('播放时应该启动动画', () => {
const mockAudio = document.createElement('audio')
render(
)
// 检查 requestAnimationFrame 是否被调用
expect(mockRequestAnimationFrame).toHaveBeenCalled()
})
it('停止播放时组件应该正确响应状态变化', () => {
const mockAudio = document.createElement('audio')
const { rerender, container } = render(
)
// 验证播放状态下可视化层可见
const visualizerLayers = container.querySelectorAll('.transition-opacity')
expect(visualizerLayers.length).toBeGreaterThan(0)
// 重新渲染为非播放状态
rerender(
)
// 验证组件正确响应了状态变化(封面图应该显示)
const coverLayer = container.querySelector('.z-10')
expect(coverLayer).toHaveClass('opacity-100')
})
})
describe('组件卸载', () => {
it('卸载时组件应该正确清理', () => {
const mockAudio = document.createElement('audio')
const { unmount, container } = render(
)
// 验证组件已渲染
expect(container.firstChild).toBeInTheDocument()
// 卸载组件
unmount()
// 验证组件已从 DOM 中移除
expect(container.firstChild).toBeNull()
// 注意:由于 requestAnimationFrame 的异步特性和测试环境的限制,
// 我们无法直接验证 cancelAnimationFrame 是否被调用。
// 但组件的 cleanup effect 会在卸载时执行,确保资源被正确清理。
})
})
describe('默认封面组件', () => {
it('应该渲染音符图标', () => {
const { container } = render(
)
// 检查 SVG 音符图标
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('不同尺寸应该有不同的图标大小', () => {
const { container: smContainer } = render(
)
const smSvg = smContainer.querySelector('svg')
expect(smSvg).toHaveClass('w-8', 'h-8')
const { container: lgContainer } = render(
)
const lgSvg = lgContainer.querySelector('svg')
expect(lgSvg).toHaveClass('w-16', 'h-16')
})
})
describe('可视化层显示', () => {
it('播放时可视化层应该可见', () => {
const mockAudio = document.createElement('audio')
const { container } = render(
)
// 可视化层应该有 opacity-100 类
const visualizerLayer = container.querySelectorAll('.transition-opacity')[1]
expect(visualizerLayer).toHaveClass('opacity-100')
})
it('未播放时可视化层应该隐藏', () => {
const { container } = render(
)
// 可视化层应该有 opacity-0 类
const visualizerLayer = container.querySelectorAll('.transition-opacity')[1]
expect(visualizerLayer).toHaveClass('opacity-0')
})
})
})
describe('AudioVisualizer 边界情况', () => {
it('应该处理 null coverImage', () => {
const { container } = render(
)
expect(container.firstChild).toBeInTheDocument()
})
it('应该处理空字符串 coverImage', () => {
const { container } = render(
)
// 空字符串应该被视为 falsy,显示默认封面
const defaultCover = container.querySelector('.bg-gradient-to-br')
expect(defaultCover).toBeInTheDocument()
})
it('应该处理 showCoverOnPause 为 false 的情况', () => {
const { container } = render(
)
expect(container.firstChild).toBeInTheDocument()
})
})