946308b75e
将生成器对话框作为画布布局项序列化和恢复 生成成功后保留生成器快照并锚定到成品图层 图片类生成结果同步写入账号素材库 补充生成器持久化测试和浏览器回归相关文档
392 lines
12 KiB
TypeScript
392 lines
12 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { act, render, screen, waitFor } from '@testing-library/react';
|
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
CanvasLayer,
|
|
CanvasViewport,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
|
|
|
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
|
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock('../../services/image-editor/editorProjectClient', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('../../services/image-editor/editorProjectClient')
|
|
>('../../services/image-editor/editorProjectClient');
|
|
return {
|
|
...actual,
|
|
createEditorProjectResource: createEditorProjectResourceMock,
|
|
loadEditorProject: loadEditorProjectMock,
|
|
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
|
saveEditorProjectLayout: saveEditorProjectLayoutMock,
|
|
};
|
|
});
|
|
|
|
function createLayer(id: string): CanvasLayer {
|
|
return {
|
|
id,
|
|
resourceId: `local-${id}`,
|
|
title: '账号素材A',
|
|
src: 'data:image/png;base64,YQ==',
|
|
x: 10,
|
|
y: 20,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
zIndex: 3,
|
|
sourceType: 'uploaded',
|
|
sourceAssetId: 'asset-a',
|
|
};
|
|
}
|
|
|
|
function ProjectPersistenceHarness({
|
|
canAccessProtectedData = true,
|
|
initialGenerationDialogs = [],
|
|
}: {
|
|
canAccessProtectedData?: boolean;
|
|
initialGenerationDialogs?: CanvasGenerationDialogState[];
|
|
}) {
|
|
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
|
const [generationDialogs, setGenerationDialogs] = useState<
|
|
CanvasGenerationDialogState[]
|
|
>(initialGenerationDialogs);
|
|
const [viewport, setViewport] = useState<CanvasViewport>({
|
|
x: 0,
|
|
y: 0,
|
|
scale: 1,
|
|
});
|
|
const [projectTitle, setProjectTitle] = useState('');
|
|
const [projectRenameValue, setProjectRenameValue] = useState('');
|
|
const layersRef = useRef(layers);
|
|
const viewportRef = useRef(viewport);
|
|
const canvasGenerationDialogsRef = useRef(generationDialogs);
|
|
const selectedLayerRef = useRef<string | null>(null);
|
|
const layerCounterRef = useRef(0);
|
|
const openEditorLoginModalRef = useRef(vi.fn());
|
|
|
|
layersRef.current = layers;
|
|
viewportRef.current = viewport;
|
|
canvasGenerationDialogsRef.current = generationDialogs;
|
|
const selectSingleLayer = useCallback((layerId: string | null) => {
|
|
selectedLayerRef.current = layerId;
|
|
}, []);
|
|
const setLayerCounter = useCallback((value: number) => {
|
|
layerCounterRef.current = value;
|
|
}, []);
|
|
const persistenceRefs = useMemo(
|
|
() => ({
|
|
layersRef,
|
|
viewportRef,
|
|
canvasGenerationDialogsRef,
|
|
}),
|
|
[],
|
|
);
|
|
const persistenceSetters = useMemo(
|
|
() => ({
|
|
setProjectTitle,
|
|
setProjectRenameValue,
|
|
setViewport,
|
|
setLayers,
|
|
selectSingleLayer,
|
|
setLayerCounter,
|
|
restoreCanvasGenerationDialogs: setGenerationDialogs,
|
|
}),
|
|
[selectSingleLayer, setLayerCounter],
|
|
);
|
|
|
|
const persistence = useImageCanvasProjectPersistence({
|
|
refs: persistenceRefs,
|
|
setters: persistenceSetters,
|
|
layers,
|
|
canvasGenerationDialogs: generationDialogs,
|
|
viewport,
|
|
canAccessProtectedData,
|
|
openEditorLoginModal: openEditorLoginModalRef.current,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
<span data-testid="project-id">{persistence.projectId ?? '-'}</span>
|
|
<span data-testid="project-title">{projectTitle}</span>
|
|
<span data-testid="project-rename">{projectRenameValue}</span>
|
|
<span data-testid="layers">
|
|
{layers.map((layer) => `${layer.id}:${layer.resourceId}`).join(',')}
|
|
</span>
|
|
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
|
|
<span data-testid="counter">{layerCounterRef.current}</span>
|
|
<span data-testid="generation-dialogs">
|
|
{generationDialogs
|
|
.map((dialog) => `${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`)
|
|
.join(',')}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
persistence.appendCanvasLayersWithResources([createLayer('layer-a')]);
|
|
}}
|
|
>
|
|
append
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const generatedLayer = createLayer('layer-generated');
|
|
persistence.appendCanvasLayersWithResources([
|
|
{
|
|
...generatedLayer,
|
|
title: '生成图片',
|
|
sourceType: 'generated',
|
|
sourceAssetId: undefined,
|
|
},
|
|
]);
|
|
}}
|
|
>
|
|
append generated
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setGenerationDialogs((currentDialogs) =>
|
|
currentDialogs.map((dialog) =>
|
|
dialog.id === 'generation-dialog-1'
|
|
? {
|
|
...dialog,
|
|
prompt: '移动后的生成器',
|
|
placeholder: dialog.placeholder
|
|
? { ...dialog.placeholder, x: 88 }
|
|
: dialog.placeholder,
|
|
}
|
|
: dialog,
|
|
),
|
|
);
|
|
}}
|
|
>
|
|
move generation
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe('useImageCanvasProjectPersistence', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
|
|
projectId: 'editor-project-default',
|
|
title: '空画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
createEditorProjectResourceMock.mockResolvedValue({
|
|
resourceId: 'resource-added-asset-a',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: 'data:image/png;base64,YQ==',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
});
|
|
saveEditorProjectLayoutMock.mockResolvedValue({
|
|
projectId: 'editor-project-default',
|
|
title: '空画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
});
|
|
|
|
it('saves appended layers with the server resource id immediately after resource creation', async () => {
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
expect(screen.getByTestId('project-title').textContent).toBe('空画布项目');
|
|
expect(screen.getByTestId('project-rename').textContent).toBe(
|
|
'空画布项目',
|
|
);
|
|
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'append' }).click();
|
|
});
|
|
|
|
expect(screen.getByTestId('layers').textContent).toBe(
|
|
'layer-a:local-layer-a',
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toBe(
|
|
'layer-a:resource-added-asset-a',
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
layers: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
layerId: 'layer-a',
|
|
resourceId: 'resource-added-asset-a',
|
|
sourceAssetId: 'asset-a',
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('saves generation dialogs in the project layout and restores them on load', async () => {
|
|
const savedDialog: CanvasGenerationDialogState = {
|
|
id: 'generation-dialog-1',
|
|
mode: 'generate',
|
|
prompt: '刷新后继续生成',
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
placeholder: {
|
|
x: 42,
|
|
y: 56,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 2048,
|
|
originalHeight: 2048,
|
|
},
|
|
};
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '空画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
itemType: 'generation-dialog',
|
|
layerId: 'generation-dialog:generation-dialog-1',
|
|
resourceId: 'generation-dialog:generation-dialog-1',
|
|
dialog: savedDialog,
|
|
},
|
|
],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
|
|
'generation-dialog-1:刷新后继续生成:42',
|
|
);
|
|
});
|
|
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'move generation' }).click();
|
|
});
|
|
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
|
|
'generation-dialog-1:移动后的生成器:88',
|
|
);
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
layers: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
itemType: 'generation-dialog',
|
|
dialog: expect.objectContaining({
|
|
id: 'generation-dialog-1',
|
|
prompt: '移动后的生成器',
|
|
placeholder: expect.objectContaining({ x: 88 }),
|
|
}),
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('saves generated layers together with their generator snapshots', async () => {
|
|
const savedDialog: CanvasGenerationDialogState = {
|
|
id: 'generation-dialog-1',
|
|
mode: 'generate',
|
|
prompt: '已生成也要保存生成器',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generatedLayerId: 'layer-generated',
|
|
placeholder: {
|
|
x: 70,
|
|
y: 80,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 2048,
|
|
originalHeight: 2048,
|
|
},
|
|
};
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '空画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
itemType: 'generation-dialog',
|
|
layerId: 'generation-dialog:generation-dialog-1',
|
|
resourceId: 'generation-dialog:generation-dialog-1',
|
|
dialog: savedDialog,
|
|
},
|
|
],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
|
|
'generation-dialog-1:已生成也要保存生成器:70',
|
|
);
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'append generated' }).click();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
layers: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
layerId: 'layer-generated',
|
|
sourceType: 'generated',
|
|
}),
|
|
expect.objectContaining({
|
|
itemType: 'generation-dialog',
|
|
dialog: expect.objectContaining({
|
|
id: 'generation-dialog-1',
|
|
generatedLayerId: 'layer-generated',
|
|
prompt: '已生成也要保存生成器',
|
|
placeholder: expect.objectContaining({
|
|
x: 70,
|
|
width: 420,
|
|
}),
|
|
}),
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('does not load protected project data before login is available', () => {
|
|
render(<ProjectPersistenceHarness canAccessProtectedData={false} />);
|
|
|
|
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
|
expect(loadEditorProjectMock).not.toHaveBeenCalled();
|
|
expect(screen.getByTestId('project-id').textContent).toBe('-');
|
|
});
|
|
});
|