Files
Genarrative/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx
T
JenkenB 9c7b3ad16d Image editor: Seedance2.0 refs and audio changes
Adds Seedance 2.0 reference-media constraints and forbids video data URLs; updates docs/decision-log and pitfalls to record the new rules. Introduces typed audio params (type: one-shot|loop, tempo: number|null), generator-specific placeholder styles/avoidance and UI changes for generation panels. Implements related front-end changes (new crop/ raster edit components, many image-editor models, tests and workflow hooks) and server-side contract/handler updates across api-server, shared-contracts, platform-audio, platform-image and vector_engine_audio_generation to support the new submission/asset flow and validations.
2026-06-18 17:31:35 +08:00

541 lines
18 KiB
TypeScript

/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import {
type ComponentProps,
createRef,
type Dispatch,
type SetStateAction,
useState,
} from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
function mockStateSetter<T>() {
return vi.fn() as unknown as Dispatch<SetStateAction<T>>;
}
function createComposerProps(
generateDialog: GenerateDialogState,
overrides: Partial<
ComponentProps<typeof ImageCanvasGenerationComposerView>
> = {},
): ComponentProps<typeof ImageCanvasGenerationComposerView> {
return {
specToolWrapRef: createRef(),
characterSpecButtonRef: createRef(),
characterReferenceButtonRef: createRef(),
generationReferenceButtonRef: createRef(),
iconSpecButtonRef: createRef(),
isSpecMenuOpen: false,
isCharacterSpecMenuOpen: false,
isCharacterReferenceMenuOpen: false,
isGenerationReferenceMenuOpen: false,
isIconSpecMenuOpen: false,
isUiDesignSpecMenuOpen: false,
isPickingCharacterSpecFromCanvas: false,
isPickingCharacterReferenceFromCanvas: false,
isPickingGenerationReferenceFromCanvas: false,
isPickingIconSpecFromCanvas: false,
isPickingUiDesignSpecFromCanvas: false,
generateDialog,
generationComposerStyle: { left: 320, top: 240 },
iconComposerStyle: { left: 320, top: 240, width: '32rem' },
quickEditPanel: null,
quickEditSourceLayer: null,
quickEditPanelStyle: null,
cropExpandPanel: null,
cropExpandSourceLayer: null,
cropExpandPanelStyle: null,
quickEditSizeOptions: ['1024x1024'],
quickEditModelOptions: [{ label: 'gpt-image-2', value: 'gpt-image-2' }],
characterAnimationPanel: null,
characterAnimationSourceLayer: null,
characterAnimationPanelStyle: null,
characterAnimationPrice: 40,
setGenerateDialog: mockStateSetter<GenerateDialogState | null>(),
setQuickEditPanel:
mockStateSetter<
ComponentProps<
typeof ImageCanvasGenerationComposerView
>['quickEditPanel']
>(),
setCropExpandPanel:
mockStateSetter<
ComponentProps<
typeof ImageCanvasGenerationComposerView
>['cropExpandPanel']
>(),
setCharacterAnimationPanel:
mockStateSetter<
ComponentProps<
typeof ImageCanvasGenerationComposerView
>['characterAnimationPanel']
>(),
setIsCharacterSpecMenuOpen: mockStateSetter<boolean>(),
setIsCharacterReferenceMenuOpen: mockStateSetter<boolean>(),
setIsGenerationReferenceMenuOpen: mockStateSetter<boolean>(),
setIsIconSpecMenuOpen: mockStateSetter<boolean>(),
setIsUiDesignSpecMenuOpen: mockStateSetter<boolean>(),
setIsPickingCharacterSpecFromCanvas: mockStateSetter<boolean>(),
setIsPickingCharacterReferenceFromCanvas: mockStateSetter<boolean>(),
setIsPickingGenerationReferenceFromCanvas: mockStateSetter<boolean>(),
setIsPickingIconSpecFromCanvas: mockStateSetter<boolean>(),
setIsPickingUiDesignSpecFromCanvas: mockStateSetter<boolean>(),
onOpenSpecDialog: vi.fn(),
onRequestUpload: vi.fn(),
onSubmitImageGeneration: vi.fn(),
onSubmitIconSpritesheetGeneration: vi.fn(),
onSubmitQuickEdit: vi.fn(),
onSubmitCropExpand: vi.fn(),
onSubmitCharacterAnimation: vi.fn(),
onCloseGenerateComposer: vi.fn(),
onUpdateSpecFormValue: vi.fn(),
onUpdateIconDescriptionText: vi.fn(),
onUpdateCharacterAnimationDuration: vi.fn(),
onRememberImageModel: vi.fn(),
...overrides,
};
}
function renderComposer(
generateDialog: GenerateDialogState,
overrides: Partial<
ComponentProps<typeof ImageCanvasGenerationComposerView>
> = {},
) {
const props = createComposerProps(generateDialog, overrides);
return render(<ImageCanvasGenerationComposerView {...props} />);
}
describe('ImageCanvasGenerationComposerView', () => {
it('让生成UI设计图面板复用普通图片生成面板的纵向结构', () => {
const setGenerateDialog = vi.fn();
renderComposer({
mode: 'ui-design',
prompt: '',
status: 'idle',
composerOpen: true,
uiDesignSpecReference: null,
imageModel: 'gpt-image-2',
aspectRatio: '16:9',
imageSize: '1K',
}, {
isUiDesignSpecMenuOpen: true,
setGenerateDialog:
setGenerateDialog as unknown as Dispatch<
SetStateAction<GenerateDialogState | null>
>,
});
const panel = screen.getByRole('dialog', { name: '生成UI设计图' });
expect(
panel.firstElementChild?.className.includes(
'image-canvas-editor__reference-strip',
),
).toBe(true);
expect(panel.className).toContain(
'image-canvas-editor__generation-composer',
);
expect(panel.className).toContain(
'image-canvas-editor__generation-composer--image',
);
expect(
within(panel).getByRole('button', { name: 'UI设计图标素材规范' }),
).toBeTruthy();
expect(
within(panel).getByRole('textbox', { name: 'UI设计要求' }).className,
).toContain('image-canvas-editor__generation-prompt');
expect(
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();
fireEvent.change(within(panel).getByRole('textbox', { name: 'UI设计要求' }), {
target: { value: '主界面和结算弹窗' },
});
expect(setGenerateDialog).toHaveBeenCalled();
const menu = screen.getByRole('menu', { name: '参考图来源' });
expect(
within(menu).getByRole('menuitem', { name: '新建图标素材规范' }),
).toBeTruthy();
});
it('让生成规范图片面板复用生成类面板 shell 和底部按钮结构', () => {
renderComposer({
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'character',
specValues: {
playSetting: '战棋类RPG玩法',
artStyle: '像素风',
bodyRatio: '3',
characterView: '右向斜侧身站姿',
customPrompt: '',
},
specReference: null,
});
const panel = screen.getByRole('dialog', { name: '生成规范' });
expect(
panel.firstElementChild?.className.includes(
'image-canvas-editor__reference-strip',
),
).toBe(true);
expect(panel.className).toContain(
'image-canvas-editor__generation-composer',
);
expect(panel.className).toContain(
'image-canvas-editor__generation-composer--image',
);
expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy();
expect(
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '提交生成规范' }).className,
).toContain('image-canvas-editor__generation-submit');
});
it('让图标素材规范生成面板也保留首行参考区', () => {
renderComposer({
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'icon',
specValues: {
playSetting: '休闲小游戏',
artStyle: '清爽卡通',
bodyRatio: '3',
characterView: '右向斜侧身站姿',
customPrompt: '',
},
specReference: null,
});
const panel = screen.getByRole('dialog', { name: '生成规范' });
expect(
panel.firstElementChild?.className.includes(
'image-canvas-editor__reference-strip',
),
).toBe(true);
expect(within(panel).getByRole('button', { name: '参考图' })).toBeTruthy();
});
it('生成图片参考图点击先弹来源菜单,不直接打开上传', () => {
const onRequestUpload = vi.fn();
const setIsGenerationReferenceMenuOpen = vi.fn();
renderComposer(
{
mode: 'generate',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
},
{
isGenerationReferenceMenuOpen: true,
onRequestUpload,
setIsGenerationReferenceMenuOpen,
},
);
const panel = screen.getByRole('dialog', { name: '生成图片' });
fireEvent.click(within(panel).getByRole('button', { name: '添加参考图' }));
expect(onRequestUpload).not.toHaveBeenCalled();
expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled();
const menu = screen.getByRole('menu', { name: '参考图来源' });
expect(
within(menu).getByRole('menuitem', { name: '从画布中选择' }),
).toBeTruthy();
expect(
within(menu).getByRole('menuitem', { name: '上传图片' }),
).toBeTruthy();
});
it('生成视频参考图点击先弹来源菜单,不直接打开上传', () => {
const onRequestUpload = vi.fn();
const setIsGenerationReferenceMenuOpen = vi.fn();
renderComposer(
{
mode: 'video',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
videoModel: 'seedance2.0-fast',
videoAspectRatio: '16:9',
videoDurationSeconds: 4,
videoResolution: '480p',
videoMode: 'std',
videoSound: 'off',
},
{
isGenerationReferenceMenuOpen: true,
onRequestUpload,
setIsGenerationReferenceMenuOpen,
},
);
const panel = screen.getByRole('dialog', { name: '生成视频' });
fireEvent.click(
within(panel).getByRole('button', { name: '添加视频参考素材' }),
);
expect(onRequestUpload).not.toHaveBeenCalled();
expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled();
const menu = screen.getByRole('menu', { name: '参考素材来源' });
expect(
within(menu).getByRole('menuitem', { name: '从画布中选择' }),
).toBeTruthy();
expect(
within(menu).getByRole('menuitem', { name: '上传图片' }),
).toBeTruthy();
expect(
within(menu).getByRole('menuitem', { name: '上传视频' }),
).toBeTruthy();
expect(
within(menu).getByRole('menuitem', { name: '上传音频' }),
).toBeTruthy();
});
it('点击生成视频父级面板任意区域会收起参考素材菜单', () => {
function VideoReferenceMenuHarness() {
const [isGenerationReferenceMenuOpen, setIsGenerationReferenceMenuOpen] =
useState(true);
const dialog: GenerateDialogState = {
mode: 'video',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
videoModel: 'seedance2.0-fast',
videoAspectRatio: '16:9',
videoDurationSeconds: 4,
videoResolution: '480p',
videoMode: 'std',
videoSound: 'off',
};
return (
<ImageCanvasGenerationComposerView
{...createComposerProps(dialog)}
isGenerationReferenceMenuOpen={isGenerationReferenceMenuOpen}
setIsGenerationReferenceMenuOpen={setIsGenerationReferenceMenuOpen}
/>
);
}
render(<VideoReferenceMenuHarness />);
expect(screen.getByRole('menu', { name: '参考素材来源' })).toBeTruthy();
fireEvent.click(screen.getByRole('textbox', { name: '视频描述' }));
expect(screen.queryByRole('menu', { name: '参考素材来源' })).toBeNull();
});
it('生成视频面板用子面板修改参数和模型,不展示 Veo,并在按钮内显示泥点文案', () => {
function VideoHarness() {
const [dialog, setDialog] = useState<GenerateDialogState>({
mode: 'video',
prompt: '',
status: 'idle',
composerOpen: true,
generationReferences: [],
videoModel: 'seedance2.0-fast',
videoAspectRatio: '16:9',
videoDurationSeconds: 4,
videoResolution: '480p',
videoMode: 'std',
videoSound: 'off',
});
return (
<>
<ImageCanvasGenerationComposerView
{...createComposerProps(dialog)}
setGenerateDialog={setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>}
/>
<output aria-label="当前视频模型">{dialog.videoModel}</output>
<output aria-label="当前视频时长">{dialog.videoDurationSeconds}</output>
<output aria-label="当前视频清晰度">{dialog.videoResolution}</output>
</>
);
}
render(<VideoHarness />);
const panel = screen.getByRole('dialog', { name: '生成视频' });
expect(
within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }),
).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '模型 Seedance 2.0 Fast' }),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '生成视频' }).textContent).toBe(
'生成40泥点',
);
fireEvent.click(
within(panel).getByRole('button', { name: '视频参数 16:9 · 4秒 · 480p' }),
);
const paramsPanel = screen.getByRole('menu', {
name: '视频参数选项',
});
fireEvent.click(within(paramsPanel).getByRole('button', { name: '时长 5秒' }));
fireEvent.click(within(paramsPanel).getByRole('button', { name: '清晰度 720p' }));
expect(
screen.getByRole('menu', { name: '视频参数选项' }),
).toBeTruthy();
expect(screen.getByLabelText('当前视频时长').textContent).toBe('5');
expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p');
expect(screen.getByRole('button', { name: '视频参数 16:9 · 5秒 · 720p' })).toBeTruthy();
expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe(
'生成100泥点',
);
fireEvent.click(screen.getByRole('button', { name: '模型 Seedance 2.0 Fast' }));
const modelPanel = screen.getByRole('menu', { name: '视频模型选项' });
expect(within(modelPanel).queryByRole('button', { name: /Veo/i })).toBeNull();
fireEvent.click(within(modelPanel).getByRole('button', { name: 'Kling 3.0' }));
expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy();
expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0');
expect(screen.getByRole('button', { name: '模型 Kling 3.0' })).toBeTruthy();
});
it('生成游戏音效面板把类型和 BPM 合并到一个无标题参数按钮', () => {
const onSubmitImageGeneration = vi.fn();
function AudioHarness() {
const [dialog, setDialog] = useState<GenerateDialogState>({
mode: 'audio-sound-effect',
prompt: '',
status: 'idle',
composerOpen: true,
soundType: 'one-shot',
soundTempo: null,
});
return (
<>
<ImageCanvasGenerationComposerView
{...createComposerProps(dialog, { onSubmitImageGeneration })}
setGenerateDialog={
setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>
}
/>
<output aria-label="当前音效类型">{dialog.soundType}</output>
<output aria-label="当前BPM">{dialog.soundTempo ?? 'null'}</output>
</>
);
}
render(<AudioHarness />);
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
expect(within(panel).getByRole('textbox', { name: 'sound提示词' })).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '音效参数 单次' }),
).toBeTruthy();
expect(within(panel).queryByRole('button', { name: /type/u })).toBeNull();
expect(within(panel).queryByRole('button', { name: /tempo/u })).toBeNull();
expect(within(panel).getByRole('button', { name: '生成游戏音效' }).textContent).toBe(
'生成10泥点',
);
fireEvent.click(
within(panel).getByRole('button', { name: '音效参数 单次' }),
);
const optionPanel = screen.getByRole('menu', { name: '音效参数选项' });
fireEvent.click(within(optionPanel).getByRole('button', { name: '循环' }));
fireEvent.change(within(optionPanel).getByLabelText('BPM数值'), {
target: { value: '120' },
});
expect(screen.getByLabelText('当前音效类型').textContent).toBe('loop');
expect(screen.getByLabelText('当前BPM').textContent).toBe('120');
expect(
screen.getByRole('button', { name: '音效参数 循环·120BPM' }),
).toBeTruthy();
fireEvent.change(within(optionPanel).getByLabelText('BPM拖拉条'), {
target: { value: '88' },
});
expect(screen.getByLabelText('当前BPM').textContent).toBe('88');
expect(
screen.getByRole('button', { name: '音效参数 循环·88BPM' }),
).toBeTruthy();
fireEvent.submit(panel);
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'audio-sound-effect',
soundType: 'loop',
soundTempo: 88,
}),
);
});
it('生成游戏背景音乐面板只展示 gpt_description_prompt 并固定隐藏 instrumental 开关', () => {
renderComposer({
mode: 'audio-background-music',
prompt: '',
status: 'idle',
composerOpen: true,
});
const panel = screen.getByRole('dialog', { name: '生成游戏背景音乐' });
expect(
within(panel).getByRole('textbox', { name: 'gpt_description_prompt' }),
).toBeTruthy();
expect(within(panel).queryByText('make_instrumental')).toBeNull();
expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe(
'生成5泥点',
);
});
it('生成规范参考图点击先弹来源菜单,不直接打开上传', () => {
const onRequestUpload = vi.fn();
const setIsGenerationReferenceMenuOpen = vi.fn();
renderComposer(
{
mode: 'spec',
prompt: '',
status: 'idle',
composerOpen: true,
specType: 'character',
specValues: {
playSetting: '战棋类RPG玩法',
artStyle: '像素风',
bodyRatio: '3',
characterView: '右向斜侧身站姿',
customPrompt: '',
},
specReference: null,
},
{
isGenerationReferenceMenuOpen: true,
onRequestUpload,
setIsGenerationReferenceMenuOpen,
},
);
const panel = screen.getByRole('dialog', { name: '生成规范' });
fireEvent.click(within(panel).getByRole('button', { name: '参考图' }));
expect(onRequestUpload).not.toHaveBeenCalled();
expect(setIsGenerationReferenceMenuOpen).toHaveBeenCalled();
const menu = screen.getByRole('menu', { name: '参考图来源' });
expect(within(menu).getByRole('menuitem', { name: '从画布中选择' })).toBeTruthy();
expect(within(menu).getByRole('menuitem', { name: '上传图片' })).toBeTruthy();
});
});