Files
Genarrative/src/components/image-editor/useImageCanvasSoundEffectPromptAssist.test.tsx
T
lhk229 5c316fc680 统一SFX提示词 trim 规范化
将 SFX Prompt 首尾规范化改为 ECMAScript String.trim 语义并同步 Rust 等值实现

更新共享边界 fixture、前后端与 Worker 回归测试

同步权威设计、实施计划、决策日志和 External OpenAPI 描述
2026-08-07 07:27:52 +00:00

393 lines
12 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { type SetStateAction, useCallback, useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
import { useImageCanvasSoundEffectPromptAssist } from './useImageCanvasSoundEffectPromptAssist';
const clientMocks = vi.hoisted(() => ({
optimize: vi.fn(),
}));
vi.mock('../../services/image-editor/editorProjectClient', () => ({
optimizeEditorSoundEffectPrompt: clientMocks.optimize,
}));
type OptimizeResponse = { prompt: string; charCount: number };
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function createDialog(
id: string,
prompt: string,
overrides: Partial<CanvasGenerationDialogState> = {},
): CanvasGenerationDialogState {
return {
id,
mode: 'audio-sound-effect',
prompt,
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 5,
soundLoop: false,
...overrides,
};
}
function requireDialog(
dialogs: CanvasGenerationDialogState[],
dialogId: string,
) {
const dialog = dialogs.find((candidate) => candidate.id === dialogId);
if (!dialog) {
throw new Error(`missing test dialog: ${dialogId}`);
}
return dialog;
}
function useHarness({
initialDialogs,
currentUserId,
projectId,
}: {
initialDialogs: CanvasGenerationDialogState[];
currentUserId?: string | null;
projectId?: string | null;
}) {
const [dialogs, setDialogs] = useState(initialDialogs);
const dialogsRef = useRef(dialogs);
dialogsRef.current = dialogs;
const getCanvasGenerationDialogById = useCallback(
(dialogId: string) =>
dialogsRef.current.find((dialog) => dialog.id === dialogId),
[],
);
const updateCanvasGenerationDialogById = useCallback(
(
dialogId: string,
updater: (
dialog: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
) => {
const currentDialog = dialogsRef.current.find(
(dialog) => dialog.id === dialogId,
);
if (!currentDialog) {
return;
}
const nextDialog = updater(currentDialog);
const nextDialogs = nextDialog
? dialogsRef.current.map((dialog) =>
dialog.id === dialogId ? nextDialog : dialog,
)
: dialogsRef.current.filter((dialog) => dialog.id !== dialogId);
dialogsRef.current = nextDialogs;
setDialogs(nextDialogs);
},
[],
);
const updateDialogs = useCallback(
(updater: SetStateAction<CanvasGenerationDialogState[]>) => {
const nextDialogs =
typeof updater === 'function' ? updater(dialogsRef.current) : updater;
dialogsRef.current = nextDialogs;
setDialogs(nextDialogs);
},
[],
);
const setPrompt = useCallback(
(dialogId: string, prompt: string) => {
updateCanvasGenerationDialogById(dialogId, (dialog) => ({
...dialog,
prompt,
}));
},
[updateCanvasGenerationDialogById],
);
const removeDialog = useCallback(
(dialogId: string) => {
updateDialogs((currentDialogs) =>
currentDialogs.filter((dialog) => dialog.id !== dialogId),
);
},
[updateDialogs],
);
const promptAssist = useImageCanvasSoundEffectPromptAssist({
canvasGenerationDialogs: dialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
return { dialogs, promptAssist, setPrompt, removeDialog };
}
describe('useImageCanvasSoundEffectPromptAssist', () => {
beforeEach(() => {
clientMocks.optimize.mockReset();
});
it('canonicalizes before await, collapses double clicks, and creates one undo snapshot', async () => {
const deferred = createDeferred<OptimizeResponse>();
clientMocks.optimize.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() =>
useHarness({
initialDialogs: [
createDialog('dialog-a', '\uFEFF金币落地声\u3000', {
status: 'failed',
errorMessage: '旧错误',
}),
],
currentUserId: 'user-a',
projectId: 'project-a',
}),
);
let firstAction!: ReturnType<
typeof result.current.promptAssist.optimizePrompt
>;
let duplicateResult!: Awaited<typeof firstAction>;
act(() => {
firstAction = result.current.promptAssist.optimizePrompt('dialog-a');
});
await act(async () => {
duplicateResult =
await result.current.promptAssist.optimizePrompt('dialog-a');
});
expect(clientMocks.optimize).toHaveBeenCalledTimes(1);
expect(clientMocks.optimize).toHaveBeenCalledWith(
{ currentPrompt: '金币落地声' },
{ signal: expect.any(AbortSignal) },
);
expect(requireDialog(result.current.dialogs, 'dialog-a')).toMatchObject({
prompt: '金币落地声',
status: 'idle',
errorMessage: undefined,
});
expect(duplicateResult.reason).toBe('duplicate');
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'optimizing',
undoPromptSnapshot: null,
temporaryPromptSnapshot: '金币落地声',
});
let actionResult!: Awaited<typeof firstAction>;
await act(async () => {
deferred.resolve({ prompt: '清脆明亮的金币落地声', charCount: 10 });
actionResult = await firstAction;
});
expect(actionResult.reason).toBe('applied');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'清脆明亮的金币落地声',
);
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'idle',
undoPromptSnapshot: '金币落地声',
temporaryPromptSnapshot: null,
});
act(() => {
result.current.setPrompt('dialog-a', '手动改过的结果');
result.current.promptAssist.undoPrompt('dialog-a');
});
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'金币落地声',
);
expect(
result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot,
).toBe('手动改过的结果');
});
it('rejects mismatched response counts without exposing the candidate or restoring an older snapshot', async () => {
clientMocks.optimize
.mockResolvedValueOnce({ prompt: '第一次优化结果', charCount: 7 })
.mockResolvedValueOnce({ prompt: '不应写入的候选', charCount: 999 });
const { result } = renderHook(() =>
useHarness({
initialDialogs: [createDialog('dialog-a', '原始描述')],
}),
);
await act(async () => {
await result.current.promptAssist.optimizePrompt('dialog-a');
});
act(() => result.current.setPrompt('dialog-a', '第二次输入'));
let actionResult!: Awaited<
ReturnType<typeof result.current.promptAssist.optimizePrompt>
>;
await act(async () => {
actionResult =
await result.current.promptAssist.optimizePrompt('dialog-a');
});
expect(actionResult).toMatchObject({ applied: false, reason: 'failed' });
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'第二次输入',
);
expect(JSON.stringify(result.current)).not.toContain('不应写入的候选');
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'idle',
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: 'AI 返回的游戏音效描述无效,请稍后重试',
});
});
it('drops a late response after account or project scope changes', async () => {
const deferred = createDeferred<OptimizeResponse>();
clientMocks.optimize.mockReturnValueOnce(deferred.promise);
const { result, rerender } = renderHook(
({ currentUserId, projectId }) =>
useHarness({
initialDialogs: [createDialog('dialog-a', '旧作用域输入')],
currentUserId,
projectId,
}),
{
initialProps: {
currentUserId: 'user-a',
projectId: 'project-a',
},
},
);
let action!: ReturnType<typeof result.current.promptAssist.optimizePrompt>;
act(() => {
action = result.current.promptAssist.optimizePrompt('dialog-a');
});
act(() => {
rerender({ currentUserId: 'user-b', projectId: 'project-b' });
result.current.setPrompt('dialog-a', '新作用域输入');
});
let actionResult!: Awaited<typeof action>;
await act(async () => {
deferred.resolve({ prompt: '旧响应候选', charCount: 6 });
actionResult = await action;
});
expect(actionResult.reason).toBe('stale');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'新作用域输入',
);
expect(JSON.stringify(result.current)).not.toContain('旧响应候选');
});
it('removes dialog state and ignores a response after dialog deletion', async () => {
const deferred = createDeferred<OptimizeResponse>();
clientMocks.optimize.mockReturnValueOnce(deferred.promise);
const { result } = renderHook(() =>
useHarness({ initialDialogs: [createDialog('dialog-a', '输入')] }),
);
let action!: ReturnType<typeof result.current.promptAssist.optimizePrompt>;
act(() => {
action = result.current.promptAssist.optimizePrompt('dialog-a');
result.current.removeDialog('dialog-a');
});
await act(async () => {
deferred.resolve({ prompt: '迟到候选', charCount: 4 });
await action;
});
expect(result.current.dialogs).toEqual([]);
expect(
result.current.promptAssist.dialogStates['dialog-a'],
).toBeUndefined();
});
it('freezes duration mode, manual value, and Loop in one synchronous submission claim', () => {
const { result } = renderHook(() =>
useHarness({
initialDialogs: [
createDialog('dialog-a', ' \uFEFF循环环境声\u3000', {
soundDurationMode: 'auto',
soundDurationSeconds: 7.3,
soundLoop: true,
}),
],
}),
);
let claim!: ReturnType<typeof result.current.promptAssist.beginSubmission>;
act(() => {
claim = result.current.promptAssist.beginSubmission('dialog-a');
});
expect(claim).toMatchObject({
prompt: '循环环境声',
durationMode: 'auto',
manualDurationSeconds: 7.3,
loop: true,
});
expect(result.current.promptAssist.getDialogState('dialog-a').status).toBe(
'submitting',
);
let duplicateClaim: ReturnType<
typeof result.current.promptAssist.beginSubmission
> = claim;
act(() => {
duplicateClaim = result.current.promptAssist.beginSubmission('dialog-a');
});
expect(duplicateClaim).toBeNull();
let finished = false;
act(() => {
finished = result.current.promptAssist.finishSubmission({
operation: claim!.operation,
accepted: false,
});
});
expect(finished).toBe(true);
expect(result.current.promptAssist.getDialogState('dialog-a').status).toBe(
'idle',
);
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'循环环境声',
);
});
it('clears the undo snapshot when a preset boundary is prepared', async () => {
clientMocks.optimize.mockResolvedValueOnce({
prompt: '优化结果',
charCount: 4,
});
const { result } = renderHook(() =>
useHarness({ initialDialogs: [createDialog('dialog-a', '原始输入')] }),
);
await act(async () => {
await result.current.promptAssist.optimizePrompt('dialog-a');
});
expect(
result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot,
).toBe('原始输入');
act(() => {
result.current.promptAssist.preparePreset('dialog-a');
});
await waitFor(() => {
expect(
result.current.promptAssist.getDialogState('dialog-a')
.undoPromptSnapshot,
).toBeNull();
});
});
});