Files
Genarrative/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
T
lhk229 8f19964e3f
Project CI / Repository checks (push) Successful in 1m0s
Project CI / Backend tests (push) Successful in 3m52s
Project CI / Native shell tests (push) Successful in 13m8s
Project CI / Frontend tests (push) Successful in 2m43s
加入手动完美像素入口;像素艺术勾选时自动补提示词约束 (#124)
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>
2026-08-05 21:28:40 +08:00

3726 lines
128 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
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();
});
});