Files
Genarrative/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx
T
lhk229 2c07b91c25 修复完美像素对账请求的绝对截止
为 requestJson 增加覆盖鉴权、重试和响应体读取的可选绝对 deadline。

让完美像素项目对账按 75 秒窗口和单次 10 秒上界读取权威快照。

补充鉴权等待、响应体读取、客户端透传和对账边界的定向测试与文档。
2026-08-04 10:01:44 +00:00

4236 lines
144 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 } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
GenerateDialogState,
ImageContextMenuState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import {
inspectPerfectPixelProjectSnapshot,
useImageCanvasGenerationWorkflow,
} from './useImageCanvasGenerationWorkflow';
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorSoundEffectMock = vi.hoisted(() => vi.fn());
const generateEditorBackgroundMusicMock = vi.hoisted(() => vi.fn());
const editEditorImageMock = vi.hoisted(() => vi.fn());
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
const renderCropExpandImageMock = vi.hoisted(() => vi.fn());
const removeImageBackgroundMock = vi.hoisted(() => vi.fn());
const snapImageToPerfectPixelsMock = vi.hoisted(() => vi.fn());
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const resolveEditorImageReferenceDataUrlMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorImageReference', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorImageReference')
>('../../services/image-editor/editorImageReference');
return {
...actual,
resolveEditorImageReferenceDataUrl: resolveEditorImageReferenceDataUrlMock,
resolveEditorImageReferenceDataUrlForGeneration: vi.fn(
async (src: string) => src,
),
};
});
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
typeof import('../../services/image-editor/editorProjectClient')
>('../../services/image-editor/editorProjectClient');
return {
...actual,
editEditorImage: editEditorImageMock,
createEditorProjectResource: createEditorProjectResourceMock,
generateEditorBackgroundMusic: generateEditorBackgroundMusicMock,
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
generateEditorImage: generateEditorImageMock,
generateEditorSoundEffect: generateEditorSoundEffectMock,
loadEditorProject: loadEditorProjectMock,
splitEditorIconSpritesheet: splitEditorIconSpritesheetMock,
};
});
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
vi.mock('./ImageCanvasRasterEditModel', async () => {
const actual = await vi.importActual<
typeof import('./ImageCanvasRasterEditModel')
>('./ImageCanvasRasterEditModel');
return {
...actual,
removeImageBackground: removeImageBackgroundMock,
renderCropExpandImage: renderCropExpandImageMock,
snapImageToPerfectPixels: snapImageToPerfectPixelsMock,
};
});
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-source',
resourceId: 'resource-source',
title: '源图',
src: 'data:image/png;base64,source',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'uploaded',
...overrides,
};
}
function createGenerated(overrides = {}) {
return {
imageSrc: 'data:image/png;base64,generated',
width: 1024,
height: 1024,
sourceType: 'generated' as const,
prompt: '生成提示词',
actualPrompt: '生成提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-generated',
...overrides,
};
}
function createPerfectPixelResource(operationId: string) {
return {
resourceId: `resource-${operationId}`,
projectId: 'project-1',
imageSrc: `/generated-images/editor/${operationId}.png`,
objectKey: `generated-images/editor/${operationId}.png`,
assetObjectId: `asset-object-${operationId}`,
width: 1024,
height: 768,
sourceType: 'generated' as const,
taskId: `pixel-art-snap-${operationId}`,
};
}
function createPerfectPixelAsset(operationId: string) {
return {
assetId: `asset-${operationId}`,
folderId: 'default',
label: '源图 · 完美像素',
imageSrc: `/generated-images/editor/${operationId}.png`,
objectKey: `generated-images/editor/${operationId}.png`,
assetObjectId: `asset-object-${operationId}`,
width: 1024,
height: 768,
sourceType: 'generated' as const,
taskId: `pixel-art-snap-${operationId}`,
};
}
function createPerfectPixelProject(
operationId: string,
state: 'pending' | 'applied' | 'dialog-missing',
): EditorProjectSnapshot {
const resource = createPerfectPixelResource(operationId);
const generatedLayerId = `layer-${operationId}`;
const dialogLayer = {
itemType: 'generation-dialog',
layerId: `generation-dialog:${operationId}`,
resourceId: `generation-dialog:${operationId}`,
dialog: {
id: operationId,
mode: 'quick-edit',
prompt: '完美像素',
status: state === 'pending' ? 'generating' : 'idle',
composerOpen: false,
...(state === 'applied' ? { generatedLayerId } : {}),
},
};
return {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers:
state === 'dialog-missing'
? []
: [
...(state === 'applied'
? [
{
itemType: 'image',
layerId: generatedLayerId,
resourceId: resource.resourceId,
},
]
: []),
dialogLayer,
],
resources: state === 'pending' ? [] : [resource],
updatedAt: '2026-08-03T00:00:00.000Z',
};
}
function createEmptyPerfectPixelProject(): EditorProjectSnapshot {
return {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-03T00:00:00.000Z',
};
}
function createConflictingPerfectPixelProject(
operationId: string,
): EditorProjectSnapshot {
const project = createPerfectPixelProject(operationId, 'applied');
return {
...project,
resources: [
...project.resources,
{
...createPerfectPixelResource(operationId),
resourceId: `resource-duplicate-${operationId}`,
},
],
};
}
function createPerfectPixelResult(
request: EditorPixelArtSnapInput,
project: EditorProjectSnapshot | null,
) {
const operationId = request.canvasCompletion.dialogId;
const resource = createPerfectPixelResource(operationId);
return {
imageSrc: resource.imageSrc,
objectKey: resource.objectKey,
assetObjectId: resource.assetObjectId,
width: resource.width,
height: resource.height,
sourceType: 'generated' as const,
taskId: resource.taskId,
elapsedMs: 250,
provider: 'Genarrative' as const,
resource,
asset: createPerfectPixelAsset(operationId),
project,
};
}
function createMismatchedPerfectPixelResult(request: EditorPixelArtSnapInput) {
const result = createPerfectPixelResult(request, null);
return {
...result,
taskId: 'unexpected-task',
resource: {
...result.resource,
taskId: 'unexpected-task',
},
};
}
function createHydratedPerfectPixelDialog({
operationId,
projectId = 'project-1',
status = 'generating',
reconcileUntil = Date.now() + PERFECT_PIXEL_RECONCILIATION_WINDOW_MS,
}: {
operationId: string;
projectId?: string;
status?: CanvasGenerationDialogState['status'];
reconcileUntil?: number;
}): CanvasGenerationDialogState {
const request: EditorPixelArtSnapInput = {
sourceImageSrc: 'generated-images/editor/source.png',
projectId,
sourceResourceId: 'resource-source',
assetLabel: '源图 · 完美像素',
canvasCompletion: {
dialogId: operationId,
title: '源图 · 完美像素',
placeholder: {
x: 60,
y: -132,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
},
},
};
return {
id: operationId,
mode: 'quick-edit',
prompt: '完美像素',
assetLabel: '源图 · 完美像素',
status,
composerOpen: false,
sourceLayerId: 'layer-source',
placeholder: { ...request.canvasCompletion.placeholder },
perfectPixelOperation: {
version: 1,
kind: 'perfect-pixel',
operationId,
taskId: `pixel-art-snap-${operationId}`,
request,
submittedAt: Date.now() - 1_000,
reconcileUntil,
},
};
}
function createDeferred<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,
applyProjectSnapshot,
applyProjectSnapshotWithoutHistory,
flushProjectPersistence,
onFlushProjectPersistenceSnapshot,
refreshAssetLibrary,
upsertGeneratedAsset,
}: {
initialLayers?: CanvasLayer[];
initialCanvasGenerationDialogs?: CanvasGenerationDialogState[];
initialViewport?: { x: number; y: number; scale: number };
projectId?: string;
currentUserId?: string;
applyProjectSnapshot?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['applyProjectSnapshot'];
applyProjectSnapshotWithoutHistory?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['applyProjectSnapshotWithoutHistory'];
refreshAssetLibrary?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['refreshAssetLibrary'];
flushProjectPersistence?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['flushProjectPersistence'];
onFlushProjectPersistenceSnapshot?: (
dialogs: CanvasGenerationDialogState[],
) => void;
upsertGeneratedAsset?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['upsertGeneratedAsset'];
}) {
const [layers, setLayers] = useState<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,
openCanvasGenerationDialog: dialogs.openCanvasGenerationDialog,
updateCanvasGenerationDialogById: dialogs.updateCanvasGenerationDialogById,
hasCanvasGenerationDialogById: dialogs.hasCanvasGenerationDialogById,
removeCanvasGenerationDialogsByLayerId:
dialogs.removeCanvasGenerationDialogsByLayerId,
getGeneratingDialogPlaceholder: dialogs.getGeneratingDialogPlaceholder,
archiveActiveCanvasGenerationDialog:
dialogs.archiveActiveCanvasGenerationDialog,
appendCanvasLayersWithResources: (nextLayers) =>
setLayers((currentLayers) => [...currentLayers, ...nextLayers]),
selectSingleLayer: setSelectedLayerId,
fitLayers: fitLayersMockRef.current,
captureCanvasHistory: () => {},
setActiveTool,
setActiveSidebarPanel,
setMetadataLayer,
setImageContextMenu,
projectId,
currentUserId,
applyProjectSnapshot,
applyProjectSnapshotWithoutHistory,
flushProjectPersistence: flushProjectPersistence
? (options) => {
onFlushProjectPersistenceSnapshot?.(
dialogs.getCanvasGenerationDialogsSnapshot(),
);
return flushProjectPersistence(options);
}
: undefined,
refreshAssetLibrary,
upsertGeneratedAsset,
});
const activeDialog = dialogs.generateDialog;
activeDialogRef.current = activeDialog;
const activeCanvasDialog =
activeDialog && 'id' in activeDialog
? (activeDialog as CanvasGenerationDialogState)
: null;
return (
<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="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="quick-edit">
{workflow.quickEditPanel
? `${workflow.quickEditPanel.sourceLayerId}:${workflow.quickEditPanel.status}:${workflow.quickEditPanel.prompt || '-'}`
: '-'}
</span>
<span data-testid="quick-edit-image-options">
{workflow.quickEditPanel
? `${workflow.quickEditPanel.mode ?? '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('ui')}>
打开UI规范
</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: 'duration', 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,
)
}
>
选择11
</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={() => {
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,
)
}
>
选择232K
</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.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',
},
}
: 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.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.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={() => {
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('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('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('layer-sound');
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('dialog').textContent).toBe(
'audio-sound-effect:idle:open:-:placeholder',
);
expect(screen.getByTestId('dialog-prompt').textContent).toBe('金币跳出音');
expect(screen.getByTestId('placeholder').textContent).toBe(
'572:140:420:120',
);
});
it('opens generated layer remodel with user input snapshot instead of internal prompt', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开用户快照改造' }));
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('dialog').textContent).toBe(
'quick-edit:idle:open:-:placeholder',
);
expect(screen.getByTestId('dialog-prompt').textContent).toBe(
'用户的视频描述',
);
});
it('keeps the active canvas generator when opening quick edit for another layer', () => {
render(<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('opens image redraw as a new canvas generator next to the source layer', () => {
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开生成' }));
fireEvent.click(screen.getByRole('button', { name: '打开图片改造' }));
expect(screen.getByTestId('dialog').textContent).toBe(
'quick-edit:idle:open:-:placeholder',
);
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('placeholder').textContent).toContain('1024:768');
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
'generation-dialog-1:generate:closed|generation-dialog-2:quick-edit:open',
);
});
it('prefers the source generation parameter snapshot when opening quick edit', () => {
render(
<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: '金币跳出音',
model: 'audio1.0',
provider: 'VectorEngine',
taskId: 'task-sound',
audioKind: 'sound-effect',
durationSeconds: 4,
});
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开音效改造' }));
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
await waitFor(() => {
expect(generateEditorSoundEffectMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '金币跳出音',
model: 'audio1.0',
duration: 8,
generationInputs: {
fields: [
{ title: 'prompt', value: '金币跳出音' },
{ title: 'model', value: 'audio1.0' },
{ title: 'duration', value: '8秒' },
],
references: [],
},
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-audio-1:游戏音效 1:-:sound-effect',
);
});
expect(screen.getByTestId('dialog').textContent).toBe(
'audio-sound-effect:idle:open:layer-audio-1:placeholder',
);
expect(screen.getByTestId('placeholder').textContent).toBe(
'572:140:420:120',
);
});
it('submits a normal generation, appends the generated layer, and keeps the composer anchored', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '一张生成图' }),
);
render(<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: {
fields: [{ title: '生成提示词', value: '一张生成图' }],
references: [],
},
});
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:生成图片 1',
);
});
expect(screen.getByTestId('sidebar').textContent).toBe('layers');
expect(screen.getByTestId('selected').textContent).toBe(
'layer-generated-1',
);
expect(screen.getByTestId('dialog').textContent).toBe(
'generate:idle:open:layer-generated-1:placeholder',
);
});
it('reuses the last image ratio and size when reopening character generation and submitting', async () => {
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: '一张生成图' }),
);
render(<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: {
fields: [{ title: '生成提示词', value: '一张生成图' }],
references: [
{
title: '参考图 1',
label: '画布参考图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
});
});
});
it('reuses the last picked character spec when opening the next character material dialog', () => {
render(<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 spec generation with reference image and reference prompt semantics', async () => {
uploadEditorMediaAssetFileMock.mockResolvedValueOnce({
src: 'https://signed.example.test/generation-reference.png',
objectKey:
'generated-character-drafts/editor/generation-references/reference.png',
assetObjectId: 'asset-object-generation-reference',
legacyPublicPath:
'/generated-character-drafts/editor/generation-references/reference.png',
});
generateEditorImageMock.mockResolvedValueOnce(
createGenerated({ prompt: 'UI规范图' }),
);
render(<GenerationWorkflowHarness />);
fireEvent.click(screen.getByRole('button', { name: '打开UI规范' }));
fireEvent.click(screen.getByRole('button', { name: '添加规范参考图' }));
fireEvent.click(screen.getByRole('button', { name: '提交生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
size: '2048x1152',
kind: 'spec',
model: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '2K',
referenceImageSrcs: [
'generated-character-drafts/editor/generation-references/reference.png',
],
prompt: expect.stringContaining('参考图生成规范'),
}),
);
});
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-generated-1:图标规范 1',
);
});
});
it('opens quick edit with inactive selection tools and keeps tall sources above the panel', () => {
render(
<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: '快速修图',
sourceImageSrc: 'resource-source',
assetLabel: '源图 快速编辑',
sourceResourceId: 'resource-source',
generationInputs: {
fields: [{ title: '快速编辑提示词', value: '快速修图' }],
references: [
{
title: '原图',
label: '源图',
refType: 'project-resource',
refId: 'resource-source',
},
],
},
}),
);
});
expect(generateEditorImageMock).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('layers').textContent).toContain(
'layer-source:源图 快速编辑:resource-source:-:data:image/png;base64,generated:task-generated',
);
});
expect(screen.getByTestId('quick-edit').textContent).toBe('-');
expect(screen.getByTestId('tool').textContent).toBe('select');
expect(screen.getByTestId('fit-count').textContent).toBe('0');
});
it('opens crop-expand in free ratio and completes as a new beside layer', async () => {
renderCropExpandImageMock.mockResolvedValueOnce({
imageSrc: 'data:image/png;base64,crop-expanded',
width: 380,
height: 280,
});
render(<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',
}),
);
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',
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: undefined,
assetFolderId: undefined,
assetLabel: '源图 去背景',
sourceResourceId: 'resource-source',
});
});
await waitFor(() => {
expect(screen.getByTestId('task-refresh-key').textContent).toBe('1');
});
expect(screen.getByTestId('layers').textContent).toContain(
'/api/assets/read-url?objectKey=generated-character-drafts/editor/private.png',
);
expect(screen.getByTestId('layers').textContent).not.toContain(
'background-removal-task',
);
});
it('opens a generating canvas placeholder when removing background in a project', async () => {
removeImageBackgroundMock.mockImplementationOnce(
() => new Promise(() => {}),
);
render(
<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: '源图 去背景',
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({
requireSuccess: true,
requiredDialogId: flushedOperation!.operationId,
});
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
expect(flushedOperation).toEqual(
expect.objectContaining({
version: 1,
kind: 'perfect-pixel',
operationId: expect.stringMatching(/^perfect-pixel-/u),
submittedAt: expect.any(Number),
reconcileUntil: expect.any(Number),
}),
);
expect(flushedOperation!.taskId).toBe(
`pixel-art-snap-${flushedOperation!.operationId}`,
);
expect(
flushedOperation!.reconcileUntil - flushedOperation!.submittedAt,
).toBe(75_000);
expect(flushedOperation!.request).toEqual(postedRequest);
expect(postedRequest).toEqual(
expect.objectContaining({
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'project-1',
sourceResourceId: 'resource-source',
assetKind: 'character',
generationInputs: sourceLayer.generationInputs,
assetLabel: '源图 · 完美像素',
canvasCompletion: expect.objectContaining({
dialogId: flushedOperation!.operationId,
title: '源图 · 完美像素',
placeholder: expect.objectContaining({
width: 320,
height: 240,
}),
}),
}),
);
expect(screen.getByTestId('live-session-dialogs').textContent).toBe('-');
await waitFor(
() => {
expect(loadEditorProjectMock).toHaveBeenCalledTimes(2);
expect(upsertGeneratedAsset).toHaveBeenCalledWith(
createPerfectPixelAsset(flushedOperation!.operationId),
);
expect(applyProjectSnapshot).toHaveBeenCalledWith(completedProject, {
type: 'perfect-pixel',
count: 1,
});
},
{ timeout: 3_000 },
);
expect(applyProjectSnapshot).not.toHaveBeenCalledWith(postResponseProject, {
type: 'perfect-pixel',
count: 1,
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
});
it('polls an unknown perfect-pixel outcome until a later GET confirms it without replaying POST', async () => {
const applyProjectSnapshot = vi.fn();
let operationId = '';
let reconciledProject: EditorProjectSnapshot | undefined;
const timeoutError = new Error('The operation timed out.');
timeoutError.name = 'TimeoutError';
snapImageToPerfectPixelsMock.mockImplementationOnce(
async (request: EditorPixelArtSnapInput) => {
operationId = request.canvasCompletion.dialogId;
throw timeoutError;
},
);
loadEditorProjectMock
.mockImplementationOnce(async () =>
createPerfectPixelProject(operationId, 'pending'),
)
.mockImplementationOnce(async () => {
reconciledProject = createPerfectPixelProject(operationId, 'applied');
return reconciledProject;
});
render(
<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);
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({
objectKey: 'generated-images/editor/source.png',
src: '/generated-images/editor/source.png',
}),
]}
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 firstDialogList =
screen.getByTestId('generation-dialogs').textContent;
const firstOperation = JSON.parse(
screen.getByTestId('perfect-pixel-operation').textContent!,
) as {
operationId: string;
taskId: string;
request: EditorPixelArtSnapInput;
};
const firstRequestBytes = JSON.stringify(firstRequest);
fireEvent.click(screen.getByRole('button', { name: '重试完美像素' }));
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(2);
});
const retryRequest = snapImageToPerfectPixelsMock.mock.calls[1]?.[0] as
| EditorPixelArtSnapInput
| undefined;
expect(JSON.stringify(retryRequest)).toBe(firstRequestBytes);
expect(retryRequest).toEqual(firstOperation.request);
expect(retryRequest?.canvasCompletion.dialogId).toBe(
firstOperation.operationId,
);
expect(firstOperation.taskId).toBe(
`pixel-art-snap-${firstOperation.operationId}`,
);
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
firstDialogList,
);
expect(
screen
.getByTestId('generation-dialogs')
.textContent!.split('|')
.filter(Boolean),
).toHaveLength(1);
expect(flushProjectPersistence).toHaveBeenCalledTimes(2);
expect(flushProjectPersistence).toHaveBeenNthCalledWith(1, {
requireSuccess: true,
requiredDialogId: firstOperation.operationId,
});
expect(flushProjectPersistence).toHaveBeenNthCalledWith(2, {
requireSuccess: true,
requiredDialogId: firstOperation.operationId,
});
});
it('reuses one inline upload and the exact persisted POST bytes when an unknown operation is retried', async () => {
const flushProjectPersistence = vi.fn().mockResolvedValue(undefined);
uploadEditorMediaAssetObjectFileMock.mockResolvedValueOnce({
objectKey: 'generated-images/editor/inline-perfect-pixel-source.png',
assetObjectId: 'asset-object-inline-perfect-pixel-source',
legacyPublicPath:
'/generated-images/editor/inline-perfect-pixel-source.png',
});
snapImageToPerfectPixelsMock
.mockImplementationOnce(async (request: EditorPixelArtSnapInput) =>
createMismatchedPerfectPixelResult(request),
)
.mockImplementationOnce(async (request: EditorPixelArtSnapInput) =>
createMismatchedPerfectPixelResult(request),
);
loadEditorProjectMock.mockImplementation(async () => {
const request = snapImageToPerfectPixelsMock.mock.calls.at(-1)?.[0] as
| EditorPixelArtSnapInput
| undefined;
return createConflictingPerfectPixelProject(
request!.canvasCompletion.dialogId,
);
});
render(
<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',
);
});
const firstDialogList =
screen.getByTestId('generation-dialogs').textContent;
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
fireEvent.click(screen.getByRole('button', { name: '第二图层完美像素' }));
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('generation-dialogs').textContent).toBe(
firstDialogList,
);
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'该素材已有一条待确认的完美像素操作,请在原占位上继续核对或重试。',
);
});
it('does not reconcile when the perfect-pixel request never left the client', async () => {
// 中文注释:占位创建、源图解析和 flush 都在 POST 之前。它们失败时请求根本没发出,
// 说「素材库可能已存在派生图」是反向谎报,也不该白打一次权威读取。
const applyProjectSnapshot = vi.fn();
const flushProjectPersistence = vi
.fn()
.mockRejectedValueOnce(new Error('画布保存失败'));
render(
<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('failed');
});
expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled();
expect(loadEditorProjectMock).not.toHaveBeenCalled();
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog-error').textContent).toBe('画布保存失败');
});
it('reconciles a responded failure that the server marked as post-persistence', async () => {
const applyProjectSnapshot = vi.fn();
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
let operationId = '';
let reconciledProject: EditorProjectSnapshot | undefined;
snapImageToPerfectPixelsMock.mockImplementationOnce(
async (request: EditorPixelArtSnapInput) => {
operationId = request.canvasCompletion.dialogId;
throw new ApiClientError({
message: '画布版本冲突。',
status: 409,
code: 'HTTP_409',
details: { resultPersistenceStarted: true },
});
},
);
loadEditorProjectMock.mockImplementationOnce(async () => {
reconciledProject = createPerfectPixelProject(operationId, 'applied');
return reconciledProject;
});
render(
<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('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('keeps the durable operation when deletion is attempted and applies a later GET result', async () => {
const applyProjectSnapshot = vi.fn();
let operationId = '';
let rejectPerfectPixel: ((error: unknown) => void) | undefined;
snapImageToPerfectPixelsMock.mockImplementationOnce(
(request: EditorPixelArtSnapInput) =>
new Promise((_resolve, reject) => {
operationId = request.canvasCompletion.dialogId;
rejectPerfectPixel = reject;
}),
);
loadEditorProjectMock.mockImplementationOnce(async () =>
createPerfectPixelProject(operationId, 'applied'),
);
render(
<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).not.toBe('-');
rejectPerfectPixel?.(new Error('网络中断'));
await waitFor(() => {
expect(applyProjectSnapshot).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'project-1',
}),
{
type: 'perfect-pixel',
count: 1,
},
);
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
});
it('does not let a never-settling asset refresh block a terminal reconciliation verdict', async () => {
const applyProjectSnapshot = vi.fn();
const refreshAssetLibrary = vi.fn(() => new Promise(() => {}));
let operationId = '';
snapImageToPerfectPixelsMock.mockImplementationOnce(
async (request: EditorPixelArtSnapInput) => {
operationId = request.canvasCompletion.dialogId;
throw new Error('网络中断');
},
);
loadEditorProjectMock.mockImplementationOnce(async () =>
createPerfectPixelProject(operationId, 'dialog-missing'),
);
render(
<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('refuses to delete a durable in-flight operation and keeps a later known failure visible', async () => {
let rejectPerfectPixel: ((error: unknown) => void) | undefined;
snapImageToPerfectPixelsMock.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectPerfectPixel = reject;
}),
);
render(
<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).not.toBe('-');
// 中文注释:用 ApiClientError(已知结果)避免走对账分支,把用例聚焦在「占位没了还要不要说话」。
rejectPerfectPixel?.(
new ApiClientError({
message: '完美像素画布占位不存在或尚未保存,请重试。',
status: 409,
code: 'HTTP_409',
}),
);
await waitFor(() => {
expect(screen.getByTestId('dialog-error').textContent).toBe(
'完美像素画布占位不存在或尚未保存,请重试。',
);
});
expect(screen.getByTestId('dialog').textContent).toContain('failed');
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
it('keeps the operation identity through a successful POST and applies only the project GET snapshot', async () => {
let resolvePerfectPixel:
| ((value: ReturnType<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();
});
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
expect(screen.getByTestId('generation-dialogs').textContent).not.toBe('-');
resolvePerfectPixel?.(
createPerfectPixelResult(
postedRequest!,
createPerfectPixelProject(
postedRequest!.canvasCompletion.dialogId,
'applied',
),
),
);
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
expect(upsertGeneratedAsset).toHaveBeenCalledWith(
createPerfectPixelAsset(postedRequest!.canvasCompletion.dialogId),
);
expect(applyProjectSnapshot).toHaveBeenCalledWith(getProject, {
type: 'perfect-pixel',
count: 1,
});
expect(screen.getByTestId('reference-pick-warning').textContent).toBe('-');
});
it('recovers a hydrated generating operation with GET only and applies it without perfect-pixel history', async () => {
const operationId = 'perfect-pixel-hydrated-applied';
const hydratedDialog = createHydratedPerfectPixelDialog({ operationId });
const appliedProject = createPerfectPixelProject(operationId, 'applied');
const applyProjectSnapshot = vi.fn();
const applyProjectSnapshotWithoutHistory = vi.fn().mockReturnValue(true);
loadEditorProjectMock.mockResolvedValueOnce(appliedProject);
render(
<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');
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('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('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',
);
});
});