281c84b7bf
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>
1202 lines
37 KiB
TypeScript
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(
|
|
'森林冒险',
|
|
);
|
|
});
|
|
});
|