/* @vitest-environment jsdom */ import { act, render, screen, waitFor } from '@testing-library/react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; import { DEFAULT_CANVAS_BACKGROUND_COLOR, normalizeCanvasBackgroundHex, } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasViewport, } from './ImageCanvasEditorTypes'; import { useImageCanvasProjectPersistence } from './useImageCanvasProjectPersistence'; const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const createProjectCoverSnapshotBlobMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const putEditorProjectCoverCacheMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY = 'genarrative.imageCanvas.projectSnapshot.v1:recent'; vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => { const actual = await vi.importActual< typeof import('./ImageCanvasProjectCoverSnapshotModel') >('./ImageCanvasProjectCoverSnapshotModel'); return { ...actual, createProjectCoverSnapshotBlob: createProjectCoverSnapshotBlobMock, }; }); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') >('../../services/image-editor/editorProjectClient'); return { ...actual, createEditorProjectResource: createEditorProjectResourceMock, loadEditorProject: loadEditorProjectMock, loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock, saveEditorProjectLayout: saveEditorProjectLayoutMock, }; }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); vi.mock('../../services/image-editor/editorProjectCoverCache', () => ({ putEditorProjectCoverCache: putEditorProjectCoverCacheMock, })); function createLayer( id: string, overrides: Partial = {}, ): CanvasLayer { 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 ProjectPersistenceHarness({ canAccessProtectedData = true, initialCanvasBackgroundColor = DEFAULT_CANVAS_BACKGROUND_COLOR, initialGenerationDialogs = [], onProjectAccessLost, }: { canAccessProtectedData?: boolean; 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 [isViewportInteracting, setIsViewportInteracting] = useState(false); const [projectTitle, setProjectTitle] = useState(''); const [projectRenameValue, setProjectRenameValue] = useState(''); const layersRef = useRef(layers); const viewportRef = useRef(viewport); const canvasGenerationDialogsRef = useRef(generationDialogs); const canvasBackgroundColorRef = useRef(canvasBackgroundColor); const selectedLayerRef = useRef(null); const layerCounterRef = useRef(0); const openEditorLoginModalRef = useRef(vi.fn()); layersRef.current = layers; viewportRef.current = viewport; canvasGenerationDialogsRef.current = generationDialogs; canvasBackgroundColorRef.current = canvasBackgroundColor; const selectSingleLayer = useCallback((layerId: string | null) => { selectedLayerRef.current = layerId; }, []); const setLayerCounter = useCallback((value: number) => { layerCounterRef.current = value; }, []); const applyCanvasBackgroundColor = useCallback((color: string) => { const normalizedColor = normalizeCanvasBackgroundHex(color); if (!normalizedColor) { return false; } setCanvasBackgroundColor(normalizedColor); return true; }, []); const persistenceRefs = useMemo( () => ({ layersRef, viewportRef, canvasGenerationDialogsRef, canvasBackgroundColorRef, }), [], ); const persistenceSetters = useMemo( () => ({ setProjectTitle, setProjectRenameValue, setViewport, setLayers, selectSingleLayer, setLayerCounter, restoreCanvasGenerationDialogs: setGenerationDialogs, applyCanvasBackgroundColor, }), [applyCanvasBackgroundColor, selectSingleLayer, setLayerCounter], ); const persistence = useImageCanvasProjectPersistence({ refs: persistenceRefs, setters: persistenceSetters, layers, canvasGenerationDialogs: generationDialogs, viewport, canvasBackgroundColor, isViewportInteracting, canAccessProtectedData, openEditorLoginModal: openEditorLoginModalRef.current, onProjectAccessLost, }); return (
{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 ?? '-'} {layerCounterRef.current} {viewport.x},{viewport.y},{viewport.scale} {String(isViewportInteracting)} {generationDialogs .map( (dialog) => `${dialog.id}:${dialog.prompt}:${dialog.placeholder?.x}`, ) .join(',')} {canvasBackgroundColor}
); } describe('useImageCanvasProjectPersistence', () => { beforeEach(() => { vi.clearAllMocks(); try { globalThis.sessionStorage?.clear(); } catch { // ignore session storage cleanup errors in jsdom } loadOrCreateRecentEditorProjectMock.mockResolvedValue({ projectId: 'editor-project-default', title: '空画布项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); createEditorProjectResourceMock.mockResolvedValue({ resourceId: 'resource-added-asset-a', projectId: 'editor-project-default', imageSrc: '/generated-character-drafts/editor/assets/asset-a.png', objectKey: 'generated-character-drafts/editor/assets/asset-a.png', assetObjectId: 'asset-object-a', width: 320, height: 240, sourceType: 'uploaded', }); saveEditorProjectLayoutMock.mockResolvedValue({ projectId: 'editor-project-default', title: '空画布项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); createProjectCoverSnapshotBlobMock.mockResolvedValue(null); putEditorProjectCoverCacheMock.mockResolvedValue(undefined); uploadEditorMediaAssetFileMock.mockResolvedValue({ src: 'https://signed.example.test/project-cover.png', objectKey: 'generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png', assetObjectId: 'asset-object-cover', legacyPublicPath: '/generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png', }); }); it('saves appended layers with the server resource id immediately after resource creation', async () => { render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); expect(screen.getByTestId('project-title').textContent).toBe('空画布项目'); expect(screen.getByTestId('project-rename').textContent).toBe('空画布项目'); act(() => { screen.getByRole('button', { name: 'append' }).click(); }); expect(screen.getByTestId('layers').textContent).toBe( 'layer-a:local-layer-a:-:-:generated-character-drafts/editor/assets/asset-a.png:asset-object-a', ); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toBe( 'layer-a:resource-added-asset-a:-:-:generated-character-drafts/editor/assets/asset-a.png:asset-object-a', ); }); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ layerId: 'layer-a', resourceId: 'resource-added-asset-a', sourceAssetId: 'asset-a', objectKey: 'generated-character-drafts/editor/assets/asset-a.png', }), ]), }), ); }); }); it('reloads the authoritative snapshot after a revision conflict without replaying the stale layout', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '初始画布', canvas: { canvasId: 'editor-project-default:canvas:default', projectId: 'editor-project-default', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], revision: 3, layoutStorageVersion: 1, updatedAt: '2026-07-19T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-07-19T00:00:00.000Z', }); saveEditorProjectLayoutMock.mockRejectedValueOnce( new ApiClientError({ message: '图片画布版本冲突', status: 409, code: 'conflict', }), ); loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '其它标签页已保存', canvas: { canvasId: 'editor-project-default:canvas:default', projectId: 'editor-project-default', title: '默认画布', viewport: { x: 12, y: 0, scale: 1 }, layers: [ { layerId: 'layer-remote', resourceId: 'resource-remote', x: 20, y: 30, }, ], revision: 4, layoutStorageVersion: 1, updatedAt: '2026-07-19T00:00:01.000Z', }, viewport: { x: 12, y: 0, scale: 1 }, layers: [ { layerId: 'layer-remote', resourceId: 'resource-remote', x: 20, y: 30, }, ], resources: [ { resourceId: 'resource-remote', projectId: 'editor-project-default', imageSrc: '/generated/remote.png', objectKey: 'generated/remote.png', width: 320, height: 240, sourceType: 'uploaded', }, ], updatedAt: '2026-07-19T00:00:01.000Z', }); render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); act(() => { screen.getByRole('button', { name: 'append server resource' }).click(); }); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledWith( 'editor-project-default', ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-remote:resource-remote', ); }); expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ expectedRevision: 3 }), ); }); it('continues with the latest pending layout when an in-flight save fails', async () => { const firstSave = createDeferred(); 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('retries conflict recovery when the authoritative snapshot reload fails', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '初始画布', canvas: { canvasId: 'editor-project-default:canvas:default', projectId: 'editor-project-default', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], revision: 3, layoutStorageVersion: 1, updatedAt: '2026-07-20T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-07-20T00:00:00.000Z', }); const conflict = new ApiClientError({ message: '图片画布版本冲突', status: 409, code: 'conflict', }); saveEditorProjectLayoutMock .mockRejectedValueOnce(conflict) .mockRejectedValueOnce(conflict); loadEditorProjectMock .mockRejectedValueOnce(new TypeError('connection dropped')) .mockResolvedValueOnce({ projectId: 'editor-project-default', title: '其它标签页已保存', canvas: { canvasId: 'editor-project-default:canvas:default', projectId: 'editor-project-default', title: '默认画布', viewport: { x: 12, y: 0, scale: 1 }, layers: [], revision: 4, layoutStorageVersion: 1, updatedAt: '2026-07-20T00:00:01.000Z', }, viewport: { x: 12, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-07-20T00:00:01.000Z', }); render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); vi.useFakeTimers(); try { act(() => { screen.getByRole('button', { name: 'move viewport' }).click(); }); await act(async () => { vi.advanceTimersByTime(451); await Promise.resolve(); await Promise.resolve(); }); expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); await act(async () => { vi.advanceTimersByTime(1_001); await Promise.resolve(); await Promise.resolve(); }); expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); expect(screen.getByTestId('project-title').textContent).toBe( '其它标签页已保存', ); } finally { vi.useRealTimers(); } }); it('does not retry a permanent layout payload error', async () => { render(); 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', }), ]), }), ); }); const cachedRaw = globalThis.sessionStorage.getItem( EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, ); expect(cachedRaw).toBeTruthy(); expect(cachedRaw).not.toContain('data:image'); const cached = JSON.parse(cachedRaw ?? '{}') as { project?: EditorProjectSnapshot; }; expect(cached.project?.resources).toEqual([ expect.objectContaining({ resourceId: 'resource-added-asset-a', imageSrc: '/generated-character-drafts/editor/assets/asset-a.png', objectKey: 'generated-character-drafts/editor/assets/asset-a.png', assetObjectId: 'asset-object-a', }), ]); }); it('restores the persisted canvas background color without autosaving on load', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '自定义背景画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { itemType: 'canvas-settings', layerId: 'canvas-settings:default', resourceId: 'canvas-settings:default', canvasBackgroundColor: '#112233', }, ], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); render(); 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: '已有封面画布', 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(putEditorProjectCoverCacheMock).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'editor-project-default', blob: expect.any(Blob), }), ); expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ assetKind: 'editor_project_cover_snapshot', pathSegments: [ 'editor', 'project-covers', 'editor-project-default', expect.any(String), ], entityId: 'editor-project-cover-editor-project-default', metadata: { editor_project_id: 'editor-project-default', }, }), ); }); await waitFor(() => { expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ imageSrc: '/generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png', objectKey: 'generated-character-drafts/editor/project-covers/editor-project-default/1771400000000/editor-project-default-cover.png', assetObjectId: 'asset-object-cover', width: 320, height: 240, sourceType: 'uploaded', assetKind: 'project-cover-snapshot', }), ); }); }); it('keeps the local project cover cache when snapshot upload fails', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '本地封面画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-cover', resourceId: 'resource-cover-source', title: '封面源图', x: 10, y: 20, width: 320, height: 240, originalWidth: 320, originalHeight: 240, zIndex: 1, sourceType: 'uploaded', }, ], resources: [ { resourceId: 'resource-cover-source', projectId: 'editor-project-default', imageSrc: '/generated/source.png', objectKey: 'generated/source.png', assetObjectId: 'asset-object-source', width: 320, height: 240, sourceType: 'uploaded', }, ], updatedAt: '2026-06-12T00:00:00.000Z', }); const coverBlob = new Blob(['cover'], { type: 'image/png' }); createProjectCoverSnapshotBlobMock.mockResolvedValueOnce(coverBlob); uploadEditorMediaAssetFileMock.mockRejectedValueOnce( new TypeError('Failed to fetch'), ); render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); await waitFor(() => { expect(putEditorProjectCoverCacheMock).toHaveBeenCalledWith({ projectId: 'editor-project-default', blob: coverBlob, }); }); await waitFor(() => { expect(uploadEditorMediaAssetFileMock).toHaveBeenCalled(); }); expect(createEditorProjectResourceMock).not.toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ assetKind: 'project-cover-snapshot', }), ); }); it('loads and saves viewport scale with display zoom semantics', async () => { render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); expect(screen.getByTestId('viewport').textContent).toBe('0,0,0.5'); act(() => { screen.getByRole('button', { name: 'append server resource' }).click(); }); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ viewport: { x: 0, y: 0, scale: 1 }, }), ); }); }); it('does not serialize or write session cache while viewport interactions are active', async () => { const sessionSetItemSpy = vi.spyOn( Storage.prototype, 'setItem', ); render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); sessionSetItemSpy.mockClear(); saveEditorProjectLayoutMock.mockClear(); vi.useFakeTimers(); act(() => { screen.getByRole('button', { name: 'begin viewport interaction' }).click(); }); expect(screen.getByTestId('viewport-interacting').textContent).toBe('true'); act(() => { screen.getByRole('button', { name: 'move viewport' }).click(); screen.getByRole('button', { name: 'move viewport' }).click(); }); act(() => { vi.advanceTimersByTime(451); }); expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); expect(sessionSetItemSpy).not.toHaveBeenCalled(); act(() => { screen.getByRole('button', { name: 'end viewport interaction' }).click(); }); expect(screen.getByTestId('viewport-interacting').textContent).toBe( 'false', ); act(() => { vi.advanceTimersByTime(451); }); expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ viewport: { x: 20, y: 10, scale: 1 }, }), ); expect(sessionSetItemSpy).toHaveBeenCalled(); vi.useRealTimers(); sessionSetItemSpy.mockRestore(); }); it('does not create a project resource again for backend-persisted generated layers', async () => { render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); act(() => { screen.getByRole('button', { name: 'append server resource' }).click(); }); expect(screen.getByTestId('layers').textContent).toBe( 'layer-server-resource:resource-from-generation-bff:-:-:-:-', ); expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ layerId: 'layer-server-resource', resourceId: 'resource-from-generation-bff', sourceType: 'generated', }), ]), }), ); }); }); it('persists generated layer metadata on the project resource and restores it from resources', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '已有角色项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-character', resourceId: 'resource-character', title: '角色形象', x: 10, y: 20, width: 320, height: 240, originalWidth: 320, originalHeight: 240, zIndex: 3, sourceType: 'generated', }, ], resources: [ { resourceId: 'resource-character', projectId: 'editor-project-default', imageSrc: '/generated/character.png', objectKey: 'generated/character.png', assetObjectId: 'asset-object-character', width: 320, height: 240, sourceType: 'generated', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '银发游侠' }], references: [], }, }, ], updatedAt: '2026-06-12T00:00:00.000Z', }); createEditorProjectResourceMock.mockResolvedValueOnce({ resourceId: 'resource-character-new', projectId: 'editor-project-default', imageSrc: '/generated/character-new.png', objectKey: 'generated/character-new.png', assetObjectId: 'asset-object-character-new', width: 320, height: 240, sourceType: 'generated', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '银发游侠' }], references: [], }, }); render(); 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: '空画布项目', 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: '空画布项目', 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', title: '空画布项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:01.000Z', }); await Promise.resolve(); }); expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); expect(saveEditorProjectLayoutMock).toHaveBeenLastCalledWith( 'editor-project-default', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ itemType: 'generation-dialog', dialog: expect.objectContaining({ id: 'generation-dialog-1', prompt: '第二次移动后的生成器', placeholder: expect.objectContaining({ x: 120 }), }), }), ]), }), ); vi.useRealTimers(); }); it('drops stale pending generating saves when a backend completion snapshot is applied', async () => { const savedDialog: CanvasGenerationDialogState = { id: 'generation-dialog-1', mode: 'generate', prompt: '刷新后继续生成', status: 'generating', composerOpen: false, placeholder: { x: 42, y: 56, width: 420, height: 420, originalWidth: 2048, originalHeight: 2048, }, }; loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { itemType: 'generation-dialog', layerId: 'generation-dialog:generation-dialog-1', resourceId: 'generation-dialog:generation-dialog-1', dialog: savedDialog, }, ], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); vi.useFakeTimers(); act(() => { screen.getByRole('button', { name: 'move generation' }).click(); }); act(() => { screen.getByRole('button', { name: 'apply completed project' }).click(); }); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:后端完成生成器:42', ); expect(screen.getByTestId('layers').textContent).toBe( 'layer-generated:resource-generated:-:-:generated/result.png:asset-object-result', ); act(() => { vi.advanceTimersByTime(451); }); expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); vi.useRealTimers(); }); it('saves generated layers together with their generator snapshots', async () => { const savedDialog: CanvasGenerationDialogState = { id: 'generation-dialog-1', mode: 'generate', prompt: '已生成也要保存生成器', status: 'idle', composerOpen: true, generatedLayerId: 'layer-generated', placeholder: { x: 70, y: 80, width: 420, height: 420, originalWidth: 2048, originalHeight: 2048, }, }; loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { itemType: 'generation-dialog', layerId: 'generation-dialog:generation-dialog-1', resourceId: 'generation-dialog:generation-dialog-1', dialog: savedDialog, }, ], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:已生成也要保存生成器:70', ); act(() => { screen.getByRole('button', { name: 'append generated' }).click(); }); await waitFor(() => { expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ layers: expect.arrayContaining([ expect.objectContaining({ layerId: 'layer-generated', sourceType: 'generated', }), expect.objectContaining({ itemType: 'generation-dialog', dialog: expect.objectContaining({ id: 'generation-dialog-1', generatedLayerId: 'layer-generated', prompt: '已生成也要保存生成器', placeholder: expect.objectContaining({ x: 70, width: 420, }), }), }), ]), }), ); }); }); it('keeps backend layout as the project truth when loading generating dialogs', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '角色画布项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { itemType: 'generation-dialog', layerId: 'generation-dialog:generation-dialog-4', resourceId: 'generation-dialog:generation-dialog-4', dialog: { id: 'generation-dialog-4', mode: 'character', prompt: '挥舞武器', status: 'generating', composerOpen: false, imageModel: 'gpt-image-2', placeholder: { x: 2851, y: -521, width: 1024, height: 1024, originalWidth: 1024, originalHeight: 1024, }, }, }, ], resources: [ { resourceId: 'editor-resource-character', projectId: 'editor-project-default', imageSrc: '/api/assets/read-url?objectKey=character.png', objectKey: 'generated-character-drafts/editor/character-images/vector-engine-edit/image.png', assetObjectId: 'asset-object-character', width: 1254, height: 1254, sourceType: 'generated', prompt: '挥舞武器', actualPrompt: '挥舞武器', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'vector-engine-edit-1', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '挥舞武器' }], references: [], }, updatedAt: '2026-06-21T12:16:22.000Z', }, ], updatedAt: '2026-06-21T12:15:00.000Z', }); render(); 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(), 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: '后端画布项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-12T00:00:02.000Z', }); await backendProjectLoad.promise; }); await waitFor(() => { expect(screen.getByTestId('project-title').textContent).toBe( '后端画布项目', ); }); expect(screen.getByTestId('layers').textContent).toBe(''); expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); }); it('does not load protected project data before login is available', () => { render(); 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'); }); });