Files
Genarrative/src/components/image-editor/useImageCanvasBackgroundMusicPromptAssist.test.tsx
T
lhk229 281c84b7bf
Project CI / Repository checks (push) Successful in 1m15s
Project CI / Frontend tests (push) Failing after 2m11s
Project CI / Backend tests (push) Successful in 3m33s
Project CI / Native shell tests (push) Successful in 13m57s
BGM生成路径优化 (#142)
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/142
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-08-06 17:54:40 +08:00

1202 lines
37 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import {
type ReactNode,
type SetStateAction,
StrictMode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
canonicalizeBackgroundMusicPrompt,
countPromptCodePoints,
} from './ImageCanvasBackgroundMusicPromptModel';
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
import { useImageCanvasBackgroundMusicPromptAssist } from './useImageCanvasBackgroundMusicPromptAssist';
const promptAssistClientMocks = vi.hoisted(() => ({
complete: vi.fn(),
simplify: vi.fn(),
}));
vi.mock('../../services/image-editor/editorProjectClient', () => ({
completeEditorBackgroundMusicPrompt: promptAssistClientMocks.complete,
simplifyEditorBackgroundMusicPrompt: promptAssistClientMocks.simplify,
}));
type PromptAssistResponse = {
prompt: string;
charCount: number;
};
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
};
type HarnessProps = {
initialDialogs: CanvasGenerationDialogState[];
currentUserId?: string | null;
projectId?: string | null;
};
function createDeferred<T>(): Deferred<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 promptAssistResponse(prompt: string): PromptAssistResponse {
const canonicalPrompt = canonicalizeBackgroundMusicPrompt(prompt);
return {
prompt,
charCount: countPromptCodePoints(canonicalPrompt),
};
}
function createDialog(
id: string,
prompt: string,
overrides: Partial<CanvasGenerationDialogState> = {},
): CanvasGenerationDialogState {
return {
id,
mode: 'audio-background-music',
prompt,
status: 'idle',
composerOpen: true,
...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;
}
/**
* 记录同一次 commit 内的相位顺序。scope 清理必须发生在 commit 的 layout 阶段:
* 排到 passive 阶段就会留出“新 scope 已 commit、旧 operation 仍有效”的窗口,
* 而 Testing Library 的 rerender / act 会自动冲刷 passive effect,行为断言覆盖不到。
*/
function ScopePhaseProbe({ record }: { record: (phase: string) => void }) {
useLayoutEffect(() => {
record('probe-layout');
});
useEffect(() => {
record('probe-passive');
});
return null;
}
function usePromptAssistHarness({
initialDialogs,
currentUserId,
projectId,
}: HarnessProps) {
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 currentDialogs = dialogsRef.current;
const currentDialog = currentDialogs.find(
(dialog) => dialog.id === dialogId,
);
if (!currentDialog) {
return;
}
const nextDialog = updater(currentDialog);
const nextDialogs = nextDialog
? currentDialogs.map((dialog) =>
dialog.id === dialogId ? nextDialog : dialog,
)
: currentDialogs.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 setComposerOpen = useCallback(
(dialogId: string, composerOpen: boolean) => {
updateCanvasGenerationDialogById(dialogId, (dialog) => ({
...dialog,
composerOpen,
}));
},
[updateCanvasGenerationDialogById],
);
const removeDialog = useCallback(
(dialogId: string) => {
updateDialogs((currentDialogs) =>
currentDialogs.filter((dialog) => dialog.id !== dialogId),
);
},
[updateDialogs],
);
const promptAssist = useImageCanvasBackgroundMusicPromptAssist({
canvasGenerationDialogs: dialogs,
getCanvasGenerationDialogById,
updateCanvasGenerationDialogById,
currentUserId,
projectId,
});
return {
dialogs,
promptAssist,
setPrompt,
setComposerOpen,
removeDialog,
};
}
async function resolveAction<T>(
deferred: Deferred<PromptAssistResponse>,
responsePrompt: string,
actionPromise: Promise<T>,
) {
let actionResult!: T;
await act(async () => {
deferred.resolve(promptAssistResponse(responsePrompt));
actionResult = await actionPromise;
});
return actionResult;
}
describe('useImageCanvasBackgroundMusicPromptAssist', () => {
beforeEach(() => {
promptAssistClientMocks.complete.mockReset();
promptAssistClientMocks.simplify.mockReset();
});
it('writes the canonical Prompt before requesting and collapses a same-action double click', async () => {
const request = createDeferred<PromptAssistResponse>();
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [
createDialog('dialog-a', ' \u0085森林冒险\u3000', {
status: 'failed',
errorMessage: '旧生成错误',
}),
],
currentUserId: 'user-a',
projectId: 'project-a',
}),
);
let firstAction!: ReturnType<
typeof result.current.promptAssist.completePrompt
>;
let duplicateAction!: ReturnType<
typeof result.current.promptAssist.completePrompt
>;
act(() => {
firstAction = result.current.promptAssist.completePrompt('dialog-a');
duplicateAction = result.current.promptAssist.completePrompt('dialog-a');
});
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险',
);
expect(requireDialog(result.current.dialogs, 'dialog-a')).toMatchObject({
status: 'idle',
errorMessage: undefined,
});
expect(promptAssistClientMocks.complete).toHaveBeenCalledTimes(1);
expect(promptAssistClientMocks.complete).toHaveBeenCalledWith(
{ currentPrompt: '森林冒险' },
{ signal: expect.any(AbortSignal) },
);
await expect(duplicateAction).resolves.toMatchObject({
started: false,
applied: false,
reason: 'duplicate',
});
const outcome = await resolveAction(
request,
' \u0085轻快的森林冒险背景音乐\u3000',
firstAction,
);
expect(outcome).toMatchObject({
started: true,
applied: true,
reason: 'applied',
});
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'轻快的森林冒险背景音乐',
);
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'idle',
undoPromptSnapshot: '森林冒险',
temporaryPromptSnapshot: null,
});
});
it('aborts an older operation and discards its response even when the client ignores the signal', async () => {
const completionRequest = createDeferred<PromptAssistResponse>();
const simplificationRequest = createDeferred<PromptAssistResponse>();
let completionSignal: AbortSignal | undefined;
promptAssistClientMocks.complete.mockImplementationOnce(
(
_input: { currentPrompt: string },
options: { signal?: AbortSignal },
) => {
completionSignal = options.signal;
return completionRequest.promise;
},
);
promptAssistClientMocks.simplify.mockReturnValueOnce(
simplificationRequest.promise,
);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [createDialog('dialog-a', '森林冒险')],
}),
);
let completionAction!: ReturnType<
typeof result.current.promptAssist.completePrompt
>;
act(() => {
completionAction = result.current.promptAssist.completePrompt('dialog-a');
});
act(() => {
result.current.setPrompt('dialog-a', '长'.repeat(201));
});
let simplificationAction!: ReturnType<
typeof result.current.promptAssist.simplifyPrompt
>;
act(() => {
simplificationAction =
result.current.promptAssist.simplifyPrompt('dialog-a');
});
expect(completionSignal?.aborted).toBe(true);
const simplificationOutcome = await resolveAction(
simplificationRequest,
'紧张推进的战斗背景音乐',
simplificationAction,
);
expect(simplificationOutcome.reason).toBe('applied');
const completionOutcome = await resolveAction(
completionRequest,
'不能写回的迟到补全结果',
completionAction,
);
expect(completionOutcome.reason).toBe('stale');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'紧张推进的战斗背景音乐',
);
});
it('drops responses after dialogs close or disappear and leaves no processing state behind', async () => {
const closeRequest = createDeferred<PromptAssistResponse>();
const deleteRequest = createDeferred<PromptAssistResponse>();
const signals = new Map<string, AbortSignal>();
promptAssistClientMocks.complete.mockImplementation(
(input: { currentPrompt: string }, options: { signal: AbortSignal }) => {
signals.set(input.currentPrompt, options.signal);
return input.currentPrompt === '关闭面板'
? closeRequest.promise
: deleteRequest.promise;
},
);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [
createDialog('dialog-close', ' \u0085关闭面板\u3000'),
createDialog('dialog-delete', ' \u0085删除面板\u3000'),
],
}),
);
let closeAction!: ReturnType<
typeof result.current.promptAssist.completePrompt
>;
let deleteAction!: ReturnType<
typeof result.current.promptAssist.completePrompt
>;
act(() => {
closeAction = result.current.promptAssist.completePrompt('dialog-close');
deleteAction =
result.current.promptAssist.completePrompt('dialog-delete');
});
act(() => {
result.current.setComposerOpen('dialog-close', false);
result.current.removeDialog('dialog-delete');
});
await waitFor(() => {
expect(signals.get('关闭面板')?.aborted).toBe(true);
expect(signals.get('删除面板')?.aborted).toBe(true);
});
let closeOutcome!: Awaited<typeof closeAction>;
let deleteOutcome!: Awaited<typeof deleteAction>;
await act(async () => {
closeRequest.resolve(promptAssistResponse('关闭后的迟到结果'));
deleteRequest.resolve(promptAssistResponse('删除后的迟到结果'));
[closeOutcome, deleteOutcome] = await Promise.all([
closeAction,
deleteAction,
]);
});
expect(closeOutcome.reason).toBe('stale');
expect(deleteOutcome.reason).toBe('stale');
expect(requireDialog(result.current.dialogs, 'dialog-close')).toMatchObject(
{
composerOpen: false,
prompt: '关闭面板',
},
);
expect(
result.current.dialogs.some((dialog) => dialog.id === 'dialog-delete'),
).toBe(false);
expect(
result.current.promptAssist.getDialogState('dialog-close').status,
).toBe('idle');
expect(
Object.prototype.hasOwnProperty.call(
result.current.promptAssist.dialogStates,
'dialog-delete',
),
).toBe(false);
});
it('keeps dialog responses isolated from edits in another dialog', async () => {
const request = createDeferred<PromptAssistResponse>();
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [
createDialog('dialog-a', '森林冒险'),
createDialog('dialog-b', '城市夜晚'),
],
}),
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
act(() => {
result.current.setPrompt('dialog-b', ' \u0085用户正在编辑 B\u3000');
});
await resolveAction(request, '森林冒险补全结果', action);
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险补全结果',
);
expect(requireDialog(result.current.dialogs, 'dialog-b').prompt).toBe(
' \u0085用户正在编辑 B\u3000',
);
});
it('keeps the canonical request Prompt and clears the replaced undo snapshot after failure', async () => {
promptAssistClientMocks.complete
.mockResolvedValueOnce(promptAssistResponse('第一次补全结果'))
.mockRejectedValueOnce(new Error('LLM 暂时不可用'));
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [createDialog('dialog-a', '初始描述')],
}),
);
await act(async () => {
await result.current.promptAssist.completePrompt('dialog-a');
});
expect(
result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot,
).toBe('初始描述');
act(() => {
result.current.setPrompt('dialog-a', ' \u0085用户手动修改\u3000');
});
let failureOutcome!: Awaited<
ReturnType<typeof result.current.promptAssist.completePrompt>
>;
await act(async () => {
failureOutcome =
await result.current.promptAssist.completePrompt('dialog-a');
});
expect(failureOutcome).toMatchObject({
applied: false,
reason: 'failed',
errorMessage: 'LLM 暂时不可用',
});
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'用户手动修改',
);
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'idle',
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: 'LLM 暂时不可用',
});
});
it('keeps one successful snapshot and swaps it repeatedly after manual edits', async () => {
promptAssistClientMocks.complete.mockResolvedValueOnce(
promptAssistResponse('完整的森林冒险背景音乐'),
);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [createDialog('dialog-a', '森林冒险')],
}),
);
await act(async () => {
await result.current.promptAssist.completePrompt('dialog-a');
});
act(() => {
result.current.setPrompt('dialog-a', ' \u0085用户手动调整\u3000');
});
let firstUndoApplied = false;
act(() => {
firstUndoApplied = result.current.promptAssist.undoPrompt('dialog-a');
});
expect(firstUndoApplied).toBe(true);
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险',
);
expect(
result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot,
).toBe('用户手动调整');
let secondUndoApplied = false;
act(() => {
secondUndoApplied = result.current.promptAssist.undoPrompt('dialog-a');
});
expect(secondUndoApplied).toBe(true);
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'用户手动调整',
);
expect(
result.current.promptAssist.getDialogState('dialog-a').undoPromptSnapshot,
).toBe('森林冒险');
});
it('treats the preset boundary as cancellation and clears all Prompt snapshots', async () => {
const request = createDeferred<PromptAssistResponse>();
let signal: AbortSignal | undefined;
promptAssistClientMocks.complete.mockImplementationOnce(
(
_input: { currentPrompt: string },
options: { signal?: AbortSignal },
) => {
signal = options.signal;
return request.promise;
},
);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [createDialog('dialog-a', ' \u0085森林冒险\u3000')],
}),
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
let preparedPrompt: string | null = null;
act(() => {
preparedPrompt = result.current.promptAssist.preparePreset('dialog-a');
});
expect(preparedPrompt).toBe('森林冒险');
expect(signal?.aborted).toBe(true);
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'idle',
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
});
const outcome = await resolveAction(
request,
'预设边界后的迟到结果',
action,
);
expect(outcome.reason).toBe('stale');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险',
);
});
it('requests simplification at 2000 code points but preserves and rejects 2001', async () => {
const request = createDeferred<PromptAssistResponse>();
promptAssistClientMocks.simplify.mockReturnValueOnce(request.promise);
const eligiblePrompt = 'A'.repeat(2000);
const ineligiblePrompt = 'B'.repeat(2001);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [
createDialog('dialog-eligible', ` \u0085${eligiblePrompt}\u3000`),
createDialog('dialog-ineligible', ` \u0085${ineligiblePrompt}\u3000`),
],
}),
);
let eligibleAction!: ReturnType<
typeof result.current.promptAssist.simplifyPrompt
>;
let ineligibleAction!: ReturnType<
typeof result.current.promptAssist.simplifyPrompt
>;
act(() => {
eligibleAction =
result.current.promptAssist.simplifyPrompt('dialog-eligible');
ineligibleAction =
result.current.promptAssist.simplifyPrompt('dialog-ineligible');
});
expect(promptAssistClientMocks.simplify).toHaveBeenCalledTimes(1);
expect(promptAssistClientMocks.simplify).toHaveBeenCalledWith(
{ currentPrompt: eligiblePrompt },
{ signal: expect.any(AbortSignal) },
);
expect(
requireDialog(result.current.dialogs, 'dialog-eligible').prompt,
).toBe(eligiblePrompt);
expect(
requireDialog(result.current.dialogs, 'dialog-ineligible').prompt,
).toBe(ineligiblePrompt);
await expect(ineligibleAction).resolves.toMatchObject({
started: false,
reason: 'ineligible',
});
const outcome = await resolveAction(
request,
'合法的简化结果',
eligibleAction,
);
expect(outcome.reason).toBe('applied');
});
it('aborts and invalidates an active request when projectId changes', async () => {
const oldScopeRequest = createDeferred<PromptAssistResponse>();
const newScopeRequest = createDeferred<PromptAssistResponse>();
let signal: AbortSignal | undefined;
promptAssistClientMocks.complete
.mockImplementationOnce(
(
_input: { currentPrompt: string },
options: { signal?: AbortSignal },
) => {
signal = options.signal;
return oldScopeRequest.promise;
},
)
.mockReturnValueOnce(newScopeRequest.promise);
const initialDialogs = [createDialog('dialog-a', ' \u0085森林冒险\u3000')];
const { result, rerender } = renderHook(
({ projectId }: { projectId: string }) =>
usePromptAssistHarness({
initialDialogs,
currentUserId: 'user-a',
projectId,
}),
{
initialProps: { projectId: 'project-a' },
},
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
rerender({ projectId: 'project-b' });
expect(signal?.aborted).toBe(true);
expect(result.current.promptAssist.dialogStates).toEqual({});
let newScopeAction!: ReturnType<
typeof result.current.promptAssist.completePrompt
>;
act(() => {
newScopeAction = result.current.promptAssist.completePrompt('dialog-a');
});
const oldScopeOutcome = await resolveAction(
oldScopeRequest,
'旧工程的迟到结果',
action,
);
expect(oldScopeOutcome.reason).toBe('stale');
expect(result.current.promptAssist.getDialogState('dialog-a').status).toBe(
'completing',
);
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险',
);
const newScopeOutcome = await resolveAction(
newScopeRequest,
'新工程的有效结果',
newScopeAction,
);
expect(newScopeOutcome.reason).toBe('applied');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'新工程的有效结果',
);
});
it('aborts requests and clears snapshots when only currentUserId changes', async () => {
const staleRequest = createDeferred<PromptAssistResponse>();
let signal: AbortSignal | undefined;
promptAssistClientMocks.complete
.mockResolvedValueOnce(promptAssistResponse('完整的森林冒险背景音乐'))
.mockImplementationOnce(
(
_input: { currentPrompt: string },
options: { signal?: AbortSignal },
) => {
signal = options.signal;
return staleRequest.promise;
},
);
const initialDialogs = [createDialog('dialog-a', '森林冒险')];
const { result, rerender } = renderHook(
({ currentUserId }: { currentUserId: string }) =>
usePromptAssistHarness({
initialDialogs,
currentUserId,
projectId: 'project-a',
}),
{
initialProps: { currentUserId: 'user-a' },
},
);
await act(async () => {
await result.current.promptAssist.completePrompt('dialog-a');
});
expect(
result.current.promptAssist.getDialogState('dialog-a')
.undoPromptSnapshot,
).toBe('森林冒险');
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
rerender({ currentUserId: 'user-b' });
expect(signal?.aborted).toBe(true);
expect(result.current.promptAssist.dialogStates).toEqual({});
expect(
result.current.promptAssist.getDialogState('dialog-a'),
).toMatchObject({
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: null,
});
const staleOutcome = await resolveAction(
staleRequest,
'旧账号的迟到结果',
action,
);
expect(staleOutcome.reason).toBe('stale');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'完整的森林冒险背景音乐',
);
expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject(
{
status: 'idle',
undoPromptSnapshot: null,
},
);
});
it('invalidates old-scope operations inside the commit, before any passive effect runs', async () => {
const staleRequest = createDeferred<PromptAssistResponse>();
let signal: AbortSignal | undefined;
promptAssistClientMocks.complete.mockImplementationOnce(
(
_input: { currentPrompt: string },
options: { signal?: AbortSignal },
) => {
signal = options.signal;
return staleRequest.promise;
},
);
const phases: string[] = [];
const initialDialogs = [createDialog('dialog-a', '森林冒险')];
const { result, rerender } = renderHook(
({ currentUserId }: { currentUserId: string }) =>
usePromptAssistHarness({
initialDialogs,
currentUserId,
projectId: 'project-a',
}),
{
initialProps: { currentUserId: 'user-a' },
wrapper: ({ children }: { children: ReactNode }) => (
<>
<ScopePhaseProbe record={(phase) => phases.push(phase)} />
{children}
</>
),
},
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
signal?.addEventListener('abort', () => phases.push('abort'), {
once: true,
});
// 只观察 scope 切换这一次 commit 的相位顺序。
phases.length = 0;
rerender({ currentUserId: 'user-b' });
expect(phases[0]).toBe('probe-layout');
expect(phases).toContain('abort');
expect(phases.indexOf('abort')).toBeLessThan(
phases.indexOf('probe-passive'),
);
expect(signal?.aborted).toBe(true);
expect(result.current.promptAssist.dialogStates).toEqual({});
const staleOutcome = await resolveAction(
staleRequest,
'旧账号的迟到结果',
action,
);
expect(staleOutcome.reason).toBe('stale');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险',
);
});
it('keeps an in-flight operation valid across renders that recreate equal scope props', async () => {
const request = createDeferred<PromptAssistResponse>();
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
const initialDialogs = [createDialog('dialog-a', '森林冒险')];
const { result, rerender } = renderHook(
({ currentUserId, projectId }: HarnessProps) =>
usePromptAssistHarness({
initialDialogs,
currentUserId,
projectId,
}),
{
initialProps: {
initialDialogs,
currentUserId: 'user-a',
projectId: 'project-a',
},
},
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
// scope 身份必须按值判定:每次 render 重新构造等值 props 不得让运行中的
// operation 变成 stale。
rerender({
initialDialogs,
currentUserId: 'user-a',
projectId: 'project-a',
});
const outcome = await resolveAction(
request,
'完整的森林冒险背景音乐',
action,
);
expect(outcome.reason).toBe('applied');
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'完整的森林冒险背景音乐',
);
expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject(
{
status: 'idle',
undoPromptSnapshot: '森林冒险',
},
);
});
it('completes without sticking in a busy state under StrictMode double rendering', async () => {
const request = createDeferred<PromptAssistResponse>();
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
const { result } = renderHook(
() =>
usePromptAssistHarness({
initialDialogs: [createDialog('dialog-a', '森林冒险')],
currentUserId: 'user-a',
projectId: 'project-a',
}),
{ wrapper: StrictMode },
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
expect(result.current.promptAssist.getDialogState('dialog-a').status).toBe(
'completing',
);
const outcome = await resolveAction(
request,
'完整的森林冒险背景音乐',
action,
);
expect(outcome.reason).toBe('applied');
expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject(
{
status: 'idle',
operationId: null,
undoPromptSnapshot: '森林冒险',
},
);
});
it('returns to idle without an undo snapshot when the browser request times out', async () => {
const request = createDeferred<PromptAssistResponse>();
promptAssistClientMocks.complete.mockReturnValueOnce(request.promise);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [createDialog('dialog-a', ' …森林冒险 ')],
currentUserId: 'user-a',
projectId: 'project-a',
}),
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
const timeoutError = Object.assign(new Error('请求超时:180000ms'), {
name: 'TimeoutError',
});
let outcome!: Awaited<typeof action>;
await act(async () => {
request.reject(timeoutError);
outcome = await action;
});
expect(outcome).toMatchObject({
started: true,
applied: false,
reason: 'failed',
errorMessage: '请求超时:180000ms',
});
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险',
);
expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject(
{
status: 'idle',
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: '请求超时:180000ms',
},
);
});
it('aborts on unmount and discards a client response that still resolves', async () => {
const request = createDeferred<PromptAssistResponse>();
let signal: AbortSignal | undefined;
promptAssistClientMocks.complete.mockImplementationOnce(
(
_input: { currentPrompt: string },
options: { signal?: AbortSignal },
) => {
signal = options.signal;
return request.promise;
},
);
const { result, unmount } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [createDialog('dialog-a', '森林冒险')],
}),
);
let action!: ReturnType<typeof result.current.promptAssist.completePrompt>;
act(() => {
action = result.current.promptAssist.completePrompt('dialog-a');
});
unmount();
expect(signal?.aborted).toBe(true);
request.resolve(promptAssistResponse('卸载后的迟到结果'));
await expect(action).resolves.toMatchObject({
applied: false,
reason: 'stale',
});
});
it('preserves undo on rejection, settles a closed composer, and ignores a deleted dialog', async () => {
promptAssistClientMocks.complete.mockResolvedValueOnce(
promptAssistResponse('完整的森林冒险背景音乐'),
);
const { result } = renderHook(() =>
usePromptAssistHarness({
initialDialogs: [
createDialog('dialog-reject', '森林冒险'),
createDialog('dialog-close', '关闭前描述'),
createDialog('dialog-delete', '删除前描述'),
],
}),
);
await act(async () => {
await result.current.promptAssist.completePrompt('dialog-reject');
});
act(() => {
result.current.setPrompt('dialog-reject', ' \u0085提交前修改\u3000');
});
let rejectedClaim!: NonNullable<
ReturnType<typeof result.current.promptAssist.beginSubmission>
>;
act(() => {
rejectedClaim =
result.current.promptAssist.beginSubmission('dialog-reject')!;
});
let blockedCompletion!: Awaited<
ReturnType<typeof result.current.promptAssist.completePrompt>
>;
let blockedSimplification!: Awaited<
ReturnType<typeof result.current.promptAssist.simplifyPrompt>
>;
await act(async () => {
blockedCompletion =
await result.current.promptAssist.completePrompt('dialog-reject');
blockedSimplification =
await result.current.promptAssist.simplifyPrompt('dialog-reject');
});
let preparedDuringSubmission: string | null = '';
let undoDuringSubmission = true;
act(() => {
preparedDuringSubmission =
result.current.promptAssist.preparePreset('dialog-reject');
undoDuringSubmission =
result.current.promptAssist.undoPrompt('dialog-reject');
});
expect(blockedCompletion.reason).toBe('locked');
expect(blockedSimplification.reason).toBe('locked');
expect(promptAssistClientMocks.complete).toHaveBeenCalledTimes(1);
expect(promptAssistClientMocks.simplify).not.toHaveBeenCalled();
expect(preparedDuringSubmission).toBeNull();
expect(undoDuringSubmission).toBe(false);
expect(requireDialog(result.current.dialogs, 'dialog-reject').prompt).toBe(
'提交前修改',
);
expect(
result.current.promptAssist.getDialogState('dialog-reject'),
).toMatchObject({
status: 'submitting',
operationId: rejectedClaim.operation.operationId,
undoPromptSnapshot: '森林冒险',
});
let rejectionApplied = false;
act(() => {
rejectionApplied = result.current.promptAssist.finishSubmission({
operation: rejectedClaim.operation,
accepted: false,
});
});
expect(rejectionApplied).toBe(true);
expect(requireDialog(result.current.dialogs, 'dialog-reject').prompt).toBe(
'提交前修改',
);
expect(
result.current.promptAssist.getDialogState('dialog-reject'),
).toMatchObject({
status: 'idle',
undoPromptSnapshot: '森林冒险',
});
let closeClaim!: NonNullable<
ReturnType<typeof result.current.promptAssist.beginSubmission>
>;
let deleteClaim!: NonNullable<
ReturnType<typeof result.current.promptAssist.beginSubmission>
>;
act(() => {
closeClaim = result.current.promptAssist.beginSubmission('dialog-close')!;
deleteClaim =
result.current.promptAssist.beginSubmission('dialog-delete')!;
});
act(() => {
result.current.setComposerOpen('dialog-close', false);
result.current.removeDialog('dialog-delete');
});
await waitFor(() => {
expect(
result.current.promptAssist.getDialogState('dialog-close').status,
).toBe('submitting');
expect(
Object.prototype.hasOwnProperty.call(
result.current.promptAssist.dialogStates,
'dialog-delete',
),
).toBe(false);
});
let closeFinished = false;
let deleteFinished = true;
act(() => {
closeFinished = result.current.promptAssist.finishSubmission({
operation: closeClaim.operation,
accepted: true,
});
deleteFinished = result.current.promptAssist.finishSubmission({
operation: deleteClaim.operation,
accepted: true,
});
});
expect(closeFinished).toBe(true);
expect(deleteFinished).toBe(false);
expect(
result.current.promptAssist.getDialogState('dialog-close'),
).toMatchObject({
status: 'idle',
operationId: null,
});
expect(
Object.prototype.hasOwnProperty.call(
result.current.promptAssist.dialogStates,
'dialog-delete',
),
).toBe(false);
expect(
requireDialog(result.current.dialogs, 'dialog-close').composerOpen,
).toBe(false);
expect(
result.current.dialogs.some((dialog) => dialog.id === 'dialog-delete'),
).toBe(false);
});
it('rejects a submission finish after the committed scope changes', () => {
const initialDialogs = [createDialog('dialog-a', '森林冒险')];
const { result, rerender } = renderHook(
({ currentUserId, projectId }: Omit<HarnessProps, 'initialDialogs'>) =>
usePromptAssistHarness({
initialDialogs,
currentUserId,
projectId,
}),
{
initialProps: {
currentUserId: 'user-a',
projectId: 'project-a',
},
},
);
let claim!: NonNullable<
ReturnType<typeof result.current.promptAssist.beginSubmission>
>;
act(() => {
claim = result.current.promptAssist.beginSubmission('dialog-a')!;
});
rerender({
currentUserId: 'user-a',
projectId: 'project-b',
});
let finished = true;
act(() => {
finished = result.current.promptAssist.finishSubmission({
operation: claim.operation,
accepted: true,
});
});
expect(finished).toBe(false);
expect(result.current.promptAssist.getDialogState('dialog-a')).toMatchObject(
{
status: 'idle',
operationId: null,
},
);
expect(requireDialog(result.current.dialogs, 'dialog-a').prompt).toBe(
'森林冒险',
);
});
});