/** * 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() }) })