676bd524ee
示例来自微信:  before: - firefox:  - edge:  after: - firefox:   - edge:  - 使用 Lexical + OverlayScrollbars 实现统一可控的滑动条样式, 取代原来的textarea Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/149 Co-authored-by: 王德宇 <kvtodev@outlook.com> Co-committed-by: 王德宇 <kvtodev@outlook.com>
463 lines
15 KiB
TypeScript
463 lines
15 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,
|
|
hasPendingImageReferenceUploads = false,
|
|
onRememberImageModel = vi.fn(),
|
|
lockedModel,
|
|
}: {
|
|
initialDialog: GenerateDialogState;
|
|
includeDimensions?: boolean;
|
|
hasPendingImageReferenceUploads?: boolean;
|
|
onRememberImageModel?: (model: string) => void;
|
|
lockedModel?: string;
|
|
}) {
|
|
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
|
initialDialog,
|
|
);
|
|
|
|
return dialog ? (
|
|
<div>
|
|
<ImageCanvasGenerationImageOptionsView
|
|
dialog={dialog}
|
|
setGenerateDialog={setDialog}
|
|
includeDimensions={includeDimensions}
|
|
hasPendingImageReferenceUploads={hasPendingImageReferenceUploads}
|
|
onRememberImageModel={onRememberImageModel}
|
|
lockedModel={lockedModel}
|
|
cost={calculateEditorImageGenerationPrice({
|
|
model: lockedModel ?? dialog.imageModel,
|
|
imageSize: dialog.imageSize,
|
|
})}
|
|
submitLabel="生成"
|
|
submitAriaLabel="生成角色形象"
|
|
onSubmitRequest={() => undefined}
|
|
/>
|
|
<output aria-label="当前模型">{dialog.imageModel}</output>
|
|
<output aria-label="当前比例">{dialog.aspectRatio}</output>
|
|
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
|
<output aria-label="当前风格">{dialog.style ?? '-'}</output>
|
|
<output aria-label="当前状态">{dialog.status}</output>
|
|
<output aria-label="当前错误">{dialog.errorMessage ?? '-'}</output>
|
|
<output aria-label="当前参考图数量">
|
|
{dialog.mode === 'character'
|
|
? (dialog.characterReferences?.length ?? 0)
|
|
: dialog.mode === 'publication'
|
|
? (dialog.publicationReferences?.length ?? 0)
|
|
: (dialog.generationReferences?.length ?? 0)}
|
|
</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.each(['generate', 'character', 'icon'] as const)(
|
|
'shows and updates the pixel art style for %s generation',
|
|
(mode) => {
|
|
render(
|
|
<ImageOptionsHarness
|
|
initialDialog={{
|
|
mode,
|
|
prompt: '',
|
|
status: 'failed',
|
|
errorMessage: '旧错误',
|
|
style: 'none',
|
|
imageModel: IMAGE_MODEL_NANOBANANA2,
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
const toggle = screen.getByRole('checkbox', {
|
|
name: '像素艺术',
|
|
}) as HTMLInputElement;
|
|
const modelButton = screen.getByRole('button', {
|
|
name: '生成图片模型 nanobanana2',
|
|
});
|
|
expect(toggle.checked).toBe(false);
|
|
expect(
|
|
toggle.compareDocumentPosition(modelButton) &
|
|
Node.DOCUMENT_POSITION_FOLLOWING,
|
|
).not.toBe(0);
|
|
|
|
fireEvent.click(toggle);
|
|
|
|
expect(toggle.checked).toBe(true);
|
|
expect(screen.getByLabelText('当前风格').textContent).toBe('pixelArt');
|
|
expect(screen.getByLabelText('当前状态').textContent).toBe('idle');
|
|
expect(screen.getByLabelText('当前错误').textContent).toBe('-');
|
|
},
|
|
);
|
|
|
|
it.each(['spec', 'quick-edit', 'ui-design', 'publication'] as const)(
|
|
'does not show the pixel art style for %s generation',
|
|
(mode) => {
|
|
render(
|
|
<ImageOptionsHarness
|
|
initialDialog={{
|
|
mode,
|
|
prompt: '',
|
|
status: 'idle',
|
|
imageModel: IMAGE_MODEL_NANOBANANA2,
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByRole('checkbox', { name: '像素艺术' })).toBeNull();
|
|
},
|
|
);
|
|
|
|
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('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('rejects a model switch that would discard existing references', () => {
|
|
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
|
const rememberImageModel = vi.fn();
|
|
render(
|
|
<ImageOptionsHarness
|
|
initialDialog={{
|
|
mode: 'icon',
|
|
prompt: '',
|
|
status: 'idle',
|
|
imageModel: IMAGE_MODEL_NANOBANANA2,
|
|
generationReferences: Array.from({ length: 8 }, (_, index) => ({
|
|
id: `reference-${index + 1}`,
|
|
label: `参考图${index + 1}`,
|
|
src: `/reference-${index + 1}.png`,
|
|
})),
|
|
}}
|
|
includeDimensions={false}
|
|
onRememberImageModel={rememberImageModel}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
|
|
fireEvent.click(
|
|
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
|
);
|
|
fireEvent.click(
|
|
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
|
|
'button',
|
|
{ name: 'gpt-image-2' },
|
|
),
|
|
);
|
|
|
|
expect(alertMock).toHaveBeenCalledWith(
|
|
'当前已有 8 张参考图,gpt-image-2 最多允许 4 张,请先删除多余参考图后再切换',
|
|
);
|
|
expect(screen.getByLabelText('当前参考图数量').textContent).toBe('8');
|
|
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
|
IMAGE_MODEL_NANOBANANA2,
|
|
);
|
|
expect(rememberImageModel).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('rejects model switching while reference uploads are pending', () => {
|
|
const alertMock = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
|
const rememberImageModel = vi.fn();
|
|
render(
|
|
<ImageOptionsHarness
|
|
initialDialog={{
|
|
mode: 'generate',
|
|
prompt: '',
|
|
status: 'idle',
|
|
imageModel: IMAGE_MODEL_NANOBANANA2,
|
|
}}
|
|
hasPendingImageReferenceUploads
|
|
onRememberImageModel={rememberImageModel}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
|
);
|
|
fireEvent.click(
|
|
within(screen.getByRole('menu', { name: '生成图片模型选项' })).getByRole(
|
|
'button',
|
|
{ name: 'gpt-image-2' },
|
|
),
|
|
);
|
|
|
|
expect(alertMock).toHaveBeenCalledWith(
|
|
'参考图正在上传,请等待上传完成后再切换模型',
|
|
);
|
|
expect(screen.getByLabelText('当前模型').textContent).toBe(
|
|
IMAGE_MODEL_NANOBANANA2,
|
|
);
|
|
expect(
|
|
(
|
|
screen.getByRole('button', {
|
|
name: '生成角色形象',
|
|
}) as HTMLButtonElement
|
|
).disabled,
|
|
).toBe(true);
|
|
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',
|
|
);
|
|
});
|
|
});
|