Files
Genarrative/src/components/image-editor/ImageCanvasGenerationImageOptionsView.test.tsx
T
lhk229 06cad7e428 支持画布生图选择抠图背景色
新增画布生图背景色选项并在角色、图标和 UI 素材提取入口展示选择控件。

将 screenColor 贯穿前端提交、后端请求解析、prompt 构建和生成后本地后处理调用。

保留角色动作旧绿幕路径,避免影响既有动画生成流程。

补充相关前后端单元测试和编辑器文档说明。
2026-07-02 14:56:26 +00:00

355 lines
11 KiB
TypeScript

/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorImageGenerationPrice,
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
function ImageOptionsHarness({
initialDialog,
includeDimensions = true,
includeBackgroundColor = false,
onRememberImageModel = vi.fn(),
lockedModel,
}: {
initialDialog: GenerateDialogState;
includeDimensions?: boolean;
includeBackgroundColor?: boolean;
onRememberImageModel?: (model: string) => void;
lockedModel?: string;
}) {
const [dialog, setDialog] = useState<GenerateDialogState | null>(
initialDialog,
);
return dialog ? (
<div>
<ImageCanvasGenerationImageOptionsView
dialog={dialog}
setGenerateDialog={setDialog}
includeDimensions={includeDimensions}
includeBackgroundColor={includeBackgroundColor}
onRememberImageModel={onRememberImageModel}
lockedModel={lockedModel}
cost={calculateEditorImageGenerationPrice({
model: lockedModel ?? dialog.imageModel,
imageSize: dialog.imageSize,
})}
submitLabel="生成"
submitAriaLabel="生成角色形象"
/>
<output aria-label="当前模型">{dialog.imageModel}</output>
<output aria-label="当前比例">{dialog.aspectRatio}</output>
<output aria-label="当前尺寸">{dialog.imageSize}</output>
<output aria-label="当前背景色">{dialog.screenColor}</output>
<output aria-label="当前状态">{dialog.status}</output>
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
<output aria-label="当前占位">
{dialog.placeholder
? [
dialog.placeholder.x,
dialog.placeholder.y,
dialog.placeholder.width,
dialog.placeholder.height,
dialog.placeholder.originalWidth,
dialog.placeholder.originalHeight,
].join(':')
: '-'}
</output>
</div>
) : null;
}
describe('ImageCanvasGenerationImageOptionsView', () => {
it('updates dimensions from a menu, keeps the menu open and marks the selection', () => {
render(
<ImageOptionsHarness
initialDialog={{
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
}}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
);
const panel = screen.getByRole('menu', { name: '生成图片尺寸选项' });
fireEvent.click(within(panel).getByRole('button', { name: '16:9' }));
fireEvent.click(within(panel).getByRole('button', { name: '2K' }));
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
expect(
within(panel)
.getByRole('button', { name: '16:9' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
within(panel)
.getByRole('button', { name: '2K' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', { name: '生成图片尺寸 16:9·2K' }),
).toBeTruthy();
expect(screen.getByLabelText('当前比例').textContent).toBe('16:9');
expect(screen.getByLabelText('当前尺寸').textContent).toBe('2K');
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
});
it('closes option panels when clicking parent panel outside trigger buttons', () => {
render(
<ImageOptionsHarness
initialDialog={{
mode: 'generate',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
}}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
);
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
fireEvent.click(screen.getByLabelText('当前模型'));
expect(screen.queryByRole('menu', { name: '生成图片尺寸选项' })).toBeNull();
});
it('renders model choices as one model per row and uses a check mark for selection', () => {
const rememberImageModel = vi.fn();
render(
<ImageOptionsHarness
initialDialog={{
mode: 'icon',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '9:16',
imageSize: '0.5K',
}}
onRememberImageModel={rememberImageModel}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
);
const panel = screen.getByRole('menu', { name: '生成图片模型选项' });
expect(
within(panel).getByRole('button', { name: 'nanobanana2' }),
).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: 'gpt-image-2' }));
expect(screen.getByRole('menu', { name: '生成图片模型选项' })).toBeTruthy();
expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
expect(screen.getByLabelText('当前模型').textContent).toBe(
IMAGE_MODEL_GPT_IMAGE_2,
);
expect(screen.getByLabelText('当前比例').textContent).toBe('9:16');
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K');
expect(
screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '生成角色形象' }).textContent,
).toBe('生成3泥点');
});
it('updates the keyed background color from the color menu', () => {
render(
<ImageOptionsHarness
includeBackgroundColor
initialDialog={{
mode: 'character',
prompt: '',
status: 'failed',
errorMessage: '旧错误',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
screenColor: '#CFEFFF',
}}
/>,
);
fireEvent.click(
screen.getByRole('button', {
name: '生成图片背景色 浅雾蓝 #CFEFFF',
}),
);
const panel = screen.getByRole('menu', { name: '生成图片背景色选项' });
fireEvent.click(
within(panel).getByRole('button', { name: '淡薰衣草紫 #E6D8FF' }),
);
expect(screen.getByRole('menu', { name: '生成图片背景色选项' })).toBeTruthy();
expect(
within(panel)
.getByRole('button', { name: '淡薰衣草紫 #E6D8FF' })
.getAttribute('aria-pressed'),
).toBe('true');
expect(
screen.getByRole('button', {
name: '生成图片背景色 淡薰衣草紫 #E6D8FF',
}),
).toBeTruthy();
expect(screen.getByLabelText('当前背景色').textContent).toBe('#E6D8FF');
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
});
it('locks the model trigger when a fixed model is provided', () => {
const rememberImageModel = vi.fn();
render(
<ImageOptionsHarness
initialDialog={{
mode: 'ui-design',
prompt: '',
status: 'idle',
imageModel: 'nanobanana2',
aspectRatio: '16:9',
imageSize: '1K',
}}
lockedModel={IMAGE_MODEL_GPT_IMAGE_2}
onRememberImageModel={rememberImageModel}
/>,
);
const modelButton = screen.getByRole('button', {
name: '生成图片模型 gpt-image-2',
}) as HTMLButtonElement;
expect(modelButton.disabled).toBe(true);
expect(modelButton.closest('div')?.className).toContain(
'image-canvas-editor__readonly-generation-option',
);
expect(modelButton.textContent).not.toContain('nanobanana2');
fireEvent.click(modelButton);
expect(screen.queryByRole('menu', { name: '生成图片模型选项' })).toBeNull();
expect(rememberImageModel).not.toHaveBeenCalled();
});
it('renders option menus and shows mud point text in the submit button', () => {
render(
<ImageOptionsHarness
initialDialog={{
mode: 'generate',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
}}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
);
expect(screen.getByRole('menu', { name: '生成图片尺寸选项' })).toBeTruthy();
const submit = screen.getByRole('button', { name: '生成角色形象' });
expect(submit.textContent).toBe('生成12泥点');
});
it('keeps fixed-card placeholders unchanged when model-only controls are shown', () => {
render(
<ImageOptionsHarness
includeDimensions={false}
initialDialog={{
mode: 'publication',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '4:3',
imageSize: '1K',
placeholder: {
x: 120,
y: 160,
width: 720,
height: 540,
originalWidth: 720,
originalHeight: 540,
},
}}
/>,
);
expect(screen.queryByRole('button', { name: /生成图片尺寸/u })).toBeNull();
fireEvent.click(
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
'button',
{ name: 'gpt-image-2' },
),
);
expect(screen.getByLabelText('当前模型').textContent).toBe(
IMAGE_MODEL_GPT_IMAGE_2,
);
expect(screen.getByLabelText('当前比例').textContent).toBe('4:3');
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K');
expect(screen.getByLabelText('当前占位').textContent).toBe(
'120:160:720:540:720:540',
);
});
it('keeps the canvas placeholder centered while syncing its ratio and size selection', () => {
render(
<ImageOptionsHarness
initialDialog={{
mode: 'character',
prompt: '',
status: 'idle',
imageModel: IMAGE_MODEL_NANOBANANA2,
aspectRatio: '1:1',
imageSize: '1K',
placeholder: {
x: 100,
y: 200,
width: 420,
height: 420,
originalWidth: 1024,
originalHeight: 1024,
},
}}
/>,
);
fireEvent.click(
screen.getByRole('button', { name: '生成图片尺寸 1:1·1K' }),
);
const panel = screen.getByRole('menu', { name: '生成图片尺寸选项' });
fireEvent.click(within(panel).getByRole('button', { name: '16:9' }));
fireEvent.click(within(panel).getByRole('button', { name: '2K' }));
expect(screen.getByLabelText('当前占位').textContent).toBe(
'-714:-166:2048:1152:2048:1152',
);
});
});