5c316fc680
将 SFX Prompt 首尾规范化改为 ECMAScript String.trim 语义并同步 Rust 等值实现 更新共享边界 fixture、前后端与 Worker 回归测试 同步权威设计、实施计划、决策日志和 External OpenAPI 描述
393 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|