1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
424 lines
13 KiB
TypeScript
424 lines
13 KiB
TypeScript
/**
|
||
* 浏览器端音频解析模块测试
|
||
* 测试 lib/audio-parser-browser.ts 的核心功能
|
||
*/
|
||
|
||
// Mock music-metadata-browser
|
||
jest.mock('music-metadata-browser', () => ({
|
||
parseBlob: jest.fn(),
|
||
}))
|
||
|
||
import {
|
||
formatDuration,
|
||
isAudioFile,
|
||
parseAudioFile,
|
||
AUDIO_ACCEPT_TYPES,
|
||
AUDIO_FILE_EXTENSIONS,
|
||
AudioMetadataBrowser,
|
||
} from '../audio-parser-browser'
|
||
|
||
const mockParseBlob = jest.requireMock('music-metadata-browser').parseBlob as jest.Mock
|
||
|
||
describe('浏览器端音频解析模块', () => {
|
||
describe('formatDuration', () => {
|
||
it('应该正确格式化0秒', () => {
|
||
expect(formatDuration(0)).toBe('00:00')
|
||
})
|
||
|
||
it('应该正确格式化小于1分钟的时长', () => {
|
||
expect(formatDuration(30)).toBe('00:30')
|
||
expect(formatDuration(59)).toBe('00:59')
|
||
})
|
||
|
||
it('应该正确格式化整分钟', () => {
|
||
expect(formatDuration(60)).toBe('01:00')
|
||
expect(formatDuration(120)).toBe('02:00')
|
||
expect(formatDuration(600)).toBe('10:00')
|
||
})
|
||
|
||
it('应该正确格式化分钟和秒的组合', () => {
|
||
expect(formatDuration(90)).toBe('01:30')
|
||
expect(formatDuration(125)).toBe('02:05')
|
||
expect(formatDuration(3661)).toBe('61:01') // 超过1小时
|
||
})
|
||
|
||
it('应该正确处理小数秒(向下取整)', () => {
|
||
expect(formatDuration(30.5)).toBe('00:30')
|
||
expect(formatDuration(59.9)).toBe('00:59')
|
||
expect(formatDuration(90.7)).toBe('01:30')
|
||
})
|
||
})
|
||
|
||
describe('isAudioFile', () => {
|
||
it('应该通过MIME类型识别音频文件', () => {
|
||
const audioFile = new File([''], 'test.mp3', { type: 'audio/mpeg' })
|
||
expect(isAudioFile(audioFile)).toBe(true)
|
||
})
|
||
|
||
it('应该通过文件扩展名识别音频文件', () => {
|
||
const mp3File = new File([''], 'test.mp3', { type: '' })
|
||
const wavFile = new File([''], 'test.wav', { type: '' })
|
||
const flacFile = new File([''], 'test.flac', { type: '' })
|
||
const aacFile = new File([''], 'test.aac', { type: '' })
|
||
const m4aFile = new File([''], 'test.m4a', { type: '' })
|
||
const oggFile = new File([''], 'test.ogg', { type: '' })
|
||
|
||
expect(isAudioFile(mp3File)).toBe(true)
|
||
expect(isAudioFile(wavFile)).toBe(true)
|
||
expect(isAudioFile(flacFile)).toBe(true)
|
||
expect(isAudioFile(aacFile)).toBe(true)
|
||
expect(isAudioFile(m4aFile)).toBe(true)
|
||
expect(isAudioFile(oggFile)).toBe(true)
|
||
})
|
||
|
||
it('应该拒绝非音频文件', () => {
|
||
const imageFile = new File([''], 'test.png', { type: 'image/png' })
|
||
const videoFile = new File([''], 'test.mp4', { type: 'video/mp4' })
|
||
const textFile = new File([''], 'test.txt', { type: 'text/plain' })
|
||
|
||
expect(isAudioFile(imageFile)).toBe(false)
|
||
expect(isAudioFile(videoFile)).toBe(false)
|
||
expect(isAudioFile(textFile)).toBe(false)
|
||
})
|
||
|
||
it('应该处理没有扩展名的文件', () => {
|
||
const noExtFile = new File([''], 'test', { type: '' })
|
||
expect(isAudioFile(noExtFile)).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('AUDIO_ACCEPT_TYPES', () => {
|
||
it('应该包含常见音频MIME类型', () => {
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('audio/mpeg')
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('audio/wav')
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('audio/flac')
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('audio/aac')
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('audio/ogg')
|
||
})
|
||
|
||
it('应该包含常见音频文件扩展名', () => {
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('.mp3')
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('.wav')
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('.flac')
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('.aac')
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('.m4a')
|
||
expect(AUDIO_ACCEPT_TYPES).toContain('.ogg')
|
||
})
|
||
})
|
||
|
||
describe('AUDIO_FILE_EXTENSIONS', () => {
|
||
it('应该包含所有支持的音频扩展名', () => {
|
||
const expectedExtensions = ['mp3', 'wav', 'flac', 'aac', 'm4a', 'ogg', 'wma', 'aiff', 'ape', 'webm']
|
||
expectedExtensions.forEach(ext => {
|
||
expect(AUDIO_FILE_EXTENSIONS).toContain(ext)
|
||
})
|
||
})
|
||
|
||
it('应该有正确的扩展名数量', () => {
|
||
expect(AUDIO_FILE_EXTENSIONS.length).toBe(10)
|
||
})
|
||
})
|
||
|
||
describe('parseAudioFile', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
})
|
||
|
||
it('应该正确解析音频文件元数据', async () => {
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 180.5,
|
||
container: 'MPEG',
|
||
bitrate: 320000,
|
||
sampleRate: 44100,
|
||
numberOfChannels: 2,
|
||
},
|
||
common: {
|
||
title: 'Test Song',
|
||
artist: 'Test Artist',
|
||
album: 'Test Album',
|
||
year: 2024,
|
||
genre: ['Pop', 'Rock'],
|
||
},
|
||
})
|
||
|
||
const file = new File(['test audio data'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result?.duration).toBe(180.5)
|
||
expect(result?.durationFormatted).toBe('03:00')
|
||
expect(result?.format).toBe('MPEG')
|
||
expect(result?.bitrate).toBe(320000)
|
||
expect(result?.sampleRate).toBe(44100)
|
||
expect(result?.channels).toBe(2)
|
||
expect(result?.title).toBe('Test Song')
|
||
expect(result?.artist).toBe('Test Artist')
|
||
expect(result?.album).toBe('Test Album')
|
||
expect(result?.year).toBe(2024)
|
||
expect(result?.genre).toEqual(['Pop', 'Rock'])
|
||
})
|
||
|
||
it('应该处理缺少duration的情况', async () => {
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
container: 'WAV',
|
||
},
|
||
common: {},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.wav', { type: 'audio/wav' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result?.duration).toBe(0)
|
||
expect(result?.durationFormatted).toBe('00:00')
|
||
})
|
||
|
||
it('应该处理缺少container的情况', async () => {
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 60,
|
||
},
|
||
common: {},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result?.format).toBe('unknown')
|
||
})
|
||
|
||
it('应该优雅处理解析错误', async () => {
|
||
mockParseBlob.mockRejectedValue(new Error('Invalid audio format'))
|
||
|
||
const file = new File(['invalid data'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).toBeNull()
|
||
})
|
||
|
||
it('应该提取内嵌封面图', async () => {
|
||
const mockPictureData = new Uint8Array([0x89, 0x50, 0x4E, 0x47]) // PNG header
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 120,
|
||
container: 'MP3',
|
||
},
|
||
common: {
|
||
picture: [{
|
||
format: 'image/png',
|
||
data: mockPictureData,
|
||
}],
|
||
},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result?.coverImage).toBeDefined()
|
||
expect(result?.coverImage).toMatch(/^data:image\/png;base64,/)
|
||
})
|
||
|
||
it('应该处理没有封面图的情况', async () => {
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 60,
|
||
container: 'MP3',
|
||
},
|
||
common: {},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result?.coverImage).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('AudioMetadataBrowser 类型验证', () => {
|
||
it('应该有正确的类型结构', () => {
|
||
const mockMetadata: AudioMetadataBrowser = {
|
||
duration: 180,
|
||
durationFormatted: '03:00',
|
||
format: 'mp3',
|
||
bitrate: 320000,
|
||
sampleRate: 44100,
|
||
channels: 2,
|
||
title: 'Test',
|
||
artist: 'Artist',
|
||
album: 'Album',
|
||
year: 2024,
|
||
genre: ['Pop'],
|
||
coverImage: 'data:image/png;base64,xxx',
|
||
}
|
||
|
||
expect(mockMetadata.duration).toBe(180)
|
||
expect(mockMetadata.durationFormatted).toBe('03:00')
|
||
expect(mockMetadata.format).toBe('mp3')
|
||
expect(mockMetadata.title).toBe('Test')
|
||
})
|
||
|
||
it('可选字段应该可以为undefined', () => {
|
||
const minimalMetadata: AudioMetadataBrowser = {
|
||
duration: 60,
|
||
durationFormatted: '01:00',
|
||
format: 'wav',
|
||
}
|
||
|
||
expect(minimalMetadata.bitrate).toBeUndefined()
|
||
expect(minimalMetadata.sampleRate).toBeUndefined()
|
||
expect(minimalMetadata.channels).toBeUndefined()
|
||
expect(minimalMetadata.title).toBeUndefined()
|
||
expect(minimalMetadata.artist).toBeUndefined()
|
||
expect(minimalMetadata.coverImage).toBeUndefined()
|
||
})
|
||
})
|
||
|
||
describe('GBK编码乱码修复', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
})
|
||
|
||
it('应该正确处理正常的英文标签(不触发GBK解码)', async () => {
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 180,
|
||
container: 'MP3',
|
||
},
|
||
common: {
|
||
title: 'Normal English Title',
|
||
artist: 'Normal Artist',
|
||
album: 'Normal Album',
|
||
},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result?.title).toBe('Normal English Title')
|
||
expect(result?.artist).toBe('Normal Artist')
|
||
expect(result?.album).toBe('Normal Album')
|
||
})
|
||
|
||
it('应该正确处理正常的中文标签(UTF-8编码)', async () => {
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 180,
|
||
container: 'MP3',
|
||
},
|
||
common: {
|
||
title: '中文歌曲名称',
|
||
artist: '中文艺术家',
|
||
album: '中文专辑',
|
||
},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result?.title).toBe('中文歌曲名称')
|
||
expect(result?.artist).toBe('中文艺术家')
|
||
expect(result?.album).toBe('中文专辑')
|
||
})
|
||
|
||
it('应该尝试修复GBK编码的乱码标签', async () => {
|
||
// 模拟 GBK 编码的"测试"被错误解码为 ISO-8859-1 后的乱码
|
||
// "测试" 的 GBK 编码是 0xB2 0xE2 0xCA 0xD4
|
||
// 被当作 ISO-8859-1 解码后变成 "²âÊÔ"
|
||
const garbledTitle = String.fromCharCode(0xB2, 0xE2, 0xCA, 0xD4)
|
||
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 180,
|
||
container: 'MP3',
|
||
},
|
||
common: {
|
||
title: garbledTitle,
|
||
artist: 'Normal Artist',
|
||
},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
// 应该被解码为中文"测试"
|
||
expect(result?.title).toBe('测试')
|
||
// 正常的英文不应该被改变
|
||
expect(result?.artist).toBe('Normal Artist')
|
||
})
|
||
|
||
it('应该处理空标签', async () => {
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 180,
|
||
container: 'MP3',
|
||
},
|
||
common: {
|
||
title: undefined,
|
||
artist: '',
|
||
},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result?.title).toBeUndefined()
|
||
expect(result?.artist).toBe('')
|
||
})
|
||
|
||
it('应该处理流派数组中的乱码', async () => {
|
||
// 模拟 GBK 编码的"流行"被错误解码
|
||
// "流行" 的 GBK 编码是 0xC1 0xF7 0xD0 0xD0
|
||
const garbledGenre = String.fromCharCode(0xC1, 0xF7, 0xD0, 0xD0)
|
||
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 180,
|
||
container: 'MP3',
|
||
},
|
||
common: {
|
||
genre: [garbledGenre, 'Rock'],
|
||
},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
expect(result?.genre).toContain('流行')
|
||
expect(result?.genre).toContain('Rock')
|
||
})
|
||
|
||
it('应该处理低比例高位字符的字符串(不触发GBK解码)', async () => {
|
||
// 模拟一个高位字符比例低于30%的字符串(不会触发GBK解码)
|
||
// 例如:10个字符中只有2个高位字符(20%)
|
||
const mixedString = 'Hello' + String.fromCharCode(0xB2, 0xE2) + 'abc'
|
||
|
||
mockParseBlob.mockResolvedValue({
|
||
format: {
|
||
duration: 180,
|
||
container: 'MP3',
|
||
},
|
||
common: {
|
||
title: mixedString,
|
||
},
|
||
})
|
||
|
||
const file = new File(['test'], 'test.mp3', { type: 'audio/mpeg' })
|
||
const result = await parseAudioFile(file)
|
||
|
||
expect(result).not.toBeNull()
|
||
// 高位字符比例低于30%,不会触发GBK解码,保持原样
|
||
expect(result?.title).toBe(mixedString)
|
||
})
|
||
})
|
||
})
|