fix: delay wooden fish audio upload

This commit is contained in:
kdletters
2026-06-06 22:53:05 +08:00
parent d5b51a4242
commit ff7a2f6284
13 changed files with 1771 additions and 38 deletions

View File

@@ -0,0 +1,300 @@
/* @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 {
CreativeAudioInputPanel,
} from './CreativeAudioInputPanel';
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
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> = {}): TestAudioAsset {
return {
assetId: 'asset-test',
audioSrc: 'blob:audio-preview',
audioObjectKey: '',
assetObjectId: '',
source: 'uploaded',
prompt: 'hit.wav',
durationMs: 800,
...overrides,
};
}
function renderPanel(
overrides: Partial<ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>> = {},
) {
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(
<CreativeAudioInputPanel<TestAudioAsset>
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 秒' });
expect(screen.getByText('最长 1 秒')).toBeTruthy();
});
test('音频面板未传限制标签时不渲染限制提示', () => {
renderPanel();
expect(screen.queryByText('最长 1 秒')).toBeNull();
});
test('音频面板无资产时显示默认音效文案', () => {
renderPanel();
expect(screen.getByText('默认木鱼音')).toBeTruthy();
});
test('音频面板有预览地址时渲染 audio 控件', () => {
const { container } = render(
<CreativeAudioInputPanel<TestAudioAsset>
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);
});