Files
Genarrative/src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx
T
lhk229 c276810bff 完美像素审查第一批:补齐持久化标记、失败反馈与测试竞态
A1 持久化标记漏洞。snap_editor_image_to_pixel_art 对
persist_editor_generated_image_owned 用裸 ?,而该 helper 内部是
PUT → HEAD → confirm_asset_object。后两步失败时 OSS 对象已存在,错误却不带
resultPersistenceStarted,客户端 outcomeMayBePersisted 为假、对账不执行,
重试会用新 task_id 造出无从发现的孤儿对象。标记加在 helper 内部而非调用点,
边界取第一次 PUT:prepare_put_object 与 OSS 未配置都在 PUT 之前,标了是反向
谎报;PUT 自身要标,响应丢失时字节可能已落盘。

A3 失败反馈第三态。catch 尾部缺「有 dialogId 但占位已被删」分支,而删除生成中
占位是产品支持的流程。用户删完后请求才失败时 errorMessage 被整段丢弃,界面零
反馈,连对账得出的「请确认素材库」也一并丢失。

E1 测试并行竞态。对进程级队列深度做绝对断言,与相邻持有 guard 的用例并行时
随机变红,改为相对断言;同时更正该用例注释——它覆盖的是预算预检先于入队,
不是 guard 归还。

两条修复均先回退生产代码确认测试变红再恢复。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 05:33:09 +00:00

3021 lines
104 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { useRef, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasTool,
GenerateDialogState,
ImageContextMenuState,
SidebarPanel,
} from './ImageCanvasEditorTypes';
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
import { useCanvasGenerationDialogs } from './useCanvasGenerationDialogs';
import { 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 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', () => ({
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 GenerationWorkflowHarness({
initialLayers = [createLayer()],
initialViewport = { x: 10, y: 20, scale: 2 },
projectId,
currentUserId,
applyProjectSnapshot,
flushProjectPersistence,
refreshAssetLibrary,
upsertGeneratedAsset,
}: {
initialLayers?: CanvasLayer[];
initialViewport?: { x: number; y: number; scale: number };
projectId?: string;
currentUserId?: string;
applyProjectSnapshot?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['applyProjectSnapshot'];
refreshAssetLibrary?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['refreshAssetLibrary'];
flushProjectPersistence?: Parameters<
typeof useImageCanvasGenerationWorkflow
>[0]['flushProjectPersistence'];
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 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,
flushProjectPersistence,
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="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,
)
}
>
选择1比1
</button>
<button type="button" onClick={() => void workflow.submitCropExpand()}>
完成裁扩
</button>
<button
type="button"
onClick={() => void workflow.removeSelectedLayerBackground(layers[0]!)}
>
去除背景
</button>
<button
type="button"
onClick={() =>
void workflow.snapSelectedLayerToPerfectPixels(layers[0]!)
}
>
完美像素
</button>
<button
type="button"
onClick={() => {
if (activeCanvasDialog) {
dialogs.removeCanvasGenerationDialogById(activeCanvasDialog.id);
}
}}
>
删除处理占位
</button>
<button
type="button"
onClick={() => void workflow.splitSelectedIconSpritesheet(layers[0]!)}
>
拆分图集
</button>
<output aria-label="拆图状态">
{workflow.splittingIconSpritesheetLayerIds.has(layers[0]!.id)
? '拆图中'
: '空闲'}
</output>
<button
type="button"
onClick={() =>
workflow.openCharacterAnimationPanel(
createLayer({ id: 'layer-character', assetKind: 'character' }),
)
}
>
打开角色动画
</button>
<button
type="button"
onClick={() => workflow.openCharacterAnimationPanel(layers[0]!)}
>
对当前图层生成动作
</button>
<button
type="button"
onClick={() =>
workflow.openCharacterAnimationPanel(
createLayer({ id: 'layer-plain' }),
)
}
>
打开普通动画
</button>
<button
type="button"
onClick={() =>
workflow.setCharacterAnimationPanel((currentPanel) =>
currentPanel
? { ...currentPanel, assetLabel: '勇者挥手' }
: currentPanel,
)
}
>
命名角色动画
</button>
<button
type="button"
onClick={() =>
dialogs.setGenerateDialog((currentDialog) =>
currentDialog
? { ...currentDialog, prompt: '一张生成图' }
: currentDialog,
)
}
>
填写生成提示词
</button>
<button
type="button"
onClick={() =>
dialogs.setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
prompt: '快速修图',
}
: currentDialog,
)
}
>
填写快速编辑提示词
</button>
<button
type="button"
onClick={() =>
dialogs.setGenerateDialog((currentDialog) =>
currentDialog
? {
...currentDialog,
aspectRatio: '2:3',
imageSize: '2K',
}
: currentDialog,
)
}
>
选择2比3和2K
</button>
<button
type="button"
onClick={() => {
const latestDialog = activeDialogRef.current;
if (latestDialog) {
void workflow.submitImageGeneration(latestDialog);
}
}}
>
提交生成
</button>
<button
type="button"
onClick={() =>
workflow.pickGenerationReferenceFromLayer(
createLayer({ id: 'layer-reference', title: '画布参考图' }),
)
}
>
选择画布参考图
</button>
<button
type="button"
onClick={() =>
workflow.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();
// 中文注释:未知结果的对账 GET 默认返回 null,等价于"权威快照读不到"。需要具体
// 对账结果的用例各自 mockResolvedValueOnce 覆盖。
loadEditorProjectMock.mockResolvedValue(null);
resolveEditorImageReferenceDataUrlMock.mockImplementation(
async (src: string) => src,
);
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('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 a closed perfect-pixel placeholder before submitting the stable source', async () => {
const events: string[] = [];
const applyProjectSnapshot = vi.fn();
const upsertGeneratedAsset = 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-30T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-30T00:00:00.000Z',
};
const asset = {
assetId: 'asset-perfect-pixel',
label: '源图 · 完美像素',
imageSrc: '/generated-images/editor/perfect-pixel.png',
width: 1024,
height: 768,
sourceType: 'generated' as const,
};
const flushProjectPersistence = vi.fn(async () => {
events.push('flush');
expect(screen.getByTestId('dialog').textContent).toBe(
'quick-edit:generating:closed:-:placeholder',
);
});
snapImageToPerfectPixelsMock.mockImplementationOnce(async () => {
events.push('post');
return {
imageSrc: '/generated-images/editor/perfect-pixel.png',
objectKey: 'generated-images/editor/perfect-pixel.png',
assetObjectId: 'asset-object-perfect-pixel',
width: 1024,
height: 768,
sourceType: 'generated',
taskId: 'perfect-pixel-1',
elapsedMs: 250,
provider: 'Genarrative',
resource: { resourceId: 'resource-perfect-pixel' },
asset,
project,
};
});
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}
upsertGeneratedAsset={upsertGeneratedAsset}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
fireEvent.click(screen.getByRole('button', { name: '完美像素' }));
await waitFor(() => {
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
});
expect(events).toEqual(['flush', 'post']);
expect(flushProjectPersistence).toHaveBeenCalledTimes(1);
expect(resolveEditorImageReferenceDataUrlMock).not.toHaveBeenCalled();
// 中文注释:完美像素是同步 HTTP、服务端无 durable job,占位的收口只能由本页会话完成。
// 标记必须真的落在提交出去的那份占位上——它会随 flush 一起持久化,是刷新后唯一能把
// 孤儿占位和队列型占位区分开的依据。漏置位就退回「刷新后永久转圈」。
expect(screen.getByTestId('live-session-dialogs').textContent).toBe(
'generation-dialog-1',
);
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'generated-images/editor/source.png',
projectId: 'project-1',
sourceResourceId: 'resource-source',
assetKind: 'character',
generationInputs: sourceLayer.generationInputs,
assetLabel: '源图 · 完美像素',
canvasCompletion: expect.objectContaining({
dialogId: 'generation-dialog-1',
title: '源图 · 完美像素',
placeholder: expect.objectContaining({
width: 320,
height: 240,
}),
}),
}),
);
expect(upsertGeneratedAsset).toHaveBeenCalledWith(asset);
expect(applyProjectSnapshot).toHaveBeenCalledWith(project, {
type: 'perfect-pixel',
count: 1,
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
});
it('reconciles an unknown perfect-pixel outcome that actually succeeded', async () => {
// 中文注释:120 秒超时或 transport abort 时服务端可能已经写完。此路由禁用自动
// 重放,直接标 failed 会谎报结果、诱使用户重试再造一份对象与素材。契约要求先
// GET 权威快照对账;占位已被 completion 消费掉即视为成功。
const applyProjectSnapshot = vi.fn();
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
const timeoutError = new Error('The operation timed out.');
timeoutError.name = 'TimeoutError';
snapImageToPerfectPixelsMock.mockRejectedValueOnce(timeoutError);
loadEditorProjectMock.mockResolvedValueOnce(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).toHaveBeenCalledWith('project-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('marks an unknown perfect-pixel outcome as unresolved when the placeholder survives', async () => {
// 中文注释:占位仍在权威快照里说明画布没收到结果。持久化是非事务的,OSS 对象与
// 账号素材仍可能已落库,因此只同步快照、不写历史,文案必须要求用户先核对。
const applyProjectSnapshot = vi.fn();
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
dialog: {
id: 'generation-dialog-1',
mode: 'quick-edit',
status: 'generating',
prompt: '完美像素',
},
},
],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
const abortError = new Error('The operation was aborted.');
abortError.name = 'AbortError';
snapImageToPerfectPixelsMock.mockRejectedValueOnce(abortError);
loadEditorProjectMock.mockResolvedValueOnce(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).toHaveBeenCalledWith('project-1');
});
// 中文注释:判定用途,不做同步。传进来的 applyProjectSnapshot 是
// applyGeneratedProjectSnapshot,不传 action 会写一条类型错误且受撤销保护的
// generate-image 历史;而权威快照此刻与本地一致,套用只会覆盖未保存编辑。
expect(applyProjectSnapshot).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
});
const unresolvedMessage =
screen.getByTestId('dialog-error').textContent ?? '';
expect(unresolvedMessage).toContain('画布未收到完美像素结果。');
expect(unresolvedMessage).toContain('请确认素材库是否已生成派生图');
expect(unresolvedMessage).not.toContain('素材库已刷新');
});
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 () => {
// 中文注释:服务端持久化非事务,completion 失败经 map_editor_project_error 变成 4xx,
// 状态码分不出阶段。服务端置 resultPersistenceStarted 后客户端才对账。
const applyProjectSnapshot = vi.fn();
const refreshAssetLibrary = vi.fn().mockResolvedValue(undefined);
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
itemType: 'generation-dialog',
dialog: {
id: 'generation-dialog-1',
mode: 'quick-edit',
status: 'generating',
prompt: '完美像素',
},
},
],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
snapImageToPerfectPixelsMock.mockRejectedValueOnce(
new ApiClientError({
message: '画布版本冲突。',
status: 409,
code: 'HTTP_409',
// 中文注释:服务端在第一次 OSS PUT 之后的失败路径上置位该字段。
details: { resultPersistenceStarted: true },
}),
);
loadEditorProjectMock.mockResolvedValueOnce(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(loadEditorProjectMock).toHaveBeenCalledWith('project-1');
});
// 中文注释:契约要求核对「项目 / 素材」两份快照,素材库是独立记录。
expect(refreshAssetLibrary).toHaveBeenCalledTimes(1);
expect(applyProjectSnapshot).not.toHaveBeenCalled();
await waitFor(() => {
expect(screen.getByTestId('dialog').textContent).toContain('failed');
});
const message = screen.getByTestId('dialog-error').textContent ?? '';
// 中文注释:服务端原文保留便于定位,对账结论与核对提示附加在后。
expect(message).toContain('画布版本冲突。');
expect(message).toContain('画布未收到完美像素结果。');
// 中文注释:只下指令、不断言素材库已刷新——refreshAssetLibrary 可能是 no-op 或
// 静默失败,断言会让用户对着旧列表判定「没有派生图,可以重试」。
expect(message).toContain('请确认素材库是否已生成派生图');
expect(message).not.toContain('素材库已刷新');
});
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('drops a reconciled perfect-pixel result when the user deleted the placeholder', async () => {
// 中文注释:权威快照里占位不在了有两种原因——服务端 completion 消费掉,或者用户在
// 请求期间主动删除。后者契约要求不应用完成快照、不写历史,删除意图胜出。
const applyProjectSnapshot = vi.fn();
const reconciledProject = {
projectId: 'project-1',
title: '未命名画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-08-01T00:00:00.000Z',
};
const timeoutError = new Error('The operation timed out.');
timeoutError.name = 'TimeoutError';
snapImageToPerfectPixelsMock.mockImplementationOnce(async () => {
// 中文注释:请求在途时用户删掉占位。
fireEvent.click(screen.getByRole('button', { name: '删除处理占位' }));
throw timeoutError;
});
loadEditorProjectMock.mockResolvedValueOnce(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).toHaveBeenCalledWith('project-1');
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
expect(screen.getByTestId('dialog').textContent).toBe('-');
});
it('uploads an inline perfect-pixel source before flushing and posting', async () => {
const order: string[] = [];
uploadEditorMediaAssetFileMock.mockImplementationOnce(async () => {
order.push('upload');
return {
src: 'https://signed.example.test/perfect-pixel-source.png',
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');
});
snapImageToPerfectPixelsMock.mockImplementationOnce(async () => {
order.push('post');
return {
imageSrc: '/generated-images/editor/perfect-pixel.png',
objectKey: 'generated-images/editor/perfect-pixel.png',
assetObjectId: 'asset-object-perfect-pixel',
width: 1024,
height: 768,
sourceType: 'generated',
taskId: 'perfect-pixel-1',
elapsedMs: 250,
provider: 'Genarrative',
resource: { resourceId: 'resource-perfect-pixel' },
asset: { assetId: 'asset-perfect-pixel' },
project: null,
};
});
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(snapImageToPerfectPixelsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'generated-images/editor/uploaded-source.png',
sourceResourceId: undefined,
}),
);
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('still surfaces the perfect-pixel failure after the placeholder was deleted mid-flight', async () => {
// 中文注释:删除生成中占位是产品支持的流程(会先弹确认)。删完之后请求才失败时,
// dialogId 有值但占位已不在,没有地方挂错误——必须退回全局提示,否则界面零反馈,
// 用户会在不知道「素材库可能已有派生图」的情况下重试,再造一份对象与素材。
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: '删除处理占位' }));
await waitFor(() => {
expect(screen.getByTestId('generation-dialogs').textContent).toBe('-');
});
// 中文注释:用 ApiClientError(已知结果)避免走对账分支,把用例聚焦在「占位没了还要不要说话」。
rejectPerfectPixel?.(
new ApiClientError({
message: '完美像素画布占位不存在或尚未保存,请重试。',
status: 409,
code: 'HTTP_409',
}),
);
await waitFor(() => {
expect(screen.getByTestId('reference-pick-warning').textContent).toBe(
'完美像素画布占位不存在或尚未保存,请重试。',
);
});
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
it('does not apply a completed project after the perfect-pixel placeholder was deleted', async () => {
let resolvePerfectPixel:
| ((value: {
imageSrc: string;
objectKey: string;
assetObjectId: string;
width: number;
height: number;
sourceType: 'generated';
taskId: string;
elapsedMs: number;
provider: 'Genarrative';
resource: { resourceId: string };
asset: { assetId: string };
project: {
projectId: string;
title: string;
canvas: {
canvasId: string;
projectId: string;
title: string;
viewport: { x: number; y: number; scale: number };
layers: never[];
updatedAt: string;
};
viewport: { x: number; y: number; scale: number };
layers: never[];
resources: never[];
updatedAt: string;
};
}) => void)
| undefined;
const applyProjectSnapshot = vi.fn();
const upsertGeneratedAsset = vi.fn();
snapImageToPerfectPixelsMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolvePerfectPixel = resolve;
}),
);
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('dialog').textContent).toBe('-');
resolvePerfectPixel?.({
imageSrc: '/generated-images/editor/perfect-pixel.png',
objectKey: 'generated-images/editor/perfect-pixel.png',
assetObjectId: 'asset-object-perfect-pixel',
width: 1024,
height: 768,
sourceType: 'generated',
taskId: 'perfect-pixel-1',
elapsedMs: 250,
provider: 'Genarrative',
resource: { resourceId: 'resource-perfect-pixel' },
asset: { assetId: 'asset-perfect-pixel' },
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-30T00:00:00.000Z',
},
viewport: { x: 0, y: 0, scale: 1 },
layers: [],
resources: [],
updatedAt: '2026-07-30T00:00:00.000Z',
},
});
await waitFor(() => {
expect(
screen.getByRole('status', { name: '完美像素状态' }).textContent,
).toBe('空闲');
});
expect(upsertGeneratedAsset).toHaveBeenCalledWith({
assetId: 'asset-perfect-pixel',
});
expect(applyProjectSnapshot).not.toHaveBeenCalled();
expect(screen.getByTestId('dialog').textContent).toBe('-');
});
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',
);
});
});