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>
579 lines
17 KiB
TypeScript
579 lines
17 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, renderHook } from '@testing-library/react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
|
|
import {
|
|
requiresGenerationDeleteConfirmation,
|
|
useCanvasGenerationDialogs,
|
|
} from './useCanvasGenerationDialogs';
|
|
|
|
function createDialog(
|
|
mode: CanvasGenerationDialogState['mode'],
|
|
prompt: string,
|
|
): Omit<CanvasGenerationDialogState, 'id'> {
|
|
return {
|
|
mode,
|
|
prompt,
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
placeholder: {
|
|
x: mode === 'character' ? 30 : 10,
|
|
y: mode === 'character' ? 40 : 20,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
},
|
|
};
|
|
}
|
|
|
|
function durablePerfectPixelDialog(
|
|
dialogId: string,
|
|
status: 'generating' | 'pending-confirmation' | 'failed',
|
|
): CanvasGenerationDialogState {
|
|
const submittedAt = 1_700_000_000_000;
|
|
return {
|
|
id: dialogId,
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status,
|
|
composerOpen: true,
|
|
sourceLayerId: 'layer-source',
|
|
// 中文注释:marker 从占位创建那一刻就存在,账本形成后也一直在。夹具必须还原这个形状,
|
|
// 否则下游用例是在测一个真实链路里不存在的状态。
|
|
perfectPixelOperationId: dialogId,
|
|
perfectPixelOperation: {
|
|
version: 1,
|
|
kind: 'perfect-pixel',
|
|
operationId: dialogId,
|
|
taskId: `pixel-art-snap-${dialogId}`,
|
|
request: {
|
|
sourceImageSrc: 'generated-images/editor/source.png',
|
|
projectId: 'project-1',
|
|
canvasCompletion: {
|
|
dialogId,
|
|
title: '源图 · 完美像素',
|
|
placeholder: {
|
|
x: 0,
|
|
y: 0,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
},
|
|
},
|
|
},
|
|
submittedAt,
|
|
reconcileUntil: submittedAt + 75_000,
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('useCanvasGenerationDialogs', () => {
|
|
it('keeps the active dialog context stable while reference uploads are pending', () => {
|
|
let locked = false;
|
|
const onContextMutationRejected = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useCanvasGenerationDialogs({
|
|
isContextMutationLocked: () => locked,
|
|
onContextMutationRejected,
|
|
}),
|
|
);
|
|
|
|
act(() => {
|
|
result.current.openCanvasGenerationDialog(
|
|
createDialog('generate', 'first'),
|
|
);
|
|
});
|
|
const activeId = result.current.activeCanvasGenerationDialog?.id;
|
|
expect(activeId).toBeTruthy();
|
|
|
|
locked = true;
|
|
act(() => {
|
|
result.current.openCanvasGenerationDialog(
|
|
createDialog('character', 'second'),
|
|
);
|
|
result.current.setGenerateDialog(null);
|
|
});
|
|
|
|
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
|
expect.objectContaining({ id: activeId, prompt: 'first' }),
|
|
);
|
|
expect(result.current.inactiveGenerateDialogs).toEqual([]);
|
|
expect(onContextMutationRejected).toHaveBeenCalledTimes(2);
|
|
|
|
act(() => {
|
|
result.current.setGenerateDialog((currentDialog) =>
|
|
currentDialog ? { ...currentDialog, prompt: 'updated' } : currentDialog,
|
|
);
|
|
});
|
|
expect(result.current.activeCanvasGenerationDialog?.prompt).toBe('updated');
|
|
});
|
|
|
|
it('rejects switching between same-mode non-canvas dialogs while uploads are pending', () => {
|
|
let locked = false;
|
|
const onContextMutationRejected = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useCanvasGenerationDialogs({
|
|
isContextMutationLocked: () => locked,
|
|
onContextMutationRejected,
|
|
}),
|
|
);
|
|
|
|
act(() => {
|
|
result.current.setGenerateDialog({
|
|
mode: 'edit',
|
|
sourceLayerId: 'layer-a',
|
|
prompt: '',
|
|
status: 'idle',
|
|
});
|
|
});
|
|
locked = true;
|
|
act(() => {
|
|
result.current.setGenerateDialog({
|
|
mode: 'edit',
|
|
sourceLayerId: 'layer-b',
|
|
prompt: '',
|
|
status: 'idle',
|
|
});
|
|
});
|
|
|
|
expect(result.current.generateDialog?.sourceLayerId).toBe('layer-a');
|
|
expect(onContextMutationRejected).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('archives, activates, updates, and removes canvas generation dialogs', () => {
|
|
const onActivate = vi.fn();
|
|
const { result } = renderHook(() =>
|
|
useCanvasGenerationDialogs({ onActivate }),
|
|
);
|
|
|
|
act(() => {
|
|
result.current.openCanvasGenerationDialog(
|
|
createDialog('generate', 'first'),
|
|
);
|
|
});
|
|
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
|
expect.objectContaining({
|
|
prompt: 'first',
|
|
composerOpen: true,
|
|
}),
|
|
);
|
|
|
|
act(() => {
|
|
result.current.openCanvasGenerationDialog(
|
|
createDialog('character', 'second'),
|
|
);
|
|
});
|
|
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
|
expect.objectContaining({
|
|
prompt: 'second',
|
|
composerOpen: true,
|
|
}),
|
|
);
|
|
expect(result.current.inactiveGenerateDialogs).toEqual([
|
|
expect.objectContaining({
|
|
prompt: 'first',
|
|
composerOpen: false,
|
|
}),
|
|
]);
|
|
expect(
|
|
result.current.canvasGenerationDialogs.map((dialog) => dialog.prompt),
|
|
).toEqual(['first', 'second']);
|
|
|
|
const archivedFirstDialog = result.current.inactiveGenerateDialogs[0];
|
|
expect(archivedFirstDialog).toBeDefined();
|
|
|
|
act(() => {
|
|
result.current.activateCanvasGenerationDialog(archivedFirstDialog!);
|
|
});
|
|
expect(onActivate).toHaveBeenCalledTimes(1);
|
|
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
|
expect.objectContaining({
|
|
prompt: 'first',
|
|
composerOpen: true,
|
|
}),
|
|
);
|
|
expect(result.current.inactiveGenerateDialogs).toEqual([
|
|
expect.objectContaining({
|
|
prompt: 'second',
|
|
composerOpen: false,
|
|
}),
|
|
]);
|
|
|
|
const activeDialog = result.current.activeCanvasGenerationDialog;
|
|
expect(activeDialog).toBeTruthy();
|
|
act(() => {
|
|
result.current.updateCanvasGenerationDialogById(
|
|
activeDialog!.id,
|
|
(dialog) => ({
|
|
...dialog,
|
|
status: 'generating',
|
|
placeholder: dialog.placeholder
|
|
? {
|
|
...dialog.placeholder,
|
|
x: 99,
|
|
}
|
|
: dialog.placeholder,
|
|
}),
|
|
);
|
|
});
|
|
expect(
|
|
result.current.getGeneratingDialogPlaceholder(activeDialog!)?.x,
|
|
).toBe(99);
|
|
|
|
act(() => {
|
|
result.current.updateCanvasGenerationDialogById(
|
|
activeDialog!.id,
|
|
(dialog) => ({
|
|
...dialog,
|
|
sourceLayerId: 'layer-a',
|
|
}),
|
|
);
|
|
});
|
|
act(() => {
|
|
result.current.removeCanvasGenerationDialogsByLayerId('layer-a');
|
|
});
|
|
expect(result.current.activeCanvasGenerationDialog).toBeNull();
|
|
expect(result.current.inactiveGenerateDialogs).toEqual([
|
|
expect.objectContaining({
|
|
prompt: 'second',
|
|
}),
|
|
]);
|
|
|
|
const archivedSecondDialog = result.current.inactiveGenerateDialogs[0];
|
|
expect(archivedSecondDialog).toBeDefined();
|
|
act(() => {
|
|
result.current.activateCanvasGenerationDialog(archivedSecondDialog!);
|
|
});
|
|
act(() => {
|
|
result.current.removeCanvasGenerationDialogById(archivedSecondDialog!.id);
|
|
});
|
|
expect(result.current.activeCanvasGenerationDialog).toBeNull();
|
|
expect(result.current.inactiveGenerateDialogs).toEqual([]);
|
|
});
|
|
|
|
it('restores saved dialogs and continues ids after the saved maximum', () => {
|
|
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
|
|
|
act(() => {
|
|
result.current.restoreCanvasGenerationDialogs([
|
|
{
|
|
id: 'generation-dialog-12',
|
|
mode: 'generate',
|
|
prompt: 'restored',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
placeholder: {
|
|
x: 12,
|
|
y: 24,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
|
expect.objectContaining({
|
|
id: 'generation-dialog-12',
|
|
prompt: 'restored',
|
|
}),
|
|
);
|
|
|
|
let nextDialogId = '';
|
|
act(() => {
|
|
nextDialogId = result.current.openCanvasGenerationDialog(
|
|
createDialog('character', 'second'),
|
|
);
|
|
});
|
|
expect(nextDialogId).toBe('generation-dialog-13');
|
|
expect(result.current.inactiveGenerateDialogs).toEqual([
|
|
expect.objectContaining({
|
|
id: 'generation-dialog-12',
|
|
prompt: 'restored',
|
|
composerOpen: false,
|
|
}),
|
|
]);
|
|
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
|
expect.objectContaining({
|
|
id: 'generation-dialog-13',
|
|
prompt: 'second',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('keeps the saved open dialog active when restoring multiple dialogs', () => {
|
|
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
|
|
|
act(() => {
|
|
result.current.restoreCanvasGenerationDialogs([
|
|
{
|
|
id: 'generation-dialog-2',
|
|
mode: 'generate',
|
|
prompt: 'inactive saved',
|
|
status: 'idle',
|
|
composerOpen: false,
|
|
},
|
|
{
|
|
id: 'generation-dialog-3',
|
|
mode: 'character',
|
|
prompt: 'active saved',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
},
|
|
]);
|
|
});
|
|
|
|
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
|
expect.objectContaining({
|
|
id: 'generation-dialog-3',
|
|
prompt: 'active saved',
|
|
composerOpen: true,
|
|
}),
|
|
);
|
|
expect(result.current.inactiveGenerateDialogs).toEqual([
|
|
expect.objectContaining({
|
|
id: 'generation-dialog-2',
|
|
prompt: 'inactive saved',
|
|
composerOpen: false,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('keeps refs in sync when an archived dialog is activated and immediately updated', () => {
|
|
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
|
|
|
act(() => {
|
|
result.current.openCanvasGenerationDialog(
|
|
createDialog('generate', 'first'),
|
|
);
|
|
});
|
|
act(() => {
|
|
result.current.openCanvasGenerationDialog(
|
|
createDialog('character', 'second'),
|
|
);
|
|
});
|
|
|
|
const archivedFirstDialog = result.current.inactiveGenerateDialogs[0];
|
|
expect(archivedFirstDialog).toEqual(
|
|
expect.objectContaining({ prompt: 'first' }),
|
|
);
|
|
|
|
let activeDialogRefId: string | undefined;
|
|
act(() => {
|
|
result.current.activateCanvasGenerationDialog(archivedFirstDialog!);
|
|
activeDialogRefId = result.current.generateDialogRef.current?.id;
|
|
result.current.updateCanvasGenerationDialogById(
|
|
archivedFirstDialog!.id,
|
|
(dialog) => ({
|
|
...dialog,
|
|
placeholder: dialog.placeholder
|
|
? {
|
|
...dialog.placeholder,
|
|
x: 128,
|
|
}
|
|
: dialog.placeholder,
|
|
}),
|
|
);
|
|
});
|
|
|
|
expect(activeDialogRefId).toBe(archivedFirstDialog!.id);
|
|
expect(result.current.activeCanvasGenerationDialog).toEqual(
|
|
expect.objectContaining({
|
|
id: archivedFirstDialog!.id,
|
|
prompt: 'first',
|
|
composerOpen: true,
|
|
placeholder: expect.objectContaining({ x: 128 }),
|
|
}),
|
|
);
|
|
expect(result.current.inactiveGenerateDialogs).toEqual([
|
|
expect.objectContaining({
|
|
prompt: 'second',
|
|
composerOpen: false,
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('reads the latest active and archived dialog synchronously by id', () => {
|
|
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
|
let firstDialogId = '';
|
|
let secondDialogId = '';
|
|
let activeDialogRead: CanvasGenerationDialogState | undefined;
|
|
let archivedDialogRead: CanvasGenerationDialogState | undefined;
|
|
|
|
act(() => {
|
|
firstDialogId = result.current.openCanvasGenerationDialog(
|
|
createDialog('audio-background-music', 'first'),
|
|
);
|
|
activeDialogRead =
|
|
result.current.getCanvasGenerationDialogById(firstDialogId);
|
|
secondDialogId = result.current.openCanvasGenerationDialog(
|
|
createDialog('audio-background-music', 'second'),
|
|
);
|
|
archivedDialogRead =
|
|
result.current.getCanvasGenerationDialogById(firstDialogId);
|
|
});
|
|
|
|
expect(activeDialogRead).toEqual(
|
|
expect.objectContaining({
|
|
id: firstDialogId,
|
|
prompt: 'first',
|
|
composerOpen: true,
|
|
}),
|
|
);
|
|
expect(archivedDialogRead).toEqual(
|
|
expect.objectContaining({
|
|
id: firstDialogId,
|
|
prompt: 'first',
|
|
composerOpen: false,
|
|
}),
|
|
);
|
|
expect(result.current.getCanvasGenerationDialogById(secondDialogId)).toEqual(
|
|
expect.objectContaining({
|
|
prompt: 'second',
|
|
composerOpen: true,
|
|
}),
|
|
);
|
|
|
|
let updatedDialogRead: CanvasGenerationDialogState | undefined;
|
|
act(() => {
|
|
result.current.updateCanvasGenerationDialogById(
|
|
firstDialogId,
|
|
(dialog) => ({
|
|
...dialog,
|
|
prompt: 'updated first',
|
|
}),
|
|
);
|
|
updatedDialogRead =
|
|
result.current.getCanvasGenerationDialogById(firstDialogId);
|
|
result.current.removeCanvasGenerationDialogById(firstDialogId);
|
|
});
|
|
|
|
expect(updatedDialogRead).toEqual(
|
|
expect.objectContaining({
|
|
prompt: 'updated first',
|
|
composerOpen: false,
|
|
}),
|
|
);
|
|
expect(
|
|
result.current.getCanvasGenerationDialogById(firstDialogId),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it('returns a newly opened explicit dialog from the synchronous snapshot getter in the same action', () => {
|
|
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
|
let openedDialogId = '';
|
|
let immediateSnapshot: CanvasGenerationDialogState[] = [];
|
|
|
|
act(() => {
|
|
openedDialogId = result.current.openCanvasGenerationDialog({
|
|
...createDialog('quick-edit', '完美像素'),
|
|
id: 'generation-dialog-perfect-pixel',
|
|
status: 'generating',
|
|
});
|
|
immediateSnapshot = result.current.getCanvasGenerationDialogsSnapshot();
|
|
});
|
|
|
|
expect(openedDialogId).toBe('generation-dialog-perfect-pixel');
|
|
expect(immediateSnapshot).toEqual([
|
|
expect.objectContaining({
|
|
id: 'generation-dialog-perfect-pixel',
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'generating',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
// 中文注释:占位是用户文档的一部分,删除它不撤销任何在途请求——完美像素没有取消接口,
|
|
// 结果照常落库并进素材库。低层删除因此不得对未收口 operation 抗命,否则上层会写出
|
|
// 「历史记了一笔、占位还在」的伪历史。
|
|
it.each(['generating', 'pending-confirmation', 'failed'] as const)(
|
|
'deletes an unsettled durable perfect-pixel operation by id while %s',
|
|
(status) => {
|
|
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
|
const dialogId = 'perfect-pixel-durable';
|
|
|
|
act(() => {
|
|
result.current.restoreCanvasGenerationDialogs([
|
|
durablePerfectPixelDialog(dialogId, status),
|
|
]);
|
|
});
|
|
act(() => {
|
|
result.current.removeCanvasGenerationDialogById(dialogId);
|
|
});
|
|
|
|
expect(result.current.activeCanvasGenerationDialog).toBeNull();
|
|
expect(result.current.canvasGenerationDialogs).toEqual([]);
|
|
},
|
|
);
|
|
|
|
// 中文注释:确认弹窗讲的是「已消耗的泥点不会返还」,只对计费生成成立;完美像素免费且删除
|
|
// 占位不撤销在途请求,所以它在任何状态都直接删。
|
|
it.each(['generating', 'pending-confirmation', 'failed'] as const)(
|
|
'never asks for delete confirmation on a perfect-pixel placeholder while %s',
|
|
(status) => {
|
|
expect(
|
|
requiresGenerationDeleteConfirmation(
|
|
durablePerfectPixelDialog('perfect-pixel-confirm', status),
|
|
),
|
|
).toBe(false);
|
|
},
|
|
);
|
|
|
|
it('never asks for delete confirmation before the perfect-pixel ledger exists', () => {
|
|
// 中文注释:源图解析 / 直传最长 90 秒,期间占位只有 marker、还没有账本。此前判据按账本
|
|
// 判,用户在这段窗口里删一个免费操作会看到「已消耗的泥点不会返还」。
|
|
expect(
|
|
requiresGenerationDeleteConfirmation({
|
|
id: 'perfect-pixel-preparing',
|
|
mode: 'quick-edit',
|
|
prompt: '完美像素',
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
sourceLayerId: 'layer-source',
|
|
requiresLiveSession: true,
|
|
perfectPixelOperationId: 'perfect-pixel-preparing',
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('still asks for delete confirmation on an ordinary generating placeholder', () => {
|
|
expect(
|
|
requiresGenerationDeleteConfirmation({
|
|
id: 'generation-dialog-1',
|
|
...createDialog('generate', '一只猫'),
|
|
status: 'generating',
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
requiresGenerationDeleteConfirmation({
|
|
id: 'generation-dialog-2',
|
|
...createDialog('generate', '一只猫'),
|
|
status: 'failed',
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('drops unsettled durable perfect-pixel operations together with their source layer', () => {
|
|
const { result } = renderHook(() => useCanvasGenerationDialogs());
|
|
const dialogId = 'perfect-pixel-durable';
|
|
|
|
act(() => {
|
|
result.current.restoreCanvasGenerationDialogs([
|
|
durablePerfectPixelDialog(dialogId, 'pending-confirmation'),
|
|
]);
|
|
});
|
|
act(() => {
|
|
result.current.removeCanvasGenerationDialogsByLayerId('layer-source');
|
|
});
|
|
|
|
expect(result.current.activeCanvasGenerationDialog).toBeNull();
|
|
expect(result.current.canvasGenerationDialogs).toEqual([]);
|
|
});
|
|
});
|