/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasTool, GenerateDialogState, ImageContextMenuState, SidebarPanel, } from './ImageCanvasEditorTypes'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { useImageCanvasGenerationWorkflow } from './useImageCanvasGenerationWorkflow'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', () => ({ resolveEditorImageReferenceDataUrl: vi.fn(async (src: string) => src), })); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') >('../../services/image-editor/editorProjectClient'); return { ...actual, editEditorImage: editEditorImageMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, }; }); function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-source', resourceId: 'resource-source', title: '源图', src: 'data:image/png;base64,source', x: 120, y: 140, width: 320, height: 240, originalWidth: 1024, originalHeight: 768, zIndex: 2, sourceType: 'uploaded', ...overrides, }; } function createGenerated(overrides = {}) { return { imageSrc: 'data:image/png;base64,generated', width: 1024, height: 1024, sourceType: 'generated' as const, prompt: '生成提示词', actualPrompt: '生成提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-generated', ...overrides, }; } function GenerationWorkflowHarness({ initialLayers = [createLayer()], }: { initialLayers?: CanvasLayer[]; }) { const [layers, setLayers] = useState(initialLayers); const [activeTool, setActiveTool] = useState('select'); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); const [selectedLayerId, setSelectedLayerId] = useState(null); const [metadataLayer, setMetadataLayer] = useState(null); const [imageContextMenu, setImageContextMenu] = useState({ layerId: 'layer-source', x: 1, y: 2, }); const fitLayersMockRef = useRef(vi.fn()); const layerCounterRef = useRef(0); const dialogs = useCanvasGenerationDialogs(); const workflow = useImageCanvasGenerationWorkflow({ layers, canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, layerCounterRef, generateDialog: dialogs.generateDialog, setGenerateDialog: dialogs.setGenerateDialog, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, removeCanvasGenerationDialogById: dialogs.removeCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId: dialogs.removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, appendCanvasLayersWithResources: (nextLayers) => setLayers((currentLayers) => [...currentLayers, ...nextLayers]), selectSingleLayer: setSelectedLayerId, fitLayers: fitLayersMockRef.current, setActiveTool, setActiveSidebarPanel, setMetadataLayer, setImageContextMenu, }); const activeDialog = dialogs.generateDialog; const activeCanvasDialog = activeDialog && 'id' in activeDialog ? (activeDialog as CanvasGenerationDialogState) : null; return (
{activeTool} {activeSidebarPanel ?? '-'} {selectedLayerId ?? '-'} {metadataLayer?.id ?? '-'} {imageContextMenu ? 'open' : '-'} {layers .map( (layer) => `${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}`, ) .join('|')} {activeDialog ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}` : '-'} {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` : '-'} {workflow.characterAnimationPanel ? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}` : '-'} {fitLayersMockRef.current.mock.calls.length}
); } describe('useImageCanvasGenerationWorkflow', () => { beforeEach(() => { vi.clearAllMocks(); }); it('opens a movable canvas generation placeholder and keeps toolbar state active', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); expect(screen.getByTestId('tool').textContent).toBe('generate'); expect(screen.getByTestId('selected').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:-:placeholder', ); }); it('submits a normal generation, appends the generated layer, and keeps the composer anchored', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '一张生成图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '一张生成图', }); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:generating:closed:-:placeholder', ); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:生成图片 1', ); }); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); expect(screen.getByTestId('selected').textContent).toBe( 'layer-generated-1', ); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:layer-generated-1:-', ); }); it('submits quick edits beside the source and fits source plus result', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '快速修图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); await waitFor(() => { expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); }); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); await waitFor(() => { expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:快速修图', ); }); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '快速修图', size: '1024x768', kind: 'quick-edit', model: 'gpt-image-2', referenceImageSrcs: ['data:image/png;base64,source'], }); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-quick-edit-1:源图 快速编辑:resource-source', ); }); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); it('clears generation side panels and linked edit dialogs when a source layer is deleted', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); await waitFor(() => { expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); }); fireEvent.click(screen.getByRole('button', { name: '打开修改状态' })); fireEvent.click(screen.getByRole('button', { name: '清理源图状态' })); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe('-'); }); it('only opens the character animation panel for character layers', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开普通动画' })); expect(screen.getByTestId('character-animation').textContent).toBe('-'); fireEvent.click(screen.getByRole('button', { name: '打开角色动画' })); expect(screen.getByTestId('character-animation').textContent).toBe( 'layer-character:idle', ); expect(screen.getByTestId('image-context').textContent).toBe('-'); }); it('hides non-generating canvas generation composers without deleting placeholders', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '隐藏生成面板' })); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:closed:-:placeholder', ); }); });