8f19964e3f
Co-authored-by: 段舒康 <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/124 Reviewed-by: 段舒康 <kdletters@qq.com> Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
3726 lines
128 KiB
TypeScript
3726 lines
128 KiB
TypeScript
/* @vitest-environment jsdom */
|
||
|
||
import {
|
||
act,
|
||
fireEvent,
|
||
render,
|
||
screen,
|
||
waitFor,
|
||
within,
|
||
} from '@testing-library/react';
|
||
import userEvent from '@testing-library/user-event';
|
||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||
import {
|
||
ApiClientError,
|
||
defaultEditorProjectLayers,
|
||
defaultEditorProjectResources,
|
||
dispatchPointerEvent,
|
||
ImageCanvasEditorView,
|
||
setupImageCanvasEditorViewTestLifecycle,
|
||
} from './ImageCanvasEditorView.test-utils';
|
||
|
||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
|
||
const extractEditorUiDesignAssetsMock = vi.hoisted(() => vi.fn());
|
||
const renderUiDesignAssetExtractionMarkedImageMock = vi.hoisted(() => vi.fn());
|
||
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
|
||
const editEditorImageMock = vi.hoisted(() => vi.fn());
|
||
const createEditorAssetMock = vi.hoisted(() => vi.fn());
|
||
const createEditorProjectResourceMock = vi.hoisted(() => vi.fn());
|
||
const createEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
|
||
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||
const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
|
||
const deleteEditorAssetMock = vi.hoisted(() => vi.fn());
|
||
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
|
||
const loadEditorProjectMock = vi.hoisted(() => vi.fn());
|
||
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
|
||
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
|
||
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
|
||
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
|
||
const uploadEditorMediaAssetObjectFileMock = vi.hoisted(() => vi.fn());
|
||
|
||
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,
|
||
extractEditorUiDesignAssets: extractEditorUiDesignAssetsMock,
|
||
createEditorAsset: createEditorAssetMock,
|
||
createEditorAssetFolder: createEditorAssetFolderMock,
|
||
createEditorProjectResource: createEditorProjectResourceMock,
|
||
deleteEditorAsset: deleteEditorAssetMock,
|
||
deleteEditorAssetFolder: deleteEditorAssetFolderMock,
|
||
generateEditorCharacterAnimation: generateEditorCharacterAnimationMock,
|
||
generateEditorIconSpritesheet: generateEditorIconSpritesheetMock,
|
||
generateEditorImage: generateEditorImageMock,
|
||
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
|
||
loadEditorProject: loadEditorProjectMock,
|
||
loadOrCreateRecentEditorProject: loadOrCreateRecentEditorProjectMock,
|
||
renameEditorProject: renameEditorProjectMock,
|
||
saveEditorProjectLayout: saveEditorProjectLayoutMock,
|
||
updateEditorAsset: updateEditorAssetMock,
|
||
updateEditorAssetFolder: updateEditorAssetFolderMock,
|
||
};
|
||
});
|
||
|
||
vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
|
||
renderUiDesignAssetExtractionMarkedImage:
|
||
renderUiDesignAssetExtractionMarkedImageMock,
|
||
}));
|
||
|
||
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
|
||
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
|
||
// 中文注释:inline 源图上传走的是 object-only 版本,不是带签名 URL 的那个。留成裸 vi.fn()
|
||
// 会返回 undefined,取 objectKey 抛的 TypeError 被 extractUiDesignAssets 的 catch 吞成
|
||
// window.alert,最终只表现为「提取接口一次都没调」,报错里看不到任何线索。
|
||
uploadEditorMediaAssetObjectFile: uploadEditorMediaAssetObjectFileMock,
|
||
}));
|
||
|
||
vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({
|
||
createProjectCoverSnapshotBlob: vi.fn(async () => null),
|
||
}));
|
||
|
||
describe('ImageCanvasEditorView generation integration', () => {
|
||
setupImageCanvasEditorViewTestLifecycle({
|
||
generateEditorImageMock,
|
||
generateEditorIconSpritesheetMock,
|
||
extractEditorUiDesignAssetsMock,
|
||
generateEditorCharacterAnimationMock,
|
||
editEditorImageMock,
|
||
createEditorAssetMock,
|
||
createEditorProjectResourceMock,
|
||
createEditorAssetFolderMock,
|
||
updateEditorAssetMock,
|
||
updateEditorAssetFolderMock,
|
||
deleteEditorAssetFolderMock,
|
||
deleteEditorAssetMock,
|
||
loadEditorAssetLibraryMock,
|
||
loadEditorProjectMock,
|
||
loadOrCreateRecentEditorProjectMock,
|
||
renameEditorProjectMock,
|
||
saveEditorProjectLayoutMock,
|
||
});
|
||
|
||
beforeEach(() => {
|
||
window.localStorage.clear();
|
||
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
|
||
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
|
||
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
|
||
);
|
||
uploadEditorMediaAssetFileMock.mockReset();
|
||
uploadEditorMediaAssetFileMock.mockResolvedValue({
|
||
objectKey:
|
||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||
assetObjectId: 'asset-object-marked-ui-design',
|
||
legacyPublicPath:
|
||
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||
src: 'https://oss.example.com/marked-ui-design.png',
|
||
});
|
||
uploadEditorMediaAssetObjectFileMock.mockReset();
|
||
uploadEditorMediaAssetObjectFileMock.mockResolvedValue({
|
||
objectKey:
|
||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||
assetObjectId: 'asset-object-marked-ui-design',
|
||
legacyPublicPath:
|
||
'/generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||
});
|
||
});
|
||
|
||
const openGenerationDimensionsPanel = (
|
||
panel: HTMLElement,
|
||
labelPrefix = '生成图片',
|
||
) => {
|
||
fireEvent.click(
|
||
within(panel).getByRole('button', {
|
||
name: new RegExp(`^${labelPrefix}尺寸 `, 'u'),
|
||
}),
|
||
);
|
||
return screen.getByRole('menu', { name: `${labelPrefix}尺寸选项` });
|
||
};
|
||
|
||
const openGenerationModelPanel = (
|
||
panel: HTMLElement,
|
||
labelPrefix = '生成图片',
|
||
) => {
|
||
fireEvent.click(
|
||
within(panel).getByRole('button', {
|
||
name: new RegExp(`^${labelPrefix}模型 `, 'u'),
|
||
}),
|
||
);
|
||
return screen.getByRole('menu', { name: `${labelPrefix}模型选项` });
|
||
};
|
||
|
||
const selectGenerationModel = (panel: HTMLElement, modelLabel: string) => {
|
||
const modelPanel = openGenerationModelPanel(panel);
|
||
fireEvent.click(
|
||
within(modelPanel).getByRole('button', {
|
||
name: new RegExp(`^${modelLabel}(?: ✓)?$`, 'u'),
|
||
}),
|
||
);
|
||
};
|
||
|
||
const selectGenerationDimensions = (
|
||
panel: HTMLElement,
|
||
aspectRatio: string,
|
||
imageSize: string,
|
||
) => {
|
||
const dimensionsPanel = openGenerationDimensionsPanel(panel);
|
||
fireEvent.click(
|
||
within(dimensionsPanel).getByRole('button', {
|
||
name: new RegExp(`^(?:比例 )?${aspectRatio}$`, 'u'),
|
||
}),
|
||
);
|
||
fireEvent.click(
|
||
within(dimensionsPanel).getByRole('button', {
|
||
name: new RegExp(`^(?:尺寸 )?${imageSize}$`, 'u'),
|
||
}),
|
||
);
|
||
};
|
||
|
||
const expectReferenceChip = (panel: HTMLElement, label: string) => {
|
||
const button =
|
||
within(panel).queryByRole('button', { name: label }) ??
|
||
Array.from(panel.querySelectorAll('button')).find(
|
||
(candidate) =>
|
||
candidate.getAttribute('title') === label ||
|
||
candidate.textContent?.includes(label),
|
||
);
|
||
expect(button).toBeTruthy();
|
||
};
|
||
|
||
const createGeneratedResource = ({
|
||
resourceId,
|
||
projectId = 'editor-project-default',
|
||
imageSrc = 'data:image/png;base64,generated',
|
||
width = 1024,
|
||
height = 1024,
|
||
objectKey,
|
||
assetObjectId,
|
||
assetKind,
|
||
generationInputs,
|
||
}: {
|
||
resourceId: string;
|
||
projectId?: string;
|
||
imageSrc?: string;
|
||
width?: number;
|
||
height?: number;
|
||
objectKey?: string;
|
||
assetObjectId?: string;
|
||
assetKind?: string;
|
||
generationInputs?: unknown;
|
||
}) => ({
|
||
resourceId,
|
||
projectId,
|
||
imageSrc,
|
||
objectKey,
|
||
assetObjectId,
|
||
width,
|
||
height,
|
||
sourceType: 'generated',
|
||
assetKind,
|
||
generationInputs,
|
||
createdAt: '2026-06-19T00:00:00.000Z',
|
||
updatedAt: '2026-06-19T00:00:00.000Z',
|
||
});
|
||
|
||
const createGeneratedAsset = ({
|
||
assetId,
|
||
folderId = 'folder-project',
|
||
label = '生成素材',
|
||
imageSrc = 'data:image/png;base64,generated',
|
||
width = 1024,
|
||
height = 1024,
|
||
objectKey,
|
||
assetObjectId,
|
||
assetKind,
|
||
generationInputs,
|
||
}: {
|
||
assetId: string;
|
||
folderId?: string;
|
||
label?: string;
|
||
imageSrc?: string;
|
||
width?: number;
|
||
height?: number;
|
||
objectKey?: string;
|
||
assetObjectId?: string;
|
||
assetKind?: string;
|
||
generationInputs?: unknown;
|
||
}) => ({
|
||
assetId,
|
||
folderId,
|
||
label,
|
||
imageSrc,
|
||
objectKey,
|
||
assetObjectId,
|
||
width,
|
||
height,
|
||
sourceType: 'generated',
|
||
assetKind,
|
||
generationInputs,
|
||
createdAt: '2026-06-19T00:00:00.000Z',
|
||
updatedAt: '2026-06-19T00:00:00.000Z',
|
||
});
|
||
|
||
const createExternalGenerationTask = (
|
||
overrides: Partial<ExternalGenerationTaskRecord> = {},
|
||
): ExternalGenerationTaskRecord => {
|
||
const now = new Date().toISOString();
|
||
return {
|
||
jobId: overrides.jobId ?? 'task-editor-character-refresh',
|
||
jobKind: overrides.jobKind ?? 'editor_image_generation',
|
||
sourceModule: overrides.sourceModule ?? 'editor-canvas',
|
||
sourceEntityId:
|
||
overrides.sourceEntityId ?? 'editor-project-refresh-completion',
|
||
requestLabel: overrides.requestLabel ?? '图片画布生成角色形象',
|
||
requestPrompt: overrides.requestPrompt ?? '刷新后角色',
|
||
status: overrides.status ?? 'running',
|
||
phaseLabel: overrides.phaseLabel ?? '生成图片',
|
||
phaseDetail: overrides.phaseDetail ?? '正在生成角色形象。',
|
||
progress: overrides.progress ?? 40,
|
||
error: overrides.error ?? null,
|
||
warning: overrides.warning ?? null,
|
||
priceMudPoints: overrides.priceMudPoints ?? 3,
|
||
refundLedgerId: overrides.refundLedgerId ?? null,
|
||
notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null,
|
||
createdAt: overrides.createdAt ?? now,
|
||
startedAt: overrides.startedAt ?? now,
|
||
completedAt: overrides.completedAt ?? null,
|
||
updatedAt: overrides.updatedAt ?? now,
|
||
updatedAtMicros: overrides.updatedAtMicros ?? 1,
|
||
};
|
||
};
|
||
|
||
const createGeneratedImageResponse = (
|
||
input: {
|
||
projectId?: string | null;
|
||
assetFolderId?: string | null;
|
||
assetLabel?: string | null;
|
||
assetKind?: string | null;
|
||
kind?: string | null;
|
||
model?: string | null;
|
||
aspectRatio?: string | null;
|
||
imageSize?: string | null;
|
||
generationInputs?: unknown;
|
||
canvasCompletion?: {
|
||
dialogId: string;
|
||
title: string;
|
||
placeholder: {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
originalWidth?: number;
|
||
originalHeight?: number;
|
||
};
|
||
} | null;
|
||
},
|
||
{
|
||
imageSrc,
|
||
width = 1024,
|
||
height = 1024,
|
||
prompt,
|
||
actualPrompt = prompt,
|
||
model = 'gpt-image-2',
|
||
provider = 'VectorEngine',
|
||
taskId,
|
||
objectKey,
|
||
assetObjectId,
|
||
resource,
|
||
asset,
|
||
projectLayers = defaultEditorProjectLayers,
|
||
projectResources = defaultEditorProjectResources,
|
||
}: {
|
||
imageSrc: string;
|
||
width?: number;
|
||
height?: number;
|
||
prompt: string;
|
||
actualPrompt?: string;
|
||
model?: string;
|
||
provider?: string;
|
||
taskId: string;
|
||
sourceType?: 'generated';
|
||
objectKey?: string;
|
||
assetObjectId?: string;
|
||
resource?: ReturnType<typeof createGeneratedResource>;
|
||
asset?: ReturnType<typeof createGeneratedAsset>;
|
||
projectLayers?: Array<Record<string, unknown>>;
|
||
projectResources?: Array<Record<string, unknown>>;
|
||
},
|
||
) => {
|
||
const normalizedAsset = asset
|
||
? {
|
||
...asset,
|
||
folderId: input.assetFolderId ?? asset.folderId ?? 'project',
|
||
}
|
||
: undefined;
|
||
const baseResponse = {
|
||
imageSrc,
|
||
width,
|
||
height,
|
||
sourceType: 'generated',
|
||
prompt,
|
||
actualPrompt,
|
||
model,
|
||
provider,
|
||
taskId,
|
||
objectKey,
|
||
assetObjectId,
|
||
resource,
|
||
asset: normalizedAsset,
|
||
};
|
||
const canvasCompletion = input.canvasCompletion;
|
||
if (!input.projectId || !canvasCompletion) {
|
||
return baseResponse;
|
||
}
|
||
const resourceId = resource?.resourceId ?? `resource-${taskId}`;
|
||
const layerId = `layer-${taskId}`;
|
||
const title = canvasCompletion.title;
|
||
const placeholder = canvasCompletion.placeholder;
|
||
const originalWidth = placeholder.originalWidth ?? width;
|
||
const originalHeight = placeholder.originalHeight ?? height;
|
||
const dialogMode =
|
||
input.kind === 'character'
|
||
? 'character'
|
||
: input.kind === 'icon'
|
||
? 'icon'
|
||
: input.kind === 'spec'
|
||
? 'spec'
|
||
: input.kind === 'ui-design'
|
||
? 'ui-design'
|
||
: 'generate';
|
||
return {
|
||
...baseResponse,
|
||
resource:
|
||
resource ??
|
||
createGeneratedResource({
|
||
resourceId,
|
||
projectId: input.projectId,
|
||
imageSrc,
|
||
width,
|
||
height,
|
||
assetKind: input.assetKind ?? undefined,
|
||
generationInputs: input.generationInputs,
|
||
objectKey,
|
||
assetObjectId,
|
||
}),
|
||
asset:
|
||
normalizedAsset ??
|
||
(input.assetLabel
|
||
? createGeneratedAsset({
|
||
assetId: `asset-${taskId}`,
|
||
folderId: input.assetFolderId ?? 'project',
|
||
label: input.assetLabel,
|
||
imageSrc,
|
||
width,
|
||
height,
|
||
assetKind: input.assetKind ?? undefined,
|
||
generationInputs: input.generationInputs,
|
||
})
|
||
: undefined),
|
||
project: {
|
||
projectId: input.projectId,
|
||
title: '默认项目',
|
||
canvas: { revision: 1 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
resources: [
|
||
...projectResources,
|
||
resource ??
|
||
createGeneratedResource({
|
||
resourceId,
|
||
projectId: input.projectId,
|
||
imageSrc,
|
||
width,
|
||
height,
|
||
assetKind: input.assetKind ?? undefined,
|
||
generationInputs: input.generationInputs,
|
||
objectKey,
|
||
assetObjectId,
|
||
}),
|
||
],
|
||
layers: [
|
||
...projectLayers,
|
||
{
|
||
itemType: 'generation-dialog',
|
||
layerId: `generation-dialog:${canvasCompletion.dialogId}`,
|
||
resourceId: `generation-dialog:${canvasCompletion.dialogId}`,
|
||
dialog: {
|
||
id: canvasCompletion.dialogId,
|
||
mode: dialogMode,
|
||
prompt,
|
||
status: 'idle',
|
||
composerOpen: false,
|
||
generatedLayerId: layerId,
|
||
placeholder,
|
||
imageModel: model,
|
||
aspectRatio: input.aspectRatio,
|
||
imageSize: input.imageSize,
|
||
},
|
||
},
|
||
{
|
||
layerId,
|
||
resourceId,
|
||
title,
|
||
x: placeholder.x + placeholder.width / 2 - originalWidth / 2,
|
||
y: placeholder.y + placeholder.height / 2 - originalHeight / 2,
|
||
width: originalWidth,
|
||
height: originalHeight,
|
||
originalWidth,
|
||
originalHeight,
|
||
zIndex: 12,
|
||
sourceType: 'generated',
|
||
objectKey,
|
||
assetObjectId,
|
||
prompt,
|
||
actualPrompt,
|
||
model,
|
||
provider,
|
||
taskId,
|
||
assetKind: input.assetKind ?? undefined,
|
||
generationInputs: input.generationInputs,
|
||
},
|
||
],
|
||
updatedAt: '2026-06-19T00:00:00.000Z',
|
||
},
|
||
};
|
||
};
|
||
|
||
it('restores generated image composer from saved generator snapshots', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-generated-dialog',
|
||
title: '已生成画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-generated-restored',
|
||
resourceId: 'resource-generated-restored',
|
||
title: '生成图片 1',
|
||
x: 236,
|
||
y: 66,
|
||
width: 512,
|
||
height: 512,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
zIndex: 11,
|
||
sourceType: 'generated',
|
||
generationInputs: {
|
||
fields: [{ title: '生成提示词', value: '刷新后恢复生成器' }],
|
||
references: [],
|
||
},
|
||
},
|
||
{
|
||
itemType: 'generation-dialog',
|
||
layerId: 'generation-dialog:generation-dialog-1',
|
||
resourceId: 'generation-dialog:generation-dialog-1',
|
||
dialog: {
|
||
id: 'generation-dialog-1',
|
||
mode: 'generate',
|
||
prompt: '刷新后恢复生成器',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
generatedLayerId: 'layer-generated-restored',
|
||
placeholder: {
|
||
x: 282,
|
||
y: 112,
|
||
width: 420,
|
||
height: 420,
|
||
originalWidth: 2048,
|
||
originalHeight: 2048,
|
||
},
|
||
},
|
||
},
|
||
],
|
||
resources: [
|
||
{
|
||
resourceId: 'resource-generated-restored',
|
||
projectId: 'editor-project-generated-dialog',
|
||
imageSrc: 'data:image/png;base64,cmVzdG9yZWQ=',
|
||
width: 512,
|
||
height: 512,
|
||
sourceType: 'generated',
|
||
},
|
||
],
|
||
updatedAt: '2026-06-17T00:00:00.000Z',
|
||
});
|
||
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
expect(await screen.findByAltText('画布图片:生成图片 1')).toBeTruthy();
|
||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
||
expect(generateDialog).toBeTruthy();
|
||
expect(
|
||
(screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value,
|
||
).toBe('刷新后恢复生成器');
|
||
});
|
||
|
||
it('restores generated image composer from layer metadata when the dialog snapshot is missing', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-generated-layer-only',
|
||
title: '已生成画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-generated-metadata',
|
||
resourceId: 'resource-generated-metadata',
|
||
title: '生成图片 1',
|
||
x: 236,
|
||
y: 66,
|
||
width: 512,
|
||
height: 512,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
zIndex: 11,
|
||
sourceType: 'generated',
|
||
},
|
||
],
|
||
resources: [
|
||
{
|
||
resourceId: 'resource-generated-metadata',
|
||
projectId: 'editor-project-generated-layer-only',
|
||
imageSrc: 'data:image/png;base64,bWV0YWRhdGE=',
|
||
width: 512,
|
||
height: 512,
|
||
sourceType: 'generated',
|
||
prompt: '图层提示词',
|
||
model: 'gpt-image-2',
|
||
generationInputs: {
|
||
fields: [{ title: '生成提示词', value: '元数据恢复生成器' }],
|
||
references: [],
|
||
},
|
||
},
|
||
],
|
||
updatedAt: '2026-06-17T00:00:00.000Z',
|
||
});
|
||
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const generatedImage = await screen.findByAltText('画布图片:生成图片 1');
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
|
||
fireEvent.click(generatedImage.closest('button')!);
|
||
|
||
expect(
|
||
await screen.findByRole('dialog', { name: '生成图片' }),
|
||
).toBeTruthy();
|
||
expect(
|
||
(screen.getByLabelText('生成提示词') as HTMLTextAreaElement).value,
|
||
).toBe('元数据恢复生成器');
|
||
});
|
||
|
||
it('does not open a generator on direct click for tagged uploaded images', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-uploaded-tagged-layer',
|
||
title: '标签素材画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-uploaded-character',
|
||
resourceId: 'resource-uploaded-character',
|
||
title: '上传角色',
|
||
src: 'data:image/png;base64,dXBsb2FkZWQ=',
|
||
x: 236,
|
||
y: 66,
|
||
width: 512,
|
||
height: 512,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
zIndex: 11,
|
||
sourceType: 'uploaded',
|
||
assetKind: 'character',
|
||
generationInputs: {
|
||
fields: [{ title: '角色设定', value: '黑衣剑士' }],
|
||
references: [],
|
||
},
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-17T00:00:00.000Z',
|
||
});
|
||
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const uploadedImage = await screen.findByAltText('画布图片:上传角色');
|
||
fireEvent.click(uploadedImage.closest('button')!);
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
|
||
|
||
expect(
|
||
await screen.findByRole('dialog', { name: '快速编辑图片' }),
|
||
).toBeTruthy();
|
||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||
});
|
||
|
||
it('opens quick edit from image context for tagged uploaded images', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-context-tagged-layer',
|
||
title: '右键标签素材画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-context-character',
|
||
resourceId: 'resource-context-character',
|
||
title: '右键上传角色',
|
||
src: 'data:image/png;base64,Y29udGV4dA==',
|
||
x: 236,
|
||
y: 66,
|
||
width: 512,
|
||
height: 512,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
zIndex: 11,
|
||
sourceType: 'uploaded',
|
||
assetKind: 'character',
|
||
generationInputs: {
|
||
fields: [{ title: '角色设定', value: '红披风骑士' }],
|
||
references: [],
|
||
},
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-17T00:00:00.000Z',
|
||
});
|
||
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const uploadedImage = await screen.findByAltText('画布图片:右键上传角色');
|
||
fireEvent.contextMenu(uploadedImage.closest('button')!, {
|
||
clientX: 260,
|
||
clientY: 220,
|
||
});
|
||
fireEvent.click(
|
||
within(screen.getByRole('menu', { name: '图片功能面板' })).getByRole(
|
||
'menuitem',
|
||
{ name: '快速编辑' },
|
||
),
|
||
);
|
||
|
||
expect(
|
||
await screen.findByRole('dialog', { name: '快速编辑图片' }),
|
||
).toBeTruthy();
|
||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||
});
|
||
|
||
it('restores publication material composer fields and references from saved generator snapshots', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-publication-dialog',
|
||
title: '宣发画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
itemType: 'generation-dialog',
|
||
layerId: 'generation-dialog:generation-dialog-publication',
|
||
resourceId: 'generation-dialog:generation-dialog-publication',
|
||
dialog: {
|
||
id: 'generation-dialog-publication',
|
||
mode: 'publication',
|
||
prompt: '马戏团午夜惊魂|非对称对抗|找到钥匙逃离',
|
||
status: 'idle',
|
||
composerOpen: true,
|
||
publicationWorkflowId: 'publication-promo-poster',
|
||
publicationGameInfo: {
|
||
gameName: '马戏团午夜惊魂',
|
||
gameCategories: '非对称对抗',
|
||
gameDescription: '找到钥匙,开门逃离马戏团',
|
||
},
|
||
publicationReferences: [
|
||
{
|
||
id: 'publication-reference-1',
|
||
label: '首图参考',
|
||
src: '/generated-character-drafts/editor/reference.png',
|
||
objectKey: 'generated-character-drafts/editor/reference.png',
|
||
assetObjectId: 'asset-object-publication-reference',
|
||
},
|
||
],
|
||
imageModel: 'gpt-image-2',
|
||
aspectRatio: '16:9',
|
||
imageSize: '2K',
|
||
placeholder: {
|
||
x: 282,
|
||
y: 112,
|
||
width: 1280,
|
||
height: 720,
|
||
originalWidth: 1280,
|
||
originalHeight: 720,
|
||
},
|
||
},
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-17T00:00:00.000Z',
|
||
});
|
||
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const publicationDialog = await screen.findByRole('dialog', {
|
||
name: '运营海报生成卡片',
|
||
});
|
||
expect(
|
||
(
|
||
within(publicationDialog).getByRole('textbox', {
|
||
name: '运营海报游戏名',
|
||
}) as HTMLInputElement
|
||
).value,
|
||
).toBe('马戏团午夜惊魂');
|
||
expect(
|
||
(
|
||
within(publicationDialog).getByRole('textbox', {
|
||
name: '运营海报游戏分类',
|
||
}) as HTMLInputElement
|
||
).value,
|
||
).toBe('非对称对抗');
|
||
expect(
|
||
(
|
||
within(publicationDialog).getByRole('textbox', {
|
||
name: '运营海报一句话描述游戏',
|
||
}) as HTMLTextAreaElement
|
||
).value,
|
||
).toBe('找到钥匙,开门逃离马戏团');
|
||
expect(within(publicationDialog).getByLabelText('首图参考')).toBeTruthy();
|
||
expect(screen.getByLabelText('宣发素材生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('opens a canvas generation frame and composer before creating a generated layer', async () => {
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '一张明亮的拼图主视觉',
|
||
actualPrompt: '一张明亮的拼图主视觉',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-real-task-1',
|
||
resource: createGeneratedResource({
|
||
resourceId: 'resource-generated-image-1',
|
||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||
assetKind: 'character',
|
||
}),
|
||
asset: createGeneratedAsset({
|
||
assetId: 'asset-generated-image-1',
|
||
label: '生成图片 1',
|
||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||
assetKind: 'character',
|
||
}),
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||
fireEvent.click(
|
||
within(bottomToolbar).getByRole('button', { name: '生成图片' }),
|
||
);
|
||
|
||
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
||
const initialComposerTop = Number.parseFloat(
|
||
(generateDialog as HTMLElement).style.top,
|
||
);
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
expect(within(generateDialog).getByText('参考图')).toBeTruthy();
|
||
expect(
|
||
within(generateDialog).getByRole('button', { name: '添加参考图' })
|
||
.className,
|
||
).toContain('image-canvas-editor__reference-chip');
|
||
expect(
|
||
within(generateDialog).getByRole('button', { name: '添加参考图' })
|
||
.className,
|
||
).toContain('image-canvas-editor__reference-chip');
|
||
const generatePrompt = screen.getByLabelText('生成提示词');
|
||
expect(generatePrompt.className).toContain('auto-grow-text-area');
|
||
expect(generatePrompt.className).not.toContain('platform-text-field');
|
||
expect(generatePrompt.className).toContain(
|
||
'image-canvas-editor__generation-prompt',
|
||
);
|
||
expect(
|
||
within(generateDialog).getByRole('button', {
|
||
name: '生成图片尺寸 1:1·1K',
|
||
}).className,
|
||
).toContain('platform-inline-option-button');
|
||
expect(
|
||
within(generateDialog).getByRole('button', {
|
||
name: '生成图片模型 nanobanana2',
|
||
}).className,
|
||
).toContain('platform-inline-option-button');
|
||
const pixelArtToggle = within(generateDialog).getByRole('checkbox', {
|
||
name: '像素艺术',
|
||
}) as HTMLInputElement;
|
||
expect(pixelArtToggle.checked).toBe(false);
|
||
expect(
|
||
within(generateDialog).getByRole('button', { name: '生成' }).className,
|
||
).toContain('platform-button');
|
||
expect(
|
||
within(generateDialog).getByRole('button', { name: '生成' }).className,
|
||
).toContain('image-canvas-editor__generation-submit');
|
||
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
|
||
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '一张明亮的拼图主视觉' },
|
||
});
|
||
fireEvent.click(pixelArtToggle);
|
||
fireEvent.click(
|
||
within(generateDialog).getByRole('button', { name: '生成' }),
|
||
);
|
||
|
||
expect(screen.getByRole('status').textContent).toContain('生成中');
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
prompt: '一张明亮的拼图主视觉',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
style: 'pixelArt',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
projectId: 'editor-project-default',
|
||
assetFolderId: 'project',
|
||
assetLabel: expect.stringMatching(/生成图片/u),
|
||
}),
|
||
);
|
||
});
|
||
const submittedGenerationInputs =
|
||
generateEditorImageMock.mock.calls[0]?.[0]?.generationInputs;
|
||
expect(JSON.stringify(submittedGenerationInputs)).not.toContain(
|
||
'pixelArt',
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button')!;
|
||
expect(generatedLayer).toBeTruthy();
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
expect(
|
||
Number.isFinite(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.top),
|
||
),
|
||
).toBe(true);
|
||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||
const metadataButtons = screen.getAllByRole('button', {
|
||
name: /查看生成图片 .*图片信息/,
|
||
});
|
||
expect(metadataButtons[0]).toBeTruthy();
|
||
fireEvent.click(metadataButtons[0]!);
|
||
|
||
const infoPanel = screen.getByRole('dialog', { name: '图片信息' });
|
||
expect(within(infoPanel).queryByText('Prompt')).toBeNull();
|
||
expect(
|
||
within(infoPanel).queryByRole('button', { name: '复制Prompt' }),
|
||
).toBeNull();
|
||
expect(within(infoPanel).getByText('生成输入')).toBeTruthy();
|
||
expect(within(infoPanel).getByText('生成提示词')).toBeTruthy();
|
||
expect(within(infoPanel).getByText('一张明亮的拼图主视觉')).toBeTruthy();
|
||
});
|
||
|
||
it('drags the generation placeholder and places the generated layer there', async () => {
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,ZHJhZ2dlZC1mcmFtZQ==',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '拖拽后的生成图',
|
||
actualPrompt: '拖拽后的生成图',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-drag-frame-1',
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
const initialComposerTop = Number.parseFloat(
|
||
(screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style
|
||
.top,
|
||
);
|
||
const frame = screen.getByLabelText('图像生成占位图');
|
||
dispatchPointerEvent(frame, 'pointerdown', {
|
||
button: 0,
|
||
pointerId: 61,
|
||
clientX: 500,
|
||
clientY: 260,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||
pointerId: 61,
|
||
clientX: 582,
|
||
clientY: 342,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
|
||
pointerId: 61,
|
||
clientX: 582,
|
||
clientY: 342,
|
||
});
|
||
const draggedComposerTop = Number.parseFloat(
|
||
(screen.getByRole('dialog', { name: '生成图片' }) as HTMLElement).style
|
||
.top,
|
||
);
|
||
expect(draggedComposerTop).toBeGreaterThan(initialComposerTop);
|
||
const draggedFrame = screen.getByLabelText('图像生成占位图') as HTMLElement;
|
||
const draggedFrameCenterX =
|
||
Number.parseFloat(draggedFrame.style.left) +
|
||
Number.parseFloat(draggedFrame.style.width) / 2;
|
||
const draggedFrameCenterY =
|
||
Number.parseFloat(draggedFrame.style.top) +
|
||
Number.parseFloat(draggedFrame.style.height) / 2;
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '拖拽后的生成图' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button')!;
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||
expect(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.left) +
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.width) / 2,
|
||
).toBeCloseTo(draggedFrameCenterX, 1);
|
||
expect(
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.top) +
|
||
Number.parseFloat((generatedLayer as HTMLElement).style.height) / 2,
|
||
).toBeCloseTo(draggedFrameCenterY, 1);
|
||
});
|
||
|
||
it('keeps the generation placeholder draggable while the image is generating', async () => {
|
||
let resolveGeneration!: (value: unknown) => void;
|
||
generateEditorImageMock.mockImplementationOnce(
|
||
(input) =>
|
||
new Promise((resolve) => {
|
||
resolveGeneration = (value) =>
|
||
resolve(createGeneratedImageResponse(input, value as never));
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
const generateDialog = screen.getByRole('dialog', { name: '生成图片' });
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '生成中继续拖动的图片' },
|
||
});
|
||
fireEvent.click(
|
||
within(generateDialog).getByRole('button', { name: '生成' }),
|
||
);
|
||
|
||
const frame = screen.getByLabelText('图像生成占位图');
|
||
expect(frame.className).toContain(
|
||
'image-canvas-editor__generation-frame--generating',
|
||
);
|
||
const initialLeft = Number.parseFloat((frame as HTMLElement).style.left);
|
||
const initialTop = Number.parseFloat((frame as HTMLElement).style.top);
|
||
|
||
dispatchPointerEvent(frame, 'pointerdown', {
|
||
button: 0,
|
||
pointerId: 67,
|
||
clientX: 500,
|
||
clientY: 260,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||
pointerId: 67,
|
||
clientX: 620,
|
||
clientY: 360,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
|
||
pointerId: 67,
|
||
clientX: 620,
|
||
clientY: 360,
|
||
});
|
||
|
||
const draggedFrame = screen.getByLabelText('图像生成占位图');
|
||
expect(
|
||
Number.parseFloat((draggedFrame as HTMLElement).style.left),
|
||
).toBeGreaterThan(initialLeft);
|
||
expect(
|
||
Number.parseFloat((draggedFrame as HTMLElement).style.top),
|
||
).toBeGreaterThan(initialTop);
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalled();
|
||
});
|
||
await act(async () => {
|
||
resolveGeneration({
|
||
imageSrc: 'data:image/png;base64,Z2VuZXJhdGluZy1kcmFn',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '生成中继续拖动的图片',
|
||
actualPrompt: '生成中继续拖动的图片',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-generating-drag-1',
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button')!;
|
||
expect(generatedLayer).toBeTruthy();
|
||
});
|
||
|
||
it('confirms before deleting a generating placeholder because mud points are not refunded', async () => {
|
||
let resolveGeneration!: (value: unknown) => void;
|
||
generateEditorImageMock.mockReturnValueOnce(
|
||
new Promise((resolve) => {
|
||
resolveGeneration = resolve;
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '准备删除的生成中图片' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
const frame = screen.getByLabelText('图像生成占位图');
|
||
expect(frame.className).toContain(
|
||
'image-canvas-editor__generation-frame--generating',
|
||
);
|
||
|
||
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
|
||
|
||
const confirmDialog = screen.getByRole('dialog', {
|
||
name: '删除生成中的占位图',
|
||
});
|
||
expect(
|
||
within(confirmDialog).getByText(
|
||
'删除后会停止在画布中显示这个生成任务,已消耗的泥点不会返还。',
|
||
),
|
||
).toBeTruthy();
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
|
||
fireEvent.click(
|
||
within(confirmDialog).getByRole('button', { name: '取消' }),
|
||
);
|
||
expect(
|
||
screen.queryByRole('dialog', { name: '删除生成中的占位图' }),
|
||
).toBeNull();
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
|
||
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
|
||
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||
|
||
expect(screen.queryByLabelText('图像生成占位图')).toBeNull();
|
||
|
||
await act(async () => {
|
||
resolveGeneration({
|
||
imageSrc: 'data:image/png;base64,ZGVsZXRlZC1wbGFjZWhvbGRlcg==',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '准备删除的生成中图片',
|
||
actualPrompt: '准备删除的生成中图片',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-deleted-placeholder-1',
|
||
});
|
||
});
|
||
|
||
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
|
||
});
|
||
|
||
it('hides the generation composer when selecting another image but keeps the placeholder', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 62,
|
||
clientX: 120,
|
||
clientY: 120,
|
||
},
|
||
);
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('图像生成占位图'), {
|
||
button: 0,
|
||
pointerId: 64,
|
||
clientX: 300,
|
||
clientY: 180,
|
||
});
|
||
|
||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||
});
|
||
|
||
it('hides the generation composer when clicking the canvas outside generation controls', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
|
||
button: 0,
|
||
pointerId: 63,
|
||
clientX: 260,
|
||
clientY: 180,
|
||
});
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('does not render a standalone close button on the generation composer', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
|
||
expect(screen.queryByRole('button', { name: '关闭生成图片' })).toBeNull();
|
||
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('shows generation errors instead of falling back to mock images', async () => {
|
||
generateEditorImageMock.mockRejectedValueOnce(
|
||
new Error('VectorEngine 未配置'),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '一张真实生成失败的图' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
expect(screen.getByRole('status').textContent).toContain('生成中');
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('alert').textContent).toContain(
|
||
'VectorEngine 未配置',
|
||
);
|
||
});
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
|
||
});
|
||
|
||
it('asks the user to log in when real generation is unauthorized', async () => {
|
||
generateEditorImageMock.mockRejectedValueOnce(
|
||
new ApiClientError({
|
||
message: '未授权访问(requestId: web-login-required)',
|
||
status: 401,
|
||
code: 'UNAUTHORIZED',
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '一张需要登录生成的图' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByRole('alert').textContent).toBe(
|
||
'请先登录后再生成图片',
|
||
);
|
||
});
|
||
expect(screen.queryByText(/requestId/u)).toBeNull();
|
||
});
|
||
|
||
it('hides image generation setting panels after generation starts while keeping the preview frame visible', async () => {
|
||
const cases = [
|
||
{
|
||
open: () => {
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
selectGenerationDimensions(
|
||
screen.getByRole('dialog', { name: '生成图片' }),
|
||
'1:1',
|
||
'2K',
|
||
);
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '生成中的普通图片' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
},
|
||
dialogName: '生成图片',
|
||
frameLabel: '图像生成占位图',
|
||
expectedFrameSize: { width: 2048, height: 2048 },
|
||
},
|
||
{
|
||
open: () => {
|
||
fireEvent.click(
|
||
within(
|
||
screen.getByRole('toolbar', { name: 'AI画布工具栏' }),
|
||
).getByRole('button', { name: '生成规范' }),
|
||
);
|
||
fireEvent.click(
|
||
within(
|
||
screen.getByRole('menu', { name: '生成规范类型' }),
|
||
).getByRole('menuitem', { name: '自定义规范' }),
|
||
);
|
||
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
|
||
target: { value: '生成中的自定义规范图' },
|
||
});
|
||
fireEvent.click(
|
||
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
|
||
'button',
|
||
{ name: '提交生成规范' },
|
||
),
|
||
);
|
||
},
|
||
dialogName: '生成规范',
|
||
frameLabel: '规范生成占位图',
|
||
expectedFrameSize: { width: 2048, height: 1152 },
|
||
},
|
||
{
|
||
open: () => {
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
selectGenerationDimensions(
|
||
screen.getByRole('dialog', { name: '生成角色形象' }),
|
||
'2:3',
|
||
'2K',
|
||
);
|
||
fireEvent.change(screen.getByLabelText('角色设定'), {
|
||
target: { value: '生成中的角色形象' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
},
|
||
dialogName: '生成角色形象',
|
||
frameLabel: '角色生成占位图',
|
||
expectedFrameSize: { width: 1365, height: 2048 },
|
||
},
|
||
] as const;
|
||
|
||
for (const testCase of cases) {
|
||
generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
|
||
const { unmount } = render(<ImageCanvasEditorView />);
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
testCase.open();
|
||
|
||
expect(
|
||
screen.queryByRole('dialog', { name: testCase.dialogName }),
|
||
).toBeNull();
|
||
const frame = screen.getByLabelText(testCase.frameLabel);
|
||
expect(frame.className).toContain(
|
||
'image-canvas-editor__generation-frame--generating',
|
||
);
|
||
expect((frame as HTMLElement).style.width).toBe(
|
||
`${testCase.expectedFrameSize.width}px`,
|
||
);
|
||
expect((frame as HTMLElement).style.height).toBe(
|
||
`${testCase.expectedFrameSize.height}px`,
|
||
);
|
||
expect(within(frame).getByRole('status').textContent).toContain('生成中');
|
||
|
||
unmount();
|
||
}
|
||
});
|
||
|
||
it('hides the icon material panel after generation starts while keeping the icon preview frame visible', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-icons-generating',
|
||
title: '图标素材生成中画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-icon-spec-generating',
|
||
resourceId: 'resource-icon-spec-generating',
|
||
title: '清爽按钮图标规范',
|
||
src: 'data:image/png;base64,icon-spec-generating',
|
||
x: 80,
|
||
y: 80,
|
||
width: 160,
|
||
height: 160,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
zIndex: 10,
|
||
sourceType: 'generated',
|
||
assetKind: 'icon-spec',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||
});
|
||
generateEditorIconSpritesheetMock.mockReturnValueOnce(
|
||
new Promise(() => undefined),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await screen.findByAltText('画布图片:清爽按钮图标规范');
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
fireEvent.click(
|
||
within(iconPanel).getByRole('button', { name: '图标规范' }),
|
||
);
|
||
fireEvent.click(
|
||
within(screen.getByRole('menu', { name: '图标规范来源' })).getByRole(
|
||
'menuitem',
|
||
{ name: '从画布中选择' },
|
||
),
|
||
);
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 1260,
|
||
clientX: 120,
|
||
clientY: 120,
|
||
},
|
||
);
|
||
fireEvent.change(
|
||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||
'textbox',
|
||
{ name: '素材描述' },
|
||
),
|
||
{ target: { value: '返回按钮' } },
|
||
);
|
||
fireEvent.click(
|
||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||
'button',
|
||
{ name: '生成' },
|
||
),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(
|
||
screen.queryByRole('dialog', { name: '生成图标素材' }),
|
||
).toBeNull();
|
||
});
|
||
const frame = screen.getByLabelText('图标素材生成占位图');
|
||
expect(frame.className).toContain(
|
||
'image-canvas-editor__generation-frame--generating',
|
||
);
|
||
expect(within(frame).getByRole('status').textContent).toContain('生成中');
|
||
});
|
||
|
||
it('opens character spec generation form and creates a labeled spec layer', async () => {
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,c3BlYy1yb2xl',
|
||
width: 2048,
|
||
height: 1152,
|
||
sourceType: 'generated',
|
||
prompt: '角色规范提示词',
|
||
actualPrompt: '角色规范提示词',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-spec-role-1',
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||
const generationToolLabels = within(bottomToolbar)
|
||
.getAllByRole('button')
|
||
.filter((button) => button.getAttribute('aria-label')?.startsWith('生成'))
|
||
.map((button) => button.getAttribute('aria-label'));
|
||
expect(generationToolLabels).toContain('生成图片');
|
||
expect(generationToolLabels).toContain('生成规范');
|
||
fireEvent.click(
|
||
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
|
||
);
|
||
|
||
const specMenu = screen.getByRole('menu', { name: '生成规范类型' });
|
||
expect(
|
||
within(specMenu).getByRole('menuitem', { name: '角色规范' }),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(specMenu).getByRole('menuitem', { name: '图标规范' }),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(specMenu).getByRole('menuitem', { name: '自定义规范' }),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.click(
|
||
within(specMenu).getByRole('menuitem', { name: '角色规范' }),
|
||
);
|
||
|
||
const specDialog = screen.getByRole('dialog', { name: '生成规范' });
|
||
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
|
||
expect(screen.getByText('2048 x 1152')).toBeTruthy();
|
||
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
|
||
'',
|
||
);
|
||
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
|
||
'',
|
||
);
|
||
expect((screen.getByLabelText('头身比') as HTMLSelectElement).value).toBe(
|
||
'3',
|
||
);
|
||
expect((screen.getByLabelText('角色视角') as HTMLInputElement).value).toBe(
|
||
'右向斜侧身站姿,保留少量正面信息,能读到面部轮廓与胸肩结构,禁止生成完全 90 度纯右视图,也禁止生成正面立绘。',
|
||
);
|
||
expect(
|
||
within(specDialog).getByRole('button', { name: '提交生成规范' })
|
||
.textContent,
|
||
).toContain('生成5泥点');
|
||
|
||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||
target: { value: '平台跳跃玩法' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('美术风格'), {
|
||
target: { value: '低多边形卡通' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('头身比'), {
|
||
target: { value: '4' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('角色视角'), {
|
||
target: { value: '左向三分之二侧身站姿' },
|
||
});
|
||
fireEvent.submit(specDialog);
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
kind: 'spec',
|
||
model: 'gpt-image-2',
|
||
size: '2048x1152',
|
||
aspectRatio: '16:9',
|
||
imageSize: '2K',
|
||
prompt: expect.stringContaining('玩法设计:平台跳跃玩法'),
|
||
projectId: 'editor-project-default',
|
||
assetFolderId: 'project',
|
||
assetKind: 'spec',
|
||
generationInputs: expect.objectContaining({
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '玩法设定',
|
||
value: '平台跳跃玩法',
|
||
}),
|
||
]),
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
|
||
expect(prompt).toContain('生成2D 角色美术视觉规范设定图');
|
||
expect(prompt).toContain('美术风格:低多边形卡通');
|
||
expect(prompt).toContain('头身比:4');
|
||
expect(prompt).toContain('视角要求:左向三分之二侧身站姿');
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:角色规范/)).toBeTruthy();
|
||
});
|
||
expect(screen.getByText('规范')).toBeTruthy();
|
||
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
|
||
'editor-project-default',
|
||
expect.objectContaining({
|
||
expectedRevision: 1,
|
||
layers: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
itemType: 'generation-dialog',
|
||
dialog: expect.objectContaining({
|
||
mode: 'spec',
|
||
status: 'idle',
|
||
generatedLayerId: 'layer-editor-spec-role-1',
|
||
specValues: expect.objectContaining({
|
||
playSetting: '平台跳跃玩法',
|
||
artStyle: '低多边形卡通',
|
||
bodyRatio: '4',
|
||
characterView: '左向三分之二侧身站姿',
|
||
}),
|
||
}),
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('shows visible titles for character spec, icon spec, and icon spritesheet generation fields', async () => {
|
||
render(<ImageCanvasEditorView />);
|
||
await screen.findByAltText('画布图片:拼图素材');
|
||
|
||
fireEvent.click(
|
||
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
|
||
'button',
|
||
{ name: '生成规范' },
|
||
),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '角色规范' }));
|
||
|
||
const characterSpecDialog = screen.getByRole('dialog', {
|
||
name: '生成规范',
|
||
});
|
||
['玩法设定', '美术风格', '头身比', '角色视角'].forEach((title) => {
|
||
expect(within(characterSpecDialog).getByText(title)).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
|
||
const iconSpritesheetPanel = screen.getByRole('dialog', {
|
||
name: '生成图标素材',
|
||
});
|
||
expect(
|
||
within(iconSpritesheetPanel).getByRole('button', {
|
||
name: '图标规范',
|
||
}),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(iconSpritesheetPanel).getByRole('textbox', {
|
||
name: '素材描述',
|
||
}),
|
||
).toBeTruthy();
|
||
expect(within(iconSpritesheetPanel).queryByText('素材描述 1')).toBeNull();
|
||
expect(within(iconSpritesheetPanel).queryByText('素材描述 6')).toBeNull();
|
||
expect(
|
||
within(iconSpritesheetPanel).getByRole('button', {
|
||
name: '生成图片模型 nanobanana2',
|
||
}),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.click(
|
||
within(iconSpritesheetPanel).getByRole('button', {
|
||
name: '图标规范',
|
||
}),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '新建图标规范' }));
|
||
|
||
const iconSpecDialog = screen.getByRole('dialog', { name: '生成规范' });
|
||
['玩法设定', '美术风格'].forEach((title) => {
|
||
expect(within(iconSpecDialog).getByText(title)).toBeTruthy();
|
||
});
|
||
});
|
||
|
||
it('defaults character and icon generation to nanobanana2 model options', async () => {
|
||
render(<ImageCanvasEditorView />);
|
||
await screen.findByAltText('画布图片:拼图素材');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', {
|
||
name: '生成角色形象',
|
||
});
|
||
expect(
|
||
within(characterPanel).getByRole('button', {
|
||
name: '生成图片尺寸 1:1·1K',
|
||
}),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(characterPanel).getByRole('button', {
|
||
name: '生成图片模型 nanobanana2',
|
||
}),
|
||
).toBeTruthy();
|
||
const characterDimensionsPanel =
|
||
openGenerationDimensionsPanel(characterPanel);
|
||
expect(
|
||
within(characterDimensionsPanel).getByText(/^(?:比例|Aspect ratio)$/u),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(characterDimensionsPanel).getByText(/^(?:尺寸|Size)$/u),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(characterDimensionsPanel).getByRole('button', {
|
||
name: /^(?:比例 )?1:1$/u,
|
||
}),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(characterDimensionsPanel).getByRole('button', {
|
||
name: /^(?:尺寸 )?1K$/u,
|
||
}),
|
||
).toBeTruthy();
|
||
const characterModelPanel = openGenerationModelPanel(characterPanel);
|
||
expect(
|
||
within(characterModelPanel).getByRole('button', {
|
||
name: /nanobanana2/u,
|
||
}),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
expect(
|
||
within(iconPanel).getByRole('button', {
|
||
name: '生成图片尺寸 1:1·1K',
|
||
}),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(iconPanel).getByRole('button', {
|
||
name: '生成图片模型 nanobanana2',
|
||
}),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
it('submits character generation with default model and dimension options', async () => {
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,character-model-options',
|
||
width: 1024,
|
||
height: 1536,
|
||
sourceType: 'generated',
|
||
prompt: '高个子游侠',
|
||
actualPrompt: '高个子游侠',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'character-model-options-1',
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
await screen.findByAltText('画布图片:拼图素材');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', {
|
||
name: '生成角色形象',
|
||
});
|
||
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
|
||
target: { value: '高个子游侠' },
|
||
});
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '生成' }),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
kind: 'character',
|
||
prompt: '高个子游侠',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('remembers the last selected image model for character and icon generation', async () => {
|
||
const rememberedProjectLayers = [
|
||
...defaultEditorProjectLayers,
|
||
{
|
||
layerId: 'layer-remembered-icon-spec',
|
||
resourceId: 'resource-remembered-icon-spec',
|
||
title: '图标规范 2',
|
||
src: 'data:image/png;base64,remembered-icon-spec',
|
||
x: 180,
|
||
y: 80,
|
||
width: 560,
|
||
height: 315,
|
||
originalWidth: 2048,
|
||
originalHeight: 1152,
|
||
zIndex: 3,
|
||
sourceType: 'generated',
|
||
assetKind: 'icon-spec',
|
||
},
|
||
];
|
||
const rememberedProjectResources = [
|
||
...defaultEditorProjectResources,
|
||
{
|
||
resourceId: 'resource-remembered-icon-spec',
|
||
projectId: 'editor-project-default',
|
||
imageSrc: 'data:image/png;base64,remembered-icon-spec',
|
||
width: 2048,
|
||
height: 1152,
|
||
sourceType: 'generated',
|
||
assetKind: 'icon-spec',
|
||
},
|
||
];
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-default',
|
||
title: '默认项目',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: rememberedProjectLayers,
|
||
resources: rememberedProjectResources,
|
||
updatedAt: '2026-06-19T00:00:00.000Z',
|
||
});
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,character-gpt-model',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '蓝衣剑士',
|
||
actualPrompt: '蓝衣剑士',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'character-gpt-model-1',
|
||
projectLayers: rememberedProjectLayers,
|
||
projectResources: rememberedProjectResources,
|
||
}),
|
||
);
|
||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model',
|
||
spritesheetWidth: 1024,
|
||
spritesheetHeight: 1024,
|
||
iconImageSrcs: [],
|
||
prompt: '图标 prompt',
|
||
actualPrompt: '图标 prompt',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'icon-gpt-model-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
await screen.findByAltText('画布图片:拼图素材');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', {
|
||
name: '生成角色形象',
|
||
});
|
||
selectGenerationModel(characterPanel, 'gpt-image-2');
|
||
selectGenerationDimensions(characterPanel, '2:3', '2K');
|
||
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
|
||
target: { value: '蓝衣剑士' },
|
||
});
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '生成' }),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
kind: 'character',
|
||
prompt: '蓝衣剑士',
|
||
model: 'gpt-image-2',
|
||
aspectRatio: '2:3',
|
||
imageSize: '2K',
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:角色形象/u)).toBeTruthy();
|
||
});
|
||
|
||
const iconSpecLayer = await screen.findByAltText('画布图片:图标规范 2');
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
expect(
|
||
within(iconPanel).getByRole('button', {
|
||
name: '生成图片模型 gpt-image-2',
|
||
}),
|
||
).toBeTruthy();
|
||
fireEvent.click(
|
||
within(iconPanel).getByRole('button', { name: '图标规范' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
||
fireEvent.pointerDown(iconSpecLayer.closest('button')!, {
|
||
button: 0,
|
||
pointerId: 1640,
|
||
clientX: 260,
|
||
clientY: 100,
|
||
});
|
||
expectReferenceChip(
|
||
screen.getByRole('dialog', { name: '生成图标素材' }),
|
||
'图标规范 2',
|
||
);
|
||
fireEvent.change(
|
||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||
'textbox',
|
||
{ name: '素材描述' },
|
||
),
|
||
{ target: { value: '返回按钮' } },
|
||
);
|
||
fireEvent.click(
|
||
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
|
||
'button',
|
||
{ name: '生成' },
|
||
),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
model: 'gpt-image-2',
|
||
aspectRatio: '2:3',
|
||
imageSize: '2K',
|
||
}),
|
||
);
|
||
});
|
||
});
|
||
|
||
it('keeps the bottom AI toolbar visible while generation panels are open', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
expect(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).toBeTruthy();
|
||
});
|
||
|
||
it('keeps existing generation placeholders when another bottom generation object is created', async () => {
|
||
render(<ImageCanvasEditorView />);
|
||
await act(async () => {});
|
||
|
||
const bottomToolbar = screen.getByRole('toolbar', {
|
||
name: 'AI画布工具栏',
|
||
});
|
||
fireEvent.click(
|
||
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '角色规范' }));
|
||
|
||
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
|
||
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
|
||
expect(screen.getByLabelText('规范生成占位图')).toBeTruthy();
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('规范生成占位图'), {
|
||
button: 0,
|
||
pointerId: 1701,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
|
||
expect(screen.getByRole('dialog', { name: '生成规范' })).toBeTruthy();
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('keeps archived generation logic using the latest placeholder when another object is active', async () => {
|
||
let resolveGeneration!: (value: unknown) => void;
|
||
generateEditorImageMock.mockImplementationOnce(
|
||
(input) =>
|
||
new Promise((resolve) => {
|
||
resolveGeneration = (value) =>
|
||
resolve(createGeneratedImageResponse(input, value as never));
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '生成中切换后仍保留位置' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
const originalFrame = screen.getByLabelText('图像生成占位图');
|
||
const originalLeft = Number.parseFloat(
|
||
(originalFrame as HTMLElement).style.left,
|
||
);
|
||
const originalTop = Number.parseFloat(
|
||
(originalFrame as HTMLElement).style.top,
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
expect(screen.getByLabelText('图像生成占位图')).toBeTruthy();
|
||
const characterFrame = screen.getByLabelText('角色生成占位图');
|
||
expect(characterFrame).toBeTruthy();
|
||
|
||
dispatchPointerEvent(
|
||
screen.getByLabelText('图像生成占位图'),
|
||
'pointerdown',
|
||
{
|
||
button: 0,
|
||
pointerId: 1702,
|
||
clientX: 500,
|
||
clientY: 260,
|
||
},
|
||
);
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointermove', {
|
||
pointerId: 1702,
|
||
clientX: 650,
|
||
clientY: 390,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
|
||
pointerId: 1702,
|
||
clientX: 650,
|
||
clientY: 390,
|
||
});
|
||
const movedFrame = screen.getByLabelText('图像生成占位图');
|
||
const movedLeft = Number.parseFloat((movedFrame as HTMLElement).style.left);
|
||
const movedTop = Number.parseFloat((movedFrame as HTMLElement).style.top);
|
||
expect(movedLeft).toBeGreaterThan(originalLeft);
|
||
expect(movedTop).toBeGreaterThan(originalTop);
|
||
|
||
dispatchPointerEvent(characterFrame, 'pointerdown', {
|
||
button: 0,
|
||
pointerId: 1703,
|
||
clientX: 360,
|
||
clientY: 240,
|
||
});
|
||
dispatchPointerEvent(screen.getByLabelText('画布工作区'), 'pointerup', {
|
||
pointerId: 1703,
|
||
clientX: 360,
|
||
clientY: 240,
|
||
});
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalled();
|
||
});
|
||
await act(async () => {
|
||
resolveGeneration({
|
||
imageSrc: 'data:image/png;base64,YXJjaGl2ZWQtbG9naWM=',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '生成中切换后仍保留位置',
|
||
actualPrompt: '生成中切换后仍保留位置',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-archived-generation-1',
|
||
});
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button') as HTMLElement;
|
||
expect(generatedLayer).toBeTruthy();
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
|
||
it('renders editor popup menus outside clipped local containers', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const bottomToolbar = screen.getByRole('toolbar', { name: 'AI画布工具栏' });
|
||
fireEvent.click(
|
||
within(bottomToolbar).getByRole('button', { name: '生成规范' }),
|
||
);
|
||
const specMenu = screen.getByRole('menu', { name: '生成规范类型' });
|
||
|
||
expect(bottomToolbar.contains(specMenu)).toBe(false);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '角色规范' }),
|
||
);
|
||
const referenceRow = characterPanel.querySelector(
|
||
'.image-canvas-editor__reference-strip',
|
||
);
|
||
const sourceMenu = screen.getByRole('menu', { name: '角色规范来源' });
|
||
|
||
if (!referenceRow) {
|
||
throw new Error('角色生成面板应包含参考图区域');
|
||
}
|
||
expect(referenceRow.contains(sourceMenu)).toBe(false);
|
||
expect(sourceMenu.className).toContain('platform-floating-menu--top-start');
|
||
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
|
||
);
|
||
const regularReferenceMenu = screen.getByRole('menu', {
|
||
name: '常规参考图来源',
|
||
});
|
||
expect(referenceRow.contains(regularReferenceMenu)).toBe(false);
|
||
expect(regularReferenceMenu.className).toContain(
|
||
'platform-floating-menu--top-start',
|
||
);
|
||
});
|
||
|
||
it('uses Lovart-style reference tiles in the character generation panel', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||
const specTile = within(characterPanel).getByRole('button', {
|
||
name: '角色规范',
|
||
});
|
||
const uploadTile = within(characterPanel).getByRole('button', {
|
||
name: '上传常规参考图',
|
||
});
|
||
|
||
expect(specTile.className).toContain('image-canvas-editor__reference-chip');
|
||
expect(uploadTile.className).toContain(
|
||
'image-canvas-editor__reference-chip',
|
||
);
|
||
expect(
|
||
specTile.querySelector('.image-canvas-editor__reference-chip-icon'),
|
||
).toBeTruthy();
|
||
expect(
|
||
uploadTile.querySelector('.image-canvas-editor__reference-chip-icon'),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
it('uses one direct text input for icon asset descriptions', async () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await waitFor(() => {
|
||
expect(loadOrCreateRecentEditorProjectMock).toHaveBeenCalled();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
expect(iconPanel.style.width).toBe('');
|
||
expect(
|
||
iconPanel.querySelector('.image-canvas-editor__icon-description-list'),
|
||
).toBeNull();
|
||
expect(
|
||
iconPanel.querySelector('.image-canvas-editor__icon-description-card'),
|
||
).toBeNull();
|
||
expect(
|
||
iconPanel.querySelector('.image-canvas-editor__icon-spec-card'),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.change(
|
||
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
|
||
{ target: { value: '返回按钮\n设置按钮\n提示按钮' } },
|
||
);
|
||
|
||
const iconPrompt = within(iconPanel).getByRole('textbox', {
|
||
name: '素材描述',
|
||
});
|
||
expect(iconPrompt.tagName).toBe('TEXTAREA');
|
||
expect(iconPrompt.className).toContain(
|
||
'image-canvas-editor__generation-prompt',
|
||
);
|
||
expect(iconPrompt.className).not.toContain(
|
||
'image-canvas-editor__generation-prompt--borderless',
|
||
);
|
||
expect(
|
||
within(iconPanel).queryByRole('textbox', { name: '资源名称' }),
|
||
).toBeNull();
|
||
expect(
|
||
within(iconPanel).queryByRole('button', { name: '添加素材描述' }),
|
||
).toBeNull();
|
||
});
|
||
|
||
it('hides the active generation panel and clears image selection after canvas background focus', async () => {
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,Zm9jdXMtY2xlYXI=',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '发光蘑菇角色',
|
||
actualPrompt: '发光蘑菇角色',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-focus-clear-1',
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '发光蘑菇角色' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
const generatedImage = await screen.findByAltText(/画布图片:生成图片/u);
|
||
const generatedLayerButton = generatedImage.closest('button')!;
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
fireEvent.click(generatedLayerButton);
|
||
expect(generatedLayerButton.className).toContain(
|
||
'image-canvas-editor__layer--selected',
|
||
);
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
|
||
button: 0,
|
||
pointerId: 261,
|
||
clientX: 40,
|
||
clientY: 40,
|
||
});
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
expect(generatedLayerButton.className).not.toContain(
|
||
'image-canvas-editor__layer--selected',
|
||
);
|
||
});
|
||
|
||
it('hides a newly created placeholder panel after canvas background focus', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(screen.getByLabelText('画布工作区'), {
|
||
button: 0,
|
||
pointerId: 262,
|
||
clientX: 40,
|
||
clientY: 40,
|
||
});
|
||
|
||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
});
|
||
|
||
it('builds UI spec prompts from two fields and uses 2K landscape generation', async () => {
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,c3BlYy11aQ==',
|
||
width: 2048,
|
||
height: 1152,
|
||
sourceType: 'generated',
|
||
prompt: 'UI规范提示词',
|
||
actualPrompt: 'UI规范提示词',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-spec-ui-1',
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(
|
||
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
|
||
'button',
|
||
{ name: '生成规范' },
|
||
),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '图标规范' }));
|
||
|
||
expect((screen.getByLabelText('玩法设定') as HTMLInputElement).value).toBe(
|
||
'',
|
||
);
|
||
expect((screen.getByLabelText('美术风格') as HTMLInputElement).value).toBe(
|
||
'',
|
||
);
|
||
fireEvent.change(screen.getByLabelText('玩法设定'), {
|
||
target: { value: '消除类派对玩法' },
|
||
});
|
||
fireEvent.change(screen.getByLabelText('美术风格'), {
|
||
target: { value: '糖果玻璃拟物' },
|
||
});
|
||
fireEvent.submit(screen.getByRole('dialog', { name: '生成规范' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
kind: 'spec',
|
||
model: 'gpt-image-2',
|
||
size: '2048x1152',
|
||
aspectRatio: '16:9',
|
||
imageSize: '2K',
|
||
prompt: expect.stringContaining('生成一张完整游戏UI规范汇总设定展板'),
|
||
projectId: 'editor-project-default',
|
||
assetFolderId: 'project',
|
||
assetKind: 'icon-spec',
|
||
}),
|
||
);
|
||
});
|
||
const prompt = generateEditorImageMock.mock.calls[0]?.[0]?.prompt ?? '';
|
||
expect(prompt).toContain('玩法设定:消除类派对玩法');
|
||
expect(prompt).toContain('美术风格:糖果玻璃拟物');
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:图标规范/)).toBeTruthy();
|
||
});
|
||
expect(screen.getAllByText('图标规范').length).toBeGreaterThan(0);
|
||
});
|
||
|
||
it('uses the custom spec prompt without template rewriting', async () => {
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,c3BlYy1jdXN0b20=',
|
||
width: 2048,
|
||
height: 1152,
|
||
sourceType: 'generated',
|
||
prompt: '自定义规范提示词',
|
||
actualPrompt: '自定义规范提示词',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-spec-custom-1',
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(
|
||
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
|
||
'button',
|
||
{ name: '生成规范' },
|
||
),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '自定义规范' }));
|
||
fireEvent.change(screen.getByLabelText('自定义规范提示词'), {
|
||
target: { value: ' 生成一张武器图标规范展板 ' },
|
||
});
|
||
fireEvent.submit(screen.getByRole('dialog', { name: '生成规范' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
kind: 'spec',
|
||
model: 'gpt-image-2',
|
||
size: '2048x1152',
|
||
aspectRatio: '16:9',
|
||
imageSize: '2K',
|
||
prompt: '生成一张武器图标规范展板',
|
||
projectId: 'editor-project-default',
|
||
assetFolderId: 'project',
|
||
assetKind: 'spec',
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:自定义规范/)).toBeTruthy();
|
||
});
|
||
expect(screen.getByText('规范')).toBeTruthy();
|
||
});
|
||
|
||
it('keeps spec reference source menu actions clickable from the portal', async () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(
|
||
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
|
||
'button',
|
||
{ name: '生成规范' },
|
||
),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '角色规范' }));
|
||
|
||
const specDialog = screen.getByRole('dialog', { name: '生成规范' });
|
||
await userEvent.click(
|
||
within(specDialog).getByRole('button', { name: '参考图' }),
|
||
);
|
||
await userEvent.click(
|
||
within(screen.getByRole('menu', { name: '参考图来源' })).getByRole(
|
||
'menuitem',
|
||
{ name: '从画布中选择' },
|
||
),
|
||
);
|
||
|
||
expect(
|
||
screen.getByText('请选择画布中的图片作为参考图,按 Esc 退出'),
|
||
).toBeTruthy();
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:拼图素材').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 190,
|
||
clientX: 120,
|
||
clientY: 120,
|
||
},
|
||
);
|
||
expectReferenceChip(
|
||
screen.getByRole('dialog', { name: '生成规范' }),
|
||
'拼图素材',
|
||
);
|
||
|
||
await userEvent.click(
|
||
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
|
||
'button',
|
||
{ name: '参考图' },
|
||
),
|
||
);
|
||
await userEvent.click(
|
||
within(screen.getByRole('menu', { name: '参考图来源' })).getByRole(
|
||
'menuitem',
|
||
{ name: '上传图片' },
|
||
),
|
||
);
|
||
await userEvent.upload(
|
||
screen.getByLabelText('上传媒体文件'),
|
||
new File(['spec-reference'], '规范上传.png', { type: 'image/png' }),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expectReferenceChip(
|
||
screen.getByRole('dialog', { name: '生成规范' }),
|
||
'参考图',
|
||
);
|
||
});
|
||
});
|
||
|
||
it('supports character generation from a picked canvas spec and numbered references', async () => {
|
||
const characterReferenceProjectLayers = [
|
||
defaultEditorProjectLayers[0]!,
|
||
{
|
||
...defaultEditorProjectLayers[1]!,
|
||
src: 'data:image/png;base64,YmlnLWZpc2gtcmVm',
|
||
},
|
||
{
|
||
layerId: 'layer-character-spec',
|
||
resourceId: 'resource-character-spec',
|
||
title: '角色规范',
|
||
src: 'data:image/png;base64,cm9sZS1zcGVj',
|
||
x: 180,
|
||
y: 80,
|
||
width: 560,
|
||
height: 315,
|
||
originalWidth: 2048,
|
||
originalHeight: 1152,
|
||
zIndex: 3,
|
||
sourceType: 'generated',
|
||
assetKind: 'spec',
|
||
},
|
||
];
|
||
const characterReferenceProjectResources = [
|
||
defaultEditorProjectResources[0]!,
|
||
{
|
||
...defaultEditorProjectResources[1]!,
|
||
imageSrc: 'data:image/png;base64,YmlnLWZpc2gtcmVm',
|
||
},
|
||
{
|
||
resourceId: 'resource-character-spec',
|
||
projectId: 'editor-project-default',
|
||
imageSrc: 'data:image/png;base64,cm9sZS1zcGVj',
|
||
width: 2048,
|
||
height: 1152,
|
||
sourceType: 'generated',
|
||
assetKind: 'spec',
|
||
},
|
||
];
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-default',
|
||
title: '默认项目',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: characterReferenceProjectLayers,
|
||
resources: characterReferenceProjectResources,
|
||
updatedAt: '2026-06-19T00:00:00.000Z',
|
||
});
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,Y2hhcmFjdGVy',
|
||
objectKey:
|
||
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
|
||
assetObjectId: 'asset-object-editor-character-1',
|
||
width: 2048,
|
||
height: 2048,
|
||
sourceType: 'generated',
|
||
prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
|
||
actualPrompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-character-1',
|
||
projectLayers: characterReferenceProjectLayers,
|
||
projectResources: characterReferenceProjectResources,
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await screen.findByAltText('画布图片:角色规范');
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
|
||
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
expect(
|
||
within(characterPanel).getByRole('button', { name: '角色规范' }),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '角色规范' }),
|
||
);
|
||
const specSourceMenu = screen.getByRole('menu', {
|
||
name: '角色规范来源',
|
||
});
|
||
fireEvent.click(
|
||
within(specSourceMenu).getByRole('menuitem', { name: '从画布中选择' }),
|
||
);
|
||
expect(
|
||
screen.getByText('请选择画布中的图片作为角色规范,按 Esc 退出'),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:角色规范').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 170,
|
||
clientX: 120,
|
||
clientY: 120,
|
||
},
|
||
);
|
||
expectReferenceChip(characterPanel, '角色规范');
|
||
expect(
|
||
screen.queryByText('请选择画布中的图片作为角色规范,按 Esc 退出'),
|
||
).toBeNull();
|
||
|
||
const canvasReferenceLayer = screen
|
||
.getByAltText('画布图片:大鱼素材')
|
||
.closest('button')!;
|
||
expect(canvasReferenceLayer.className).not.toContain(
|
||
'image-canvas-editor__layer--selected',
|
||
);
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
|
||
);
|
||
const regularReferenceMenu = screen.getByRole('menu', {
|
||
name: '常规参考图来源',
|
||
});
|
||
fireEvent.click(
|
||
within(regularReferenceMenu).getByRole('menuitem', {
|
||
name: '从画布中选择',
|
||
}),
|
||
);
|
||
expect(
|
||
screen.getByText('请选择画布中的图片作为常规参考图,按 Esc 退出'),
|
||
).toBeTruthy();
|
||
fireEvent.pointerDown(canvasReferenceLayer, {
|
||
button: 0,
|
||
pointerId: 171,
|
||
clientX: 180,
|
||
clientY: 120,
|
||
});
|
||
expect(
|
||
screen.queryByText('请选择画布中的图片作为常规参考图,按 Esc 退出'),
|
||
).toBeNull();
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
expect(canvasReferenceLayer.className).not.toContain(
|
||
'image-canvas-editor__layer--selected',
|
||
);
|
||
expect(within(characterPanel).getByText('大鱼素材')).toBeTruthy();
|
||
|
||
const readyCharacterPanel = screen.getByRole('dialog', {
|
||
name: '生成角色形象',
|
||
});
|
||
fireEvent.change(within(readyCharacterPanel).getByLabelText('角色设定'), {
|
||
target: { value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。' },
|
||
});
|
||
fireEvent.click(
|
||
within(readyCharacterPanel).getByRole('button', { name: '生成' }),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
kind: 'character',
|
||
prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
referenceImageSrcs: [
|
||
'resource-character-spec',
|
||
'resource-big-fish',
|
||
],
|
||
projectId: 'editor-project-default',
|
||
assetFolderId: 'project',
|
||
assetKind: 'character',
|
||
generationInputs: expect.objectContaining({
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '角色设定',
|
||
value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
|
||
}),
|
||
]),
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
const characterGenerationInput = generateEditorImageMock.mock.calls[0]?.[0];
|
||
expect(characterGenerationInput?.generationInputs).toEqual(
|
||
expect.objectContaining({
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '角色设定',
|
||
value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
|
||
}),
|
||
]),
|
||
references: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '角色规范',
|
||
label: '角色规范',
|
||
}),
|
||
expect.objectContaining({
|
||
title: '常规参考图 1',
|
||
label: '大鱼素材',
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
});
|
||
|
||
it('removes the active character generation placeholder with Backspace', async () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
|
||
expect(screen.getByLabelText('角色生成占位图')).toBeTruthy();
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
|
||
await act(async () => {
|
||
fireEvent.keyDown(window, { key: 'Backspace', code: 'Backspace' });
|
||
});
|
||
|
||
expect(screen.queryByLabelText('角色生成占位图')).toBeNull();
|
||
expect(screen.queryByRole('dialog', { name: '生成角色形象' })).toBeNull();
|
||
expect(screen.getByAltText('画布图片:拼图素材')).toBeTruthy();
|
||
});
|
||
|
||
it('opens icon asset generation panel, only picks icon specs, and lays out the generated spritesheet slices', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-icons',
|
||
title: '图标素材画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-plain',
|
||
resourceId: 'resource-plain',
|
||
title: '普通参考图',
|
||
src: 'data:image/png;base64,plain',
|
||
x: 80,
|
||
y: 80,
|
||
width: 120,
|
||
height: 120,
|
||
originalWidth: 512,
|
||
originalHeight: 512,
|
||
zIndex: 10,
|
||
sourceType: 'uploaded',
|
||
},
|
||
{
|
||
layerId: 'layer-icon-spec',
|
||
resourceId: 'resource-icon-spec',
|
||
title: '清爽按钮图标规范',
|
||
src: 'data:image/png;base64,icon-spec',
|
||
x: 240,
|
||
y: 80,
|
||
width: 160,
|
||
height: 120,
|
||
originalWidth: 2048,
|
||
originalHeight: 1152,
|
||
zIndex: 11,
|
||
sourceType: 'generated',
|
||
assetKind: 'icon-spec',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||
});
|
||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,sheet',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
iconImageSrcs: [
|
||
{
|
||
name: '返回按钮',
|
||
imageSrc: 'data:image/png;base64,back',
|
||
width: 128,
|
||
height: 128,
|
||
},
|
||
{
|
||
name: '设置按钮',
|
||
imageSrc: 'data:image/png;base64,settings',
|
||
width: 128,
|
||
height: 128,
|
||
},
|
||
],
|
||
prompt: '图标 prompt',
|
||
actualPrompt: '图标 prompt',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
provider: 'VectorEngine',
|
||
taskId: 'icon-task-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText('画布图片:普通参考图')).toBeTruthy();
|
||
expect(screen.getByAltText('画布图片:清爽按钮图标规范')).toBeTruthy();
|
||
});
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
expect(screen.getByLabelText('图标素材生成占位图')).toBeTruthy();
|
||
expect(
|
||
within(iconPanel).getByRole('button', { name: '图标规范' }),
|
||
).toBeTruthy();
|
||
expect(
|
||
(
|
||
within(iconPanel).getByRole('textbox', {
|
||
name: '素材描述',
|
||
}) as HTMLTextAreaElement
|
||
).value,
|
||
).toBe('');
|
||
|
||
fireEvent.click(
|
||
within(iconPanel).getByRole('button', { name: '图标规范' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
||
expect(
|
||
screen.getByText('请选择画布中的图标规范,按 Esc 退出'),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:普通参考图').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 180,
|
||
clientX: 100,
|
||
clientY: 100,
|
||
},
|
||
);
|
||
expect(
|
||
within(iconPanel).getByRole('button', { name: '图标规范' }),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 181,
|
||
clientX: 260,
|
||
clientY: 100,
|
||
},
|
||
);
|
||
expect(
|
||
within(iconPanel).getByRole('button', { name: '清爽按钮图标规范' }),
|
||
).toBeTruthy();
|
||
expect(
|
||
screen.queryByText('请选择画布中的图标规范,按 Esc 退出'),
|
||
).toBeNull();
|
||
|
||
fireEvent.change(
|
||
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
|
||
{
|
||
target: { value: '返回按钮\n设置按钮' },
|
||
},
|
||
);
|
||
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
referenceImageSrc: 'resource-icon-spec',
|
||
iconDescriptions: ['返回按钮\n设置按钮'],
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
projectId: 'editor-project-icons',
|
||
assetFolderId: 'project',
|
||
generationInputs: expect.objectContaining({
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '素材描述',
|
||
value: '返回按钮\n设置按钮',
|
||
}),
|
||
]),
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/图层缩略图:图标素材图集/u)).toBeTruthy();
|
||
});
|
||
expect(screen.getByAltText('画布图片:返回按钮')).toBeTruthy();
|
||
expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy();
|
||
expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull();
|
||
expect(screen.getByText('图集')).toBeTruthy();
|
||
fireEvent.click(
|
||
screen.getByRole('button', { name: /查看图标素材图集.*图片信息/u }),
|
||
);
|
||
const iconInfoPanel = screen.getByRole('dialog', { name: '图片信息' });
|
||
expect(within(iconInfoPanel).queryByText('Prompt')).toBeNull();
|
||
expect(within(iconInfoPanel).getByText('生成输入')).toBeTruthy();
|
||
expect(within(iconInfoPanel).getByText('素材描述')).toBeTruthy();
|
||
expect(
|
||
within(iconInfoPanel).getByText(
|
||
(_, element) => element?.textContent === '返回按钮\n设置按钮',
|
||
),
|
||
).toBeTruthy();
|
||
expect(within(iconInfoPanel).getByText('图标规范')).toBeTruthy();
|
||
expect(within(iconInfoPanel).getByText('清爽按钮图标规范')).toBeTruthy();
|
||
});
|
||
|
||
it('refreshes the asset library after an icon generation project snapshot is applied', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-icon-library-refresh',
|
||
title: '图标素材刷新画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-icon-spec-refresh',
|
||
resourceId: 'resource-icon-spec-refresh',
|
||
title: '清爽按钮图标规范',
|
||
src: 'data:image/png;base64,icon-spec-refresh',
|
||
x: 240,
|
||
y: 80,
|
||
width: 160,
|
||
height: 120,
|
||
originalWidth: 2048,
|
||
originalHeight: 1152,
|
||
zIndex: 11,
|
||
sourceType: 'generated',
|
||
assetKind: 'icon-spec',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||
});
|
||
loadEditorAssetLibraryMock
|
||
.mockResolvedValueOnce({
|
||
folders: [
|
||
{
|
||
folderId: 'project',
|
||
label: '项目素材',
|
||
sortOrder: 0,
|
||
collapsed: false,
|
||
systemDefault: true,
|
||
},
|
||
],
|
||
assets: [],
|
||
})
|
||
.mockResolvedValueOnce({
|
||
folders: [
|
||
{
|
||
folderId: 'project',
|
||
label: '项目素材',
|
||
sortOrder: 0,
|
||
collapsed: false,
|
||
systemDefault: true,
|
||
},
|
||
],
|
||
assets: [
|
||
createGeneratedAsset({
|
||
assetId: 'asset-icon-spritesheet-refresh',
|
||
folderId: 'project',
|
||
label: '清爽按钮素材图集',
|
||
imageSrc: 'data:image/png;base64,icon-sheet-refresh',
|
||
width: 512,
|
||
height: 512,
|
||
assetKind: 'icon-spritesheet',
|
||
}),
|
||
],
|
||
});
|
||
const spritesheetResource = createGeneratedResource({
|
||
resourceId: 'resource-icon-spritesheet-refresh',
|
||
projectId: 'editor-project-icon-library-refresh',
|
||
imageSrc: 'data:image/png;base64,icon-sheet-refresh',
|
||
width: 512,
|
||
height: 512,
|
||
assetKind: 'icon-spritesheet',
|
||
});
|
||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,icon-sheet-refresh',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
iconImageSrcs: [],
|
||
prompt: '图标 prompt',
|
||
actualPrompt: '图标 prompt',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
provider: 'VectorEngine',
|
||
taskId: 'icon-library-refresh-task',
|
||
priceMudPoints: 3,
|
||
spritesheetResource,
|
||
spritesheetAsset: createGeneratedAsset({
|
||
assetId: 'asset-icon-spritesheet-refresh',
|
||
folderId: 'project',
|
||
label: '清爽按钮素材图集',
|
||
imageSrc: 'data:image/png;base64,icon-sheet-refresh',
|
||
width: 512,
|
||
height: 512,
|
||
assetKind: 'icon-spritesheet',
|
||
}),
|
||
project: {
|
||
projectId: 'editor-project-icon-library-refresh',
|
||
title: '图标素材刷新画布',
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [],
|
||
resources: [spritesheetResource],
|
||
updatedAt: '2026-06-15T00:01:00.000Z',
|
||
},
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await screen.findByAltText('画布图片:清爽按钮图标规范');
|
||
await waitFor(() => {
|
||
expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(1);
|
||
});
|
||
expect(
|
||
screen.queryByRole('button', { name: '添加清爽按钮素材图集' }),
|
||
).toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
fireEvent.click(
|
||
within(iconPanel).getByRole('button', { name: '图标规范' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
||
fireEvent.pointerDown(
|
||
screen.getByAltText('画布图片:清爽按钮图标规范').closest('button')!,
|
||
{
|
||
button: 0,
|
||
pointerId: 182,
|
||
clientX: 260,
|
||
clientY: 100,
|
||
},
|
||
);
|
||
fireEvent.change(
|
||
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
|
||
{
|
||
target: { value: '返回按钮' },
|
||
},
|
||
);
|
||
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalled();
|
||
});
|
||
await waitFor(() => {
|
||
expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2);
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
|
||
expect(
|
||
await screen.findByRole('button', { name: '添加清爽按钮素材图集' }),
|
||
).toBeTruthy();
|
||
});
|
||
|
||
it('reloads project and asset snapshots when a restored queued generation completed after refresh', async () => {
|
||
window.history.replaceState(null, '', '/editor/canvas');
|
||
const projectId = 'editor-project-refresh-completion';
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId,
|
||
title: '刷新恢复画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
itemType: 'generation-dialog',
|
||
layerId: 'generation-dialog:generation-dialog-refresh-character',
|
||
resourceId: 'generation-dialog:generation-dialog-refresh-character',
|
||
dialog: {
|
||
id: 'generation-dialog-refresh-character',
|
||
mode: 'character',
|
||
prompt: '刷新后角色',
|
||
status: 'generating',
|
||
composerOpen: false,
|
||
imageModel: 'gpt-image-2',
|
||
placeholder: {
|
||
x: 240,
|
||
y: 120,
|
||
width: 420,
|
||
height: 560,
|
||
originalWidth: 1024,
|
||
originalHeight: 1536,
|
||
},
|
||
},
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-21T00:00:00.000Z',
|
||
});
|
||
const generatedResource = createGeneratedResource({
|
||
resourceId: 'resource-refresh-character-result',
|
||
projectId,
|
||
imageSrc: 'data:image/png;base64,cmVmcmVzaC1yZXN1bHQ=',
|
||
width: 1024,
|
||
height: 1536,
|
||
assetKind: 'character',
|
||
generationInputs: {
|
||
fields: [{ title: '角色设定', value: '刷新后角色' }],
|
||
references: [],
|
||
},
|
||
});
|
||
loadEditorProjectMock.mockResolvedValueOnce({
|
||
projectId,
|
||
title: '刷新恢复画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-refresh-character-result',
|
||
resourceId: 'resource-refresh-character-result',
|
||
title: '刷新后生成结果',
|
||
x: 240,
|
||
y: 120,
|
||
width: 1024,
|
||
height: 1536,
|
||
originalWidth: 1024,
|
||
originalHeight: 1536,
|
||
zIndex: 12,
|
||
sourceType: 'generated',
|
||
assetKind: 'character',
|
||
},
|
||
{
|
||
itemType: 'generation-dialog',
|
||
layerId: 'generation-dialog:generation-dialog-refresh-character',
|
||
resourceId: 'generation-dialog:generation-dialog-refresh-character',
|
||
dialog: {
|
||
id: 'generation-dialog-refresh-character',
|
||
mode: 'character',
|
||
prompt: '刷新后角色',
|
||
status: 'idle',
|
||
composerOpen: false,
|
||
generatedLayerId: 'layer-refresh-character-result',
|
||
imageModel: 'gpt-image-2',
|
||
placeholder: {
|
||
x: 240,
|
||
y: 120,
|
||
width: 420,
|
||
height: 560,
|
||
originalWidth: 1024,
|
||
originalHeight: 1536,
|
||
},
|
||
},
|
||
},
|
||
],
|
||
resources: [generatedResource],
|
||
updatedAt: '2026-06-21T00:01:00.000Z',
|
||
});
|
||
loadEditorAssetLibraryMock
|
||
.mockResolvedValueOnce({
|
||
folders: [
|
||
{
|
||
folderId: 'project',
|
||
label: '项目素材',
|
||
sortOrder: 0,
|
||
collapsed: false,
|
||
systemDefault: true,
|
||
},
|
||
],
|
||
assets: [],
|
||
})
|
||
.mockResolvedValueOnce({
|
||
folders: [
|
||
{
|
||
folderId: 'project',
|
||
label: '项目素材',
|
||
sortOrder: 0,
|
||
collapsed: false,
|
||
systemDefault: true,
|
||
},
|
||
],
|
||
assets: [
|
||
createGeneratedAsset({
|
||
assetId: 'asset-refresh-character-result',
|
||
folderId: 'project',
|
||
label: '刷新后生成结果素材',
|
||
imageSrc: 'data:image/png;base64,cmVmcmVzaC1yZXN1bHQ=',
|
||
width: 1024,
|
||
height: 1536,
|
||
assetKind: 'character',
|
||
}),
|
||
],
|
||
});
|
||
const completedTask = createExternalGenerationTask({
|
||
jobId: 'task-refresh-character-result',
|
||
sourceEntityId: projectId,
|
||
requestPrompt: '刷新后角色',
|
||
status: 'completed',
|
||
progress: 100,
|
||
phaseDetail: '生成已完成。',
|
||
warning: '图集已生成,但自动拆分未完成:连通域数量不足',
|
||
completedAt: '2026-06-21T00:01:00.000Z',
|
||
updatedAt: '2026-06-21T00:01:00.000Z',
|
||
updatedAtMicros: 2,
|
||
});
|
||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||
const requestUrl =
|
||
typeof input === 'string'
|
||
? input
|
||
: input instanceof URL
|
||
? input.href
|
||
: input.url;
|
||
const url = new URL(requestUrl, 'http://localhost');
|
||
if (url.pathname === '/api/runtime/external-generation/jobs') {
|
||
const statuses = url.searchParams.get('statuses') ?? '';
|
||
return new Response(
|
||
JSON.stringify({
|
||
overview: {
|
||
pendingCount: 0,
|
||
runningCount: 0,
|
||
unacknowledgedTerminalCount: 1,
|
||
updatedAtMicros: 2,
|
||
},
|
||
tasks: statuses.includes('completed') ? [completedTask] : [],
|
||
}),
|
||
{
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
},
|
||
);
|
||
}
|
||
return new Response(null, { status: 404 });
|
||
});
|
||
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
await waitFor(() => {
|
||
expect(loadEditorProjectMock).toHaveBeenCalledWith(projectId);
|
||
});
|
||
expect(await screen.findByAltText('画布图片:刷新后生成结果')).toBeTruthy();
|
||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||
'图集已生成,但自动拆分未完成:连通域数量不足',
|
||
);
|
||
await waitFor(() => {
|
||
expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2);
|
||
});
|
||
expect(createEditorAssetMock).not.toHaveBeenCalled();
|
||
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
|
||
});
|
||
|
||
it('allows icon asset generation to reference icon specs created from the renamed spec entry', async () => {
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
|
||
width: 2048,
|
||
height: 1152,
|
||
sourceType: 'generated',
|
||
prompt: '图标规范提示词',
|
||
actualPrompt: '图标规范提示词',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-icon-spec-renamed-1',
|
||
}),
|
||
);
|
||
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,cmVuYW1lZC1zaGVldA==',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
iconImageSrcs: [],
|
||
prompt: '图标 prompt',
|
||
actualPrompt: '图标 prompt',
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
provider: 'VectorEngine',
|
||
taskId: 'icon-task-renamed-spec-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(
|
||
within(screen.getByRole('toolbar', { name: 'AI画布工具栏' })).getByRole(
|
||
'button',
|
||
{ name: '生成规范' },
|
||
),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '图标规范' }));
|
||
const specDialog = screen.getByRole('dialog', { name: '生成规范' });
|
||
fireEvent.change(within(specDialog).getByLabelText('玩法设定'), {
|
||
target: { value: '背包整理玩法' },
|
||
});
|
||
fireEvent.click(
|
||
within(specDialog).getByRole('button', { name: '提交生成规范' }),
|
||
);
|
||
|
||
const generatedSpec = await screen.findByAltText(/画布图片:图标规范/u);
|
||
expect(generateEditorImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
kind: 'spec',
|
||
prompt: expect.stringContaining('背包整理玩法'),
|
||
projectId: 'editor-project-default',
|
||
assetFolderId: 'project',
|
||
assetKind: 'icon-spec',
|
||
generationInputs: expect.objectContaining({
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '玩法设定',
|
||
value: '背包整理玩法',
|
||
}),
|
||
]),
|
||
}),
|
||
}),
|
||
);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图标素材' }));
|
||
const iconPanel = screen.getByRole('dialog', { name: '生成图标素材' });
|
||
fireEvent.click(
|
||
within(iconPanel).getByRole('button', { name: '图标规范' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
||
fireEvent.pointerDown(generatedSpec.closest('button')!, {
|
||
button: 0,
|
||
pointerId: 182,
|
||
clientX: 260,
|
||
clientY: 100,
|
||
});
|
||
|
||
expect(
|
||
within(iconPanel).getByRole('button', { name: /^图标规范 \d+$/u }),
|
||
).toBeTruthy();
|
||
expect(
|
||
screen.queryByText('请选择画布中的图标规范,按 Esc 退出'),
|
||
).toBeNull();
|
||
|
||
fireEvent.change(
|
||
within(iconPanel).getByRole('textbox', { name: '素材描述' }),
|
||
{
|
||
target: { value: '背包按钮' },
|
||
},
|
||
);
|
||
fireEvent.click(within(iconPanel).getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
referenceImageSrc: 'resource-editor-icon-spec-renamed-1',
|
||
iconDescriptions: ['背包按钮'],
|
||
model: 'gemini-3.1-flash-image-preview',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
projectId: 'editor-project-default',
|
||
assetFolderId: 'project',
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/图层缩略图:图标素材图集/u)).toBeTruthy();
|
||
});
|
||
expect(screen.queryByAltText('画布图片:背包按钮')).toBeNull();
|
||
});
|
||
|
||
it('exits character generation canvas picking with Escape', () => {
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
|
||
const characterPanel = screen.getByRole('dialog', { name: '生成角色形象' });
|
||
fireEvent.click(
|
||
within(characterPanel).getByRole('button', { name: '角色规范' }),
|
||
);
|
||
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
||
|
||
expect(
|
||
screen.getByText('请选择画布中的图片作为角色规范,按 Esc 退出'),
|
||
).toBeTruthy();
|
||
|
||
fireEvent.keyDown(window, { key: 'Escape' });
|
||
|
||
expect(
|
||
screen.queryByText('请选择画布中的图片作为角色规范,按 Esc 退出'),
|
||
).toBeNull();
|
||
expect(screen.getByRole('dialog', { name: '生成角色形象' })).toBeTruthy();
|
||
});
|
||
|
||
it('only exposes character animation generation for character layers and submits the panel payload', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-character-animation',
|
||
title: '角色动画画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-character',
|
||
resourceId: 'resource-character',
|
||
title: '市场老妇人',
|
||
src: 'data:image/png;base64,character',
|
||
x: 160,
|
||
y: 140,
|
||
width: 320,
|
||
height: 320,
|
||
originalWidth: 1024,
|
||
originalHeight: 1024,
|
||
zIndex: 2,
|
||
sourceType: 'generated',
|
||
objectKey:
|
||
'generated-character-drafts/editor/character-images/source/image.png',
|
||
assetKind: 'character',
|
||
},
|
||
{
|
||
layerId: 'layer-prop',
|
||
resourceId: 'resource-prop',
|
||
title: '普通道具',
|
||
src: 'data:image/png;base64,prop',
|
||
x: 520,
|
||
y: 140,
|
||
width: 280,
|
||
height: 220,
|
||
originalWidth: 700,
|
||
originalHeight: 550,
|
||
zIndex: 1,
|
||
sourceType: 'uploaded',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-15T00:00:00.000Z',
|
||
});
|
||
generateEditorCharacterAnimationMock.mockResolvedValueOnce({
|
||
taskId: 'character-animation-task-1',
|
||
model: 'seedance2.0-fast',
|
||
prompt: '生成游戏角色动画\n动作描述:\n待机',
|
||
previewVideoPath: '/generated-character-drafts/editor/preview.mp4',
|
||
frames: Array.from({ length: 48 }, (_, index) => ({
|
||
frameIndex: index + 1,
|
||
imageSrc: `/generated-character-drafts/editor/frame${index + 1}.png`,
|
||
width: 1024,
|
||
height: 1024,
|
||
})),
|
||
frameCount: 48,
|
||
durationSeconds: 6,
|
||
fps: 8,
|
||
});
|
||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||
const requestUrl =
|
||
typeof input === 'string'
|
||
? input
|
||
: input instanceof URL
|
||
? input.href
|
||
: input.url;
|
||
const url = new URL(requestUrl, 'http://localhost');
|
||
if (url.pathname === '/api/assets/read-url') {
|
||
const legacyPublicPath =
|
||
url.searchParams.get('legacyPublicPath') ?? 'generated-frame.png';
|
||
return new Response(
|
||
JSON.stringify({
|
||
signedUrl: `https://assets.test${legacyPublicPath}`,
|
||
expiresAt: '2099-01-01T00:00:00.000Z',
|
||
}),
|
||
{
|
||
status: 200,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
},
|
||
);
|
||
}
|
||
return new Response(null, { status: 404 });
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const propLayer = await screen.findByAltText('画布图片:普通道具');
|
||
fireEvent.click(propLayer.closest('button')!);
|
||
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
|
||
fireEvent.contextMenu(propLayer.closest('button')!, {
|
||
clientX: 220,
|
||
clientY: 180,
|
||
});
|
||
expect(screen.queryByRole('menuitem', { name: '生成动画' })).toBeNull();
|
||
|
||
// 项目封面快照会在图层落位后触发一次重渲染,同步查询可能正好落进该窗口;
|
||
// 这里用异步查询容忍这次重渲染,不改变断言语义。
|
||
const characterLayer = await screen.findByAltText('画布图片:市场老妇人');
|
||
fireEvent.click(characterLayer.closest('button')!);
|
||
expect(screen.getByText('角色')).toBeTruthy();
|
||
expect(screen.getByRole('button', { name: '生成动画' })).toBeTruthy();
|
||
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
|
||
expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
|
||
expect(
|
||
screen.queryByRole('dialog', { name: '角色动画生成面板' }),
|
||
).toBeNull();
|
||
fireEvent.contextMenu(characterLayer.closest('button')!, {
|
||
clientX: 260,
|
||
clientY: 220,
|
||
});
|
||
expect(screen.getByRole('menuitem', { name: '生成动画' })).toBeTruthy();
|
||
|
||
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
|
||
expect(screen.queryByRole('dialog', { name: '改造图片' })).toBeNull();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成动画' }));
|
||
expect(
|
||
screen.getByRole('button', { name: '角色动作生成占位图' }),
|
||
).toBeTruthy();
|
||
expect(screen.getByText('Action Generator')).toBeTruthy();
|
||
expect(screen.getByText('动作')).toBeTruthy();
|
||
const panel = screen.getByRole('dialog', { name: '角色动画生成面板' });
|
||
expect(panel.className).toContain(
|
||
'image-canvas-editor__generation-composer--character-animation',
|
||
);
|
||
expect(
|
||
within(panel).getByRole('button', {
|
||
name: '动画参数 同图尺寸 · 4秒 · 480p',
|
||
}),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(panel).getByRole('button', { name: '生成40泥点' }),
|
||
).toBeTruthy();
|
||
for (const actionLabel of [
|
||
'待机',
|
||
'行走',
|
||
'奔跑',
|
||
'跳跃',
|
||
'攻击',
|
||
'受击',
|
||
'倒下',
|
||
]) {
|
||
expect(
|
||
within(panel).getByRole('button', { name: actionLabel }),
|
||
).toBeTruthy();
|
||
}
|
||
fireEvent.click(within(panel).getByRole('button', { name: '待机' }));
|
||
expect(
|
||
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
|
||
).toContain('待机');
|
||
const longPrompt = '走'.repeat(4100);
|
||
fireEvent.change(within(panel).getByLabelText('动画描述'), {
|
||
target: { value: longPrompt },
|
||
});
|
||
expect(
|
||
(within(panel).getByLabelText('动画描述') as HTMLTextAreaElement).value,
|
||
).toHaveLength(4000);
|
||
const precisePrompt =
|
||
'The elderly market woman gently shifts weight while the basket sways.';
|
||
fireEvent.change(within(panel).getByLabelText('动画描述'), {
|
||
target: { value: precisePrompt },
|
||
});
|
||
expect(
|
||
within(panel).getByLabelText(`生成文本:${precisePrompt}`),
|
||
).toBeTruthy();
|
||
fireEvent.click(
|
||
within(panel).getByRole('button', {
|
||
name: '动画参数 同图尺寸 · 4秒 · 480p',
|
||
}),
|
||
);
|
||
const animationMenu = screen.getByRole('menu', { name: '动画参数选项' });
|
||
fireEvent.click(
|
||
within(animationMenu).getByRole('button', { name: '清晰度 720p' }),
|
||
);
|
||
fireEvent.click(
|
||
within(animationMenu).getByRole('button', { name: '比例 16:9' }),
|
||
);
|
||
fireEvent.click(
|
||
within(animationMenu).getByRole('button', { name: '时长 48帧·6秒' }),
|
||
);
|
||
expect(screen.getByRole('menu', { name: '动画参数选项' })).toBeTruthy();
|
||
expect(
|
||
within(panel).getByRole('button', {
|
||
name: '动画参数 16:9 · 6秒 · 720p',
|
||
}),
|
||
).toBeTruthy();
|
||
fireEvent.click(within(panel).getByRole('button', { name: '生成120泥点' }));
|
||
|
||
await waitFor(() => {
|
||
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
sourceLayerId: 'layer-character',
|
||
sourceImageSrc:
|
||
'generated-character-drafts/editor/character-images/source/image.png',
|
||
sourceWidth: 1024,
|
||
sourceHeight: 1024,
|
||
resolution: '720p',
|
||
ratio: '16:9',
|
||
frameCount: 48,
|
||
durationSeconds: 6,
|
||
model: 'seedance2.0-fast',
|
||
}),
|
||
);
|
||
});
|
||
expect(
|
||
generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText,
|
||
).toBe(precisePrompt);
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText('画布序列帧:角色动作')).toBeTruthy();
|
||
});
|
||
const actionFrame = screen.getByAltText('画布序列帧:角色动作');
|
||
expect(actionFrame.tagName.toLowerCase()).toBe('img');
|
||
await waitFor(() => {
|
||
expect(actionFrame.getAttribute('src')).toBe(
|
||
'https://assets.test/generated-character-drafts/editor/frame1.png',
|
||
);
|
||
});
|
||
// 中文注释:分子不能钉死。序列帧图层默认自动播放(frames.length > 1 即 isPlaying),
|
||
// 48 帧 6 秒算出 125ms 的真实 setInterval,而这里用的是真实定时器下的 waitFor——
|
||
// 上一条 waitFor 在 CI 上多轮询一次就已经越过 125ms,计数器变成 2/48。要断言的是
|
||
// 「按序列帧播放器渲染、总帧数 48」(下一行排除视频元素),分母才是这条断言的意义。
|
||
expect(screen.getByText(/^\d+\/48$/u)).toBeTruthy();
|
||
expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull();
|
||
expect(screen.getByText('动作')).toBeTruthy();
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '选择角色动作' }));
|
||
fireEvent.click(screen.getByRole('button', { name: '改造' }));
|
||
|
||
const remodelPanel = screen.getByRole('dialog', {
|
||
name: '角色动画生成面板',
|
||
});
|
||
expect(
|
||
(within(remodelPanel).getByLabelText('动画描述') as HTMLTextAreaElement)
|
||
.value,
|
||
).toBe(precisePrompt);
|
||
});
|
||
|
||
it('extracts UI design assets from the floating toolbar and keeps the spritesheet on canvas', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-ui-extract',
|
||
title: 'UI素材提取画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-ui-design',
|
||
resourceId: 'resource-ui-design',
|
||
title: '战斗UI设计图',
|
||
src: 'data:image/png;base64,dWktZGVzaWdu',
|
||
x: 120,
|
||
y: 140,
|
||
width: 560,
|
||
height: 315,
|
||
originalWidth: 2048,
|
||
originalHeight: 1152,
|
||
zIndex: 2,
|
||
sourceType: 'generated',
|
||
prompt: '战斗UI',
|
||
actualPrompt: '战斗UI',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'ui-design-task-1',
|
||
assetKind: 'ui-design',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-18T00:00:00.000Z',
|
||
});
|
||
extractEditorUiDesignAssetsMock.mockResolvedValueOnce({
|
||
spritesheetImageSrc: 'data:image/png;base64,dWktc2hlZXQ=',
|
||
spritesheetWidth: 512,
|
||
spritesheetHeight: 512,
|
||
iconImageSrcs: [
|
||
{
|
||
name: '素材 1',
|
||
imageSrc: 'data:image/png;base64,YXNzZXQtMQ==',
|
||
width: 96,
|
||
height: 96,
|
||
},
|
||
],
|
||
prompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
actualPrompt:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'ui-extract-task-1',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const uiDesignImage = await screen.findByAltText('画布图片:战斗UI设计图');
|
||
fireEvent.pointerDown(uiDesignImage.closest('button')!, {
|
||
button: 0,
|
||
pointerId: 156,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
||
pointerId: 156,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '提取素材' }));
|
||
|
||
const extractionToolbar = await screen.findByRole('toolbar', {
|
||
name: 'UI素材框选工具',
|
||
});
|
||
expect(extractionToolbar).toBeTruthy();
|
||
const extractionPanel = await screen.findByRole('dialog', {
|
||
name: 'UI素材提取',
|
||
});
|
||
expect(
|
||
within(extractionPanel).getByRole<HTMLButtonElement>('button', {
|
||
name: '提取',
|
||
}).disabled,
|
||
).toBe(true);
|
||
expect(extractEditorUiDesignAssetsMock).not.toHaveBeenCalled();
|
||
|
||
const extractionCanvas = screen.getByRole('application', {
|
||
name: 'UI素材框选画布',
|
||
});
|
||
vi.spyOn(extractionCanvas, 'getBoundingClientRect').mockReturnValue({
|
||
x: 120,
|
||
y: 140,
|
||
left: 120,
|
||
top: 140,
|
||
right: 680,
|
||
bottom: 455,
|
||
width: 560,
|
||
height: 315,
|
||
toJSON: () => ({}),
|
||
} as DOMRect);
|
||
dispatchPointerEvent(extractionCanvas, 'pointerdown', {
|
||
pointerId: 201,
|
||
clientX: 140,
|
||
clientY: 160,
|
||
});
|
||
dispatchPointerEvent(extractionCanvas, 'pointermove', {
|
||
pointerId: 201,
|
||
clientX: 300,
|
||
clientY: 260,
|
||
});
|
||
dispatchPointerEvent(extractionCanvas, 'pointerup', {
|
||
pointerId: 201,
|
||
clientX: 300,
|
||
clientY: 260,
|
||
});
|
||
|
||
expect(
|
||
within(
|
||
screen.getByRole('dialog', { name: 'UI素材提取' }),
|
||
).getByRole<HTMLButtonElement>('button', {
|
||
name: '提取',
|
||
}).disabled,
|
||
).toBe(false);
|
||
fireEvent.click(
|
||
within(screen.getByRole('dialog', { name: 'UI素材提取' })).getByRole(
|
||
'button',
|
||
{ name: '提取' },
|
||
),
|
||
);
|
||
|
||
await waitFor(() => {
|
||
expect(renderUiDesignAssetExtractionMarkedImageMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
source: 'data:image/png;base64,dWktZGVzaWdu',
|
||
marks: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
tool: 'rect',
|
||
}),
|
||
]),
|
||
}),
|
||
);
|
||
});
|
||
await waitFor(() => {
|
||
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
|
||
expect.objectContaining({
|
||
sourceImageSrc:
|
||
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
|
||
screenColor: 'auto',
|
||
segModel: 'birefnet',
|
||
aspectRatio: '1:1',
|
||
imageSize: '1K',
|
||
projectId: 'editor-project-ui-extract',
|
||
assetFolderId: 'project',
|
||
spritesheetLabel: '战斗UI设计图 素材图集',
|
||
generationInputs: expect.objectContaining({
|
||
fields: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: '提取提示词',
|
||
value:
|
||
'仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用所选单一纯色抠图背景。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。',
|
||
}),
|
||
]),
|
||
references: expect.arrayContaining([
|
||
expect.objectContaining({
|
||
title: 'UI设计图',
|
||
label: '战斗UI设计图',
|
||
}),
|
||
]),
|
||
}),
|
||
}),
|
||
);
|
||
});
|
||
expect(
|
||
await screen.findByAltText('画布图片:战斗UI设计图 素材图集'),
|
||
).toBeTruthy();
|
||
expect(screen.getByAltText('画布图片:素材 1')).toBeTruthy();
|
||
expect(screen.getByText('图集')).toBeTruthy();
|
||
});
|
||
|
||
it('closes UI design asset extraction when clicking outside the source and panel', async () => {
|
||
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
|
||
projectId: 'editor-project-ui-extract-dismiss',
|
||
title: 'UI素材提取外部关闭画布',
|
||
canvas: { revision: 0 },
|
||
viewport: { x: 0, y: 0, scale: 1 },
|
||
layers: [
|
||
{
|
||
layerId: 'layer-ui-design-dismiss',
|
||
resourceId: 'resource-ui-design-dismiss',
|
||
title: '背包UI设计图',
|
||
src: 'data:image/png;base64,dWktZGVzaWdu',
|
||
x: 120,
|
||
y: 140,
|
||
width: 560,
|
||
height: 315,
|
||
originalWidth: 2048,
|
||
originalHeight: 1152,
|
||
zIndex: 2,
|
||
sourceType: 'generated',
|
||
prompt: '背包UI',
|
||
actualPrompt: '背包UI',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'ui-design-dismiss-task-1',
|
||
assetKind: 'ui-design',
|
||
},
|
||
],
|
||
resources: [],
|
||
updatedAt: '2026-06-18T00:00:00.000Z',
|
||
});
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
const uiDesignImage = await screen.findByAltText('画布图片:背包UI设计图');
|
||
fireEvent.pointerDown(uiDesignImage.closest('button')!, {
|
||
button: 0,
|
||
pointerId: 256,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
|
||
pointerId: 256,
|
||
clientX: 180,
|
||
clientY: 180,
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '提取素材' }));
|
||
|
||
expect(
|
||
await screen.findByRole('dialog', { name: 'UI素材提取' }),
|
||
).toBeTruthy();
|
||
fireEvent.pointerDown(screen.getByTestId('image-canvas-world'), {
|
||
button: 0,
|
||
pointerId: 257,
|
||
clientX: 24,
|
||
clientY: 24,
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(screen.queryByRole('dialog', { name: 'UI素材提取' })).toBeNull();
|
||
});
|
||
});
|
||
|
||
it('opens generated image info from the corner button', async () => {
|
||
generateEditorImageMock.mockImplementationOnce(async (input) =>
|
||
createGeneratedImageResponse(input, {
|
||
imageSrc: 'data:image/png;base64,ZmFrZS1pbWFnZQ==',
|
||
width: 1024,
|
||
height: 1024,
|
||
sourceType: 'generated',
|
||
prompt: '一张可修改的生成图',
|
||
actualPrompt: '一张可修改的生成图',
|
||
model: 'gpt-image-2',
|
||
provider: 'VectorEngine',
|
||
taskId: 'editor-real-task-2',
|
||
}),
|
||
);
|
||
render(<ImageCanvasEditorView />);
|
||
|
||
fireEvent.click(screen.getByRole('button', { name: '生成图片' }));
|
||
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||
target: { value: '一张可修改的生成图' },
|
||
});
|
||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||
|
||
await waitFor(() => {
|
||
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
|
||
});
|
||
const generatedLayer = screen
|
||
.getByAltText(/画布图片:生成图片/)
|
||
.closest('button') as HTMLElement;
|
||
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
|
||
expect(Number.parseFloat(generatedLayer.style.height)).toBe(1024);
|
||
expect(screen.queryByRole('dialog', { name: '生成图片' })).toBeNull();
|
||
|
||
const metadataCornerButton = screen.getAllByRole('button', {
|
||
name: /查看生成图片 .*图片信息/,
|
||
})[0];
|
||
if (!metadataCornerButton) {
|
||
throw new Error('metadata corner button should exist');
|
||
}
|
||
expect(metadataCornerButton.className).toContain(
|
||
'image-canvas-editor__metadata-corner',
|
||
);
|
||
fireEvent.click(metadataCornerButton);
|
||
|
||
const metadataDialog = screen.getByRole('dialog', { name: '图片信息' });
|
||
expect(metadataDialog).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('图片类型')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('生成图片')).toBeTruthy();
|
||
expect(within(metadataDialog).queryByText('Prompt')).toBeNull();
|
||
expect(
|
||
within(metadataDialog).queryByRole('button', { name: '复制Prompt' }),
|
||
).toBeNull();
|
||
expect(within(metadataDialog).getByText('生成输入')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('生成提示词')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('一张可修改的生成图')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('Model')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('gpt-image-2')).toBeTruthy();
|
||
expect(within(metadataDialog).queryByText('Size')).toBeNull();
|
||
expect(within(metadataDialog).getByText('Resolution')).toBeTruthy();
|
||
expect(within(metadataDialog).getByText('1024 x 1024 px')).toBeTruthy();
|
||
|
||
expect(
|
||
screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
|
||
).toBeTruthy();
|
||
});
|
||
});
|