/* @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 { 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() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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([]); const [generationDialogs, setGenerationDialogs] = useState< CanvasGenerationDialogState[] >(initialGenerationDialogs); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState( initialCanvasBackgroundColor, ); const [viewport, setViewport] = useState({ 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(null); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const layersRef = useRef(layers); const viewportRef = useRef(viewport); const canvasGenerationDialogsRef = useRef(generationDialogs); const canvasBackgroundColorRef = useRef(canvasBackgroundColor); const selectedLayerRef = useRef(selectedLayerId); const selectedLayerIdsRef = useRef(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 (
{persistence.projectId ?? '-'} {projectTitle} {projectRenameValue} {layers .map( (layer) => `${layer.id}:${layer.resourceId}:${layer.assetKind ?? '-'}:${ layer.generationInputs?.fields[0]?.value ?? '-' }:${layer.objectKey ?? '-'}:${layer.assetObjectId ?? '-'}`, ) .join(',')} {selectedLayerRef.current ?? '-'} {selectedLayerIdsRef.current.join(',') || '-'} {layerCounterRef.current} {viewport.x},{viewport.y},{viewport.scale} {String(isViewportInteracting)} {generationDialogs .map( (dialog) => `${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`, ) .join(',')} {canvasBackgroundColor} {persistence.assetKindFallbackNotice?.message ?? '-'} {String(flushCompleted)}
); } 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([]); const [viewport, setViewport] = useState({ x: 0, y: 0, scale: 1, }); const [canvasBackgroundColor, setCanvasBackgroundColor] = useState( DEFAULT_CANVAS_BACKGROUND_COLOR, ); const [selectedLayerId, setSelectedLayerId] = useState(null); const [selectedLayerIds, setSelectedLayerIds] = useState([]); const [strictStatus, setStrictStatus] = useState< 'idle' | 'pending' | 'resolved' | 'rejected' >('idle'); const [downstreamPostCount, setDownstreamPostCount] = useState(0); const dialogs = useCanvasGenerationDialogs(); const automaticGenerationDialogs = useMemo( () => [], [], ); 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 (
{persistence.projectId ?? '-'} {strictStatus} {downstreamPostCount}
); } 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(); 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(); 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(); 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(); 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 }).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(); 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( , ); 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(); 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(); createEditorProjectResourceMock.mockReturnValueOnce(resourceCreate.promise); const { rerender } = render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); rerender(); 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(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); const staleReload = createDeferred(); await act(async () => { rerender(); await Promise.resolve(); }); loadOrCreateRecentEditorProjectMock.mockReturnValueOnce( staleReload.promise, ); await act(async () => { rerender(); 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(); 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( , ); act(() => { screen.getByRole('button', { name: 'append' }).click(); }); expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); rerender(); 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(); render(); 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(); 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(); await Promise.resolve(); }); await act(async () => { rerender(); 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( , ); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); createProjectCoverSnapshotBlobMock.mockResolvedValue(coverBlob); putEditorProjectCoverCacheMock.mockReturnValue(coverCacheWrite.promise); render(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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( , ); 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(); loadOrCreateRecentEditorProjectMock.mockReturnValueOnce( secondBackendLoad.promise, ); saveEditorProjectLayoutMock.mockClear(); vi.useFakeTimers(); await act(async () => { rerender(); 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(); 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(); 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(); 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( , ); await waitFor(() => { expect(onProjectAccessLost).toHaveBeenCalledTimes(1); }); expect(loadEditorProjectMock).toHaveBeenCalledWith('missing-project'); }); });