7e3adc38b9
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/172 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
5595 lines
193 KiB
TypeScript
5595 lines
193 KiB
TypeScript
/* @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> = {}): 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<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((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<CanvasLayer[]>(initialLayers);
|
|
const [viewport, setViewport] = useState(initialViewport);
|
|
const [activeTool, setActiveTool] = useState<CanvasTool>('select');
|
|
const [activeSidebarPanel, setActiveSidebarPanel] =
|
|
useState<SidebarPanel | null>('assets');
|
|
const [selectedLayerId, setSelectedLayerId] = useState<string | null>(null);
|
|
const [metadataLayer, setMetadataLayer] = useState<CanvasLayer | null>(null);
|
|
const [imageContextMenu, setImageContextMenu] =
|
|
useState<ImageContextMenuState | null>({
|
|
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<GenerateDialogState | null>(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 (
|
|
<div>
|
|
<span data-testid="tool">{activeTool}</span>
|
|
<span data-testid="sidebar">{activeSidebarPanel ?? '-'}</span>
|
|
<span data-testid="selected">{selectedLayerId ?? '-'}</span>
|
|
<span data-testid="metadata">{metadataLayer?.id ?? '-'}</span>
|
|
<span data-testid="image-context">{imageContextMenu ? 'open' : '-'}</span>
|
|
<span data-testid="viewport">{`${viewport.x}:${viewport.y}:${viewport.scale}`}</span>
|
|
<span data-testid="placeholder">
|
|
{activeDialog?.placeholder
|
|
? `${activeDialog.placeholder.x}:${activeDialog.placeholder.y}:${activeDialog.placeholder.width}:${activeDialog.placeholder.height}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="layers">
|
|
{layers
|
|
.map(
|
|
(layer) =>
|
|
`${layer.id}:${layer.title}:${layer.sourceResourceId ?? '-'}:${layer.assetKind ?? '-'}:${layer.src}:${layer.taskId ?? '-'}`,
|
|
)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="layer-resources">
|
|
{layers
|
|
.map(
|
|
(layer) =>
|
|
`${layer.id}:${layer.resourceId}:${layer.objectKey ?? '-'}:${layer.assetObjectId ?? '-'}`,
|
|
)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="layer-placements">
|
|
{layers
|
|
.map(
|
|
(layer) =>
|
|
`${layer.id}:${layer.x}:${layer.y}:${layer.width}:${layer.height}`,
|
|
)
|
|
.join('|')}
|
|
</span>
|
|
<span data-testid="dialog">
|
|
{activeDialog
|
|
? `${activeDialog.mode}:${activeDialog.status}:${activeDialog.composerOpen !== false ? 'open' : 'closed'}:${activeDialog.generatedLayerId ?? '-'}:${activeDialog.placeholder ? 'placeholder' : '-'}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="dialog-prompt">{activeDialog?.prompt || '-'}</span>
|
|
<span data-testid="dialog-error">
|
|
{activeDialog?.errorMessage || '-'}
|
|
</span>
|
|
<span data-testid="dialog-image-options">
|
|
{activeDialog
|
|
? `${activeDialog.mode}:${activeDialog.imageModel ?? '-'}:${activeDialog.aspectRatio ?? '-'}:${activeDialog.imageSize ?? '-'}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="generation-dialogs">
|
|
{dialogs.canvasGenerationDialogs
|
|
.map(
|
|
(dialog) =>
|
|
`${dialog.id}:${dialog.mode}:${dialog.composerOpen !== false ? 'open' : 'closed'}`,
|
|
)
|
|
.join('|') || '-'}
|
|
</span>
|
|
<span data-testid="live-session-dialogs">
|
|
{dialogs.canvasGenerationDialogs
|
|
.filter((dialog) => dialog.requiresLiveSession === true)
|
|
.map((dialog) => dialog.id)
|
|
.join('|') || '-'}
|
|
</span>
|
|
<span data-testid="perfect-pixel-operation">
|
|
{activeDialog?.perfectPixelOperation
|
|
? JSON.stringify(activeDialog.perfectPixelOperation)
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="perfect-pixel-operation-invalid">
|
|
{activeDialog?.perfectPixelOperationInvalid === true ? 'invalid' : '-'}
|
|
</span>
|
|
<span data-testid="generation-references">
|
|
{activeDialog?.generationReferences
|
|
?.map((reference) => reference.label)
|
|
.join('|') ?? '-'}
|
|
</span>
|
|
<span data-testid="character-spec-reference">
|
|
{activeDialog?.characterSpecReference?.label ?? '-'}
|
|
</span>
|
|
<span data-testid="icon-spec-reference">
|
|
{activeDialog?.iconSpecReference?.label ?? '-'}
|
|
</span>
|
|
<span data-testid="ui-design-spec-reference">
|
|
{activeDialog?.uiDesignSpecReference?.label ?? '-'}
|
|
</span>
|
|
<span data-testid="reference-pick-warning">
|
|
{workflow.generationWarning ?? '-'}
|
|
</span>
|
|
<span data-testid="generation-reference-picking">
|
|
{workflow.isPickingGenerationReferenceFromCanvas ? 'picking' : 'idle'}
|
|
</span>
|
|
<span data-testid="quick-edit">
|
|
{workflow.quickEditPanel
|
|
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="quick-edit-image-options">
|
|
{workflow.quickEditPanel
|
|
? `quick-edit:${workflow.quickEditPanel.model}:${workflow.quickEditPanel.aspectRatio ?? '-'}:${workflow.quickEditPanel.imageSize ?? '-'}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="crop-expand">
|
|
{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}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="ui-extraction">
|
|
{workflow.uiAssetExtractionState
|
|
? `${workflow.uiAssetExtractionState.sourceLayerId}:${workflow.uiAssetExtractionState.tool ?? 'none'}:${workflow.uiAssetExtractionState.marks.length}:${workflow.uiAssetExtractionState.status}:${workflow.uiAssetExtractionState.model}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="ui-extraction-source">
|
|
{workflow.uiAssetExtractionSourceLayer?.id ?? '-'}
|
|
</span>
|
|
<span data-testid="quick-edit-selection">
|
|
{workflow.quickEditSelectionState
|
|
? `${workflow.quickEditSelectionState.sourceLayerId}:${workflow.quickEditSelectionState.tool ?? 'none'}:${workflow.quickEditSelectionState.marks.length}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="character-animation">
|
|
{workflow.characterAnimationPanel
|
|
? `${workflow.characterAnimationPanel.sourceLayerId}:${workflow.characterAnimationPanel.status}`
|
|
: '-'}
|
|
</span>
|
|
<span data-testid="character-animation-name">
|
|
{`${workflow.characterAnimationPanel?.assetLabel ?? '-'}:${activeDialog?.assetLabel ?? '-'}`}
|
|
</span>
|
|
<span data-testid="character-animation-price">
|
|
{workflow.characterAnimationPrice}
|
|
</span>
|
|
<span data-testid="fit-count">
|
|
{fitLayersMockRef.current.mock.calls.length}
|
|
</span>
|
|
<span data-testid="task-refresh-key">{workflow.taskListRefreshKey}</span>
|
|
<output aria-label="完美像素状态">
|
|
{workflow.perfectPixelLayerIds.has(layers[0]!.id) ? '处理中' : '空闲'}
|
|
</output>
|
|
<button type="button" onClick={workflow.openGenerateDialog}>
|
|
打开生成
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.openCanvasGenerationDialog({
|
|
mode: 'generate',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
sourceLayerId: 'layer-source',
|
|
imageModel: 'gpt-image-2',
|
|
aspectRatio: '2:3',
|
|
imageSize: '2K',
|
|
})
|
|
}
|
|
>
|
|
写入源图生成参数快照
|
|
</button>
|
|
<button type="button" onClick={workflow.openCharacterGenerationDialog}>
|
|
打开角色生成
|
|
</button>
|
|
<button type="button" onClick={workflow.openIconGenerationDialog}>
|
|
打开图标生成
|
|
</button>
|
|
<button type="button" onClick={() => workflow.openSpecDialog('icon')}>
|
|
打开图标规范
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
workflow.updateSpecFormValue('playSetting', '消除玩法');
|
|
workflow.updateSpecFormValue('artStyle', '清爽卡通');
|
|
}}
|
|
>
|
|
填写图标规范
|
|
</button>
|
|
<button type="button" onClick={workflow.openUiDesignGenerationDialog}>
|
|
打开UI设计生成
|
|
</button>
|
|
<button type="button" onClick={workflow.openVideoGenerationDialog}>
|
|
打开视频生成
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.openRedrawPanel(
|
|
createLayer({
|
|
id: 'layer-sound',
|
|
title: '游戏音效',
|
|
src: 'data:audio/mpeg;base64,sound',
|
|
mediaType: 'audio',
|
|
assetKind: 'sound-effect',
|
|
width: 420,
|
|
height: 120,
|
|
originalWidth: 420,
|
|
originalHeight: 120,
|
|
prompt: '【系统内置】完整音效提示词:金币跳出音,内部混音参数。',
|
|
generationInputs: {
|
|
fields: [
|
|
{ title: 'prompt', value: '金币跳出音' },
|
|
{ title: 'model', value: 'audio1.0' },
|
|
{ title: '时长', value: '8秒' },
|
|
],
|
|
references: [],
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
打开音效改造
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.setGenerateDialog({
|
|
mode: 'edit',
|
|
prompt: '',
|
|
status: 'idle',
|
|
sourceLayerId: 'layer-source',
|
|
})
|
|
}
|
|
>
|
|
打开修改状态
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.openQuickEditPanel(layers[0]!)}
|
|
>
|
|
打开快速编辑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.openRedrawPanel(layers[0]!)}
|
|
>
|
|
打开图片改造
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.openRedrawPanel(
|
|
createLayer({
|
|
id: 'layer-internal-prompt',
|
|
prompt: '【系统内置】完整拼接提示词,带默认画质和模型约束。',
|
|
generationInputs: {
|
|
fields: [{ title: '视频描述', value: '用户的视频描述' }],
|
|
references: [],
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
打开用户快照改造
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.openQuickEditPanel(
|
|
createLayer({
|
|
id: 'layer-character-generated',
|
|
title: '角色形象',
|
|
assetKind: 'character',
|
|
prompt: '【系统内置】角色提示词',
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '红发骑士' }],
|
|
references: [],
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
快速编辑角色图
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.openRedrawPanel(
|
|
createLayer({
|
|
id: 'layer-publication-generated',
|
|
title: '宣发素材',
|
|
assetKind: 'publication-material',
|
|
originalWidth: 720,
|
|
originalHeight: 1280,
|
|
width: 180,
|
|
height: 320,
|
|
prompt: '【系统内置】宣发提示词',
|
|
generationInputs: {
|
|
fields: [
|
|
{ title: '游戏名', value: '星海远征' },
|
|
{ title: '游戏分类', value: '冒险' },
|
|
{ title: '一句话描述游戏', value: '驾驶飞船探索星云' },
|
|
],
|
|
references: [],
|
|
},
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
改造宣发图
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.openCanvasGenerationDialog({
|
|
mode: 'generate',
|
|
prompt: '普通图',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generatedLayerId: 'layer-source',
|
|
imageModel: 'gpt-image-2',
|
|
aspectRatio: '2:3',
|
|
imageSize: '2K',
|
|
})
|
|
}
|
|
>
|
|
写入普通图来源生成器
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.openCanvasGenerationDialog({
|
|
mode: 'quick-edit',
|
|
prompt: '去除背景',
|
|
status: 'generating',
|
|
composerOpen: false,
|
|
sourceLayerId: 'layer-source',
|
|
imageModel: 'gpt-image-2',
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
})
|
|
}
|
|
>
|
|
写入派生快速编辑生成器
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.openQuickEditPanel(layers[0]!)}
|
|
>
|
|
快速编辑来源普通图
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.openCropExpandPanel(layers[0]!)}
|
|
>
|
|
打开裁扩
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.setCropExpandPanel((currentPanel) =>
|
|
currentPanel
|
|
? {
|
|
...currentPanel,
|
|
frame: {
|
|
x: currentPanel.frame.x - 10,
|
|
y: currentPanel.frame.y - 20,
|
|
width: currentPanel.frame.width + 60,
|
|
height: currentPanel.frame.height + 40,
|
|
},
|
|
}
|
|
: currentPanel,
|
|
)
|
|
}
|
|
>
|
|
拖拽裁扩边界
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.setCropExpandPanel((currentPanel) =>
|
|
currentPanel ? { ...currentPanel, ratio: '1:1' } : currentPanel,
|
|
)
|
|
}
|
|
>
|
|
选择1比1
|
|
</button>
|
|
<button type="button" onClick={() => void workflow.submitCropExpand()}>
|
|
完成裁扩
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void workflow.removeSelectedLayerBackground(layers[0]!)}
|
|
>
|
|
去除背景
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
void workflow.snapSelectedLayerToPerfectPixels(layers[0]!)
|
|
}
|
|
>
|
|
完美像素
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
void workflow.snapSelectedLayerToPerfectPixels(
|
|
layers[1] ?? layers[0]!,
|
|
)
|
|
}
|
|
>
|
|
第二图层完美像素
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.openCanvasGenerationDialog({
|
|
mode: 'generate',
|
|
prompt: '其它操作',
|
|
status: 'idle',
|
|
})
|
|
}
|
|
>
|
|
打开其它占位
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (activeCanvasDialog) {
|
|
void workflow.retryPerfectPixelOperation(activeCanvasDialog.id);
|
|
}
|
|
}}
|
|
>
|
|
重试完美像素
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (activeCanvasDialog) {
|
|
dialogs.removeCanvasGenerationDialogById(activeCanvasDialog.id);
|
|
}
|
|
}}
|
|
>
|
|
删除处理占位
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void workflow.splitSelectedIconSpritesheet(layers[0]!)}
|
|
>
|
|
拆分图集
|
|
</button>
|
|
<output aria-label="拆图状态">
|
|
{workflow.splittingIconSpritesheetLayerIds.has(layers[0]!.id)
|
|
? '拆图中'
|
|
: '空闲'}
|
|
</output>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.openCharacterAnimationPanel(
|
|
createLayer({ id: 'layer-character', assetKind: 'character' }),
|
|
)
|
|
}
|
|
>
|
|
打开角色动画
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.openCharacterAnimationPanel(layers[0]!)}
|
|
>
|
|
对当前图层生成动作
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.openCharacterAnimationPanel(
|
|
createLayer({ id: 'layer-plain' }),
|
|
)
|
|
}
|
|
>
|
|
打开普通动画
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.setCharacterAnimationPanel((currentPanel) =>
|
|
currentPanel
|
|
? { ...currentPanel, assetLabel: '勇者挥手' }
|
|
: currentPanel,
|
|
)
|
|
}
|
|
>
|
|
命名角色动画
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.setGenerateDialog((currentDialog) =>
|
|
currentDialog
|
|
? { ...currentDialog, prompt: '一张生成图' }
|
|
: currentDialog,
|
|
)
|
|
}
|
|
>
|
|
填写生成提示词
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.setGenerateDialog((currentDialog) =>
|
|
currentDialog
|
|
? {
|
|
...currentDialog,
|
|
prompt: '快速修图',
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
>
|
|
填写快速编辑提示词
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.setGenerateDialog((currentDialog) =>
|
|
currentDialog
|
|
? {
|
|
...currentDialog,
|
|
aspectRatio: '2:3',
|
|
imageSize: '2K',
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
>
|
|
选择2比3和2K
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const latestDialog = activeDialogRef.current;
|
|
if (latestDialog) {
|
|
void workflow.submitImageGeneration(latestDialog);
|
|
}
|
|
}}
|
|
>
|
|
提交生成
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.pickGenerationReferenceFromLayer(
|
|
createLayer({ id: 'layer-reference', title: '画布参考图' }),
|
|
)
|
|
}
|
|
>
|
|
选择画布参考图
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.setIsPickingGenerationReferenceFromCanvas(true)}
|
|
>
|
|
开始从画布选择参考图
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.pickCharacterSpecFromLayer(
|
|
createLayer({
|
|
id: 'layer-character-spec',
|
|
resourceId: 'resource-character-spec',
|
|
title: '上一张角色规范',
|
|
src: 'data:image/png;base64,character-spec',
|
|
assetKind: 'spec',
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
选择角色规范
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.pickCharacterSpecFromLayer(
|
|
createLayer({
|
|
id: 'layer-character-image',
|
|
resourceId: 'resource-character-image',
|
|
title: '角色形象 2',
|
|
src: 'data:image/png;base64,character-image',
|
|
assetKind: 'character',
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
选择非规范角色图
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.pickIconSpecFromLayer(
|
|
createLayer({
|
|
id: 'layer-icon-spec',
|
|
resourceId: 'resource-icon-spec',
|
|
title: '上一张图标规范',
|
|
src: 'data:image/png;base64,icon-spec',
|
|
assetKind: 'icon-spec',
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
选择图标规范
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.pickIconSpecFromLayer(
|
|
createLayer({
|
|
id: 'layer-icon-image',
|
|
resourceId: 'resource-icon-image',
|
|
title: '图标素材 2',
|
|
src: 'data:image/png;base64,icon-image',
|
|
assetKind: 'icon',
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
选择非图标规范图
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.pickUiDesignSpecFromLayer(
|
|
createLayer({
|
|
id: 'layer-ui-icon-spec',
|
|
resourceId: 'resource-ui-icon-spec',
|
|
title: '上一张UI图标规范',
|
|
src: 'data:image/png;base64,ui-icon-spec',
|
|
assetKind: 'icon-spec',
|
|
}),
|
|
)
|
|
}
|
|
>
|
|
选择UI图标规范
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'character'
|
|
? {
|
|
...currentDialog,
|
|
characterSpecReference: {
|
|
id: 'upload-character-spec',
|
|
label: '上传角色规范',
|
|
src: 'data:image/png;base64,upload-character-spec',
|
|
resourceId: 'resource-upload-character-spec',
|
|
},
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
>
|
|
模拟上传角色规范
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'icon'
|
|
? {
|
|
...currentDialog,
|
|
iconSpecReference: {
|
|
id: 'upload-icon-spec',
|
|
label: '上传图标规范',
|
|
src: 'data:image/png;base64,upload-icon-spec',
|
|
resourceId: 'resource-upload-icon-spec',
|
|
},
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
>
|
|
模拟上传图标规范
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
dialogs.setGenerateDialog((currentDialog) =>
|
|
currentDialog?.mode === 'spec'
|
|
? {
|
|
...currentDialog,
|
|
specReference: {
|
|
id: 'spec-ref',
|
|
label: '参考.png',
|
|
src: 'data:image/png;base64,ref',
|
|
resourceId: 'resource-spec-ref',
|
|
},
|
|
}
|
|
: currentDialog,
|
|
)
|
|
}
|
|
>
|
|
添加规范参考图
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.setQuickEditPanel((currentPanel) =>
|
|
currentPanel
|
|
? { ...currentPanel, prompt: '快速修图' }
|
|
: currentPanel,
|
|
)
|
|
}
|
|
>
|
|
填写快速编辑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.changeQuickEditSelectionTool('rect')}
|
|
>
|
|
选择快速编辑矩形框选
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.changeQuickEditSelectionTool('ellipse')}
|
|
>
|
|
选择快速编辑椭圆框选
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.changeQuickEditSelectionTool('brush')}
|
|
>
|
|
选择快速编辑画笔框选
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
workflow.startQuickEditSelectionPointer({ x: 20, y: 30 });
|
|
workflow.moveQuickEditSelectionPointer({ x: 120, y: 150 });
|
|
workflow.endQuickEditSelectionPointer();
|
|
}}
|
|
>
|
|
快速编辑框选
|
|
</button>
|
|
<button type="button" onClick={() => void workflow.submitQuickEdit()}>
|
|
提交快速编辑
|
|
</button>
|
|
<button type="button" onClick={() => workflow.setQuickEditPanel(null)}>
|
|
关闭快速编辑
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.clearDeletedLayerGenerationState('layer-source')
|
|
}
|
|
>
|
|
清理源图状态
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.extractUiDesignAssets(
|
|
createLayer({ assetKind: 'ui-design' }),
|
|
)
|
|
}
|
|
>
|
|
打开UI素材框选
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => workflow.changeUiAssetExtractionTool('ellipse')}
|
|
>
|
|
选择椭圆框选
|
|
</button>
|
|
<button type="button" onClick={workflow.cancelUiAssetExtraction}>
|
|
关闭UI素材框选
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
workflow.changeUiAssetExtractionModel(IMAGE_MODEL_GPT_IMAGE_2)
|
|
}
|
|
>
|
|
选择GPT提取模型
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
workflow.startUiAssetExtractionPointer({ x: 10, y: 20 });
|
|
workflow.moveUiAssetExtractionPointer({ x: 80, y: 100 });
|
|
workflow.endUiAssetExtractionPointer();
|
|
}}
|
|
>
|
|
框选UI素材
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void workflow.submitUiAssetExtraction()}
|
|
>
|
|
提交UI素材提取
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (activeCanvasDialog) {
|
|
dialogs.updateCanvasGenerationDialogById(
|
|
activeCanvasDialog.id,
|
|
(dialog) => ({
|
|
...dialog,
|
|
generatedLayerId: 'layer-source',
|
|
}),
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
绑定生成图层
|
|
</button>
|
|
<button type="button" onClick={workflow.hideGeneratedLayerPanelAfterBlur}>
|
|
隐藏生成面板
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[createLayer({ assetKind: 'icon-spritesheet' })]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
title: '源视频',
|
|
src: '/generated/video.mp4',
|
|
mediaType: 'video',
|
|
assetKind: 'video',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
id: 'center-layer',
|
|
x: 0,
|
|
y: -80,
|
|
width: 460,
|
|
height: 460,
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
id: 'center-layer',
|
|
x: 0,
|
|
y: -80,
|
|
width: 460,
|
|
height: 460,
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
sourceType: 'generated',
|
|
prompt: '【系统内置】旧版森林场景完整提示词',
|
|
model: IMAGE_MODEL_GPT_IMAGE_2,
|
|
generationInputs: {
|
|
fields: [{ title: '生成提示词', value: '旧版森林场景' }],
|
|
references: [],
|
|
},
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
title: '角色形象',
|
|
sourceType: 'generated',
|
|
assetKind: 'character',
|
|
prompt: '【系统内置】红发骑士完整提示词',
|
|
model: IMAGE_MODEL_GPT_IMAGE_2,
|
|
generationInputs: {
|
|
fields: [{ title: '角色设定', value: '红发骑士' }],
|
|
references: [],
|
|
},
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
id: 'layer-edit-result',
|
|
resourceId: 'resource-edit-result',
|
|
sourceType: 'generated',
|
|
generationInputs: {
|
|
version: 2,
|
|
action: 'image.edit',
|
|
fields: [
|
|
{ id: 'prompt', title: '修改要求', value: '改成雨天' },
|
|
{ id: 'model', title: '模型', value: 'gpt-image-2' },
|
|
{ id: 'aspectRatio', title: '比例', value: '4:3' },
|
|
{ id: 'imageSize', title: '尺寸', value: '1K' },
|
|
],
|
|
references: [
|
|
{
|
|
id: 'source',
|
|
title: '原图',
|
|
refType: 'project-resource',
|
|
refId: 'resource-edit-source',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
{
|
|
...createLayer(),
|
|
originalWidth: 1537,
|
|
originalHeight: 1024,
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
{
|
|
...createLayer(),
|
|
originalWidth: 1537,
|
|
originalHeight: 1024,
|
|
},
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness hasPendingImageReferenceUploads />);
|
|
|
|
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(<GenerationWorkflowHarness hasPendingImageReferenceUploads />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开角色生成' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '选择角色规范' }));
|
|
expect(screen.getByTestId('character-spec-reference').textContent).toBe(
|
|
'上一张角色规范',
|
|
);
|
|
|
|
firstRender.unmount();
|
|
render(<GenerationWorkflowHarness />);
|
|
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(
|
|
<GenerationWorkflowHarness currentUserId="user-a" />,
|
|
);
|
|
|
|
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(<GenerationWorkflowHarness currentUserId="user-b" />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开图标生成' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '选择图标规范' }));
|
|
expect(screen.getByTestId('icon-spec-reference').textContent).toBe(
|
|
'上一张图标规范',
|
|
);
|
|
|
|
firstRender.unmount();
|
|
render(<GenerationWorkflowHarness />);
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({ x: 100, y: 50, width: 360, height: 1600 }),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness projectId="project-1" />);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
initialLayers={[
|
|
createLayer({
|
|
assetKind: 'icon-spritesheet',
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={vi.fn()}
|
|
initialLayers={[
|
|
createLayer({
|
|
assetKind: 'icon-spritesheet',
|
|
originalWidth: 1024,
|
|
originalHeight: 1024,
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
assetKind: 'character',
|
|
objectKey: 'generated-character-drafts/editor/private.png',
|
|
src: '/api/assets/read-url?objectKey=generated-character-drafts/editor/private.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[sourceLayer]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={flushProjectPersistence}
|
|
onFlushProjectPersistenceSnapshot={(dialogs) => {
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn(async () => {})}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn(async () => {})}
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={flushProjectPersistence}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn(async () => {})}
|
|
/>,
|
|
);
|
|
|
|
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<void>();
|
|
const flushProjectPersistence = vi.fn(() => flushGate.promise);
|
|
let requestAtPost: EditorPixelArtSnapInput | undefined;
|
|
let ledgerAtPost: ReturnType<typeof readPerfectPixelOperations> =
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
currentUserId="user-a"
|
|
applyProjectSnapshot={vi.fn()}
|
|
flushProjectPersistence={flushProjectPersistence}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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<void>();
|
|
const flushProjectPersistence = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(undefined)
|
|
.mockImplementationOnce(() => retryFlushGate.promise);
|
|
let requestAtRetryPost: EditorPixelArtSnapInput | undefined;
|
|
let ledgerAtRetryPost: ReturnType<typeof readPerfectPixelOperations> =
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
currentUserId="user-a"
|
|
applyProjectSnapshot={vi.fn()}
|
|
flushProjectPersistence={flushProjectPersistence}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
resourceId: 'local-resource-source',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={vi.fn()}
|
|
flushProjectPersistence={flushProjectPersistence}
|
|
/>,
|
|
);
|
|
|
|
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<ReturnType<typeof createMismatchedPerfectPixelResult>>();
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
currentUserId="user-a"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
refreshAssetLibrary={refreshAssetLibrary}
|
|
upsertGeneratedAsset={upsertGeneratedAsset}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
currentUserId="user-b"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
refreshAssetLibrary={refreshAssetLibrary}
|
|
upsertGeneratedAsset={upsertGeneratedAsset}
|
|
/>,
|
|
);
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
createLayer({
|
|
id: 'layer-source-alias',
|
|
resourceId: 'resource-source',
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={vi.fn()}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
resourceId: 'local-resource-source',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={flushProjectPersistence}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn(async () => {})}
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn(async () => {})}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn(async () => {})}
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn(async () => {})}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
currentUserId="user-a"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
currentUserId="user-a"
|
|
applyProjectSnapshotWithoutHistory={vi.fn(() => 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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
currentUserId="user-a"
|
|
applyProjectSnapshotWithoutHistory={vi.fn(() => 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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
currentUserId="user-a"
|
|
applyProjectSnapshotWithoutHistory={vi.fn(() => 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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
refreshAssetLibrary={refreshAssetLibrary}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
refreshAssetLibrary={refreshAssetLibrary}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={vi.fn()}
|
|
flushProjectPersistence={flushProjectPersistence}
|
|
initialLayers={[createLayer({ resourceId: 'local-resource-source' })]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={vi.fn()}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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<typeof createPerfectPixelResult>) => 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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
flushProjectPersistence={vi.fn().mockResolvedValue(undefined)}
|
|
upsertGeneratedAsset={upsertGeneratedAsset}
|
|
initialLayers={[
|
|
createLayer({
|
|
objectKey: 'generated-images/editor/source.png',
|
|
src: '/generated-images/editor/source.png',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialCanvasGenerationDialogs={[hydratedDialog]}
|
|
applyProjectSnapshot={applyProjectSnapshot}
|
|
applyProjectSnapshotWithoutHistory={applyProjectSnapshotWithoutHistory}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialCanvasGenerationDialogs={[hydratedDialog]}
|
|
applyProjectSnapshotWithoutHistory={applyProjectSnapshotWithoutHistory}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialCanvasGenerationDialogs={[hydratedDialog]}
|
|
applyProjectSnapshotWithoutHistory={applyProjectSnapshotWithoutHistory}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialCanvasGenerationDialogs={[hydratedDialog]}
|
|
applyProjectSnapshotWithoutHistory={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
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<EditorProjectSnapshot>();
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialCanvasGenerationDialogs={hydratedDialogs}
|
|
applyProjectSnapshotWithoutHistory={
|
|
applyProjectSnapshotWithoutHistory
|
|
}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
if (exitMode === 'project-switch') {
|
|
view.rerender(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-2"
|
|
initialCanvasGenerationDialogs={hydratedDialogs}
|
|
applyProjectSnapshotWithoutHistory={
|
|
applyProjectSnapshotWithoutHistory
|
|
}
|
|
/>,
|
|
);
|
|
} 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(
|
|
<GenerationWorkflowHarness
|
|
projectId="project-1"
|
|
initialCanvasGenerationDialogs={[hydratedDialog]}
|
|
applyProjectSnapshotWithoutHistory={applyProjectSnapshotWithoutHistory}
|
|
/>,
|
|
);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' }));
|
|
rerender(<GenerationWorkflowHarness hasPendingImageReferenceUploads />);
|
|
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(<GenerationWorkflowHarness />);
|
|
fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '框选UI素材' }));
|
|
rerender(<GenerationWorkflowHarness hasPendingImageReferenceUploads />);
|
|
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(<GenerationWorkflowHarness />);
|
|
fireEvent.click(screen.getByRole('button', { name: '打开快速编辑' }));
|
|
quickEditRender.rerender(
|
|
<GenerationWorkflowHarness hasPendingImageReferenceUploads />,
|
|
);
|
|
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(<GenerationWorkflowHarness />);
|
|
fireEvent.click(screen.getByRole('button', { name: '打开UI素材框选' }));
|
|
uiRender.rerender(
|
|
<GenerationWorkflowHarness hasPendingImageReferenceUploads />,
|
|
);
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
assetKind: 'character',
|
|
sourceType: 'generated',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
id: 'layer-action',
|
|
resourceId: 'resource-action',
|
|
title: '角色动作',
|
|
mediaType: 'image-sequence',
|
|
assetKind: 'character-animation',
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'resource-character',
|
|
}),
|
|
createLayer({
|
|
id: 'layer-character',
|
|
resourceId: 'resource-character',
|
|
title: '角色形象',
|
|
assetKind: 'character',
|
|
sourceType: 'generated',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
title: '角色动作',
|
|
mediaType: 'image-sequence',
|
|
assetKind: 'character-animation',
|
|
sourceType: 'generated',
|
|
sourceResourceId: 'missing-character-resource',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
title: 'UI 素材图集',
|
|
assetKind: 'icon-spritesheet',
|
|
sourceType: 'generated',
|
|
generationInputs: {
|
|
version: 2,
|
|
action: 'ui-design.extract-assets',
|
|
fields: [
|
|
{ id: 'prompt', title: '提取提示词', value: '提取按钮' },
|
|
],
|
|
references: [
|
|
{
|
|
id: 'source',
|
|
title: 'UI设计图',
|
|
refType: 'project-resource',
|
|
refId: 'missing-ui-design-resource',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
sourceType: 'generated',
|
|
generationInputs: {
|
|
version: 2,
|
|
action: 'image.generate',
|
|
fields: [
|
|
{ id: 'prompt', title: '生成提示词', value: '森林场景' },
|
|
],
|
|
references: [
|
|
{
|
|
id: 'reference',
|
|
title: '面板上传参考图',
|
|
refType: 'project-resource',
|
|
refId: 'resource-panel-upload',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
sourceType: 'generated',
|
|
assetKind: 'character',
|
|
generationInputs: {
|
|
version: 2,
|
|
action: 'character.generate',
|
|
fields: [
|
|
{ id: 'prompt', title: '角色设定', value: '红发骑士' },
|
|
{ id: 'model', title: '模型', value: DEFAULT_IMAGE_MODEL },
|
|
{ id: 'style', title: '风格', value: 'none' },
|
|
{ id: 'aspectRatio', title: '比例', value: '1:1' },
|
|
{ id: 'imageSize', title: '尺寸', value: '1K' },
|
|
],
|
|
references: [
|
|
{
|
|
id: 'specReference',
|
|
title: '角色规范',
|
|
refType: 'project-resource',
|
|
refId: 'resource-missing-character-spec',
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
sourceType: 'generated',
|
|
generationInputs: {
|
|
version: 2,
|
|
action: 'image.generate',
|
|
fields: [
|
|
{ id: 'prompt', title: '生成提示词', value: '森林场景' },
|
|
{ id: 'model', title: '模型', value: 'retired-image-model' },
|
|
{ id: 'aspectRatio', title: '比例', value: '7:5' },
|
|
{ id: 'imageSize', title: '尺寸', value: '8K' },
|
|
],
|
|
references: [],
|
|
},
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
sourceType: 'generated',
|
|
generationInputs: {
|
|
version: 2,
|
|
action: 'image.generate',
|
|
fields: [
|
|
{
|
|
id: 42,
|
|
title: '生成提示词',
|
|
value: '损坏数据',
|
|
} as never,
|
|
],
|
|
references: [],
|
|
},
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
sourceType: 'generated',
|
|
generationInputs: null,
|
|
generationInputsHydrationState: 'invalid-versioned',
|
|
prompt: '这不是可恢复的旧版配方',
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(
|
|
<GenerationWorkflowHarness
|
|
initialLayers={[
|
|
createLayer({
|
|
sourceType: 'generated',
|
|
generationInputs: {
|
|
fields: [{ title: '生成提示词', value: '旧版森林场景' }],
|
|
references: [],
|
|
},
|
|
}),
|
|
]}
|
|
/>,
|
|
);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
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(<GenerationWorkflowHarness />);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
|
|
fireEvent.click(screen.getByRole('button', { name: '隐藏生成面板' }));
|
|
|
|
expect(screen.getByTestId('dialog').textContent).toBe(
|
|
'generate:idle:closed:-:placeholder',
|
|
);
|
|
});
|
|
});
|