/** * 浏览器端音频解析模块测试 * 测试 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) }) }) })