/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor, within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration'; import { ApiClientError, defaultEditorProjectLayers, defaultEditorProjectResources, dispatchPointerEvent, ImageCanvasEditorView, setupImageCanvasEditorViewTestLifecycle, } from './ImageCanvasEditorView.test-utils'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn()); const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorAssetMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const updateEditorAssetMock = vi.hoisted(() => vi.fn()); const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const deleteEditorAssetMock = vi.hoisted(() => vi.fn()); const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn()); const renameEditorProjectMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn()); 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, createEditorAsset: createEditorAssetMock, createEditorAssetFolder: createEditorAssetFolderMock, createEditorProjectResource: createEditorProjectResourceMock, deleteEditorAsset: deleteEditorAssetMock, deleteEditorAssetFolder: deleteEditorAssetFolderMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, loadEditorAssetLibrary: loadEditorAssetLibraryMock, loadEditorProject: loadEditorProjectMock, loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock, renameEditorProject: renameEditorProjectMock, saveEditorProjectLayout: saveEditorProjectLayoutMock, updateEditorAsset: updateEditorAssetMock, updateEditorAssetFolder: updateEditorAssetFolderMock, }; }); vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({ renderUiDesignAssetExtractionMarkedImage: renderUiDesignAssetExtractionMarkedImageMock, })); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, // 中文注释:inline 源图上传走的是 object-only 版本,不是带签名 URL 的那个。留成裸 vi.fn() // 会返回 undefined,取 objectKey 抛的 TypeError 被 extractUiDesignAssets 的 catch 吞成 // window.alert,最终只表现为「提取接口一次都没调」,报错里看不到任何线索。 uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, })); vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({ createProjectCoverSnapshotBlob: vi.fn(async () => null), })); describe('ImageCanvasEditorView generation integration', () => { setupImageCanvasEditorViewTestLifecycle({ generateEditorImageMock, generateEditorIconSpritesheetMock, extractEditorUiDesignAssetsMock, generateEditorCharacterAnimationMock, editEditorImageMock, createEditorAssetMock, createEditorProjectResourceMock, createEditorAssetFolderMock, updateEditorAssetMock, updateEditorAssetFolderMock, deleteEditorAssetFolderMock, deleteEditorAssetMock, loadEditorAssetLibraryMock, loadEditorProjectMock, loadOrCreateRecentEditorProjectMock, renameEditorProjectMock, saveEditorProjectLayoutMock, }); beforeEach(() => { window.localStorage.clear(); renderUiDesignAssetExtractionMarkedImageMock.mockReset(); renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue( 'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==', ); uploadEditorMediaAssetFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockResolvedValue({ objectKey: 'generated-character-drafts/editor/generation-references/marked-ui-design.png', assetObjectId: 'asset-object-marked-ui-design', legacyPublicPath: '/generated-character-drafts/editor/generation-references/marked-ui-design.png', src: 'https://oss.example.com/marked-ui-design.png', }); uploadEditorMediaAssetObjectFileMock.mockReset(); uploadEditorMediaAssetObjectFileMock.mockResolvedValue({ objectKey: 'generated-character-drafts/editor/generation-references/marked-ui-design.png', assetObjectId: 'asset-object-marked-ui-design', legacyPublicPath: '/generated-character-drafts/editor/generation-references/marked-ui-design.png', }); }); const openGenerationDimensionsPanel = ( panel: HTMLElement, labelPrefix = '生成图片', ) => { fireEvent.click( within(panel).getByRole('button', { name: new RegExp(`^${labelPrefix}尺寸 `, 'u'), }), ); return screen.getByRole('menu', { name: `${labelPrefix}尺寸选项` }); }; const openGenerationModelPanel = ( panel: HTMLElement, labelPrefix = '生成图片', ) => { fireEvent.click( within(panel).getByRole('button', { name: new RegExp(`^${labelPrefix}模型 `, 'u'), }), ); return screen.getByRole('menu', { name: `${labelPrefix}模型选项` }); }; const selectGenerationModel = (panel: HTMLElement, modelLabel: string) => { const modelPanel = openGenerationModelPanel(panel); fireEvent.click( within(modelPanel).getByRole('button', { name: new RegExp(`^${modelLabel}(?: ✓)?$`, 'u'), }), ); }; const selectGenerationDimensions = ( panel: HTMLElement, aspectRatio: string, imageSize: string, ) => { const dimensionsPanel = openGenerationDimensionsPanel(panel); fireEvent.click( within(dimensionsPanel).getByRole('button', { name: new RegExp(`^(?:比例 )?${aspectRatio}$`, 'u'), }), ); fireEvent.click( within(dimensionsPanel).getByRole('button', { name: new RegExp(`^(?:尺寸 )?${imageSize}$`, 'u'), }), ); }; const expectReferenceChip = (panel: HTMLElement, label: string) => { const button = within(panel).queryByRole('button', { name: label }) ?? Array.from(panel.querySelectorAll('button')).find( (candidate) => candidate.getAttribute('title') === label || candidate.textContent?.includes(label), ); expect(button).toBeTruthy(); }; const createGeneratedResource = ({ resourceId, projectId = 'editor-project-default', imageSrc = 'data:image/png;base64,generated', width = 1024, height = 1024, objectKey, assetObjectId, assetKind, generationInputs, }: { resourceId: string; projectId?: string; imageSrc?: string; width?: number; height?: number; objectKey?: string; assetObjectId?: string; assetKind?: string; generationInputs?: unknown; }) => ({ resourceId, projectId, imageSrc, objectKey, assetObjectId, width, height, sourceType: 'generated', assetKind, generationInputs, createdAt: '2026-06-19T00:00:00.000Z', updatedAt: '2026-06-19T00:00:00.000Z', }); const createGeneratedAsset = ({ assetId, folderId = 'folder-project', label = '生成素材', imageSrc = 'data:image/png;base64,generated', width = 1024, height = 1024, objectKey, assetObjectId, assetKind, generationInputs, }: { assetId: string; folderId?: string; label?: string; imageSrc?: string; width?: number; height?: number; objectKey?: string; assetObjectId?: string; assetKind?: string; generationInputs?: unknown; }) => ({ assetId, folderId, label, imageSrc, objectKey, assetObjectId, width, height, sourceType: 'generated', assetKind, generationInputs, createdAt: '2026-06-19T00:00:00.000Z', updatedAt: '2026-06-19T00:00:00.000Z', }); const createExternalGenerationTask = ( overrides: Partial = {}, ): ExternalGenerationTaskRecord => { const now = new Date().toISOString(); return { jobId: overrides.jobId ?? 'task-editor-character-refresh', jobKind: overrides.jobKind ?? 'editor_image_generation', sourceModule: overrides.sourceModule ?? 'editor-canvas', sourceEntityId: overrides.sourceEntityId ?? 'editor-project-refresh-completion', requestLabel: overrides.requestLabel ?? '图片画布生成角色形象', requestPrompt: overrides.requestPrompt ?? '刷新后角色', status: overrides.status ?? 'running', phaseLabel: overrides.phaseLabel ?? '生成图片', phaseDetail: overrides.phaseDetail ?? '正在生成角色形象。', progress: overrides.progress ?? 40, error: overrides.error ?? null, warning: overrides.warning ?? null, priceMudPoints: overrides.priceMudPoints ?? 3, refundLedgerId: overrides.refundLedgerId ?? null, notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null, createdAt: overrides.createdAt ?? now, startedAt: overrides.startedAt ?? now, completedAt: overrides.completedAt ?? null, updatedAt: overrides.updatedAt ?? now, updatedAtMicros: overrides.updatedAtMicros ?? 1, }; }; const createGeneratedImageResponse = ( input: { projectId?: string | null; assetFolderId?: string | null; assetLabel?: string | null; assetKind?: string | null; kind?: string | null; model?: string | null; aspectRatio?: string | null; imageSize?: string | null; generationInputs?: unknown; canvasCompletion?: { dialogId: string; title: string; placeholder: { x: number; y: number; width: number; height: number; originalWidth?: number; originalHeight?: number; }; } | null; }, { imageSrc, width = 1024, height = 1024, prompt, actualPrompt = prompt, model = 'gpt-image-2', provider = 'VectorEngine', taskId, objectKey, assetObjectId, resource, asset, projectLayers = defaultEditorProjectLayers, projectResources = defaultEditorProjectResources, }: { imageSrc: string; width?: number; height?: number; prompt: string; actualPrompt?: string; model?: string; provider?: string; taskId: string; sourceType?: 'generated'; objectKey?: string; assetObjectId?: string; resource?: ReturnType; asset?: ReturnType; projectLayers?: Array>; projectResources?: Array>; }, ) => { const normalizedAsset = asset ? { ...asset, folderId: input.assetFolderId ?? asset.folderId ?? 'project', } : undefined; const baseResponse = { imageSrc, width, height, sourceType: 'generated', prompt, actualPrompt, model, provider, taskId, objectKey, assetObjectId, resource, asset: normalizedAsset, }; const canvasCompletion = input.canvasCompletion; if (!input.projectId || !canvasCompletion) { return baseResponse; } const resourceId = resource?.resourceId ?? `resource-${taskId}`; const layerId = `layer-${taskId}`; const title = canvasCompletion.title; const placeholder = canvasCompletion.placeholder; const originalWidth = placeholder.originalWidth ?? width; const originalHeight = placeholder.originalHeight ?? height; const dialogMode = input.kind === 'character' ? 'character' : input.kind === 'icon' ? 'icon' : input.kind === 'spec' ? 'spec' : input.kind === 'ui-design' ? 'ui-design' : 'generate'; return { ...baseResponse, resource: resource ?? createGeneratedResource({ resourceId, projectId: input.projectId, imageSrc, width, height, assetKind: input.assetKind ?? undefined, generationInputs: input.generationInputs, objectKey, assetObjectId, }), asset: normalizedAsset ?? (input.assetLabel ? createGeneratedAsset({ assetId: `asset-${taskId}`, folderId: input.assetFolderId ?? 'project', label: input.assetLabel, imageSrc, width, height, assetKind: input.assetKind ?? undefined, generationInputs: input.generationInputs, }) : undefined), project: { projectId: input.projectId, title: '默认项目', canvas: { revision: 1 }, viewport: { x: 0, y: 0, scale: 1 }, resources: [ ...projectResources, resource ?? createGeneratedResource({ resourceId, projectId: input.projectId, imageSrc, width, height, assetKind: input.assetKind ?? undefined, generationInputs: input.generationInputs, objectKey, assetObjectId, }), ], layers: [ ...projectLayers, { itemType: 'generation-dialog', layerId: `generation-dialog:${canvasCompletion.dialogId}`, resourceId: `generation-dialog:${canvasCompletion.dialogId}`, dialog: { id: canvasCompletion.dialogId, mode: dialogMode, prompt, status: 'idle', composerOpen: false, generatedLayerId: layerId, placeholder, imageModel: model, aspectRatio: input.aspectRatio, imageSize: input.imageSize, }, }, { layerId, resourceId, title, x: placeholder.x + placeholder.width / 2 - originalWidth / 2, y: placeholder.y + placeholder.height / 2 - originalHeight / 2, width: originalWidth, height: originalHeight, originalWidth, originalHeight, zIndex: 12, sourceType: 'generated', objectKey, assetObjectId, prompt, actualPrompt, model, provider, taskId, assetKind: input.assetKind ?? undefined, generationInputs: input.generationInputs, }, ], updatedAt: '2026-06-19T00:00:00.000Z', }, }; }; it('restores generated image composer from saved generator snapshots', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-generated-dialog', title: '已生成画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-generated-restored', resourceId: 'resource-generated-restored', title: '生成图片 1', x: 236, y: 66, width: 512, height: 512, originalWidth: 512, originalHeight: 512, zIndex: 11, sourceType: 'generated', generationInputs: { fields: [{ title: '生成提示词', value: '刷新后恢复生成器' }], references: [], }, }, { itemType: 'generation-dialog', layerId: 'generation-dialog:generation-dialog-1', resourceId: 'generation-dialog:generation-dialog-1', dialog: { id: 'generation-dialog-1', mode: 'generate', prompt: '刷新后恢复生成器', status: 'idle', composerOpen: true, generatedLayerId: 'layer-generated-restored', placeholder: { x: 282, y: 112, width: 420, height: 420, originalWidth: 2048, originalHeight: 2048, }, }, }, ], resources: [ { resourceId: 'resource-generated-restored', projectId: 'editor-project-generated-dialog', imageSrc: 'data:image/png;base64,cmVzdG9yZWQ=', width: 512, height: 512, sourceType: 'generated', }, ], updatedAt: '2026-06-17T00:00:00.000Z', }); render(); expect(await screen.findByAltText('画布图片:生成图片 1')).toBeTruthy(); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); const generateDialog = screen.getByRole('dialog', { name: '生成图片' }); expect(generateDialog).toBeTruthy(); expect( (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, ).toBe('刷新后恢复生成器'); }); it('restores generated image composer from layer metadata when the dialog snapshot is missing', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-generated-layer-only', title: '已生成画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-generated-metadata', resourceId: 'resource-generated-metadata', title: '生成图片 1', x: 236, y: 66, width: 512, height: 512, originalWidth: 512, originalHeight: 512, zIndex: 11, sourceType: 'generated', }, ], resources: [ { resourceId: 'resource-generated-metadata', projectId: 'editor-project-generated-layer-only', imageSrc: 'data:image/png;base64,bWV0YWRhdGE=', width: 512, height: 512, sourceType: 'generated', prompt: '图层提示词', model: 'gpt-image-2', generationInputs: { fields: [{ title: '生成提示词', value: '元数据恢复生成器' }], references: [], }, }, ], updatedAt: '2026-06-17T00:00:00.000Z', }); render(); const generatedImage = await screen.findByAltText('画布图片:生成图片 1'); expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); fireEvent.click(generatedImage.closest('button')!); expect( await screen.findByRole('dialog', { name: '生成图片' }), ).toBeTruthy(); expect( (screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value, ).toBe('元数据恢复生成器'); }); it('does not open a generator on direct click for tagged uploaded images', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-uploaded-tagged-layer', title: '标签素材画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-uploaded-character', resourceId: 'resource-uploaded-character', title: '上传角色', src: 'data:image/png;base64,dXBsb2FkZWQ=', x: 236, y: 66, width: 512, height: 512, originalWidth: 512, originalHeight: 512, zIndex: 11, sourceType: 'uploaded', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '黑衣剑士' }], references: [], }, }, ], resources: [], updatedAt: '2026-06-17T00:00:00.000Z', }); render(); const uploadedImage = await screen.findByAltText('画布图片:上传角色'); fireEvent.click(uploadedImage.closest('button')!); expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '快速编辑' })); expect( await screen.findByRole('dialog', { name: '快速编辑图片' }), ).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); }); it('opens quick edit from image context for tagged uploaded images', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-context-tagged-layer', title: '右键标签素材画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-context-character', resourceId: 'resource-context-character', title: '右键上传角色', src: 'data:image/png;base64,Y29udGV4dA==', x: 236, y: 66, width: 512, height: 512, originalWidth: 512, originalHeight: 512, zIndex: 11, sourceType: 'uploaded', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '红披风骑士' }], references: [], }, }, ], resources: [], updatedAt: '2026-06-17T00:00:00.000Z', }); render(); const uploadedImage = await screen.findByAltText('画布图片:右键上传角色'); fireEvent.contextMenu(uploadedImage.closest('button')!, { clientX: 260, clientY: 220, }); fireEvent.click( within(screen.getByRole('menu', { name: '图片功能面板' })).getByRole( 'menuitem', { name: '快速编辑' }, ), ); expect( await screen.findByRole('dialog', { name: '快速编辑图片' }), ).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); }); it('restores publication material composer fields and references from saved generator snapshots', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-publication-dialog', title: '宣发画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { itemType: 'generation-dialog', layerId: 'generation-dialog:generation-dialog-publication', resourceId: 'generation-dialog:generation-dialog-publication', dialog: { id: 'generation-dialog-publication', mode: 'publication', prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离', status: 'idle', composerOpen: true, publicationWorkflowId: 'publication-promo-poster', publicationGameInfo: { gameName: '马戏团午夜惊魂', gameCategories: '非对称对抗', gameDescription: '找到钥匙,开门逃离马戏团', }, publicationReferences: [ { id: 'publication-reference-1', label: '首图参考', src: '/generated-character-drafts/editor/reference.png', objectKey: 'generated-character-drafts/editor/reference.png', assetObjectId: 'asset-object-publication-reference', }, ], imageModel: 'gpt-image-2', aspectRatio: '16:9', imageSize: '2K', placeholder: { x: 282, y: 112, width: 1280, height: 720, originalWidth: 1280, originalHeight: 720, }, }, }, ], resources: [], updatedAt: '2026-06-17T00:00:00.000Z', }); render(); const publicationDialog = await screen.findByRole('dialog', { name: '运营海报生成卡片', }); expect( ( within(publicationDialog).getByRole('textbox', { name: '运营海报游戏名', }) as HTMLInputElement ).value, ).toBe('马戏团午夜惊魂'); expect( ( within(publicationDialog).getByRole('textbox', { name: '运营海报游戏分类', }) as HTMLInputElement ).value, ).toBe('非对称对抗'); expect( ( within(publicationDialog).getByRole('textbox', { name: '运营海报一句话描述游戏', }) as HTMLTextAreaElement ).value, ).toBe('找到钥匙,开门逃离马戏团'); expect(within(publicationDialog).getByLabelText('首图参考')).toBeTruthy(); expect(screen.getByLabelText('宣发素材生成占位图')).toBeTruthy(); }); it('opens a canvas generation frame and composer before creating a generated layer', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', width: 1024, height: 1024, sourceType: 'generated', prompt: '一张明亮的拼图主视觉', actualPrompt: '一张明亮的拼图主视觉', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-real-task-1', resource: createGeneratedResource({ resourceId: 'resource-generated-image-1', imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', assetKind: 'character', }), asset: createGeneratedAsset({ assetId: 'asset-generated-image-1', label: '生成图片 1', imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', assetKind: 'character', }), }), ); render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); fireEvent.click( within(bottomToolbar).getByRole('button', { name: '生成图片' }), ); const generateDialog = screen.getByRole('dialog', { name: '生成图片' }); const initialComposerTop = Number.parseFloat( (generateDialog as HTMLElement).style.top, ); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); expect(within(generateDialog).getByText('参考图')).toBeTruthy(); expect( within(generateDialog).getByRole('button', { name: '添加参考图' }) .className, ).toContain('image-canvas-editor__reference-chip'); expect( within(generateDialog).getByRole('button', { name: '添加参考图' }) .className, ).toContain('image-canvas-editor__reference-chip'); const generatePrompt = screen.getByLabelText('生成提示词'); expect(generatePrompt.className).toContain('auto-grow-text-area'); expect(generatePrompt.className).not.toContain('platform-text-field'); expect(generatePrompt.className).toContain( 'image-canvas-editor__generation-prompt', ); expect( within(generateDialog).getByRole('button', { name: '生成图片尺寸 1:1·1K', }).className, ).toContain('platform-inline-option-button'); expect( within(generateDialog).getByRole('button', { name: '生成图片模型 nanobanana2', }).className, ).toContain('platform-inline-option-button'); const pixelArtToggle = within(generateDialog).getByRole('checkbox', { name: '像素艺术', }) as HTMLInputElement; expect(pixelArtToggle.checked).toBe(false); expect( within(generateDialog).getByRole('button', { name: '生成' }).className, ).toContain('platform-button'); expect( within(generateDialog).getByRole('button', { name: '生成' }).className, ).toContain('image-canvas-editor__generation-submit'); expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张明亮的拼图主视觉' }, }); fireEvent.click(pixelArtToggle); fireEvent.click( within(generateDialog).getByRole('button', { name: '生成' }), ); expect(screen.getByRole('status').textContent).toContain('生成中'); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '一张明亮的拼图主视觉', model: 'gemini-3.1-flash-image-preview', style: 'pixelArt', aspectRatio: '1:1', imageSize: '1K', projectId: 'editor-project-default', assetFolderId: 'project', assetLabel: expect.stringMatching(/生成图片/u), }), ); }); const submittedGenerationInputs = generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs; expect(JSON.stringify(submittedGenerationInputs)).not.toContain( 'pixelArt', ); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); expect(createEditorAssetMock).not.toHaveBeenCalled(); expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button')!; expect(generatedLayer).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect( Number.isFinite( Number.parseFloat((generatedLayer as HTMLElement).style.top), ), ).toBe(true); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); const metadataButtons = screen.getAllByRole('button', { name: /查看生成图片 .*图片信息/, }); expect(metadataButtons[0]).toBeTruthy(); fireEvent.click(metadataButtons[0]!); const infoPanel = screen.getByRole('dialog', { name: '图片信息' }); expect(within(infoPanel).queryByText('Prompt')).toBeNull(); expect( within(infoPanel).queryByRole('button', { name: '复制Prompt' }), ).toBeNull(); expect(within(infoPanel).getByText('生成输入')).toBeTruthy(); expect(within(infoPanel).getByText('生成提示词')).toBeTruthy(); expect(within(infoPanel).getByText('一张明亮的拼图主视觉')).toBeTruthy(); }); it('drags the generation placeholder and places the generated layer there', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==', width: 1024, height: 1024, sourceType: 'generated', prompt: '拖拽后的生成图', actualPrompt: '拖拽后的生成图', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-drag-frame-1', }), ); render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); const initialComposerTop = Number.parseFloat( (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style .top, ); const frame = screen.getByLabelText('图像生成占位图'); dispatchPointerEvent(frame, 'pointerdown', { button: 0, pointerId: 61, clientX: 500, clientY: 260, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 61, clientX: 582, clientY: 342, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { pointerId: 61, clientX: 582, clientY: 342, }); const draggedComposerTop = Number.parseFloat( (screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style .top, ); expect(draggedComposerTop).toBeGreaterThan(initialComposerTop); const draggedFrame = screen.getByLabelText('图像生成占位图') as HTMLElement; const draggedFrameCenterX = Number.parseFloat(draggedFrame.style.left) + Number.parseFloat(draggedFrame.style.width) / 2; const draggedFrameCenterY = Number.parseFloat(draggedFrame.style.top) + Number.parseFloat(draggedFrame.style.height) / 2; fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '拖拽后的生成图' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button')!; expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); expect( Number.parseFloat((generatedLayer as HTMLElement).style.left) + Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2, ).toBeCloseTo(draggedFrameCenterX, 1); expect( Number.parseFloat((generatedLayer as HTMLElement).style.top) + Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2, ).toBeCloseTo(draggedFrameCenterY, 1); }); it('keeps the generation placeholder draggable while the image is generating', async () => { let resolveGeneration!: (value: unknown) => void; generateEditorImageMock.mockImplementationOnce( (input) => new Promise((resolve) => { resolveGeneration = (value) => resolve(createGeneratedImageResponse(input, value as never)); }), ); render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); const generateDialog = screen.getByRole('dialog', { name: '生成图片' }); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '生成中继续拖动的图片' }, }); fireEvent.click( within(generateDialog).getByRole('button', { name: '生成' }), ); const frame = screen.getByLabelText('图像生成占位图'); expect(frame.className).toContain( 'image-canvas-editor__generation-frame--generating', ); const initialLeft = Number.parseFloat((frame as HTMLElement).style.left); const initialTop = Number.parseFloat((frame as HTMLElement).style.top); dispatchPointerEvent(frame, 'pointerdown', { button: 0, pointerId: 67, clientX: 500, clientY: 260, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 67, clientX: 620, clientY: 360, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { pointerId: 67, clientX: 620, clientY: 360, }); const draggedFrame = screen.getByLabelText('图像生成占位图'); expect( Number.parseFloat((draggedFrame as HTMLElement).style.left), ).toBeGreaterThan(initialLeft); expect( Number.parseFloat((draggedFrame as HTMLElement).style.top), ).toBeGreaterThan(initialTop); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalled(); }); await act(async () => { resolveGeneration({ imageSrc: 'data:image/png;base64,Z2VuZXJhdGluZy1kcmFn', width: 1024, height: 1024, sourceType: 'generated', prompt: '生成中继续拖动的图片', actualPrompt: '生成中继续拖动的图片', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-generating-drag-1', }); }); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button')!; expect(generatedLayer).toBeTruthy(); }); it('confirms before deleting a generating placeholder because mud points are not refunded', async () => { let resolveGeneration!: (value: unknown) => void; generateEditorImageMock.mockReturnValueOnce( new Promise((resolve) => { resolveGeneration = resolve; }), ); render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '准备删除的生成中图片' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); const frame = screen.getByLabelText('图像生成占位图'); expect(frame.className).toContain( 'image-canvas-editor__generation-frame--generating', ); fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); const confirmDialog = screen.getByRole('dialog', { name: '删除生成中的占位图', }); expect( within(confirmDialog).getByText( '删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。', ), ).toBeTruthy(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); fireEvent.click( within(confirmDialog).getByRole('button', { name: '取消' }), ); expect( screen.queryByRole('dialog', { name: '删除生成中的占位图' }), ).toBeNull(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' }); fireEvent.click(screen.getByRole('button', { name: '确认删除' })); expect(screen.queryByLabelText('图像生成占位图')).toBeNull(); await act(async () => { resolveGeneration({ imageSrc: 'data:image/png;base64,ZGVsZXRlZC1wbGFjZWhvbGRlcg==', width: 1024, height: 1024, sourceType: 'generated', prompt: '准备删除的生成中图片', actualPrompt: '准备删除的生成中图片', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-deleted-placeholder-1', }); }); expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull(); }); it('hides the generation composer when selecting another image but keeps the placeholder', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); fireEvent.pointerDown( screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 62, clientX: 120, clientY: 120, }, ); expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('图像生成占位图'), { button: 0, pointerId: 64, clientX: 300, clientY: 180, }); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); }); it('hides the generation composer when clicking the canvas outside generation controls', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { button: 0, pointerId: 63, clientX: 260, clientY: 180, }); expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); }); it('does not render a standalone close button on the generation composer', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); expect(screen.queryByRole('button', { name: '关闭生成图片' })).toBeNull(); expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy(); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); }); it('shows generation errors instead of falling back to mock images', async () => { generateEditorImageMock.mockRejectedValueOnce( new Error('VectorEngine 未配置'), ); render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张真实生成失败的图' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); expect(screen.getByRole('status').textContent).toContain('生成中'); await waitFor(() => { expect(screen.getByRole('alert').textContent).toContain( 'VectorEngine 未配置', ); }); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull(); }); it('asks the user to log in when real generation is unauthorized', async () => { generateEditorImageMock.mockRejectedValueOnce( new ApiClientError({ message: '未授权访问(requestId: web-login-required)', status: 401, code: 'UNAUTHORIZED', }), ); render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张需要登录生成的图' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe( '请先登录后再生成图片', ); }); expect(screen.queryByText(/requestId/u)).toBeNull(); }); it('hides image generation setting panels after generation starts while keeping the preview frame visible', async () => { const cases = [ { open: () => { fireEvent.click(screen.getByRole('button', { name: '生成图片' })); selectGenerationDimensions( screen.getByRole('dialog', { name: '生成图片' }), '1:1', '2K', ); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '生成中的普通图片' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); }, dialogName: '生成图片', frameLabel: '图像生成占位图', expectedFrameSize: { width: 2048, height: 2048 }, }, { open: () => { fireEvent.click( within( screen.getByRole('toolbar', { name: 'AI画布工具栏' }), ).getByRole('button', { name: '生成规范' }), ); fireEvent.click( within( screen.getByRole('menu', { name: '生成规范类型' }), ).getByRole('menuitem', { name: '自定义规范' }), ); fireEvent.change(screen.getByLabelText('自定义规范提示词'), { target: { value: '生成中的自定义规范图' }, }); fireEvent.click( within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( 'button', { name: '提交生成规范' }, ), ); }, dialogName: '生成规范', frameLabel: '规范生成占位图', expectedFrameSize: { width: 2048, height: 1152 }, }, { open: () => { fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); selectGenerationDimensions( screen.getByRole('dialog', { name: '生成角色形象' }), '2:3', '2K', ); fireEvent.change(screen.getByLabelText('角色设定'), { target: { value: '生成中的角色形象' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); }, dialogName: '生成角色形象', frameLabel: '角色生成占位图', expectedFrameSize: { width: 1365, height: 2048 }, }, ] as const; for (const testCase of cases) { generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined)); const { unmount } = render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); testCase.open(); expect( screen.queryByRole('dialog', { name: testCase.dialogName }), ).toBeNull(); const frame = screen.getByLabelText(testCase.frameLabel); expect(frame.className).toContain( 'image-canvas-editor__generation-frame--generating', ); expect((frame as HTMLElement).style.width).toBe( `${testCase.expectedFrameSize.width}px`, ); expect((frame as HTMLElement).style.height).toBe( `${testCase.expectedFrameSize.height}px`, ); expect(within(frame).getByRole('status').textContent).toContain('生成中'); unmount(); } }); it('hides the icon material panel after generation starts while keeping the icon preview frame visible', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icons-generating', title: '图标素材生成中画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-icon-spec-generating', resourceId: 'resource-icon-spec-generating', title: '清爽按钮图标规范', src: 'data:image/png;base64,icon-spec-generating', x: 80, y: 80, width: 160, height: 160, originalWidth: 512, originalHeight: 512, zIndex: 10, sourceType: 'generated', assetKind: 'icon-spec', }, ], resources: [], updatedAt: '2026-06-12T00:00:00.000Z', }); generateEditorIconSpritesheetMock.mockReturnValueOnce( new Promise(() => undefined), ); render(); await screen.findByAltText('画布图片:清爽按钮图标规范'); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); fireEvent.click( within(iconPanel).getByRole('button', { name: '图标规范' }), ); fireEvent.click( within(screen.getByRole('menu', { name: '图标规范来源' })).getByRole( 'menuitem', { name: '从画布中选择' }, ), ); fireEvent.pointerDown( screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, { button: 0, pointerId: 1260, clientX: 120, clientY: 120, }, ); fireEvent.change( within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( 'textbox', { name: '素材描述' }, ), { target: { value: '返回按钮' } }, ); fireEvent.click( within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( 'button', { name: '生成' }, ), ); await waitFor(() => { expect( screen.queryByRole('dialog', { name: '生成图标素材' }), ).toBeNull(); }); const frame = screen.getByLabelText('图标素材生成占位图'); expect(frame.className).toContain( 'image-canvas-editor__generation-frame--generating', ); expect(within(frame).getByRole('status').textContent).toContain('生成中'); }); it('opens character spec generation form and creates a labeled spec layer', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,c3BlYy1yb2xl', width: 2048, height: 1152, sourceType: 'generated', prompt: '角色规范提示词', actualPrompt: '角色规范提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-spec-role-1', }), ); render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); const generationToolLabels = within(bottomToolbar) .getAllByRole('button') .filter((button) => button.getAttribute('aria-label')?.startsWith('生成')) .map((button) => button.getAttribute('aria-label')); expect(generationToolLabels).toContain('生成图片'); expect(generationToolLabels).toContain('生成规范'); fireEvent.click( within(bottomToolbar).getByRole('button', { name: '生成规范' }), ); const specMenu = screen.getByRole('menu', { name: '生成规范类型' }); expect( within(specMenu).getByRole('menuitem', { name: '角色规范' }), ).toBeTruthy(); expect( within(specMenu).getByRole('menuitem', { name: '图标规范' }), ).toBeTruthy(); expect( within(specMenu).getByRole('menuitem', { name: '自定义规范' }), ).toBeTruthy(); fireEvent.click( within(specMenu).getByRole('menuitem', { name: '角色规范' }), ); const specDialog = screen.getByRole('dialog', { name: '生成规范' }); expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); expect(screen.getByText('2048 x 1152')).toBeTruthy(); expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( '', ); expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( '', ); expect((screen.getByLabelText('头身比') as HTMLSelectElement).value).toBe( '3', ); expect((screen.getByLabelText('角色视角') as HTMLInputElement).value).toBe( '右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。', ); expect( within(specDialog).getByRole('button', { name: '提交生成规范' }) .textContent, ).toContain('生成5泥点'); fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '平台跳跃玩法' }, }); fireEvent.change(screen.getByLabelText('美术风格'), { target: { value: '低多边形卡通' }, }); fireEvent.change(screen.getByLabelText('头身比'), { target: { value: '4' }, }); fireEvent.change(screen.getByLabelText('角色视角'), { target: { value: '左向三分之二侧身站姿' }, }); fireEvent.submit(specDialog); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', model: 'gpt-image-2', size: '2048x1152', aspectRatio: '16:9', imageSize: '2K', prompt: expect.stringContaining('玩法设计:平台跳跃玩法'), projectId: 'editor-project-default', assetFolderId: 'project', assetKind: 'spec', generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ title: '玩法设定', value: '平台跳跃玩法', }), ]), }), }), ); }); const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; expect(prompt).toContain('生成2D 角色美术视觉规范设定图'); expect(prompt).toContain('美术风格:低多边形卡通'); expect(prompt).toContain('头身比:4'); expect(prompt).toContain('视角要求:左向三分之二侧身站姿'); await waitFor(() => { expect(screen.getByAltText(/画布图片:角色规范/)).toBeTruthy(); }); expect(screen.getByText('规范')).toBeTruthy(); expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( 'editor-project-default', expect.objectContaining({ expectedRevision: 1, layers: expect.arrayContaining([ expect.objectContaining({ itemType: 'generation-dialog', dialog: expect.objectContaining({ mode: 'spec', status: 'idle', generatedLayerId: 'layer-editor-spec-role-1', specValues: expect.objectContaining({ playSetting: '平台跳跃玩法', artStyle: '低多边形卡通', bodyRatio: '4', characterView: '左向三分之二侧身站姿', }), }), }), ]), }), ); }); it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => { render(); await screen.findByAltText('画布图片:拼图素材'); fireEvent.click( within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( 'button', { name: '生成规范' }, ), ); fireEvent.click(screen.getByRole('menuitem', { name: '角色规范' })); const characterSpecDialog = screen.getByRole('dialog', { name: '生成规范', }); ['玩法设定', '美术风格', '头身比', '角色视角'].forEach((title) => { expect(within(characterSpecDialog).getByText(title)).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconSpritesheetPanel = screen.getByRole('dialog', { name: '生成图标素材', }); expect( within(iconSpritesheetPanel).getByRole('button', { name: '图标规范', }), ).toBeTruthy(); expect( within(iconSpritesheetPanel).getByRole('textbox', { name: '素材描述', }), ).toBeTruthy(); expect(within(iconSpritesheetPanel).queryByText('素材描述 1')).toBeNull(); expect(within(iconSpritesheetPanel).queryByText('素材描述 6')).toBeNull(); expect( within(iconSpritesheetPanel).getByRole('button', { name: '生成图片模型 nanobanana2', }), ).toBeTruthy(); fireEvent.click( within(iconSpritesheetPanel).getByRole('button', { name: '图标规范', }), ); fireEvent.click(screen.getByRole('menuitem', { name: '新建图标规范' })); const iconSpecDialog = screen.getByRole('dialog', { name: '生成规范' }); ['玩法设定', '美术风格'].forEach((title) => { expect(within(iconSpecDialog).getByText(title)).toBeTruthy(); }); }); it('defaults character and icon generation to nanobanana2 model options', async () => { render(); await screen.findByAltText('画布图片:拼图素材'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象', }); expect( within(characterPanel).getByRole('button', { name: '生成图片尺寸 1:1·1K', }), ).toBeTruthy(); expect( within(characterPanel).getByRole('button', { name: '生成图片模型 nanobanana2', }), ).toBeTruthy(); const characterDimensionsPanel = openGenerationDimensionsPanel(characterPanel); expect( within(characterDimensionsPanel).getByText(/^(?:比例|Aspect ratio)$/u), ).toBeTruthy(); expect( within(characterDimensionsPanel).getByText(/^(?:尺寸|Size)$/u), ).toBeTruthy(); expect( within(characterDimensionsPanel).getByRole('button', { name: /^(?:比例 )?1:1$/u, }), ).toBeTruthy(); expect( within(characterDimensionsPanel).getByRole('button', { name: /^(?:尺寸 )?1K$/u, }), ).toBeTruthy(); const characterModelPanel = openGenerationModelPanel(characterPanel); expect( within(characterModelPanel).getByRole('button', { name: /nanobanana2/u, }), ).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect( within(iconPanel).getByRole('button', { name: '生成图片尺寸 1:1·1K', }), ).toBeTruthy(); expect( within(iconPanel).getByRole('button', { name: '生成图片模型 nanobanana2', }), ).toBeTruthy(); }); it('submits character generation with default model and dimension options', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,character-model-options', width: 1024, height: 1536, sourceType: 'generated', prompt: '高个子游侠', actualPrompt: '高个子游侠', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'character-model-options-1', }), ); render(); await screen.findByAltText('画布图片:拼图素材'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象', }); fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { target: { value: '高个子游侠' }, }); fireEvent.click( within(characterPanel).getByRole('button', { name: '生成' }), ); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'character', prompt: '高个子游侠', model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', }), ); }); }); it('remembers the last selected image model for character and icon generation', async () => { const rememberedProjectLayers = [ ...defaultEditorProjectLayers, { layerId: 'layer-remembered-icon-spec', resourceId: 'resource-remembered-icon-spec', title: '图标规范 2', src: 'data:image/png;base64,remembered-icon-spec', x: 180, y: 80, width: 560, height: 315, originalWidth: 2048, originalHeight: 1152, zIndex: 3, sourceType: 'generated', assetKind: 'icon-spec', }, ]; const rememberedProjectResources = [ ...defaultEditorProjectResources, { resourceId: 'resource-remembered-icon-spec', projectId: 'editor-project-default', imageSrc: 'data:image/png;base64,remembered-icon-spec', width: 2048, height: 1152, sourceType: 'generated', assetKind: 'icon-spec', }, ]; loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '默认项目', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: rememberedProjectLayers, resources: rememberedProjectResources, updatedAt: '2026-06-19T00:00:00.000Z', }); generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,character-gpt-model', width: 1024, height: 1024, sourceType: 'generated', prompt: '蓝衣剑士', actualPrompt: '蓝衣剑士', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'character-gpt-model-1', projectLayers: rememberedProjectLayers, projectResources: rememberedProjectResources, }), ); generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model', spritesheetWidth: 1024, spritesheetHeight: 1024, iconImageSrcs: [], prompt: '图标 prompt', actualPrompt: '图标 prompt', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'icon-gpt-model-1', }); render(); await screen.findByAltText('画布图片:拼图素材'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象', }); selectGenerationModel(characterPanel, 'gpt-image-2'); selectGenerationDimensions(characterPanel, '2:3', '2K'); fireEvent.change(within(characterPanel).getByLabelText('角色设定'), { target: { value: '蓝衣剑士' }, }); fireEvent.click( within(characterPanel).getByRole('button', { name: '生成' }), ); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'character', prompt: '蓝衣剑士', model: 'gpt-image-2', aspectRatio: '2:3', imageSize: '2K', }), ); }); await waitFor(() => { expect(screen.getByAltText(/画布图片:角色形象/u)).toBeTruthy(); }); const iconSpecLayer = await screen.findByAltText('画布图片:图标规范 2'); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect( within(iconPanel).getByRole('button', { name: '生成图片模型 gpt-image-2', }), ).toBeTruthy(); fireEvent.click( within(iconPanel).getByRole('button', { name: '图标规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); fireEvent.pointerDown(iconSpecLayer.closest('button')!, { button: 0, pointerId: 1640, clientX: 260, clientY: 100, }); expectReferenceChip( screen.getByRole('dialog', { name: '生成图标素材' }), '图标规范 2', ); fireEvent.change( within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( 'textbox', { name: '素材描述' }, ), { target: { value: '返回按钮' } }, ); fireEvent.click( within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole( 'button', { name: '生成' }, ), ); await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ model: 'gpt-image-2', aspectRatio: '2:3', imageSize: '2K', }), ); }); }); it('keeps the bottom AI toolbar visible while generation panels are open', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy(); }); it('keeps existing generation placeholders when another bottom generation object is created', async () => { render(); await act(async () => {}); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏', }); fireEvent.click( within(bottomToolbar).getByRole('button', { name: '生成规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '角色规范' })); expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByLabelText('规范生成占位图')).toBeTruthy(); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('规范生成占位图'), { button: 0, pointerId: 1701, clientX: 180, clientY: 180, }); expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy(); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); }); it('keeps archived generation logic using the latest placeholder when another object is active', async () => { let resolveGeneration!: (value: unknown) => void; generateEditorImageMock.mockImplementationOnce( (input) => new Promise((resolve) => { resolveGeneration = (value) => resolve(createGeneratedImageResponse(input, value as never)); }), ); render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '生成中切换后仍保留位置' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); const originalFrame = screen.getByLabelText('图像生成占位图'); const originalLeft = Number.parseFloat( (originalFrame as HTMLElement).style.left, ); const originalTop = Number.parseFloat( (originalFrame as HTMLElement).style.top, ); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByLabelText('图像生成占位图')).toBeTruthy(); const characterFrame = screen.getByLabelText('角色生成占位图'); expect(characterFrame).toBeTruthy(); dispatchPointerEvent( screen.getByLabelText('图像生成占位图'), 'pointerdown', { button: 0, pointerId: 1702, clientX: 500, clientY: 260, }, ); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', { pointerId: 1702, clientX: 650, clientY: 390, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { pointerId: 1702, clientX: 650, clientY: 390, }); const movedFrame = screen.getByLabelText('图像生成占位图'); const movedLeft = Number.parseFloat((movedFrame as HTMLElement).style.left); const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top); expect(movedLeft).toBeGreaterThan(originalLeft); expect(movedTop).toBeGreaterThan(originalTop); dispatchPointerEvent(characterFrame, 'pointerdown', { button: 0, pointerId: 1703, clientX: 360, clientY: 240, }); dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', { pointerId: 1703, clientX: 360, clientY: 240, }); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalled(); }); await act(async () => { resolveGeneration({ imageSrc: 'data:image/png;base64,YXJjaGl2ZWQtbG9naWM=', width: 1024, height: 1024, sourceType: 'generated', prompt: '生成中切换后仍保留位置', actualPrompt: '生成中切换后仍保留位置', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-archived-generation-1', }); }); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button') as HTMLElement; expect(generatedLayer).toBeTruthy(); expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); it('renders editor popup menus outside clipped local containers', () => { render(); const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' }); fireEvent.click( within(bottomToolbar).getByRole('button', { name: '生成规范' }), ); const specMenu = screen.getByRole('menu', { name: '生成规范类型' }); expect(bottomToolbar.contains(specMenu)).toBe(false); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); fireEvent.click( within(characterPanel).getByRole('button', { name: '角色规范' }), ); const referenceRow = characterPanel.querySelector( '.image-canvas-editor__reference-strip', ); const sourceMenu = screen.getByRole('menu', { name: '角色规范来源' }); if (!referenceRow) { throw new Error('角色生成面板应包含参考图区域'); } expect(referenceRow.contains(sourceMenu)).toBe(false); expect(sourceMenu.className).toContain('platform-floating-menu--top-start'); fireEvent.click( within(characterPanel).getByRole('button', { name: '上传常规参考图' }), ); const regularReferenceMenu = screen.getByRole('menu', { name: '常规参考图来源', }); expect(referenceRow.contains(regularReferenceMenu)).toBe(false); expect(regularReferenceMenu.className).toContain( 'platform-floating-menu--top-start', ); }); it('uses Lovart-style reference tiles in the character generation panel', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); const specTile = within(characterPanel).getByRole('button', { name: '角色规范', }); const uploadTile = within(characterPanel).getByRole('button', { name: '上传常规参考图', }); expect(specTile.className).toContain('image-canvas-editor__reference-chip'); expect(uploadTile.className).toContain( 'image-canvas-editor__reference-chip', ); expect( specTile.querySelector('.image-canvas-editor__reference-chip-icon'), ).toBeTruthy(); expect( uploadTile.querySelector('.image-canvas-editor__reference-chip-icon'), ).toBeTruthy(); }); it('uses one direct text input for icon asset descriptions', async () => { render(); await waitFor(() => { expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect(iconPanel.style.width).toBe(''); expect( iconPanel.querySelector('.image-canvas-editor__icon-description-list'), ).toBeNull(); expect( iconPanel.querySelector('.image-canvas-editor__icon-description-card'), ).toBeNull(); expect( iconPanel.querySelector('.image-canvas-editor__icon-spec-card'), ).toBeTruthy(); fireEvent.change( within(iconPanel).getByRole('textbox', { name: '素材描述' }), { target: { value: '返回按钮\n设置按钮\n提示按钮' } }, ); const iconPrompt = within(iconPanel).getByRole('textbox', { name: '素材描述', }); expect(iconPrompt.tagName).toBe('TEXTAREA'); expect(iconPrompt.className).toContain( 'image-canvas-editor__generation-prompt', ); expect(iconPrompt.className).not.toContain( 'image-canvas-editor__generation-prompt--borderless', ); expect( within(iconPanel).queryByRole('textbox', { name: '资源名称' }), ).toBeNull(); expect( within(iconPanel).queryByRole('button', { name: '添加素材描述' }), ).toBeNull(); }); it('hides the active generation panel and clears image selection after canvas background focus', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=', width: 1024, height: 1024, sourceType: 'generated', prompt: '发光蘑菇角色', actualPrompt: '发光蘑菇角色', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-focus-clear-1', }), ); render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '发光蘑菇角色' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); const generatedImage = await screen.findByAltText(/画布图片:生成图片/u); const generatedLayerButton = generatedImage.closest('button')!; expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); fireEvent.click(generatedLayerButton); expect(generatedLayerButton.className).toContain( 'image-canvas-editor__layer--selected', ); fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { button: 0, pointerId: 261, clientX: 40, clientY: 40, }); expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); expect(generatedLayerButton.className).not.toContain( 'image-canvas-editor__layer--selected', ); }); it('hides a newly created placeholder panel after canvas background focus', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); fireEvent.pointerDown(screen.getByLabelText('画布工作区'), { button: 0, pointerId: 262, clientX: 40, clientY: 40, }); expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); }); it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,c3BlYy11aQ==', width: 2048, height: 1152, sourceType: 'generated', prompt: 'UI规范提示词', actualPrompt: 'UI规范提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-spec-ui-1', }), ); render(); fireEvent.click( within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( 'button', { name: '生成规范' }, ), ); fireEvent.click(screen.getByRole('menuitem', { name: '图标规范' })); expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe( '', ); expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe( '', ); fireEvent.change(screen.getByLabelText('玩法设定'), { target: { value: '消除类派对玩法' }, }); fireEvent.change(screen.getByLabelText('美术风格'), { target: { value: '糖果玻璃拟物' }, }); fireEvent.submit(screen.getByRole('dialog', { name: '生成规范' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', model: 'gpt-image-2', size: '2048x1152', aspectRatio: '16:9', imageSize: '2K', prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'), projectId: 'editor-project-default', assetFolderId: 'project', assetKind: 'icon-spec', }), ); }); const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? ''; expect(prompt).toContain('玩法设定:消除类派对玩法'); expect(prompt).toContain('美术风格:糖果玻璃拟物'); await waitFor(() => { expect(screen.getByAltText(/画布图片:图标规范/)).toBeTruthy(); }); expect(screen.getAllByText('图标规范').length).toBeGreaterThan(0); }); it('uses the custom spec prompt without template rewriting', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,c3BlYy1jdXN0b20=', width: 2048, height: 1152, sourceType: 'generated', prompt: '自定义规范提示词', actualPrompt: '自定义规范提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-spec-custom-1', }), ); render(); fireEvent.click( within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( 'button', { name: '生成规范' }, ), ); fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' })); fireEvent.change(screen.getByLabelText('自定义规范提示词'), { target: { value: ' 生成一张武器图标规范展板 ' }, }); fireEvent.submit(screen.getByRole('dialog', { name: '生成规范' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', model: 'gpt-image-2', size: '2048x1152', aspectRatio: '16:9', imageSize: '2K', prompt: '生成一张武器图标规范展板', projectId: 'editor-project-default', assetFolderId: 'project', assetKind: 'spec', }), ); }); await waitFor(() => { expect(screen.getByAltText(/画布图片:自定义规范/)).toBeTruthy(); }); expect(screen.getByText('规范')).toBeTruthy(); }); it('keeps spec reference source menu actions clickable from the portal', async () => { render(); fireEvent.click( within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( 'button', { name: '生成规范' }, ), ); fireEvent.click(screen.getByRole('menuitem', { name: '角色规范' })); const specDialog = screen.getByRole('dialog', { name: '生成规范' }); await userEvent.click( within(specDialog).getByRole('button', { name: '参考图' }), ); await userEvent.click( within(screen.getByRole('menu', { name: '参考图来源' })).getByRole( 'menuitem', { name: '从画布中选择' }, ), ); expect( screen.getByText('请选择画布中的图片作为参考图,按 Esc 退出'), ).toBeTruthy(); fireEvent.pointerDown( screen.getByAltText('画布图片:拼图素材').closest('button')!, { button: 0, pointerId: 190, clientX: 120, clientY: 120, }, ); expectReferenceChip( screen.getByRole('dialog', { name: '生成规范' }), '拼图素材', ); await userEvent.click( within(screen.getByRole('dialog', { name: '生成规范' })).getByRole( 'button', { name: '参考图' }, ), ); await userEvent.click( within(screen.getByRole('menu', { name: '参考图来源' })).getByRole( 'menuitem', { name: '上传图片' }, ), ); await userEvent.upload( screen.getByLabelText('上传媒体文件'), new File(['spec-reference'], '规范上传.png', { type: 'image/png' }), ); await waitFor(() => { expectReferenceChip( screen.getByRole('dialog', { name: '生成规范' }), '参考图', ); }); }); it('supports character generation from a picked canvas spec and numbered references', async () => { const characterReferenceProjectLayers = [ defaultEditorProjectLayers[0]!, { ...defaultEditorProjectLayers[1]!, src: 'data:image/png;base64,YmlnLWZpc2gtcmVm', }, { layerId: 'layer-character-spec', resourceId: 'resource-character-spec', title: '角色规范', src: 'data:image/png;base64,cm9sZS1zcGVj', x: 180, y: 80, width: 560, height: 315, originalWidth: 2048, originalHeight: 1152, zIndex: 3, sourceType: 'generated', assetKind: 'spec', }, ]; const characterReferenceProjectResources = [ defaultEditorProjectResources[0]!, { ...defaultEditorProjectResources[1]!, imageSrc: 'data:image/png;base64,YmlnLWZpc2gtcmVm', }, { resourceId: 'resource-character-spec', projectId: 'editor-project-default', imageSrc: 'data:image/png;base64,cm9sZS1zcGVj', width: 2048, height: 1152, sourceType: 'generated', assetKind: 'spec', }, ]; loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '默认项目', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: characterReferenceProjectLayers, resources: characterReferenceProjectResources, updatedAt: '2026-06-19T00:00:00.000Z', }); generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,Y2hhcmFjdGVy', objectKey: 'generated-character-drafts/editor/character-images/editor-character-1/image.png', assetObjectId: 'asset-object-editor-character-1', width: 2048, height: 2048, sourceType: 'generated', prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', actualPrompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-character-1', projectLayers: characterReferenceProjectLayers, projectResources: characterReferenceProjectResources, }), ); render(); await screen.findByAltText('画布图片:角色规范'); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); expect( within(characterPanel).getByRole('button', { name: '角色规范' }), ).toBeTruthy(); fireEvent.click( within(characterPanel).getByRole('button', { name: '角色规范' }), ); const specSourceMenu = screen.getByRole('menu', { name: '角色规范来源', }); fireEvent.click( within(specSourceMenu).getByRole('menuitem', { name: '从画布中选择' }), ); expect( screen.getByText('请选择画布中的图片作为角色规范,按 Esc 退出'), ).toBeTruthy(); fireEvent.pointerDown( screen.getByAltText('画布图片:角色规范').closest('button')!, { button: 0, pointerId: 170, clientX: 120, clientY: 120, }, ); expectReferenceChip(characterPanel, '角色规范'); expect( screen.queryByText('请选择画布中的图片作为角色规范,按 Esc 退出'), ).toBeNull(); const canvasReferenceLayer = screen .getByAltText('画布图片:大鱼素材') .closest('button')!; expect(canvasReferenceLayer.className).not.toContain( 'image-canvas-editor__layer--selected', ); fireEvent.click( within(characterPanel).getByRole('button', { name: '上传常规参考图' }), ); const regularReferenceMenu = screen.getByRole('menu', { name: '常规参考图来源', }); fireEvent.click( within(regularReferenceMenu).getByRole('menuitem', { name: '从画布中选择', }), ); expect( screen.getByText('请选择画布中的图片作为常规参考图,按 Esc 退出'), ).toBeTruthy(); fireEvent.pointerDown(canvasReferenceLayer, { button: 0, pointerId: 171, clientX: 180, clientY: 120, }); expect( screen.queryByText('请选择画布中的图片作为常规参考图,按 Esc 退出'), ).toBeNull(); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); expect(canvasReferenceLayer.className).not.toContain( 'image-canvas-editor__layer--selected', ); expect(within(characterPanel).getByText('大鱼素材')).toBeTruthy(); const readyCharacterPanel = screen.getByRole('dialog', { name: '生成角色形象', }); fireEvent.change(within(readyCharacterPanel).getByLabelText('角色设定'), { target: { value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。' }, }); fireEvent.click( within(readyCharacterPanel).getByRole('button', { name: '生成' }), ); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'character', prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', referenceImageSrcs: [ 'resource-character-spec', 'resource-big-fish', ], projectId: 'editor-project-default', assetFolderId: 'project', assetKind: 'character', generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ title: '角色设定', value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', }), ]), }), }), ); }); const characterGenerationInput = generateEditorImageMock.mock.calls[0]?.[0]; expect(characterGenerationInput?.generationInputs).toEqual( expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ title: '角色设定', value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。', }), ]), references: expect.arrayContaining([ expect.objectContaining({ title: '角色规范', label: '角色规范', }), expect.objectContaining({ title: '常规参考图 1', label: '大鱼素材', }), ]), }), ); }); it('removes the active character generation placeholder with Backspace', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); expect(screen.getByLabelText('角色生成占位图')).toBeTruthy(); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); await act(async () => { fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' }); }); expect(screen.queryByLabelText('角色生成占位图')).toBeNull(); expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull(); expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy(); }); it('opens icon asset generation panel, only picks icon specs, and lays out the generated spritesheet slices', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icons', title: '图标素材画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-plain', resourceId: 'resource-plain', title: '普通参考图', src: 'data:image/png;base64,plain', x: 80, y: 80, width: 120, height: 120, originalWidth: 512, originalHeight: 512, zIndex: 10, sourceType: 'uploaded', }, { layerId: 'layer-icon-spec', resourceId: 'resource-icon-spec', title: '清爽按钮图标规范', src: 'data:image/png;base64,icon-spec', x: 240, y: 80, width: 160, height: 120, originalWidth: 2048, originalHeight: 1152, zIndex: 11, sourceType: 'generated', assetKind: 'icon-spec', }, ], resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,sheet', spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [ { name: '返回按钮', imageSrc: 'data:image/png;base64,back', width: 128, height: 128, }, { name: '设置按钮', imageSrc: 'data:image/png;base64,settings', width: 128, height: 128, }, ], prompt: '图标 prompt', actualPrompt: '图标 prompt', model: 'gemini-3.1-flash-image-preview', provider: 'VectorEngine', taskId: 'icon-task-1', }); render(); await waitFor(() => { expect(screen.getByAltText('画布图片:普通参考图')).toBeTruthy(); expect(screen.getByAltText('画布图片:清爽按钮图标规范')).toBeTruthy(); }); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); expect(screen.getByLabelText('图标素材生成占位图')).toBeTruthy(); expect( within(iconPanel).getByRole('button', { name: '图标规范' }), ).toBeTruthy(); expect( ( within(iconPanel).getByRole('textbox', { name: '素材描述', }) as HTMLTextAreaElement ).value, ).toBe(''); fireEvent.click( within(iconPanel).getByRole('button', { name: '图标规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); expect( screen.getByText('请选择画布中的图标规范,按 Esc 退出'), ).toBeTruthy(); fireEvent.pointerDown( screen.getByAltText('画布图片:普通参考图').closest('button')!, { button: 0, pointerId: 180, clientX: 100, clientY: 100, }, ); expect( within(iconPanel).getByRole('button', { name: '图标规范' }), ).toBeTruthy(); fireEvent.pointerDown( screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, { button: 0, pointerId: 181, clientX: 260, clientY: 100, }, ); expect( within(iconPanel).getByRole('button', { name: '清爽按钮图标规范' }), ).toBeTruthy(); expect( screen.queryByText('请选择画布中的图标规范,按 Esc 退出'), ).toBeNull(); fireEvent.change( within(iconPanel).getByRole('textbox', { name: '素材描述' }), { target: { value: '返回按钮\n设置按钮' }, }, ); fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ referenceImageSrc: 'resource-icon-spec', iconDescriptions: ['返回按钮\n设置按钮'], model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', projectId: 'editor-project-icons', assetFolderId: 'project', generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ title: '素材描述', value: '返回按钮\n设置按钮', }), ]), }), }), ); }); await waitFor(() => { expect(screen.getByAltText(/图层缩略图:图标素材图集/u)).toBeTruthy(); }); expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy(); expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy(); expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull(); expect(screen.getByText('图集')).toBeTruthy(); fireEvent.click( screen.getByRole('button', { name: /查看图标素材图集.*图片信息/u }), ); const iconInfoPanel = screen.getByRole('dialog', { name: '图片信息' }); expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull(); expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy(); expect(within(iconInfoPanel).getByText('素材描述')).toBeTruthy(); expect( within(iconInfoPanel).getByText( (_, element) => element?.textContent === '返回按钮\n设置按钮', ), ).toBeTruthy(); expect(within(iconInfoPanel).getByText('图标规范')).toBeTruthy(); expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy(); }); it('refreshes the asset library after an icon generation project snapshot is applied', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-icon-library-refresh', title: '图标素材刷新画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-icon-spec-refresh', resourceId: 'resource-icon-spec-refresh', title: '清爽按钮图标规范', src: 'data:image/png;base64,icon-spec-refresh', x: 240, y: 80, width: 160, height: 120, originalWidth: 2048, originalHeight: 1152, zIndex: 11, sourceType: 'generated', assetKind: 'icon-spec', }, ], resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); loadEditorAssetLibraryMock .mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [], }) .mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createGeneratedAsset({ assetId: 'asset-icon-spritesheet-refresh', folderId: 'project', label: '清爽按钮素材图集', imageSrc: 'data:image/png;base64,icon-sheet-refresh', width: 512, height: 512, assetKind: 'icon-spritesheet', }), ], }); const spritesheetResource = createGeneratedResource({ resourceId: 'resource-icon-spritesheet-refresh', projectId: 'editor-project-icon-library-refresh', imageSrc: 'data:image/png;base64,icon-sheet-refresh', width: 512, height: 512, assetKind: 'icon-spritesheet', }); generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,icon-sheet-refresh', spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [], prompt: '图标 prompt', actualPrompt: '图标 prompt', model: 'gemini-3.1-flash-image-preview', provider: 'VectorEngine', taskId: 'icon-library-refresh-task', priceMudPoints: 3, spritesheetResource, spritesheetAsset: createGeneratedAsset({ assetId: 'asset-icon-spritesheet-refresh', folderId: 'project', label: '清爽按钮素材图集', imageSrc: 'data:image/png;base64,icon-sheet-refresh', width: 512, height: 512, assetKind: 'icon-spritesheet', }), project: { projectId: 'editor-project-icon-library-refresh', title: '图标素材刷新画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [spritesheetResource], updatedAt: '2026-06-15T00:01:00.000Z', }, }); render(); await screen.findByAltText('画布图片:清爽按钮图标规范'); await waitFor(() => { expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(1); }); expect( screen.queryByRole('button', { name: '添加清爽按钮素材图集' }), ).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); fireEvent.click( within(iconPanel).getByRole('button', { name: '图标规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); fireEvent.pointerDown( screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!, { button: 0, pointerId: 182, clientX: 260, clientY: 100, }, ); fireEvent.change( within(iconPanel).getByRole('textbox', { name: '素材描述' }), { target: { value: '返回按钮' }, }, ); fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalled(); }); await waitFor(() => { expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2); }); fireEvent.click(screen.getByRole('button', { name: '打开素材' })); expect( await screen.findByRole('button', { name: '添加清爽按钮素材图集' }), ).toBeTruthy(); }); it('reloads project and asset snapshots when a restored queued generation completed after refresh', async () => { window.history.replaceState(null, '', '/editor/canvas'); const projectId = 'editor-project-refresh-completion'; loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId, title: '刷新恢复画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { itemType: 'generation-dialog', layerId: 'generation-dialog:generation-dialog-refresh-character', resourceId: 'generation-dialog:generation-dialog-refresh-character', dialog: { id: 'generation-dialog-refresh-character', mode: 'character', prompt: '刷新后角色', status: 'generating', composerOpen: false, imageModel: 'gpt-image-2', placeholder: { x: 240, y: 120, width: 420, height: 560, originalWidth: 1024, originalHeight: 1536, }, }, }, ], resources: [], updatedAt: '2026-06-21T00:00:00.000Z', }); const generatedResource = createGeneratedResource({ resourceId: 'resource-refresh-character-result', projectId, imageSrc: 'data:image/png;base64,cmVmcmVzaC1yZXN1bHQ=', width: 1024, height: 1536, assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '刷新后角色' }], references: [], }, }); loadEditorProjectMock.mockResolvedValueOnce({ projectId, title: '刷新恢复画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-refresh-character-result', resourceId: 'resource-refresh-character-result', title: '刷新后生成结果', x: 240, y: 120, width: 1024, height: 1536, originalWidth: 1024, originalHeight: 1536, zIndex: 12, sourceType: 'generated', assetKind: 'character', }, { itemType: 'generation-dialog', layerId: 'generation-dialog:generation-dialog-refresh-character', resourceId: 'generation-dialog:generation-dialog-refresh-character', dialog: { id: 'generation-dialog-refresh-character', mode: 'character', prompt: '刷新后角色', status: 'idle', composerOpen: false, generatedLayerId: 'layer-refresh-character-result', imageModel: 'gpt-image-2', placeholder: { x: 240, y: 120, width: 420, height: 560, originalWidth: 1024, originalHeight: 1536, }, }, }, ], resources: [generatedResource], updatedAt: '2026-06-21T00:01:00.000Z', }); loadEditorAssetLibraryMock .mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [], }) .mockResolvedValueOnce({ folders: [ { folderId: 'project', label: '项目素材', sortOrder: 0, collapsed: false, systemDefault: true, }, ], assets: [ createGeneratedAsset({ assetId: 'asset-refresh-character-result', folderId: 'project', label: '刷新后生成结果素材', imageSrc: 'data:image/png;base64,cmVmcmVzaC1yZXN1bHQ=', width: 1024, height: 1536, assetKind: 'character', }), ], }); const completedTask = createExternalGenerationTask({ jobId: 'task-refresh-character-result', sourceEntityId: projectId, requestPrompt: '刷新后角色', status: 'completed', progress: 100, phaseDetail: '生成已完成。', warning: '图集已生成,但自动拆分未完成:连通域数量不足', completedAt: '2026-06-21T00:01:00.000Z', updatedAt: '2026-06-21T00:01:00.000Z', updatedAtMicros: 2, }); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; const url = new URL(requestUrl, 'http://localhost'); if (url.pathname === '/api/runtime/external-generation/jobs') { const statuses = url.searchParams.get('statuses') ?? ''; return new Response( JSON.stringify({ overview: { pendingCount: 0, runningCount: 0, unacknowledgedTerminalCount: 1, updatedAtMicros: 2, }, tasks: statuses.includes('completed') ? [completedTask] : [], }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ); } return new Response(null, { status: 404 }); }); render(); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledWith(projectId); }); expect(await screen.findByAltText('画布图片:刷新后生成结果')).toBeTruthy(); expect((await screen.findByRole('alert')).textContent).toBe( '图集已生成,但自动拆分未完成:连通域数量不足', ); await waitFor(() => { expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2); }); expect(createEditorAssetMock).not.toHaveBeenCalled(); expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); }); it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=', width: 2048, height: 1152, sourceType: 'generated', prompt: '图标规范提示词', actualPrompt: '图标规范提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-icon-spec-renamed-1', }), ); generateEditorIconSpritesheetMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,cmVuYW1lZC1zaGVldA==', spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [], prompt: '图标 prompt', actualPrompt: '图标 prompt', model: 'gemini-3.1-flash-image-preview', provider: 'VectorEngine', taskId: 'icon-task-renamed-spec-1', }); render(); fireEvent.click( within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole( 'button', { name: '生成规范' }, ), ); fireEvent.click(screen.getByRole('menuitem', { name: '图标规范' })); const specDialog = screen.getByRole('dialog', { name: '生成规范' }); fireEvent.change(within(specDialog).getByLabelText('玩法设定'), { target: { value: '背包整理玩法' }, }); fireEvent.click( within(specDialog).getByRole('button', { name: '提交生成规范' }), ); const generatedSpec = await screen.findByAltText(/画布图片:图标规范/u); expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ kind: 'spec', prompt: expect.stringContaining('背包整理玩法'), projectId: 'editor-project-default', assetFolderId: 'project', assetKind: 'icon-spec', generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ title: '玩法设定', value: '背包整理玩法', }), ]), }), }), ); fireEvent.click(screen.getByRole('button', { name: '生成图标素材' })); const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' }); fireEvent.click( within(iconPanel).getByRole('button', { name: '图标规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); fireEvent.pointerDown(generatedSpec.closest('button')!, { button: 0, pointerId: 182, clientX: 260, clientY: 100, }); expect( within(iconPanel).getByRole('button', { name: /^图标规范 \d+$/u }), ).toBeTruthy(); expect( screen.queryByText('请选择画布中的图标规范,按 Esc 退出'), ).toBeNull(); fireEvent.change( within(iconPanel).getByRole('textbox', { name: '素材描述' }), { target: { value: '背包按钮' }, }, ); fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' })); await waitFor(() => { expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith( expect.objectContaining({ referenceImageSrc: 'resource-editor-icon-spec-renamed-1', iconDescriptions: ['背包按钮'], model: 'gemini-3.1-flash-image-preview', aspectRatio: '1:1', imageSize: '1K', projectId: 'editor-project-default', assetFolderId: 'project', }), ); }); await waitFor(() => { expect(screen.getByAltText(/图层缩略图:图标素材图集/u)).toBeTruthy(); }); expect(screen.queryByAltText('画布图片:背包按钮')).toBeNull(); }); it('exits character generation canvas picking with Escape', () => { render(); fireEvent.click(screen.getByRole('button', { name: '生成角色形象' })); const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' }); fireEvent.click( within(characterPanel).getByRole('button', { name: '角色规范' }), ); fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' })); expect( screen.getByText('请选择画布中的图片作为角色规范,按 Esc 退出'), ).toBeTruthy(); fireEvent.keyDown(window, { key: 'Escape' }); expect( screen.queryByText('请选择画布中的图片作为角色规范,按 Esc 退出'), ).toBeNull(); expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy(); }); it('only exposes character animation generation for character layers and submits the panel payload', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-character-animation', title: '角色动画画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-character', resourceId: 'resource-character', title: '市场老妇人', src: 'data:image/png;base64,character', x: 160, y: 140, width: 320, height: 320, originalWidth: 1024, originalHeight: 1024, zIndex: 2, sourceType: 'generated', objectKey: 'generated-character-drafts/editor/character-images/source/image.png', assetKind: 'character', }, { layerId: 'layer-prop', resourceId: 'resource-prop', title: '普通道具', src: 'data:image/png;base64,prop', x: 520, y: 140, width: 280, height: 220, originalWidth: 700, originalHeight: 550, zIndex: 1, sourceType: 'uploaded', }, ], resources: [], updatedAt: '2026-06-15T00:00:00.000Z', }); const animationFrames = Array.from({ length: 48 }, (_, index) => ({ imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`, width: 1024, height: 1024, })); generateEditorCharacterAnimationMock.mockResolvedValueOnce({ taskId: 'character-animation-task-1', model: 'seedance2.0-fast', prompt: '生成游戏角色动画\n动作描述:\n待机', previewVideoPath: '/generated-character-drafts/editor/preview.mp4', frames: animationFrames, frameCount: 48, durationSeconds: 6, fps: 8, resource: { resourceId: 'resource-character-animation-final', projectId: 'editor-project-character-animation', imageSrc: animationFrames[0]!.imageSrc, width: 1024, height: 1024, sourceType: 'generated', sourceResourceId: 'resource-character-animation-preview', assetKind: 'character-animation', imageSequenceFrames: animationFrames, imageSequenceDurationMs: 6_000, }, }); vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { const requestUrl = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; const url = new URL(requestUrl, 'http://localhost'); if (url.pathname === '/api/assets/read-url') { const legacyPublicPath = url.searchParams.get('legacyPublicPath') ?? 'generated-frame.png'; return new Response( JSON.stringify({ signedUrl: `https://assets.test${legacyPublicPath}`, expiresAt: '2099-01-01T00:00:00.000Z', }), { status: 200, headers: { 'Content-Type': 'application/json' }, }, ); } return new Response(null, { status: 404 }); }); render(); const propLayer = await screen.findByAltText('画布图片:普通道具'); fireEvent.click(propLayer.closest('button')!); expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull(); fireEvent.contextMenu(propLayer.closest('button')!, { clientX: 220, clientY: 180, }); expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull(); // 项目封面快照会在图层落位后触发一次重渲染,同步查询可能正好落进该窗口; // 这里用异步查询容忍这次重渲染,不改变断言语义。 const characterLayer = await screen.findByAltText('画布图片:市场老妇人'); fireEvent.click(characterLayer.closest('button')!); expect(screen.getByText('角色')).toBeTruthy(); expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull(); expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); expect( screen.queryByRole('dialog', { name: '角色动画生成面板' }), ).toBeNull(); fireEvent.contextMenu(characterLayer.closest('button')!, { clientX: 260, clientY: 220, }); expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull(); expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull(); fireEvent.click(screen.getByRole('button', { name: '生成动画' })); expect( screen.getByRole('button', { name: '角色动作生成占位图' }), ).toBeTruthy(); expect(screen.getByText('Action Generator')).toBeTruthy(); expect(screen.getByText('动作')).toBeTruthy(); const panel = screen.getByRole('dialog', { name: '角色动画生成面板' }); expect(panel.className).toContain( 'image-canvas-editor__generation-composer--character-animation', ); expect( within(panel).getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p', }), ).toBeTruthy(); expect( within(panel).getByRole('button', { name: '生成40泥点' }), ).toBeTruthy(); for (const actionLabel of [ '待机', '行走', '奔跑', '跳跃', '攻击', '受击', '倒下', ]) { expect( within(panel).getByRole('button', { name: actionLabel }), ).toBeTruthy(); } fireEvent.click(within(panel).getByRole('button', { name: '待机' })); expect( (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value, ).toContain('待机'); const longPrompt = '走'.repeat(4100); fireEvent.change(within(panel).getByLabelText('动画描述'), { target: { value: longPrompt }, }); expect( (within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value, ).toHaveLength(4000); const precisePrompt = 'The elderly market woman gently shifts weight while the basket sways.'; fireEvent.change(within(panel).getByLabelText('动画描述'), { target: { value: precisePrompt }, }); expect( within(panel).getByLabelText(`生成文本:${precisePrompt}`), ).toBeTruthy(); fireEvent.click( within(panel).getByRole('button', { name: '动画参数 同图尺寸 · 4秒 · 480p', }), ); const animationMenu = screen.getByRole('menu', { name: '动画参数选项' }); fireEvent.click( within(animationMenu).getByRole('button', { name: '清晰度 720p' }), ); fireEvent.click( within(animationMenu).getByRole('button', { name: '比例 16:9' }), ); fireEvent.click( within(animationMenu).getByRole('button', { name: '时长 48帧·6秒' }), ); expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy(); expect( within(panel).getByRole('button', { name: '动画参数 16:9 · 6秒 · 720p', }), ).toBeTruthy(); fireEvent.click(within(panel).getByRole('button', { name: '生成120泥点' })); await waitFor(() => { expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith( expect.objectContaining({ sourceLayerId: 'layer-character', sourceImageSrc: 'generated-character-drafts/editor/character-images/source/image.png', sourceWidth: 1024, sourceHeight: 1024, resolution: '720p', ratio: '16:9', frameCount: 48, durationSeconds: 6, model: 'seedance2.0-fast', }), ); }); expect( generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText, ).toBe(precisePrompt); await waitFor(() => { expect(screen.getByAltText('画布序列帧:角色动作')).toBeTruthy(); }); const actionFrame = screen.getByAltText('画布序列帧:角色动作'); expect(actionFrame.tagName.toLowerCase()).toBe('img'); await waitFor(() => { expect(actionFrame.getAttribute('src')).toBe( 'https://assets.test/generated-character-drafts/editor/frame1.png', ); }); // 中文注释:分子不能钉死。序列帧图层默认自动播放(frames.length > 1 即 isPlaying), // 48 帧 6 秒算出 125ms 的真实 setInterval,而这里用的是真实定时器下的 waitFor—— // 上一条 waitFor 在 CI 上多轮询一次就已经越过 125ms,计数器变成 2/48。要断言的是 // 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。 expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy(); expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull(); expect(screen.getByText('动作')).toBeTruthy(); fireEvent.click(screen.getByRole('button', { name: '选择角色动作' })); fireEvent.click(screen.getByRole('button', { name: '改造' })); const remodelPanel = screen.getByRole('dialog', { name: '角色动画生成面板', }); expect( (within(remodelPanel).getByLabelText('动画描述') as HTMLTextAreaElement) .value, ).toBe(precisePrompt); }); it('extracts UI design assets from the floating toolbar and keeps the spritesheet on canvas', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-ui-extract', title: 'UI素材提取画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-ui-design', resourceId: 'resource-ui-design', title: '战斗UI设计图', src: 'data:image/png;base64,dWktZGVzaWdu', x: 120, y: 140, width: 560, height: 315, originalWidth: 2048, originalHeight: 1152, zIndex: 2, sourceType: 'generated', prompt: '战斗UI', actualPrompt: '战斗UI', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'ui-design-task-1', assetKind: 'ui-design', }, ], resources: [], updatedAt: '2026-06-18T00:00:00.000Z', }); extractEditorUiDesignAssetsMock.mockResolvedValueOnce({ spritesheetImageSrc: 'data:image/png;base64,dWktc2hlZXQ=', spritesheetWidth: 512, spritesheetHeight: 512, iconImageSrcs: [ { name: '素材 1', imageSrc: 'data:image/png;base64,YXNzZXQtMQ==', width: 96, height: 96, }, ], prompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', actualPrompt: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'ui-extract-task-1', }); render(); const uiDesignImage = await screen.findByAltText('画布图片:战斗UI设计图'); fireEvent.pointerDown(uiDesignImage.closest('button')!, { button: 0, pointerId: 156, clientX: 180, clientY: 180, }); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 156, clientX: 180, clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '提取素材' })); const extractionToolbar = await screen.findByRole('toolbar', { name: 'UI素材框选工具', }); expect(extractionToolbar).toBeTruthy(); const extractionPanel = await screen.findByRole('dialog', { name: 'UI素材提取', }); expect( within(extractionPanel).getByRole('button', { name: '提取', }).disabled, ).toBe(true); expect(extractEditorUiDesignAssetsMock).not.toHaveBeenCalled(); const extractionCanvas = screen.getByRole('application', { name: 'UI素材框选画布', }); vi.spyOn(extractionCanvas, 'getBoundingClientRect').mockReturnValue({ x: 120, y: 140, left: 120, top: 140, right: 680, bottom: 455, width: 560, height: 315, toJSON: () => ({}), } as DOMRect); dispatchPointerEvent(extractionCanvas, 'pointerdown', { pointerId: 201, clientX: 140, clientY: 160, }); dispatchPointerEvent(extractionCanvas, 'pointermove', { pointerId: 201, clientX: 300, clientY: 260, }); dispatchPointerEvent(extractionCanvas, 'pointerup', { pointerId: 201, clientX: 300, clientY: 260, }); expect( within( screen.getByRole('dialog', { name: 'UI素材提取' }), ).getByRole('button', { name: '提取', }).disabled, ).toBe(false); fireEvent.click( within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole( 'button', { name: '提取' }, ), ); await waitFor(() => { expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith( expect.objectContaining({ source: 'data:image/png;base64,dWktZGVzaWdu', marks: expect.arrayContaining([ expect.objectContaining({ tool: 'rect', }), ]), }), ); }); await waitFor(() => { expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'generated-character-drafts/editor/generation-references/marked-ui-design.png', screenColor: 'auto', segModel: 'birefnet', aspectRatio: '1:1', imageSize: '1K', projectId: 'editor-project-ui-extract', assetFolderId: 'project', spritesheetLabel: '战斗UI设计图 素材图集', generationInputs: expect.objectContaining({ fields: expect.arrayContaining([ expect.objectContaining({ title: '提取提示词', value: '仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。', }), ]), references: expect.arrayContaining([ expect.objectContaining({ title: 'UI设计图', label: '战斗UI设计图', }), ]), }), }), ); }); expect( await screen.findByAltText('画布图片:战斗UI设计图 素材图集'), ).toBeTruthy(); expect(screen.getByAltText('画布图片:素材 1')).toBeTruthy(); expect(screen.getByText('图集')).toBeTruthy(); }); it('closes UI design asset extraction when clicking outside the source and panel', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-ui-extract-dismiss', title: 'UI素材提取外部关闭画布', canvas: { revision: 0 }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { layerId: 'layer-ui-design-dismiss', resourceId: 'resource-ui-design-dismiss', title: '背包UI设计图', src: 'data:image/png;base64,dWktZGVzaWdu', x: 120, y: 140, width: 560, height: 315, originalWidth: 2048, originalHeight: 1152, zIndex: 2, sourceType: 'generated', prompt: '背包UI', actualPrompt: '背包UI', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'ui-design-dismiss-task-1', assetKind: 'ui-design', }, ], resources: [], updatedAt: '2026-06-18T00:00:00.000Z', }); render(); const uiDesignImage = await screen.findByAltText('画布图片:背包UI设计图'); fireEvent.pointerDown(uiDesignImage.closest('button')!, { button: 0, pointerId: 256, clientX: 180, clientY: 180, }); fireEvent.pointerUp(screen.getByLabelText('画布工作区'), { pointerId: 256, clientX: 180, clientY: 180, }); fireEvent.click(screen.getByRole('button', { name: '提取素材' })); expect( await screen.findByRole('dialog', { name: 'UI素材提取' }), ).toBeTruthy(); fireEvent.pointerDown(screen.getByTestId('image-canvas-world'), { button: 0, pointerId: 257, clientX: 24, clientY: 24, }); await waitFor(() => { expect(screen.queryByRole('dialog', { name: 'UI素材提取' })).toBeNull(); }); }); it('opens generated image info from the corner button', async () => { generateEditorImageMock.mockImplementationOnce(async (input) => createGeneratedImageResponse(input, { imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==', width: 1024, height: 1024, sourceType: 'generated', prompt: '一张可修改的生成图', actualPrompt: '一张可修改的生成图', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'editor-real-task-2', }), ); render(); fireEvent.click(screen.getByRole('button', { name: '生成图片' })); fireEvent.change(screen.getByLabelText('生成提示词'), { target: { value: '一张可修改的生成图' }, }); fireEvent.click(screen.getByRole('button', { name: '生成' })); await waitFor(() => { expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy(); }); const generatedLayer = screen .getByAltText(/画布图片:生成图片/) .closest('button') as HTMLElement; expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024); expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024); expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull(); const metadataCornerButton = screen.getAllByRole('button', { name: /查看生成图片 .*图片信息/, })[0]; if (!metadataCornerButton) { throw new Error('metadata corner button should exist'); } expect(metadataCornerButton.className).toContain( 'image-canvas-editor__metadata-corner', ); fireEvent.click(metadataCornerButton); const metadataDialog = screen.getByRole('dialog', { name: '图片信息' }); expect(metadataDialog).toBeTruthy(); expect(within(metadataDialog).getByText('图片类型')).toBeTruthy(); expect(within(metadataDialog).getByText('生成图片')).toBeTruthy(); expect(within(metadataDialog).queryByText('Prompt')).toBeNull(); expect( within(metadataDialog).queryByRole('button', { name: '复制Prompt' }), ).toBeNull(); expect(within(metadataDialog).getByText('生成输入')).toBeTruthy(); expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy(); expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy(); expect(within(metadataDialog).getByText('Model')).toBeTruthy(); expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy(); expect(within(metadataDialog).queryByText('Size')).toBeNull(); expect(within(metadataDialog).getByText('Resolution')).toBeTruthy(); expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy(); expect( screen.getByRole('button', { name: /当前缩放比例 \d+%/u }), ).toBeTruthy(); }); });