/* @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 { ApiClientError } from '../../services/apiClient'; import type { CanvasGenerationDialogState, CanvasHistoryAction, CanvasLayer, CanvasTool, CharacterAnimationPanelState, GenerateDialogState, QuickEditPanelState, SidebarPanel, } from './ImageCanvasEditorTypes'; import { createQuickEditPanelDraft } from './ImageCanvasGenerationDialogModel'; import type { UiAssetExtractionState } from './ImageCanvasUiAssetExtractionModel'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { applyQueuedEditorGenerationProject, resolveEditorGenerationMediaReference, useImageCanvasGenerationSubmissionWorkflow, } from './useImageCanvasGenerationSubmissionWorkflow'; const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); const resolveEditorImageReferenceDataUrlForGenerationMock = vi.hoisted(() => vi.fn(), ); const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn()); const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorVideoMock = vi.hoisted(() => vi.fn()); const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn()); const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const getExternalGenerationJobStatusMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', () => ({ resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock, resolveEditorImageReferenceDataUrlForGeneration: resolveEditorImageReferenceDataUrlForGenerationMock, })); 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, extractEditorUiDesignAssets: extractEditorUiDesignAssetsMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorVideo: generateEditorVideoMock, generateEditorSoundEffect: generateEditorSoundEffectMock, generateEditorBackgroundMusic: generateEditorBackgroundMusicMock, loadEditorProject: loadEditorProjectMock, }; }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); vi.mock('../../services/external-generation', () => ({ getExternalGenerationJobStatus: getExternalGenerationJobStatusMock, })); vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({ renderUiDesignAssetExtractionMarkedImage: renderUiDesignAssetExtractionMarkedImageMock, })); function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-source', resourceId: 'resource-source', title: '源图', src: 'https://assets.example.test/source.png', 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 createVideoGenerated(overrides = {}) { return { videoSrc: 'https://assets.example.test/generated-video.mp4', width: 854, height: 480, sourceType: 'generated' as const, prompt: '视频提示词', actualPrompt: '视频提示词', model: 'seedance2.0-fast', provider: 'ark', taskId: 'task-video-generated', ...overrides, }; } function createAudioGenerated(overrides = {}) { return { audioSrc: 'https://assets.example.test/generated-audio.mp3', width: 420, height: 120, sourceType: 'generated' as const, prompt: '金币掉落叮当声', actualPrompt: '金币掉落叮当声', model: 'audio1.0', provider: 'VectorEngine', taskId: 'task-audio-generated', priceMudPoints: 5, audioKind: 'sound-effect' as const, durationSeconds: 7, ...overrides, }; } function createIconResult(name: string, imageSrc: string) { return { name, imageSrc, width: 128, height: 128, }; } function createQueueState( overrides: Partial<{ operationId: string; status: 'queued' | 'running' | 'completed' | 'failed'; phaseLabel: string; phaseDetail: string; progress: number; error: string | null; warning: string | null; updatedAtMicros: number; }> = {}, ) { return { operationId: 'editor-generation-operation-1', status: 'completed' as const, phaseLabel: '完成', phaseDetail: '', progress: 1, error: null, updatedAtMicros: 1, ...overrides, }; } function SubmissionWorkflowHarness({ initialDialog = null, initialQuickEditPanel = null, initialQuickEditSelectionState = null, initialCharacterAnimationPanel = null, initialLayers = [createLayer()], projectId = undefined, assetFolderId = projectId ? 'project' : undefined, upsertGeneratedAsset = undefined, applyProjectSnapshot = undefined, onQueuedGenerationTask = undefined, onWalletBalanceMayHaveChanged = undefined, captureCanvasHistory = () => undefined, fitLayers = undefined, onAppendCanvasLayers = undefined, }: { initialDialog?: GenerateDialogState | null; initialQuickEditPanel?: QuickEditPanelState | null; initialQuickEditSelectionState?: UiAssetExtractionState | null; initialCharacterAnimationPanel?: CharacterAnimationPanelState | null; initialLayers?: CanvasLayer[]; projectId?: string | null; assetFolderId?: string | null; upsertGeneratedAsset?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['upsertGeneratedAsset']; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['applyProjectSnapshot']; onQueuedGenerationTask?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['onQueuedGenerationTask']; onWalletBalanceMayHaveChanged?: Parameters< typeof useImageCanvasGenerationSubmissionWorkflow >[0]['onWalletBalanceMayHaveChanged']; captureCanvasHistory?: (action: CanvasHistoryAction) => void; fitLayers?: ( targetLayers?: CanvasLayer[], options?: { captureHistory?: boolean }, ) => void; onAppendCanvasLayers?: (nextLayers: CanvasLayer[]) => void; }) { const [layers, setLayers] = useState(initialLayers); const [activeTool, setActiveTool] = useState('generate'); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); const [selectedLayerId, setSelectedLayerId] = useState(null); const [quickEditPanel, setQuickEditPanel] = useState(initialQuickEditPanel); const [characterAnimationPanel, setCharacterAnimationPanel] = useState( initialCharacterAnimationPanel, ); const [generationWarning, setGenerationWarning] = useState( null, ); const rememberedImageModelRef = useRef(null); const fitLayersRef = useRef(fitLayers); fitLayersRef.current = fitLayers; const fitLayersMockRef = useRef( vi.fn( (targetLayers?: CanvasLayer[], options?: { captureHistory?: boolean }) => fitLayersRef.current?.(targetLayers, options), ), ); const layerCounterRef = useRef(0); const dialogs = useCanvasGenerationDialogs(); const sourceLayer = layers[0] ?? null; const quickEditSourceLayer = quickEditPanel ? (layers.find((layer) => layer.id === quickEditPanel.sourceLayerId) ?? null) : null; const characterAnimationSourceLayer = characterAnimationPanel ? (layers.find( (layer) => layer.id === characterAnimationPanel.sourceLayerId, ) ?? null) : null; const activeDialog = dialogs.generateDialog; const activeCanvasDialog = activeDialog && 'id' in activeDialog ? (activeDialog as CanvasGenerationDialogState) : null; const workflow = useImageCanvasGenerationSubmissionWorkflow({ layers, canvasSize: { width: 900, height: 640 }, viewport: { x: 10, y: 20, scale: 2 }, layerCounterRef, quickEditPanel, quickEditSourceLayer, quickEditSelectionState: initialQuickEditSelectionState, canvasGenerationDialogs: dialogs.canvasGenerationDialogs, setQuickEditPanel, characterAnimationPanel, characterAnimationDialog: activeCanvasDialog, characterAnimationSourceLayer, setCharacterAnimationPanel, setGenerateDialog: dialogs.setGenerateDialog, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, appendCanvasLayersWithResources: (nextLayers) => { onAppendCanvasLayers?.(nextLayers); setLayers((currentLayers) => [...currentLayers, ...nextLayers]); }, captureCanvasHistory, updateSourceLayer: (sourceLayerId, updater) => setLayers((currentLayers) => currentLayers.map((layer) => layer.id === sourceLayerId ? updater(layer) : layer, ), ), selectSingleLayer: setSelectedLayerId, fitLayers: fitLayersMockRef.current, setActiveTool, setActiveSidebarPanel, rememberImageModel: (imageModel) => { rememberedImageModelRef.current = imageModel; }, projectId, assetFolderId, upsertGeneratedAsset, applyProjectSnapshot, onQueuedGenerationTask, onWalletBalanceMayHaveChanged, onGenerationWarning: setGenerationWarning, }); return (
{activeTool} {activeSidebarPanel ?? '-'} {selectedLayerId ?? '-'} {layers .map( (layer) => `${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.mediaType ?? '-'}:${layer.src}:${layer.thumbnailSrc ?? '-'}:${layer.durationSeconds ?? '-'}`, ) .join('|')} {layers .map( (layer) => `${layer.id}:${layer.x}:${layer.y}:${layer.width}:${layer.height}`, ) .join('|')} {activeDialog ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}:${activeDialog.errorMessage ?? '-'}` : '-'} {quickEditPanel ? `${quickEditPanel.sourceLayerId}:${quickEditPanel.status}:${quickEditPanel.prompt || '-'}:${quickEditPanel.errorMessage ?? '-'}` : '-'} {characterAnimationPanel ? `${characterAnimationPanel.sourceLayerId}:${characterAnimationPanel.status}:${characterAnimationPanel.promptText || '-'}:${characterAnimationPanel.errorMessage ?? '-'}:${characterAnimationPanel.result ? 'result' : '-'}` : '-'} {fitLayersMockRef.current.mock.calls.length} {rememberedImageModelRef.current ?? '-'} {generationWarning ?? '-'}
); } describe('useImageCanvasGenerationSubmissionWorkflow', () => { beforeEach(() => { resolveEditorImageReferenceDataUrlMock.mockReset(); resolveEditorImageReferenceDataUrlForGenerationMock.mockReset(); uploadEditorMediaAssetObjectFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockReset(); editEditorImageMock.mockReset(); extractEditorUiDesignAssetsMock.mockReset(); renderUiDesignAssetExtractionMarkedImageMock.mockReset(); generateEditorCharacterAnimationMock.mockReset(); generateEditorIconSpritesheetMock.mockReset(); generateEditorImageMock.mockReset(); generateEditorVideoMock.mockReset(); generateEditorSoundEffectMock.mockReset(); generateEditorBackgroundMusicMock.mockReset(); loadEditorProjectMock.mockReset(); getExternalGenerationJobStatusMock.mockReset(); resolveEditorImageReferenceDataUrlMock.mockImplementation( async (src: string) => `data:image/png;base64,${src.split('/').pop()}`, ); resolveEditorImageReferenceDataUrlForGenerationMock.mockImplementation( async (src: string) => src, ); uploadEditorMediaAssetObjectFileMock.mockResolvedValue({ objectKey: 'generated-character-drafts/editor/generation-references/reference.png', assetObjectId: 'asset-object-generation-reference', legacyPublicPath: '/generated-character-drafts/editor/generation-references/reference.png', }); renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue( 'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==', ); }); it('uses distinct upload paths for concurrent inline references in the same millisecond', async () => { const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_752_144_000_000); try { await Promise.all([ resolveEditorGenerationMediaReference( { src: 'data:image/png;base64,YQ==' }, 'image', 'project-1', ), resolveEditorGenerationMediaReference( { src: 'data:image/png;base64,Yg==' }, 'image', 'project-1', ), ]); } finally { dateNowSpy.mockRestore(); } expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2); const [firstFile, , firstOptions] = uploadEditorMediaAssetObjectFileMock.mock.calls[0] ?? []; const [secondFile, , secondOptions] = uploadEditorMediaAssetObjectFileMock.mock.calls[1] ?? []; expect((firstFile as File).name).not.toBe((secondFile as File).name); expect(firstOptions.pathSegments).not.toEqual(secondOptions.pathSegments); }); it('reuses a caller supplied upload id for the same inline operation', async () => { const options = { uploadId: 'perfect-pixel-operation-1' }; await resolveEditorGenerationMediaReference( { src: 'data:image/png;base64,YQ==' }, 'image', 'project-1', options, ); await resolveEditorGenerationMediaReference( { src: 'data:image/png;base64,YQ==' }, 'image', 'project-1', options, ); expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(2); for (const [ file, mediaType, uploadOptions, ] of uploadEditorMediaAssetObjectFileMock.mock.calls) { expect((file as File).name).toBe( 'generation-reference-perfect-pixel-operation-1.png', ); expect(mediaType).toBe('image'); expect(uploadOptions).toEqual( expect.objectContaining({ pathSegments: [ 'editor', 'generation-references', 'project-1', 'perfect-pixel-operation-1', ], }), ); } }); it('uploads image references without an object reference before generation', async () => { const controller = new AbortController(); resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce( 'data:image/png;base64,ZXhhbXBsZQ==', ); const result = await resolveEditorGenerationMediaReference( { src: '/creation-type-references/example.webp' }, 'image', 'project-1', { signal: controller.signal, uploadId: 'perfect-pixel-operation-1', }, ); expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith( '/creation-type-references/example.webp', controller.signal, ); expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ assetKind: 'editor_generation_reference_image', signal: controller.signal, }), ); expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); expect(result).toBe( 'generated-character-drafts/editor/generation-references/reference.png', ); }); it('passes the abort signal through blob fetch and object registration', async () => { const controller = new AbortController(); const blobMock = vi .fn() .mockResolvedValue(new Blob(['video'], { type: 'video/mp4' })); const fetchMock = vi.fn().mockResolvedValue({ ok: true, blob: blobMock, }); vi.stubGlobal('fetch', fetchMock); try { await resolveEditorGenerationMediaReference( { src: 'blob:https://editor.example.test/reference-video' }, 'video', 'project-1', { signal: controller.signal, uploadId: 'video-operation-1', }, ); } finally { vi.unstubAllGlobals(); } expect(fetchMock).toHaveBeenCalledWith( 'blob:https://editor.example.test/reference-video', { signal: controller.signal }, ); expect(blobMock).toHaveBeenCalledTimes(1); expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'video', expect.objectContaining({ pathSegments: [ 'editor', 'generation-references', 'project-1', 'video-operation-1', ], signal: controller.signal, }), ); }); it('does not parse or upload an inline Data URL after cancellation', async () => { const controller = new AbortController(); controller.abort(new DOMException('已取消', 'AbortError')); await expect( resolveEditorGenerationMediaReference( { src: 'data:image/png;base64,YQ==' }, 'image', 'project-1', { signal: controller.signal, uploadId: 'cancelled-operation', }, ), ).rejects.toMatchObject({ name: 'AbortError' }); expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); }); it('stops after image source parsing when cancellation wins the boundary', async () => { const controller = new AbortController(); let finishImageParsing!: (value: string) => void; resolveEditorImageReferenceDataUrlMock.mockImplementationOnce( () => new Promise((resolve) => { finishImageParsing = resolve; }), ); const resolution = resolveEditorGenerationMediaReference( { src: '/creation-type-references/slow.webp' }, 'image', 'project-1', { signal: controller.signal, uploadId: 'cancelled-after-parse', }, ); controller.abort(new DOMException('已取消', 'AbortError')); finishImageParsing('data:image/png;base64,YQ=='); await expect(resolution).rejects.toMatchObject({ name: 'AbortError' }); expect(resolveEditorImageReferenceDataUrlMock).toHaveBeenCalledWith( '/creation-type-references/slow.webp', controller.signal, ); expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); }); it('submits quick edits and updates the source layer directly', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '快速修图', model: 'gemini-3.1-flash-image-preview', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:generating:快速修图:-', ); expect(screen.getByTestId('dialog').textContent).toBe('-'); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '快速修图', sourceImageSrc: 'resource-source', size: '2048x1365', model: 'gemini-3.1-flash-image-preview', aspectRatio: '3:2', imageSize: '2K', }), ); }); expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty( 'canvasCompletion', ); expect(generateEditorImageMock).not.toHaveBeenCalled(); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:源图 快速编辑:resource-source:icon-spritesheet:-:data:image/png;base64,generated', ); }); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); expect(screen.getByTestId('selected').textContent).toBe('-'); expect(screen.getByTestId('fit-count').textContent).toBe('0'); }); it('uses one custom name for quick-edit requests and local layer results', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '改成雨天' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ assetLabel: '雨天勇者' }), ); expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:雨天勇者:resource-source', ); }); }); it('shows dimension restore warnings after non-character quick edits', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '改成雨天', width: 1024, height: 576, warning: { code: 'dimension-restore-fallback', reason: 'provider 回图小于目标尺寸,已保留实际回图。', }, }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(screen.getByTestId('generation-warning').textContent).toBe( 'provider 回图小于目标尺寸,已保留实际回图。', ); }); }); it('rejects direct quick edit submission for icon assets', async () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); expect(editEditorImageMock).not.toHaveBeenCalled(); expect(screen.getByTestId('quick-edit').textContent).toContain( 'failed:修改图标:图标类素材不支持快速编辑', ); }); it('allows direct quick edit submission for icon spritesheets', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '调整图集布局' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '调整图集布局' }), ); }); }); it('clears quick edit panel after a queued edit reloads the project snapshot', async () => { const applyProjectSnapshot = vi.fn(); const refreshTaskList = vi.fn(); const backendProject = { projectId: 'editor-project-1', title: '队列修图项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { ...createLayer(), src: 'data:image/png;base64,queued-generated', }, ], resources: [], updatedAt: '2026-06-23T08:00:00.000Z', }; loadEditorProjectMock.mockResolvedValueOnce(backendProject); editEditorImageMock.mockResolvedValueOnce({ ...createGenerated({ prompt: '快速修图' }), queueState: createQueueState(), }); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1'); }); expect(refreshTaskList).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); }); it('submits quick-edit canvas generators with the source image reference', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '把当前图改成雨天' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '把当前图改成雨天', sourceImageSrc: 'resource-source', size: '1024x768', model: 'gpt-image-2', sourceResourceId: 'resource-source', targetLayerId: 'layer-source', assetLabel: '源图 快速编辑', }), ); }); expect(generateEditorImageMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:源图 快速编辑:resource-source', ); }); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); }); it('refreshes the wallet balance after inline image generation succeeds', async () => { const refreshWalletBalance = vi.fn(); 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).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect(refreshWalletBalance).toHaveBeenCalledTimes(1); }); }); it('keeps quick edit results on the source layer when nearby assets exist', async () => { const blockingLayer = createLayer({ id: 'layer-blocker', resourceId: 'resource-blocker', title: '右侧已有素材', x: 472, y: 140, width: 1024, height: 768, originalWidth: 1024, originalHeight: 768, zIndex: 3, }); editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '快速修图', width: 1024, height: 768 }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:源图 快速编辑:resource-source:-:-:data:image/png;base64,generated', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-quick-edit-1', ); }); it('submits quick edits with the numbered red selection image as the source image', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色', }), ); render( , ); renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce( 'data:image/png;base64,bWFya2VkLXNvdXJjZQ==', ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith( { source: 'data:image/png;base64,source.png', marks: [ { id: 'quick-edit-selection-mark-1', tool: 'rect', x: 24, y: 36, width: 160, height: 120, }, ], showIndexLabels: true, }, ); }); await waitFor(() => { expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ assetKind: 'editor_generation_reference_image', }), ); expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'generated-character-drafts/editor/generation-references/reference.png', targetLayerId: 'layer-source', }), ); }); expect( uploadEditorMediaAssetObjectFileMock.mock.invocationCallOrder[0], ).toBeLessThan(editEditorImageMock.mock.invocationCallOrder[0] ?? 0); expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty( 'referenceImageSrcs', ); expect(generateEditorImageMock).not.toHaveBeenCalled(); }); it('submits canvas quick edits with numbered selection marks', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '对1号红色圈选框里的内容做以下修改:改成蓝色', }), ); render( , ); renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce( 'data:image/png;base64,bWFya2VkLXNvdXJjZQ==', ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith( { source: 'data:image/png;base64,source.png', marks: [ { id: 'quick-edit-selection-mark-1', tool: 'rect', x: 24, y: 36, width: 160, height: 120, }, ], showIndexLabels: true, }, ); }); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'generated-character-drafts/editor/generation-references/reference.png', model: 'gpt-image-2', targetLayerId: 'layer-source', }), ); }); }); it('keeps failed quick edit panels visible with the normalized prompt', async () => { const refreshWalletBalance = vi.fn(); editEditorImageMock.mockRejectedValueOnce(new Error('修图失败')); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:failed:快速编辑图片:修图失败', ); }); expect(refreshWalletBalance).toHaveBeenCalledTimes(1); }); it('submits character redraws through the character image prompt contract', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '角色换成蓝色披风', warning: { code: 'postprocess-failed-source-preserved', reason: '生成任务成功,后处理失败。', }, }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '角色换成蓝色披风', size: '1024x768', aspectRatio: '4:3', imageSize: '1K', kind: 'character', model: 'gpt-image-2', referenceImageSrcs: ['resource-source'], assetKind: 'character', assetLabel: '蓝披风勇者', canvasCompletion: expect.objectContaining({ title: '蓝披风勇者', }), }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-quick-edit-1:蓝披风勇者:resource-source:character', ); }); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:角色换成蓝色披风:-', ); expect(screen.getByTestId('generation-warning').textContent).toBe( '生成任务成功,后处理失败。', ); }); it('submits video quick edits through the video API with the source video', async () => { const applyProjectSnapshot = vi.fn(); const upsertGeneratedAsset = vi.fn(); generateEditorVideoMock.mockResolvedValueOnce( createVideoGenerated({ prompt: '把当前图改成雨天', project: { projectId: 'editor-project-1', title: '项目', coverImageSrc: null, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], createdAt: '2026-06-27T00:00:00.000Z', updatedAt: '2026-06-27T00:00:00.000Z', }, asset: { assetId: 'asset-video-quick-edit', folderId: 'user-1:asset-folder:project', label: '源视频 快速编辑', imageSrc: 'https://assets.example.test/generated-video.mp4', thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', width: 854, height: 480, sourceType: 'generated', assetKind: 'video', }, }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(generateEditorVideoMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '把当前图改成雨天', model: 'seedance2.0-fast', aspectRatio: '16:9', durationSeconds: 4, resolution: '480p', mode: 'std', sound: 'off', referenceVideoSrcs: [ 'generated-character-drafts/editor/generation-references/reference.png', ], assetFolderId: 'project', assetLabel: '源视频 快速编辑', }), ); }); expect(generateEditorVideoMock.mock.calls[0]?.[0]).not.toHaveProperty( 'referenceImageSrcs', ); expect(generateEditorImageMock).not.toHaveBeenCalled(); expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'editor-project-1' }), ); expect(upsertGeneratedAsset).toHaveBeenCalledWith( expect.objectContaining({ assetId: 'asset-video-quick-edit', thumbnailSrc: 'https://assets.example.test/generated-video-cover.png', }), ); }); }); it('uploads inline video references and keeps image objectKey and asset URLs', async () => { generateEditorVideoMock.mockResolvedValueOnce(createVideoGenerated()); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledWith( expect.any(File), 'video', expect.objectContaining({ assetKind: 'editor_generation_reference_video', }), ); expect(generateEditorVideoMock).toHaveBeenCalledWith( expect.objectContaining({ referenceImageSrcs: [ 'generated-character-drafts/editor/refs/image.png', ], referenceVideoSrcs: [ 'generated-character-drafts/editor/generation-references/reference.png', ], referenceAudioSrcs: ['asset://asset-audio-reference'], }), ); }); }); it('submits character action quick edits through image edits', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '让当前图跳得更高', imageSrc: 'data:image/png;base64,action-frame-edited', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '让当前图跳得更高', sourceImageSrc: 'resource-source', model: 'gpt-image-2', assetLabel: '角色动作 快速编辑', targetLayerId: 'layer-source', }), ); }); expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); expect(generateEditorImageMock).not.toHaveBeenCalled(); expect(generateEditorVideoMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:角色动作 快速编辑:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame-edited', ); }); }); it('captures a local edit fallback when the project response has no snapshot', async () => { const applyProjectSnapshot = vi.fn(); const captureCanvasHistory = vi.fn(); const fitLayers = vi.fn(); editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '修改当前图片' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'edit:generating:open:-:-:-', ); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '修改当前图片', sourceImageSrc: 'resource-source', canvasCompletion: expect.objectContaining({ placeholder: { x: 472, y: 140, width: 1024, height: 768, originalWidth: 1024, originalHeight: 768, }, }), }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-edit-1:源图 修改结果:resource-source:-', ); }); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('fit-count').textContent).toBe('1'); expect(captureCanvasHistory).toHaveBeenCalledTimes(1); expect(captureCanvasHistory).toHaveBeenCalledWith({ type: 'generate-image', count: 1, }); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), { captureHistory: false, }); }); it('uses the latest moved canvas placeholder for local-only generation fallback', async () => { const callOrder: string[] = []; const captureCanvasHistory = vi.fn((action: CanvasHistoryAction) => { callOrder.push(`capture:${action.type}:${action.count}`); }); const onAppendCanvasLayers = vi.fn(() => { callOrder.push('append'); }); generateEditorImageMock.mockResolvedValueOnce( createGenerated({ width: 512, height: 512 }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '移动占位' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:生成图片 1:-:-', ); }); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:layer-generated-1:placeholder:-', ); expect(captureCanvasHistory).toHaveBeenCalledWith({ type: 'generate-image', count: 1, }); expect(onAppendCanvasLayers).toHaveBeenCalledTimes(1); expect(callOrder).toEqual(['capture:generate-image:1', 'append']); }); it('passes canvas completion context and refreshes from the backend project snapshot', async () => { const applyProjectSnapshot = vi.fn(); const captureCanvasHistory = vi.fn(); generateEditorImageMock.mockResolvedValueOnce({ ...createGenerated({ width: 1024, height: 1024, prompt: '银发游侠', }), project: { projectId: 'editor-project-1', title: '角色项目', viewport: { x: 12, y: 24, scale: 1.5 }, layers: [ { layerId: 'layer-editor-resource-character', resourceId: 'editor-resource-character', }, { layerId: 'layer-editor-resource-character-source', resourceId: 'editor-resource-character-source', }, ], resources: [ { resourceId: 'editor-resource-character', projectId: 'editor-project-1', imageSrc: '/generated-character-drafts/editor/character.png', width: 1024, height: 1024, sourceType: 'generated', assetKind: 'character', }, { resourceId: 'editor-resource-character-source', projectId: 'editor-project-1', imageSrc: '/generated-character-drafts/editor/character-green-screen-source.png', width: 1024, height: 1024, sourceType: 'generated', assetKind: 'character', }, ], updatedAt: '2026-06-21T12:16:22.000Z', }, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '银发游侠', kind: 'character', projectId: 'editor-project-1', assetKind: 'character', assetFolderId: 'project', canvasCompletion: { dialogId: 'dialog-character', title: '角色形象 1', placeholder: { x: 200, y: 160, width: 420, height: 560, originalWidth: 1024, originalHeight: 1536, }, }, }), ); }); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'editor-project-1', title: '角色项目', layers: expect.arrayContaining([ expect.objectContaining({ resourceId: 'editor-resource-character', }), expect.objectContaining({ resourceId: 'editor-resource-character-source', }), ]), resources: expect.arrayContaining([ expect.objectContaining({ resourceId: 'editor-resource-character', assetKind: 'character', }), expect.objectContaining({ resourceId: 'editor-resource-character-source', assetKind: 'character', }), ]), }), ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-1', ); expect(captureCanvasHistory).not.toHaveBeenCalled(); }); it('submits audio generations with asset library context and upserts the returned asset', async () => { const applyProjectSnapshot = vi.fn(); const upsertGeneratedAsset = vi.fn(); generateEditorSoundEffectMock.mockResolvedValueOnce( createAudioGenerated({ project: { projectId: 'editor-project-audio', title: '音频项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-27T00:00:00.000Z', }, asset: { assetId: 'asset-sound-1', folderId: 'user-1:asset-folder:project', label: '游戏音效 1', imageSrc: 'https://assets.example.test/generated-audio.mp3', width: 420, height: 120, sourceType: 'generated', assetKind: 'sound-effect', }, }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(generateEditorSoundEffectMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '金币掉落叮当声', duration: 7, projectId: 'editor-project-audio', assetFolderId: 'project', assetLabel: '游戏音效 1', canvasCompletion: { dialogId: 'dialog-sound', title: '游戏音效 1', placeholder: { x: 200, y: 160, width: 420, height: 120, originalWidth: 420, originalHeight: 120, }, }, }), ); }); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'editor-project-audio' }), ); expect(upsertGeneratedAsset).toHaveBeenCalledWith( expect.objectContaining({ assetId: 'asset-sound-1', assetKind: 'sound-effect', }), ); }); }); it('submits uploaded objectKey references without downloading or inlining them', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '参考图生成' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '参考图生成', referenceImageSrcs: [ 'generated-character-drafts/editor/refs/ref.png', ], }), ); }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); }); it('refreshes the wallet and shows the warning after a queued character generation completes', async () => { const applyProjectSnapshot = vi.fn(); const refreshTaskList = vi.fn(); const refreshWalletBalance = vi.fn(); const backendProject = { projectId: 'editor-project-1', title: '队列项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-23T08:00:00.000Z', }; loadEditorProjectMock.mockResolvedValueOnce(backendProject); generateEditorImageMock.mockResolvedValueOnce({ ...createGenerated({ imageSrc: '/generated-editor-images/project/image.png', width: 1024, height: 1024, prompt: '队列角色生成', }), queueState: createQueueState({ warning: '生成任务成功,后处理失败。', }), }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(refreshTaskList).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledWith('editor-project-1'); }); expect(refreshWalletBalance).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).toHaveBeenCalledWith(backendProject); expect(getExternalGenerationJobStatusMock).not.toHaveBeenCalled(); expect(screen.getByTestId('generation-warning').textContent).toBe( '生成任务成功,后处理失败。', ); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-1', ); }); it('reloads a queued project snapshot when a later duplicate completion dialog is unresolved', async () => { vi.useFakeTimers(); try { const applyProjectSnapshot = vi.fn(); const refreshTaskList = vi.fn(); const refreshWalletBalance = vi.fn(); const unresolvedProject = { projectId: 'editor-project-1', title: '队列项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'generation-dialog-background-removal-completed', resourceId: 'generation-dialog-background-removal-completed', itemType: 'generation-dialog', dialog: { id: 'dialog-background-removal', status: 'idle', generatedLayerId: 'layer-background-removal-result', }, }, { layerId: 'generation-dialog-background-removal', resourceId: 'generation-dialog-background-removal', itemType: 'generation-dialog', dialog: { id: 'dialog-background-removal', status: 'generating', }, }, ], resources: [], updatedAt: '2026-06-23T08:00:00.000Z', }; const completedProject = { ...unresolvedProject, layers: [ { layerId: 'generation-dialog-background-removal', resourceId: 'generation-dialog-background-removal', itemType: 'generation-dialog', dialog: { id: 'dialog-background-removal', status: 'idle', generatedLayerId: 'layer-background-removal-result', }, }, ], updatedAt: '2026-06-23T08:00:01.000Z', }; loadEditorProjectMock .mockResolvedValueOnce(unresolvedProject) .mockResolvedValueOnce(completedProject); const applyPromise = applyQueuedEditorGenerationProject( { queueState: createQueueState() }, 'editor-project-1', applyProjectSnapshot, refreshTaskList, refreshWalletBalance, undefined, 'dialog-background-removal', ); await Promise.resolve(); await Promise.resolve(); expect(refreshTaskList).toHaveBeenCalledTimes(1); expect(refreshWalletBalance).toHaveBeenCalledTimes(1); expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); await vi.advanceTimersByTimeAsync(650); await applyPromise; expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); expect(applyProjectSnapshot).toHaveBeenNthCalledWith( 1, unresolvedProject, ); expect(applyProjectSnapshot).toHaveBeenNthCalledWith(2, completedProject); } finally { vi.useRealTimers(); } }); it('reports a non-blocking warning after a queued generation completes', async () => { const onGenerationWarning = vi.fn(); loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-1', title: '队列图集项目', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-06-23T08:00:00.000Z', }); await applyQueuedEditorGenerationProject( { queueState: createQueueState({ warning: '生成任务成功,后处理失败。', }), }, 'editor-project-1', vi.fn(), vi.fn(), vi.fn(), onGenerationWarning, ); expect(onGenerationWarning).toHaveBeenCalledWith( '生成任务成功,后处理失败。', ); }); it('keeps a queued generation pending when the frontend queue wait expires', async () => { vi.useFakeTimers(); try { const refreshWalletBalance = vi.fn(); const refreshTaskList = vi.fn(); const runningState = createQueueState({ status: 'running', phaseLabel: '生成中', phaseDetail: '后端任务继续执行中', progress: 0.35, }); generateEditorImageMock.mockResolvedValueOnce({ ...createGenerated({ imageSrc: '/generated-editor-images/project/image.png', width: 1024, height: 1024, prompt: '队列生成', }), queueState: runningState, }); getExternalGenerationJobStatusMock.mockResolvedValue({ job: runningState, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(refreshTaskList).toHaveBeenCalledTimes(1); await act(async () => { await vi.advanceTimersByTimeAsync(20 * 60 * 1000 + 1600); }); expect(refreshWalletBalance).toHaveBeenCalledTimes(1); expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect(getExternalGenerationJobStatusMock).toHaveBeenCalled(); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:generating:closed:-:placeholder:-', ); expect(screen.getByTestId('dialog').textContent).not.toContain( '生成任务仍在队列中', ); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-1', ); } finally { vi.useRealTimers(); } }); it('does not add a local layer when project generation returns without a backend snapshot', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ width: 1024, height: 1024, prompt: '银发游侠', project: null, }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'editor-project-1', canvasCompletion: expect.objectContaining({ dialogId: 'dialog-character', }), }), ); }); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:-:placeholder:-', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-1', ); }); it('submits publication detail materials as one billable editor image and appends the result', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ imageSrc: 'data:image/png;base64,publication', width: 720, height: 1280, prompt: '游戏名:云朵消消乐', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'publication:generating:closed:-:placeholder:-', ); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: expect.stringContaining('【宣发素材类型】详情页单图'), size: '720x1280', kind: 'publication-material', assetKind: 'publication-material', model: 'gpt-image-2', aspectRatio: '9:16', imageSize: '1K', referenceImageSrcs: [ 'generated-character-drafts/editor/generation-references/reference.png', ], }), ); }); const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; expect(prompt).toContain('游戏名:云朵消消乐'); expect(prompt).toContain('本次只生成一张 720 x 1280 竖版详情图'); expect(prompt).toContain('不是五图合集或拼贴'); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:1 宣发素材:-:publication-material:-', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-generated-2', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-generated-1', ); }); it('submits publication resource ids without downloading or inlining them', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ imageSrc: 'data:image/png;base64,publication', width: 720, height: 540, prompt: '游戏名:云朵消消乐', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'publication-material', aspectRatio: '4:3', imageSize: '1K', referenceImageSrcs: ['resource-publication-ref'], }), ); }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); expect(uploadEditorMediaAssetObjectFileMock).not.toHaveBeenCalled(); }); it('submits icon spec objects without requiring an icon spec reference', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ imageSrc: 'data:image/png;base64,icon-spec', width: 2048, height: 1152, prompt: '图标规范提示词', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled(); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', assetKind: 'icon-spec', assetLabel: '图标规范 1', size: '2048x1152', aspectRatio: '16:9', imageSize: '2K', }), ); }); const payload = generateEditorImageMock.mock.calls[0]?.[0] ?? {}; expect(payload).not.toHaveProperty('referenceImageSrcs'); expect(payload.prompt).toContain('背包整理玩法'); expect(payload.prompt).not.toContain('参考图生成规范'); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:图标规范 1:-:icon-spec', ); }); expect(screen.getByTestId('dialog').textContent).not.toContain( '请选择图标规范', ); }); it('validates icon submission before calling the API', async () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交图标' })); expect(generateEditorIconSpritesheetMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( '请选择图标规范', ); }); }); it('submits icon spritesheets, trims the complete prompt, and remembers the model', async () => { generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '图标素材', actualPrompt: '图标素材', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-icons', iconImageSrcs: [], sliceWarning: { code: 'insufficient-connected-components', reason: '连通域数量不足', }, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交图标' })); await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ referenceImageSrc: 'generated-character-drafts/editor/generation-references/reference.png', iconDescriptions: ['返回按钮\n设置按钮'], model: 'gpt-image-2', assetLabel: '复古操作图标', canvasCompletion: expect.objectContaining({ title: '复古操作图标', }), }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-icon-spritesheet-1:复古操作图标:-:icon-spritesheet', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-icon-2', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-icon-spritesheet-1', ); expect(screen.getByTestId('dialog').textContent).toBe( 'icon:idle:open:layer-icon-spritesheet-1:placeholder:-', ); expect(screen.getByTestId('remembered-model').textContent).toBe( 'gpt-image-2', ); expect(screen.getByTestId('generation-warning').textContent).toBe( '图集已生成,但自动拆分未完成:连通域数量不足', ); }); it('keeps both the common warning and the slice warning when they coexist', async () => { generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '图标素材', actualPrompt: '图标素材', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-icons', iconImageSrcs: [], warning: { code: 'unsupported-image-style', reason: '不支持的图片风格,已按无风格继续生成。', }, sliceWarning: { code: 'insufficient-connected-components', reason: '连通域数量不足', }, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交图标' })); // 风格归一化告警与拆分告警可以并存,两者都必须出现在同一条提示里。 await waitFor(() => { expect(screen.getByTestId('generation-warning').textContent).toBe( '不支持的图片风格,已按无风格继续生成。 图集已生成,但自动拆分未完成:连通域数量不足', ); }); }); it('submits uploaded objectKey icon references before submitting spritesheets', async () => { generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '图标素材', actualPrompt: '图标素材', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-icons', iconImageSrcs: [], }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交图标' })); await waitFor(() => { expect( resolveEditorImageReferenceDataUrlForGenerationMock, ).not.toHaveBeenCalled(); expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ referenceImageSrc: 'generated-character-drafts/editor/specs/icon-spec.png', referenceImageSrcs: [ 'generated-character-drafts/editor/refs/icon-style.png', ], iconDescriptions: ['返回按钮'], }), ); }); }); it('extracts UI design assets into a spritesheet layer and sliced icon layers', async () => { const captureCanvasHistory = vi.fn(); const fitLayers = vi.fn(); extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', iconImageSrcs: [ createIconResult('素材 1', 'data:image/png;base64,asset-1'), ], }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提取UI素材' })); await waitFor(() => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'generated/ui-design.png', model: 'gemini-3.1-flash-image-preview', screenColor: 'auto', segModel: 'birefnet', }), ); }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalledWith( 'generated/ui-design.png', ); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet', ); }); expect(screen.getByTestId('layers').textContent).toContain( 'layer-icon-2:素材 1:-:icon', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-icon-spritesheet-1', ); expect(screen.getByTestId('fit-count').textContent).toBe('1'); expect(captureCanvasHistory).toHaveBeenCalledTimes(1); expect(captureCanvasHistory).toHaveBeenCalledWith({ type: 'generate-image', count: 2, }); expect(fitLayers).toHaveBeenCalledWith(expect.any(Array), { captureHistory: false, }); }); it('keeps the extracted spritesheet when extracted icon results are missing', async () => { extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', warning: { code: 'postprocess-failed-source-preserved', reason: '生成任务成功,后处理失败。', }, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提取UI素材' })); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-icon-spritesheet-1:源图 素材图集:-:icon-spritesheet', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-icon-2:', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-icon-spritesheet-1', ); expect(screen.getByTestId('fit-count').textContent).toBe('1'); expect(screen.getByTestId('generation-warning').textContent).toBe( '生成任务成功,后处理失败。', ); }); it('renders selected UI marks into the extraction reference image and sends derived extraction pricing', async () => { renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValueOnce( 'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==', ); extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', iconImageSrcs: [ createIconResult('素材 1', 'data:image/png;base64,asset-1'), ], }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提取已框选UI素材' })); await waitFor(() => { expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith( expect.objectContaining({ source: 'data:image/png;base64,ui-design.png', marks: expect.arrayContaining([ { id: 'mark-1', tool: 'rect', x: 10, y: 20, width: 80, height: 60, }, ]), }), ); expect( renderUiDesignAssetExtractionMarkedImageMock.mock.calls[0]?.[0].marks, ).toHaveLength(7); }); await waitFor(() => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'generated-character-drafts/editor/generation-references/reference.png', model: 'gemini-3.1-flash-image-preview', screenColor: 'auto', segModel: 'birefnet', aspectRatio: '1:1', imageSize: '2K', canvasCompletion: expect.objectContaining({ placeholder: { x: 472, y: 140, width: 2048, height: 2048, originalWidth: 2048, originalHeight: 2048, }, }), generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ title: '提取提示词', value: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', }), ]), }), }), ); }); }); it('submits UI asset extraction with uploaded references', async () => { extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,ui-sheet', spritesheetWidth: 512, spritesheetHeight: 512, prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-ui-assets', }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提取带参考图UI素材' })); await waitFor(() => { expect( resolveEditorImageReferenceDataUrlForGenerationMock, ).not.toHaveBeenCalled(); }); await waitFor(() => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ screenColor: 'auto', segModel: 'birefnet', referenceImageSrcs: ['generated/ref-style.png'], generationInputs: expect.objectContaining({ references: expect.arrayContaining([ expect.objectContaining({ title: '参考图 1', label: '风格参考', refType: 'project-resource', }), ]), }), }), ); }); }); it('keeps character generation dialog data after the result is created', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ width: 768, height: 768, warning: { code: 'postprocess-failed-source-preserved', reason: '生成任务成功,后处理失败。', }, }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交当前生成' })); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:角色形象 1:-:character', ); }); expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:layer-generated-1:placeholder:-', ); expect(screen.getByTestId('generation-warning').textContent).toBe( '生成任务成功,后处理失败。', ); }); it('moves character animation panels from generating to completed', async () => { generateEditorCharacterAnimationMock.mockResolvedValueOnce({ frames: [{ imageSrc: 'data:image/png;base64,frame' }], previewSrc: 'data:image/png;base64,preview', taskId: 'animation-task', }); render( , ); fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); expect(screen.getByTestId('character-animation').textContent).toBe( 'character-layer:generating:跑步:-:-', ); await waitFor(() => { expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( expect.objectContaining({ sourceLayerId: 'character-layer', sourceImageSrc: 'generated-character-drafts/editor/generation-references/reference.png', promptText: '跑步', model: 'seedance2.0-fast', assetFolderId: 'project', }), ); }); await waitFor(() => { expect(screen.getByTestId('character-animation').textContent).toBe( 'character-layer:completed:跑步:-:result', ); }); }); it('uses updated character animation folder and queue callbacks after rerender', async () => { const firstQueuedTaskCallback = vi.fn(); const latestQueuedTaskCallback = vi.fn(); generateEditorCharacterAnimationMock.mockResolvedValueOnce({ taskId: 'animation-task-queued', queueState: createQueueState({ status: 'queued' }), project: { id: 'editor-project-1', }, }); const harnessProps = { projectId: 'editor-project-1', initialLayers: [ createLayer({ id: 'character-layer', assetKind: 'character' as const, objectKey: 'generated/character.png', }), ], initialCharacterAnimationPanel: { sourceLayerId: 'character-layer', promptText: '跑步', resolution: '480p' as const, ratio: 'same' as const, frameCount: 32 as const, durationSeconds: 4 as const, status: 'idle' as const, }, }; const { rerender } = render( , ); rerender( , ); fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); await waitFor(() => { expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( expect.objectContaining({ assetFolderId: 'folder-latest' }), ); }); await waitFor(() => { expect(latestQueuedTaskCallback).toHaveBeenCalledTimes(1); }); expect(firstQueuedTaskCallback).not.toHaveBeenCalled(); }); it('does not resubmit character animation while it is already generating', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); expect(generateEditorCharacterAnimationMock).not.toHaveBeenCalled(); }); it('creates an action layer for dialog-driven character animation submissions', async () => { generateEditorCharacterAnimationMock.mockResolvedValueOnce({ taskId: 'animation-task-dialog', model: 'seedance2.0-fast', prompt: '挥手动作', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: [ { frameIndex: 1, imageSrc: 'data:image/png;base64,action-frame', width: 1024, height: 1024, }, ], frameCount: 32, durationSeconds: 4, fps: 8, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '设置初始对话' })); fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); await waitFor(() => { expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( expect.objectContaining({ sourceLayerId: 'character-layer', sourceImageSrc: 'generated/character.png', promptText: '挥手动作', model: 'seedance2.0-fast', assetLabel: '勇者挥手', canvasCompletion: expect.objectContaining({ title: '勇者挥手', }), }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-character-animation-1:勇者挥手:resource-source:character-animation:image-sequence:data:image/png;base64,action-frame:data:image/png;base64,action-frame:4', ); }); expect(screen.getByTestId('dialog').textContent).toBe( 'character-animation:idle:open:layer-character-animation-1:placeholder:-', ); expect(screen.getByTestId('selected').textContent).toBe( 'layer-character-animation-1', ); }); it('shows a readable conflict message for failed character animation submissions', async () => { generateEditorCharacterAnimationMock.mockRejectedValueOnce( new ApiClientError({ message: '请求冲突(requestId: wallet-one)', status: 409, code: 'CONFLICT', details: { provider: 'profile-wallet' }, }), ); render( , ); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); }); await waitFor(() => { expect(screen.getByTestId('character-animation').textContent).toBe( 'character-layer:failed:跑步:泥点余额不足,请充值后再生成:-', ); }); }); it('uses a fallback error message for failed character animation submissions', async () => { generateEditorCharacterAnimationMock.mockRejectedValueOnce(new Error('')); render( , ); await act(async () => { fireEvent.click(screen.getByRole('button', { name: '提交角色动画' })); }); await waitFor(() => { expect(screen.getByTestId('character-animation').textContent).toBe( 'character-layer:failed:-:生成角色动画失败:-', ); }); }); });