Files
Genarrative/src/components/image-editor/ImageCanvasGenerationComposerView.test.tsx
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

1973 lines
66 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 { setPlainTextEditorValue } from '../common/AutoGrowTextArea.test-utils';
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';
import type {
SoundEffectPromptAssistComposerController,
SoundEffectPromptAssistDialogState,
} from './useImageCanvasSoundEffectPromptAssist';
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,
soundEffectPromptAssist: createSoundEffectPromptAssistStub(),
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 createSoundEffectPromptAssistStub(
assistState: Partial<SoundEffectPromptAssistDialogState> = {},
overrides: Partial<SoundEffectPromptAssistComposerController> = {},
): SoundEffectPromptAssistComposerController {
return {
getDialogState: (dialogId: string) => ({
dialogId,
status: 'idle' as const,
operationId: null,
undoPromptSnapshot: null,
temporaryPromptSnapshot: null,
errorMessage: null,
...assistState,
}),
optimizePrompt: 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',
});
}
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();
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.getAttribute('contenteditable')).toBe('true');
expect(customStyle.closest('.auto-grow-text-area')?.className).toContain(
'auto-grow-text-area',
);
expect(customStyle.closest('.auto-grow-text-area')?.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();
setPlainTextEditorValue(sceneContent, '雨夜中的海边车站');
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();
setPlainTextEditorValue(customStyle, '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设计要求' })
.closest('.auto-grow-text-area')?.className,
).toContain('image-canvas-editor__generation-prompt');
expect(
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();
setPlainTextEditorValue(
within(panel).getByRole('textbox', { name: 'UI设计要求' }),
'主界面和结算弹窗',
);
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: '运营海报一句话描述游戏',
}).textContent,
).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('生成游戏音效面板展示 SFX V2 预设、参数和 ElevenLabs', () => {
const onSubmitImageGeneration = vi.fn();
function AudioHarness() {
const [dialog, setDialog] = useState<GenerateDialogState>({
id: 'dialog-sfx',
mode: 'audio-sound-effect',
prompt: '开门声',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 5,
soundLoop: false,
});
const updateCanvasGenerationDialogById = (
dialogId: string,
updater: (
current: CanvasGenerationDialogState,
) => CanvasGenerationDialogState | null,
) => {
setDialog((current) => {
if (current.id !== dialogId) {
return current;
}
return updater(current as CanvasGenerationDialogState) ?? current;
});
};
return (
<>
<ImageCanvasGenerationComposerView
{...createComposerProps(dialog, {
onSubmitImageGeneration,
updateCanvasGenerationDialogById,
})}
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();
// 底部一行:左侧一键优化与时长、循环,右侧 ElevenLabs + 生成。
expect(footer?.children[0]?.className).toContain(
'image-canvas-editor__sound-effect-prompt-actions',
);
expect(footer?.children[1]?.className).toContain(
'image-canvas-editor__option-popover-anchor--dimensions',
);
expect(footer?.children[2]?.className).toContain(
'image-canvas-editor__sound-effect-loop',
);
expect(footer?.children[3]?.className).toContain(
'image-canvas-editor__option-popover-anchor--model',
);
expect(footer?.children[4]?.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(
'eleven_text_to_sound_v2',
);
const soundModelButton = within(panel).getByRole('button', {
name: '音效模型 ElevenLabs',
});
expect((soundModelButton as HTMLButtonElement).disabled).toBe(true);
expect(within(panel).getByText('ElevenLabs')).toBeTruthy();
expect(within(panel).queryByText(/Suno/u)).toBeNull();
expect(
within(panel).getByRole('button', { name: '音效时长 5秒' }),
).toBeTruthy();
expect(within(panel).queryByText('单次')).toBeNull();
expect(
within(panel)
.getByRole('button', { name: '循环 关' })
.getAttribute('aria-pressed'),
).toBe('false');
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).getByRole('button', { name: '一键优化' }),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '展开' })).toBeTruthy();
expect(
within(panel).getByRole('button', { name: '生成游戏音效' }).textContent,
).toBe('生成5泥点');
fireEvent.click(
within(panel).getByRole('button', { name: '音效时长 5秒' }),
);
const optionPanel = screen.getByRole('group', { name: '音效时长选项' });
const durationSlider = within(optionPanel).getByRole('slider', {
name: '手动音效时长',
}) as HTMLInputElement;
expect(durationSlider.min).toBe('0.5');
expect(durationSlider.max).toBe('30');
expect(durationSlider.step).toBe('0.1');
expect(durationSlider.value).toBe('5');
expect(
within(optionPanel).queryByRole('button', { name: '时长 2秒' }),
).toBeNull();
expect(within(optionPanel).queryByText(/BPM/u)).toBeNull();
const automaticDuration = within(optionPanel).getByRole('checkbox', {
name: '自动时长',
}) as HTMLInputElement;
fireEvent.click(automaticDuration);
expect(
screen.getByRole('button', { name: '音效时长 自动时长' }),
).toBeTruthy();
expect(durationSlider.disabled).toBe(true);
fireEvent.click(automaticDuration);
expect(durationSlider.disabled).toBe(false);
expect(durationSlider.value).toBe('5');
fireEvent.change(durationSlider, { target: { value: '8' } });
expect(screen.getByLabelText('当前音效时长').textContent).toBe('8');
expect(screen.getByRole('button', { name: '音效时长 8秒' })).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: '循环 关' }));
fireEvent.click(
within(panel).getByRole('button', { name: '生成游戏音效' }),
);
expect(onSubmitImageGeneration).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'audio-sound-effect',
soundDurationSeconds: 8,
soundLoop: true,
}),
);
});
it('SFX 优化中保留撤销按钮并只锁当前 dialog 的全部控件', () => {
renderComposer(
{
id: 'dialog-sfx-locked',
mode: 'audio-sound-effect',
prompt: '金币落地声',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationMode: 'manual',
soundDurationSeconds: 5,
soundLoop: false,
},
{
soundEffectPromptAssist: createSoundEffectPromptAssistStub({
status: 'optimizing',
operationId: 'operation-sfx-1',
temporaryPromptSnapshot: '金币落地声',
}),
},
);
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
expect(panel.getAttribute('aria-busy')).toBe('true');
// AutoGrowTextArea 改用 Lexical contentEditable 后不再有原生 readOnly 属性,
// 锁定态由 aria-readonly 暴露(同时 editor.setEditable(false))。
expect(
within(panel)
.getByRole('textbox', { name: 'prompt' })
.getAttribute('aria-readonly'),
).toBe('true');
for (const name of [
'展开',
'一键优化',
'撤销',
'音效时长 5秒',
'循环 关',
'生成游戏音效',
]) {
expect(
(within(panel).getByRole('button', { name }) as HTMLButtonElement)
.disabled,
).toBe(true);
}
expect(within(panel).getByText('优化中')).toBeTruthy();
expect(within(panel).queryByText('Suno')).toBeNull();
expect(within(panel).queryByRole('button', { name: 'AI 补全' })).toBeNull();
});
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.textContent).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.textContent).toBe(prompt);
expect(textarea.textContent).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.click(getBackgroundMusicActionButton('生成游戏背景音乐'));
expect(onSubmit).toHaveBeenCalledWith(dialog);
onSubmit.mockClear();
const blocked = renderBackgroundMusicComposer({
dialog: createBackgroundMusicDialog({ prompt: ' ' }),
onSubmit,
});
fireEvent.click(
within(blocked.container).getByRole('button', {
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().getAttribute('aria-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().getAttribute('aria-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('BGM controller 不影响带稳定 ID 的 SFX 正常渲染及默认时长', () => {
renderComposer({
id: 'dialog-sfx',
mode: 'audio-sound-effect',
prompt: '',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
});
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 = {
id: 'dialog-sfx',
mode: 'audio-sound-effect',
prompt: '开门声',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationSeconds: 5,
};
const view = renderComposer(soundEffectDialog, {
backgroundMusicPromptAssist: promptAssist,
});
fireEvent.click(screen.getByRole('button', { name: '音效时长 5秒' }));
expect(screen.getByRole('group', { name: '音效时长选项' })).toBeTruthy();
const backgroundMusicDialog = createBackgroundMusicDialog({
id: 'dialog-bgm-locked',
prompt: '森林冒险',
});
view.rerender(
<ImageCanvasGenerationComposerView
{...createComposerProps(backgroundMusicDialog, {
backgroundMusicPromptAssist: promptAssist,
})}
/>,
);
expect(screen.queryByRole('group', { 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' })
.getAttribute('aria-disabled'),
).toBeNull();
expect(
(
within(soundEffectPanel).getByRole('button', {
name: '生成游戏音效',
}) as HTMLButtonElement
).disabled,
).toBe(false);
expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull();
});
it('音效时长浮层是参数分组,打开后移焦并支持 Escape 关闭回焦', () => {
const soundEffectDialog: GenerateDialogState = {
id: 'dialog-sfx-options-a11y',
mode: 'audio-sound-effect',
prompt: '开门声',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationSeconds: 5,
};
renderComposer(soundEffectDialog);
const trigger = screen.getByRole('button', { name: '音效时长 5秒' });
fireEvent.click(trigger);
// 复选框和滑块不是 menuitem,浮层不能再声明成菜单。
const optionPanel = screen.getByRole('group', { name: '音效时长选项' });
expect(screen.queryByRole('menu', { name: '音效时长选项' })).toBeNull();
expect(within(optionPanel).queryAllByRole('menuitem')).toHaveLength(0);
// 浮层 portal 到 body,必须主动移焦,否则参数控件键盘不可达。
const automaticDuration = within(optionPanel).getByRole('checkbox', {
name: '自动时长',
});
expect(document.activeElement).toBe(automaticDuration);
// 画布全局快捷键在 window 上监听 Escape 并会关掉整个生成 dialog;浮层打开时
// 这一次 Escape 必须被浮层吃掉,不能冒泡到 window。
const globalEscape = vi.fn();
window.addEventListener('keydown', globalEscape);
try {
fireEvent.keyDown(automaticDuration, { key: 'Escape' });
expect(
screen.queryByRole('group', { name: '音效时长选项' }),
).toBeNull();
expect(document.activeElement).toBe(trigger);
expect(globalEscape).not.toHaveBeenCalled();
// 浮层关闭后 Escape 重新归全局快捷键所有。
fireEvent.keyDown(trigger, { key: 'Escape' });
expect(globalEscape).toHaveBeenCalledTimes(1);
} finally {
window.removeEventListener('keydown', globalEscape);
}
});
it('点击浮层外部只收起时长浮层,不抢走焦点', () => {
const soundEffectDialog: GenerateDialogState = {
id: 'dialog-sfx-options-outside-click',
mode: 'audio-sound-effect',
prompt: '开门声',
status: 'idle',
composerOpen: true,
soundModel: 'eleven_text_to_sound_v2',
soundDurationSeconds: 5,
};
renderComposer(soundEffectDialog);
const trigger = screen.getByRole('button', { name: '音效时长 5秒' });
fireEvent.click(trigger);
expect(screen.getByRole('group', { name: '音效时长选项' })).toBeTruthy();
const promptInput = screen.getByRole('textbox', { name: 'prompt' });
promptInput.focus();
fireEvent.click(promptInput);
// 外部点击只关闭浮层;还焦是 Escape 专属,否则会把用户从输入框拽回触发按钮。
expect(screen.queryByRole('group', { name: '音效时长选项' })).toBeNull();
expect(document.activeElement).toBe(promptInput);
});
});
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();
});
});