1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
407 lines
12 KiB
TypeScript
407 lines
12 KiB
TypeScript
/**
|
||
* 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-gradient-to-br')
|
||
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-gradient-to-br')
|
||
expect(defaultCover).toBeInTheDocument()
|
||
})
|
||
|
||
it('应该处理 showCoverOnPause 为 false 的情况', () => {
|
||
const { container } = render(
|
||
<AudioVisualizer
|
||
audioElement={null}
|
||
isPlaying={false}
|
||
showCoverOnPause={false}
|
||
/>
|
||
)
|
||
expect(container.firstChild).toBeInTheDocument()
|
||
})
|
||
})
|