/* @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 { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; 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 createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = 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, createEditorProjectResource: createEditorProjectResourceMock, generateEditorBackgroundMusic: generateEditorBackgroundMusicMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorSoundEffect: generateEditorSoundEffectMock, splitEditorIconSpritesheet: splitEditorIconSpritesheetMock, }; }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); 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 }, projectId, currentUserId, applyProjectSnapshot, }: { initialLayers?: CanvasLayer[]; initialViewport?: { x: number; y: number; scale: number }; projectId?: string; currentUserId?: string; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['applyProjectSnapshot']; }) { 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, projectId, currentUserId, applyProjectSnapshot, }); 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 ?? '-'}:${layer.src}:${layer.taskId ?? '-'}`, ) .join('|')} {layers .map( (layer) => `${layer.id}:${layer.resourceId}:${layer.objectKey ?? '-'}:${layer.assetObjectId ?? '-'}`, ) .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.generationWarning ?? '-'} {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` : '-'} {workflow.quickEditPanel ? `${workflow.quickEditPanel.mode ?? 'quick-edit'}:${workflow.quickEditPanel.model}:${workflow.quickEditPanel.aspectRatio ?? '-'}:${workflow.quickEditPanel.imageSize ?? '-'}` : '-'} {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.uiAssetExtractionState.model}` : '-'} {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.characterAnimationPanel?.assetLabel ?? '-'}:${activeDialog?.assetLabel ?? '-'}`} {workflow.characterAnimationPrice} {fitLayersMockRef.current.mock.calls.length} {workflow.taskListRefreshKey} {workflow.splittingIconSpritesheetLayerIds.has(layers[0]!.id) ? '拆图中' : '空闲'}
); } describe('useImageCanvasGenerationWorkflow', () => { beforeEach(() => { vi.clearAllMocks(); resolveEditorImageReferenceDataUrlMock.mockImplementation( async (src: string) => src, ); uploadEditorMediaAssetFileMock.mockResolvedValue({ src: 'https://signed.example.test/crop-expand.png', objectKey: 'generated-character-drafts/editor/crop-expand/project-1/result.png', assetObjectId: 'asset-object-crop-expand', legacyPublicPath: '/generated-character-drafts/editor/crop-expand/project-1/result.png', }); createEditorProjectResourceMock.mockResolvedValue({ resourceId: 'resource-crop-expand', projectId: 'project-1', imageSrc: '/generated-character-drafts/editor/crop-expand/project-1/result.png', objectKey: 'generated-character-drafts/editor/crop-expand/project-1/result.png', assetObjectId: 'asset-object-crop-expand', width: 380, height: 280, sourceType: 'generated', sourceResourceId: 'resource-source', }); 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('opens quick edit for generated character images', () => { render(); fireEvent.click(screen.getByRole('button', { name: '快速编辑角色图' })); expect(screen.getByTestId('tool').textContent).toBe('generate'); expect(screen.getByTestId('selected').textContent).toBe( 'layer-character-generated', ); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-character-generated:idle:-', ); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-character-generated:none:0', ); }); it('opens the source generator for generated publication redraw', () => { render(); fireEvent.click(screen.getByRole('button', { name: '改造宣发图' })); expect(screen.getByTestId('tool').textContent).toBe('publication'); expect(screen.getByTestId('dialog').textContent).toBe( 'publication:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-prompt').textContent).toBe( '游戏名:星海远征\n游戏分类:冒险\n一句话描述游戏:驾驶飞船探索星云', ); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'publication:gpt-image-2:9:16:1K', ); }); it('opens quick edit for plain generated images with source model parameters', () => { render(); fireEvent.click( screen.getByRole('button', { name: '写入普通图来源生成器' }), ); fireEvent.click(screen.getByRole('button', { name: '快速编辑来源普通图' })); expect(screen.getByTestId('tool').textContent).toBe('generate'); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); expect(screen.getByTestId('quick-edit-image-options').textContent).toBe( 'quick-edit:gpt-image-2:2:3:2K', ); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:none:0', ); }); it('keeps plain images without a source generator on the quick edit panel', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('tool').textContent).toBe('generate'); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:none:0', ); }); it('opens quick edit for icon spritesheets', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); }); 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 focuses 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 focuses 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('-'); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:generate:closed', ); }); 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('prefers the source generation parameter snapshot when opening quick edit', () => { render( , ); fireEvent.click( screen.getByRole('button', { name: '写入源图生成参数快照' }), ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit-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( 'generate:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'generate:gpt-image-2:2:3:2K', ); }); it('ignores a derived quick edit dialog when restoring the source generator', () => { render(); fireEvent.click( screen.getByRole('button', { name: '写入普通图来源生成器' }), ); fireEvent.click( screen.getByRole('button', { name: '写入派生快速编辑生成器' }), ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'generate: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: ['resource-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('keeps reference pick warnings visible until the user takes a follow-up action', () => { vi.useFakeTimers(); try { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是角色规范图,请选择生成规范里的角色规范。', ); act(() => { vi.advanceTimersByTime(10_000); }); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是角色规范图,请选择生成规范里的角色规范。', ); } finally { vi.useRealTimers(); } }); 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('does not reuse cached spec references written by another user', () => { const firstRender = render( , ); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); firstRender.unmount(); render(); 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: '打开UI设计生成' })); expect(screen.getByTestId('ui-design-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 () => { uploadEditorMediaAssetFileMock.mockResolvedValueOnce({ src: 'https://signed.example.test/generation-reference.png', objectKey: 'generated-character-drafts/editor/generation-references/reference.png', assetObjectId: 'asset-object-generation-reference', legacyPublicPath: '/generated-character-drafts/editor/generation-references/reference.png', }); 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: [ 'generated-character-drafts/editor/generation-references/reference.png', ], 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( 'layer-source:idle:-', ); 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( 'layer-source:idle:对1号红色圈选框里的内容做以下修改:', ); 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( 'layer-source:idle:对1号红色圈选框里的内容做以下修改:\n对2号红色圈选框里的内容做以下修改:', ); }); it('submits quick edits and updates the source layer directly', async () => { editEditorImageMock.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(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '快速修图', sourceImageSrc: 'resource-source', assetLabel: '源图 快速编辑', sourceResourceId: 'resource-source', generationInputs: { fields: [{ title: '快速编辑提示词', value: '快速修图' }], references: [ { title: '原图', label: '源图', refType: 'project-resource', refId: 'resource-source', }, ], }, }), ); }); expect(generateEditorImageMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:源图 快速编辑:resource-source:-:data:image/png;base64,generated:task-generated', ); }); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('fit-count').textContent).toBe('0'); }); 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('uploads a project crop-expand result before adding it to the canvas', async () => { renderCropExpandImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,Y3JvcC1leHBhbmRlZA==', width: 380, height: 280, }); render(); fireEvent.click(screen.getByRole('button', { name: '打开裁扩' })); fireEvent.click(screen.getByRole('button', { name: '拖拽裁扩边界' })); fireEvent.click(screen.getByRole('button', { name: '完成裁扩' })); await waitFor(() => { expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ assetKind: 'editor_crop_expand_image', entityId: 'project-1', metadata: { editor_project_id: 'project-1', source_resource_id: 'resource-source', }, }), ); }); const uploadCallOrder = uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0]; const resourceCreateCallOrder = createEditorProjectResourceMock.mock.invocationCallOrder[0]; expect(uploadCallOrder).toBeDefined(); expect(resourceCreateCallOrder).toBeDefined(); expect(uploadCallOrder!).toBeLessThan(resourceCreateCallOrder!); expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'project-1', expect.objectContaining({ imageSrc: '/generated-character-drafts/editor/crop-expand/project-1/result.png', objectKey: 'generated-character-drafts/editor/crop-expand/project-1/result.png', assetObjectId: 'asset-object-crop-expand', width: 380, height: 280, sourceType: 'generated', sourceResourceId: 'resource-source', }), ); await waitFor(() => { expect(screen.getByTestId('layer-resources').textContent).toContain( 'layer-crop-expand-1:resource-crop-expand:generated-character-drafts/editor/crop-expand/project-1/result.png:asset-object-crop-expand', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-crop-expand-1:源图 裁扩:resource-source:-:data:image', ); }); it('splits an icon spritesheet through the project endpoint and applies the project snapshot', async () => { const applyProjectSnapshot = vi.fn(); const project = { projectId: 'project-1', title: '未命名画布', canvas: { canvasId: 'project-1:canvas:default', projectId: 'project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-07-13T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-07-13T00:00:00.000Z', }; splitEditorIconSpritesheetMock.mockResolvedValueOnce({ iconImageSrcs: [], project, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '拆分图集' })); await waitFor(() => { expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith({ projectId: 'project-1', sourceResourceId: 'resource-source', assetFolderId: undefined, canvasCompletion: { title: '拆分图集', placeholder: { x: 120, y: 140, width: 320, height: 240, originalWidth: 1024, originalHeight: 1024, }, }, }); }); expect(applyProjectSnapshot).toHaveBeenCalledWith(project, { type: 'split-atlas', count: 1, }); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); }); it('exposes a splitting state while the manual atlas request is pending', async () => { let resolveSplit: | ((value: { iconImageSrcs: never[]; project: unknown }) => void) | undefined; splitEditorIconSpritesheetMock.mockImplementationOnce( () => new Promise((resolve) => { resolveSplit = resolve; }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '拆分图集' })); fireEvent.click(screen.getByRole('button', { name: '拆分图集' })); expect(screen.getByRole('status', { name: '拆图状态' }).textContent).toBe( '拆图中', ); expect(splitEditorIconSpritesheetMock).toHaveBeenCalledTimes(1); resolveSplit?.({ iconImageSrcs: [], project: { projectId: 'project-1', title: '未命名画布', canvas: { canvasId: 'project-1:canvas:default', projectId: 'project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-07-18T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-07-18T00:00:00.000Z', }, }); await waitFor(() => { expect( screen.getByRole('status', { name: '拆图状态' }).textContent, ).toBe('空闲'); }); }); it('queues background removal for private character images', async () => { resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce( 'data:image/png;base64,resolved-character', ); removeImageBackgroundMock.mockResolvedValueOnce({ queueState: { operationId: 'background-removal-task', status: 'completed', phaseLabel: '已完成', phaseDetail: '已完成', progress: 100, updatedAtMicros: 1, }, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '去除背景' })); await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled()); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); await waitFor(() => { expect(removeImageBackgroundMock).toHaveBeenCalledWith({ sourceImageSrc: 'generated-character-drafts/editor/private.png', projectId: undefined, targetLayerId: 'layer-source', assetKind: 'character', generationInputs: undefined, assetFolderId: undefined, assetLabel: '源图 去背景', sourceResourceId: 'resource-source', }); }); await waitFor(() => { expect(screen.getByTestId('task-refresh-key').textContent).toBe('1'); }); expect(screen.getByTestId('layers').textContent).toContain( '/api/assets/read-url?objectKey=generated-character-drafts/editor/private.png', ); expect(screen.getByTestId('layers').textContent).not.toContain( 'background-removal-task', ); }); it('opens a generating canvas placeholder when removing background in a project', async () => { removeImageBackgroundMock.mockImplementationOnce( () => new Promise(() => {}), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '去除背景' })); await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled()); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:generating:closed:-:placeholder', ); expect(screen.getByTestId('generation-dialogs').textContent).toContain( 'generation-dialog-1:quick-edit:closed', ); expect(removeImageBackgroundMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'resource-source', projectId: 'project-1', targetLayerId: 'layer-source', assetLabel: '源图 去背景', canvasCompletion: expect.objectContaining({ dialogId: 'generation-dialog-1', title: '源图 去背景', placeholder: expect.objectContaining({ width: 320, height: 240, }), }), }), ); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); }); 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:gemini-3.1-flash-image-preview', ); 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: '选择GPT提取模型' })); fireEvent.click(screen.getByRole('button', { name: '框选UI素材' })); expect(screen.getByTestId('ui-extraction').textContent).toBe( 'layer-source:ellipse:1:idle:gpt-image-2', ); }); 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: '打开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('reopens the character generator when remodeling after opening character animation', () => { render( , ); fireEvent.click( screen.getByRole('button', { name: '对当前图层生成动作' }), ); expect(screen.getByTestId('dialog').textContent).toBe( 'character-animation:idle:open:-:placeholder', ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:-:placeholder', ); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:character-animation:closed|generation-dialog-2:character:open', ); }); it('reopens character animation remodeling from an action result layer', () => { render( , ); 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', ); }); it('warns when an action result has lost its source character layer', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '未找到角色动作关联的原角色图层,无法改造。', ); }); it('keeps character animation asset names synchronized with the dialog', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色动画' })); fireEvent.click(screen.getByRole('button', { name: '命名角色动画' })); expect(screen.getByTestId('character-animation-name').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', ); }); });