292 lines
9.8 KiB
TypeScript
292 lines
9.8 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { fireEvent, render, screen, within } from '@testing-library/react';
|
|
import { createRef, useState } from 'react';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
|
|
import {
|
|
getPlainTextEditorHost,
|
|
setPlainTextEditorValue,
|
|
} from '../common/AutoGrowTextArea.test-utils';
|
|
import type {
|
|
GenerateDialogState,
|
|
UploadTarget,
|
|
} from './ImageCanvasEditorTypes';
|
|
import { ImageCanvasIconSpritesheetComposerView } from './ImageCanvasIconSpritesheetComposerView';
|
|
|
|
function createIconDialog(
|
|
patch: Partial<GenerateDialogState> = {},
|
|
): GenerateDialogState {
|
|
return {
|
|
mode: 'icon',
|
|
prompt: '',
|
|
status: 'idle',
|
|
iconDescriptions: ['剑', '盾'],
|
|
imageModel: 'nanobanana2',
|
|
aspectRatio: '1:1',
|
|
imageSize: '1K',
|
|
...patch,
|
|
};
|
|
}
|
|
|
|
function IconComposerHarness({
|
|
initialDialog,
|
|
initialMenuOpen = false,
|
|
onOpenSpecDialog = vi.fn(),
|
|
onRequestUpload = vi.fn(),
|
|
onUpdateIconDescriptionText = vi.fn(),
|
|
onRememberImageModel = vi.fn(),
|
|
onSubmit = vi.fn(),
|
|
}: {
|
|
initialDialog: GenerateDialogState;
|
|
initialMenuOpen?: boolean;
|
|
onOpenSpecDialog?: (specType: 'character' | 'ui' | 'icon' | 'custom') => void;
|
|
onRequestUpload?: (target: UploadTarget) => void;
|
|
onUpdateIconDescriptionText?: (value: string) => void;
|
|
onRememberImageModel?: (model: string) => void;
|
|
onSubmit?: (dialog: GenerateDialogState) => void;
|
|
}) {
|
|
const [dialog, setDialog] = useState<GenerateDialogState | null>(
|
|
initialDialog,
|
|
);
|
|
const [isMenuOpen, setIsMenuOpen] = useState(initialMenuOpen);
|
|
const [isPickingIconSpec, setIsPickingIconSpec] = useState(false);
|
|
const iconSpecButtonRef = createRef<HTMLButtonElement>();
|
|
|
|
return dialog ? (
|
|
<div>
|
|
<ImageCanvasIconSpritesheetComposerView
|
|
dialog={dialog}
|
|
style={{ left: 12, top: 24 }}
|
|
iconSpecButtonRef={iconSpecButtonRef}
|
|
isIconSpecMenuOpen={isMenuOpen}
|
|
setGenerateDialog={setDialog}
|
|
setIsIconSpecMenuOpen={setIsMenuOpen}
|
|
setIsPickingIconSpecFromCanvas={setIsPickingIconSpec}
|
|
renderEditorPortal={(node) => node}
|
|
buildPortalMenuStyle={() => ({ position: 'fixed', left: 0, top: 0 })}
|
|
onOpenSpecDialog={onOpenSpecDialog}
|
|
onRequestUpload={onRequestUpload}
|
|
onUpdateIconDescriptionText={onUpdateIconDescriptionText}
|
|
onRememberImageModel={onRememberImageModel}
|
|
onSubmit={onSubmit}
|
|
/>
|
|
<output aria-label="当前菜单">{String(isMenuOpen)}</output>
|
|
<output aria-label="正在选择图标规范">{String(isPickingIconSpec)}</output>
|
|
<output aria-label="当前模型">{dialog.imageModel}</output>
|
|
<output aria-label="当前尺寸">{dialog.imageSize}</output>
|
|
</div>
|
|
) : null;
|
|
}
|
|
|
|
describe('ImageCanvasIconSpritesheetComposerView', () => {
|
|
it('opens icon spec menu and supports each source action', () => {
|
|
const openSpecDialog = vi.fn();
|
|
const requestUpload = vi.fn();
|
|
render(
|
|
<IconComposerHarness
|
|
initialDialog={createIconDialog()}
|
|
onOpenSpecDialog={openSpecDialog}
|
|
onRequestUpload={requestUpload}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '图标规范' }));
|
|
expect(screen.getByRole('menu', { name: '图标规范来源' })).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '从画布中选择' }));
|
|
expect(screen.getByLabelText('正在选择图标规范').textContent).toBe('true');
|
|
expect(screen.getByLabelText('当前菜单').textContent).toBe('false');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '图标规范' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '新建图标规范' }));
|
|
expect(openSpecDialog).toHaveBeenCalledWith('icon');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '图标规范' }));
|
|
fireEvent.click(screen.getByRole('menuitem', { name: '上传图片' }));
|
|
expect(requestUpload).toHaveBeenCalledWith('icon-spec');
|
|
});
|
|
|
|
it('uses a grey hint instead of default icon text for new icon drafts', () => {
|
|
render(
|
|
<IconComposerHarness
|
|
initialDialog={createIconDialog({ iconDescriptions: [] })}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText('素材描述');
|
|
expect(input.textContent).toBe('');
|
|
expect(
|
|
getPlainTextEditorHost(input).querySelector(
|
|
'.auto-grow-text-area__placeholder',
|
|
)?.textContent,
|
|
).toBe('你需要哪些图标?');
|
|
});
|
|
|
|
it('keeps the spec reference as a single first-row card without extra source buttons', () => {
|
|
render(<IconComposerHarness initialDialog={createIconDialog()} />);
|
|
|
|
const panel = screen.getByRole('dialog', { name: '生成图标素材' });
|
|
const firstRow = panel.querySelector(
|
|
'.image-canvas-editor__reference-strip',
|
|
);
|
|
|
|
expect(firstRow).toBeTruthy();
|
|
expect(
|
|
within(firstRow as HTMLElement).getByRole('button', {
|
|
name: '图标规范',
|
|
}),
|
|
).toBeTruthy();
|
|
expect(
|
|
panel.querySelector('.image-canvas-editor__icon-spec-actions'),
|
|
).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '画布' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '新建' })).toBeNull();
|
|
expect(screen.queryByRole('button', { name: '上传' })).toBeNull();
|
|
});
|
|
|
|
it('removes a bound icon spec from the hover delete button', () => {
|
|
render(
|
|
<IconComposerHarness
|
|
initialDialog={createIconDialog({
|
|
iconSpecReference: {
|
|
id: 'icon-spec-a',
|
|
label: '图标规范A',
|
|
src: 'data:image/png;base64,aWNvbg==',
|
|
},
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '删除图标规范' }));
|
|
|
|
expect(screen.getByRole('button', { name: '图标规范' })).toBeTruthy();
|
|
expect(screen.queryByRole('button', { name: '图标规范A' })).toBeNull();
|
|
});
|
|
|
|
it('uploads and removes icon generation references', () => {
|
|
const requestUpload = vi.fn();
|
|
render(
|
|
<IconComposerHarness
|
|
initialDialog={createIconDialog({
|
|
generationReferences: [
|
|
{
|
|
id: 'ref-a',
|
|
label: '按钮风格参考',
|
|
src: 'data:image/png;base64,ref',
|
|
},
|
|
],
|
|
})}
|
|
onRequestUpload={requestUpload}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByLabelText('按钮风格参考')).toBeTruthy();
|
|
fireEvent.click(screen.getByRole('button', { name: '上传图标素材参考图' }));
|
|
expect(requestUpload).toHaveBeenCalledWith('generation-reference');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '删除按钮风格参考' }));
|
|
expect(screen.queryByLabelText('按钮风格参考')).toBeNull();
|
|
});
|
|
|
|
it('updates multi-line descriptions and submits', () => {
|
|
const updateIconDescriptionText = vi.fn();
|
|
const submitIconGeneration = vi.fn();
|
|
const dialog = createIconDialog({ prompt: '剑\n盾' });
|
|
render(
|
|
<IconComposerHarness
|
|
initialDialog={dialog}
|
|
onUpdateIconDescriptionText={updateIconDescriptionText}
|
|
onSubmit={submitIconGeneration}
|
|
/>,
|
|
);
|
|
|
|
const description = screen.getByLabelText('素材描述');
|
|
expect(description.className).toContain('auto-grow-text-area');
|
|
expect(description.className).not.toContain('platform-text-field');
|
|
|
|
setPlainTextEditorValue(description, '魔法剑\n圆盾');
|
|
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
|
|
|
expect(updateIconDescriptionText).toHaveBeenCalledWith('魔法剑\n圆盾');
|
|
expect(submitIconGeneration).toHaveBeenCalledWith(dialog);
|
|
});
|
|
|
|
it('limits description input by Unicode characters', () => {
|
|
const updateIconDescriptionText = vi.fn();
|
|
render(
|
|
<IconComposerHarness
|
|
initialDialog={createIconDialog({ prompt: '' })}
|
|
onUpdateIconDescriptionText={updateIconDescriptionText}
|
|
/>,
|
|
);
|
|
|
|
setPlainTextEditorValue(
|
|
screen.getByLabelText('素材描述'),
|
|
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1),
|
|
);
|
|
|
|
expect(updateIconDescriptionText).toHaveBeenCalledWith(
|
|
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
|
|
);
|
|
});
|
|
|
|
it('keeps image option changes inside the icon dialog', () => {
|
|
const rememberImageModel = vi.fn();
|
|
render(
|
|
<IconComposerHarness
|
|
initialDialog={createIconDialog({
|
|
imageModel: 'nanobanana2',
|
|
imageSize: '0.5K',
|
|
})}
|
|
onRememberImageModel={rememberImageModel}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(
|
|
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
|
|
);
|
|
const modelPanel = screen.getByRole('menu', { name: '生成图片模型选项' });
|
|
fireEvent.click(
|
|
within(modelPanel).getByRole('button', { name: 'gpt-image-2' }),
|
|
);
|
|
|
|
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
|
|
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
|
|
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K');
|
|
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
|
|
'生成3泥点',
|
|
);
|
|
});
|
|
|
|
it('disables generation controls and renders failure state', () => {
|
|
const submitIconGeneration = vi.fn();
|
|
const { rerender } = render(
|
|
<IconComposerHarness
|
|
initialDialog={createIconDialog({ status: 'generating' })}
|
|
onSubmit={submitIconGeneration}
|
|
/>,
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
|
|
|
expect(
|
|
screen.getByLabelText('素材描述').getAttribute('aria-disabled'),
|
|
).toBe('true');
|
|
expect(submitIconGeneration).not.toHaveBeenCalled();
|
|
|
|
rerender(
|
|
<IconComposerHarness
|
|
key="failed"
|
|
initialDialog={createIconDialog({
|
|
status: 'failed',
|
|
errorMessage: '生成失败',
|
|
})}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.getByRole('alert').textContent).toContain('生成失败');
|
|
});
|
|
});
|