Files
Genarrative/src/components/image-editor/useCanvasGenerationDialogs.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

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([]);
});
});