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>
3021 lines
104 KiB
TypeScript
3021 lines
104 KiB
TypeScript
/* @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',
|
||
);
|
||
});
|
||
});
|