/* @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, splitCanvasLayoutItems, } from './ImageCanvasEditorModel'; import type { CanvasGenerationDialogState, CanvasLayer, CanvasTool, GenerateDialogState, ImageContextMenuState, SidebarPanel, } from './ImageCanvasEditorTypes'; import { CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, DEFAULT_IMAGE_MODEL, IMAGE_MODEL_GPT_IMAGE_2, } from './ImageCanvasGenerationModel'; import { readPerfectPixelOperations, savePerfectPixelOperation, } from './perfectPixelOperationStore'; import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs'; import { inspectPerfectPixelProjectSnapshot, useImageCanvasGenerationWorkflow, } from './useImageCanvasGenerationWorkflow'; const generateEditorImageMock = vi.hoisted(() => vi.fn()); const generateEditorIconSpecMock = 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, generateEditorIconSpec: generateEditorIconSpecMock, 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, // 中文注释:服务端完成 completion 时只做字段级改写(status/composerOpen/ // generatedLayerId/errorMessage),从不摘掉 perfectPixelOperationId。fixture 必须 // 保留这个标记,否则「收口态占位 + 账本已清」这条真实形状永远测不到。 perfectPixelOperationId: operationId, ...(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', generationInputs: { version: 2, action: 'image.perfect-pixel', fields: [], references: [ { id: 'source', title: '原图', refType: 'project-resource', refId: '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, hasPendingImageReferenceUploads = false, applyProjectSnapshot, applyProjectSnapshotWithoutHistory, flushProjectPersistence, onFlushProjectPersistenceSnapshot, refreshAssetLibrary, upsertGeneratedAsset, }: { initialLayers?: CanvasLayer[]; initialCanvasGenerationDialogs?: CanvasGenerationDialogState[]; initialViewport?: { x: number; y: number; scale: number }; projectId?: string; currentUserId?: string; hasPendingImageReferenceUploads?: boolean; 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, hasPendingImageReferenceUploads, openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog, activateCanvasGenerationDialog: dialogs.activateCanvasGenerationDialog, updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById, hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById, getCanvasGenerationDialogById: dialogs.getCanvasGenerationDialogById, 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('|')} {layers .map( (layer) => `${layer.id}:${layer.x}:${layer.y}:${layer.width}:${layer.height}`, ) .join('|')} {activeDialog ? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}` : '-'} {activeDialog?.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.isPickingGenerationReferenceFromCanvas ? 'picking' : 'idle'} {workflow.quickEditPanel ? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}` : '-'} {workflow.quickEditPanel ? `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('rejects an incomplete matching task resource instead of treating it as an applied result', () => { const operationId = 'perfect-pixel-corrupted-resource'; const project = createPerfectPixelProject(operationId, 'applied'); project.resources[0] = { ...project.resources[0]!, objectKey: null, }; expect( inspectPerfectPixelProjectSnapshot(project, { operationId, taskId: `pixel-art-snap-${operationId}`, }), ).toMatchObject({ kind: 'conflict', project, message: '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', }); }); it('uses the server-confirmed stable resource id to reject a corrupted task id', () => { const operationId = 'perfect-pixel-corrupted-task'; const project = createPerfectPixelProject(operationId, 'dialog-missing'); project.resources[0] = { ...project.resources[0]!, taskId: 'pixel-art-snap-another-operation', objectKey: null, }; expect( inspectPerfectPixelProjectSnapshot( project, { operationId, taskId: `pixel-art-snap-${operationId}`, }, project.resources[0]!.resourceId, ), ).toMatchObject({ kind: 'conflict', project, message: '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', }); }); it('rejects a settled dialog whose generated layer points at another resource', () => { const operationId = 'perfect-pixel-mismatched-layer'; const project = createPerfectPixelProject(operationId, 'applied'); project.layers[0] = { ...project.layers[0]!, resourceId: 'resource-other', }; expect( inspectPerfectPixelProjectSnapshot(project, { operationId, taskId: `pixel-art-snap-${operationId}`, }), ).toMatchObject({ kind: 'conflict', project, message: '完美像素占位与任务资源的画布关联不一致,无法自动应用。', }); }); 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('does not open quick edit programmatically for video layers', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); expect(screen.getByTestId('tool').textContent).toBe('select'); expect(screen.getByTestId('selected').textContent).toBe('-'); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('quick-edit-selection').textContent).toBe('-'); }); 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('-'); 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( '10:-12:420:120', ); }); it('opens legacy image remodel with user input snapshot in the default image tool', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开用户快照改造' })); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('dialog').textContent).toBe( 'generate: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('submits legacy image remodel without an origin action through the default image tool', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '旧版森林场景' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:-:placeholder', ); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('placeholder').textContent).toContain('1024:768'); const expectedResultPlacement = screen.getByTestId('placeholder').textContent; fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '旧版森林场景', model: IMAGE_MODEL_GPT_IMAGE_2, generationInputs: expect.objectContaining({ version: 2, action: 'image.generate', references: [], }), }), ); }); const request = generateEditorImageMock.mock.calls[0]?.[0]; expect(request).not.toHaveProperty('referenceImageSrcs'); expect(request).not.toHaveProperty('sourceResourceId'); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:生成图片 1:-:-', ); }); expect(screen.getByTestId('layer-placements').textContent).toContain( `layer-generated-1:${expectedResultPlacement}`, ); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:layer-generated-1:placeholder', ); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:generate:open', ); }); it('submits legacy character remodel through the character tool selected by asset kind', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '红发骑士' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:-:placeholder', ); expect(screen.getByTestId('dialog-prompt').textContent).toBe('红发骑士'); const expectedResultPlacement = screen.getByTestId('placeholder').textContent; fireEvent.click(screen.getByRole('button', { name: '选择角色规范' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '红发骑士', kind: 'character', model: IMAGE_MODEL_GPT_IMAGE_2, referenceImageSrcs: ['resource-character-spec'], generationInputs: expect.objectContaining({ version: 2, action: 'character.generate', references: [ expect.objectContaining({ id: 'specReference', refType: 'project-resource', refId: 'resource-character-spec', }), ], }), }), ); }); await waitFor(() => { expect(screen.getByTestId('layers').textContent).toContain( 'layer-generated-1:角色形象 1:-:character', ); }); expect(screen.getByTestId('layer-placements').textContent).toContain( `layer-generated-1:${expectedResultPlacement}`, ); expect(screen.getByTestId('dialog').textContent).toBe( 'character:idle:open:layer-generated-1:placeholder', ); expect(screen.getByTestId('generation-dialogs').textContent).toBe( 'generation-dialog-1:character:open', ); }); it('rejects image.edit remodel even when the internal callback is invoked', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('quick-edit').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '快速编辑结果不支持改造,请继续使用快速编辑。', ); }); 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: 'A bright coin pop sound', model: 'eleven_text_to_sound_v2', taskId: 'task-sound', audioKind: 'sound-effect', durationSeconds: 7.42, loop: false, }); render(); fireEvent.click(screen.getByRole('button', { name: '打开音效改造' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorSoundEffectMock).toHaveBeenCalledWith( expect.objectContaining({ prompt: '金币跳出音', model: 'eleven_text_to_sound_v2', duration: 8, loop: false, generationInputs: { version: 2, action: 'audio.sound-effect.generate', fields: [ { id: 'prompt', title: '用户描述', value: '金币跳出音' }, { id: 'model', title: 'model', value: 'eleven_text_to_sound_v2' }, { id: 'durationMode', title: '时长模式', value: 'manual' }, { id: 'durationSeconds', title: '请求时长', value: 8 }, { id: 'loop', title: 'Loop', value: false }, ], 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( '10:-12: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: expect.objectContaining({ version: 2, action: 'image.generate', fields: expect.arrayContaining([ { id: 'prompt', 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: expect.objectContaining({ version: 2, action: 'image.generate', fields: expect.arrayContaining([ { id: 'prompt', title: '生成提示词', value: '一张生成图' }, ]), references: [ { id: 'reference', title: '参考图 1', label: '画布参考图', refType: 'project-resource', refId: 'resource-source', }, ], }), }); }); }); it('keeps canvas reference picking active and warns when the next image exceeds capacity', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); for (let index = 0; index < 5; index += 1) { fireEvent.click(screen.getByRole('button', { name: '选择画布参考图' })); } fireEvent.click( screen.getByRole('button', { name: '开始从画布选择参考图' }), ); fireEvent.click(screen.getByRole('button', { name: '选择画布参考图' })); expect( screen.getByTestId('generation-references').textContent?.split('|'), ).toHaveLength(5); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '参考图最多允许 5 张', ); expect(screen.getByTestId('generation-reference-picking').textContent).toBe( 'picking', ); }); it('keeps canvas reference picking active while reference uploads are pending', () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click( screen.getByRole('button', { name: '开始从画布选择参考图' }), ); fireEvent.click(screen.getByRole('button', { name: '选择画布参考图' })); expect(screen.getByTestId('generation-references').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '参考图正在上传,请等待上传完成后再调整参考图或切换模型', ); expect(screen.getByTestId('generation-reference-picking').textContent).toBe( 'picking', ); }); it('rejects image generation submission while reference uploads are pending', async () => { render(); fireEvent.click(screen.getByRole('button', { name: '打开生成' })); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '参考图正在上传,请等待上传完成后再提交生成', ); }); expect(generateEditorImageMock).not.toHaveBeenCalled(); }); 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 icon spec generation with business fields and a stable reference', async () => { generateEditorIconSpecMock.mockResolvedValueOnce( createGenerated({ prompt: 'UI规范图' }), ); 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: '提交生成' })); await waitFor(() => { expect(generateEditorIconSpecMock).toHaveBeenCalledWith( expect.objectContaining({ playSetting: '消除玩法', artStyle: '清爽卡通', referenceId: 'resource-spec-ref', }), ); }); 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: '快速修图', sourceReferenceId: 'resource-source', assetLabel: '源图 快速编辑', generationInputs: expect.objectContaining({ version: 2, action: 'image.edit', fields: expect.arrayContaining([ { id: 'prompt', title: '快速编辑提示词', value: '快速修图' }, ]), references: [ { id: 'source', title: '原图', label: '源图', refType: 'project-resource', refId: 'resource-source', }, ], }), }), ); }); expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty( 'sourceImageSrc', ); expect(editEditorImageMock.mock.calls[0]?.[0]).not.toHaveProperty( 'sourceResourceId', ); 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', generationInputs: { version: 2, action: 'image.crop-expand', fields: [], references: [ expect.objectContaining({ id: 'source', refType: 'project-resource', refId: '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', sourceLayerId: 'layer-source', 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: { version: 2, action: 'image.remove-background', fields: [], references: [ expect.objectContaining({ id: 'source', refType: 'project-resource', refId: 'resource-source', }), ], }, 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: '源图 去背景', generationInputs: { version: 2, action: 'image.remove-background', fields: [], references: [ expect.objectContaining({ id: 'source', refType: 'project-resource', refId: 'resource-source', }), ], }, 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({ preferLatestGenerationDialogs: true, }); 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: { version: 2, action: 'image.perfect-pixel', fields: [], references: [ expect.objectContaining({ id: 'source', refType: 'project-resource', refId: 'resource-source', }), ], }, 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); const applyProjectSnapshot = vi.fn(); snapImageToPerfectPixelsMock .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ) .mockRejectedValueOnce( new ApiClientError({ message: '同一完美像素操作已有其它权威结果,请先读取项目状态对账。', status: 409, code: 'HTTP_409', details: { operationResultAlreadyExists: true }, }), ); loadEditorProjectMock.mockImplementation(async () => { const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as | EditorPixelArtSnapInput | undefined; return snapImageToPerfectPixelsMock.mock.calls.length === 1 ? createConflictingPerfectPixelProject( request!.canvasCompletion.dialogId, ) : createPerfectPixelProject( request!.canvasCompletion.dialogId, 'applied', ); }); 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, { preferLatestGenerationDialogs: true, }); expect(flushProjectPersistence).toHaveBeenNthCalledWith(2, { preferLatestGenerationDialogs: true, }); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalledWith( expect.objectContaining({ projectId: 'project-1' }), { type: 'perfect-pixel', count: 1 }, ); }); }); it('reconciles a retried existing-result marker against its corrupted stable resource as a conflict', async () => { const applyProjectSnapshot = vi.fn(); let corruptedProject: EditorProjectSnapshot | undefined; snapImageToPerfectPixelsMock .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ) .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => { const operationId = request.canvasCompletion.dialogId; corruptedProject = createPerfectPixelProject( operationId, 'dialog-missing', ); corruptedProject.resources[0] = { ...corruptedProject.resources[0]!, taskId: 'pixel-art-snap-corrupted-task', objectKey: null, }; throw new ApiClientError({ message: '同一完美像素操作已有权威结果,请先读取项目状态对账。', status: 409, code: 'HTTP_409', details: { operationResultAlreadyExists: true, resultResourceId: corruptedProject.resources[0]!.resourceId, }, }); }); loadEditorProjectMock.mockImplementation(async () => { const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as | EditorPixelArtSnapInput | undefined; return snapImageToPerfectPixelsMock.mock.calls.length === 1 ? createConflictingPerfectPixelProject( request!.canvasCompletion.dialogId, ) : corruptedProject!; }); render( {})} />, ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog-error').textContent).toContain( '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', ); }); expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2); expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); it('anchors the first submission reconciliation window at POST time when the pre-POST flush is slow', async () => { // 中文注释:pre-POST flush 是服务端硬前置(占位未持久化会被 409 拒收),且没有整体 // 上限。窗口若锚在 flush 之前,慢保存会让 POST 带着已过期的 reconciliation deadline // 发出。这里把时钟停住手工推进,断言 POST 那一刻窗口是刚建立的完整 75 秒。 let clock = 1_800_000_000_000; const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock); try { const flushGate = createDeferred(); const flushProjectPersistence = vi.fn(() => flushGate.promise); let requestAtPost: EditorPixelArtSnapInput | undefined; let ledgerAtPost: ReturnType = new Map(); let clockAtPost = 0; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { requestAtPost = request; clockAtPost = clock; ledgerAtPost = readPerfectPixelOperations('user-a', 'project-1'); throw new ApiClientError({ message: '素材类型校验失败。', status: 400, code: 'HTTP_400', }); }, ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(flushProjectPersistence).toHaveBeenCalledTimes(1); }); // 中文注释:flush 期间必须已有 provisional 账本——否则另一标签页加载同一项目时, // 服务端那条带 marker 的占位会被 hydrate 成 failed + invalid。 const provisional = readPerfectPixelOperations('user-a', 'project-1'); expect(provisional.size).toBe(1); const [provisionalOperation] = [...provisional.values()]; clock += 90_000; flushGate.resolve(); await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); }); const submitted = ledgerAtPost.get(provisionalOperation!.operationId); expect(ledgerAtPost.size).toBe(1); expect(submitted?.submittedAt).toBe(clockAtPost); expect(submitted!.reconcileUntil - submitted!.submittedAt).toBe(75_000); // 中文注释:只覆盖时间字段——identity 与请求体逐字节不变,也不产生第二条账本。 expect(submitted?.taskId).toBe(provisionalOperation!.taskId); expect(JSON.stringify(submitted!.request)).toBe( JSON.stringify(provisionalOperation!.request), ); expect(JSON.stringify(requestAtPost)).toBe( JSON.stringify(provisionalOperation!.request), ); } finally { nowSpy.mockRestore(); } }); it('anchors the exact retry reconciliation window at POST time when the pre-POST flush is slow', async () => { let clock = 1_800_000_000_000; const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => clock); try { const retryFlushGate = createDeferred(); const flushProjectPersistence = vi .fn() .mockResolvedValueOnce(undefined) .mockImplementationOnce(() => retryFlushGate.promise); let requestAtRetryPost: EditorPixelArtSnapInput | undefined; let ledgerAtRetryPost: ReturnType = new Map(); let clockAtRetryPost = 0; snapImageToPerfectPixelsMock .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ) .mockImplementationOnce(async (request: EditorPixelArtSnapInput) => { requestAtRetryPost = request; clockAtRetryPost = clock; ledgerAtRetryPost = readPerfectPixelOperations('user-a', 'project-1'); throw new ApiClientError({ message: '素材类型校验失败。', status: 400, code: 'HTTP_400', }); }); 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 beforeRetry = JSON.parse( screen.getByTestId('perfect-pixel-operation').textContent!, ) as { operationId: string; taskId: string; submittedAt: number }; fireEvent.click(screen.getByRole('button', { name: '重试完美像素' })); await waitFor(() => { expect(flushProjectPersistence).toHaveBeenCalledTimes(2); }); // 中文注释:flush 期间不得提前刷新窗口——挂着的仍是旧 operation。 const duringFlush = JSON.parse( screen.getByTestId('perfect-pixel-operation').textContent!, ) as { submittedAt: number }; expect(duringFlush.submittedAt).toBe(beforeRetry.submittedAt); clock += 90_000; retryFlushGate.resolve(); await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2); }); const retried = ledgerAtRetryPost.get(beforeRetry.operationId); expect(ledgerAtRetryPost.size).toBe(1); expect(retried?.submittedAt).toBe(clockAtRetryPost); expect(retried!.reconcileUntil - retried!.submittedAt).toBe(75_000); expect(retried?.taskId).toBe(beforeRetry.taskId); const firstRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as | EditorPixelArtSnapInput | undefined; expect(JSON.stringify(requestAtRetryPost)).toBe( JSON.stringify(firstRequest), ); } finally { nowSpy.mockRestore(); } }); 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', ); }); fireEvent.click(screen.getByRole('button', { name: '打开其它占位' })); expect(screen.getByTestId('dialog-prompt').textContent).toBe('其它操作'); const dialogIdsBeforeBlockedClick = screen .getByTestId('generation-dialogs') .textContent!.split('|') .map((item) => item.split(':')[0]) .sort(); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); fireEvent.click(screen.getByRole('button', { name: '第二图层完美像素' })); expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); expect( screen .getByTestId('generation-dialogs') .textContent!.split('|') .map((item) => item.split(':')[0]) .sort(), ).toEqual(dialogIdsBeforeBlockedClick); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '该素材已有一条未收口的完美像素操作,请在原占位上继续核对或重试。', ); }); it('still posts and keeps the operation for exact retry when layout persistence fails first', async () => { const applyProjectSnapshot = vi.fn(); const flushProjectPersistence = vi .fn() .mockRejectedValueOnce(new Error('画布保存失败')) .mockResolvedValueOnce(undefined); uploadEditorMediaAssetObjectFileMock.mockResolvedValueOnce({ objectKey: 'generated-images/editor/confirmed-source.png', assetObjectId: 'asset-object-confirmed-source', legacyPublicPath: '/generated-images/editor/confirmed-source.png', }); snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => createMismatchedPerfectPixelResult(request), ); loadEditorProjectMock.mockImplementationOnce(async () => { const request = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as | EditorPixelArtSnapInput | undefined; return createConflictingPerfectPixelProject( request!.canvasCompletion.dialogId, ); }); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); // 中文注释:阶段 3 的核心行为翻转。账本已经先落本机,布局保存失败只影响画布同步, // POST 照发——旧的严格保存会在这里把整条链路拦死。 await waitFor(() => { expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1); }); await waitFor(() => { expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); expect(flushProjectPersistence).toHaveBeenCalledTimes(1); expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).not.toHaveBeenCalled(); const postedOperation = JSON.parse( screen.getByTestId('perfect-pixel-operation').textContent!, ) as { operationId: string; taskId: string; request: EditorPixelArtSnapInput; }; expect(postedOperation.request.sourceImageSrc).toBe( 'generated-images/editor/confirmed-source.png', ); expect(postedOperation.taskId).toBe( `pixel-art-snap-${postedOperation.operationId}`, ); const postedRequest = snapImageToPerfectPixelsMock.mock.calls[0]?.[0] as | EditorPixelArtSnapInput | undefined; expect(JSON.stringify(postedRequest)).toBe( JSON.stringify(postedOperation.request), ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); expect(uploadEditorMediaAssetObjectFileMock).toHaveBeenCalledTimes(1); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '该素材已有一条未收口的完美像素操作,请在原占位上继续核对或重试。', ); }); it.each([ ['post-persistence', { resultPersistenceStarted: true }], ['an existing operation result', { operationResultAlreadyExists: true }], ])('reconciles a responded failure marked as %s', async (_label, details) => { 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, }); }, ); 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('uses the server-confirmed stable resource id during existing-result reconciliation', async () => { const applyProjectSnapshot = vi.fn(); let reconciledProject: EditorProjectSnapshot | undefined; snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { const operationId = request.canvasCompletion.dialogId; reconciledProject = createPerfectPixelProject( operationId, 'dialog-missing', ); reconciledProject.resources[0] = { ...reconciledProject.resources[0]!, taskId: 'pixel-art-snap-corrupted-task', objectKey: null, }; throw new ApiClientError({ message: '同一完美像素操作已有权威结果,请先读取项目状态对账。', status: 409, code: 'HTTP_409', details: { operationResultAlreadyExists: true, resultResourceId: reconciledProject.resources[0]!.resourceId, }, }); }, ); loadEditorProjectMock.mockImplementationOnce(async () => { expect(reconciledProject).toBeDefined(); return reconciledProject!; }); render( {})} />, ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(screen.getByTestId('dialog-error').textContent).toContain( '完美像素任务资源记录不完整或不属于当前项目,无法自动确认结果。', ); }); expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(screen.getByTestId('dialog').textContent).toContain( 'pending-confirmation', ); }); 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('deletes the durable operation and reports a later applied GET result as asset-only', 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).toBe('-'); rejectPerfectPixel?.(new Error('网络中断')); await waitFor(() => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '完美像素结果已生成并保存到素材库。画布占位已被删除,结果未落入当前画布,重新加载后可见。', ); }); expect(applyProjectSnapshot).not.toHaveBeenCalled(); expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); }); it('does not turn an applied perfect-pixel result into an invalid placeholder when rehydrated', async () => { // 中文注释:这条盯的是两个 hook 的**协作**,不是单个函数。此前所有 applied 用例的 // applyProjectSnapshot 都是空桩,于是「收口 → 清账本 → 用真实 hydrate 重新套用权威 // 快照」这条链路从未被跑过,成功结果被判成 failed 的缺陷才得以溜过整套测试。这里把 // 桩换成真实的 splitCanvasLayoutItems + 本机账本读取。 let operationId = ''; let rehydratedDialogs: CanvasGenerationDialogState[] = []; const applyProjectSnapshot = vi.fn((project: EditorProjectSnapshot) => { const { generationDialogs } = splitCanvasLayoutItems( project.layers, new Map(), 'user-a', readPerfectPixelOperations('user-a', 'project-1'), ); rehydratedDialogs = generationDialogs; }); snapImageToPerfectPixelsMock.mockImplementationOnce( async (request: EditorPixelArtSnapInput) => { operationId = request.canvasCompletion.dialogId; throw new Error('网络中断'); }, ); loadEditorProjectMock.mockImplementation(async () => createPerfectPixelProject(operationId, 'applied'), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '完美像素' })); await waitFor(() => { expect(applyProjectSnapshot).toHaveBeenCalled(); }); const settledDialog = rehydratedDialogs.find( (dialog) => dialog.id === operationId, ); expect(settledDialog).toBeTruthy(); expect(settledDialog?.status).toBe('idle'); expect(settledDialog?.generatedLayerId).toBe(`layer-${operationId}`); expect(settledDialog).not.toHaveProperty('perfectPixelOperationInvalid'); expect(settledDialog?.errorMessage).toBeUndefined(); // 中文注释:收口后账本确实被清,本用例因此正是「有标记、没账本」的真实形状。 expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0); }); it('reconciles an orphan local ledger entry whose placeholder never reached the layout', async () => { // 中文注释:账本先于 POST 落本机、布局保存只是尽力而为,因此存在「账本有、占位没写进 // 布局就断电」的窗口。刷新后必须仍然对账并告知用户结果去了素材库——这正是删掉严格 // 布局保存之后仍然安全的原因。 const operationId = 'perfect-pixel-orphan-ledger'; const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); savePerfectPixelOperation('user-a', 'project-1', { version: 1, kind: 'perfect-pixel', operationId, taskId: `pixel-art-snap-${operationId}`, request: { sourceImageSrc: 'generated-images/editor/source.png', projectId: 'project-1', canvasCompletion: { dialogId: operationId, title: '源图 · 完美像素', placeholder: { x: 0, y: 0, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }, submittedAt: Date.now(), reconcileUntil: Date.now() + 75_000, }); loadEditorProjectMock.mockImplementation(async () => createPerfectPixelProject(operationId, 'dialog-missing'), ); render( true)} flushProjectPersistence={vi.fn().mockResolvedValue(undefined)} refreshAssetLibrary={refreshAssetLibrary} />, ); await waitFor(() => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '完美像素结果已保存到素材库,画布占位已不存在。', ); }); expect(loadEditorProjectMock).toHaveBeenCalled(); expect(refreshAssetLibrary).toHaveBeenCalled(); // 中文注释:收口后账本必须清掉,否则下次加载会再发一次无谓的对账 GET。 expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0); }); it('still reconciles an orphan ledger entry whose reconcile window already expired', async () => { // 中文注释:孤儿的目标场景就是「关掉浏览器、稍后重开」,重开必然晚于 75 秒对账窗口。 // 按 reconcileUntil 短路会让这条兜底分支在它唯一的用武之地上完全失效。 const operationId = 'perfect-pixel-orphan-expired'; const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); const submittedAt = Date.now() - 6 * 60 * 60 * 1_000; savePerfectPixelOperation('user-a', 'project-1', { version: 1, kind: 'perfect-pixel', operationId, taskId: `pixel-art-snap-${operationId}`, request: { sourceImageSrc: 'generated-images/editor/source.png', projectId: 'project-1', canvasCompletion: { dialogId: operationId, title: '源图 · 完美像素', placeholder: { x: 0, y: 0, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }, submittedAt, reconcileUntil: submittedAt + 75_000, }); loadEditorProjectMock.mockImplementation(async () => createPerfectPixelProject(operationId, 'dialog-missing'), ); render( true)} flushProjectPersistence={vi.fn().mockResolvedValue(undefined)} refreshAssetLibrary={refreshAssetLibrary} />, ); await waitFor(() => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '完美像素结果已保存到素材库,画布占位已不存在。', ); }); // 中文注释:孤儿只做一次确定性的读,不轮询。 expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); expect(readPerfectPixelOperations('user-a', 'project-1').size).toBe(0); }); it('keeps an orphan ledger entry whose single read still cannot tell the outcome', async () => { // 中文注释:孤儿不等于「服务端已经停下」。浏览器关掉不会中止服务端处理,api-server 的 // 处理与持久化预算合计可达 90 秒,此刻单次 GET 没看见 resource 只说明「还不知道」。 // 清账本会让服务端稍后落库时再无对账凭据,用户也永远等不到「结果已进素材库」的提示。 const operationId = 'perfect-pixel-orphan-nothing-landed'; const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined); const submittedAt = Date.now() - 6 * 60 * 60 * 1_000; savePerfectPixelOperation('user-a', 'project-1', { version: 1, kind: 'perfect-pixel', operationId, taskId: `pixel-art-snap-${operationId}`, request: { sourceImageSrc: 'generated-images/editor/source.png', projectId: 'project-1', canvasCompletion: { dialogId: operationId, title: '源图 · 完美像素', placeholder: { x: 0, y: 0, width: 320, height: 240, originalWidth: 320, originalHeight: 240, }, }, }, submittedAt, reconcileUntil: submittedAt + 75_000, }); loadEditorProjectMock.mockImplementation(async () => createEmptyPerfectPixelProject(), ); render( true)} flushProjectPersistence={vi.fn().mockResolvedValue(undefined)} refreshAssetLibrary={refreshAssetLibrary} />, ); await waitFor(() => { expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); }); // 中文注释:结论未定,账本必须留给下次加载重读;保留期与条数上限兜住残留。 const ledger = readPerfectPixelOperations('user-a', 'project-1'); expect(ledger.size).toBe(1); expect(ledger.get(operationId)?.operationId).toBe(operationId); // 中文注释:既然什么都没确认,就不该打扰用户。 expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-'); expect(refreshAssetLibrary).not.toHaveBeenCalled(); }); 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('deletes a durable in-flight operation and reports the later known failure globally', 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).toBe('-'); // 中文注释:用 ApiClientError(已知结果)避免走对账分支,把用例聚焦在「占位没了还要不要说话」。 rejectPerfectPixel?.( new ApiClientError({ message: '完美像素画布占位不存在或尚未保存,请重试。', status: 409, code: 'HTTP_409', }), ); await waitFor(() => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '完美像素画布占位不存在或尚未保存,请重试。', ); }); expect(screen.getByTestId('generation-dialogs').textContent).toBe('-'); expect(loadEditorProjectMock).not.toHaveBeenCalled(); 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(); }); 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'); // 中文注释:标记失效必须同时丢弃快照,与 hydrateCanvasGenerationDialog 一致。两者并存会 // 造出「重试按钮因 invalid 消失、快照却还挂着」的矛盾态。 expect(screen.getByTestId('perfect-pixel-operation').textContent).toBe('-'); 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('rejects UI extraction model switching while reference uploads are pending', () => { const { rerender } = render(); fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' })); rerender(); fireEvent.click(screen.getByRole('button', { name: '选择GPT提取模型' })); expect(screen.getByTestId('ui-extraction').textContent).toBe( 'layer-source:rect:0:idle:gemini-3.1-flash-image-preview', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '参考图正在上传,请等待上传完成后再调整参考图或切换模型', ); }); it('rejects UI extraction submission while reference uploads are pending', async () => { const { rerender } = render(); fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' })); fireEvent.click(screen.getByRole('button', { name: '框选UI素材' })); rerender(); fireEvent.click(screen.getByRole('button', { name: '提交UI素材提取' })); await waitFor(() => { expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '参考图正在上传,请等待上传完成后再提交生成', ); }); expect(generateEditorImageMock).not.toHaveBeenCalled(); expect(screen.getByTestId('ui-extraction').textContent).toContain( ':1:idle:', ); }); it('keeps quick edit and UI extraction contexts open while reference uploads are pending', () => { const quickEditRender = render(); fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' })); quickEditRender.rerender( , ); fireEvent.click(screen.getByRole('button', { name: '关闭快速编辑' })); expect(screen.getByTestId('quick-edit').textContent).toBe( 'layer-source:idle:-', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '参考图正在上传,请等待上传完成后再切换或关闭生成面板', ); quickEditRender.unmount(); const uiRender = render(); fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' })); uiRender.rerender( , ); 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('reference-pick-warning').textContent).toBe( '参考图正在上传,请等待上传完成后再切换或关闭生成面板', ); }); 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('warns when UI asset extraction has lost its non-replaceable source layer', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('ui-extraction').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '原 UI 设计图不在当前画布,当前素材提取面板无法重新选择来源设计图,无法改造。', ); }); it('opens V2 panels without references that are not on the current canvas', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:-:placeholder', ); expect(screen.getByTestId('generation-references').textContent).toBe(''); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( `${CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING} 部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。`, ); }); it('best-effort restores a required reference that the target panel can replace', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toContain( 'character:idle:open', ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '部分原参考素材不在当前画布或来自面板上传,未恢复,请重新选择。', ); }); it('warns and submits current defaults when V2 parameters are invalid', async () => { generateEditorImageMock.mockResolvedValueOnce( createGenerated({ prompt: '森林场景' }), ); render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog-image-options').textContent).toBe( `generate:${DEFAULT_IMAGE_MODEL}:1:1:1K`, ); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( CANVAS_GENERATION_PARAMETER_FALLBACK_WARNING, ); fireEvent.click(screen.getByRole('button', { name: '提交生成' })); await waitFor(() => { expect(generateEditorImageMock).toHaveBeenCalledWith( expect.objectContaining({ model: DEFAULT_IMAGE_MODEL, aspectRatio: '1:1', imageSize: '1K', generationInputs: expect.objectContaining({ version: 2, action: 'image.generate', fields: expect.arrayContaining([ expect.objectContaining({ id: 'model', value: DEFAULT_IMAGE_MODEL, }), expect.objectContaining({ id: 'aspectRatio', value: '1:1' }), expect.objectContaining({ id: 'imageSize', value: '1K' }), ]), }), }), ); }); }); it('reports malformed metadata after clicking a supported redraw action', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '保存的生成参数版本不受支持或数据损坏,无法改造。', ); }); it('does not legacy-fallback when hydration retained an invalid versioned recipe', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe('-'); expect(screen.getByTestId('reference-pick-warning').textContent).toBe( '保存的生成参数版本不受支持或数据损坏,无法改造。', ); }); it('warns when a legacy recipe is restored successfully', () => { render( , ); fireEvent.click(screen.getByRole('button', { name: '打开图片改造' })); expect(screen.getByTestId('dialog').textContent).toBe( 'generate:idle:open:-:placeholder', ); expect(screen.getByTestId('quick-edit').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', ); }); });