Merge branch 'master' into codex/tiaoyitiao
This commit is contained in:
@@ -4,7 +4,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { uploadWoodenFishHitSoundAsset } from '../../../services/wooden-fish/woodenFishAssetClient';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { prepareCreativeAudioFileForLocalUse } from '../../common/creativeAudioProcessing';
|
||||
import { WoodenFishCreationWorkspace } from './WoodenFishCreationWorkspace';
|
||||
|
||||
vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
|
||||
@@ -13,8 +18,18 @@ vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/wooden-fish/woodenFishAssetClient', () => ({
|
||||
uploadWoodenFishHitSoundAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../common/creativeAudioProcessing', () => ({
|
||||
prepareCreativeAudioFileForLocalUse: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(woodenFishClient.createSession).mockReset();
|
||||
vi.mocked(uploadWoodenFishHitSoundAsset).mockReset();
|
||||
vi.mocked(prepareCreativeAudioFileForLocalUse).mockReset();
|
||||
vi.mocked(woodenFishClient.createSession).mockResolvedValue({
|
||||
session: {
|
||||
sessionId: 'wooden-fish-session-test',
|
||||
@@ -25,6 +40,30 @@ beforeEach(() => {
|
||||
updatedAt: '2026-05-24T00:00:00Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(uploadWoodenFishHitSoundAsset).mockResolvedValue({
|
||||
assetId: 'uploaded-hit-sound-asset',
|
||||
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
audioObjectKey: 'generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
assetObjectId: 'asset-object-hit-sound',
|
||||
source: 'uploaded',
|
||||
prompt: 'hit-sound.webm',
|
||||
durationMs: null,
|
||||
});
|
||||
vi.mocked(prepareCreativeAudioFileForLocalUse).mockImplementation(
|
||||
async (file, source) => ({
|
||||
assetId: 'local-hit-sound-asset',
|
||||
audioSrc: 'blob:local-hit-sound',
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs: 800,
|
||||
fileName: 'hit-sound.wav',
|
||||
mimeType: 'audio/wav',
|
||||
blob: new Blob(['processed-audio-bytes'], { type: 'audio/wav' }),
|
||||
previewUrl: 'blob:local-hit-sound',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('敲什么输入栏初始置空但提交时仍使用默认生成提示词', async () => {
|
||||
@@ -103,10 +142,303 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
|
||||
|
||||
expect(section).not.toBeNull();
|
||||
expect(within(section as HTMLElement).queryByText('音效描述')).toBeNull();
|
||||
expect(within(section as HTMLElement).getByText('最长 1 秒')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByText('上传')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('敲击音效上传后只生成本地待提交音频,点击生成时才上传 OSS', async () => {
|
||||
const onSubmitted = vi.fn();
|
||||
const audioFile = new File(['audio-bytes'], 'hit-sound.webm', {
|
||||
type: 'audio/webm',
|
||||
});
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: { files: [audioFile] },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledWith(
|
||||
audioFile,
|
||||
'uploaded',
|
||||
),
|
||||
);
|
||||
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
|
||||
expect(uploadWoodenFishHitSoundAsset).toHaveBeenCalledTimes(1);
|
||||
const uploadCall = vi.mocked(uploadWoodenFishHitSoundAsset).mock.calls[0];
|
||||
expect(uploadCall).toBeDefined();
|
||||
const [uploadedFile, source] = uploadCall!;
|
||||
expect(uploadedFile).toBeInstanceOf(File);
|
||||
const uploadedAudioFile = uploadedFile as File;
|
||||
expect(uploadedAudioFile.name).toBe('hit-sound.wav');
|
||||
expect(uploadedFile.type).toBe('audio/wav');
|
||||
expect(source).toBe('uploaded');
|
||||
|
||||
const payload = onSubmitted.mock.calls[0]?.[1];
|
||||
expect(payload.hitSoundAsset).toMatchObject({
|
||||
assetObjectId: 'asset-object-hit-sound',
|
||||
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
});
|
||||
expect(payload.hitSoundAsset.audioSrc).not.toContain('data:audio');
|
||||
});
|
||||
|
||||
test('未选择敲击音效时生成不上传 OSS 且使用默认木鱼音', async () => {
|
||||
const onSubmitted = vi.fn();
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
|
||||
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
|
||||
expect(onSubmitted.mock.calls[0]?.[1].hitSoundAsset).toEqual(
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
);
|
||||
});
|
||||
|
||||
test('敲击音效超过 1 秒时展示错误且不提交音频', async () => {
|
||||
const onSubmitted = vi.fn();
|
||||
vi.mocked(prepareCreativeAudioFileForLocalUse).mockRejectedValueOnce(
|
||||
new Error('音频最长 1 秒。'),
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['too-long-audio'], 'too-long.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('音频最长 1 秒。')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
|
||||
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
|
||||
expect(onSubmitted.mock.calls[0]?.[1].hitSoundAsset.audioSrc).not.toContain(
|
||||
'data:audio',
|
||||
);
|
||||
});
|
||||
|
||||
test('敲击音效上传失败时停留在工作台且不创建 session', async () => {
|
||||
vi.mocked(uploadWoodenFishHitSoundAsset).mockRejectedValueOnce(
|
||||
new Error('上传敲击音效失败。'),
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['audio-bytes'], 'hit-sound.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(await screen.findByText('上传敲击音效失败。')).toBeTruthy();
|
||||
expect(woodenFishClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('创建木鱼草稿失败时停留工作台并展示错误', async () => {
|
||||
vi.mocked(woodenFishClient.createSession).mockRejectedValueOnce(
|
||||
new Error('创建敲木鱼共创会话失败'),
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(await screen.findByText('创建敲木鱼共创会话失败')).toBeTruthy();
|
||||
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('提交中重复点击生成不会重复上传或创建 session', async () => {
|
||||
let resolveUpload: (
|
||||
value: Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>,
|
||||
) => void = () => {};
|
||||
vi.mocked(uploadWoodenFishHitSoundAsset).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>>((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['audio-bytes'], 'hit-sound.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '生成' });
|
||||
fireEvent.click(submitButton);
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(uploadWoodenFishHitSoundAsset).toHaveBeenCalledTimes(1);
|
||||
expect(woodenFishClient.createSession).not.toHaveBeenCalled();
|
||||
|
||||
resolveUpload({
|
||||
assetId: 'uploaded-hit-sound-asset',
|
||||
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
audioObjectKey: 'generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
assetObjectId: 'asset-object-hit-sound',
|
||||
source: 'uploaded',
|
||||
prompt: 'hit-sound.webm',
|
||||
durationMs: null,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(woodenFishClient.createSession).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('替换本地敲击音效时回收旧预览地址', async () => {
|
||||
const revokeObjectURL = vi.fn();
|
||||
URL.revokeObjectURL = revokeObjectURL;
|
||||
vi.mocked(prepareCreativeAudioFileForLocalUse)
|
||||
.mockResolvedValueOnce({
|
||||
assetId: 'local-hit-sound-old',
|
||||
audioSrc: 'blob:old-hit-sound',
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source: 'uploaded',
|
||||
prompt: 'old.webm',
|
||||
durationMs: 800,
|
||||
fileName: 'old.wav',
|
||||
mimeType: 'audio/wav',
|
||||
blob: new Blob(['old-audio'], { type: 'audio/wav' }),
|
||||
previewUrl: 'blob:old-hit-sound',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assetId: 'local-hit-sound-new',
|
||||
audioSrc: 'blob:new-hit-sound',
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source: 'uploaded',
|
||||
prompt: 'new.webm',
|
||||
durationMs: 700,
|
||||
fileName: 'new.wav',
|
||||
mimeType: 'audio/wav',
|
||||
blob: new Blob(['new-audio'], { type: 'audio/wav' }),
|
||||
previewUrl: 'blob:new-hit-sound',
|
||||
});
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['old'], 'old.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('重置')).toBeTruthy());
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['new'], 'new.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:old-hit-sound'),
|
||||
);
|
||||
});
|
||||
|
||||
test('敲击音效和功德词条不放进独立滚动窗', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishCreationWorkspace
|
||||
|
||||
@@ -13,12 +13,17 @@ import type {
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/woodenFish';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../../services/puzzleReferenceImage';
|
||||
import { uploadWoodenFishHitSoundAsset } from '../../../services/wooden-fish/woodenFishAssetClient';
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { CreativeAudioInputPanel } from '../../common/CreativeAudioInputPanel';
|
||||
import {
|
||||
type PendingCreativeAudioAsset,
|
||||
prepareCreativeAudioFileForLocalUse,
|
||||
} from '../../common/creativeAudioProcessing';
|
||||
import { CreativeImageInputPanel } from '../../common/CreativeImageInputPanel';
|
||||
|
||||
type WoodenFishCreationWorkspaceProps = {
|
||||
@@ -36,7 +41,7 @@ type WoodenFishCreationWorkspaceProps = {
|
||||
type WoodenFishWorkspaceFormState = {
|
||||
hitObjectPrompt: string;
|
||||
hitObjectReferenceImageSrc: string;
|
||||
hitSoundAsset: WoodenFishAudioAsset | null;
|
||||
hitSoundAsset: PendingCreativeAudioAsset | null;
|
||||
floatingWords: string[];
|
||||
};
|
||||
|
||||
@@ -132,6 +137,14 @@ export function WoodenFishCreationWorkspace({
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const hitSoundAsset: WoodenFishAudioAsset = formState.hitSoundAsset
|
||||
? await uploadWoodenFishHitSoundAsset(
|
||||
new File([formState.hitSoundAsset.blob], formState.hitSoundAsset.fileName, {
|
||||
type: formState.hitSoundAsset.mimeType,
|
||||
}),
|
||||
formState.hitSoundAsset.source,
|
||||
)
|
||||
: WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET;
|
||||
const payload: WoodenFishWorkspaceCreateRequest = {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '',
|
||||
@@ -143,8 +156,7 @@ export function WoodenFishCreationWorkspace({
|
||||
hitObjectReferenceImageSrc:
|
||||
formState.hitObjectReferenceImageSrc.trim() || null,
|
||||
hitSoundPrompt: null,
|
||||
hitSoundAsset:
|
||||
formState.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
hitSoundAsset,
|
||||
floatingWords: normalizedFloatingWords,
|
||||
};
|
||||
const response = await woodenFishClient.createSession(payload);
|
||||
@@ -246,18 +258,28 @@ export function WoodenFishCreationWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
|
||||
<CreativeAudioInputPanel<WoodenFishAudioAsset>
|
||||
<CreativeAudioInputPanel<PendingCreativeAudioAsset>
|
||||
disabled={isBusy || isSubmitting}
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
limitLabel="最长 1 秒"
|
||||
asset={formState.hitSoundAsset}
|
||||
buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`}
|
||||
onAssetChange={(asset) =>
|
||||
readFileAsAsset={prepareCreativeAudioFileForLocalUse}
|
||||
onAssetChange={(asset) => {
|
||||
if (
|
||||
formState.hitSoundAsset?.previewUrl &&
|
||||
formState.hitSoundAsset.previewUrl !== asset?.previewUrl &&
|
||||
typeof URL !== 'undefined' &&
|
||||
typeof URL.revokeObjectURL === 'function'
|
||||
) {
|
||||
URL.revokeObjectURL(formState.hitSoundAsset.previewUrl);
|
||||
}
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitSoundAsset: asset,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}}
|
||||
onError={setLocalError}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user