flnxi-Wu/components/__tests__/AudioVisualizer.test.tsx

407 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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(
<AudioVisualizer audioElement={null} isPlaying={false} />
)
expect(container.firstChild).toBeInTheDocument()
})
it('应该根据 size 属性渲染不同尺寸', () => {
const { container: smContainer } = render(
<AudioVisualizer audioElement={null} isPlaying={false} size="sm" />
)
expect(smContainer.firstChild).toHaveClass('w-40', 'h-40')
const { container: mdContainer } = render(
<AudioVisualizer audioElement={null} isPlaying={false} size="md" />
)
expect(mdContainer.firstChild).toHaveClass('w-[280px]', 'h-[280px]')
const { container: lgContainer } = render(
<AudioVisualizer audioElement={null} isPlaying={false} size="lg" />
)
expect(lgContainer.firstChild).toHaveClass('w-[400px]', 'h-[400px]')
})
it('应该应用自定义 className', () => {
const { container } = render(
<AudioVisualizer
audioElement={null}
isPlaying={false}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
})
describe('封面图显示', () => {
it('没有封面图时应该显示默认封面', () => {
const { container } = render(
<AudioVisualizer audioElement={null} isPlaying={false} />
)
// 检查是否有默认封面的中性背景
const defaultCover = container.querySelector('.bg-slate-100')
expect(defaultCover).toBeInTheDocument()
})
it('有封面图时应该显示封面图', () => {
const coverUrl = 'https://example.com/cover.jpg'
const { container } = render(
<AudioVisualizer
audioElement={null}
isPlaying={false}
coverImage={coverUrl}
/>
)
const img = container.querySelector('img')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', coverUrl)
})
it('播放时应该隐藏封面图', () => {
const mockAudio = document.createElement('audio')
const { container } = render(
<AudioVisualizer
audioElement={mockAudio}
isPlaying={true}
coverImage="https://example.com/cover.jpg"
showCoverOnPause={true}
/>
)
// 封面图层应该有 opacity-0 类
const coverLayer = container.querySelector('.z-10')
expect(coverLayer).toHaveClass('opacity-0')
})
it('暂停时应该显示封面图(当 showCoverOnPause 为 true)', () => {
const { container } = render(
<AudioVisualizer
audioElement={null}
isPlaying={false}
coverImage="https://example.com/cover.jpg"
showCoverOnPause={true}
/>
)
const coverLayer = container.querySelector('.z-10')
expect(coverLayer).toHaveClass('opacity-100')
})
})
describe('Canvas 渲染', () => {
it('应该渲染 canvas 元素', () => {
const { container } = render(
<AudioVisualizer audioElement={null} isPlaying={false} />
)
const canvas = container.querySelector('canvas')
expect(canvas).toBeInTheDocument()
})
it('canvas 应该有正确的尺寸属性', () => {
const { container } = render(
<AudioVisualizer audioElement={null} isPlaying={false} size="md" />
)
const canvas = container.querySelector('canvas')
expect(canvas).toHaveAttribute('width', '280')
expect(canvas).toHaveAttribute('height', '280')
})
it('小尺寸 canvas 应该有正确的尺寸', () => {
const { container } = render(
<AudioVisualizer audioElement={null} isPlaying={false} size="sm" />
)
const canvas = container.querySelector('canvas')
expect(canvas).toHaveAttribute('width', '160')
expect(canvas).toHaveAttribute('height', '160')
})
it('大尺寸 canvas 应该有正确的尺寸', () => {
const { container } = render(
<AudioVisualizer audioElement={null} isPlaying={false} size="lg" />
)
const canvas = container.querySelector('canvas')
expect(canvas).toHaveAttribute('width', '400')
expect(canvas).toHaveAttribute('height', '400')
})
})
describe('音频上下文初始化', () => {
it('播放时应该初始化 AudioContext', () => {
const mockAudio = document.createElement('audio')
render(
<AudioVisualizer
audioElement={mockAudio}
isPlaying={true}
/>
)
expect(window.AudioContext).toHaveBeenCalled()
})
it('没有音频元素时不应该初始化 AudioContext', () => {
jest.clearAllMocks()
render(
<AudioVisualizer
audioElement={null}
isPlaying={true}
/>
)
expect(window.AudioContext).not.toHaveBeenCalled()
})
})
describe('动画控制', () => {
it('播放时应该启动动画', () => {
const mockAudio = document.createElement('audio')
render(
<AudioVisualizer
audioElement={mockAudio}
isPlaying={true}
/>
)
// 检查 requestAnimationFrame 是否被调用
expect(mockRequestAnimationFrame).toHaveBeenCalled()
})
it('停止播放时组件应该正确响应状态变化', () => {
const mockAudio = document.createElement('audio')
const { rerender, container } = render(
<AudioVisualizer
audioElement={mockAudio}
isPlaying={true}
/>
)
// 验证播放状态下可视化层可见
const visualizerLayers = container.querySelectorAll('.transition-opacity')
expect(visualizerLayers.length).toBeGreaterThan(0)
// 重新渲染为非播放状态
rerender(
<AudioVisualizer
audioElement={mockAudio}
isPlaying={false}
/>
)
// 验证组件正确响应了状态变化(封面图应该显示)
const coverLayer = container.querySelector('.z-10')
expect(coverLayer).toHaveClass('opacity-100')
})
})
describe('组件卸载', () => {
it('卸载时组件应该正确清理', () => {
const mockAudio = document.createElement('audio')
const { unmount, container } = render(
<AudioVisualizer
audioElement={mockAudio}
isPlaying={true}
/>
)
// 验证组件已渲染
expect(container.firstChild).toBeInTheDocument()
// 卸载组件
unmount()
// 验证组件已从 DOM 中移除
expect(container.firstChild).toBeNull()
// 注意:由于 requestAnimationFrame 的异步特性和测试环境的限制,
// 我们无法直接验证 cancelAnimationFrame 是否被调用。
// 但组件的 cleanup effect 会在卸载时执行,确保资源被正确清理。
})
})
describe('默认封面组件', () => {
it('应该渲染音符图标', () => {
const { container } = render(
<AudioVisualizer audioElement={null} isPlaying={false} />
)
// 检查 SVG 音符图标
const svg = container.querySelector('svg')
expect(svg).toBeInTheDocument()
})
it('不同尺寸应该有不同的图标大小', () => {
const { container: smContainer } = render(
<AudioVisualizer audioElement={null} isPlaying={false} size="sm" />
)
const smSvg = smContainer.querySelector('svg')
expect(smSvg).toHaveClass('w-8', 'h-8')
const { container: lgContainer } = render(
<AudioVisualizer audioElement={null} isPlaying={false} size="lg" />
)
const lgSvg = lgContainer.querySelector('svg')
expect(lgSvg).toHaveClass('w-16', 'h-16')
})
})
describe('可视化层显示', () => {
it('播放时可视化层应该可见', () => {
const mockAudio = document.createElement('audio')
const { container } = render(
<AudioVisualizer
audioElement={mockAudio}
isPlaying={true}
/>
)
// 可视化层应该有 opacity-100 类
const visualizerLayer = container.querySelectorAll('.transition-opacity')[1]
expect(visualizerLayer).toHaveClass('opacity-100')
})
it('未播放时可视化层应该隐藏', () => {
const { container } = render(
<AudioVisualizer
audioElement={null}
isPlaying={false}
/>
)
// 可视化层应该有 opacity-0 类
const visualizerLayer = container.querySelectorAll('.transition-opacity')[1]
expect(visualizerLayer).toHaveClass('opacity-0')
})
})
})
describe('AudioVisualizer 边界情况', () => {
it('应该处理 null coverImage', () => {
const { container } = render(
<AudioVisualizer
audioElement={null}
isPlaying={false}
coverImage={null}
/>
)
expect(container.firstChild).toBeInTheDocument()
})
it('应该处理空字符串 coverImage', () => {
const { container } = render(
<AudioVisualizer
audioElement={null}
isPlaying={false}
coverImage=""
/>
)
// 空字符串应该被视为 falsy,显示默认封面
const defaultCover = container.querySelector('.bg-slate-100')
expect(defaultCover).toBeInTheDocument()
})
it('应该处理 showCoverOnPause 为 false 的情况', () => {
const { container } = render(
<AudioVisualizer
audioElement={null}
isPlaying={false}
showCoverOnPause={false}
/>
)
expect(container.firstChild).toBeInTheDocument()
})
})