Files
Genarrative/src/components/image-editor/ImageCanvasEditorGenerationIntegration.test.tsx
T
JenkenB 7830d201c4 Server-side billing for editor generation
Move editor generation pricing to backend: remove priceMudPoints from public request schemas and docs, require backend to compute model prices and perform pre-charge via execute_billable_asset_operation_with_cost. Update character animation and video handlers to use authenticated owner IDs and wrap generation requests in billing; remove client-side price validation. Adjust OpenAPI, project-memory docs, pricing management docs, and related tests to reflect that front-end only displays prices and must not submit priceMudPoints. Also document new user registration reward and wallet refund outbox behavior. Code and test changes applied across api-server handlers, shared contracts, and image-editor client/tests.
2026-06-22 23:11:23 +08:00

3651 lines
124 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 {
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());
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,
}));
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,marked-ui-design',
);
});
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 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 createGeneratedImageResponse = (
input: {
projectId?: string | null;
assetFolderId?: string | null;
assetLabel?: string | null;
assetKind?: 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,
}: {
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>;
},
) => {
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;
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: '默认项目',
viewport: { x: 0, y: 0, scale: 1 },
resources: [
...defaultEditorProjectResources,
resource ??
createGeneratedResource({
resourceId,
projectId: input.projectId,
imageSrc,
width,
height,
assetKind: input.assetKind ?? undefined,
generationInputs: input.generationInputs,
objectKey,
assetObjectId,
}),
],
layers: [
{
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,
},
{
itemType: 'generation-dialog',
layerId: `generation-dialog:${canvasCompletion.dialogId}`,
resourceId: `generation-dialog:${canvasCompletion.dialogId}`,
dialog: {
id: canvasCompletion.dialogId,
mode: 'generate',
prompt,
status: 'idle',
composerOpen: true,
generatedLayerId: layerId,
placeholder,
},
},
...defaultEditorProjectLayers,
],
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: '已生成画布',
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 publication material composer fields and references from saved generator snapshots', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-publication-dialog',
title: '宣发画布',
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('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');
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(
within(generateDialog).getByRole('button', { name: '生成' }),
);
expect(screen.getByRole('status').textContent).toContain('生成中');
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '一张明亮的拼图主视觉',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-default',
assetFolderId: 'project',
assetLabel: expect.stringMatching(/生成图片/u),
}),
);
await waitFor(() => {
expect(screen.getByAltText(/画布图片:生成图片/)).toBeTruthy();
});
expect(createEditorAssetMock).not.toHaveBeenCalled();
expect(createEditorProjectResourceMock).not.toHaveBeenCalled();
const generatedAssetButton =
screen.queryByRole('button', { name: /添加生成图片/u }) ??
(() => {
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
return screen.getByRole('button', { name: /添加生成图片/u });
})();
expect(generatedAssetButton).toBeTruthy();
const generatedLayer = screen
.getByAltText(/画布图片:生成图片/)
.closest('button')!;
const anchoredGenerateDialog = screen.getByRole('dialog', {
name: '生成图片',
});
expect(anchoredGenerateDialog).toBeTruthy();
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')!;
const anchoredGenerateDialog = screen.getByRole('dialog', {
name: '生成图片',
});
expect(anchoredGenerateDialog).toBeTruthy();
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: '生成图片' }));
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',
);
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 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('hides quick edit and redraw panels when clicking the canvas outside generation controls', () => {
render(<ImageCanvasEditorView />);
const viewport = screen.getByLabelText('画布工作区');
const sourceLayer = screen
.getByAltText('画布图片:拼图素材')
.closest('button')!;
fireEvent.pointerDown(sourceLayer, {
button: 0,
pointerId: 631,
clientX: 120,
clientY: 120,
});
fireEvent.pointerUp(viewport, {
pointerId: 631,
clientX: 120,
clientY: 120,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
expect(screen.getByRole('dialog', { name: '快速编辑图片' })).toBeTruthy();
expect(
screen.queryByRole('button', { name: '关闭快速编辑图片' }),
).toBeNull();
fireEvent.pointerDown(viewport, {
button: 0,
pointerId: 632,
clientX: 260,
clientY: 180,
});
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
fireEvent.pointerDown(sourceLayer, {
button: 0,
pointerId: 633,
clientX: 120,
clientY: 120,
});
fireEvent.pointerUp(viewport, {
pointerId: 633,
clientX: 120,
clientY: 120,
});
fireEvent.click(screen.getByRole('button', { name: '改造' }));
expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '关闭重绘图片' })).toBeNull();
fireEvent.pointerDown(viewport, {
button: 0,
pointerId: 634,
clientX: 280,
clientY: 190,
});
expect(screen.queryByRole('dialog', { name: '重绘图片' })).toBeNull();
});
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: '生成图片' }));
fireEvent.change(screen.getByLabelText('生成提示词'), {
target: { value: '生成中的普通图片' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
},
dialogName: '生成图片',
frameLabel: '图像生成占位图',
},
{
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: '规范生成占位图',
},
{
open: () => {
fireEvent.click(screen.getByRole('button', { name: '生成角色形象' }));
fireEvent.change(screen.getByLabelText('角色设定'), {
target: { value: '生成中的角色形象' },
});
fireEvent.click(screen.getByRole('button', { name: '生成' }));
},
dialogName: '生成角色形象',
frameLabel: '角色生成占位图',
},
] 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(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: '图标素材生成中画布',
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.click(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'button',
{ name: '生成' },
),
);
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(
'战棋类RPG玩法',
);
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.click(
within(specDialog).getByRole('button', { name: '提交生成规范' }),
);
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();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(/角色规范/u),
}),
]),
}),
);
});
});
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 () => {
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',
}),
);
generateEditorIconSpritesheetMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet-gpt-model',
spritesheetWidth: 1024,
spritesheetHeight: 1024,
iconImageSrcs: [
{
name: '返回按钮',
imageSrc: 'data:image/png;base64,back',
width: 128,
height: 128,
},
],
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',
}),
);
});
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: '上传图片' }));
await userEvent.upload(
screen.getByLabelText('上传媒体文件'),
new File(['icon-spec'], '图标规范.png', { type: 'image/png' }),
);
await waitFor(() => {
expect(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'button',
{ name: '图标规范.png' },
),
).toBeTruthy();
});
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'button',
{ name: '生成' },
),
);
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
}),
);
});
});
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 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提示按钮' } },
);
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(1);
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(generatedLayerButton.className).toContain(
'image-canvas-editor__layer--selected',
);
expect(screen.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
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.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
{ name: '提交生成规范' },
),
);
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.click(
within(screen.getByRole('dialog', { name: '生成规范' })).getByRole(
'button',
{ name: '提交生成规范' },
),
);
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,
},
);
expect(
within(screen.getByRole('dialog', { name: '生成规范' })).getByText(
'拼图素材',
),
).toBeTruthy();
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(() => {
expect(
within(screen.getByRole('dialog', { name: '生成规范' })).getByText(
'规范上传.png',
),
).toBeTruthy();
});
});
it('supports character generation from a picked canvas spec and numbered references', async () => {
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',
}),
);
render(<ImageCanvasEditorView />);
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,
},
);
expect(within(characterPanel).getByText('拼图素材')).toBeTruthy();
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();
fireEvent.click(
within(characterPanel).getByRole('button', { name: '上传常规参考图' }),
);
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
await userEvent.upload(
screen.getByLabelText('上传媒体文件'),
new File(['reference'], '常规参考.png', { type: 'image/png' }),
);
await waitFor(() => {
expect(within(characterPanel).getByText('常规参考.png')).toBeTruthy();
});
fireEvent.change(within(characterPanel).getByLabelText('角色设定'), {
target: { value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。' },
});
fireEvent.click(
within(characterPanel).getByRole('button', { name: '生成' }),
);
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'character',
prompt: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
referenceImageSrcs: [
'/creation-type-references/puzzle.webp',
'/creation-type-references/big-fish.webp',
expect.stringMatching(/^data:image\/png;base64,/u),
],
projectId: 'editor-project-default',
assetFolderId: 'project',
assetKind: 'character',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '角色设定',
value: '银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
}),
]),
}),
}),
);
await waitFor(() => {
expect(screen.getByAltText(/画布图片:角色形象/u)).toBeTruthy();
});
expect(screen.getByText('角色')).toBeTruthy();
fireEvent.click(
screen.getAllByRole('button', {
name: /查看角色形象 .*图片信息/u,
})[0]!,
);
const characterInfoPanel = screen.getByRole('dialog', {
name: '图片信息',
});
expect(within(characterInfoPanel).queryByText('Prompt')).toBeNull();
expect(within(characterInfoPanel).getByText('生成输入')).toBeTruthy();
expect(within(characterInfoPanel).getByText('角色设定')).toBeTruthy();
expect(
within(characterInfoPanel).getByText(
'银发游侠,蓝色披风,弓箭手,适合像素风战棋。',
),
).toBeTruthy();
expect(within(characterInfoPanel).getByText('角色规范')).toBeTruthy();
expect(within(characterInfoPanel).getByText('拼图素材')).toBeTruthy();
expect(within(characterInfoPanel).getByText('常规参考图 1')).toBeTruthy();
expect(within(characterInfoPanel).getByText('大鱼素材')).toBeTruthy();
expect(within(characterInfoPanel).getByText('常规参考图 2')).toBeTruthy();
expect(within(characterInfoPanel).getByText('常规参考.png')).toBeTruthy();
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: expect.stringMatching(/角色形象/u),
objectKey:
'generated-character-drafts/editor/character-images/editor-character-1/image.png',
assetObjectId: 'asset-object-editor-character-1',
}),
]),
}),
);
});
});
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 generated icons on canvas', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-icons',
title: '图标素材画布',
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-icon',
width: 96,
height: 96,
},
{
name: '设置按钮',
imageSrc: 'data:image/png;base64,setting-icon',
width: 96,
height: 96,
},
],
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('返回按钮\n设置按钮\n下一关按钮\n提示按钮\n原图按钮\n冻结按钮');
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: '生成' }));
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/png;base64,icon-spec',
iconDescriptions: ['返回按钮', '设置按钮'],
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('画布图片:返回按钮')).toBeTruthy();
expect(screen.getByAltText('画布图片:设置按钮')).toBeTruthy();
});
expect(screen.queryByLabelText('图标素材生成占位图')).toBeNull();
expect(screen.getAllByText('图标')).toHaveLength(2);
fireEvent.click(
screen.getAllByRole('button', { name: '查看返回按钮图片信息' })[0]!,
);
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('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: [
{
name: '背包按钮',
imageSrc: 'data:image/png;base64,YmFncGFjay1pY29u',
width: 96,
height: 96,
},
],
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: '生成' }));
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
iconDescriptions: ['背包按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
projectId: 'editor-project-default',
assetFolderId: 'project',
}),
);
await waitFor(() => {
expect(screen.getByAltText('画布图片:背包按钮')).toBeTruthy();
});
});
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: '角色动画画布',
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 = screen.getByAltText('画布图片:市场老妇人');
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泥点' }));
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',
);
});
expect(screen.getByText('1/48')).toBeTruthy();
expect(screen.queryByLabelText('画布视频:角色动作')).toBeNull();
expect(screen.getByText('动作')).toBeTruthy();
});
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素材提取画布',
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: 'data:image/png;base64,marked-ui-design',
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();
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-ui-extract',
expect.objectContaining({
sourceType: 'generated',
assetKind: 'icon',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '提取提示词',
value: '仅提取被红色框框选的素材并整理成spritesheet',
}),
]),
}),
}),
);
});
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-ui-extract',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '战斗UI设计图 素材图集',
resourceId: 'resource-editor-project-ui-extract-512',
}),
expect.objectContaining({
title: '素材 1',
resourceId: 'resource-editor-project-ui-extract-96',
}),
]),
}),
);
});
});
it('closes UI design asset extraction when clicking outside the source and panel', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-ui-extract-dismiss',
title: 'UI素材提取外部关闭画布',
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 quick edit from the floating toolbar, hides the implicit source reference, and generates from a new frame', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-quick-edit',
title: '快速编辑画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-quick-source',
resourceId: 'resource-quick-source',
title: '魔法森林',
src: 'data:image/png;base64,c291cmNl',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1536,
originalHeight: 1024,
zIndex: 2,
sourceType: 'generated',
prompt: '魔法森林原始提示词',
actualPrompt: '魔法森林原始提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'source-task-1',
assetKind: 'spec',
},
{
layerId: 'layer-quick-extra-ref',
resourceId: 'resource-quick-extra-ref',
title: '星空参考',
src: 'data:image/png;base64,ZXh0cmEtcmVm',
x: -420,
y: 100,
width: 260,
height: 260,
originalWidth: 512,
originalHeight: 512,
zIndex: 1,
sourceType: 'uploaded',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
imageSrc: 'data:image/png;base64,cXVpY2stZWRpdA==',
width: 1536,
height: 1024,
sourceType: 'generated',
prompt: '增加萤火虫',
actualPrompt: '增加萤火虫',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'quick-edit-task-1',
}),
);
render(<ImageCanvasEditorView />);
const sourceImage = await screen.findByAltText('画布图片:魔法森林');
fireEvent.pointerDown(sourceImage.closest('button')!, {
button: 0,
pointerId: 151,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 151,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(quickPanel.className).toContain(
'image-canvas-editor__quick-edit-panel',
);
expect(within(quickPanel).queryByText('魔法森林')).toBeNull();
expect(
(
within(quickPanel).getByRole('button', {
name: '快速编辑尺寸 3:2·1K',
}) as HTMLButtonElement
).disabled,
).toBe(false);
expect(
(
within(quickPanel).getByRole('button', {
name: '快速编辑模型 gpt-image-2',
}) as HTMLButtonElement
).disabled,
).toBe(false);
expect(within(quickPanel).queryByLabelText('原图 图1')).toBeNull();
fireEvent.click(
within(quickPanel).getByRole('button', {
name: '添加快速编辑参考图',
}),
);
fireEvent.click(
within(
screen.getByRole('menu', { name: '快速编辑参考图来源' }),
).getByRole('menuitem', { name: '从画布中选择' }),
);
fireEvent.pointerDown(
screen.getByAltText('画布图片:星空参考').closest('button')!,
{
button: 0,
pointerId: 152,
clientX: 140,
clientY: 120,
},
);
const updatedQuickPanel = screen.getByRole('dialog', {
name: '快速编辑图片',
});
expect(within(updatedQuickPanel).getByText('星空参考')).toBeTruthy();
expect(within(updatedQuickPanel).queryByLabelText('原图 图2')).toBeNull();
fireEvent.change(
within(updatedQuickPanel).getByLabelText('快速编辑提示词'),
{
target: { value: '参考原图增加萤火虫' },
},
);
fireEvent.click(
within(updatedQuickPanel).getByRole('button', { name: '生成' }),
);
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '参考图2增加萤火虫',
size: '1024x683',
aspectRatio: '3:2',
imageSize: '1K',
kind: 'quick-edit',
model: 'gpt-image-2',
referenceImageSrcs: [
'data:image/png;base64,ZXh0cmEtcmVm',
'data:image/png;base64,c291cmNl',
],
projectId: 'editor-project-quick-edit',
assetKind: 'spec',
assetFolderId: 'project',
assetLabel: '魔法森林 快速编辑',
sourceResourceId: 'resource-quick-source',
generationInputs: expect.objectContaining({
fields: expect.arrayContaining([
expect.objectContaining({
title: '快速编辑提示词',
value: '参考图2增加萤火虫',
}),
]),
}),
}),
);
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:魔法森林 快速编辑')).toBeTruthy();
});
const generatedLayer = screen
.getByAltText('画布图片:魔法森林 快速编辑')
.closest('button') as HTMLElement;
expect(Number.parseFloat(generatedLayer.style.left)).toBe(376);
expect(Number.parseFloat(generatedLayer.style.top)).toBe(1196);
expect(Number.parseFloat(generatedLayer.style.width)).toBe(1024);
expect(Number.parseFloat(generatedLayer.style.height)).toBe(683);
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-quick-edit',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '魔法森林 快速编辑',
resourceId: 'resource-editor-project-quick-edit-1024',
width: 1024,
height: 683,
originalWidth: 1024,
originalHeight: 683,
x: 376,
y: 1196,
}),
]),
}),
);
});
});
it('opens quick edit from the image context menu', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-context-quick-edit',
title: '右键快速编辑画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-context-source',
resourceId: 'resource-context-source',
title: '右键图片',
src: 'data:image/png;base64,Y29udGV4dA==',
x: 80,
y: 90,
width: 260,
height: 260,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 1,
sourceType: 'uploaded',
model: 'gpt-image-2',
},
],
resources: [],
updatedAt: '2026-06-15T00:00:00.000Z',
});
generateEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
imageSrc: 'data:image/png;base64,Y29udGV4dC1xdWljaw==',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '换成夜晚',
actualPrompt: '换成夜晚',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'context-quick-task-1',
}),
);
render(<ImageCanvasEditorView />);
const contextImage = await screen.findByAltText('画布图片:右键图片');
fireEvent.contextMenu(contextImage.closest('button')!, {
clientX: 260,
clientY: 220,
});
const menu = screen.getByRole('menu', { name: '图片功能面板' });
expect(
within(menu).getByRole('menuitem', { name: '快速编辑' }),
).toBeTruthy();
fireEvent.click(within(menu).getByRole('menuitem', { name: '快速编辑' }));
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
expect(within(panel).queryByText('右键图片')).toBeNull();
fireEvent.change(within(panel).getByLabelText('快速编辑提示词'), {
target: { value: '换成夜晚' },
});
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '换成夜晚',
referenceImageSrcs: ['data:image/png;base64,Y29udGV4dA=='],
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
model: 'gpt-image-2',
kind: 'quick-edit',
projectId: 'editor-project-context-quick-edit',
assetFolderId: 'project',
assetLabel: '右键图片 快速编辑',
sourceResourceId: 'resource-context-source',
}),
);
});
await waitFor(() => {
expect(screen.getByAltText('画布图片:右键图片 快速编辑')).toBeTruthy();
});
});
it('converts non-data-url quick edit source images before submitting references', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-public-quick-edit',
title: '公开素材快速编辑画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-public-source',
resourceId: 'resource-public-source',
title: '公开拼图素材',
src: '/creation-type-references/puzzle.webp',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 640,
originalHeight: 640,
zIndex: 2,
sourceType: 'uploaded',
},
],
resources: [],
updatedAt: '2026-06-16T00:00:00.000Z',
});
vi.spyOn(globalThis, 'fetch').mockImplementation(
async () =>
new Response(new Uint8Array([104, 101, 108, 108, 111]), {
status: 200,
headers: {
'Content-Type': 'image/webp',
},
}),
);
generateEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
imageSrc: 'data:image/png;base64,cHVibGljLXF1aWNr',
width: 640,
height: 640,
sourceType: 'generated',
prompt: '改成陶泥风格',
actualPrompt: '改成陶泥风格',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'public-quick-edit-task-1',
}),
);
render(<ImageCanvasEditorView />);
const sourceImage = await screen.findByAltText('画布图片:公开拼图素材');
fireEvent.pointerDown(sourceImage.closest('button')!, {
button: 0,
pointerId: 161,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 161,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
target: { value: '改成陶泥风格' },
});
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenCalledWith(
expect.objectContaining({
prompt: '改成陶泥风格',
kind: 'quick-edit',
referenceImageSrcs: ['data:image/webp;base64,aGVsbG8='],
projectId: 'editor-project-public-quick-edit',
assetFolderId: 'project',
assetLabel: '公开拼图素材 快速编辑',
sourceResourceId: 'resource-public-source',
}),
);
});
expect(globalThis.fetch).toHaveBeenCalledWith(
'/creation-type-references/puzzle.webp',
expect.objectContaining({
signal: undefined,
}),
);
});
it('opens generated image info from the corner button and creates a real right-side edit result', 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',
}),
);
generateEditorImageMock.mockImplementationOnce(async (input) =>
createGeneratedImageResponse(input, {
imageSrc: 'data:image/png;base64,ZWRhd24tcmVkcmF3',
width: 1024,
height: 1024,
sourceType: 'generated',
prompt: '把画面改成黄昏光线',
actualPrompt: '把画面改成黄昏光线',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'editor-real-redraw-1',
}),
);
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.getByRole('dialog', { name: '生成图片' })).toBeTruthy();
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();
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const editDialog = screen.getByRole('dialog', { name: '重绘图片' });
expect(editDialog).toBeTruthy();
const editPrompt = within(editDialog).getByLabelText('重绘提示词');
expect(editPrompt.className).toContain('platform-text-field');
expect(editPrompt.className).toContain(
'image-canvas-editor__quick-edit-prompt',
);
fireEvent.change(editPrompt, {
target: { value: '把画面改成黄昏光线' },
});
fireEvent.click(within(editDialog).getByRole('button', { name: '生成' }));
await waitFor(() => {
expect(generateEditorImageMock).toHaveBeenLastCalledWith(
expect.objectContaining({
prompt: '把画面改成黄昏光线',
size: '1024x1024',
aspectRatio: '1:1',
imageSize: '1K',
kind: 'quick-edit',
model: 'gpt-image-2',
referenceImageSrcs: ['data:image/png;base64,ZmFrZS1pbWFnZQ=='],
}),
);
});
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy();
});
expect(screen.getByAltText(/画布图片:生成图片 .* 重绘/)).toBeTruthy();
fireEvent.click(
screen.getAllByRole('button', {
name: /查看生成图片 .* 重绘图片信息/u,
})[0]!,
);
const editedMetadataDialog = screen.getByRole('dialog', {
name: '图片信息',
});
expect(within(editedMetadataDialog).queryByText('Prompt')).toBeNull();
expect(within(editedMetadataDialog).getByText('重绘提示词')).toBeTruthy();
expect(
within(editedMetadataDialog).getByText('把画面改成黄昏光线'),
).toBeTruthy();
expect(within(editedMetadataDialog).getByText('参考图')).toBeTruthy();
expect(
within(editedMetadataDialog).getByText(/^生成图片 \d+$/u),
).toBeTruthy();
expect(
screen.getByRole('button', { name: /当前缩放比例 \d+%/u }),
).toBeTruthy();
});
it('keeps the redraw panel open after generation starts while keeping the source preview visible', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-edit-generating',
title: '重绘生成中画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-edit-generating-source',
resourceId: 'resource-edit-generating-source',
title: '待重绘',
src: 'data:image/png;base64,ZWRpdC1nZW5lcmF0aW5n',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'generated',
prompt: '原始提示词',
actualPrompt: '原始提示词',
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'edit-generating-source-task',
},
],
resources: [],
updatedAt: '2026-06-16T00:00:00.000Z',
});
generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
render(<ImageCanvasEditorView />);
const sourceImage = await screen.findByAltText('画布图片:待重绘');
const sourceLayer = sourceImage.closest('button')!;
fireEvent.pointerDown(sourceLayer, {
button: 0,
pointerId: 171,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 171,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '改造' }));
const editDialog = screen.getByRole('dialog', { name: '重绘图片' });
fireEvent.change(within(editDialog).getByLabelText('重绘提示词'), {
target: { value: '改成雨夜灯光' },
});
fireEvent.click(within(editDialog).getByRole('button', { name: '生成' }));
expect(screen.getByRole('dialog', { name: '重绘图片' })).toBeTruthy();
expect(screen.getByAltText('画布图片:待重绘')).toBeTruthy();
expect(sourceLayer.className).not.toContain(
'image-canvas-editor__layer--generating',
);
expect(
within(editDialog).getByRole('button', { name: '生成中' }),
).toBeTruthy();
});
it('hides the quick edit panel after generation starts while keeping the source preview visible', async () => {
loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({
projectId: 'editor-project-quick-edit-generating',
title: '快速编辑生成中画布',
viewport: { x: 0, y: 0, scale: 1 },
layers: [
{
layerId: 'layer-quick-edit-generating-source',
resourceId: 'resource-quick-edit-generating-source',
title: '快速编辑源图',
src: 'data:image/png;base64,cXVpY2stZWRpdC1nZW5lcmF0aW5n',
x: 120,
y: 140,
width: 320,
height: 240,
originalWidth: 1024,
originalHeight: 768,
zIndex: 2,
sourceType: 'uploaded',
model: 'gpt-image-2',
},
],
resources: [],
updatedAt: '2026-06-16T00:00:00.000Z',
});
generateEditorImageMock.mockReturnValueOnce(new Promise(() => undefined));
render(<ImageCanvasEditorView />);
const sourceImage = await screen.findByAltText('画布图片:快速编辑源图');
const sourceLayer = sourceImage.closest('button')!;
fireEvent.pointerDown(sourceLayer, {
button: 0,
pointerId: 172,
clientX: 180,
clientY: 180,
});
fireEvent.pointerUp(screen.getByLabelText('画布工作区'), {
pointerId: 172,
clientX: 180,
clientY: 180,
});
fireEvent.click(screen.getByRole('button', { name: '快速编辑' }));
const quickPanel = screen.getByRole('dialog', { name: '快速编辑图片' });
fireEvent.change(within(quickPanel).getByLabelText('快速编辑提示词'), {
target: { value: '加一层暖光' },
});
fireEvent.click(within(quickPanel).getByRole('button', { name: '生成' }));
expect(screen.queryByRole('dialog', { name: '快速编辑图片' })).toBeNull();
expect(screen.getByAltText('画布图片:快速编辑源图')).toBeTruthy();
expect(sourceLayer.className).not.toContain(
'image-canvas-editor__layer--generating',
);
const quickEditFrame = screen.getByLabelText('快速编辑生成占位图');
expect(quickEditFrame).toBeTruthy();
expect(quickEditFrame.className).toContain(
'image-canvas-editor__generation-frame--generating',
);
expect(within(quickEditFrame).getByRole('status').textContent).toContain(
'生成中',
);
await act(async () => {
fireEvent.keyDown(window, { key: 'Delete', code: 'Delete' });
});
const confirmDialog = screen.getByRole('dialog', {
name: '删除生成中的占位图',
});
expect(screen.getByLabelText('快速编辑生成占位图')).toBeTruthy();
fireEvent.click(
within(confirmDialog).getByRole('button', { name: '确认删除' }),
);
expect(screen.queryByLabelText('快速编辑生成占位图')).toBeNull();
});
});