/* @vitest-environment jsdom */ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ComponentProps } from 'react'; import { afterEach, expect, test, vi } from 'vitest'; import type { CreativeAudioAsset } from './creativeAudioFileAsset'; import { CreativeAudioInputPanel } from './CreativeAudioInputPanel'; type TestAudioAsset = CreativeAudioAsset; const originalMediaRecorder = globalThis.MediaRecorder; const originalMediaDevices = navigator.mediaDevices; afterEach(() => { globalThis.MediaRecorder = originalMediaRecorder; Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: originalMediaDevices, }); vi.restoreAllMocks(); }); function buildAsset(overrides: Partial = {}): TestAudioAsset { return { assetId: 'asset-test', audioSrc: 'blob:audio-preview', audioObjectKey: '', assetObjectId: '', source: 'uploaded', prompt: 'hit.wav', durationMs: 800, ...overrides, }; } function renderPanel( overrides: Partial< ComponentProps> > = {}, ) { const onAssetChange = vi.fn(); const onError = vi.fn(); const readFileAsAsset = vi.fn( async (file: File, source: 'uploaded' | 'recorded') => buildAsset({ audioSrc: `blob:${source}`, source, prompt: file.name, }), ); const rendered = render( title="敲击音效" defaultLabel="默认木鱼音" asset={null} buildRecordedFileName={() => 'recorded-hit.webm'} onAssetChange={onAssetChange} onError={onError} readFileAsAsset={readFileAsAsset} {...overrides} />, ); return { ...rendered, onAssetChange, onError, readFileAsAsset }; } function getUploadInput() { const input = screen .getByText('上传') .closest('label') ?.querySelector('input[type="file"]') as HTMLInputElement | null; expect(input).not.toBeNull(); return input!; } test('音频面板按需显示最长限制标签', () => { renderPanel({ limitLabel: '最长 1 秒' }); const limitBadge = screen.getByText('最长 1 秒'); expect(limitBadge.className).toContain('rounded-full'); expect(limitBadge.className).toContain( 'border-[var(--platform-subpanel-border)]', ); expect(limitBadge.className).toContain('bg-[var(--platform-subpanel-fill)]'); expect(limitBadge.className).toContain('text-[var(--platform-text-soft)]'); expect(limitBadge.className).toContain('px-2'); expect(limitBadge.className).toContain('py-1'); }); test('音频面板未传限制标签时不渲染限制提示', () => { renderPanel(); expect(screen.queryByText('最长 1 秒')).toBeNull(); }); test('音频面板无资产时显示默认音效文案', () => { renderPanel(); expect(screen.getByText('默认木鱼音')).toBeTruthy(); }); test('音频面板有预览地址时渲染 audio 控件', () => { const { container } = render( title="敲击音效" defaultLabel="默认木鱼音" asset={buildAsset({ audioSrc: 'blob:preview' })} buildRecordedFileName={() => 'recorded-hit.webm'} onAssetChange={() => {}} onError={() => {}} />, ); expect(container.querySelector('audio')?.getAttribute('src')).toBe( 'blob:preview', ); }); test('音频面板有资产但无预览地址时显示已选择状态', () => { renderPanel({ asset: buildAsset({ audioSrc: '' }) }); expect(screen.getByText('音效已选择')).toBeTruthy(); }); test('点击重置清空当前音频资产', () => { const onAssetChange = vi.fn(); renderPanel({ asset: buildAsset(), onAssetChange, }); fireEvent.click(screen.getByRole('button', { name: '重置' })); expect(onAssetChange).toHaveBeenCalledWith(null); }); test('取消上传选择时不读取音频', () => { const { readFileAsAsset, onAssetChange } = renderPanel(); fireEvent.change(getUploadInput(), { target: { files: [] } }); expect(readFileAsAsset).not.toHaveBeenCalled(); expect(onAssetChange).not.toHaveBeenCalled(); }); test('上传音频成功后清空错误并写入资产', async () => { const audioFile = new File(['audio'], 'hit.webm', { type: 'audio/webm' }); const { readFileAsAsset, onAssetChange, onError } = renderPanel(); fireEvent.change(getUploadInput(), { target: { files: [audioFile] } }); await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledWith(audioFile, 'uploaded'), ); await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1)); expect(onError).toHaveBeenCalledWith(null); }); test('上传音频失败时提示错误且不写入资产', async () => { const readFileAsAsset = vi.fn(async () => { throw new Error('音频最长 1 秒。'); }); const onAssetChange = vi.fn(); const onError = vi.fn(); renderPanel({ readFileAsAsset, onAssetChange, onError }); fireEvent.change(getUploadInput(), { target: { files: [new File(['audio'], 'hit.webm', { type: 'audio/webm' })], }, }); await waitFor(() => expect(onError).toHaveBeenCalledWith('音频最长 1 秒。')); expect(onAssetChange).not.toHaveBeenCalled(); }); test('浏览器不支持录音时提示错误', async () => { Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: undefined, }); globalThis.MediaRecorder = undefined as unknown as typeof MediaRecorder; const { onError } = renderPanel(); fireEvent.click(screen.getByRole('button', { name: '录音' })); await waitFor(() => expect(onError).toHaveBeenCalledWith('当前浏览器不支持录音。'), ); }); test('录音启动失败时透传启动错误', async () => { Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia: vi.fn(async () => { throw new Error('麦克风拒绝授权。'); }), }, }); globalThis.MediaRecorder = class { start = vi.fn(); stop = vi.fn(); } as unknown as typeof MediaRecorder; const { onError } = renderPanel(); fireEvent.click(screen.getByRole('button', { name: '录音' })); await waitFor(() => expect(onError).toHaveBeenCalledWith('麦克风拒绝授权。')); }); test('录音停止后按 recorded 来源读取音频', async () => { const stopTrack = vi.fn(); const recorderInstances: Array<{ ondataavailable: ((event: BlobEvent) => void) | null; onstop: (() => void) | null; }> = []; Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [{ stop: stopTrack }], })), }, }); globalThis.MediaRecorder = class { mimeType = 'audio/webm'; ondataavailable: ((event: BlobEvent) => void) | null = null; onstop: (() => void) | null = null; constructor() { recorderInstances.push(this); } start = vi.fn(); stop = vi.fn(() => { this.ondataavailable?.({ data: new Blob(['recorded-audio'], { type: 'audio/webm' }), } as BlobEvent); this.onstop?.(); }); } as unknown as typeof MediaRecorder; const { readFileAsAsset, onAssetChange } = renderPanel(); fireEvent.click(screen.getByRole('button', { name: '录音' })); await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(), ); fireEvent.click(screen.getByRole('button', { name: '停止' })); await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1)); const [recordedFile, source] = readFileAsAsset.mock.calls[0]!; expect(recordedFile).toBeInstanceOf(File); expect((recordedFile as File).name).toBe('recorded-hit.webm'); expect(source).toBe('recorded'); expect(stopTrack).toHaveBeenCalledTimes(1); await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1)); expect(recorderInstances).toHaveLength(1); }); test('录音保存失败时提示错误', async () => { Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [], })), }, }); globalThis.MediaRecorder = class { mimeType = 'audio/webm'; ondataavailable: ((event: BlobEvent) => void) | null = null; onstop: (() => void) | null = null; start = vi.fn(); stop = vi.fn(() => this.onstop?.()); } as unknown as typeof MediaRecorder; const readFileAsAsset = vi.fn(async () => { throw new Error('音频声音过小,请重新录制或上传。'); }); const onError = vi.fn(); renderPanel({ readFileAsAsset, onError }); fireEvent.click(screen.getByRole('button', { name: '录音' })); await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy(), ); fireEvent.click(screen.getByRole('button', { name: '停止' })); await waitFor(() => expect(onError).toHaveBeenCalledWith('音频声音过小,请重新录制或上传。'), ); }); test('禁用状态不启动录音也不允许上传', () => { const getUserMedia = vi.fn(); Object.defineProperty(navigator, 'mediaDevices', { configurable: true, value: { getUserMedia }, }); const { container } = renderPanel({ disabled: true }); fireEvent.click(screen.getByRole('button', { name: '录音' })); expect(getUserMedia).not.toHaveBeenCalled(); const input = container.querySelector('input[type="file"]'); expect(input).not.toBeNull(); expect((input as HTMLInputElement).disabled).toBe(true); });