Files
Genarrative/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
T
k88936 6ee33dc561
Project CI / Repository checks (push) Successful in 1m4s
Project CI / Frontend tests (push) Successful in 2m9s
Project CI / Native shell tests (push) Successful in 2m38s
Project CI / Backend tests (push) Successful in 3m23s
fix: 首页「最近项目」缩略图与退出前视角不一致,出现偏移或裁切 (#111)
- 封面使用真实画布尺寸及最终 viewport.x/y/scale。
- 320×240 封面改为居中 cover 裁切,不再从左上角取景。
- 正确绘制画布背景色。

in canva:
![shotmd-1784874081.jpg](/attachments/4ac128cc-64c8-4a78-9e75-c35902049d3e)
![shotmd-1784876202.jpg](/attachments/6197259b-887e-4fd6-95b5-b722ec670a02)

in home:
![shotmd-1784874090.jpg](/attachments/241c7cd3-1eee-4f7b-96f2-4ca0268d2bee)
![shotmd-1784876211.jpg](/attachments/aa4200b6-bef0-4122-a797-ebc082b27a17)

因为原本的布局持久化可能有一些bug,改动不相关,所以我回退到最开始的一版:
- 不额外监听画布尺寸变化,给最小尺寸兜底,(大了比小了好)
- 封面不要求强一致性,生成/上传时机仍然按照原来随布局持久化

优化了封面生成:
- 改用webp格式
- 尽量利用图片缓存

最终生成封面从700ms加速到150ms以内, 加上必须的上传和确认150ms 和 布局持久化的时间相差不多,不是特别离谱的手速都能保证两者一致

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/111
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-07-29 16:09:30 +08:00

2790 lines
89 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, render, screen, waitFor } from '@testing-library/react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { afterEach, 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.v2:user-test:recent';
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
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', () => ({
uploadEditorMediaAssetObjectFile: 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,
currentUserId = 'user-test',
initialCanvasBackgroundColor = DEFAULT_CANVAS_BACKGROUND_COLOR,
initialGenerationDialogs = [],
onProjectAccessLost,
}: {
canAccessProtectedData?: boolean;
currentUserId?: string | null;
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 canvasSize = { width: 900, height: 640 };
const [isViewportInteracting, setIsViewportInteracting] = useState(false);
const [projectTitle, setProjectTitle] = useState('');
const [projectRenameValue, setProjectRenameValue] = useState('');
const [flushCompleted, setFlushCompleted] = useState(false);
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,
canvasSize,
canvasBackgroundColor,
isViewportInteracting,
canAccessProtectedData,
currentUserId,
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>
<span data-testid="flush-completed">{String(flushCompleted)}</span>
<button
type="button"
onClick={() => {
setFlushCompleted(false);
void persistence.flushProjectPersistence().then(() => {
setFlushCompleted(true);
});
}}
>
flush project persistence
</button>
<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: '空画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 1,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:01.000Z',
},
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>
<button
type="button"
onClick={() => {
persistence.applyProjectSnapshot({
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: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:00.000Z',
});
}}
>
apply stale project
</button>
<button
type="button"
onClick={() => {
persistence.applyProjectSnapshot({
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: 5,
layoutStorageVersion: 0,
updatedAt: '2026-07-29T03:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-async-completed',
resourceId: 'resource-async-completed',
title: '异步完成结果',
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 420,
originalHeight: 420,
zIndex: 1,
sourceType: 'generated',
},
],
resources: [
{
resourceId: 'resource-async-completed',
projectId: 'editor-project-default',
imageSrc: '/generated/async-completed.png',
objectKey: 'generated/async-completed.png',
width: 420,
height: 420,
sourceType: 'generated',
},
],
updatedAt: '2026-07-29T03:00:00.000Z',
});
}}
>
apply newer project
</button>
</div>
);
}
describe('useImageCanvasProjectPersistence', () => {
afterEach(() => {
vi.useRealTimers();
});
beforeEach(() => {
vi.resetAllMocks();
try {
globalThis.sessionStorage?.clear();
} catch {
// ignore session storage cleanup errors in jsdom
}
loadOrCreateRecentEditorProjectMock.mockResolvedValue({
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: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
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',
canvasId: 'editor-project-default:canvas:default',
revision: 1,
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('merges a late resource response into a reloaded authoritative layout', async () => {
const resourceCreate =
createDeferred<EditorProjectSnapshot['resources'][number]>();
createEditorProjectResourceMock.mockReturnValueOnce(resourceCreate.promise);
const { rerender } = render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
act(() => {
screen.getByRole('button', { name: 'append' }).click();
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-a:local-layer-a',
);
rerender(<ProjectPersistenceHarness canAccessProtectedData={false} />);
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: 4,
layoutStorageVersion: 1,
updatedAt: '2026-07-29T02:00:00.000Z',
},
viewport: { x: 0, 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-29T02:00:00.000Z',
});
rerender(<ProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-remote:resource-remote',
);
});
saveEditorProjectLayoutMock.mockClear();
await act(async () => {
resourceCreate.resolve({
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',
});
await resourceCreate.promise;
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-remote:resource-remote',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-a:resource-added-asset-a',
);
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
expectedRevision: 4,
layers: expect.arrayContaining([
expect.objectContaining({ layerId: 'layer-remote' }),
expect.objectContaining({
layerId: 'layer-a',
resourceId: 'resource-added-asset-a',
}),
]),
}),
);
});
});
it('merges a late resource response after conflict recovery replaces the 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-29T05:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-29T05:00:00.000Z',
});
const resourceCreate =
createDeferred<EditorProjectSnapshot['resources'][number]>();
createEditorProjectResourceMock.mockReturnValueOnce(resourceCreate.promise);
const conflict = new ApiClientError({
message: '图片画布版本冲突',
status: 409,
code: 'conflict',
});
saveEditorProjectLayoutMock
.mockRejectedValueOnce(conflict)
.mockResolvedValueOnce({
projectId: 'editor-project-default',
canvasId: 'editor-project-default:canvas:default',
revision: 5,
updatedAt: '2026-07-29T05:00:02.000Z',
});
loadEditorProjectMock.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: 4,
layoutStorageVersion: 1,
updatedAt: '2026-07-29T05:00:01.000Z',
},
viewport: { x: 0, 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-29T05:00:01.000Z',
});
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: 'append' }).click();
});
await act(async () => {
vi.advanceTimersByTime(451);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-remote:resource-remote',
);
expect(screen.getByTestId('layers').textContent).not.toContain('layer-a:');
await act(async () => {
resourceCreate.resolve({
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',
});
await resourceCreate.promise;
await Promise.resolve();
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-remote:resource-remote',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-a:resource-added-asset-a',
);
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({
expectedRevision: 4,
layers: expect.arrayContaining([
expect.objectContaining({ layerId: 'layer-remote' }),
expect.objectContaining({
layerId: 'layer-a',
resourceId: 'resource-added-asset-a',
}),
]),
}),
);
});
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('ignores an authoritative callback older than the applied project revision', async () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
act(() => {
screen.getByRole('button', { name: 'apply completed project' }).click();
screen.getByRole('button', { name: 'apply stale project' }).click();
});
expect(screen.getByTestId('project-title').textContent).toBe('空画布项目');
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated:resource-generated',
);
});
it('rejects an asynchronous project callback after authority is revoked', async () => {
const { rerender } = render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
rerender(<ProjectPersistenceHarness canAccessProtectedData={false} />);
act(() => {
screen.getByRole('button', { name: 'apply completed project' }).click();
});
expect(screen.getByTestId('project-title').textContent).toBe('空画布项目');
expect(screen.getByTestId('layers').textContent).toBe('');
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
});
it('keeps a newer same-user completion snapshot that arrives during an authority reload', 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: 0,
updatedAt: '2026-07-29T02:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-29T02:00:00.000Z',
});
const { rerender } = render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
const staleReload = createDeferred<EditorProjectSnapshot>();
await act(async () => {
rerender(<ProjectPersistenceHarness canAccessProtectedData={false} />);
await Promise.resolve();
});
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(
staleReload.promise,
);
await act(async () => {
rerender(<ProjectPersistenceHarness />);
await Promise.resolve();
});
act(() => {
screen.getByRole('button', { name: 'apply newer project' }).click();
});
expect(screen.getByTestId('project-title').textContent).toBe(
'异步完成的新画布',
);
await act(async () => {
staleReload.resolve({
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: 4,
layoutStorageVersion: 0,
updatedAt: '2026-07-29T02:30:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-29T02:30:00.000Z',
});
await staleReload.promise;
await Promise.resolve();
});
expect(screen.getByTestId('project-title').textContent).toBe(
'异步完成的新画布',
);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-async-completed:resource-async-completed',
);
saveEditorProjectLayoutMock.mockClear();
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: 'move viewport' }).click();
});
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({ expectedRevision: 5 }),
);
});
it('does not drain one user pending resource layer into another user project', async () => {
const firstUserLoad = createDeferred<EditorProjectSnapshot>();
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(
firstUserLoad.promise,
);
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-user-two',
title: '用户二画布',
canvas: {
canvasId: 'editor-project-user-two:canvas:default',
projectId: 'editor-project-user-two',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 7,
layoutStorageVersion: 0,
updatedAt: '2026-07-29T04:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-29T04:00:00.000Z',
});
const { rerender } = render(
<ProjectPersistenceHarness currentUserId="user-one" />,
);
act(() => {
screen.getByRole('button', { name: 'append' }).click();
});
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
rerender(<ProjectPersistenceHarness currentUserId="user-two" />);
await waitFor(() => {
expect(screen.getByTestId('project-title').textContent).toBe(
'用户二画布',
);
});
expect(screen.getByTestId('layers').textContent).toBe('');
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
});
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('drains a new-authority save after an old in-flight request fails', async () => {
const firstSave = createDeferred<{
projectId: string;
canvasId: string;
revision: number;
updatedAt: string;
}>();
const { rerender } = render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
saveEditorProjectLayoutMock.mockClear();
saveEditorProjectLayoutMock
.mockReturnValueOnce(firstSave.promise)
.mockResolvedValue({
projectId: 'editor-project-default',
canvasId: 'editor-project-default:canvas:default',
revision: 1,
updatedAt: '2026-07-20T00:00:02.000Z',
});
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: 'move viewport' }).click();
});
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
await act(async () => {
rerender(<ProjectPersistenceHarness canAccessProtectedData={false} />);
await Promise.resolve();
});
await act(async () => {
rerender(<ProjectPersistenceHarness />);
await Promise.resolve();
await Promise.resolve();
});
act(() => {
screen.getByRole('button', { name: 'move viewport' }).click();
});
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
await act(async () => {
firstSave.reject(new TypeError('old connection dropped'));
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2);
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({ expectedRevision: 0 }),
);
});
it('keeps conflict recovery cache isolated when an old runner drains a new user save', async () => {
const projectForUser = (
userNumber: 'one' | 'two',
revision: number,
title: string,
): EditorProjectSnapshot => ({
projectId: `editor-project-user-${userNumber}`,
title,
canvas: {
canvasId: `editor-project-user-${userNumber}:canvas:default`,
projectId: `editor-project-user-${userNumber}`,
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision,
layoutStorageVersion: 0,
updatedAt: `2026-07-20T00:00:0${revision}.000Z`,
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: `2026-07-20T00:00:0${revision}.000Z`,
});
loadOrCreateRecentEditorProjectMock
.mockResolvedValueOnce(projectForUser('one', 3, '用户一画布'))
.mockResolvedValueOnce(projectForUser('two', 7, '用户二画布'));
const firstSave = createDeferred<{
projectId: string;
canvasId: string;
revision: number;
updatedAt: string;
}>();
const conflict = new ApiClientError({
message: '图片画布版本冲突',
status: 409,
code: 'conflict',
});
saveEditorProjectLayoutMock
.mockReturnValueOnce(firstSave.promise)
.mockRejectedValueOnce(conflict);
loadEditorProjectMock.mockResolvedValueOnce(
projectForUser('two', 8, '用户二冲突恢复画布'),
);
const { rerender } = render(
<ProjectPersistenceHarness currentUserId="user-one" />,
);
await waitFor(() => {
expect(screen.getByTestId('project-title').textContent).toBe(
'用户一画布',
);
});
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: 'move viewport' }).click();
});
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
await act(async () => {
rerender(<ProjectPersistenceHarness currentUserId="user-two" />);
await Promise.resolve();
await Promise.resolve();
});
expect(screen.getByTestId('project-title').textContent).toBe('用户二画布');
act(() => {
screen.getByRole('button', { name: 'move viewport' }).click();
});
act(() => {
vi.advanceTimersByTime(451);
});
await act(async () => {
firstSave.resolve({
projectId: 'editor-project-user-one',
canvasId: 'editor-project-user-one:canvas:default',
revision: 4,
updatedAt: '2026-07-20T00:00:04.000Z',
});
await firstSave.promise;
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2);
expect(loadEditorProjectMock).toHaveBeenCalledWith(
'editor-project-user-two',
);
expect(screen.getByTestId('project-title').textContent).toBe(
'用户二冲突恢复画布',
);
const userOneCache = globalThis.sessionStorage.getItem(
'genarrative.imageCanvas.projectSnapshot.v2:user-one:recent',
);
const userTwoCache = globalThis.sessionStorage.getItem(
'genarrative.imageCanvas.projectSnapshot.v2:user-two:recent',
);
expect(userOneCache).toContain('editor-project-user-one');
expect(userOneCache).not.toContain('editor-project-user-two');
expect(userTwoCache).toContain('editor-project-user-two');
expect(userTwoCache).toContain('用户二冲突恢复画布');
});
it('retries only the authoritative reload after conflict recovery 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);
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();
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(1);
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 {
ownerUserId?: string;
project?: EditorProjectSnapshot;
revision?: number;
};
expect(cached.ownerUserId).toBe('user-test');
expect(cached.revision).toBe(1);
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: '已有封面画布',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 12, y: -8, scale: 1 },
layers: [],
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
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(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith(
expect.objectContaining({
viewport: { x: 202, y: 152, scale: 0.5 },
viewportSize: { width: 1280, height: 960 },
backgroundColor: DEFAULT_CANVAS_BACKGROUND_COLOR,
}),
);
});
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: '本地封面画布',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
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('flushes the latest layout and waits for the local cover cache before returning', async () => {
const coverBlob = new Blob(['cover'], { type: 'image/webp' });
const coverCacheWrite = createDeferred<void>();
createProjectCoverSnapshotBlobMock.mockResolvedValue(coverBlob);
putEditorProjectCoverCacheMock.mockReturnValue(coverCacheWrite.promise);
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
act(() => {
screen.getByRole('button', { name: 'append server resource' }).click();
screen.getByRole('button', { name: 'move viewport' }).click();
});
await waitFor(() => {
expect(screen.getByTestId('viewport').textContent).toBe('10,5,0.5');
});
saveEditorProjectLayoutMock.mockClear();
act(() => {
screen.getByRole('button', { name: 'flush project persistence' }).click();
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
viewport: { x: 10, y: 5, scale: 1 },
}),
);
expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalledWith(
expect.objectContaining({
viewport: { x: 200, y: 165, scale: 0.5 },
viewportSize: { width: 1280, height: 960 },
}),
);
expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith({
projectId: 'editor-project-default',
blob: coverBlob,
});
});
expect(screen.getByTestId('flush-completed').textContent).toBe('false');
coverCacheWrite.resolve();
await waitFor(() => {
expect(screen.getByTestId('flush-completed').textContent).toBe('true');
});
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled();
expect(createEditorProjectResourceMock).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 () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
const cachedBeforeInteraction = globalThis.sessionStorage.getItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
);
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(
globalThis.sessionStorage.getItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
),
).toBe(cachedBeforeInteraction);
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 },
}),
);
const cachedAfterInteraction = JSON.parse(
globalThis.sessionStorage.getItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
) ?? '{}',
) as { project?: EditorProjectSnapshot };
expect(cachedAfterInteraction.project?.viewport).toEqual({
x: 20,
y: 10,
scale: 1,
});
vi.useRealTimers();
});
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: '已有角色项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
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: '空画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
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: '空画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
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',
canvasId: 'editor-project-default:canvas:default',
revision: 1,
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: '空画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
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: '空画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:00.000Z',
},
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: '角色画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 0,
layoutStorageVersion: 0,
updatedAt: '2026-06-21T12:15:00.000Z',
},
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(),
ownerUserId: 'user-test',
revision: 2,
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: '后端画布项目',
canvas: {
canvasId: 'editor-project-default:canvas:default',
projectId: 'editor-project-default',
title: '默认画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
revision: 3,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:02.000Z',
},
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('waits for authority before creating a cached-project resource and then restores its layer', async () => {
const backendProjectLoad = createDeferred<EditorProjectSnapshot>();
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(
backendProjectLoad.promise,
);
globalThis.sessionStorage.setItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
JSON.stringify({
cachedAt: Date.now(),
ownerUserId: 'user-test',
revision: 2,
project: {
projectId: 'editor-project-default',
title: '缓存画布项目',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:01.000Z',
} satisfies EditorProjectSnapshot,
}),
);
render(<ProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('project-title').textContent).toBe(
'缓存画布项目',
);
});
act(() => {
screen.getByRole('button', { name: 'append' }).click();
});
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
await act(async () => {
backendProjectLoad.resolve({
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: 0,
updatedAt: '2026-06-12T00:00:02.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-06-12T00:00:02.000Z',
});
await backendProjectLoad.promise;
});
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('layers').textContent).toContain(
'layer-a:resource-added-asset-a',
);
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
expectedRevision: 3,
layers: expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-a',
resourceId: 'resource-added-asset-a',
}),
]),
}),
);
});
});
it('does not autosave a cached snapshot when user identity restarts the authoritative load', 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: 0,
updatedAt: '2026-07-29T01:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-29T01:00:00.000Z',
});
const { rerender } = render(
<ProjectPersistenceHarness currentUserId={null} />,
);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
await waitFor(() => {
expect(screen.getByTestId('project-title').textContent).toBe(
'首次后端画布',
);
});
globalThis.sessionStorage.setItem(
EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY,
JSON.stringify({
cachedAt: Date.now(),
ownerUserId: 'user-test',
revision: 1,
project: {
projectId: 'editor-project-default',
title: '公司旧缓存画布',
viewport: { x: 100, y: 200, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-28T01:00:00.000Z',
} satisfies EditorProjectSnapshot,
}),
);
const secondBackendLoad = createDeferred<EditorProjectSnapshot>();
loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(
secondBackendLoad.promise,
);
saveEditorProjectLayoutMock.mockClear();
vi.useFakeTimers();
await act(async () => {
rerender(<ProjectPersistenceHarness currentUserId="user-test" />);
await Promise.resolve();
});
expect(screen.getByTestId('project-title').textContent).toBe(
'公司旧缓存画布',
);
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
await act(async () => {
secondBackendLoad.resolve({
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: 4,
layoutStorageVersion: 0,
updatedAt: '2026-07-29T01:01:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-29T01:01:00.000Z',
});
await secondBackendLoad.promise;
await Promise.resolve();
});
expect(screen.getByTestId('project-title').textContent).toBe(
'二次后端权威画布',
);
act(() => {
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
vi.useRealTimers();
});
it('does not save when the authoritative snapshot has no revision', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-default',
title: '缺少版本的异常快照',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-29T01:00:00.000Z',
});
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
await waitFor(() => {
expect(screen.getByTestId('project-title').textContent).toBe(
'缺少版本的异常快照',
);
});
saveEditorProjectLayoutMock.mockClear();
vi.useFakeTimers();
act(() => {
screen.getByRole('button', { name: 'move viewport' }).click();
vi.advanceTimersByTime(451);
});
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
vi.useRealTimers();
});
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');
});
});