3d8e0211db
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>
3900 lines
124 KiB
TypeScript
3900 lines
124 KiB
TypeScript
/* @vitest-environment jsdom */
|
||
|
||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import { ApiClientError } from '../../services/apiClient';
|
||
import type {
|
||
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');
|
||
});
|
||
});
|