Files
Genarrative/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
T
lhk229 3d8e0211db
Project CI / Repository checks (push) Successful in 3m54s
Project CI / Frontend tests (push) Successful in 4m20s
Project CI / Backend tests (push) Successful in 4m33s
Project CI / Native shell tests (push) Successful in 16m31s
5分钟策划agent (#159)
Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/159
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-08-24 21:44:44 +08:00

3900 lines
124 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @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 {
EditorProjectLayerSnapshot,
EditorProjectSnapshot,
} from '../../services/image-editor/editorProjectClient';
import {
DEFAULT_CANVAS_BACKGROUND_COLOR,
normalizeCanvasBackgroundHex,
serializeCanvasLayout,
splitCanvasLayoutItems,
} from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasViewport,
} from './ImageCanvasEditorTypes';
import { readPerfectPixelOperations } from './perfectPixelOperationStore';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
mergeAuthoritativeCanvasLayoutWithPendingLocalLayout,
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 createCompletedEditorProjectSnapshot(
revision = 1,
): EditorProjectSnapshot {
return {
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,
layoutStorageVersion: 0,
updatedAt: '2026-06-12T00:00:01.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: {
id: 'generation-dialog-1',
mode: 'generate',
prompt: '后端完成生成器',
status: 'idle',
composerOpen: false,
generatedLayerId: 'layer-generated',
placeholder: {
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 420,
originalHeight: 420,
},
},
},
{
layerId: 'layer-generated',
resourceId: 'resource-generated',
title: '生成结果',
x: 42,
y: 56,
width: 420,
height: 420,
originalWidth: 420,
originalHeight: 420,
zIndex: 1,
sourceType: 'generated',
},
],
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',
};
}
it('merges pending geometry and dialog edits while retaining backend additions and deletions', () => {
const authoritativeItems: EditorProjectLayerSnapshot[] = [
{
itemType: 'canvas-settings',
layerId: 'canvas-settings:default',
resourceId: 'canvas-settings:default',
canvasBackgroundColor: '#FFFFFF',
},
{
layerId: 'layer-existing',
resourceId: 'resource-existing-v2',
title: '后端标题',
x: 10,
y: 20,
zIndex: 1,
sourceType: 'generated',
objectKey: 'generated/new.png',
},
{
layerId: 'layer-deleted-locally',
resourceId: 'resource-deleted-locally',
title: '本地已删除',
x: 0,
y: 0,
zIndex: 2,
sourceType: 'generated',
},
{
layerId: 'layer-generated-by-backend',
resourceId: 'resource-generated-by-backend',
title: '后端新增结果',
x: 40,
y: 50,
zIndex: 3,
sourceType: 'generated',
},
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:dialog-1',
resourceId: 'generation-dialog:dialog-1',
dialog: {
id: 'dialog-1',
mode: 'generate',
prompt: '后端原提示词',
status: 'idle',
composerOpen: false,
generatedLayerId: 'layer-generated-by-backend',
placeholder: {
x: 40,
y: 50,
width: 320,
height: 320,
originalWidth: 1024,
originalHeight: 1024,
},
},
},
];
const pendingItems: EditorProjectLayerSnapshot[] = [
{
itemType: 'canvas-settings',
layerId: 'canvas-settings:default',
resourceId: 'canvas-settings:default',
canvasBackgroundColor: '#AABBCC',
},
{
layerId: 'layer-existing',
resourceId: 'resource-existing-v1',
title: '本地标题',
x: 88,
y: 99,
zIndex: 7,
sourceType: 'generated',
objectKey: 'generated/old.png',
},
{
layerId: 'layer-deleted-by-backend',
resourceId: 'resource-deleted-by-backend',
title: '后端已删除',
x: 1,
y: 2,
zIndex: 8,
sourceType: 'generated',
},
{
layerId: 'layer-added-locally',
resourceId: 'resource-added-locally',
title: '本地新增',
x: 3,
y: 4,
zIndex: 9,
sourceType: 'generated',
},
{
itemType: 'generation-dialog',
layerId: 'generation-dialog:dialog-1',
resourceId: 'generation-dialog:dialog-1',
dialog: {
id: 'dialog-1',
mode: 'generate',
prompt: '请求在途期间的新提示词',
status: 'generating',
composerOpen: true,
placeholder: {
x: 88,
y: 99,
width: 320,
height: 320,
originalWidth: 1024,
originalHeight: 1024,
},
},
},
];
const merged = mergeAuthoritativeCanvasLayoutWithPendingLocalLayout({
authoritativeItems,
pendingItems,
previousAuthoritativeItemIds: new Set([
'canvas-settings:default',
'layer-existing',
'layer-deleted-locally',
'layer-deleted-by-backend',
'generation-dialog:dialog-1',
]),
});
expect(merged).toEqual(
expect.arrayContaining([
expect.objectContaining({
layerId: 'canvas-settings:default',
canvasBackgroundColor: '#AABBCC',
}),
expect.objectContaining({
layerId: 'layer-existing',
resourceId: 'resource-existing-v2',
title: '本地标题',
x: 88,
y: 99,
zIndex: 7,
objectKey: 'generated/new.png',
}),
expect.objectContaining({
layerId: 'layer-generated-by-backend',
resourceId: 'resource-generated-by-backend',
}),
expect.objectContaining({
layerId: 'layer-added-locally',
resourceId: 'resource-added-locally',
}),
expect.objectContaining({
layerId: 'generation-dialog:dialog-1',
dialog: expect.objectContaining({
prompt: '请求在途期间的新提示词',
status: 'idle',
composerOpen: false,
generatedLayerId: 'layer-generated-by-backend',
placeholder: expect.objectContaining({ x: 88, y: 99 }),
}),
}),
]),
);
expect(merged.some((item) => item.layerId === 'layer-deleted-locally')).toBe(
false,
);
expect(
merged.some((item) => item.layerId === 'layer-deleted-by-backend'),
).toBe(false);
});
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 [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
const canvasGenerationDialogsRef = useRef(generationDialogs);
const canvasBackgroundColorRef = useRef(canvasBackgroundColor);
const selectedLayerRef = useRef<string | null>(selectedLayerId);
const selectedLayerIdsRef = useRef<string[]>(selectedLayerIds);
const layerCounterRef = useRef(0);
const openEditorLoginModalRef = useRef(vi.fn());
layersRef.current = layers;
viewportRef.current = viewport;
canvasGenerationDialogsRef.current = generationDialogs;
canvasBackgroundColorRef.current = canvasBackgroundColor;
selectedLayerRef.current = selectedLayerId;
selectedLayerIdsRef.current = selectedLayerIds;
const selectSingleLayer = useCallback((layerId: string | null) => {
setSelectedLayerId(layerId);
setSelectedLayerIds(layerId ? [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,
selectedLayerIdRef: selectedLayerRef,
selectedLayerIdsRef,
}),
[],
);
const persistenceSetters = useMemo(
() => ({
setProjectTitle,
setProjectRenameValue,
setViewport,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
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="selected-ids">
{selectedLayerIdsRef.current.join(',') || '-'}
</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="asset-kind-fallback-notice">
{persistence.assetKindFallbackNotice?.message ?? '-'}
</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={() => {
setLayers((currentLayers) =>
currentLayers.filter((layer) => layer.id !== 'layer-generated'),
);
}}
>
remove 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(
createCompletedEditorProjectSnapshot(),
);
}}
>
apply completed project
</button>
<button
type="button"
onClick={() => {
setSelectedLayerId('layer-generated');
setSelectedLayerIds([
'layer-generated',
'generation-dialog:generation-dialog-1',
]);
}}
>
select completed objects
</button>
<button
type="button"
onClick={() => {
persistence.applyProjectSnapshot(
createCompletedEditorProjectSnapshot(2),
);
}}
>
reapply 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>
);
}
const STRICT_DIALOG_ID = 'generation-dialog-perfect-pixel';
const STRICT_TASK_ID = `pixel-art-snap-${STRICT_DIALOG_ID}`;
const STRICT_PLACEHOLDER = {
x: 360,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
};
function PerfectPixelProjectPersistenceHarness({
canAccessProtectedData = true,
operationSourceImageSrc = 'resource-source',
}: {
canAccessProtectedData?: boolean;
operationSourceImageSrc?: string;
}) {
const [layers, setLayers] = useState<CanvasLayer[]>([]);
const [viewport, setViewport] = useState<CanvasViewport>({
x: 0,
y: 0,
scale: 1,
});
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState(
DEFAULT_CANVAS_BACKGROUND_COLOR,
);
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
const [strictStatus, setStrictStatus] = useState<
'idle' | 'pending' | 'resolved' | 'rejected'
>('idle');
const [downstreamPostCount, setDownstreamPostCount] = useState(0);
const dialogs = useCanvasGenerationDialogs();
const automaticGenerationDialogs = useMemo<CanvasGenerationDialogState[]>(
() => [],
[],
);
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
const canvasGenerationDialogsRef = useRef(dialogs.canvasGenerationDialogs);
const canvasBackgroundColorRef = useRef(canvasBackgroundColor);
const selectedLayerIdRef = useRef(selectedLayerId);
const selectedLayerIdsRef = useRef(selectedLayerIds);
const layerCounterRef = useRef(0);
const openEditorLoginModalRef = useRef(vi.fn());
layersRef.current = layers;
viewportRef.current = viewport;
canvasGenerationDialogsRef.current = dialogs.canvasGenerationDialogs;
canvasBackgroundColorRef.current = canvasBackgroundColor;
selectedLayerIdRef.current = selectedLayerId;
selectedLayerIdsRef.current = selectedLayerIds;
const selectSingleLayer = useCallback((layerId: string | null) => {
setSelectedLayerId(layerId);
setSelectedLayerIds(layerId ? [layerId] : []);
}, []);
const applyCanvasBackgroundColor = useCallback((color: string) => {
const normalizedColor = normalizeCanvasBackgroundHex(color);
if (!normalizedColor) {
return false;
}
setCanvasBackgroundColor(normalizedColor);
return true;
}, []);
const persistenceRefs = useMemo(
() => ({
layersRef,
viewportRef,
canvasGenerationDialogsRef,
getCanvasGenerationDialogsSnapshot:
dialogs.getCanvasGenerationDialogsSnapshot,
canvasBackgroundColorRef,
selectedLayerIdRef,
selectedLayerIdsRef,
}),
[dialogs.getCanvasGenerationDialogsSnapshot],
);
const persistenceSetters = useMemo(
() => ({
setProjectTitle: () => undefined,
setProjectRenameValue: () => undefined,
setViewport,
setLayers,
setSelectedLayerId,
setSelectedLayerIds,
selectSingleLayer,
setLayerCounter: (value: number) => {
layerCounterRef.current = value;
},
restoreCanvasGenerationDialogs: dialogs.restoreCanvasGenerationDialogs,
applyCanvasBackgroundColor,
}),
[
applyCanvasBackgroundColor,
dialogs.restoreCanvasGenerationDialogs,
selectSingleLayer,
],
);
const persistence = useImageCanvasProjectPersistence({
refs: persistenceRefs,
setters: persistenceSetters,
layers,
canvasGenerationDialogs: automaticGenerationDialogs,
viewport,
canvasSize: { width: 900, height: 640 },
canvasBackgroundColor,
isViewportInteracting: false,
canAccessProtectedData,
currentUserId: 'user-test',
openEditorLoginModal: openEditorLoginModalRef.current,
});
return (
<div>
<span data-testid="strict-project-id">
{persistence.projectId ?? '-'}
</span>
<span data-testid="strict-save-status">{strictStatus}</span>
<span data-testid="strict-downstream-post-count">
{downstreamPostCount}
</span>
<button
type="button"
onClick={() => {
setStrictStatus('pending');
dialogs.openCanvasGenerationDialog({
id: STRICT_DIALOG_ID,
mode: 'quick-edit',
prompt: '完美像素',
status: 'generating',
composerOpen: false,
sourceLayerId: 'layer-source',
requiresLiveSession: true,
perfectPixelOperation: {
version: 1,
kind: 'perfect-pixel',
operationId: STRICT_DIALOG_ID,
taskId: STRICT_TASK_ID,
submittedAt: 1_771_400_000_000,
reconcileUntil: 1_771_400_075_000,
request: {
sourceImageSrc: operationSourceImageSrc,
projectId: 'editor-project-default',
sourceResourceId: 'resource-source',
assetKind: 'icon',
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId: STRICT_DIALOG_ID,
title: '源图 · 完美像素',
placeholder: { ...STRICT_PLACEHOLDER },
},
},
},
placeholder: { ...STRICT_PLACEHOLDER },
});
void persistence
.flushProjectPersistence({ preferLatestGenerationDialogs: true })
.then(
() => {
setStrictStatus('resolved');
setDownstreamPostCount((count) => count + 1);
},
() => {
setStrictStatus('rejected');
},
);
}}
>
open and flush perfect pixel
</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('keeps and repairs a layer whose persisted override crosses media families', 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: 7,
layoutStorageVersion: 0,
updatedAt: '2026-08-05T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-image',
resourceId: 'resource-image',
title: '普通图片',
x: 10,
y: 20,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'uploaded',
assetKindOverride: 'character-animation',
},
],
resources: [
{
resourceId: 'resource-image',
projectId: 'editor-project-default',
imageSrc: '/read/image.png',
width: 320,
height: 240,
sourceType: 'uploaded',
assetKind: null,
},
],
updatedAt: '2026-08-05T00:00:00.000Z',
});
render(<ProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-image:resource-image:-',
);
});
expect(
screen.getByTestId('asset-kind-fallback-notice').textContent,
).toContain('已保留图层并恢复为素材默认标签');
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
expectedRevision: 7,
layers: expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-image',
resourceId: 'resource-image',
assetKindOverride: null,
}),
]),
}),
);
});
});
it('migrates a legacy inline ledger on a failed placeholder that exact retry still accepts', async () => {
// 中文注释:`failed + perfectPixelOperation` 是旧严格保存失败的合法持久化形状——请求
// 已备好但 POST 从未发出,`retryPerfectPixelOperation` 明确接受该状态,面板上的
// 「重试同一完美像素操作」也只在它带 operation 时才出现。按状态白名单列举会把这批漏掉:
// 下一次保存剥成 marker 后再加载就是 failed + invalid,重试按钮消失,用户只剩删掉重做
// ——而那正是新 identity,正是 exact retry 要避免的重复。
window.localStorage.clear();
const dialogId = 'generation-dialog-legacy-prepared-failure';
const submittedAt = Date.now();
const legacyOperation = {
version: 1 as const,
kind: 'perfect-pixel' as const,
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'generated-images/editor/legacy-prepared.png',
projectId: 'editor-project-default',
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId,
title: '源图 · 完美像素',
placeholder: { ...STRICT_PLACEHOLDER },
},
},
submittedAt,
reconcileUntil: submittedAt + 75_000,
};
const legacyDialogItem = {
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialogId}`,
resourceId: `generation-dialog:${dialogId}`,
dialog: {
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
// 中文注释:失败但未收口——没有 generatedLayerId,账本仍是它唯一的重试凭据。
status: 'failed',
composerOpen: false,
errorMessage: '完美像素请求尚未发出:提交前画布保存失败',
perfectPixelOperation: legacyOperation,
placeholder: { ...STRICT_PLACEHOLDER },
},
} as unknown as EditorProjectLayerSnapshot;
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: [legacyDialogItem],
revision: 4,
layoutStorageVersion: 0,
updatedAt: '2026-08-05T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [legacyDialogItem],
resources: [],
updatedAt: '2026-08-05T00:00:00.000Z',
});
render(<PerfectPixelProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('strict-project-id').textContent).toBe(
'editor-project-default',
);
});
await waitFor(() => {
expect(
readPerfectPixelOperations('user-test', 'editor-project-default').size,
).toBe(1);
});
expect(
readPerfectPixelOperations('user-test', 'editor-project-default').get(
dialogId,
),
).toEqual(legacyOperation);
});
it('migrates a legacy inline perfect-pixel ledger into local storage on load', async () => {
// 中文注释:布局内联账本是账本移出布局之前的 legacy 形状。第一次 hydrate 还能认它,但
// 下一次保存会把它剥成 perfectPixelOperationId 标记,此后再没有路径能补写本机账本——
// 不迁移的话,部署那一刻仍在途的操作会在第二次加载变成 failed + invalid,永久失去
// exact retry 的 identity。
window.localStorage.clear();
const dialogId = 'generation-dialog-legacy-inflight';
const submittedAt = Date.now();
const legacyOperation = {
version: 1 as const,
kind: 'perfect-pixel' as const,
operationId: dialogId,
taskId: `pixel-art-snap-${dialogId}`,
request: {
sourceImageSrc: 'generated-images/editor/legacy-source.png',
projectId: 'editor-project-default',
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId,
title: '源图 · 完美像素',
placeholder: { ...STRICT_PLACEHOLDER },
},
},
submittedAt,
reconcileUntil: submittedAt + 75_000,
};
const legacyDialogItem = {
itemType: 'generation-dialog',
layerId: `generation-dialog:${dialogId}`,
resourceId: `generation-dialog:${dialogId}`,
dialog: {
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'pending-confirmation',
composerOpen: false,
sourceLayerId: 'layer-source',
perfectPixelOperation: legacyOperation,
placeholder: { ...STRICT_PLACEHOLDER },
},
} as unknown as EditorProjectLayerSnapshot;
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: [legacyDialogItem],
revision: 3,
layoutStorageVersion: 0,
updatedAt: '2026-08-05T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [legacyDialogItem],
resources: [],
updatedAt: '2026-08-05T00:00:00.000Z',
});
render(<PerfectPixelProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('strict-project-id').textContent).toBe(
'editor-project-default',
);
});
await waitFor(() => {
expect(
readPerfectPixelOperations('user-test', 'editor-project-default').size,
).toBe(1);
});
const migrated = readPerfectPixelOperations(
'user-test',
'editor-project-default',
).get(dialogId);
expect(migrated).toEqual(legacyOperation);
// 中文注释:往返验证——布局再保存一次只剩标记,此时只有本机账本能把它救回来。
const strippedLayout = serializeCanvasLayout({
layers: [],
canvasGenerationDialogs: [
{
id: dialogId,
mode: 'quick-edit',
prompt: '完美像素',
status: 'pending-confirmation',
composerOpen: false,
perfectPixelOperationId: dialogId,
perfectPixelOperation: migrated,
} as unknown as CanvasGenerationDialogState,
],
});
expect(JSON.stringify(strippedLayout)).not.toContain(
'"perfectPixelOperation"',
);
const { generationDialogs } = splitCanvasLayoutItems(
strippedLayout,
new Map(),
'user-test',
readPerfectPixelOperations('user-test', 'editor-project-default'),
);
expect(generationDialogs[0]).toMatchObject({
id: dialogId,
status: 'pending-confirmation',
});
expect(generationDialogs[0]?.perfectPixelOperation).toEqual(
legacyOperation,
);
expect(generationDialogs[0]).not.toHaveProperty(
'perfectPixelOperationInvalid',
);
});
it('persists the perfect-pixel marker without leaking the request ledger into the layout', async () => {
render(<PerfectPixelProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('strict-project-id').textContent).toBe(
'editor-project-default',
);
});
act(() => {
screen
.getByRole('button', { name: 'open and flush perfect pixel' })
.click();
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalled();
});
const [, layoutInput] = saveEditorProjectLayoutMock.mock.calls[0] as [
string,
{ layers: unknown[] },
];
const dialogItem = layoutInput.layers.find(
(item) =>
(item as { layerId?: string }).layerId ===
`generation-dialog:${STRICT_DIALOG_ID}`,
);
// 中文注释:`preferLatestGenerationDialogs` 让刚创建的占位不会因为 ref 落后一帧而漏存。
expect(dialogItem).toBeTruthy();
expect(
(dialogItem as { dialog: Record<string, unknown> }).dialog,
).toMatchObject({
id: STRICT_DIALOG_ID,
status: 'generating',
perfectPixelOperationId: STRICT_DIALOG_ID,
});
// 中文注释:请求账本只在本机,布局里连源图地址都不该出现。
expect(JSON.stringify(layoutInput.layers)).not.toContain(
'"perfectPixelOperation"',
);
expect(JSON.stringify(layoutInput.layers)).not.toContain('resource-source');
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
1,
'editor-project-default',
expect.anything(),
);
});
it.each([
['400', 400],
['403', 403],
])(
'never blocks the caller when layout persistence fails with HTTP %s',
async (_label, status) => {
saveEditorProjectLayoutMock.mockRejectedValue(
new ApiClientError({
message: '布局保存被拒绝',
status,
code: 'layout_rejected',
}),
);
render(<PerfectPixelProjectPersistenceHarness />);
await waitFor(() => {
expect(screen.getByTestId('strict-project-id').textContent).toBe(
'editor-project-default',
);
});
act(() => {
screen
.getByRole('button', { name: 'open and flush perfect pixel' })
.click();
});
// 中文注释:阶段 3 的核心断言——布局保存失败只是画布没同步上去,绝不能把下游
// 完美像素 POST 拦下来。账本已经先落本机,请求仍然可追溯。
await waitFor(() => {
expect(screen.getByTestId('strict-save-status').textContent).toBe(
'resolved',
);
});
expect(
screen.getByTestId('strict-downstream-post-count').textContent,
).toBe('1');
},
);
it('never blocks the caller when the project has no authority', async () => {
render(
<PerfectPixelProjectPersistenceHarness canAccessProtectedData={false} />,
);
act(() => {
screen
.getByRole('button', { name: 'open and flush perfect pixel' })
.click();
});
await waitFor(() => {
expect(screen.getByTestId('strict-save-status').textContent).toBe(
'resolved',
);
});
expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled();
expect(screen.getByTestId('strict-downstream-post-count').textContent).toBe(
'1',
);
});
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:');
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: 'local-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();
await Promise.resolve();
await Promise.resolve();
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(
3,
'editor-project-default',
expect.objectContaining({
expectedRevision: 5,
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 and replays the in-flight 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(2);
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
1,
'editor-project-default',
expect.objectContaining({ expectedRevision: 3 }),
);
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({
expectedRevision: 4,
layers: expect.arrayContaining([
expect.objectContaining({ layerId: 'layer-remote' }),
expect.objectContaining({
layerId: 'layer-server-resource',
resourceId: 'resource-from-generation-bff',
}),
]),
}),
);
});
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('does not steal an empty selection when a same-project completion snapshot arrives', async () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
expect(screen.getByTestId('selected').textContent).toBe('-');
act(() => {
screen.getByRole('button', { name: 'apply completed project' }).click();
});
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated:resource-generated',
);
expect(screen.getByTestId('selected').textContent).toBe('-');
});
it('preserves valid layer and generation selections across a same-project snapshot', async () => {
render(<ProjectPersistenceHarness />);
expect(await screen.findByText('editor-project-default')).toBeTruthy();
act(() => {
screen.getByRole('button', { name: 'apply completed project' }).click();
});
act(() => {
screen.getByRole('button', { name: 'select completed objects' }).click();
});
expect(screen.getByTestId('selected-ids').textContent).toBe(
'layer-generated,generation-dialog:generation-dialog-1',
);
act(() => {
screen.getByRole('button', { name: 'reapply completed project' }).click();
});
expect(screen.getByTestId('selected').textContent).toBe('layer-generated');
expect(screen.getByTestId('selected-ids').textContent).toBe(
'layer-generated,generation-dialog:generation-dialog-1',
);
});
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(3);
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
3,
'editor-project-user-two',
expect.objectContaining({ expectedRevision: 8 }),
);
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',
}),
]),
}),
);
});
await waitFor(() => {
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 without waiting for the cover snapshot chain', async () => {
// 中文注释:flush 曾经 `await` 封面持久化,于是每个 await flush 的调用方(完美像素提交、
// 人工重试、图集拆分)都被挂在封面链后面。封面渲染要为每个可绘制图层取 signed URL 再
// `new Image()` 加载,而那个 Image 没有 timeout 也没有 AbortSignal——一张图不 settle
// 生成 POST 就永远发不出去。await flush 的调用方要的只是「布局已持久化」这一个前置,
// 封面与它无关。本用例钉住:封面仍然被发起,但 flush 不等它。
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,
});
});
// 中文注释:封面缓存写入仍然挂着,flush 必须已经返回——这正是删除 `await coverSave`
// 要钉住的行为。
await waitFor(() => {
expect(screen.getByTestId('flush-completed').textContent).toBe('true');
});
// 中文注释:不等 ≠ 不做。封面链继续跑到底,上传与资源登记照常发生。
coverCacheWrite.resolve();
await waitFor(() => {
expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled();
});
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({ assetKind: 'project-cover-snapshot' }),
);
});
it('returns from flush even when the cover snapshot never settles', async () => {
// 中文注释:真实渲染器里的 `new Image()` 无 timeout / 无 AbortSignal,一张图既不 load
// 也不 error 时,封面链的 Promise 永不 settle。用永不 resolve 的 blob 模拟该形状——
// 若 flush 仍 `await` 封面,本用例会挂到超时。
createProjectCoverSnapshotBlobMock.mockReturnValue(new 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');
});
act(() => {
screen.getByRole('button', { name: 'flush project persistence' }).click();
});
await waitFor(() => {
expect(screen.getByTestId('flush-completed').textContent).toBe('true');
});
expect(createProjectCoverSnapshotBlobMock).toHaveBeenCalled();
expect(putEditorProjectCoverCacheMock).not.toHaveBeenCalled();
});
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('replays an in-flight local layout over completion snapshots without letting an old save replace the authority baseline', 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 firstSave = createDeferred<{
projectId: string;
canvasId: string;
revision: number;
updatedAt: string;
}>();
const secondSave = createDeferred<{
projectId: string;
canvasId: string;
revision: number;
updatedAt: string;
}>();
saveEditorProjectLayoutMock
.mockReturnValueOnce(firstSave.promise)
.mockReturnValueOnce(secondSave.promise)
.mockResolvedValueOnce({
projectId: 'editor-project-default',
canvasId: 'editor-project-default:canvas:default',
revision: 3,
updatedAt: '2026-06-12T00:00:03.000Z',
});
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({ expectedRevision: 0 }),
);
act(() => {
screen.getByRole('button', { name: 'apply completed project' }).click();
});
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:移动后的生成器:88',
);
expect(screen.getByTestId('layers').textContent).toBe(
'layer-generated:resource-generated:-:-:generated/result.png:asset-object-result',
);
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1);
await act(async () => {
firstSave.resolve({
projectId: 'editor-project-default',
canvasId: 'editor-project-default:canvas:default',
revision: 1,
updatedAt: '2026-06-12T00:00:01.000Z',
});
await firstSave.promise;
await Promise.resolve();
await Promise.resolve();
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2);
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({
expectedRevision: 1,
layers: expect.arrayContaining([
expect.objectContaining({
layerId: 'layer-generated',
resourceId: 'resource-generated',
}),
expect.objectContaining({
itemType: 'generation-dialog',
dialog: expect.objectContaining({
id: 'generation-dialog-1',
prompt: '移动后的生成器',
status: 'idle',
generatedLayerId: 'layer-generated',
placeholder: expect.objectContaining({ x: 88 }),
}),
}),
]),
}),
);
act(() => {
screen.getByRole('button', { name: 'remove generated' }).click();
});
act(() => {
screen.getByRole('button', { name: 'apply completed project' }).click();
});
expect(screen.getByTestId('layers').textContent).toBe('');
await act(async () => {
secondSave.resolve({
projectId: 'editor-project-default',
canvasId: 'editor-project-default:canvas:default',
revision: 2,
updatedAt: '2026-06-12T00:00:02.000Z',
});
await secondSave.promise;
await Promise.resolve();
await Promise.resolve();
});
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(3);
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
3,
'editor-project-default',
expect.objectContaining({ expectedRevision: 2 }),
);
const thirdSaveInput = saveEditorProjectLayoutMock.mock.calls[2]?.[1];
expect(
thirdSaveInput.layers.some(
(item: EditorProjectLayerSnapshot) =>
item.layerId === 'layer-generated',
),
).toBe(false);
});
it('replays an in-flight local layout after a 409 reloads the authoritative completion snapshot', 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 firstSave = createDeferred<never>();
saveEditorProjectLayoutMock
.mockReturnValueOnce(firstSave.promise)
.mockResolvedValueOnce({
projectId: 'editor-project-default',
canvasId: 'editor-project-default:canvas:default',
revision: 2,
updatedAt: '2026-06-12T00:00:02.000Z',
});
loadEditorProjectMock.mockResolvedValueOnce(
createCompletedEditorProjectSnapshot(),
);
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);
await act(async () => {
firstSave.reject(
new ApiClientError({
message: '图片画布版本冲突',
status: 409,
code: 'conflict',
}),
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
expect(loadEditorProjectMock).toHaveBeenCalledWith(
'editor-project-default',
);
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:移动后的生成器:88',
);
expect(screen.getByTestId('layers').textContent).toBe(
'layer-generated:resource-generated:-:-:generated/result.png:asset-object-result',
);
expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2);
expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith(
2,
'editor-project-default',
expect.objectContaining({
expectedRevision: 1,
layers: expect.arrayContaining([
expect.objectContaining({ layerId: 'layer-generated' }),
expect.objectContaining({
itemType: 'generation-dialog',
dialog: expect.objectContaining({
prompt: '移动后的生成器',
status: 'idle',
placeholder: expect.objectContaining({ x: 88 }),
}),
}),
]),
}),
);
});
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');
});
});