/* @vitest-environment jsdom */ import { act, fireEvent, render, screen, waitFor, } from '@testing-library/react'; import { useEffect, useRef, useState } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { EditorPixelArtSnapInput, EditorProjectSnapshot, } from '../../services/image-editor/editorProjectClient'; import { PERFECT_PIXEL_RECONCILIATION_WINDOW_MS } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasTool, GenerateDialogState, ImageContextMenuState, SidebarPanel, } from './ImageCanvasEditorTypes'; import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { inspectPerfectPixelProjectSnapshot, useImageCanvasGenerationWorkflow, } from './useImageCanvasGenerationWorkflow'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn()); const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const renderCropExpandImageMock = vi.hoisted(() => vi.fn()); const removeImageBackgroundMock = vi.hoisted(() => vi.fn()); const snapImageToPerfectPixelsMock = vi.hoisted(() => vi.fn()); const loadEditorProjectMock = vi.hoisted(() => vi.fn()); const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn()); vi.mock('../../services/image-editor/editorImageReference', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorImageReference') >('../../services/image-editor/editorImageReference'); return { ...actual, resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock, resolveEditorImageReferenceDataUrlForGeneration: vi.fn( async (src: string) => src, ), }; }); vi.mock('../../services/image-editor/editorProjectClient', async () => { const actual = await vi.importActual< typeof import('../../services/image-editor/editorProjectClient') >('../../services/image-editor/editorProjectClient'); return { ...actual, editEditorImage: editEditorImageMock, createEditorProjectResource: createEditorProjectResourceMock, generateEditorBackgroundMusic: generateEditorBackgroundMusicMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, generateEditorIconSpritesheet: generateEditorIconSpritesheetMock, generateEditorImage: generateEditorImageMock, generateEditorSoundEffect: generateEditorSoundEffectMock, loadEditorProject: loadEditorProjectMock, splitEditorIconSpritesheet: splitEditorIconSpritesheetMock, }; }); vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({ uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock, uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock, })); vi.mock('./ImageCanvasRasterEditModel', async () => { const actual = await vi.importActual< typeof import('./ImageCanvasRasterEditModel') >('./ImageCanvasRasterEditModel'); return { ...actual, removeImageBackground: removeImageBackgroundMock, renderCropExpandImage: renderCropExpandImageMock, snapImageToPerfectPixels: snapImageToPerfectPixelsMock, }; }); function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-source', resourceId: 'resource-source', title: '源图', src: 'data:image/png;base64,source', x: 120, y: 140, width: 320, height: 240, originalWidth: 1024, originalHeight: 768, zIndex: 2, sourceType: 'uploaded', ...overrides, }; } function createGenerated(overrides = {}) { return { imageSrc: 'data:image/png;base64,generated', width: 1024, height: 1024, sourceType: 'generated' as const, prompt: '生成提示词', actualPrompt: '生成提示词', model: 'gpt-image-2', provider: 'VectorEngine', taskId: 'task-generated', ...overrides, }; } function createPerfectPixelResource(operationId: string) { return { resourceId: `resource-${operationId}`, projectId: 'project-1', imageSrc: `/generated-images/editor/${operationId}.png`, objectKey: `generated-images/editor/${operationId}.png`, assetObjectId: `asset-object-${operationId}`, width: 1024, height: 768, sourceType: 'generated' as const, taskId: `pixel-art-snap-${operationId}`, }; } function createPerfectPixelAsset(operationId: string) { return { assetId: `asset-${operationId}`, folderId: 'default', label: '源图 · 完美像素', imageSrc: `/generated-images/editor/${operationId}.png`, objectKey: `generated-images/editor/${operationId}.png`, assetObjectId: `asset-object-${operationId}`, width: 1024, height: 768, sourceType: 'generated' as const, taskId: `pixel-art-snap-${operationId}`, }; } function createPerfectPixelProject( operationId: string, state: 'pending' | 'applied' | 'dialog-missing', ): EditorProjectSnapshot { const resource = createPerfectPixelResource(operationId); const generatedLayerId = `layer-${operationId}`; const dialogLayer = { itemType: 'generation-dialog', layerId: `generation-dialog:${operationId}`, resourceId: `generation-dialog:${operationId}`, dialog: { id: operationId, mode: 'quick-edit', prompt: '完美像素', status: state === 'pending' ? 'generating' : 'idle', composerOpen: false, ...(state === 'applied' ? { generatedLayerId } : {}), }, }; return { projectId: 'project-1', title: '未命名画布', viewport: { x: 0, y: 0, scale: 1 }, layers: state === 'dialog-missing' ? [] : [ ...(state === 'applied' ? [ { itemType: 'image', layerId: generatedLayerId, resourceId: resource.resourceId, }, ] : []), dialogLayer, ], resources: state === 'pending' ? [] : [resource], updatedAt: '2026-08-03T00:00:00.000Z', }; } function createEmptyPerfectPixelProject(): EditorProjectSnapshot { return { projectId: 'project-1', title: '未命名画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-08-03T00:00:00.000Z', }; } function createConflictingPerfectPixelProject( operationId: string, ): EditorProjectSnapshot { const project = createPerfectPixelProject(operationId, 'applied'); return { ...project, resources: [ ...project.resources, { ...createPerfectPixelResource(operationId), resourceId: `resource-duplicate-${operationId}`, }, ], }; } function createPerfectPixelResult( request: EditorPixelArtSnapInput, project: EditorProjectSnapshot | null, ) { const operationId = request.canvasCompletion.dialogId; const resource = createPerfectPixelResource(operationId); return { imageSrc: resource.imageSrc, objectKey: resource.objectKey, assetObjectId: resource.assetObjectId, width: resource.width, height: resource.height, sourceType: 'generated' as const, taskId: resource.taskId, elapsedMs: 250, provider: 'Genarrative' as const, resource, asset: createPerfectPixelAsset(operationId), project, }; } function createMismatchedPerfectPixelResult(request: EditorPixelArtSnapInput) { const result = createPerfectPixelResult(request, null); return { ...result, taskId: 'unexpected-task', resource: { ...result.resource, taskId: 'unexpected-task', }, }; } function createHydratedPerfectPixelDialog({ operationId, projectId = 'project-1', status = 'generating', reconcileUntil = Date.now() + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, }: { operationId: string; projectId?: string; status?: CanvasGenerationDialogState['status']; reconcileUntil?: number; }): CanvasGenerationDialogState { const request: EditorPixelArtSnapInput = { sourceImageSrc: 'generated-images/editor/source.png', projectId, sourceResourceId: 'resource-source', assetLabel: '源图 · 完美像素', canvasCompletion: { dialogId: operationId, title: '源图 · 完美像素', placeholder: { x: 60, y: -132, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }; return { id: operationId, mode: 'quick-edit', prompt: '完美像素', assetLabel: '源图 · 完美像素', status, composerOpen: false, sourceLayerId: 'layer-source', placeholder: { ...request.canvasCompletion.placeholder }, perfectPixelOperation: { version: 1, kind: 'perfect-pixel', operationId, taskId: `pixel-art-snap-${operationId}`, request, submittedAt: Date.now() - 1_000, reconcileUntil, }, }; } function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } function GenerationWorkflowHarness({ initialLayers = [createLayer()], initialCanvasGenerationDialogs, initialViewport = { x: 10, y: 20, scale: 2 }, projectId, currentUserId, applyProjectSnapshot, applyProjectSnapshotWithoutHistory, flushProjectPersistence, onFlushProjectPersistenceSnapshot, refreshAssetLibrary, upsertGeneratedAsset, }: { initialLayers?: CanvasLayer[]; initialCanvasGenerationDialogs?: CanvasGenerationDialogState[]; initialViewport?: { x: number; y: number; scale: number }; projectId?: string; currentUserId?: string; applyProjectSnapshot?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['applyProjectSnapshot']; applyProjectSnapshotWithoutHistory?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['applyProjectSnapshotWithoutHistory']; refreshAssetLibrary?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['refreshAssetLibrary']; flushProjectPersistence?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['flushProjectPersistence']; onFlushProjectPersistenceSnapshot?: ( dialogs: CanvasGenerationDialogState[], ) => void; upsertGeneratedAsset?: Parameters< typeof useImageCanvasGenerationWorkflow >[0]['upsertGeneratedAsset']; }) { const [layers, setLayers] = useState(initialLayers); const [viewport, setViewport] = useState(initialViewport); const [activeTool, setActiveTool] = useState('select'); const [activeSidebarPanel, setActiveSidebarPanel] = useState('assets'); const [selectedLayerId, setSelectedLayerId] = useState(null); const [metadataLayer, setMetadataLayer] = useState(null); const [imageContextMenu, setImageContextMenu] = useState({ layerId: 'layer-source', x: 1, y: 2, }); const fitLayersMockRef = useRef(vi.fn()); const layerCounterRef = useRef(0); const dialogs = useCanvasGenerationDialogs(); const restoredInitialDialogsRef = useRef(false); useEffect(() => { if ( restoredInitialDialogsRef.current || !initialCanvasGenerationDialogs?.length ) { return; } restoredInitialDialogsRef.current = true; dialogs.restoreCanvasGenerationDialogs(initialCanvasGenerationDialogs); }, [dialogs, initialCanvasGenerationDialogs]); const activeDialogRef = useRef(null); const workflow = useImageCanvasGenerationWorkflow({ layers, canvasSize: { width: 900, height: 640 }, viewport, setViewport, setLayers, canvasGenerationDialogs: dialogs.canvasGenerationDialogs, layerCounterRef, generateDialog: dialogs.generateDialog, setGenerateDialog: dialogs.setGenerateDialog, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, removeCanvasGenerationDialogsByLayerId: dialogs.removeCanvasGenerationDialogsByLayerId, getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder, archiveActiveCanvasGenerationDialog: dialogs.archiveActiveCanvasGenerationDialog, appendCanvasLayersWithResources: (nextLayers) => setLayers((currentLayers) => [...currentLayers, ...nextLayers]), selectSingleLayer: setSelectedLayerId, fitLayers: fitLayersMockRef.current, captureCanvasHistory: () => {}, setActiveTool, setActiveSidebarPanel, setMetadataLayer, setImageContextMenu, projectId, currentUserId, applyProjectSnapshot, applyProjectSnapshotWithoutHistory, flushProjectPersistence: flushProjectPersistence ? (options) => { onFlushProjectPersistenceSnapshot?.( dialogs.getCanvasGenerationDialogsSnapshot(), ); return flushProjectPersistence(options); } : undefined, refreshAssetLibrary, upsertGeneratedAsset, }); const activeDialog = dialogs.generateDialog; activeDialogRef.current = activeDialog; const activeCanvasDialog = activeDialog && 'id' in activeDialog ? (activeDialog as CanvasGenerationDialogState) : null; return (
{activeTool} {activeSidebarPanel ?? '-'} {selectedLayerId ?? '-'} {metadataLayer?.id ?? '-'} {imageContextMenu ? 'open' : '-'} {`${viewport.x}:${viewport.y}:${viewport.scale}`} {activeDialog?.placeholder ? `${activeDialog.placeholder.x}:${activeDialog.placeholder.y}:${activeDialog.placeholder.width}:${activeDialog.placeholder.height}` : '-'} {layers .map( (layer) => `${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.src}:${layer.taskId ?? '-'}`, ) .join('|')} {layers .map( (layer) => `${layer.id}:${layer.resourceId}:${layer.objectKey ?? '-'}:${layer.assetObjectId ?? '-'}`, ) .join('|')} {activeDialog ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}` : '-'} {activeDialog?.prompt || '-'} {activeDialog?.errorMessage || '-'} {activeDialog ? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}` : '-'} {dialogs.canvasGenerationDialogs .map( (dialog) => `${dialog.id}:${dialog.mode}:${dialog.composerOpen !== false ? 'open' : 'closed'}`, ) .join('|') || '-'} {dialogs.canvasGenerationDialogs .filter((dialog) => dialog.requiresLiveSession === true) .map((dialog) => dialog.id) .join('|') || '-'} {activeDialog?.perfectPixelOperation ? JSON.stringify(activeDialog.perfectPixelOperation) : '-'} {activeDialog?.perfectPixelOperationInvalid === true ? 'invalid' : '-'} {activeDialog?.generationReferences ?.map((reference) => reference.label) .join('|') ?? '-'} {activeDialog?.characterSpecReference?.label ?? '-'} {activeDialog?.iconSpecReference?.label ?? '-'} {activeDialog?.uiDesignSpecReference?.label ?? '-'} {workflow.generationWarning ?? '-'} {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` : '-'} {workflow.quickEditPanel ? `${workflow.quickEditPanel.mode ?? 'quick-edit'}:${workflow.quickEditPanel.model}:${workflow.quickEditPanel.aspectRatio ?? '-'}:${workflow.quickEditPanel.imageSize ?? '-'}` : '-'} {workflow.cropExpandPanel ? `${workflow.cropExpandPanel.sourceLayerId}:${workflow.cropExpandPanel.ratio}:${workflow.cropExpandPanel.frame.x}:${workflow.cropExpandPanel.frame.y}:${workflow.cropExpandPanel.frame.width}:${workflow.cropExpandPanel.frame.height}:${workflow.cropExpandPanel.status}` : '-'} {workflow.uiAssetExtractionState ? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}:${workflow.uiAssetExtractionState.model}` : '-'} {workflow.uiAssetExtractionSourceLayer?.id ?? '-'} {workflow.quickEditSelectionState ? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool ?? 'none'}:${workflow.quickEditSelectionState.marks.length}` : '-'} {workflow.characterAnimationPanel ? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}` : '-'} {`${workflow.characterAnimationPanel?.assetLabel ?? '-'}:${activeDialog?.assetLabel ?? '-'}`} {workflow.characterAnimationPrice} {fitLayersMockRef.current.mock.calls.length} {workflow.taskListRefreshKey} {workflow.perfectPixelLayerIds.has(layers[0]!.id) ? '处理中' : '空闲'} {workflow.splittingIconSpritesheetLayerIds.has(layers[0]!.id) ? '拆图中' : '空闲'}
); } describe('useImageCanvasGenerationWorkflow', () => { beforeEach(() => { vi.clearAllMocks(); uploadEditorMediaAssetObjectFileMock.mockReset(); uploadEditorMediaAssetFileMock.mockReset(); // 中文注释:未知结果的对账 GET 默认返回 null,等价于"权威快照读不到"。需要具体 // 对账结果的用例各自 mockResolvedValueOnce 覆盖。 loadEditorProjectMock.mockResolvedValue(null); resolveEditorImageReferenceDataUrlMock.mockImplementation( async (src: string) => src, ); uploadEditorMediaAssetObjectFileMock.mockResolvedValue({ objectKey: 'generated-character-drafts/editor/generation-references/reference.png', assetObjectId: 'asset-object-generation-reference', legacyPublicPath: '/generated-character-drafts/editor/generation-references/reference.png', }); uploadEditorMediaAssetFileMock.mockResolvedValue({ src: 'https://signed.example.test/crop-expand.png', objectKey: 'generated-character-drafts/editor/crop-expand/project-1/result.png', assetObjectId: 'asset-object-crop-expand', legacyPublicPath: '/generated-character-drafts/editor/crop-expand/project-1/result.png', }); createEditorProjectResourceMock.mockResolvedValue({ resourceId: 'resource-crop-expand', projectId: 'project-1', imageSrc: '/generated-character-drafts/editor/crop-expand/project-1/result.png', objectKey: 'generated-character-drafts/editor/crop-expand/project-1/result.png', assetObjectId: 'asset-object-crop-expand', width: 380, height: 280, sourceType: 'generated', sourceResourceId: 'resource-source', }); window.localStorage.clear(); }); it('keeps a generating dialog pending even after its matching task resource appears', () => { const operationId = 'perfect-pixel-resource-before-dialog'; const project = createPerfectPixelProject(operationId, 'pending'); project.resources = [createPerfectPixelResource(operationId)]; expect( inspectPerfectPixelProjectSnapshot(project, { operationId, taskId: `pixel-art-snap-${operationId}`, }), ).toMatchObject({ kind: 'pending', project }); }); it('rejects duplicate perfect-pixel dialog ids even when the first record appears applied', () => { const operationId = 'perfect-pixel-duplicate-dialog'; const project = createPerfectPixelProject(operationId, 'applied'); project.layers.push({ itemType: 'generation-dialog', layerId: `generation-dialog:${operationId}:duplicate`, resourceId: `generation-dialog:${operationId}:duplicate`, dialog: { id: operationId, mode: 'quick-edit', prompt: '完美像素', status: 'generating', composerOpen: false, }, }); expect( inspectPerfectPixelProjectSnapshot(project, { operationId, taskId: `pixel-art-snap-${operationId}`, }), ).toMatchObject({ kind: 'conflict', project, message: '权威项目中存在重复的完美像素占位,无法自动确认结果。', }); }); it('requires a matching task resource before a missing dialog can prove asset-only success', () => { const operationId = 'perfect-pixel-missing-dialog'; const matchingProject = createPerfectPixelProject( operationId, 'dialog-missing', ); const foreignProject = { ...matchingProject, resources: [ { ...createPerfectPixelResource('foreign-operation'), projectId: 'project-1', }, ], }; const identity = { operationId, taskId: `pixel-art-snap-${operationId}`, }; expect( inspectPerfectPixelProjectSnapshot(matchingProject, identity), ).toMatchObject({ kind: 'dialog-missing' }); expect( inspectPerfectPixelProjectSnapshot(foreignProject, identity), ).toMatchObject({ kind: 'pending', project: foreignProject }); }); it('opens a movable canvas generation placeholder and keeps toolbar state active', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); expect(screen.getByTestId('tool').textContent).toBe('generate'); expect(screen.getByTestId('selected').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:-:placeholder', ); }); it('opens quick edit for generated character images', () => { render(); fireEvent.click(screen.getByRole('button', { name: '快速编辑角色图' })); expect(screen.getByTestId('tool').textContent).toBe('generate'); expect(screen.getByTestId('selected').textContent).toBe( 'layer-character-generated', ); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-character-generated:idle:-', ); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-character-generated:none:0', ); }); it('opens the source generator for generated publication redraw', () => { render(); fireEvent.click(screen.getByRole('button', { name: '改造宣发图' })); expect(screen.getByTestId('tool').textContent).toBe('publication'); expect(screen.getByTestId('dialog').textContent).toBe( 'publication:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-prompt').textContent).toBe( '游戏名:星海远征\n游戏分类:冒险\n一句话描述游戏:驾驶飞船探索星云', ); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'publication:gpt-image-2:9:16:1K', ); }); it('opens quick edit for plain generated images with source model parameters', () => { render(); fireEvent.click( screen.getByRole('button', { name: '写入普通图来源生成器' }), ); fireEvent.click(screen.getByRole('button', { name: '快速编辑来源普通图' })); expect(screen.getByTestId('tool').textContent).toBe('generate'); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); expect(screen.getByTestId('quick-edit-image-options').textContent).toBe( 'quick-edit:gpt-image-2:2:3:2K', ); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:none:0', ); }); it('keeps plain images without a source generator on the quick edit panel', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('tool').textContent).toBe('generate'); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:none:0', ); }); it('opens quick edit for icon spritesheets', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); }); it('calculates character animation button price from the model pricing config', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色动画' })); expect(screen.getByTestId('character-animation').textContent).toBe( 'layer-character:idle', ); expect(screen.getByTestId('character-animation-price').textContent).toBe( '40', ); }); it('places a new generation placeholder away from existing canvas images and focuses the viewport on it', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); expect(screen.getByTestId('placeholder').textContent).toBe( '-1056:-362:1024:1024', ); expect(screen.getByTestId('viewport').textContent).toBe('1538:20:2'); }); it('places a new video generation placeholder away from existing canvas images and focuses the viewport on it', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开视频生成' })); expect(screen.getByTestId('tool').textContent).toBe('video'); expect(screen.getByTestId('selected').textContent).toBe('-'); expect(screen.getByTestId('placeholder').textContent).toBe( '-207:412:854:480', ); expect(screen.getByTestId('viewport').textContent).toBe('10:-984:2'); }); it('opens audio remodel as an audio generation dialog next to the source layer', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开音效改造' })); expect(screen.getByTestId('tool').textContent).toBe('music'); expect(screen.getByTestId('selected').textContent).toBe('layer-sound'); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe( 'audio-sound-effect:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-prompt').textContent).toBe('金币跳出音'); expect(screen.getByTestId('placeholder').textContent).toBe( '572:140:420:120', ); }); it('opens generated layer remodel with user input snapshot instead of internal prompt', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开用户快照改造' })); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-prompt').textContent).toBe( '用户的视频描述', ); }); it('keeps the active canvas generator when opening quick edit for another layer', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:generate:closed', ); }); it('opens image redraw as a new canvas generator next to the source layer', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:idle:open:-:placeholder', ); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('placeholder').textContent).toContain('1024:768'); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:generate:closed|generation-dialog-2:quick-edit:open', ); }); it('prefers the source generation parameter snapshot when opening quick edit', () => { render( , ); fireEvent.click( screen.getByRole('button', { name: '写入源图生成参数快照' }), ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit-image-options').textContent).toBe( 'quick-edit:gpt-image-2:2:3:2K', ); }); it('keeps target generator options when opening redraw', () => { render( , ); fireEvent.click( screen.getByRole('button', { name: '写入源图生成参数快照' }), ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'generate:gpt-image-2:2:3:2K', ); }); it('ignores a derived quick edit dialog when restoring the source generator', () => { render(); fireEvent.click( screen.getByRole('button', { name: '写入普通图来源生成器' }), ); fireEvent.click( screen.getByRole('button', { name: '写入派生快速编辑生成器' }), ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'generate:gpt-image-2:2:3:2K', ); }); it('submits audio remodel with restored sound parameters and appends the new audio layer', async () => { generateEditorSoundEffectMock.mockResolvedValueOnce({ audioSrc: 'data:audio/mpeg;base64,generated', width: 420, height: 120, sourceType: 'generated', prompt: '金币跳出音', actualPrompt: '金币跳出音', model: 'audio1.0', provider: 'VectorEngine', taskId: 'task-sound', audioKind: 'sound-effect', durationSeconds: 4, }); render(); fireEvent.click(screen.getByRole('button', { name: '打开音效改造' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorSoundEffectMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '金币跳出音', model: 'audio1.0', duration: 8, generationInputs: { fields: [ { title: 'prompt', value: '金币跳出音' }, { title: 'model', value: 'audio1.0' }, { title: 'duration', value: '8秒' }, ], references: [], }, }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-audio-1:游戏音效 1:-:sound-effect', ); }); expect(screen.getByTestId('dialog').textContent).toBe( 'audio-sound-effect:idle:open:layer-audio-1:placeholder', ); expect(screen.getByTestId('placeholder').textContent).toBe( '572:140:420:120', ); }); it('submits a normal generation, appends the generated layer, and keeps the composer anchored', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '一张生成图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '一张生成图', model: 'gemini-3.1-flash-image-preview', style: 'none', aspectRatio: '1:1', imageSize: '1K', projectId: undefined, assetFolderId: undefined, assetKind: undefined, assetLabel: '生成图片 1', generationInputs: { fields: [{ title: '生成提示词', value: '一张生成图' }], references: [], }, }); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:生成图片 1', ); }); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); expect(screen.getByTestId('selected').textContent).toBe( 'layer-generated-1', ); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:layer-generated-1:placeholder', ); }); it('reuses the last image ratio and size when reopening character generation and submitting', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '一张生成图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择2比3和2K' })); await waitFor(() => { expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'character:gemini-3.1-flash-image-preview:2:3:2K', ); }); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('dialog-image-options').textContent).toBe( 'character:gemini-3.1-flash-image-preview:2:3:2K', ); fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '一张生成图', kind: 'character', model: 'gemini-3.1-flash-image-preview', aspectRatio: '2:3', imageSize: '2K', }), ); }); }); it('adds picked canvas layers as normal generation references and submits them', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '参考生成图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '选择画布参考图' })); expect(screen.getByTestId('generation-references').textContent).toBe( '画布参考图', ); fireEvent.click(screen.getByRole('button', { name: '填写生成提示词' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith({ prompt: '一张生成图', model: 'gemini-3.1-flash-image-preview', style: 'none', aspectRatio: '1:1', imageSize: '1K', projectId: undefined, assetFolderId: undefined, assetKind: undefined, assetLabel: '生成图片 1', referenceImageSrcs: ['resource-source'], generationInputs: { fields: [{ title: '生成提示词', value: '一张生成图' }], references: [ { title: '参考图 1', label: '画布参考图', refType: 'project-resource', refId: 'resource-source', }, ], }, }); }); }); it('reuses the last picked character spec when opening the next character material dialog', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '-', ); fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:-:placeholder', ); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); }); it('warns and keeps character spec empty when picked layer is not a spec image', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '-', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是角色规范图,请选择生成规范里的角色规范。', ); fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); }); it('dismisses reference pick warnings after three seconds', () => { vi.useFakeTimers(); try { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是角色规范图,请选择生成规范里的角色规范。', ); act(() => { vi.advanceTimersByTime(2_999); }); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是角色规范图,请选择生成规范里的角色规范。', ); act(() => { vi.advanceTimersByTime(1); }); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '-', ); } finally { vi.useRealTimers(); } }); it('restarts the dismissal timer when the same warning is shown again', () => { vi.useFakeTimers(); try { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); act(() => { vi.advanceTimersByTime(2_999); }); fireEvent.click(screen.getByRole('button', { name: '选择非规范角色图' })); act(() => { vi.advanceTimersByTime(1); }); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是角色规范图,请选择生成规范里的角色规范。', ); act(() => { vi.advanceTimersByTime(2_999); }); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '-', ); } finally { vi.useRealTimers(); } }); it('restores the last picked character spec from local cache after remounting the editor', () => { const firstRender = render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); firstRender.unmount(); render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上一张角色规范', ); }); it('does not reuse cached spec references written by another user', () => { const firstRender = render( , ); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); firstRender.unmount(); render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '-', ); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-'); fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' })); expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe( '-', ); }); it('reuses uploaded character and icon specs when opening matching material dialogs again', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); fireEvent.click(screen.getByRole('button', { name: '模拟上传角色规范' })); await waitFor(() => { expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上传角色规范', ); }); fireEvent.click(screen.getByRole('button', { name: '打开角色生成' })); expect(screen.getByTestId('character-spec-reference').textContent).toBe( '上传角色规范', ); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-'); fireEvent.click(screen.getByRole('button', { name: '模拟上传图标规范' })); await waitFor(() => { expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上传图标规范', ); }); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'icon:idle:open:-:placeholder', ); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上传图标规范', ); }); it('reuses the last picked icon spec when opening the next icon material dialog', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-'); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'icon:idle:open:-:placeholder', ); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); }); it('warns and keeps icon spec empty when picked layer is not an icon spec image', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); fireEvent.click(screen.getByRole('button', { name: '选择非图标规范图' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '选择的图片不是图标规范图,请选择生成规范里的图标规范。', ); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); }); it('restores the last picked icon spec from local cache for icon and UI design dialogs', () => { const firstRender = render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); firstRender.unmount(); render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张图标规范', ); fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' })); expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe( '上一张图标规范', ); }); it('shares the cached icon spec between icon material and UI design dialogs', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); fireEvent.click(screen.getByRole('button', { name: '选择图标规范' })); fireEvent.click(screen.getByRole('button', { name: '打开UI设计生成' })); expect(screen.getByTestId('dialog').textContent).toBe( 'ui-design:idle:open:-:placeholder', ); expect(screen.getByTestId('ui-design-spec-reference').textContent).toBe( '上一张图标规范', ); fireEvent.click(screen.getByRole('button', { name: '选择UI图标规范' })); fireEvent.click(screen.getByRole('button', { name: '打开图标生成' })); expect(screen.getByTestId('icon-spec-reference').textContent).toBe( '上一张UI图标规范', ); }); it('submits spec generation with reference image and reference prompt semantics', async () => { uploadEditorMediaAssetFileMock.mockResolvedValueOnce({ src: 'https://signed.example.test/generation-reference.png', objectKey: 'generated-character-drafts/editor/generation-references/reference.png', assetObjectId: 'asset-object-generation-reference', legacyPublicPath: '/generated-character-drafts/editor/generation-references/reference.png', }); generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: 'UI规范图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开UI规范' })); fireEvent.click(screen.getByRole('button', { name: '添加规范参考图' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ size: '2048x1152', kind: 'spec', model: 'gpt-image-2', aspectRatio: '16:9', imageSize: '2K', referenceImageSrcs: [ 'generated-character-drafts/editor/generation-references/reference.png', ], prompt: expect.stringContaining('参考图生成规范'), }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:图标规范 1', ); }); }); it('opens quick edit with inactive selection tools and keeps tall sources above the panel', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:none:0', ); expect(screen.getByTestId('selected').textContent).toBe('layer-source'); const viewportParts = screen .getByTestId('viewport') .textContent!.split(':') .map(Number); if (viewportParts.length !== 3) { throw new Error('viewport test output should contain x, y and scale'); } const [viewportX, viewportY, viewportScale] = viewportParts as [ number, number, number, ]; const sourceTop = viewportY + 50 * viewportScale; const sourceBottom = sourceTop + 1600 * viewportScale; expect(viewportX).toBeGreaterThan(300); expect(viewportScale).toBeLessThan(1); expect(sourceTop).toBeCloseTo(56, 2); expect(sourceBottom + 12 + 260).toBeLessThanOrEqual(640); }); it('adds numbered quick edit red selections only after enabling a selection tool', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' })); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:none:0', ); fireEvent.click( screen.getByRole('button', { name: '选择快速编辑矩形框选' }), ); fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' })); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:rect:1', ); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:对1号红色圈选框里的内容做以下修改:', ); fireEvent.click( screen.getByRole('button', { name: '选择快速编辑椭圆框选' }), ); fireEvent.click(screen.getByRole('button', { name: '快速编辑框选' })); expect(screen.getByTestId('quick-edit-selection').textContent).toBe( 'layer-source:ellipse:2', ); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:对1号红色圈选框里的内容做以下修改:\n对2号红色圈选框里的内容做以下修改:', ); }); it('submits quick edits and updates the source layer directly', async () => { editEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '快速修图' }), ); render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '填写快速编辑' })); fireEvent.click(screen.getByRole('button', { name: '提交快速编辑' })); await waitFor(() => { expect(editEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '快速修图', sourceImageSrc: 'resource-source', assetLabel: '源图 快速编辑', sourceResourceId: 'resource-source', generationInputs: { fields: [{ title: '快速编辑提示词', value: '快速修图' }], references: [ { title: '原图', label: '源图', refType: 'project-resource', refId: 'resource-source', }, ], }, }), ); }); expect(generateEditorImageMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-source:源图 快速编辑:resource-source:-:data:image/png;base64,generated:task-generated', ); }); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('fit-count').textContent).toBe('0'); }); it('opens crop-expand in free ratio and completes as a new beside layer', async () => { renderCropExpandImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,crop-expanded', width: 380, height: 280, }); render(); fireEvent.click(screen.getByRole('button', { name: '打开裁扩' })); expect(screen.getByTestId('crop-expand').textContent).toBe( 'layer-source:free:120:140:320:240:idle', ); fireEvent.click(screen.getByRole('button', { name: '拖拽裁扩边界' })); fireEvent.click(screen.getByRole('button', { name: '完成裁扩' })); await waitFor(() => { expect(renderCropExpandImageMock).toHaveBeenCalledWith({ source: 'data:image/png;base64,source', insets: { left: 32, top: 64, right: 160, bottom: 64, }, }); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-crop-expand-1:源图 裁扩:resource-source', ); }); expect(screen.getByTestId('selected').textContent).toBe( 'layer-crop-expand-1', ); expect(screen.getByTestId('crop-expand').textContent).toBe('-'); expect(screen.getByTestId('fit-count').textContent).toBe('1'); }); it('uploads a project crop-expand result before adding it to the canvas', async () => { renderCropExpandImageMock.mockResolvedValueOnce({ imageSrc: 'data:image/png;base64,Y3JvcC1leHBhbmRlZA==', width: 380, height: 280, }); render(); fireEvent.click(screen.getByRole('button', { name: '打开裁扩' })); fireEvent.click(screen.getByRole('button', { name: '拖拽裁扩边界' })); fireEvent.click(screen.getByRole('button', { name: '完成裁扩' })); await waitFor(() => { expect(uploadEditorMediaAssetFileMock).toHaveBeenCalledWith( expect.any(File), 'image', expect.objectContaining({ assetKind: 'editor_crop_expand_image', entityId: 'project-1', metadata: { editor_project_id: 'project-1', source_resource_id: 'resource-source', }, }), ); }); const uploadCallOrder = uploadEditorMediaAssetFileMock.mock.invocationCallOrder[0]; const resourceCreateCallOrder = createEditorProjectResourceMock.mock.invocationCallOrder[0]; expect(uploadCallOrder).toBeDefined(); expect(resourceCreateCallOrder).toBeDefined(); expect(uploadCallOrder!).toBeLessThan(resourceCreateCallOrder!); expect(createEditorProjectResourceMock).toHaveBeenCalledWith( 'project-1', expect.objectContaining({ imageSrc: '/generated-character-drafts/editor/crop-expand/project-1/result.png', objectKey: 'generated-character-drafts/editor/crop-expand/project-1/result.png', assetObjectId: 'asset-object-crop-expand', width: 380, height: 280, sourceType: 'generated', sourceResourceId: 'resource-source', }), ); await waitFor(() => { expect(screen.getByTestId('layer-resources').textContent).toContain( 'layer-crop-expand-1:resource-crop-expand:generated-character-drafts/editor/crop-expand/project-1/result.png:asset-object-crop-expand', ); }); expect(screen.getByTestId('layers').textContent).not.toContain( 'layer-crop-expand-1:源图 裁扩:resource-source:-:data:image', ); }); it('splits an icon spritesheet through the project endpoint and applies the project snapshot', async () => { const applyProjectSnapshot = vi.fn(); const project = { projectId: 'project-1', title: '未命名画布', canvas: { canvasId: 'project-1:canvas:default', projectId: 'project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-07-13T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-07-13T00:00:00.000Z', }; splitEditorIconSpritesheetMock.mockResolvedValueOnce({ iconImageSrcs: [], project, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '拆分图集' })); await waitFor(() => { expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith({ projectId: 'project-1', sourceResourceId: 'resource-source', assetFolderId: undefined, canvasCompletion: { title: '拆分图集', placeholder: { x: 120, y: 140, width: 320, height: 240, originalWidth: 1024, originalHeight: 1024, }, }, }); }); expect(applyProjectSnapshot).toHaveBeenCalledWith(project, { type: 'split-atlas', count: 1, }); expect(screen.getByTestId('sidebar').textContent).toBe('layers'); }); it('exposes a splitting state while the manual atlas request is pending', async () => { let resolveSplit: | ((value: { iconImageSrcs: never[]; project: unknown }) => void) | undefined; splitEditorIconSpritesheetMock.mockImplementationOnce( () => new Promise((resolve) => { resolveSplit = resolve; }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '拆分图集' })); fireEvent.click(screen.getByRole('button', { name: '拆分图集' })); expect(screen.getByRole('status', { name: '拆图状态' }).textContent).toBe( '拆图中', ); expect(splitEditorIconSpritesheetMock).toHaveBeenCalledTimes(1); resolveSplit?.({ iconImageSrcs: [], project: { projectId: 'project-1', title: '未命名画布', canvas: { canvasId: 'project-1:canvas:default', projectId: 'project-1', title: '默认画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], updatedAt: '2026-07-18T00:00:00.000Z', }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-07-18T00:00:00.000Z', }, }); await waitFor(() => { expect(screen.getByRole('status', { name: '拆图状态' }).textContent).toBe( '空闲', ); }); }); it('queues background removal for private character images', async () => { resolveEditorImageReferenceDataUrlMock.mockResolvedValueOnce( 'data:image/png;base64,resolved-character', ); removeImageBackgroundMock.mockResolvedValueOnce({ queueState: { operationId: 'background-removal-task', status: 'completed', phaseLabel: '已完成', phaseDetail: '已完成', progress: 100, updatedAtMicros: 1, }, }); render( , ); fireEvent.click(screen.getByRole('button', { name: '去除背景' })); await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled()); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); await waitFor(() => { expect(removeImageBackgroundMock).toHaveBeenCalledWith({ sourceImageSrc: 'generated-character-drafts/editor/private.png', projectId: undefined, targetLayerId: 'layer-source', assetKind: 'character', generationInputs: undefined, assetFolderId: undefined, assetLabel: '源图 去背景', sourceResourceId: 'resource-source', }); }); await waitFor(() => { expect(screen.getByTestId('task-refresh-key').textContent).toBe('1'); }); expect(screen.getByTestId('layers').textContent).toContain( '/api/assets/read-url?objectKey=generated-character-drafts/editor/private.png', ); expect(screen.getByTestId('layers').textContent).not.toContain( 'background-removal-task', ); }); it('opens a generating canvas placeholder when removing background in a project', async () => { removeImageBackgroundMock.mockImplementationOnce( () => new Promise(() => {}), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '去除背景' })); await waitFor(() => expect(removeImageBackgroundMock).toHaveBeenCalled()); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:generating:closed:-:placeholder', ); expect(screen.getByTestId('generation-dialogs').textContent).toContain( 'generation-dialog-1:quick-edit:closed', ); // 中文注释:去除背景恒返回 queueState,job 在服务端继续跑,刷新后 worker 会替换占位。 // 置位 requiresLiveSession 会让加载期把还在跑的占位清掉,因此必须保持未置位。 expect(screen.getByTestId('live-session-dialogs').textContent).toBe('-'); expect(removeImageBackgroundMock).toHaveBeenCalledWith( expect.objectContaining({ sourceImageSrc: 'resource-source', projectId: 'project-1', targetLayerId: 'layer-source', assetLabel: '源图 去背景', canvasCompletion: expect.objectContaining({ dialogId: 'generation-dialog-1', title: '源图 去背景', placeholder: expect.objectContaining({ width: 320, height: 240, }), }), }), ); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); expect(screen.getByTestId('task-refresh-key').textContent).toBe('0'); }); it('flushes the stable operation before POST and only applies the later project GET verdict', async () => { const events: string[] = []; const applyProjectSnapshot = vi.fn(); const upsertGeneratedAsset = vi.fn(); let flushedOperation: | { version: number; kind: string; operationId: string; taskId: string; request: EditorPixelArtSnapInput; submittedAt: number; reconcileUntil: number; } | undefined; const flushProjectPersistence = vi.fn(async () => { events.push('flush'); expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:generating:closed:-:placeholder', ); }); let postedRequest: EditorPixelArtSnapInput | undefined; let postResponseProject: EditorProjectSnapshot | undefined; let completedProject: EditorProjectSnapshot | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { postedRequest = request; postResponseProject = createPerfectPixelProject( request.canvasCompletion.dialogId, 'applied', ); postResponseProject = { ...postResponseProject, title: 'POST 响应快照', }; events.push('post'); return createPerfectPixelResult(request, postResponseProject); }, ); loadEditorProjectMock .mockImplementationOnce(async () => { events.push('get-pending'); return createPerfectPixelProject( postedRequest!.canvasCompletion.dialogId, 'pending', ); }) .mockImplementationOnce(async () => { events.push('get-applied'); completedProject = createPerfectPixelProject( postedRequest!.canvasCompletion.dialogId, 'applied', ); completedProject = { ...completedProject, title: 'GET 权威快照', }; return completedProject; }); const sourceLayer = createLayer({ objectKey: 'generated-images/editor/source.png', src: '/generated-images/editor/source.png', assetKind: 'character', generationInputs: { fields: [{ title: '角色设定', value: '红发骑士' }], references: [], }, }); render( { events.push('snapshot'); flushedOperation = dialogs.find( (dialog) => dialog.perfectPixelOperation, )?.perfectPixelOperation; }} upsertGeneratedAsset={upsertGeneratedAsset} />, ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); }); expect(events.slice(0, 3)).toEqual(['snapshot', 'flush', 'post']); expect(flushProjectPersistence).toHaveBeenCalledTimes(1); expect(flushProjectPersistence).toHaveBeenCalledWith({ requireSuccess: true, requiredDialogId: flushedOperation!.operationId, }); expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled(); expect(flushedOperation).toEqual( expect.objectContaining({ version: 1, kind: 'perfect-pixel', operationId: expect.stringMatching(/^perfect-pixel-/u), submittedAt: expect.any(Number), reconcileUntil: expect.any(Number), }), ); expect(flushedOperation!.taskId).toBe( `pixel-art-snap-${flushedOperation!.operationId}`, ); expect( flushedOperation!.reconcileUntil - flushedOperation!.submittedAt, ).toBe(75_000); expect(flushedOperation!.request).toEqual(postedRequest); expect(postedRequest).toEqual( expect.objectContaining({ sourceImageSrc: 'generated-images/editor/source.png', projectId: 'project-1', sourceResourceId: 'resource-source', assetKind: 'character', generationInputs: sourceLayer.generationInputs, assetLabel: '源图 · 完美像素', canvasCompletion: expect.objectContaining({ dialogId: flushedOperation!.operationId, title: '源图 · 完美像素', placeholder: expect.objectContaining({ width: 320, height: 240, }), }), }), ); expect(screen.getByTestId('live-session-dialogs').textContent).toBe('-'); await waitFor( () => { expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); expect(upsertGeneratedAsset).toHaveBeenCalledWith( createPerfectPixelAsset(flushedOperation!.operationId), ); expect(applyProjectSnapshot).toHaveBeenCalledWith(completedProject, { type: 'perfect-pixel', count: 1, }); }, { timeout: 3_000 }, ); expect(applyProjectSnapshot).not.toHaveBeenCalledWith(postResponseProject, { type: 'perfect-pixel', count: 1, }); await waitFor(() => { expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); }); }); it('polls an unknown perfect-pixel outcome until a later GET confirms it without replaying POST', async () => { const applyProjectSnapshot = vi.fn(); let operationId = ''; let reconciledProject: EditorProjectSnapshot | undefined; const timeoutError = new Error('The operation timed out.'); timeoutError.name = 'TimeoutError'; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; throw timeoutError; }, ); loadEditorProjectMock .mockImplementationOnce(async () => createPerfectPixelProject(operationId, 'pending'), ) .mockImplementationOnce(async () => { reconciledProject = createPerfectPixelProject(operationId, 'applied'); return reconciledProject; }); render( {})} />, ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor( () => { expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); }, { timeout: 3_000 }, ); expect(loadEditorProjectMock).toHaveBeenNthCalledWith( 1, 'project-1', expect.objectContaining({ deadlineAt: expect.any(Number) }), ); expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, { type: 'perfect-pixel', count: 1, }); await waitFor(() => { expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); }); expect(screen.getByTestId('dialog').textContent).not.toContain('failed'); expect(screen.getByTestId('dialog-error').textContent).toBe('-'); }); it('keeps an unknown outcome pending when both the dialog and task resource remain absent through the deadline', async () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-08-03T00:00:00.000Z')); try { const startedAt = Date.now(); let unknownObservedAt = startedAt; const readTimes: number[] = []; const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn(); snapImageToPerfectPixelsMock.mockImplementationOnce( () => new Promise((_resolve, reject) => { setTimeout(() => { unknownObservedAt = Date.now(); reject(new Error('网络中断')); }, 70_000); }), ); loadEditorProjectMock.mockImplementation(async () => { readTimes.push(Date.now()); return createEmptyPerfectPixelProject(); }); render( {})} refreshAssetLibrary={refreshAssetLibrary} />, ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await act(async () => { await vi.advanceTimersByTimeAsync( PERFECT_PIXEL_RECONCILIATION_WINDOW_MS + 1, ); }); expect(loadEditorProjectMock.mock.calls.length).toBeGreaterThan(1); expect(readTimes[0]! - unknownObservedAt).toBeLessThan(1_000); expect(readTimes.at(-1)!).toBeLessThanOrEqual( startedAt + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, ); for (let index = 1; index < readTimes.length; index += 1) { expect(readTimes[index]! - readTimes[index - 1]!).toBeLessThanOrEqual( 5_000, ); } expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(refreshAssetLibrary).not.toHaveBeenCalled(); expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); expect(screen.getByTestId('dialog-error').textContent).toContain( '权威项目尚未出现可确认的完美像素结果。', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '-', ); expect(screen.getByTestId('generation-dialogs').textContent).not.toBe( '-', ); const completedReadCount = readTimes.length; await act(async () => { await vi.advanceTimersByTimeAsync( PERFECT_PIXEL_RECONCILIATION_WINDOW_MS, ); }); expect(readTimes).toHaveLength(completedReadCount); } finally { vi.useRealTimers(); } }); it('retries the exact persisted request without creating a second dialog or operation', async () => { const flushProjectPersistence = vi.fn().mockResolvedValue(undefined); snapImageToPerfectPixelsMock .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ) .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ); loadEditorProjectMock.mockImplementation(async () => { const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as | EditorPixelArtSnapInput | undefined; return createConflictingPerfectPixelProject( request!.canvasCompletion.dialogId, ); }); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); const firstRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as | EditorPixelArtSnapInput | undefined; const firstDialogList = screen.getByTestId('generation-dialogs').textContent; const firstOperation = JSON.parse( screen.getByTestId('perfect-pixel-operation').textContent!, ) as { operationId: string; taskId: string; request: EditorPixelArtSnapInput; }; const firstRequestBytes = JSON.stringify(firstRequest); fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2); }); const retryRequest = snapImageToPerfectPixelsMock.mock.calls[1]?.[0] as | EditorPixelArtSnapInput | undefined; expect(JSON.stringify(retryRequest)).toBe(firstRequestBytes); expect(retryRequest).toEqual(firstOperation.request); expect(retryRequest?.canvasCompletion.dialogId).toBe( firstOperation.operationId, ); expect(firstOperation.taskId).toBe( `pixel-art-snap-${firstOperation.operationId}`, ); expect(screen.getByTestId('generation-dialogs').textContent).toBe( firstDialogList, ); expect( screen .getByTestId('generation-dialogs') .textContent!.split('|') .filter(Boolean), ).toHaveLength(1); expect(flushProjectPersistence).toHaveBeenCalledTimes(2); expect(flushProjectPersistence).toHaveBeenNthCalledWith(1, { requireSuccess: true, requiredDialogId: firstOperation.operationId, }); expect(flushProjectPersistence).toHaveBeenNthCalledWith(2, { requireSuccess: true, requiredDialogId: firstOperation.operationId, }); }); it('reuses one inline upload and the exact persisted POST bytes when an unknown operation is retried', async () => { const flushProjectPersistence = vi.fn().mockResolvedValue(undefined); uploadEditorMediaAssetObjectFileMock.mockResolvedValueOnce({ objectKey: 'generated-images/editor/inline-perfect-pixel-source.png', assetObjectId: 'asset-object-inline-perfect-pixel-source', legacyPublicPath: '/generated-images/editor/inline-perfect-pixel-source.png', }); snapImageToPerfectPixelsMock .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ) .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ); loadEditorProjectMock.mockImplementation(async () => { const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as | EditorPixelArtSnapInput | undefined; return createConflictingPerfectPixelProject( request!.canvasCompletion.dialogId, ); }); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); const firstRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as | EditorPixelArtSnapInput | undefined; const firstRequestBytes = JSON.stringify(firstRequest); const operation = JSON.parse( screen.getByTestId('perfect-pixel-operation').textContent!, ) as { operationId: string; request: EditorPixelArtSnapInput; }; expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); expect(uploadEditorMediaAssetObjectFileMock.mock.calls[0]?.[2]).toEqual( expect.objectContaining({ pathSegments: [ 'editor', 'generation-references', 'project-1', operation.operationId, ], }), ); expect(firstRequest?.sourceImageSrc).toBe( 'generated-images/editor/inline-perfect-pixel-source.png', ); expect(firstRequest?.canvasCompletion.dialogId).toBe(operation.operationId); fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2); expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); const retryRequest = snapImageToPerfectPixelsMock.mock.calls[1]?.[0] as | EditorPixelArtSnapInput | undefined; expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); expect(JSON.stringify(retryRequest)).toBe(firstRequestBytes); expect(retryRequest?.canvasCompletion.dialogId).toBe(operation.operationId); expect(retryRequest).toEqual(operation.request); expect(flushProjectPersistence).toHaveBeenCalledTimes(2); }); it('ignores an in-flight retry response after the active owner changes', async () => { const retryResult = createDeferred>(); const upsertGeneratedAsset = vi.fn(); const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn(); snapImageToPerfectPixelsMock .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ) .mockImplementationOnce(() => retryResult.promise); loadEditorProjectMock.mockImplementation(async () => { const request = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as | EditorPixelArtSnapInput | undefined; return createConflictingPerfectPixelProject( request!.canvasCompletion.dialogId, ); }); const { rerender } = render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); upsertGeneratedAsset.mockClear(); applyProjectSnapshot.mockClear(); refreshAssetLibrary.mockClear(); fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2); }); const retryRequest = snapImageToPerfectPixelsMock.mock.calls[1]?.[0] as | EditorPixelArtSnapInput | undefined; expect(retryRequest).toBeDefined(); rerender( , ); await act(async () => { retryResult.resolve(createMismatchedPerfectPixelResult(retryRequest!)); await retryResult.promise; }); expect(upsertGeneratedAsset).not.toHaveBeenCalled(); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(refreshAssetLibrary).not.toHaveBeenCalled(); }); it('blocks a second operation for both the same layer and another layer sharing its pending source resource', async () => { snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ); loadEditorProjectMock.mockImplementation(async () => { const request = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as | EditorPixelArtSnapInput | undefined; return createConflictingPerfectPixelProject( request!.canvasCompletion.dialogId, ); }); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); const firstDialogList = screen.getByTestId('generation-dialogs').textContent; fireEvent.click(screen.getByRole('button', { name: '完美像素' })); fireEvent.click(screen.getByRole('button', { name: '第二图层完美像素' })); expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); expect(screen.getByTestId('generation-dialogs').textContent).toBe( firstDialogList, ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '该素材已有一条待确认的完美像素操作,请在原占位上继续核对或重试。', ); }); it('does not reconcile when the perfect-pixel request never left the client', async () => { // 中文注释:占位创建、源图解析和 flush 都在 POST 之前。它们失败时请求根本没发出, // 说「素材库可能已存在派生图」是反向谎报,也不该白打一次权威读取。 const applyProjectSnapshot = vi.fn(); const flushProjectPersistence = vi .fn() .mockRejectedValueOnce(new Error('画布保存失败')); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain('failed'); }); expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(screen.getByTestId('dialog-error').textContent).toBe('画布保存失败'); }); it('reconciles a responded failure that the server marked as post-persistence', async () => { const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); let operationId = ''; let reconciledProject: EditorProjectSnapshot | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; throw new ApiClientError({ message: '画布版本冲突。', status: 409, code: 'HTTP_409', details: { resultPersistenceStarted: true }, }); }, ); loadEditorProjectMock.mockImplementationOnce(async () => { reconciledProject = createPerfectPixelProject(operationId, 'applied'); return reconciledProject; }); render( {})} refreshAssetLibrary={refreshAssetLibrary} />, ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, { type: 'perfect-pixel', count: 1, }); }); expect(loadEditorProjectMock).toHaveBeenCalledWith( 'project-1', expect.objectContaining({ deadlineAt: expect.any(Number) }), ); expect(refreshAssetLibrary).toHaveBeenCalledTimes(1); expect(screen.getByTestId('dialog').textContent).not.toContain('failed'); }); it('skips reconciliation for a responded failure the server did not mark', async () => { // 中文注释:纯校验失败发生在任何 IO 之前,不可能留下对象或素材。服务端不置 // resultPersistenceStarted,客户端就不该多打两次读取,也不该附上「请核对素材库」 // 这种不适用的提示。 const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); snapImageToPerfectPixelsMock.mockRejectedValueOnce( new ApiClientError({ message: 'assetKind 与来源素材权威类型不一致。', status: 400, code: 'HTTP_400', details: { provider: 'pixel-art-snapper', field: 'assetKind' }, }), ); render( {})} refreshAssetLibrary={refreshAssetLibrary} />, ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain('failed'); }); expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect(refreshAssetLibrary).not.toHaveBeenCalled(); expect(applyProjectSnapshot).not.toHaveBeenCalled(); // 中文注释:文案保持服务端原文,不追加对账结论。 expect(screen.getByTestId('dialog-error').textContent).toBe( 'assetKind 与来源素材权威类型不一致。', ); }); it('reports asset-only success only when project GET contains the matching task resource', async () => { const applyProjectSnapshot = vi.fn(); let operationId = ''; const timeoutError = new Error('The operation timed out.'); timeoutError.name = 'TimeoutError'; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; throw timeoutError; }, ); loadEditorProjectMock.mockImplementationOnce(async () => createPerfectPixelProject(operationId, 'dialog-missing'), ); render( {})} />, ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '完美像素结果已保存到素材库,画布占位已不存在。', ); }); expect(applyProjectSnapshot).not.toHaveBeenCalled(); await waitFor(() => { expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); }); expect(screen.getByTestId('dialog').textContent).toBe('-'); }); it('keeps the durable operation when deletion is attempted and applies a later GET result', async () => { const applyProjectSnapshot = vi.fn(); let operationId = ''; let rejectPerfectPixel: ((error: unknown) => void) | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( (request: EditorPixelArtSnapInput) => new Promise((_resolve, reject) => { operationId = request.canvasCompletion.dialogId; rejectPerfectPixel = reject; }), ); loadEditorProjectMock.mockImplementationOnce(async () => createPerfectPixelProject(operationId, 'applied'), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:generating:closed:-:placeholder', ); }); fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-'); rejectPerfectPixel?.(new Error('网络中断')); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'project-1', }), { type: 'perfect-pixel', count: 1, }, ); }); expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); }); it('does not let a never-settling asset refresh block a terminal reconciliation verdict', async () => { const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn(() => new Promise(() => {})); let operationId = ''; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; throw new Error('网络中断'); }, ); loadEditorProjectMock.mockImplementationOnce(async () => createPerfectPixelProject(operationId, 'dialog-missing'), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '完美像素结果已保存到素材库,画布占位已不存在。', ); }); expect(refreshAssetLibrary).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); }); it.each([ [ '同步抛错', () => { throw new Error('同步刷新失败'); }, ], ['异步拒绝', () => Promise.reject(new Error('异步刷新失败'))], ])( 'keeps an applied GET verdict when asset refresh %s', async (_label, refreshImplementation) => { const applyProjectSnapshot = vi.fn(); const refreshAssetLibrary = vi.fn(refreshImplementation); let operationId = ''; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; throw new Error('网络中断'); }, ); loadEditorProjectMock.mockImplementationOnce(async () => createPerfectPixelProject(operationId, 'applied'), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'project-1' }), { type: 'perfect-pixel', count: 1, }, ); expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); }); expect(refreshAssetLibrary).toHaveBeenCalledTimes(1); expect(screen.getByTestId('dialog').textContent).not.toContain('failed'); }, ); it('treats a gateway-synthesized timeout as an unknown outcome and reconciles', async () => { const applyProjectSnapshot = vi.fn(); let operationId = ''; let reconciledProject: EditorProjectSnapshot | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; throw new ApiClientError({ message: '上游服务请求超时', status: 504, code: 'GATEWAY_UPSTREAM_TIMEOUT', }); }, ); loadEditorProjectMock.mockImplementationOnce(async () => { reconciledProject = createPerfectPixelProject(operationId, 'applied'); return reconciledProject; }); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, { type: 'perfect-pixel', count: 1, }); }); }); it('still treats a gateway rate limit as a known failure', async () => { // 中文注释:对照用例。限流是在网关就被拒、根本没到应用,属于确定失败。把它也算未知会让 // 普通节流弹出「请核对素材库」,正是本次要避免的反向谎报。 const applyProjectSnapshot = vi.fn(); snapImageToPerfectPixelsMock.mockRejectedValueOnce( new ApiClientError({ message: '请求过于频繁', status: 429, code: 'GATEWAY_RATE_LIMITED', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:failed:closed:-:placeholder', ); }); expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect(applyProjectSnapshot).not.toHaveBeenCalled(); }); it('still treats an application upstream error without the marker as known', async () => { // 中文注释:对照用例。api-server 自己的 502 出现在第一次 OSS PUT **之前**(例如准备上传 // 请求失败)时不带 resultPersistenceStarted,此时什么都没落库,触发对账会告诉用户去核对 // 素材库——那是与本条镜像的反向谎报。 const applyProjectSnapshot = vi.fn(); snapImageToPerfectPixelsMock.mockRejectedValueOnce( new ApiClientError({ message: '上游服务请求失败', status: 502, code: 'UPSTREAM_ERROR', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:failed:closed:-:placeholder', ); }); expect(loadEditorProjectMock).not.toHaveBeenCalled(); }); it('keeps a foreign-deleted placeholder out of the canvas after reconciliation', async () => { const applyProjectSnapshot = vi.fn(); let operationId = ''; const transportError = new Error('网络中断'); snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; throw transportError; }, ); loadEditorProjectMock.mockImplementationOnce(async () => createPerfectPixelProject(operationId, 'dialog-missing'), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '完美像素结果已保存到素材库,画布占位已不存在。', ); }); // 中文注释:本例服务端 completion 返回 Ok(None),画布上确实没有结果图层,所以只能用 // 「只在素材库」这条;用「重新加载后可见」会是谎报。两条文案对应两种不同的事实。 expect( screen.getByTestId('reference-pick-warning').textContent, ).not.toContain('重新加载后可见'); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); }); it('uploads an inline perfect-pixel source before flushing and posting', async () => { const order: string[] = []; uploadEditorMediaAssetObjectFileMock.mockImplementationOnce(async () => { order.push('upload'); return { objectKey: 'generated-images/editor/uploaded-source.png', assetObjectId: 'asset-object-uploaded-source', legacyPublicPath: '/generated-images/editor/uploaded-source.png', }; }); const flushProjectPersistence = vi.fn(async () => { order.push('flush'); }); let postedRequest: EditorPixelArtSnapInput | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { postedRequest = request; order.push('post'); return createPerfectPixelResult(request, null); }, ); loadEditorProjectMock.mockImplementationOnce(async () => createPerfectPixelProject( postedRequest!.canvasCompletion.dialogId, 'dialog-missing', ), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalled(); }); expect(order).toEqual(['upload', 'flush', 'post']); expect(postedRequest).toEqual( expect.objectContaining({ sourceImageSrc: 'generated-images/editor/uploaded-source.png', }), ); expect(postedRequest).not.toHaveProperty('sourceResourceId'); const uploadOptions = uploadEditorMediaAssetObjectFileMock.mock.calls[0]?.[2]; expect(uploadOptions).toEqual( expect.objectContaining({ signal: expect.any(AbortSignal), pathSegments: [ 'editor', 'generation-references', 'project-1', postedRequest!.canvasCompletion.dialogId, ], }), ); expect(uploadEditorMediaAssetFileMock).not.toHaveBeenCalled(); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '完美像素结果已保存到素材库,画布占位已不存在。', ); }); }); it('keeps a failed perfect-pixel placeholder and releases busy state', async () => { const applyProjectSnapshot = vi.fn(); // 中文注释:服务端明确拒绝(识别不到像素网格)是已知结果,用 ApiClientError 表达。 // 裸 Error 在生产里对应的是 transport 异常,属于未知结果,会走对账路径。 snapImageToPerfectPixelsMock.mockRejectedValueOnce( new ApiClientError({ message: '像素网格无法识别', status: 422, code: 'HTTP_422', }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:failed:closed:-:placeholder', ); }); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(screen.getByTestId('layers').textContent).toContain( '/generated-images/editor/source.png', ); expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); }); it('refuses to delete a durable in-flight operation and keeps a later known failure visible', async () => { let rejectPerfectPixel: ((error: unknown) => void) | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectPerfectPixel = reject; }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toBe( 'quick-edit:generating:closed:-:placeholder', ); }); fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-'); // 中文注释:用 ApiClientError(已知结果)避免走对账分支,把用例聚焦在「占位没了还要不要说话」。 rejectPerfectPixel?.( new ApiClientError({ message: '完美像素画布占位不存在或尚未保存,请重试。', status: 409, code: 'HTTP_409', }), ); await waitFor(() => { expect(screen.getByTestId('dialog-error').textContent).toBe( '完美像素画布占位不存在或尚未保存,请重试。', ); }); expect(screen.getByTestId('dialog').textContent).toContain('failed'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); }); it('keeps the operation identity through a successful POST and applies only the project GET snapshot', async () => { let resolvePerfectPixel: | ((value: ReturnType) => void) | undefined; let postedRequest: EditorPixelArtSnapInput | undefined; const applyProjectSnapshot = vi.fn(); const upsertGeneratedAsset = vi.fn(); let getProject: EditorProjectSnapshot | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( (request: EditorPixelArtSnapInput) => new Promise((resolve) => { postedRequest = request; resolvePerfectPixel = resolve; }), ); loadEditorProjectMock.mockImplementationOnce(async () => { getProject = createPerfectPixelProject( postedRequest!.canvasCompletion.dialogId, 'applied', ); return getProject; }); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalled(); }); fireEvent.click(screen.getByRole('button', { name: '删除处理占位' })); expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-'); resolvePerfectPixel?.( createPerfectPixelResult( postedRequest!, createPerfectPixelProject( postedRequest!.canvasCompletion.dialogId, 'applied', ), ), ); await waitFor(() => { expect( screen.getByRole('status', { name: '完美像素状态' }).textContent, ).toBe('空闲'); }); expect(upsertGeneratedAsset).toHaveBeenCalledWith( createPerfectPixelAsset(postedRequest!.canvasCompletion.dialogId), ); expect(applyProjectSnapshot).toHaveBeenCalledWith(getProject, { type: 'perfect-pixel', count: 1, }); expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); }); it('recovers a hydrated generating operation with GET only and applies it without perfect-pixel history', async () => { const operationId = 'perfect-pixel-hydrated-applied'; const hydratedDialog = createHydratedPerfectPixelDialog({ operationId }); const appliedProject = createPerfectPixelProject(operationId, 'applied'); const applyProjectSnapshot = vi.fn(); const applyProjectSnapshotWithoutHistory = vi.fn().mockReturnValue(true); loadEditorProjectMock.mockResolvedValueOnce(appliedProject); render( , ); await waitFor(() => { expect(applyProjectSnapshotWithoutHistory).toHaveBeenCalledWith( appliedProject, ); }); expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); expect(loadEditorProjectMock).toHaveBeenCalledWith( 'project-1', expect.objectContaining({ signal: expect.any(AbortSignal), deadlineAt: expect.any(Number), }), ); const readOptions = loadEditorProjectMock.mock.calls[0]?.[1] as | { deadlineAt?: number } | undefined; expect(readOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 10_000); expect(readOptions?.deadlineAt).toBeLessThanOrEqual( hydratedDialog.perfectPixelOperation!.reconcileUntil, ); expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); expect(applyProjectSnapshotWithoutHistory).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).not.toHaveBeenCalled(); }); it('continues GET-only hydration recovery after the first project snapshot is still pending', async () => { const operationId = 'perfect-pixel-hydrated-eventual'; const hydratedDialog = createHydratedPerfectPixelDialog({ operationId }); const appliedProject = createPerfectPixelProject(operationId, 'applied'); const applyProjectSnapshotWithoutHistory = vi.fn().mockReturnValue(true); loadEditorProjectMock .mockResolvedValueOnce(createPerfectPixelProject(operationId, 'pending')) .mockResolvedValueOnce(appliedProject); render( , ); await waitFor( () => { expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); expect(applyProjectSnapshotWithoutHistory).toHaveBeenCalledWith( appliedProject, ); }, { timeout: 3_000 }, ); expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); expect(applyProjectSnapshotWithoutHistory).toHaveBeenCalledTimes(1); }); it('observes an expired hydrated operation at least once and leaves it pending when no result exists', async () => { const operationId = 'perfect-pixel-hydrated-expired'; const readStartedAt = Date.now(); const hydratedDialog = createHydratedPerfectPixelDialog({ operationId, reconcileUntil: Date.now() - 1_000, }); const applyProjectSnapshotWithoutHistory = vi.fn(); loadEditorProjectMock.mockResolvedValueOnce({ projectId: 'project-1', title: '未命名画布', viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], updatedAt: '2026-08-03T00:00:00.000Z', }); render( , ); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); const readOptions = loadEditorProjectMock.mock.calls[0]?.[1] as | { deadlineAt?: number } | undefined; expect(readOptions?.deadlineAt).toBeGreaterThanOrEqual(readStartedAt); expect(readOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 10_000); expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); expect(applyProjectSnapshotWithoutHistory).not.toHaveBeenCalled(); expect(screen.getByTestId('dialog-error').textContent).toBe( '完美像素结果仍待确认。系统不会在刷新后自动重复提交。', ); }); it('never reconstructs a POST when hydration may have happened after snapshot persistence but before submission', async () => { const operationId = 'perfect-pixel-hydrated-before-post'; const hydratedDialog = createHydratedPerfectPixelDialog({ operationId, reconcileUntil: Date.now() - 1_000, }); loadEditorProjectMock.mockResolvedValueOnce( createPerfectPixelProject(operationId, 'pending'), ); render( , ); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); expect(screen.getByTestId('perfect-pixel-operation').textContent).not.toBe( '-', ); }); it.each(['project-switch', 'unmount'] as const)( 'aborts hydration recovery on %s and never applies the stale GET result', async (exitMode) => { const operationId = `perfect-pixel-hydrated-${exitMode}`; const hydratedDialogs = [ createHydratedPerfectPixelDialog({ operationId }), ]; const appliedProject = createPerfectPixelProject(operationId, 'applied'); const deferredProject = createDeferred(); let readSignal: AbortSignal | undefined; loadEditorProjectMock.mockImplementationOnce( async (_projectId: string, options?: { signal?: AbortSignal }) => { readSignal = options?.signal; return deferredProject.promise; }, ); const applyProjectSnapshotWithoutHistory = vi.fn().mockReturnValue(true); const view = render( , ); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); }); if (exitMode === 'project-switch') { view.rerender( , ); } else { view.unmount(); } await waitFor(() => { expect(readSignal?.aborted).toBe(true); }); await act(async () => { deferredProject.resolve(appliedProject); await deferredProject.promise; }); expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); expect(applyProjectSnapshotWithoutHistory).not.toHaveBeenCalled(); if (exitMode === 'project-switch') { view.unmount(); } }, ); it('fails a hydrated project-mismatched operation closed without GET or POST', async () => { const hydratedDialog = createHydratedPerfectPixelDialog({ operationId: 'perfect-pixel-hydrated-project-mismatch', projectId: 'project-other', }); const applyProjectSnapshotWithoutHistory = vi.fn(); render( , ); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain('failed'); }); expect( screen.getByTestId('perfect-pixel-operation-invalid').textContent, ).toBe('invalid'); expect(screen.getByTestId('dialog-error').textContent).toBe( '完美像素操作快照与当前项目不匹配,禁止自动恢复或重试。', ); expect(loadEditorProjectMock).not.toHaveBeenCalled(); expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled(); expect(applyProjectSnapshotWithoutHistory).not.toHaveBeenCalled(); }); it('opens UI design extraction as a mark selection state before submitting', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' })); expect(screen.getByTestId('ui-extraction').textContent).toBe( 'layer-source:rect:0:idle:gemini-3.1-flash-image-preview', ); expect(screen.getByTestId('ui-extraction-source').textContent).toBe( 'layer-source', ); expect(screen.getByTestId('selected').textContent).toBe('layer-source'); expect(screen.getByTestId('tool').textContent).toBe('select'); fireEvent.click(screen.getByRole('button', { name: '选择椭圆框选' })); fireEvent.click(screen.getByRole('button', { name: '选择GPT提取模型' })); fireEvent.click(screen.getByRole('button', { name: '框选UI素材' })); expect(screen.getByTestId('ui-extraction').textContent).toBe( 'layer-source:ellipse:1:idle:gpt-image-2', ); }); it('clears generation side panels and linked edit dialogs when a source layer is deleted', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); await waitFor(() => { expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); }); fireEvent.click(screen.getByRole('button', { name: '打开修改状态' })); fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' })); fireEvent.click(screen.getByRole('button', { name: '清理源图状态' })); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('ui-extraction').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe('-'); }); it('creates a placed character action generation dialog for character layers', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开普通动画' })); expect(screen.getByTestId('character-animation').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe('-'); fireEvent.click(screen.getByRole('button', { name: '打开角色动画' })); expect(screen.getByTestId('dialog').textContent).toBe( 'character-animation:idle:open:-:placeholder', ); expect(screen.getByTestId('character-animation').textContent).toBe( 'layer-character:idle', ); expect(screen.getByTestId('image-context').textContent).toBe('-'); }); it('reopens the character generator when remodeling after opening character animation', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '对当前图层生成动作' })); expect(screen.getByTestId('dialog').textContent).toBe( 'character-animation:idle:open:-:placeholder', ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:-:placeholder', ); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:character-animation:closed|generation-dialog-2:character:open', ); }); it('reopens character animation remodeling from an action result layer', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'character-animation:idle:open:-:placeholder', ); expect(screen.getByTestId('character-animation').textContent).toBe( 'layer-character:idle', ); }); it('warns when an action result has lost its source character layer', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '未找到角色动作关联的原角色图层,无法改造。', ); }); it('keeps character animation asset names synchronized with the dialog', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开角色动画' })); fireEvent.click(screen.getByRole('button', { name: '命名角色动画' })); expect(screen.getByTestId('character-animation-name').textContent).toBe( '勇者挥手:勇者挥手', ); }); it('hides non-generating canvas generation composers without deleting placeholders', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '隐藏生成面板' })); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:closed:-:placeholder', ); }); });