Files
Genarrative/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx
T
k88936 d901945c77
Project CI / Repository checks (push) Successful in 1m20s
Project CI / Frontend tests (push) Successful in 2m53s
Project CI / Backend tests (push) Successful in 3m56s
Project CI / Native shell tests (push) Successful in 14m1s
移除视频快速编辑支持 (#157)
当前实现: 视频快速编辑错误地复用了图片快速编辑的panel, 然后发起一个实际是生成视频的请求

策划建议删除.

因为后端没有实际意义上的视频快速编辑, 所以api 后端代码没有改动, 只在前端删除入口和处理

---------

Co-authored-by: kdletters <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/157
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-14 18:52:56 +08:00

630 lines
21 KiB
TypeScript

/* @vitest-environment jsdom */
import {
cleanup,
fireEvent,
render,
screen,
within,
} from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import { ImageCanvasSelectedLayerToolbarView } from './ImageCanvasSelectedLayerToolbarView';
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-1',
resourceId: 'resource-1',
title: '生成主图',
src: 'data:image/png;base64,layer',
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
generationInputs: {
version: 2,
action: 'image.generate',
fields: [{ id: 'prompt', title: '生成提示词', value: '测试图片' }],
references: [],
},
...overrides,
};
}
function renderSelectedToolbar(
overrides: Partial<
Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0]
> = {},
) {
const selectedLayer = createLayer();
const props: Parameters<typeof ImageCanvasSelectedLayerToolbarView>[0] = {
selectedLayer,
selectedToolbarStyle: { left: 12, top: 24 },
onOpenQuickEditPanel: vi.fn(),
onOpenRedrawPanel: vi.fn(),
onOpenCropExpandPanel: vi.fn(),
onRemoveBackground: vi.fn(),
onPerfectPixel: vi.fn(),
isPerfectPixelProcessing: false,
isPerfectPixelPendingConfirmation: false,
isSplittingIconSpritesheet: false,
isPersistingAssetKind: false,
onSplitIconSpritesheet: vi.fn(),
onExtractUiDesignAssets: vi.fn(),
onOpenCharacterAnimationPanel: vi.fn(),
onDownloadLayer: vi.fn(),
...overrides,
};
render(<ImageCanvasSelectedLayerToolbarView {...props} />);
return props;
}
describe('ImageCanvasSelectedLayerToolbarView', () => {
it('renders common layer actions in the Lovart toolbar order and forwards callbacks', () => {
const props = renderSelectedToolbar();
const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
const buttons = within(toolbar).getAllByRole('button');
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'完美像素',
'改造',
'下载按钮',
]);
fireEvent.click(within(toolbar).getByRole('button', { name: '快速编辑' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '裁扩按钮' }));
fireEvent.click(
within(toolbar).getByRole('button', { name: '去除背景按钮' }),
);
fireEvent.click(within(toolbar).getByRole('button', { name: '完美像素' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
expect(props.onOpenQuickEditPanel).toHaveBeenCalledWith(
props.selectedLayer,
);
expect(props.onOpenCropExpandPanel).toHaveBeenCalledWith(
props.selectedLayer,
);
expect(props.onRemoveBackground).toHaveBeenCalledWith(props.selectedLayer);
expect(props.onPerfectPixel).toHaveBeenCalledWith(props.selectedLayer);
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(props.selectedLayer);
expect(props.onDownloadLayer).toHaveBeenCalledWith(props.selectedLayer);
expect(
within(toolbar).queryByRole('button', { name: '删除图片' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '修改图片' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: /查看.*图片信息/u }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '提取素材' }),
).toBeNull();
expect(
within(toolbar)
.getByRole('button', { name: '去除背景按钮' })
.querySelector('.lucide-image-off'),
).toBeTruthy();
expect(
within(toolbar)
.getByRole('button', { name: '完美像素' })
.querySelector('.lucide-grid-2x2'),
).toBeTruthy();
expect(
within(toolbar).getByRole('button', { name: '完美像素' }).textContent,
).toContain('完美像素');
expect(
within(toolbar)
.getByRole('button', { name: '完美像素' })
.getAttribute('title'),
).toBe('自动识别并规整像素网格');
});
it('renders UI design asset extraction after remove background', () => {
const layer = createLayer({
sourceType: 'generated',
assetKind: 'ui-design',
});
const props = renderSelectedToolbar({ selectedLayer: layer });
const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
const buttons = within(toolbar).getAllByRole('button');
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'完美像素',
'提取素材',
'改造',
'下载按钮',
]);
fireEvent.click(within(toolbar).getByRole('button', { name: '提取素材' }));
expect(props.onExtractUiDesignAssets).toHaveBeenCalledWith(layer);
});
it('renders icon spritesheet splitting after remove background', () => {
const layer = createLayer({
sourceType: 'generated',
assetKind: 'icon-spritesheet',
});
const props = renderSelectedToolbar({ selectedLayer: layer });
const toolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
const buttons = within(toolbar).getAllByRole('button');
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
'快速编辑',
'裁扩按钮',
'去除背景按钮',
'完美像素',
'拆分图集',
'改造',
'下载按钮',
]);
const splitButton = within(toolbar).getByRole('button', {
name: '拆分图集',
});
expect(splitButton.querySelector('.lucide-scissors')).toBeTruthy();
fireEvent.click(splitButton);
expect(props.onSplitIconSpritesheet).toHaveBeenCalledWith(layer);
});
it('renders a disabled loading state while splitting an icon spritesheet', () => {
const layer = createLayer({
sourceType: 'generated',
assetKind: 'icon-spritesheet',
});
const props = renderSelectedToolbar({
selectedLayer: layer,
isSplittingIconSpritesheet: true,
});
const splitButton = screen.getByRole('button', { name: '拆图中' });
expect(splitButton.getAttribute('title')).toBe('拆图中');
expect(splitButton.getAttribute('aria-busy')).toBe('true');
expect((splitButton as HTMLButtonElement).disabled).toBe(true);
expect(splitButton.textContent).toContain('拆图中');
expect(splitButton.querySelector('.lucide-loader-circle')).toBeTruthy();
expect(
splitButton.querySelector('.lucide-loader-circle.animate-spin'),
).toBeTruthy();
expect(splitButton.querySelector('.lucide-scissors')).toBeNull();
fireEvent.click(splitButton);
fireEvent.click(splitButton);
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
});
it('renders a disabled loading state while perfect pixel is processing', () => {
const props = renderSelectedToolbar({
isPerfectPixelProcessing: true,
});
const button = screen.getByRole('button', {
name: '完美像素处理中',
});
expect(button.getAttribute('aria-busy')).toBe('true');
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(
button.querySelector('.lucide-loader-circle.animate-spin'),
).toBeTruthy();
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
expect(button.textContent).toContain('处理中');
fireEvent.click(button);
fireEvent.click(button);
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('renders a distinct disabled state while perfect pixel is pending confirmation', () => {
const props = renderSelectedToolbar({
isPerfectPixelPendingConfirmation: true,
});
const button = screen.getByRole('button', {
name: '完美像素结果待确认',
});
expect(button.getAttribute('title')).toBe(
'结果待确认,双击原占位继续核对或重试',
);
expect(button.getAttribute('aria-busy')).toBe('false');
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.textContent).toContain('待确认');
expect(button.textContent).not.toContain('处理中');
expect(button.querySelector('.lucide-grid-2x2')).toBeTruthy();
expect(button.querySelector('.lucide-loader-circle')).toBeNull();
fireEvent.click(button);
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('does not invoke perfect pixel while the selected asset kind is persisting', () => {
// 中文注释:请求同时带 assetKind 与 sourceResourceId,本地类型已改但资源尚未落库时
// 两者不一致,后端会直接 400 并留下失败占位。名称与拆分图集按钮的「素材类型保存中」
// 必须区分开——icon-spritesheet 图层上两个按钮会同时进入保存态。
const layer = createLayer({ assetKind: 'icon-spritesheet' });
const props = renderSelectedToolbar({
selectedLayer: layer,
isPersistingAssetKind: true,
});
const button = screen.getByRole('button', {
name: '完美像素等待素材类型保存',
});
expect(button.getAttribute('aria-busy')).toBe('true');
expect((button as HTMLButtonElement).disabled).toBe(true);
expect(button.querySelector('.lucide-grid-2x2')).toBeNull();
expect(button.textContent).toContain('保存中');
fireEvent.click(button);
expect(props.onPerfectPixel).not.toHaveBeenCalled();
});
it('does not invoke atlas splitting while the selected asset kind is persisting', () => {
const layer = createLayer({ assetKind: 'icon-spritesheet' });
const props = renderSelectedToolbar({
selectedLayer: layer,
isPersistingAssetKind: true,
});
const splitButton = screen.getByRole('button', {
name: '素材类型保存中',
});
expect(splitButton.getAttribute('aria-busy')).toBe('true');
expect((splitButton as HTMLButtonElement).disabled).toBe(true);
expect(splitButton.textContent).toContain('保存中');
fireEvent.click(splitButton);
expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled();
});
it('keeps only remodel and download for audio layers', () => {
const layer = createLayer({
title: '游戏音效',
src: 'data:audio/mpeg;base64,audio',
mediaType: 'audio',
assetKind: 'sound-effect',
generationInputs: {
version: 2,
action: 'audio.sound-effect.generate',
fields: [{ id: 'prompt', title: 'prompt', value: '金币声' }],
references: [],
},
});
const props = renderSelectedToolbar({ selectedLayer: layer });
const toolbar = screen.getByRole('toolbar', { name: '素材工具栏' });
const buttons = within(toolbar).getAllByRole('button');
expect(buttons.map((button) => button.getAttribute('aria-label'))).toEqual([
'改造',
'下载按钮',
]);
expect(
within(toolbar).queryByRole('button', { name: '快速编辑' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '裁扩按钮' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '完美像素' }),
).toBeNull();
expect(
within(toolbar).queryByRole('button', { name: '生成动画' }),
).toBeNull();
fireEvent.click(within(toolbar).getByRole('button', { name: '改造' }));
fireEvent.click(within(toolbar).getByRole('button', { name: '下载按钮' }));
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(layer);
expect(props.onDownloadLayer).toHaveBeenCalledWith(layer);
});
it('hides redraw for non-generative derived and public display-only layers', () => {
renderSelectedToolbar({
selectedLayer: createLayer({
title: '手动裁扩结果',
generationInputs: null,
}),
});
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
cleanup();
renderSelectedToolbar({
selectedLayer: createLayer({
title: '公开音效',
mediaType: 'audio',
assetKind: 'sound-effect',
generationInputs: {
version: 2,
fields: [{ title: 'prompt', value: '公开展示文本' }],
references: [],
},
}),
});
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
expect(screen.getByRole('button', { name: '下载按钮' })).toBeTruthy();
});
it.each([
'image.perfect-pixel',
'spritesheet.split',
'image.remove-background',
'image.crop-expand',
] as const)('never exposes redraw for deterministic action %s', (action) => {
renderSelectedToolbar({
selectedLayer: createLayer({
generationInputs: {
version: 2,
action,
fields: [],
references: [],
},
}),
});
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
});
it('keeps quick edit but hides redraw for in-place quick-edit results', () => {
renderSelectedToolbar({
selectedLayer: createLayer({
generationInputs: {
version: 2,
action: 'image.edit',
fields: [
{ id: 'prompt', title: '快速编辑提示词', value: '改成夜景' },
],
references: [
{
id: 'source',
title: '原图',
refType: 'project-resource',
refId: 'resource-before-in-place-edit',
},
],
},
}),
});
expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '改造' })).toBeNull();
});
it('removes raster edit actions for video and character animation layers', () => {
renderSelectedToolbar({
selectedLayer: createLayer({
title: '生成视频',
src: '/generated/video.mp4',
mediaType: 'video',
assetKind: 'video',
generationInputs: {
version: 2,
action: 'video.generate',
fields: [{ id: 'prompt', title: '视频描述', value: '测试视频' }],
references: [],
},
}),
});
const videoToolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
expect(
within(videoToolbar).queryByRole('button', { name: '裁扩按钮' }),
).toBeNull();
expect(
within(videoToolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(videoToolbar).queryByRole('button', { name: '完美像素' }),
).toBeNull();
expect(
within(videoToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['改造', '下载按钮']);
cleanup();
renderSelectedToolbar({
selectedLayer: createLayer({
title: '角色动作',
src: '/generated/action/frame-01.png',
mediaType: 'image-sequence',
assetKind: 'character-animation',
generationInputs: {
version: 2,
action: 'character-animation.generate',
fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }],
references: [],
},
}),
});
const actionToolbar = screen.getByRole('toolbar', { name: '图片工具栏' });
expect(
within(actionToolbar).queryByRole('button', { name: '裁扩按钮' }),
).toBeNull();
expect(
within(actionToolbar).queryByRole('button', { name: '去除背景按钮' }),
).toBeNull();
expect(
within(actionToolbar).queryByRole('button', { name: '完美像素' }),
).toBeNull();
expect(
within(actionToolbar)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['改造', '下载按钮']);
});
it('keeps redraw visible for supported actions with a missing non-replaceable source', () => {
const generatedLayer = createLayer({
id: 'animation-layer',
resourceId: 'animation-resource',
mediaType: 'image-sequence',
assetKind: 'character-animation',
generationInputs: {
version: 2,
action: 'character-animation.generate',
fields: [{ id: 'prompt', title: '动作描述', value: '挥手' }],
references: [
{
id: 'source',
title: '原角色图',
label: '角色',
refType: 'project-resource',
refId: 'missing-source-resource',
},
],
},
});
const props = renderSelectedToolbar({ selectedLayer: generatedLayer });
fireEvent.click(screen.getByRole('button', { name: '改造' }));
expect(props.onOpenRedrawPanel).toHaveBeenCalledWith(generatedLayer);
cleanup();
const uiDesignLayer = createLayer({
id: 'ui-spritesheet-layer',
resourceId: 'ui-spritesheet-resource',
assetKind: 'icon-spritesheet',
generationInputs: {
version: 2,
action: 'ui-design.extract-assets',
fields: [{ id: 'prompt', title: '提取提示词', value: '提取按钮' }],
references: [
{
id: 'source',
title: 'UI设计图',
refType: 'project-resource',
refId: 'missing-ui-design-resource',
},
],
},
});
const uiProps = renderSelectedToolbar({ selectedLayer: uiDesignLayer });
fireEvent.click(screen.getByRole('button', { name: '改造' }));
expect(uiProps.onOpenRedrawPanel).toHaveBeenCalledWith(uiDesignLayer);
});
it('renders character animation only for character layers', () => {
const layer = createLayer({
sourceType: 'generated',
assetKind: 'character',
});
const props = renderSelectedToolbar({ selectedLayer: layer });
const animationButton = screen.getByRole('button', { name: '生成动画' });
expect(animationButton.textContent).toContain('生成动画');
expect(
animationButton.querySelector('.lucide-person-standing'),
).toBeTruthy();
fireEvent.click(animationButton);
expect(props.onOpenCharacterAnimationPanel).toHaveBeenCalledWith(layer);
cleanup();
renderSelectedToolbar({ selectedLayer: createLayer({ id: 'layer-2' }) });
expect(screen.queryByRole('button', { name: '生成动画' })).toBeNull();
});
it('hides quick edit only for individual icon assets', () => {
renderSelectedToolbar({
selectedLayer: createLayer({ assetKind: 'icon' }),
});
expect(screen.queryByRole('button', { name: '快速编辑' })).toBeNull();
});
it('keeps quick edit and manual split available for icon spritesheets', () => {
renderSelectedToolbar({
selectedLayer: createLayer({ assetKind: 'icon-spritesheet' }),
});
expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy();
expect(screen.getByRole('button', { name: '拆分图集' })).toBeTruthy();
});
it('keeps quick edit available for icon specs', () => {
renderSelectedToolbar({
selectedLayer: createLayer({ assetKind: 'icon-spec' }),
});
expect(screen.getByRole('button', { name: '快速编辑' })).toBeTruthy();
});
it('renders nothing without a selected layer or toolbar position', () => {
const { rerender } = render(
<ImageCanvasSelectedLayerToolbarView
selectedLayer={null}
selectedToolbarStyle={{ left: 0, top: 0 }}
onOpenQuickEditPanel={vi.fn()}
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
onPerfectPixel={vi.fn()}
isPerfectPixelProcessing={false}
isPerfectPixelPendingConfirmation={false}
isSplittingIconSpritesheet={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
onExtractUiDesignAssets={vi.fn()}
onOpenCharacterAnimationPanel={vi.fn()}
onDownloadLayer={vi.fn()}
/>,
);
expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull();
rerender(
<ImageCanvasSelectedLayerToolbarView
selectedLayer={createLayer()}
selectedToolbarStyle={null}
onOpenQuickEditPanel={vi.fn()}
onOpenRedrawPanel={vi.fn()}
onOpenCropExpandPanel={vi.fn()}
onRemoveBackground={vi.fn()}
onPerfectPixel={vi.fn()}
isPerfectPixelProcessing={false}
isPerfectPixelPendingConfirmation={false}
isSplittingIconSpritesheet={false}
isPersistingAssetKind={false}
onSplitIconSpritesheet={vi.fn()}
onExtractUiDesignAssets={vi.fn()}
onOpenCharacterAnimationPanel={vi.fn()}
onDownloadLayer={vi.fn()}
/>,
);
expect(screen.queryByRole('toolbar', { name: '图片工具栏' })).toBeNull();
});
});