Merge branch 'master' into codex/tiaoyitiao

This commit is contained in:
2026-06-07 00:57:38 +08:00
37 changed files with 2734 additions and 194 deletions

View File

@@ -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

View File

@@ -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}
/>