/* @vitest-environment jsdom */ import { 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 generateEditorSoundEffectMock = vi.hoisted(() => vi.fn()); const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const renderCropExpandImageMock = vi.hoisted(() => vi.fn()); const removeImageBackgroundMock = vi.hoisted(() => vi.fn()); const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorImageReference') >('../../services/image-editor/editorImageReference'); return { ...actual, resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock, resolveEditorImageReferenceDataUrlForGeneration: 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, generateEditorBackgroundMusic: generateEditorBackgroundMusicMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorSoundEffect: generateEditorSoundEffectMock, }; }); vi.mock('./ImageCanvasRasterEditModel', async () => { const actual = await vi.importActual< typeof import('./ImageCanvasRasterEditModel') >('./ImageCanvasRasterEditModel'); return { ...actual, removeImageBackground: removeImageBackgroundMock, renderCropExpandImage: renderCropExpandImageMock, }; }); 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()], initialViewport = { x: 10, y: 20, scale: 2 }, }: { initialLayers?: CanvasLayer[]; initialViewport?: { x: number; y: number; scale: number }; }) { const [layers, setLayers] = useState(initialLayers); const [viewport, setViewport] = useState(initialViewport); 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 activeDialogRef = useRef(null); const workflow = useImageCanvasGenerationWorkflow({ layers, canvasSize: { width: 900, height: 640 }, viewport, setViewport, setLayers, canvasGenerationDialogs: dialogs.canvasGenerationDialogs, layerCounterRef, generateDialog: dialogs.generateDialog, setGenerateDialog: dialogs.setGenerateDialog, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId: dialogs.removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, archiveActiveCanvasGenerationDialog: dialogs.archiveActiveCanvasGenerationDialog, appendCanvasLayersWithResources: (nextLayers) => setLayers((currentLayers) => [...currentLayers, ...nextLayers]), selectSingleLayer: setSelectedLayerId, fitLayers: fitLayersMockRef.current, captureCanvasHistory: () => {}, setActiveTool, setActiveSidebarPanel, setMetadataLayer, setImageContextMenu, }); const activeDialog = dialogs.generateDialog; activeDialogRef.current = activeDialog; const activeCanvasDialog = activeDialog && 'id' in activeDialog ? (activeDialog as CanvasGenerationDialogState) : null; return (
{activeTool} {activeSidebarPanel ?? '-'} {selectedLayerId ?? '-'} {metadataLayer?.id ?? '-'} {imageContextMenu ? 'open' : '-'} {`${viewport.x}:${viewport.y}:${viewport.scale}`} {activeDialog?.placeholder ? `${activeDialog.placeholder.x}:${activeDialog.placeholder.y}:${activeDialog.placeholder.width}:${activeDialog.placeholder.height}` : '-'} {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' : '-'}` : '-'} {activeDialog?.prompt || '-'} {activeDialog ? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}` : '-'} {dialogs.canvasGenerationDialogs .map( (dialog) => `${dialog.id}:${dialog.mode}:${dialog.composerOpen !== false ? 'open' : 'closed'}`, ) .join('|') || '-'} {activeDialog?.generationReferences ?.map((reference) => reference.label) .join('|') ?? '-'} {activeDialog?.characterSpecReference?.label ?? '-'} {activeDialog?.iconSpecReference?.label ?? '-'} {activeDialog?.uiDesignSpecReference?.label ?? '-'} {workflow.referencePickWarning ?? '-'} {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` : '-'} {workflow.cropExpandPanel ? `${workflow.cropExpandPanel.sourceLayerId}:${workflow.cropExpandPanel.ratio}:${workflow.cropExpandPanel.frame.x}:${workflow.cropExpandPanel.frame.y}:${workflow.cropExpandPanel.frame.width}:${workflow.cropExpandPanel.frame.height}:${workflow.cropExpandPanel.status}` : '-'} {workflow.uiAssetExtractionState ? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}` : '-'} {workflow.uiAssetExtractionSourceLayer?.id ?? '-'} {workflow.quickEditSelectionState ? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool ?? 'none'}:${workflow.quickEditSelectionState.marks.length}` : '-'} {workflow.characterAnimationPanel ? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}` : '-'} {workflow.characterAnimationPrice} {fitLayersMockRef.current.mock.calls.length}
); } describe('useImageCanvasGenerationWorkflow', () => { beforeEach(() => { vi.clearAllMocks(); resolveEditorImageReferenceDataUrlMock.mockImplementation( async (src: string) => src, ); window.localStorage.clear(); }); 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('calculates character animation button price from the model pricing config', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色动画' })); expect(screen.getByTestId('character-animation').textContent).toBe( 'layer-character:idle', ); expect(screen.getByTestId('character-animation-price').textContent).toBe( '40', ); }); it('places a new generation placeholder away from existing canvas images and centers the viewport on it', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); expect(screen.getByTestId('placeholder').textContent).toBe( '-1056:-362:1024:1024', ); expect(screen.getByTestId('viewport').textContent).toBe('1538:20:2'); }); it('places a new video generation placeholder away from existing canvas images and centers the viewport on it', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开视频生成' })); expect(screen.getByTestId('tool').textContent).toBe('video'); expect(screen.getByTestId('selected').textContent).toBe('-'); expect(screen.getByTestId('placeholder').textContent).toBe( '-207:412:854:480', ); expect(screen.getByTestId('viewport').textContent).toBe('10:-984:2'); }); it('opens audio remodel as an audio generation dialog next to the source layer', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开音效改造' })); expect(screen.getByTestId('tool').textContent).toBe('music'); expect(screen.getByTestId('selected').textContent).toBe('layer-sound'); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe( 'audio-sound-effect:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-prompt').textContent).toBe('金币跳出音'); expect(screen.getByTestId('placeholder').textContent).toBe( '572:140:420:120', ); }); it('opens generated layer remodel with user input snapshot instead of internal prompt', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开用户快照改造' })); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-prompt').textContent).toBe( '用户的视频描述', ); }); it('keeps the active canvas generator when opening quick edit for another layer', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:idle:open:-:placeholder', ); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:generate:closed|generation-dialog-2:quick-edit:open', ); }); it('opens image redraw as a new canvas generator next to the source layer', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:idle:open:-:placeholder', ); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('placeholder').textContent).toContain('1024:768'); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:generate:closed|generation-dialog-2:quick-edit:open', ); }); it('keeps source image model and dimensions when opening quick edit', () => { render( , ); fireEvent.click( screen.getByRole('button', { name: '写入源图生成参数快照' }), ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'quick-edit:gpt-image-2:2:3:2K', ); }); it('keeps target generator options when opening redraw', () => { render( , ); fireEvent.click( screen.getByRole('button', { name: '写入源图生成参数快照' }), ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'quick-edit:gpt-image-2:2:3:2K', ); }); it('submits audio remodel with restored sound parameters and appends the new audio layer', async () => { generateEditorSoundEffectMock.mockResolvedValueOnce({ audioSrc: 'data:audio/mpeg;base64,generated', width: 420, height: 120, sourceType: 'generated', prompt: '金币跳出音', actualPrompt: '金币跳出音', model: 'audio1.0', provider: 'VectorEngine', taskId: 'task-sound', audioKind: 'sound-effect', durationSeconds: 4, }); render(); fireEvent.click(screen.getByRole('button', { name: '打开音效改造' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorSoundEffectMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '金币跳出音', model: 'audio1.0', duration: 8, generationInputs: { fields: [ { title: 'prompt', value: '金币跳出音' }, { title: 'model', value: 'audio1.0' }, { title: 'duration', value: '8秒' }, ], references: [], }, }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-audio-1:游戏音效 1:-:sound-effect', ); }); expect(screen.getByTestId('dialog').textContent).toBe( 'audio-sound-effect:idle:open:layer-audio-1:placeholder', ); expect(screen.getByTestId('placeholder').textContent).toBe( '572:140:420:120', ); }); 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: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '一张生成图', model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', projectId: undefined, assetFolderId: undefined, assetKind: undefined, assetLabel: '生成图片 1', generationInputs: { fields: [{ title: '生成提示词', value: '一张生成图' }], references: [], }, }); }); 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:placeholder', ); }); it('reuses the last image ratio and size when reopening character generation and submitting', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '一张生成图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择2比3和2K' })); await waitFor(() => { expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'character:gemini-3.1-flash-image-preview:2:3:2K', ); }); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'character:gemini-3.1-flash-image-preview:2:3:2K', ); fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '一张生成图', kind: 'character', model: 'gemini-3.1-flash-image-preview', aspectRatio: '2:3', imageSize: '2K', }), ); }); }); it('adds picked canvas layers as normal generation references and submits them', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '参考生成图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '选择画布参考图' })); expect(screen.getByTestId('generation-references').textContent).toBe( '画布参考图', ); fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '一张生成图', model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', projectId: undefined, assetFolderId: undefined, assetKind: undefined, assetLabel: '生成图片 1', referenceImageSrcs: ['data:image/png;base64,source'], generationInputs: { fields: [{ title: '生成提示词', value: '一张生成图' }], references: [ { title: '参考图 1', label: '画布参考图', refType: 'project-resource', refId: 'resource-source', }, ], }, }); }); }); it('reuses the last picked character spec when opening the next character material dialog', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '-', ); fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:-:placeholder', ); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); }); it('warns and keeps character spec empty when picked layer is not a spec image', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '-', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是角色规范图,请选择生成规范里的角色规范。', ); fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); }); it('restores the last picked character spec from local cache after remounting the editor', () => { const firstRender = render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); firstRender.unmount(); render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); }); it('reuses uploaded character and icon specs when opening matching material dialogs again', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '模拟上传角色规范' })); await waitFor(() => { expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上传角色规范', ); }); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上传角色规范', ); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-'); fireEvent.click(screen.getByRole('button', { name: '模拟上传图标规范' })); await waitFor(() => { expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上传图标规范', ); }); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'icon:idle:open:-:placeholder', ); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上传图标规范', ); }); it('reuses the last picked icon spec when opening the next icon material dialog', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-'); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'icon:idle:open:-:placeholder', ); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); }); it('warns and keeps icon spec empty when picked layer is not an icon spec image', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); fireEvent.click(screen.getByRole('button', { name: '选择非图标规范图' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是图标规范图,请选择生成规范里的图标规范。', ); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); }); it('restores the last picked icon spec from local cache for icon and UI design dialogs', () => { const firstRender = render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); firstRender.unmount(); render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' })); expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe( '上一张图标规范', ); }); it('shares the cached icon spec between icon material and UI design dialogs', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'ui-design:idle:open:-:placeholder', ); expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe( '上一张图标规范', ); fireEvent.click(screen.getByRole('button', { name: '选择UI图标规范' })); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张UI图标规范', ); }); it('submits spec generation with reference image and reference prompt semantics', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: 'UI规范图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开UI规范' })); fireEvent.click(screen.getByRole('button', { name: '添加规范参考图' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ size: '2048x1152', kind: 'spec', model: 'gpt-image-2', aspectRatio: '16:9', imageSize: '2K', referenceImageSrcs: ['data:image/png;base64,ref'], prompt: expect.stringContaining('参考图生成规范'), }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:图标规范 1', ); }); }); it('opens quick edit with inactive selection tools and keeps tall sources above the panel', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:none:0', ); expect(screen.getByTestId('selected').textContent).toBe('layer-source'); const viewportParts = screen .getByTestId('viewport') .textContent!.split(':') .map(Number); if (viewportParts.length !== 3) { throw new Error('viewport test output should contain x, y and scale'); } const [viewportX, viewportY, viewportScale] = viewportParts as [ number, number, number, ]; const sourceTop = viewportY + 50 * viewportScale; const sourceBottom = sourceTop + 1600 * viewportScale; expect(viewportX).toBeGreaterThan(300); expect(viewportScale).toBeLessThan(1); expect(sourceTop).toBeCloseTo(56, 2); expect(sourceBottom + 12 + 260).toBeLessThanOrEqual(640); }); it('adds numbered quick edit red selections only after enabling a selection tool', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' })); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:none:0', ); fireEvent.click( screen.getByRole('button', { name: '选择快速编辑矩形框选' }), ); fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' })); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:rect:1', ); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); fireEvent.click( screen.getByRole('button', { name: '选择快速编辑椭圆框选' }), ); fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' })); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:ellipse:2', ); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); }); it('submits quick edits beside the source and fits source plus result', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '快速修图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑提示词' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '快速修图', size: '1024x768', aspectRatio: '4:3', imageSize: '1K', kind: 'quick-edit', model: 'gemini-3.1-flash-image-preview', referenceImageSrcs: ['data:image/png;base64,source'], projectId: undefined, assetFolderId: undefined, assetKind: undefined, assetLabel: '源图 快速编辑', sourceResourceId: 'resource-source', generationInputs: { fields: [{ title: '快速编辑提示词', value: '快速修图' }], references: [], }, }); }); 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('opens crop-expand in free ratio and completes as a new beside layer', async () => { renderCropExpandImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,crop-expanded', width: 380, height: 280, }); render(); fireEvent.click(screen.getByRole('button', { name: '打开裁扩' })); expect(screen.getByTestId('crop-expand').textContent).toBe( 'layer-source:free:120:140:320:240:idle', ); fireEvent.click(screen.getByRole('button', { name: '拖拽裁扩边界' })); fireEvent.click(screen.getByRole('button', { name: '完成裁扩' })); await waitFor(() => { expect(renderCropExpandImageMock).toHaveBeenCalledWith({ source: 'data:image/png;base64,source', insets: { left: 32, top: 64, right: 160, bottom: 64, }, }); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-crop-expand-1:源图 裁扩:resource-source', ); }); expect(screen.getByTestId('selected').textContent).toBe( 'layer-crop-expand-1', ); expect(screen.getByTestId('crop-expand').textContent).toBe('-'); expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); it('resolves private character image before removing background', async () => { resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce( 'data:image/png;base64,resolved-character', ); removeImageBackgroundMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,no-background', width: 512, height: 768, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '去除背景' })); await waitFor(() => { expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith( 'generated-character-drafts/editor/private.png', ); }); await waitFor(() => { expect(removeImageBackgroundMock).toHaveBeenCalledWith( 'data:image/png;base64,resolved-character', ); }); expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:源图:-:character', ); }); it('opens UI design extraction as a mark selection state before submitting', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' })); expect(screen.getByTestId('ui-extraction').textContent).toBe( 'layer-source:rect:0:idle', ); expect(screen.getByTestId('ui-extraction-source').textContent).toBe( 'layer-source', ); expect(screen.getByTestId('selected').textContent).toBe('layer-source'); expect(screen.getByTestId('tool').textContent).toBe('select'); fireEvent.click(screen.getByRole('button', { name: '选择椭圆框选' })); fireEvent.click(screen.getByRole('button', { name: '框选UI素材' })); expect(screen.getByTestId('ui-extraction').textContent).toBe( 'layer-source:ellipse:1:idle', ); }); 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('-'); }); fireEvent.click(screen.getByRole('button', { name: '打开修改状态' })); fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' })); fireEvent.click(screen.getByRole('button', { name: '清理源图状态' })); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('ui-extraction').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe('-'); }); it('creates a placed character action generation dialog for character layers', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开普通动画' })); expect(screen.getByTestId('character-animation').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe('-'); fireEvent.click(screen.getByRole('button', { name: '打开角色动画' })); expect(screen.getByTestId('dialog').textContent).toBe( 'character-animation:idle:open:-:placeholder', ); 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', ); }); });