72f268e088
实现[游戏场景需求v1.0](https://kcnz41bksl1c.feishu.cn/wiki/JSF3wdhduinpqhkrVGKcZFp4nng?psg_id=8477599259997387860&refer_index=1&refer_type=citation)。 --------- Co-authored-by: 段舒康 <kdletters@qq.com> Co-authored-by: suzmii <suzmii@foxmail.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/139 Co-authored-by: 董羽秦 <suzmii@qq.com> Co-committed-by: 董羽秦 <suzmii@qq.com>
1775 lines
59 KiB
TypeScript
1775 lines
59 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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { BACKGROUND_MUSIC_PROMPT_PRESETS } from './ImageCanvasBackgroundMusicPresetModel';
|
|
import type { BackgroundMusicGenerationDialogState } from './ImageCanvasBackgroundMusicPromptModel';
|
|
import type {
|
|
CanvasGenerationDialogState,
|
|
GenerateDialogState,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { ImageCanvasGenerationComposerView } from './ImageCanvasGenerationComposerView';
|
|
import { resolveImageGenerationErrorMessage } from './ImageCanvasGenerationModel';
|
|
import { buildImageGenerationSubmissionPlan } from './ImageCanvasGenerationSubmissionModel';
|
|
import type {
|
|
BackgroundMusicPromptAssistComposerController,
|
|
BackgroundMusicPromptAssistDialogState,
|
|
} from './useImageCanvasBackgroundMusicPromptAssist';
|
|
|
|
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(),
|
|
publicationReferenceButtonRef: createRef(),
|
|
isSpecMenuOpen: false,
|
|
isCharacterSpecMenuOpen: false,
|
|
isCharacterReferenceMenuOpen: false,
|
|
isGenerationReferenceMenuOpen: false,
|
|
isIconSpecMenuOpen: false,
|
|
isUiDesignSpecMenuOpen: false,
|
|
isPublicationReferenceMenuOpen: false,
|
|
isPickingCharacterSpecFromCanvas: false,
|
|
isPickingCharacterReferenceFromCanvas: false,
|
|
isPickingGenerationReferenceFromCanvas: false,
|
|
isPickingQuickEditReferenceFromCanvas: false,
|
|
isPickingIconSpecFromCanvas: false,
|
|
isPickingUiDesignSpecFromCanvas: false,
|
|
isPickingPublicationReferenceFromCanvas: false,
|
|
generateDialog,
|
|
updateCanvasGenerationDialogById: vi.fn(),
|
|
generationComposerStyle: { left: 320, top: 240 },
|
|
iconComposerStyle: { left: 320, top: 240, width: '32rem' },
|
|
quickEditPanel: null,
|
|
quickEditSourceLayer: null,
|
|
quickEditPanelStyle: null,
|
|
cropExpandPanel: null,
|
|
cropExpandSourceLayer: null,
|
|
cropExpandPanelStyle: null,
|
|
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>(),
|
|
setIsPickingQuickEditReferenceFromCanvas: mockStateSetter<boolean>(),
|
|
setIsIconSpecMenuOpen: mockStateSetter<boolean>(),
|
|
setIsUiDesignSpecMenuOpen: mockStateSetter<boolean>(),
|
|
setIsPublicationReferenceMenuOpen: mockStateSetter<boolean>(),
|
|
setIsPickingCharacterSpecFromCanvas: mockStateSetter<boolean>(),
|
|
setIsPickingCharacterReferenceFromCanvas: mockStateSetter<boolean>(),
|
|
setIsPickingGenerationReferenceFromCanvas: mockStateSetter<boolean>(),
|
|
setIsPickingIconSpecFromCanvas: mockStateSetter<boolean>(),
|
|
setIsPickingUiDesignSpecFromCanvas: mockStateSetter<boolean>(),
|
|
setIsPickingPublicationReferenceFromCanvas: mockStateSetter<boolean>(),
|
|
onOpenSpecDialog: vi.fn(),
|
|
onRequestUpload: vi.fn(),
|
|
onSubmitImageGeneration: vi.fn(),
|
|
onSubmitIconSpritesheetGeneration: vi.fn(),
|
|
onSubmitQuickEdit: vi.fn(),
|
|
onSubmitCropExpand: vi.fn(),
|
|
onSubmitCharacterAnimation: vi.fn(),
|
|
onUpdateSpecFormValue: vi.fn(),
|
|
onUpdateIconDescriptionText: vi.fn(),
|
|
onUpdateCharacterAnimationDuration: vi.fn(),
|
|
onRememberImageModel: vi.fn(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function SceneValidationHarness({
|
|
initialDialog,
|
|
}: {
|
|
initialDialog: GenerateDialogState;
|
|
}) {
|
|
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
|
initialDialog,
|
|
);
|
|
if (!dialog) {
|
|
return null;
|
|
}
|
|
|
|
const props = createComposerProps(dialog, {
|
|
setGenerateDialog: setDialog,
|
|
onSubmitImageGeneration: (submittedDialog) => {
|
|
try {
|
|
buildImageGenerationSubmissionPlan({
|
|
dialog: submittedDialog,
|
|
layers: [],
|
|
nextGeneratedIndex: 1,
|
|
});
|
|
} catch (error) {
|
|
setDialog({
|
|
...submittedDialog,
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
return <ImageCanvasGenerationComposerView {...props} />;
|
|
}
|
|
|
|
function renderComposer(
|
|
generateDialog: GenerateDialogState,
|
|
overrides: Partial<
|
|
ComponentProps<typeof ImageCanvasGenerationComposerView>
|
|
> = {},
|
|
) {
|
|
const props = createComposerProps(generateDialog, overrides);
|
|
|
|
return render(<ImageCanvasGenerationComposerView {...props} />);
|
|
}
|
|
|
|
type AssistStateOverrides = Partial<BackgroundMusicPromptAssistDialogState>;
|
|
|
|
function createBackgroundMusicDialog(
|
|
overrides: Partial<BackgroundMusicGenerationDialogState> = {},
|
|
): BackgroundMusicGenerationDialogState {
|
|
return {
|
|
id: 'dialog-bgm',
|
|
mode: 'audio-background-music',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createBackgroundMusicPromptAssistStub(
|
|
assistState: AssistStateOverrides = {},
|
|
overrides: Partial<BackgroundMusicPromptAssistComposerController> = {},
|
|
): BackgroundMusicPromptAssistComposerController {
|
|
return {
|
|
getDialogState: (dialogId: string) => ({
|
|
dialogId,
|
|
status: 'idle' as const,
|
|
operationId: null,
|
|
undoPromptSnapshot: null,
|
|
temporaryPromptSnapshot: null,
|
|
errorMessage: null,
|
|
...assistState,
|
|
}),
|
|
completePrompt: vi.fn(async () => ({
|
|
started: true,
|
|
applied: true,
|
|
reason: 'applied' as const,
|
|
errorMessage: null,
|
|
})),
|
|
simplifyPrompt: vi.fn(async () => ({
|
|
started: true,
|
|
applied: true,
|
|
reason: 'applied' as const,
|
|
errorMessage: null,
|
|
})),
|
|
preparePreset: vi.fn(() => ''),
|
|
undoPrompt: vi.fn(() => true),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function renderBackgroundMusicComposer({
|
|
dialog = createBackgroundMusicDialog(),
|
|
promptAssist = createBackgroundMusicPromptAssistStub(),
|
|
onSubmit = vi.fn(),
|
|
updateCanvasGenerationDialogById = vi.fn(),
|
|
}: {
|
|
dialog?: BackgroundMusicGenerationDialogState;
|
|
promptAssist?: BackgroundMusicPromptAssistComposerController;
|
|
onSubmit?: ReturnType<typeof vi.fn>;
|
|
updateCanvasGenerationDialogById?: ReturnType<typeof vi.fn>;
|
|
} = {}) {
|
|
const view = renderComposer(dialog, {
|
|
backgroundMusicPromptAssist: promptAssist,
|
|
updateCanvasGenerationDialogById,
|
|
onSubmitImageGeneration: onSubmit,
|
|
});
|
|
|
|
return {
|
|
...view,
|
|
dialog,
|
|
promptAssist,
|
|
onSubmit,
|
|
updateCanvasGenerationDialogById,
|
|
};
|
|
}
|
|
|
|
function getBackgroundMusicPanel() {
|
|
return screen.getByRole('dialog', { name: '生成游戏背景音乐' });
|
|
}
|
|
|
|
function getBackgroundMusicPromptTextarea() {
|
|
return within(getBackgroundMusicPanel()).getByRole('textbox', {
|
|
name: 'gpt_description_prompt',
|
|
}) as HTMLTextAreaElement;
|
|
}
|
|
|
|
function getBackgroundMusicActionButton(name: string) {
|
|
return within(getBackgroundMusicPanel()).getByRole('button', {
|
|
name,
|
|
}) as HTMLButtonElement;
|
|
}
|
|
|
|
function applyBackgroundMusicDialogUpdate(
|
|
updateSpy: ReturnType<typeof vi.fn>,
|
|
dialog: BackgroundMusicGenerationDialogState,
|
|
) {
|
|
const [dialogId, updater] = updateSpy.mock.calls[
|
|
updateSpy.mock.calls.length - 1
|
|
] as [
|
|
string,
|
|
(
|
|
current: CanvasGenerationDialogState,
|
|
) => CanvasGenerationDialogState | null,
|
|
];
|
|
return { dialogId, nextDialog: updater(dialog) };
|
|
}
|
|
|
|
describe('ImageCanvasGenerationComposerView', () => {
|
|
it.each([
|
|
{
|
|
status: 'pending-confirmation' as const,
|
|
message: '结果尚未确认,系统不会自动重复提交。',
|
|
},
|
|
{
|
|
status: 'failed' as const,
|
|
message: '完美像素请求尚未发出,请重试同一操作。',
|
|
},
|
|
])('$status 的完美像素操作只展示原操作重试', ({ status, message }) => {
|
|
const dialogId = `dialog-perfect-pixel-${status}`;
|
|
const onRetryPerfectPixelOperation = vi.fn();
|
|
const onSubmitImageGeneration = vi.fn();
|
|
|
|
renderComposer(
|
|
{
|
|
id: dialogId,
|
|
mode: 'generate',
|
|
prompt: '不应重新提交的普通提示词',
|
|
status,
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
...(status === 'failed' ? { errorMessage: message } : {}),
|
|
perfectPixelOperation: {
|
|
version: 1,
|
|
kind: 'perfect-pixel',
|
|
operationId: dialogId,
|
|
taskId: `pixel-art-snap-${dialogId}`,
|
|
request: {
|
|
sourceImageSrc: 'ref:project-resource:resource-source',
|
|
projectId: 'project-1',
|
|
sourceResourceId: 'resource-source',
|
|
assetKind: 'character',
|
|
assetLabel: '角色 · 完美像素',
|
|
canvasCompletion: {
|
|
dialogId,
|
|
title: '角色 · 完美像素',
|
|
placeholder: {
|
|
x: 100,
|
|
y: 120,
|
|
width: 320,
|
|
height: 320,
|
|
originalWidth: 640,
|
|
originalHeight: 640,
|
|
},
|
|
},
|
|
},
|
|
submittedAt: 1_700_000_000_000,
|
|
reconcileUntil: 1_700_000_120_000,
|
|
},
|
|
},
|
|
{
|
|
onRetryPerfectPixelOperation,
|
|
onSubmitImageGeneration,
|
|
},
|
|
);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
|
|
expect(within(panel).getByText(message)).toBeTruthy();
|
|
expect(within(panel).queryByRole('textbox')).toBeNull();
|
|
expect(within(panel).queryByText('不应重新提交的普通提示词')).toBeNull();
|
|
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
|
|
expect(within(panel).queryByRole('button', { name: '修改' })).toBeNull();
|
|
|
|
const retryButton = within(panel).getByRole('button', {
|
|
name: '重试同一完美像素操作',
|
|
});
|
|
expect(within(panel).getAllByRole('button')).toEqual([retryButton]);
|
|
|
|
fireEvent.click(retryButton);
|
|
|
|
expect(onRetryPerfectPixelOperation).toHaveBeenCalledOnce();
|
|
expect(onRetryPerfectPixelOperation).toHaveBeenCalledWith(dialogId);
|
|
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('无效完美像素标记只读展示错误,不暴露普通生成提交入口', () => {
|
|
const onRetryPerfectPixelOperation = vi.fn();
|
|
const onSubmitImageGeneration = vi.fn();
|
|
|
|
renderComposer(
|
|
{
|
|
id: 'dialog-perfect-pixel-invalid',
|
|
mode: 'generate',
|
|
prompt: '不得重新生成',
|
|
status: 'failed',
|
|
composerOpen: true,
|
|
imageModel: 'gpt-image-2',
|
|
perfectPixelOperationInvalid: true,
|
|
errorMessage: '完美像素操作快照身份不匹配。',
|
|
},
|
|
{
|
|
onRetryPerfectPixelOperation,
|
|
onSubmitImageGeneration,
|
|
},
|
|
);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '完美像素操作' });
|
|
expect(within(panel).getByRole('alert').textContent).toBe(
|
|
'完美像素操作快照身份不匹配。',
|
|
);
|
|
expect(within(panel).queryByRole('textbox')).toBeNull();
|
|
expect(within(panel).queryByRole('button')).toBeNull();
|
|
expect(within(panel).queryByText('不得重新生成')).toBeNull();
|
|
expect(within(panel).queryByText('gpt-image-2')).toBeNull();
|
|
|
|
fireEvent.submit(panel);
|
|
|
|
expect(onRetryPerfectPixelOperation).not.toHaveBeenCalled();
|
|
expect(onSubmitImageGeneration).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('让快速编辑显示提示词、尺寸和模型选择', () => {
|
|
renderComposer({
|
|
mode: 'quick-edit',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
sourceLayerId: 'layer-source',
|
|
generationReferences: [],
|
|
imageModel: 'gpt-image-2',
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
});
|
|
|
|
const panel = screen.getByRole('dialog', { name: '快速编辑图片' });
|
|
expect(panel.className).toContain(
|
|
'image-canvas-editor__generation-composer',
|
|
);
|
|
expect(panel.className).toContain(
|
|
'image-canvas-editor__generation-composer--image',
|
|
);
|
|
expect(panel.className).not.toContain(
|
|
'image-canvas-editor__quick-edit-panel',
|
|
);
|
|
expect(
|
|
within(panel).getByRole('textbox', { name: '快速编辑提示词' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(panel).queryByRole('button', { name: '添加参考图' }),
|
|
).toBeNull();
|
|
expect(
|
|
within(panel).getByRole('button', {
|
|
name: '快速编辑图片尺寸 1:1·1K',
|
|
}),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(panel).getByRole('button', {
|
|
name: '快速编辑图片模型 gpt-image-2',
|
|
}),
|
|
).toBeTruthy();
|
|
expect(within(panel).getByRole('button', { name: '修改' })).toBeTruthy();
|
|
expect(
|
|
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('显示游戏场景专用输入和画风控件', () => {
|
|
renderComposer({
|
|
mode: 'scene',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generationReferences: [],
|
|
sceneStylePreset: 'anime',
|
|
sceneCustomStyle: '',
|
|
imageModel: 'gemini-3.1-flash-image-preview',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
});
|
|
|
|
const panel = screen.getByRole('dialog', { name: '游戏场景生成器' });
|
|
const sceneContent = within(panel).getByRole('textbox', {
|
|
name: '画面内容',
|
|
});
|
|
expect(sceneContent.className).toContain('auto-grow-text-area');
|
|
expect(sceneContent.className).not.toContain('platform-text-field');
|
|
expect(
|
|
within(panel).getByRole('button', { name: '画风预设 日系动画' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(panel).getByRole('button', { name: '游戏场景尺寸 16:9·1K' }),
|
|
).toBeTruthy();
|
|
expect(within(panel).queryByText('像素艺术')).toBeNull();
|
|
});
|
|
|
|
it('触屏点击可以打开并关闭当前画风预览', () => {
|
|
renderComposer({
|
|
mode: 'scene',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generationReferences: [],
|
|
sceneStylePreset: 'anime',
|
|
sceneCustomStyle: '',
|
|
imageModel: 'gemini-3.1-flash-image-preview',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '画风预设 日系动画' }));
|
|
const previewButton = screen.getByRole('button', {
|
|
name: '预览日系动画画风',
|
|
});
|
|
|
|
fireEvent.pointerDown(previewButton, { pointerType: 'touch' });
|
|
fireEvent.click(previewButton);
|
|
fireEvent.blur(previewButton);
|
|
expect(previewButton.getAttribute('aria-expanded')).toBe('true');
|
|
expect(screen.getByAltText('日系动画固定画风预览')).toBeTruthy();
|
|
|
|
fireEvent.pointerDown(previewButton, { pointerType: 'touch' });
|
|
fireEvent.click(previewButton);
|
|
expect(previewButton.getAttribute('aria-expanded')).toBe('false');
|
|
expect(screen.queryByAltText('日系动画固定画风预览')).toBeNull();
|
|
|
|
fireEvent.focus(previewButton);
|
|
expect(previewButton.getAttribute('aria-expanded')).toBe('true');
|
|
expect(screen.getByAltText('日系动画固定画风预览')).toBeTruthy();
|
|
});
|
|
|
|
it('让场景自定义画风使用可自增长的多行字段', () => {
|
|
renderComposer({
|
|
mode: 'scene',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generationReferences: [],
|
|
sceneStylePreset: 'custom',
|
|
sceneCustomStyle: '',
|
|
imageModel: 'gemini-3.1-flash-image-preview',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
});
|
|
|
|
const panel = screen.getByRole('dialog', { name: '游戏场景生成器' });
|
|
const customStyle = within(panel).getByRole('textbox', {
|
|
name: '自定义画风',
|
|
});
|
|
expect(customStyle.tagName).toBe('TEXTAREA');
|
|
expect(customStyle.className).toContain('auto-grow-text-area');
|
|
expect(customStyle.className).toContain(
|
|
'image-canvas-editor__generation-prompt',
|
|
);
|
|
expect(customStyle.className).not.toContain('platform-text-field');
|
|
});
|
|
|
|
it('提交空场景内容时在对应字段附近呈现关联错误', () => {
|
|
render(
|
|
<SceneValidationHarness
|
|
initialDialog={{
|
|
mode: 'scene',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generationReferences: [],
|
|
sceneStylePreset: 'anime',
|
|
sceneCustomStyle: '',
|
|
imageModel: 'gemini-3.1-flash-image-preview',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '游戏场景生成器' });
|
|
const sceneContent = within(panel).getByRole('textbox', {
|
|
name: '画面内容',
|
|
});
|
|
|
|
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
|
|
|
|
const error = within(panel).getByRole('alert');
|
|
expect(error.textContent).toBe('请填写画面内容');
|
|
expect(error.className).toContain(
|
|
'image-canvas-editor__scene-field-error',
|
|
);
|
|
expect(sceneContent.getAttribute('aria-invalid')).toBe('true');
|
|
expect(sceneContent.getAttribute('aria-describedby')).toBe(error.id);
|
|
expect(
|
|
panel.querySelector('.image-canvas-editor__generate-status'),
|
|
).toBeNull();
|
|
|
|
fireEvent.change(sceneContent, { target: { value: '雨夜中的海边车站' } });
|
|
|
|
expect(within(panel).queryByRole('alert')).toBeNull();
|
|
expect(sceneContent.getAttribute('aria-invalid')).toBeNull();
|
|
expect(sceneContent.getAttribute('aria-describedby')).toBeNull();
|
|
});
|
|
|
|
it('提交空自定义画风时在自定义字段附近呈现关联错误', () => {
|
|
render(
|
|
<SceneValidationHarness
|
|
initialDialog={{
|
|
mode: 'scene',
|
|
prompt: '雨夜中的海边车站',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
generationReferences: [],
|
|
sceneStylePreset: 'custom',
|
|
sceneCustomStyle: '',
|
|
imageModel: 'gemini-3.1-flash-image-preview',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '游戏场景生成器' });
|
|
const customStyle = within(panel).getByRole('textbox', {
|
|
name: '自定义画风',
|
|
});
|
|
|
|
fireEvent.click(within(panel).getByRole('button', { name: '生成' }));
|
|
|
|
const error = within(panel).getByRole('alert');
|
|
expect(error.textContent).toBe('请填写自定义画风');
|
|
expect(error.className).toContain(
|
|
'image-canvas-editor__scene-field-error',
|
|
);
|
|
expect(customStyle.getAttribute('aria-invalid')).toBe('true');
|
|
expect(customStyle.getAttribute('aria-describedby')).toBe(error.id);
|
|
expect(
|
|
panel.querySelector('.image-canvas-editor__generate-status'),
|
|
).toBeNull();
|
|
|
|
fireEvent.change(customStyle, { target: { value: '90 年代复古像素风' } });
|
|
|
|
expect(within(panel).queryByRole('alert')).toBeNull();
|
|
expect(customStyle.getAttribute('aria-invalid')).toBeNull();
|
|
expect(customStyle.getAttribute('aria-describedby')).toBeNull();
|
|
});
|
|
|
|
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.each([
|
|
['publication-cover-image', '游戏首图', '720 x 540'],
|
|
['publication-detail-gallery', '详情五图', '720 x 1280'],
|
|
['publication-promo-poster', '运营海报', '1280 x 720'],
|
|
] as const)(
|
|
'让%s 宣发素材面板对齐生成角色面板结构',
|
|
(workflowId, label, sizeLabel) => {
|
|
renderComposer({
|
|
mode: 'publication',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
publicationWorkflowId: workflowId,
|
|
publicationGameInfo: {
|
|
gameName: '',
|
|
gameCategories: '',
|
|
gameDescription: '',
|
|
},
|
|
publicationReferences: [],
|
|
imageModel: 'gpt-image-2',
|
|
aspectRatio: '16:9',
|
|
imageSize: '1K',
|
|
});
|
|
|
|
const panel = screen.getByRole('dialog', { name: `${label}生成卡片` });
|
|
expect(panel.className).toContain(
|
|
'image-canvas-editor__character-composer',
|
|
);
|
|
expect(panel.className).toContain(
|
|
'image-canvas-editor__publication-composer',
|
|
);
|
|
expect(
|
|
panel.firstElementChild?.className.includes(
|
|
'image-canvas-editor__reference-strip',
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
within(panel).getByRole('button', { name: '添加参考图' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(panel).getByRole('textbox', { name: `${label}游戏名` }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(panel).getByRole('textbox', { name: `${label}一句话描述游戏` }),
|
|
).toBeTruthy();
|
|
expect(panel.textContent).toContain(sizeLabel);
|
|
expect(
|
|
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(panel).getByRole('button', { name: '生成' }).textContent,
|
|
).toBe('生成3泥点');
|
|
},
|
|
);
|
|
|
|
it('恢复宣发素材生成卡片的字段和参考图', () => {
|
|
renderComposer({
|
|
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',
|
|
},
|
|
],
|
|
imageModel: 'gpt-image-2',
|
|
aspectRatio: '16:9',
|
|
imageSize: '2K',
|
|
});
|
|
|
|
const panel = screen.getByRole('dialog', { name: '运营海报生成卡片' });
|
|
expect(
|
|
(
|
|
within(panel).getByRole('textbox', {
|
|
name: '运营海报游戏名',
|
|
}) as HTMLInputElement
|
|
).value,
|
|
).toBe('马戏团午夜惊魂');
|
|
expect(
|
|
(
|
|
within(panel).getByRole('textbox', {
|
|
name: '运营海报游戏分类',
|
|
}) as HTMLInputElement
|
|
).value,
|
|
).toBe('非对称对抗');
|
|
expect(
|
|
(
|
|
within(panel).getByRole('textbox', {
|
|
name: '运营海报一句话描述游戏',
|
|
}) as HTMLTextAreaElement
|
|
).value,
|
|
).toBe('找到钥匙,开门逃离马戏团');
|
|
expect(within(panel).getByLabelText('首图参考')).toBeTruthy();
|
|
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',
|
|
placeholder: {
|
|
x: 53,
|
|
y: 120,
|
|
width: 854,
|
|
height: 480,
|
|
originalWidth: 854,
|
|
originalHeight: 480,
|
|
},
|
|
});
|
|
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>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|
|
|
|
render(<VideoHarness />);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成视频' });
|
|
const prompt = within(panel).getByRole('textbox', { name: '视频描述' });
|
|
expect(prompt.className).toContain('auto-grow-text-area');
|
|
expect(prompt.className).not.toContain('platform-text-field');
|
|
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: '视频参数选项',
|
|
});
|
|
expect(
|
|
paramsPanel
|
|
.closest('.image-canvas-editor__portal-theme')
|
|
?.classList.contains('platform-theme--light'),
|
|
).toBe(true);
|
|
const ratioChoice = within(paramsPanel).getByRole('button', {
|
|
name: '比例 16:9',
|
|
});
|
|
expect(ratioChoice.className).toContain(
|
|
'image-canvas-editor__option-popover-choice--ratio',
|
|
);
|
|
expect(
|
|
ratioChoice
|
|
.querySelector('.image-canvas-editor__ratio-wireframe')
|
|
?.getAttribute('data-ratio'),
|
|
).toBe('16:9');
|
|
expect(
|
|
within(paramsPanel)
|
|
.getByRole('button', { name: '比例 21:9' })
|
|
.querySelector('.image-canvas-editor__ratio-wireframe')
|
|
?.getAttribute('data-ratio'),
|
|
).toBe('21:9');
|
|
fireEvent.change(
|
|
within(paramsPanel).getByRole('slider', { name: '视频时长' }),
|
|
{
|
|
target: { value: '5' },
|
|
},
|
|
);
|
|
fireEvent.click(
|
|
within(paramsPanel).getByRole('button', { name: '清晰度 720p' }),
|
|
);
|
|
fireEvent.click(within(paramsPanel).getByRole('button', { name: '静音' }));
|
|
|
|
expect(screen.getByRole('menu', { name: '视频参数选项' })).toBeTruthy();
|
|
expect(screen.getByLabelText('当前视频时长').textContent).toBe('5');
|
|
expect(screen.getByLabelText('当前视频清晰度').textContent).toBe('720p');
|
|
expect(screen.getByLabelText('当前视频占位').textContent).toBe(
|
|
'-160:0:1280:720:1280:720',
|
|
);
|
|
expect(
|
|
screen.getByRole('button', { name: '视频参数 16:9 · 5秒 · 720p' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(paramsPanel)
|
|
.getByRole('button', { name: '清晰度 1080p' })
|
|
.hasAttribute('disabled'),
|
|
).toBe(true);
|
|
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();
|
|
expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe(
|
|
'生成150泥点',
|
|
);
|
|
});
|
|
|
|
it('生成游戏音效面板使用 Vidu duration 参数且不再显示类型和 BPM', () => {
|
|
const onSubmitImageGeneration = vi.fn();
|
|
function AudioHarness() {
|
|
const [dialog, setDialog] = useState<GenerateDialogState>({
|
|
mode: 'audio-sound-effect',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
soundModel: 'audio1.0',
|
|
soundDurationSeconds: 5,
|
|
});
|
|
return (
|
|
<>
|
|
<ImageCanvasGenerationComposerView
|
|
{...createComposerProps(dialog, { onSubmitImageGeneration })}
|
|
setGenerateDialog={
|
|
setDialog as Dispatch<SetStateAction<GenerateDialogState | null>>
|
|
}
|
|
/>
|
|
<output aria-label="当前音效模型">{dialog.soundModel}</output>
|
|
<output aria-label="当前音效时长">
|
|
{dialog.soundDurationSeconds ?? 'null'}
|
|
</output>
|
|
</>
|
|
);
|
|
}
|
|
|
|
render(<AudioHarness />);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
|
|
const footer = panel.querySelector(
|
|
'.image-canvas-editor__generation-composer-footer',
|
|
);
|
|
expect(footer).toBeTruthy();
|
|
expect(footer?.children[0]?.className).toContain(
|
|
'image-canvas-editor__option-popover-anchor--dimensions',
|
|
);
|
|
expect(footer?.children[1]?.className).toContain(
|
|
'image-canvas-editor__option-popover-anchor--model',
|
|
);
|
|
expect(footer?.children[2]?.className).toContain(
|
|
'image-canvas-editor__generation-submit',
|
|
);
|
|
const prompt = within(panel).getByRole('textbox', { name: 'prompt' });
|
|
expect(prompt.className).toContain('auto-grow-text-area');
|
|
expect(prompt.className).not.toContain('platform-text-field');
|
|
expect(screen.getByLabelText('当前音效模型').textContent).toBe('audio1.0');
|
|
const soundModelButton = within(panel).getByRole('button', {
|
|
name: '音效模型 Vidu',
|
|
});
|
|
expect((soundModelButton as HTMLButtonElement).disabled).toBe(true);
|
|
expect(within(panel).getByText('Vidu')).toBeTruthy();
|
|
expect(within(panel).queryByText(/Suno/u)).toBeNull();
|
|
expect(
|
|
within(panel).getByRole('button', { name: '音效时长 5秒' }),
|
|
).toBeTruthy();
|
|
expect(within(panel).queryByText('单次')).toBeNull();
|
|
expect(within(panel).queryByText('循环')).toBeNull();
|
|
expect(within(panel).queryByText(/BPM/u)).toBeNull();
|
|
expect(within(panel).queryByRole('button', { name: 'AI 补全' })).toBeNull();
|
|
expect(
|
|
within(panel).queryByRole('button', { name: '一键简化' }),
|
|
).toBeNull();
|
|
expect(
|
|
within(panel).queryByRole('button', { name: '一键优化' }),
|
|
).toBeNull();
|
|
expect(within(panel).queryByRole('button', { name: '展开' })).toBeNull();
|
|
expect(
|
|
within(panel).getByRole('button', { name: '生成游戏音效' }).textContent,
|
|
).toBe('生成5泥点');
|
|
|
|
fireEvent.click(
|
|
within(panel).getByRole('button', { name: '音效时长 5秒' }),
|
|
);
|
|
const optionPanel = screen.getByRole('menu', { name: '音效时长选项' });
|
|
const durationSlider = within(optionPanel).getByRole('slider', {
|
|
name: '音效时长',
|
|
}) as HTMLInputElement;
|
|
expect(durationSlider.min).toBe('2');
|
|
expect(durationSlider.max).toBe('10');
|
|
expect(durationSlider.step).toBe('1');
|
|
expect(durationSlider.value).toBe('5');
|
|
expect(
|
|
within(optionPanel).queryByRole('button', { name: '时长 2秒' }),
|
|
).toBeNull();
|
|
expect(within(optionPanel).queryByText(/BPM/u)).toBeNull();
|
|
fireEvent.change(durationSlider, { target: { value: '8' } });
|
|
|
|
expect(screen.getByLabelText('当前音效时长').textContent).toBe('8');
|
|
expect(screen.getByRole('button', { name: '音效时长 8秒' })).toBeTruthy();
|
|
|
|
fireEvent.submit(panel);
|
|
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
mode: 'audio-sound-effect',
|
|
soundDurationSeconds: 8,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('生成游戏背景音乐面板只展示 gpt_description_prompt 并固定隐藏 instrumental 开关', () => {
|
|
renderComposer(
|
|
{
|
|
id: 'dialog-background-music',
|
|
mode: 'audio-background-music',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
},
|
|
{
|
|
backgroundMusicPromptAssist: createBackgroundMusicPromptAssistStub(),
|
|
},
|
|
);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成游戏背景音乐' });
|
|
expect(
|
|
within(panel).getByRole('textbox', { name: 'gpt_description_prompt' }),
|
|
).toBeTruthy();
|
|
const footer = panel.querySelector(
|
|
'.image-canvas-editor__generation-composer-footer',
|
|
);
|
|
expect(footer).toBeTruthy();
|
|
// 助手动作与模型、生成同处底部一行:左侧动作,右侧「Suno + 生成」。
|
|
expect(footer?.children[0]?.className).toContain(
|
|
'image-canvas-editor__background-music-prompt-actions',
|
|
);
|
|
expect(footer?.children[1]?.className).toContain(
|
|
'image-canvas-editor__option-popover-anchor--model',
|
|
);
|
|
expect(footer?.children[2]?.className).toContain(
|
|
'image-canvas-editor__generation-submit',
|
|
);
|
|
// 字数计数和「预设」同排,收起预设时也必须还在。
|
|
const presetsHeader = panel.querySelector(
|
|
'.image-canvas-editor__background-music-presets-header',
|
|
);
|
|
expect(
|
|
presetsHeader?.querySelector(
|
|
'.image-canvas-editor__background-music-prompt-count',
|
|
)?.textContent,
|
|
).toBe('0 / 200');
|
|
const backgroundMusicModelButton = within(panel).getByRole('button', {
|
|
name: '背景音乐模型 Suno',
|
|
});
|
|
expect((backgroundMusicModelButton as HTMLButtonElement).disabled).toBe(
|
|
true,
|
|
);
|
|
expect(within(panel).getByText('Suno')).toBeTruthy();
|
|
expect(within(panel).queryByText('Vidu')).toBeNull();
|
|
expect(
|
|
within(panel).queryByRole('button', { name: /音效时长/u }),
|
|
).toBeNull();
|
|
expect(within(panel).queryByText('make_instrumental')).toBeNull();
|
|
expect(
|
|
within(panel).getByRole('button', { name: '生成游戏背景音乐' })
|
|
.textContent,
|
|
).toBe('生成12泥点');
|
|
});
|
|
|
|
describe('共享音频 composer 的背景音乐行为', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal(
|
|
'requestAnimationFrame',
|
|
vi.fn(() => 1),
|
|
);
|
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
});
|
|
|
|
it('按 canonical preview 计数但不改写输入框原文', () => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({
|
|
prompt: ' 森林冒险背景音乐 ',
|
|
}),
|
|
});
|
|
|
|
const textarea = getBackgroundMusicPromptTextarea();
|
|
expect(textarea.value).toBe(' 森林冒险背景音乐 ');
|
|
expect(
|
|
within(getBackgroundMusicPanel()).getByText('8 / 200'),
|
|
).toBeTruthy();
|
|
expect(textarea.getAttribute('aria-describedby')).toBe(
|
|
within(getBackgroundMusicPanel()).getByText('8 / 200').id,
|
|
);
|
|
expect(textarea.getAttribute('aria-invalid')).toBeNull();
|
|
expect(textarea.hasAttribute('maxlength')).toBe(false);
|
|
});
|
|
|
|
it.each([
|
|
['', false, false, false],
|
|
[' ', false, false, false],
|
|
['森', false, false, true],
|
|
['森林', true, false, true],
|
|
['森'.repeat(200), true, false, true],
|
|
['森'.repeat(201), false, true, false],
|
|
['森'.repeat(2000), false, true, false],
|
|
['森'.repeat(2001), false, false, false],
|
|
])(
|
|
'按 canonical code point 解析第 %# 组动作资格',
|
|
(prompt, canComplete, canSimplify, canGenerate) => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt }),
|
|
});
|
|
|
|
expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe(
|
|
!canComplete,
|
|
);
|
|
expect(getBackgroundMusicActionButton('一键简化').disabled).toBe(
|
|
!canSimplify,
|
|
);
|
|
expect(
|
|
getBackgroundMusicActionButton('生成游戏背景音乐').disabled,
|
|
).toBe(!canGenerate);
|
|
},
|
|
);
|
|
|
|
it('保留超出接口上限的原文并标记计数无效', () => {
|
|
const prompt = '森'.repeat(2001);
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt }),
|
|
});
|
|
|
|
const textarea = getBackgroundMusicPromptTextarea();
|
|
expect(textarea.value).toBe(prompt);
|
|
expect(textarea.value).toHaveLength(2001);
|
|
expect(textarea.getAttribute('aria-invalid')).toBe('true');
|
|
expect(
|
|
within(getBackgroundMusicPanel()).getByText('2001 / 200'),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('把助手动作路由到当前 dialog ID', () => {
|
|
const promptAssist = createBackgroundMusicPromptAssistStub();
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({
|
|
id: 'dialog-second',
|
|
prompt: '森林冒险',
|
|
}),
|
|
promptAssist,
|
|
});
|
|
|
|
fireEvent.click(getBackgroundMusicActionButton('AI 补全'));
|
|
expect(promptAssist.completePrompt).toHaveBeenCalledWith('dialog-second');
|
|
expect(promptAssist.simplifyPrompt).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('把预设追加到 preparePreset 返回的 canonical prompt', () => {
|
|
const updateCanvasGenerationDialogById = vi.fn();
|
|
const promptAssist = createBackgroundMusicPromptAssistStub(
|
|
{},
|
|
{ preparePreset: vi.fn(() => '阳光动物园') },
|
|
);
|
|
const dialog = createBackgroundMusicDialog({ prompt: ' 阳光动物园 ' });
|
|
renderBackgroundMusicComposer({
|
|
dialog,
|
|
promptAssist,
|
|
updateCanvasGenerationDialogById,
|
|
});
|
|
|
|
fireEvent.click(getBackgroundMusicActionButton('展开'));
|
|
const firstPreset = BACKGROUND_MUSIC_PROMPT_PRESETS[0];
|
|
fireEvent.click(getBackgroundMusicActionButton(firstPreset.label));
|
|
|
|
expect(promptAssist.preparePreset).toHaveBeenCalledWith('dialog-bgm');
|
|
const { dialogId, nextDialog } = applyBackgroundMusicDialogUpdate(
|
|
updateCanvasGenerationDialogById,
|
|
dialog,
|
|
);
|
|
expect(dialogId).toBe('dialog-bgm');
|
|
expect(nextDialog?.prompt).toBe(`阳光动物园。${firstPreset.prompt}`);
|
|
});
|
|
|
|
it('preparePreset 拒绝当前 dialog 时不写回', () => {
|
|
const updateCanvasGenerationDialogById = vi.fn();
|
|
const promptAssist = createBackgroundMusicPromptAssistStub(
|
|
{},
|
|
{ preparePreset: vi.fn(() => null) },
|
|
);
|
|
renderBackgroundMusicComposer({
|
|
promptAssist,
|
|
updateCanvasGenerationDialogById,
|
|
});
|
|
|
|
fireEvent.click(getBackgroundMusicActionButton('展开'));
|
|
fireEvent.click(
|
|
getBackgroundMusicActionButton(
|
|
BACKGROUND_MUSIC_PROMPT_PRESETS[0].label,
|
|
),
|
|
);
|
|
|
|
expect(promptAssist.preparePreset).toHaveBeenCalledWith('dialog-bgm');
|
|
expect(updateCanvasGenerationDialogById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('同时展示助手错误和已有生成错误并保持顺序', () => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({
|
|
prompt: '森林冒险',
|
|
status: 'failed',
|
|
errorMessage: '生成失败',
|
|
}),
|
|
promptAssist: createBackgroundMusicPromptAssistStub({
|
|
errorMessage: 'AI 暂时不可用',
|
|
}),
|
|
});
|
|
|
|
const alerts = within(getBackgroundMusicPanel()).getAllByRole('alert');
|
|
expect(alerts.map((alert) => alert.textContent)).toEqual([
|
|
'AI 暂时不可用',
|
|
'生成失败',
|
|
]);
|
|
});
|
|
|
|
it('保留固定 Suno 模型和动态泥点价格', () => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }),
|
|
});
|
|
|
|
const modelButton = getBackgroundMusicActionButton('背景音乐模型 Suno');
|
|
expect(modelButton.disabled).toBe(true);
|
|
expect(
|
|
getBackgroundMusicActionButton('生成游戏背景音乐').textContent,
|
|
).toBe('生成12泥点');
|
|
});
|
|
|
|
it('仅在允许生成时提交当前 dialog', () => {
|
|
const onSubmit = vi.fn();
|
|
const dialog = createBackgroundMusicDialog({ prompt: '森林冒险' });
|
|
renderBackgroundMusicComposer({ dialog, onSubmit });
|
|
|
|
fireEvent.submit(getBackgroundMusicPanel());
|
|
expect(onSubmit).toHaveBeenCalledWith(dialog);
|
|
|
|
onSubmit.mockClear();
|
|
const blocked = renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt: ' ' }),
|
|
onSubmit,
|
|
});
|
|
fireEvent.submit(
|
|
within(blocked.container).getByRole('dialog', {
|
|
name: '生成游戏背景音乐',
|
|
}),
|
|
);
|
|
expect(onSubmit).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
['completing' as const],
|
|
['simplifying' as const],
|
|
['submitting' as const],
|
|
])('%s 状态锁定面板中的全部控件', (status) => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }),
|
|
promptAssist: createBackgroundMusicPromptAssistStub({
|
|
status,
|
|
operationId: 'operation-1',
|
|
temporaryPromptSnapshot: '森林冒险',
|
|
}),
|
|
});
|
|
|
|
const panel = getBackgroundMusicPanel();
|
|
expect(panel.getAttribute('aria-busy')).toBe('true');
|
|
expect(getBackgroundMusicPromptTextarea().readOnly).toBe(true);
|
|
expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe(true);
|
|
expect(getBackgroundMusicActionButton('一键简化').disabled).toBe(true);
|
|
expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true);
|
|
expect(getBackgroundMusicActionButton('展开').disabled).toBe(true);
|
|
expect(getBackgroundMusicActionButton('生成游戏背景音乐').disabled).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it('已有生成占位运行时锁定面板', () => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({
|
|
prompt: '森林冒险',
|
|
status: 'generating',
|
|
}),
|
|
});
|
|
|
|
expect(getBackgroundMusicPanel().getAttribute('aria-busy')).toBe('true');
|
|
expect(getBackgroundMusicPromptTextarea().readOnly).toBe(true);
|
|
expect(getBackgroundMusicActionButton('AI 补全').disabled).toBe(true);
|
|
expect(getBackgroundMusicActionButton('展开').disabled).toBe(true);
|
|
expect(
|
|
getBackgroundMusicActionButton('生成游戏背景音乐').textContent,
|
|
).toBe('生成中');
|
|
});
|
|
|
|
describe('撤销按钮矩阵', () => {
|
|
it('没有任何快照时保持隐藏', () => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }),
|
|
});
|
|
|
|
expect(
|
|
within(getBackgroundMusicPanel()).queryByRole('button', {
|
|
name: '撤销',
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each([['completing' as const], ['simplifying' as const]])(
|
|
'%s 仅持有临时快照时展示禁用的撤销',
|
|
(status) => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }),
|
|
promptAssist: createBackgroundMusicPromptAssistStub({
|
|
status,
|
|
operationId: 'operation-1',
|
|
undoPromptSnapshot: null,
|
|
temporaryPromptSnapshot: '森林冒险',
|
|
}),
|
|
});
|
|
|
|
expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true);
|
|
},
|
|
);
|
|
|
|
it('助手成功后及手动编辑后允许撤销', () => {
|
|
const promptAssist = createBackgroundMusicPromptAssistStub({
|
|
undoPromptSnapshot: '森林冒险',
|
|
});
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({
|
|
prompt: '完整的森林冒险背景音乐,手动改过',
|
|
}),
|
|
promptAssist,
|
|
});
|
|
|
|
const undoButton = getBackgroundMusicActionButton('撤销');
|
|
expect(undoButton.disabled).toBe(false);
|
|
|
|
fireEvent.click(undoButton);
|
|
expect(promptAssist.undoPrompt).toHaveBeenCalledWith('dialog-bgm');
|
|
});
|
|
|
|
it('助手失败清空两份快照后重新隐藏撤销', () => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }),
|
|
promptAssist: createBackgroundMusicPromptAssistStub({
|
|
undoPromptSnapshot: null,
|
|
temporaryPromptSnapshot: null,
|
|
errorMessage: 'AI 暂时不可用',
|
|
}),
|
|
});
|
|
|
|
expect(
|
|
within(getBackgroundMusicPanel()).queryByRole('button', {
|
|
name: '撤销',
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('有快照且正在提交时保持可见但禁用', () => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }),
|
|
promptAssist: createBackgroundMusicPromptAssistStub({
|
|
status: 'submitting',
|
|
operationId: 'operation-1',
|
|
undoPromptSnapshot: '森林冒险',
|
|
}),
|
|
});
|
|
|
|
expect(getBackgroundMusicActionButton('撤销').disabled).toBe(true);
|
|
});
|
|
|
|
it('没有快照且正在提交时保持隐藏', () => {
|
|
renderBackgroundMusicComposer({
|
|
dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }),
|
|
promptAssist: createBackgroundMusicPromptAssistStub({
|
|
status: 'submitting',
|
|
operationId: 'operation-1',
|
|
undoPromptSnapshot: null,
|
|
}),
|
|
});
|
|
|
|
expect(
|
|
within(getBackgroundMusicPanel()).queryByRole('button', {
|
|
name: '撤销',
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('共享音频 composer 的 mode 与 dialog 隔离', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal(
|
|
'requestAnimationFrame',
|
|
vi.fn(() => 1),
|
|
);
|
|
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: '缺少稳定 dialog ID',
|
|
dialog: {
|
|
mode: 'audio-background-music' as const,
|
|
prompt: '森林冒险',
|
|
status: 'idle' as const,
|
|
composerOpen: true,
|
|
},
|
|
overrides: {
|
|
backgroundMusicPromptAssist: createBackgroundMusicPromptAssistStub(),
|
|
},
|
|
},
|
|
{
|
|
label: '缺少助手 controller',
|
|
dialog: createBackgroundMusicDialog({ prompt: '森林冒险' }),
|
|
overrides: {},
|
|
},
|
|
])('$label 的 BGM 失败关闭', ({ dialog, overrides }) => {
|
|
renderComposer(dialog, overrides);
|
|
|
|
expect(
|
|
screen.queryByRole('dialog', { name: '生成游戏背景音乐' }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('缺少稳定 ID 和 BGM controller 不影响 SFX 正常渲染及默认时长', () => {
|
|
renderComposer({
|
|
mode: 'audio-sound-effect',
|
|
prompt: '',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
soundModel: 'audio1.0',
|
|
});
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
|
|
expect(
|
|
within(panel).getByRole('button', { name: '音效时长 5秒' }),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(panel).queryByRole('button', { name: 'AI 补全' }),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('切换 BGM dialog ID 时不继承预设展开状态', () => {
|
|
const promptAssist = createBackgroundMusicPromptAssistStub();
|
|
const firstDialog = createBackgroundMusicDialog({
|
|
id: 'dialog-bgm-a',
|
|
prompt: '森林冒险',
|
|
});
|
|
const view = renderBackgroundMusicComposer({
|
|
dialog: firstDialog,
|
|
promptAssist,
|
|
});
|
|
|
|
fireEvent.click(getBackgroundMusicActionButton('展开'));
|
|
expect(getBackgroundMusicActionButton('收起')).toBeTruthy();
|
|
expect(
|
|
getBackgroundMusicActionButton(
|
|
BACKGROUND_MUSIC_PROMPT_PRESETS[0].label,
|
|
),
|
|
).toBeTruthy();
|
|
|
|
const secondDialog = createBackgroundMusicDialog({
|
|
id: 'dialog-bgm-b',
|
|
prompt: '海边追逐',
|
|
});
|
|
view.rerender(
|
|
<ImageCanvasGenerationComposerView
|
|
{...createComposerProps(secondDialog, {
|
|
backgroundMusicPromptAssist: promptAssist,
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(getBackgroundMusicActionButton('展开')).toBeTruthy();
|
|
expect(
|
|
within(getBackgroundMusicPanel()).queryByRole('button', {
|
|
name: BACKGROUND_MUSIC_PROMPT_PRESETS[0].label,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
|
|
it('SFX 与 BGM 切换时不泄漏菜单或 BGM 助手锁', () => {
|
|
const promptAssist = createBackgroundMusicPromptAssistStub({
|
|
status: 'submitting',
|
|
operationId: 'operation-1',
|
|
});
|
|
const soundEffectDialog: GenerateDialogState = {
|
|
mode: 'audio-sound-effect',
|
|
prompt: '开门声',
|
|
status: 'idle',
|
|
composerOpen: true,
|
|
soundModel: 'audio1.0',
|
|
soundDurationSeconds: 5,
|
|
};
|
|
const view = renderComposer(soundEffectDialog, {
|
|
backgroundMusicPromptAssist: promptAssist,
|
|
});
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '音效时长 5秒' }));
|
|
expect(screen.getByRole('menu', { name: '音效时长选项' })).toBeTruthy();
|
|
|
|
const backgroundMusicDialog = createBackgroundMusicDialog({
|
|
id: 'dialog-bgm-locked',
|
|
prompt: '森林冒险',
|
|
});
|
|
view.rerender(
|
|
<ImageCanvasGenerationComposerView
|
|
{...createComposerProps(backgroundMusicDialog, {
|
|
backgroundMusicPromptAssist: promptAssist,
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull();
|
|
expect(getBackgroundMusicPanel().getAttribute('aria-busy')).toBe('true');
|
|
|
|
view.rerender(
|
|
<ImageCanvasGenerationComposerView
|
|
{...createComposerProps(soundEffectDialog, {
|
|
backgroundMusicPromptAssist: promptAssist,
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
const soundEffectPanel = screen.getByRole('dialog', {
|
|
name: '生成游戏音效',
|
|
});
|
|
expect(
|
|
(
|
|
within(soundEffectPanel).getByRole('textbox', {
|
|
name: 'prompt',
|
|
}) as HTMLTextAreaElement
|
|
).disabled,
|
|
).toBe(false);
|
|
expect(
|
|
(
|
|
within(soundEffectPanel).getByRole('button', {
|
|
name: '生成游戏音效',
|
|
}) as HTMLButtonElement
|
|
).disabled,
|
|
).toBe(false);
|
|
expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull();
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|