b49bf9c984
将旧布局上限统一提升至2MiB并接入revision CAS保存 新增画布图层、生成对话框和迁移状态结构化存储 新增migration operator保护的回填、激活和回滚脚本 新增历史图片映射及音频资源恢复的定向dry-run/apply流程 补齐前端保存队列、OpenAPI、Bindings、发布归档与回归测试
1772 lines
55 KiB
TypeScript
1772 lines
55 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 { ApiClientError } from '../../services/apiClient';
|
|
import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient';
|
|
import {
|
|
DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
normalizeCanvasBackgroundHex,
|
|
} from './ImageCanvasEditorModel';
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
CanvasLayer,
|
|
CanvasViewport,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence';
|
|
|
|
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
|
const createProjectCoverSnapshotBlobMock = vi.hoisted(() => vi.fn());
|
|
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
|
const putEditorProjectCoverCacheMock = vi.hoisted(() => vi.fn());
|
|
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
|
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
|
const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY =
|
|
'genarrative.imageCanvas.projectSnapshot.v1:recent';
|
|
|
|
vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => {
|
|
const actual = await vi.importActual<
|
|
typeof import('./ImageCanvasProjectCoverSnapshotModel')
|
|
>('./ImageCanvasProjectCoverSnapshotModel');
|
|
return {
|
|
...actual,
|
|
createProjectCoverSnapshotBlob: createProjectCoverSnapshotBlobMock,
|
|
};
|
|
});
|
|
|
|
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,
|
|
};
|
|
});
|
|
|
|
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
|
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
|
}));
|
|
|
|
vi.mock('../../services/image-editor/editorProjectCoverCache', () => ({
|
|
putEditorProjectCoverCache: putEditorProjectCoverCacheMock,
|
|
}));
|
|
|
|
function createLayer(
|
|
id: string,
|
|
overrides: Partial<CanvasLayer> = {},
|
|
): 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',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createDeferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function ProjectPersistenceHarness({
|
|
canAccessProtectedData = true,
|
|
initialCanvasBackgroundColor = DEFAULT_CANVAS_BACKGROUND_COLOR,
|
|
initialGenerationDialogs = [],
|
|
onProjectAccessLost,
|
|
}: {
|
|
canAccessProtectedData?: boolean;
|
|
initialCanvasBackgroundColor?: string;
|
|
initialGenerationDialogs?: CanvasGenerationDialogState[];
|
|
onProjectAccessLost?: () => void;
|
|
}) {
|
|
const [layers, setLayers] = useState<CanvasLayer[]>([]);
|
|
const [generationDialogs, setGenerationDialogs] = useState<
|
|
CanvasGenerationDialogState[]
|
|
>(initialGenerationDialogs);
|
|
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
|
|
initialCanvasBackgroundColor,
|
|
);
|
|
const [viewport, setViewport] = useState<CanvasViewport>({
|
|
x: 0,
|
|
y: 0,
|
|
scale: 1,
|
|
});
|
|
const [isViewportInteracting, setIsViewportInteracting] = useState(false);
|
|
const [projectTitle, setProjectTitle] = useState('');
|
|
const [projectRenameValue, setProjectRenameValue] = useState('');
|
|
const layersRef = useRef(layers);
|
|
const viewportRef = useRef(viewport);
|
|
const canvasGenerationDialogsRef = useRef(generationDialogs);
|
|
const canvasBackgroundColorRef = useRef(canvasBackgroundColor);
|
|
const selectedLayerRef = useRef<string | null>(null);
|
|
const layerCounterRef = useRef(0);
|
|
const openEditorLoginModalRef = useRef(vi.fn());
|
|
|
|
layersRef.current = layers;
|
|
viewportRef.current = viewport;
|
|
canvasGenerationDialogsRef.current = generationDialogs;
|
|
canvasBackgroundColorRef.current = canvasBackgroundColor;
|
|
const selectSingleLayer = useCallback((layerId: string | null) => {
|
|
selectedLayerRef.current = layerId;
|
|
}, []);
|
|
const setLayerCounter = useCallback((value: number) => {
|
|
layerCounterRef.current = value;
|
|
}, []);
|
|
const applyCanvasBackgroundColor = useCallback((color: string) => {
|
|
const normalizedColor = normalizeCanvasBackgroundHex(color);
|
|
if (!normalizedColor) {
|
|
return false;
|
|
}
|
|
setCanvasBackgroundColor(normalizedColor);
|
|
return true;
|
|
}, []);
|
|
const persistenceRefs = useMemo(
|
|
() => ({
|
|
layersRef,
|
|
viewportRef,
|
|
canvasGenerationDialogsRef,
|
|
canvasBackgroundColorRef,
|
|
}),
|
|
[],
|
|
);
|
|
const persistenceSetters = useMemo(
|
|
() => ({
|
|
setProjectTitle,
|
|
setProjectRenameValue,
|
|
setViewport,
|
|
setLayers,
|
|
selectSingleLayer,
|
|
setLayerCounter,
|
|
restoreCanvasGenerationDialogs: setGenerationDialogs,
|
|
applyCanvasBackgroundColor,
|
|
}),
|
|
[applyCanvasBackgroundColor, selectSingleLayer, setLayerCounter],
|
|
);
|
|
|
|
const persistence = useImageCanvasProjectPersistence({
|
|
refs: persistenceRefs,
|
|
setters: persistenceSetters,
|
|
layers,
|
|
canvasGenerationDialogs: generationDialogs,
|
|
viewport,
|
|
canvasBackgroundColor,
|
|
isViewportInteracting,
|
|
canAccessProtectedData,
|
|
openEditorLoginModal: openEditorLoginModalRef.current,
|
|
onProjectAccessLost,
|
|
});
|
|
|
|
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}:${layer.assetKind ?? '-'}:${
|
|
layer.generationInputs?.fields[0]?.value ?? '-'
|
|
}:${layer.objectKey ?? '-'}:${layer.assetObjectId ?? '-'}`,
|
|
)
|
|
.join(',')}
|
|
</span>
|
|
<span data-testid="selected">{selectedLayerRef.current ?? '-'}</span>
|
|
<span data-testid="counter">{layerCounterRef.current}</span>
|
|
<span data-testid="viewport">
|
|
{viewport.x},{viewport.y},{viewport.scale}
|
|
</span>
|
|
<span data-testid="viewport-interacting">
|
|
{String(isViewportInteracting)}
|
|
</span>
|
|
<span data-testid="generation-dialogs">
|
|
{generationDialogs
|
|
.map(
|
|
(dialog) =>
|
|
`${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`,
|
|
)
|
|
.join(',')}
|
|
</span>
|
|
<span data-testid="background">{canvasBackgroundColor}</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setViewport((currentViewport) => ({
|
|
...currentViewport,
|
|
x: currentViewport.x + 10,
|
|
y: currentViewport.y + 5,
|
|
}));
|
|
}}
|
|
>
|
|
move viewport
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsViewportInteracting(true);
|
|
}}
|
|
>
|
|
begin viewport interaction
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsViewportInteracting(false);
|
|
}}
|
|
>
|
|
end viewport interaction
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
persistence.appendCanvasLayersWithResources([
|
|
createLayer('layer-a', {
|
|
src: 'https://signed.example.test/asset-a.png?expires=1',
|
|
objectKey: 'generated-character-drafts/editor/assets/asset-a.png',
|
|
assetObjectId: 'asset-object-a',
|
|
}),
|
|
]);
|
|
}}
|
|
>
|
|
append
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
persistence.appendCanvasLayersWithResources([
|
|
createLayer('layer-server-resource', {
|
|
resourceId: 'resource-from-generation-bff',
|
|
title: '后端已保存图片',
|
|
sourceType: 'generated',
|
|
}),
|
|
]);
|
|
}}
|
|
>
|
|
append server resource
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
persistence.appendCanvasLayersWithResources([
|
|
createLayer('layer-character', {
|
|
title: '角色形象',
|
|
src: 'https://signed.example.test/character-new.png?expires=1',
|
|
objectKey: 'generated/character-new.png',
|
|
assetObjectId: 'asset-object-character-new',
|
|
sourceType: 'generated',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '银发游侠' }],
|
|
references: [],
|
|
},
|
|
}),
|
|
]);
|
|
}}
|
|
>
|
|
append character
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
persistence.appendCanvasLayersWithResources([
|
|
createLayer('layer-private-object', {
|
|
src: 'https://signed.example.test/private.png?expires=1',
|
|
objectKey: 'generated-character-drafts/editor/private.png',
|
|
title: '私有对象素材',
|
|
sourceType: 'generated',
|
|
}),
|
|
]);
|
|
}}
|
|
>
|
|
append private object
|
|
</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>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setGenerationDialogs((currentDialogs) =>
|
|
currentDialogs.map((dialog) =>
|
|
dialog.id === 'generation-dialog-1'
|
|
? {
|
|
...dialog,
|
|
prompt: '第二次移动后的生成器',
|
|
placeholder: dialog.placeholder
|
|
? { ...dialog.placeholder, x: 120 }
|
|
: dialog.placeholder,
|
|
}
|
|
: dialog,
|
|
),
|
|
);
|
|
}}
|
|
>
|
|
move generation again
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
applyCanvasBackgroundColor(' #ABC ');
|
|
}}
|
|
>
|
|
change background
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
applyCanvasBackgroundColor('#not-a-color');
|
|
}}
|
|
>
|
|
change invalid background
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
persistence.applyProjectSnapshot({
|
|
projectId: 'editor-project-default',
|
|
title: '空画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-generated',
|
|
resourceId: 'resource-generated',
|
|
title: '生成结果',
|
|
x: 42,
|
|
y: 56,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 420,
|
|
originalHeight: 420,
|
|
zIndex: 1,
|
|
sourceType: 'generated',
|
|
},
|
|
{
|
|
itemType: 'generation-dialog',
|
|
layerId: 'generation-dialog:generation-dialog-1',
|
|
resourceId: 'generation-dialog:generation-dialog-1',
|
|
dialog: {
|
|
id: 'generation-dialog-1',
|
|
mode: 'generate',
|
|
prompt: '后端完成生成器',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generatedLayerId: 'layer-generated',
|
|
placeholder: {
|
|
x: 42,
|
|
y: 56,
|
|
width: 420,
|
|
height: 420,
|
|
originalWidth: 420,
|
|
originalHeight: 420,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-generated',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/generated/result.png',
|
|
objectKey: 'generated/result.png',
|
|
assetObjectId: 'asset-object-result',
|
|
width: 420,
|
|
height: 420,
|
|
sourceType: 'generated',
|
|
},
|
|
],
|
|
updatedAt: '2026-06-12T00:00:01.000Z',
|
|
});
|
|
}}
|
|
>
|
|
apply completed project
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
describe('useImageCanvasProjectPersistence', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
try {
|
|
globalThis.sessionStorage?.clear();
|
|
} catch {
|
|
// ignore session storage cleanup errors in jsdom
|
|
}
|
|
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: '/generated-character-drafts/editor/assets/asset-a.png',
|
|
objectKey: 'generated-character-drafts/editor/assets/asset-a.png',
|
|
assetObjectId: 'asset-object-a',
|
|
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',
|
|
});
|
|
createProjectCoverSnapshotBlobMock.mockResolvedValue(null);
|
|
putEditorProjectCoverCacheMock.mockResolvedValue(undefined);
|
|
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
|
src: 'https://signed.example.test/project-cover.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png',
|
|
assetObjectId: 'asset-object-cover',
|
|
legacyPublicPath:
|
|
'/generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png',
|
|
});
|
|
});
|
|
|
|
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:-:-:generated-character-drafts/editor/assets/asset-a.png:asset-object-a',
|
|
);
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toBe(
|
|
'layer-a:resource-added-asset-a:-:-:generated-character-drafts/editor/assets/asset-a.png:asset-object-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',
|
|
objectKey: 'generated-character-drafts/editor/assets/asset-a.png',
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('reloads the authoritative snapshot after a revision conflict without replaying the stale layout', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '初始画布',
|
|
canvas: {
|
|
canvasId: 'editor-project-default:canvas:default',
|
|
projectId: 'editor-project-default',
|
|
title: '默认画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
revision: 3,
|
|
layoutStorageVersion: 1,
|
|
updatedAt: '2026-07-19T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-07-19T00:00:00.000Z',
|
|
});
|
|
saveEditorProjectLayoutMock.mockRejectedValueOnce(
|
|
new ApiClientError({
|
|
message: '图片画布版本冲突',
|
|
status: 409,
|
|
code: 'conflict',
|
|
}),
|
|
);
|
|
loadEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '其它标签页已保存',
|
|
canvas: {
|
|
canvasId: 'editor-project-default:canvas:default',
|
|
projectId: 'editor-project-default',
|
|
title: '默认画布',
|
|
viewport: { x: 12, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-remote',
|
|
resourceId: 'resource-remote',
|
|
x: 20,
|
|
y: 30,
|
|
},
|
|
],
|
|
revision: 4,
|
|
layoutStorageVersion: 1,
|
|
updatedAt: '2026-07-19T00:00:01.000Z',
|
|
},
|
|
viewport: { x: 12, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-remote',
|
|
resourceId: 'resource-remote',
|
|
x: 20,
|
|
y: 30,
|
|
},
|
|
],
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-remote',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/generated/remote.png',
|
|
objectKey: 'generated/remote.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
updatedAt: '2026-07-19T00:00:01.000Z',
|
|
});
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'append server resource' }).click();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(loadEditorProjectMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toContain(
|
|
'layer-remote:resource-remote',
|
|
);
|
|
});
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({ expectedRevision: 3 }),
|
|
);
|
|
});
|
|
|
|
it('continues with the latest pending layout when an in-flight save fails', async () => {
|
|
const firstSave = createDeferred<EditorProjectSnapshot>();
|
|
render(<ProjectPersistenceHarness />);
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
saveEditorProjectLayoutMock.mockClear();
|
|
saveEditorProjectLayoutMock
|
|
.mockReturnValueOnce(firstSave.promise)
|
|
.mockResolvedValue({
|
|
projectId: 'editor-project-default',
|
|
title: '空画布项目',
|
|
viewport: { x: 20, y: 10, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-07-20T00:00:00.000Z',
|
|
});
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'move viewport' }).click();
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
viewport: { x: 10, y: 5, scale: 1 },
|
|
}),
|
|
);
|
|
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'move viewport' }).click();
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
firstSave.reject(new TypeError('connection dropped'));
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2);
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
viewport: { x: 20, y: 10, scale: 1 },
|
|
}),
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('retries conflict recovery when the authoritative snapshot reload fails', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '初始画布',
|
|
canvas: {
|
|
canvasId: 'editor-project-default:canvas:default',
|
|
projectId: 'editor-project-default',
|
|
title: '默认画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
revision: 3,
|
|
layoutStorageVersion: 1,
|
|
updatedAt: '2026-07-20T00:00:00.000Z',
|
|
},
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-07-20T00:00:00.000Z',
|
|
});
|
|
const conflict = new ApiClientError({
|
|
message: '图片画布版本冲突',
|
|
status: 409,
|
|
code: 'conflict',
|
|
});
|
|
saveEditorProjectLayoutMock
|
|
.mockRejectedValueOnce(conflict)
|
|
.mockRejectedValueOnce(conflict);
|
|
loadEditorProjectMock
|
|
.mockRejectedValueOnce(new TypeError('connection dropped'))
|
|
.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '其它标签页已保存',
|
|
canvas: {
|
|
canvasId: 'editor-project-default:canvas:default',
|
|
projectId: 'editor-project-default',
|
|
title: '默认画布',
|
|
viewport: { x: 12, y: 0, scale: 1 },
|
|
layers: [],
|
|
revision: 4,
|
|
layoutStorageVersion: 1,
|
|
updatedAt: '2026-07-20T00:00:01.000Z',
|
|
},
|
|
viewport: { x: 12, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-07-20T00:00:01.000Z',
|
|
});
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'move viewport' }).click();
|
|
});
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(451);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
|
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(1_001);
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
});
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2);
|
|
expect(loadEditorProjectMock).toHaveBeenCalledTimes(2);
|
|
expect(screen.getByTestId('project-title').textContent).toBe(
|
|
'其它标签页已保存',
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('does not retry a permanent layout payload error', async () => {
|
|
render(<ProjectPersistenceHarness />);
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
saveEditorProjectLayoutMock.mockClear();
|
|
saveEditorProjectLayoutMock.mockRejectedValue(
|
|
new ApiClientError({
|
|
message: '图片画布图层布局过大',
|
|
status: 413,
|
|
code: 'payload_too_large',
|
|
}),
|
|
);
|
|
vi.useFakeTimers();
|
|
|
|
try {
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'move viewport' }).click();
|
|
});
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(451);
|
|
await Promise.resolve();
|
|
});
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
vi.advanceTimersByTime(30_000);
|
|
await Promise.resolve();
|
|
});
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it('writes a lightweight session cache when saving layout changes', async () => {
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'append' }).click();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
layers: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
layerId: 'layer-a',
|
|
resourceId: 'resource-added-asset-a',
|
|
objectKey: 'generated-character-drafts/editor/assets/asset-a.png',
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
|
|
const cachedRaw = globalThis.sessionStorage.getItem(
|
|
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
|
|
);
|
|
expect(cachedRaw).toBeTruthy();
|
|
expect(cachedRaw).not.toContain('data:image');
|
|
const cached = JSON.parse(cachedRaw ?? '{}') as {
|
|
project?: EditorProjectSnapshot;
|
|
};
|
|
expect(cached.project?.resources).toEqual([
|
|
expect.objectContaining({
|
|
resourceId: 'resource-added-asset-a',
|
|
imageSrc: '/generated-character-drafts/editor/assets/asset-a.png',
|
|
objectKey: 'generated-character-drafts/editor/assets/asset-a.png',
|
|
assetObjectId: 'asset-object-a',
|
|
}),
|
|
]);
|
|
});
|
|
|
|
it('restores the persisted canvas background color without autosaving on load', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '自定义背景画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
itemType: 'canvas-settings',
|
|
layerId: 'canvas-settings:default',
|
|
resourceId: 'canvas-settings:default',
|
|
canvasBackgroundColor: '#112233',
|
|
},
|
|
],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('background').textContent).toBe('#112233');
|
|
});
|
|
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('saves canvas background color changes in project layout and session cache', async () => {
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
saveEditorProjectLayoutMock.mockClear();
|
|
vi.useFakeTimers();
|
|
|
|
await act(async () => {
|
|
screen.getByRole('button', { name: 'change background' }).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(screen.getByTestId('background').textContent).toBe('#aabbcc');
|
|
const cachedRaw = globalThis.sessionStorage.getItem(
|
|
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
|
|
);
|
|
expect(cachedRaw).toBeTruthy();
|
|
expect(cachedRaw).not.toContain(' #ABC ');
|
|
const cached = JSON.parse(cachedRaw ?? '{}') as {
|
|
project?: EditorProjectSnapshot;
|
|
};
|
|
expect(cached.project?.layers).toEqual([
|
|
expect.objectContaining({
|
|
itemType: 'canvas-settings',
|
|
canvasBackgroundColor: '#aabbcc',
|
|
}),
|
|
]);
|
|
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
layers: [
|
|
expect.objectContaining({
|
|
itemType: 'canvas-settings',
|
|
canvasBackgroundColor: '#aabbcc',
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
|
|
await act(async () => {
|
|
screen.getByRole('button', { name: 'change invalid background' }).click();
|
|
await Promise.resolve();
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
|
|
expect(screen.getByTestId('background').textContent).toBe('#aabbcc');
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('persists a static project cover snapshot resource after loading drawable layers', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '已有封面画布',
|
|
viewport: { x: 12, y: -8, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-cover',
|
|
resourceId: 'resource-cover-source',
|
|
title: '封面源图',
|
|
x: 10,
|
|
y: 20,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-cover-source',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/generated/source.png',
|
|
objectKey: 'generated/source.png',
|
|
assetObjectId: 'asset-object-source',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
createProjectCoverSnapshotBlobMock.mockResolvedValueOnce(
|
|
new Blob(['cover'], { type: 'image/png' }),
|
|
);
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
projectId: 'editor-project-default',
|
|
blob: expect.any(Blob),
|
|
}),
|
|
);
|
|
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith(
|
|
expect.any(File),
|
|
'image',
|
|
expect.objectContaining({
|
|
assetKind: 'editor_project_cover_snapshot',
|
|
pathSegments: [
|
|
'editor',
|
|
'project-covers',
|
|
'editor-project-default',
|
|
expect.any(String),
|
|
],
|
|
entityId: 'editor-project-cover-editor-project-default',
|
|
metadata: {
|
|
editor_project_id: 'editor-project-default',
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
await waitFor(() => {
|
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
imageSrc:
|
|
'/generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png',
|
|
assetObjectId: 'asset-object-cover',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
assetKind: 'project-cover-snapshot',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('keeps the local project cover cache when snapshot upload fails', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '本地封面画布',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-cover',
|
|
resourceId: 'resource-cover-source',
|
|
title: '封面源图',
|
|
x: 10,
|
|
y: 20,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-cover-source',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/generated/source.png',
|
|
objectKey: 'generated/source.png',
|
|
assetObjectId: 'asset-object-source',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
const coverBlob = new Blob(['cover'], { type: 'image/png' });
|
|
createProjectCoverSnapshotBlobMock.mockResolvedValueOnce(coverBlob);
|
|
uploadEditorMediaAssetFileMock.mockRejectedValueOnce(
|
|
new TypeError('Failed to fetch'),
|
|
);
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith({
|
|
projectId: 'editor-project-default',
|
|
blob: coverBlob,
|
|
});
|
|
});
|
|
await waitFor(() => {
|
|
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled();
|
|
});
|
|
expect(createEditorProjectResourceMock).not.toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
assetKind: 'project-cover-snapshot',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('loads and saves viewport scale with display zoom semantics', async () => {
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
expect(screen.getByTestId('viewport').textContent).toBe('0,0,0.5');
|
|
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'append server resource' }).click();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('does not serialize or write session cache while viewport interactions are active', async () => {
|
|
const sessionSetItemSpy = vi.spyOn(
|
|
Storage.prototype,
|
|
'setItem',
|
|
);
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
sessionSetItemSpy.mockClear();
|
|
saveEditorProjectLayoutMock.mockClear();
|
|
vi.useFakeTimers();
|
|
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'begin viewport interaction' }).click();
|
|
});
|
|
expect(screen.getByTestId('viewport-interacting').textContent).toBe('true');
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'move viewport' }).click();
|
|
screen.getByRole('button', { name: 'move viewport' }).click();
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
|
|
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
|
expect(sessionSetItemSpy).not.toHaveBeenCalled();
|
|
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'end viewport interaction' }).click();
|
|
});
|
|
expect(screen.getByTestId('viewport-interacting').textContent).toBe(
|
|
'false',
|
|
);
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
viewport: { x: 20, y: 10, scale: 1 },
|
|
}),
|
|
);
|
|
expect(sessionSetItemSpy).toHaveBeenCalled();
|
|
vi.useRealTimers();
|
|
sessionSetItemSpy.mockRestore();
|
|
});
|
|
|
|
it('does not create a project resource again for backend-persisted generated layers', async () => {
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'append server resource' }).click();
|
|
});
|
|
|
|
expect(screen.getByTestId('layers').textContent).toBe(
|
|
'layer-server-resource:resource-from-generation-bff:-:-:-:-',
|
|
);
|
|
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
|
await waitFor(() => {
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
layers: expect.arrayContaining([
|
|
expect.objectContaining({
|
|
layerId: 'layer-server-resource',
|
|
resourceId: 'resource-from-generation-bff',
|
|
sourceType: 'generated',
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('persists generated layer metadata on the project resource and restores it from resources', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '已有角色项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-character',
|
|
resourceId: 'resource-character',
|
|
title: '角色形象',
|
|
x: 10,
|
|
y: 20,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
zIndex: 3,
|
|
sourceType: 'generated',
|
|
},
|
|
],
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-character',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/generated/character.png',
|
|
objectKey: 'generated/character.png',
|
|
assetObjectId: 'asset-object-character',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'generated',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '银发游侠' }],
|
|
references: [],
|
|
},
|
|
},
|
|
],
|
|
updatedAt: '2026-06-12T00:00:00.000Z',
|
|
});
|
|
createEditorProjectResourceMock.mockResolvedValueOnce({
|
|
resourceId: 'resource-character-new',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/generated/character-new.png',
|
|
objectKey: 'generated/character-new.png',
|
|
assetObjectId: 'asset-object-character-new',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'generated',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '银发游侠' }],
|
|
references: [],
|
|
},
|
|
});
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('layers').textContent).toBe(
|
|
'layer-character:resource-character:character:银发游侠:generated/character.png:asset-object-character',
|
|
);
|
|
});
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'append character' }).click();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
imageSrc: '/generated/character-new.png',
|
|
objectKey: 'generated/character-new.png',
|
|
assetObjectId: 'asset-object-character-new',
|
|
sourceType: 'generated',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '银发游侠' }],
|
|
references: [],
|
|
},
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
it('creates project resources from objectKey without posting heavy signed URLs', async () => {
|
|
createEditorProjectResourceMock.mockResolvedValueOnce({
|
|
resourceId: 'resource-private-object',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/generated-character-drafts/editor/private.png',
|
|
objectKey: 'generated-character-drafts/editor/private.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'generated',
|
|
});
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'append private object' }).click();
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
|
|
'editor-project-default',
|
|
expect.objectContaining({
|
|
imageSrc: '/generated-character-drafts/editor/private.png',
|
|
objectKey: 'generated-character-drafts/editor/private.png',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
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('serializes project layout saves and only sends the latest pending snapshot while a save is running', 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',
|
|
});
|
|
const saveResolvers: Array<(value: unknown) => void> = [];
|
|
saveEditorProjectLayoutMock.mockImplementation(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
saveResolvers.push(resolve);
|
|
}),
|
|
);
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'move generation' }).click();
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenLastCalledWith(
|
|
'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 }),
|
|
}),
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'move generation again' }).click();
|
|
});
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
saveResolvers[0]?.({
|
|
projectId: 'editor-project-default',
|
|
title: '空画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:01.000Z',
|
|
});
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2);
|
|
expect(saveEditorProjectLayoutMock).toHaveBeenLastCalledWith(
|
|
'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: 120 }),
|
|
}),
|
|
}),
|
|
]),
|
|
}),
|
|
);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('drops stale pending generating saves when a backend completion snapshot is applied', 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();
|
|
vi.useFakeTimers();
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'move generation' }).click();
|
|
});
|
|
act(() => {
|
|
screen.getByRole('button', { name: 'apply completed project' }).click();
|
|
});
|
|
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
|
|
'generation-dialog-1:后端完成生成器:42',
|
|
);
|
|
expect(screen.getByTestId('layers').textContent).toBe(
|
|
'layer-generated:resource-generated:-:-:generated/result.png:asset-object-result',
|
|
);
|
|
act(() => {
|
|
vi.advanceTimersByTime(451);
|
|
});
|
|
|
|
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
|
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('keeps backend layout as the project truth when loading generating dialogs', async () => {
|
|
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
|
projectId: 'editor-project-default',
|
|
title: '角色画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
itemType: 'generation-dialog',
|
|
layerId: 'generation-dialog:generation-dialog-4',
|
|
resourceId: 'generation-dialog:generation-dialog-4',
|
|
dialog: {
|
|
id: 'generation-dialog-4',
|
|
mode: 'character',
|
|
prompt: '挥舞武器',
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
imageModel: 'gpt-image-2',
|
|
placeholder: {
|
|
x: 2851,
|
|
y: -521,
|
|
width: 1024,
|
|
height: 1024,
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
resources: [
|
|
{
|
|
resourceId: 'editor-resource-character',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/api/assets/read-url?objectKey=character.png',
|
|
objectKey:
|
|
'generated-character-drafts/editor/character-images/vector-engine-edit/image.png',
|
|
assetObjectId: 'asset-object-character',
|
|
width: 1254,
|
|
height: 1254,
|
|
sourceType: 'generated',
|
|
prompt: '挥舞武器',
|
|
actualPrompt: '挥舞武器',
|
|
model: 'gpt-image-2',
|
|
provider: 'VectorEngine',
|
|
taskId: 'vector-engine-edit-1',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '挥舞武器' }],
|
|
references: [],
|
|
},
|
|
updatedAt: '2026-06-21T12:16:22.000Z',
|
|
},
|
|
],
|
|
updatedAt: '2026-06-21T12:15:00.000Z',
|
|
});
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
expect(await screen.findByText('editor-project-default')).toBeTruthy();
|
|
expect(screen.getByTestId('layers').textContent).toBe('');
|
|
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
|
|
'generation-dialog-4:挥舞武器:2851',
|
|
);
|
|
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('restores a cached lightweight snapshot before backend load without autosaving it', async () => {
|
|
const backendProjectLoad = createDeferred<EditorProjectSnapshot>();
|
|
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(
|
|
backendProjectLoad.promise,
|
|
);
|
|
globalThis.sessionStorage.setItem(
|
|
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
|
|
JSON.stringify({
|
|
cachedAt: Date.now(),
|
|
project: {
|
|
projectId: 'editor-project-default',
|
|
title: '缓存画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [
|
|
{
|
|
layerId: 'layer-cache',
|
|
resourceId: 'resource-cache',
|
|
title: '缓存素材',
|
|
x: 10,
|
|
y: 20,
|
|
width: 320,
|
|
height: 240,
|
|
originalWidth: 320,
|
|
originalHeight: 240,
|
|
zIndex: 1,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
resources: [
|
|
{
|
|
resourceId: 'resource-cache',
|
|
projectId: 'editor-project-default',
|
|
imageSrc: '/generated/cache.png',
|
|
objectKey: 'generated/cache.png',
|
|
width: 320,
|
|
height: 240,
|
|
sourceType: 'uploaded',
|
|
},
|
|
],
|
|
updatedAt: '2026-06-12T00:00:01.000Z',
|
|
} satisfies EditorProjectSnapshot,
|
|
}),
|
|
);
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('project-title').textContent).toBe(
|
|
'缓存画布项目',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('layers').textContent).toBe(
|
|
'layer-cache:resource-cache:-:-:generated/cache.png:-',
|
|
);
|
|
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
|
|
|
await act(async () => {
|
|
backendProjectLoad.resolve({
|
|
projectId: 'editor-project-default',
|
|
title: '后端画布项目',
|
|
viewport: { x: 0, y: 0, scale: 1 },
|
|
layers: [],
|
|
resources: [],
|
|
updatedAt: '2026-06-12T00:00:02.000Z',
|
|
});
|
|
await backendProjectLoad.promise;
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId('project-title').textContent).toBe(
|
|
'后端画布项目',
|
|
);
|
|
});
|
|
expect(screen.getByTestId('layers').textContent).toBe('');
|
|
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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('-');
|
|
});
|
|
|
|
it.each([
|
|
[404, '项目不存在'],
|
|
[403, '无权访问项目'],
|
|
])(
|
|
'returns to the project page when an explicit project fails with %i',
|
|
async (status, message) => {
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/editor/canvas?projectid=missing-project',
|
|
);
|
|
loadEditorProjectMock.mockRejectedValueOnce(
|
|
new ApiClientError({
|
|
message,
|
|
status,
|
|
code: status === 404 ? 'not_found' : 'forbidden',
|
|
}),
|
|
);
|
|
|
|
render(<ProjectPersistenceHarness />);
|
|
|
|
await waitFor(() => {
|
|
expect(window.location.pathname).toBe('/project');
|
|
});
|
|
expect(window.location.search).toBe('');
|
|
expect(loadEditorProjectMock).toHaveBeenCalledWith('missing-project');
|
|
expect(loadOrCreateRecentEditorProjectMock).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('notifies the shell to leave the canvas when an explicit project becomes inaccessible', async () => {
|
|
const onProjectAccessLost = vi.fn();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/editor/canvas?projectid=missing-project',
|
|
);
|
|
loadEditorProjectMock.mockRejectedValueOnce(
|
|
new ApiClientError({
|
|
message: '项目不存在',
|
|
status: 404,
|
|
code: 'not_found',
|
|
}),
|
|
);
|
|
|
|
render(
|
|
<ProjectPersistenceHarness onProjectAccessLost={onProjectAccessLost} />,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(onProjectAccessLost).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(loadEditorProjectMock).toHaveBeenCalledWith('missing-project');
|
|
});
|
|
});
|