Files
Genarrative/src/components/image-editor/ImageCanvasSidebarView.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

528 lines
16 KiB
TypeScript

/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type {
CanvasLayer,
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
import { ImageCanvasPanelDockView } from './ImageCanvasPanelDockView';
import {
ImageCanvasSidebarView,
type ImageCanvasSidebarViewProps,
} from './ImageCanvasSidebarView';
import { useImageCanvasEditorChrome } from './useImageCanvasEditorChrome';
function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
return {
id: 'asset-1',
label: '账号素材A',
src: '/creation-type-references/puzzle.webp',
width: 640,
height: 640,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
...overrides,
};
}
function createFolder(
overrides: Partial<EditorAssetFolder> = {},
): EditorAssetFolder {
return {
id: 'project',
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
...overrides,
};
}
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-1',
resourceId: 'resource-1',
title: '图层A',
src: '/creation-type-references/puzzle.webp',
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'uploaded',
...overrides,
};
}
function createSidebarProps(
overrides: Partial<ImageCanvasSidebarViewProps> = {},
): ImageCanvasSidebarViewProps {
const folder = createFolder();
const asset = createAsset();
return {
activeSidebarPanel: 'assets',
assetListRef: createRef<HTMLDivElement>(),
assetPointerDragRef: { current: null },
suppressAssetClickRef: { current: false },
assets: [asset],
groupedAssets: [{ ...folder, assets: [asset] }],
assetFolders: [folder],
layers: [createLayer()],
selectedLayerId: null,
selectedLayerIds: [],
isAssetSelectionMode: false,
selectedAssetIds: new Set(),
assetMoveDropFolderId: null,
pinnedAssetMoveFolderId: null,
creatingFolder: false,
newFolderName: '',
renamingFolder: null,
renamingAsset: null,
isExportingSelectedAssets: false,
assetMarquee: null,
setCreatingFolder: vi.fn(),
setNewFolderName: vi.fn(),
setRenamingFolder: vi.fn(),
setRenamingAsset: vi.fn(),
setActiveUploadFolderId: vi.fn(),
setUploadDropTarget: vi.fn(),
setAssetPointerDrag: vi.fn(),
setImageContextMenu: vi.fn(),
setContextMenu: vi.fn(),
onAssetMarqueePointerDown: vi.fn(),
onAssetMarqueePointerMove: vi.fn(),
onAssetMarqueePointerUp: vi.fn(),
onAssetMarqueePointerCancel: vi.fn(),
onAssetMarqueeLostPointerCapture: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
requestUpload: vi.fn(),
moveAssetToFolder: vi.fn(),
commitNewAssetFolder: vi.fn(),
toggleAssetFolder: vi.fn(),
startRenamingFolder: vi.fn(),
commitFolderRename: vi.fn(),
deleteAssetFolder: vi.fn(),
startRenamingAsset: vi.fn(),
commitAssetRename: vi.fn(),
deleteUploadedAsset: vi.fn(),
submitAssetToShowcase: vi.fn(),
toggleAssetSelected: vi.fn(),
addAssetLayer: vi.fn(),
onDownloadAsset: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
downloadSelectedAssets: vi.fn(),
deleteSelectedAssets: vi.fn(),
openAssetSelectionMode: vi.fn(),
closeAssetSelectionMode: vi.fn(),
groupSelectedLayers: vi.fn(),
selectSingleLayer: vi.fn(),
resolveContextMenuPosition: vi.fn(() => ({ x: 0, y: 0 })),
getCanvasPointFromClient: vi.fn(() => ({ x: 0, y: 0 })),
...overrides,
};
}
function SidebarTabsHarness() {
const chrome = useImageCanvasEditorChrome({ openEditorLoginModal: vi.fn() });
const asset = createAsset();
const folder = createFolder();
const layer = createLayer({ title: '图层B', zIndex: 2 });
const sidebarProps = createSidebarProps({
activeSidebarPanel: chrome.activeSidebarPanel,
assets: [asset],
groupedAssets: [{ ...folder, assets: [asset] }],
assetFolders: [folder],
layers: [layer],
selectedLayerId: null,
selectedLayerIds: [],
});
return (
<div>
<ImageCanvasSidebarView {...sidebarProps} />
<ImageCanvasPanelDockView
viewport={{ x: 0, y: 0, scale: 1 }}
canvasBackgroundColor={chrome.canvasBackgroundColor}
canvasBackgroundHexValue={chrome.canvasBackgroundHexValue}
canUndo={false}
canRedo={false}
isZoomMenuOpen={false}
isBackgroundSettingsOpen={false}
activeSidebarPanel={chrome.activeSidebarPanel}
isAgentConversationEnabled={false}
isAgentConversationOpen={false}
isMinimapOpen={false}
minimapModel={null}
onFitLayers={vi.fn()}
onUndoCanvasChange={vi.fn()}
onRedoCanvasChange={vi.fn()}
onUpdateScaleFromCenter={vi.fn()}
onToggleZoomMenu={vi.fn()}
onCloseZoomMenu={vi.fn()}
onToggleBackgroundSettings={vi.fn()}
onApplyCanvasBackgroundColor={vi.fn()}
onCanvasBackgroundHexChange={vi.fn()}
onToggleSidebarPanel={chrome.toggleSidebarPanel}
onToggleAgentConversation={vi.fn()}
onToggleMinimap={vi.fn()}
onMinimapPointerDown={vi.fn()}
/>
</div>
);
}
describe('ImageCanvasSidebarView', () => {
it('renders the asset library folders and asset actions', () => {
render(<ImageCanvasSidebarView {...createSidebarProps()} />);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
const folder = within(sidebar).getByRole('region', { name: '项目素材' });
expect(within(sidebar).getByRole('heading', { name: '素材' })).toBeTruthy();
expect(
within(folder).getByRole('button', { name: '添加账号素材A' }),
).toBeTruthy();
expect(
within(folder).getByRole('button', { name: '上传到项目素材' }),
).toBeTruthy();
expect(
within(folder).getByRole('button', { name: '下载素材账号素材A' }),
).toBeTruthy();
});
it('renders asset editing inputs with sidebar text-field chrome', () => {
const folder = createFolder();
const asset = createAsset();
render(
<ImageCanvasSidebarView
{...createSidebarProps({
assets: [asset],
groupedAssets: [{ ...folder, assets: [asset] }],
assetFolders: [folder],
creatingFolder: true,
newFolderName: '角色上传',
renamingFolder: { folderId: folder.id, value: '角色参考' },
renamingAsset: { assetId: asset.id, value: '主视觉素材' },
})}
/>,
);
const folderNameInput = screen.getByLabelText('素材文件夹名称');
const folderRenameInput = screen.getByLabelText('重命名文件夹项目素材');
const assetRenameInput = screen.getByLabelText('重命名素材账号素材A');
expect(folderNameInput.className).toContain('platform-text-field');
expect(folderNameInput.className).toContain(
'image-canvas-editor__folder-create-input',
);
expect(folderRenameInput.className).toContain('platform-text-field');
expect(folderRenameInput.className).toContain(
'image-canvas-editor__folder-rename-input',
);
expect(assetRenameInput.className).toContain('platform-text-field');
expect(assetRenameInput.className).toContain(
'image-canvas-editor__asset-rename-input',
);
});
it('filters assets from the sidebar search field', () => {
const folder = createFolder();
render(
<ImageCanvasSidebarView
{...createSidebarProps({
assets: [
createAsset({ id: 'asset-a', label: '账号素材A' }),
createAsset({ id: 'asset-b', label: '火焰按钮' }),
],
groupedAssets: [
{
...folder,
assets: [
createAsset({ id: 'asset-a', label: '账号素材A' }),
createAsset({ id: 'asset-b', label: '火焰按钮' }),
],
},
],
})}
/>,
);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
fireEvent.change(within(sidebar).getByLabelText('搜索素材'), {
target: { value: '火焰' },
});
expect(
within(sidebar).getByRole('button', { name: '添加火焰按钮' }),
).toBeTruthy();
expect(
within(sidebar).queryByRole('button', { name: '添加账号素材A' }),
).toBeNull();
});
it('keeps the batch toolbar outside the scrolling list and downloads the stable selection', () => {
const downloadSelectedAssets = vi.fn();
const closeAssetSelectionMode = vi.fn();
const asset = createAsset();
render(
<ImageCanvasSidebarView
{...createSidebarProps({
isAssetSelectionMode: true,
selectedAssetIds: new Set([asset.id]),
downloadSelectedAssets,
closeAssetSelectionMode,
})}
/>,
);
const toolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
const assetList = document.querySelector(
'.image-canvas-editor__asset-list',
);
expect(sidebar.className).toContain(
'image-canvas-editor__sidebar--asset-selection',
);
expect(toolbar.parentElement).toBe(assetList?.parentElement);
expect(assetList?.contains(toolbar)).toBe(false);
expect(
screen.queryByRole('button', { name: '下载素材账号素材A' }),
).toBeNull();
fireEvent.click(within(toolbar).getByRole('button', { name: '下载' }));
expect(downloadSelectedAssets).toHaveBeenCalledWith();
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(closeAssetSelectionMode).toHaveBeenCalledTimes(1);
});
it('opens selection mode through the shared lifecycle', () => {
const openAssetSelectionMode = vi.fn();
const folder = createFolder();
render(
<ImageCanvasSidebarView
{...createSidebarProps({
isAssetSelectionMode: false,
openAssetSelectionMode,
groupedAssets: [
{
...folder,
assets: [
createAsset({ id: 'asset-a', label: '角色' }),
createAsset({ id: 'asset-b', label: '背景' }),
],
},
],
})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(openAssetSelectionMode).toHaveBeenCalledTimes(1);
});
it('does not expose internal models or providers through asset search', () => {
const folder = createFolder();
const internalAsset = createAsset({
id: 'asset-internal',
label: '透明角色',
sourceType: 'generated',
model: 'BgFilter complex',
provider: 'BgFilter',
});
const visibleAsset = createAsset({
id: 'asset-visible',
label: '生成角色',
sourceType: 'generated',
model: 'gpt-image-2',
provider: 'VectorEngine',
});
render(
<ImageCanvasSidebarView
{...createSidebarProps({
assets: [internalAsset, visibleAsset],
groupedAssets: [{ ...folder, assets: [internalAsset, visibleAsset] }],
})}
/>,
);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
const search = within(sidebar).getByLabelText('搜索素材');
fireEvent.change(search, { target: { value: 'BgFilter' } });
expect(
within(sidebar).queryByRole('button', { name: '添加透明角色' }),
).toBeNull();
fireEvent.change(search, { target: { value: 'VectorEngine' } });
expect(
within(sidebar).queryByRole('button', { name: '添加生成角色' }),
).toBeNull();
fireEvent.change(search, { target: { value: 'gpt-image-2' } });
expect(
within(sidebar).getByRole('button', { name: '添加生成角色' }),
).toBeTruthy();
});
it('renders the layer panel with grouped and locked layer details', () => {
render(
<ImageCanvasSidebarView
{...createSidebarProps({
activeSidebarPanel: 'layers',
layers: [
createLayer({
title: '图层A',
groupId: 'group-1',
locked: true,
zIndex: 3,
}),
],
selectedLayerId: 'layer-1',
})}
/>,
);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
expect(within(sidebar).getByRole('heading', { name: '图层' })).toBeTruthy();
expect(
within(sidebar).getByRole('button', { name: '选择图层图层A' }),
).toBeTruthy();
expect(
within(sidebar).getByText('320 x 240 · 已打组 · 已锁定'),
).toBeTruthy();
});
it('uses the transparent audio cover for audio layer thumbnails', () => {
render(
<ImageCanvasSidebarView
{...createSidebarProps({
activeSidebarPanel: 'layers',
layers: [
createLayer({
title: '胜利音效',
src: '/generated-character-drafts/editor-audios/win.mp3',
mediaType: 'audio',
}),
],
})}
/>,
);
const image = screen.getByAltText('图层缩略图:胜利音效');
expect(image.getAttribute('src')).toBe(
'/creation-home/audio-asset-cover.png',
);
});
it('filters canvas layers from the sidebar search field', () => {
render(
<ImageCanvasSidebarView
{...createSidebarProps({
activeSidebarPanel: 'layers',
layers: [
createLayer({ id: 'layer-a', title: '角色草图', zIndex: 3 }),
createLayer({ id: 'layer-b', title: '背景山脉', zIndex: 2 }),
],
})}
/>,
);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
fireEvent.change(within(sidebar).getByLabelText('搜索画布图层'), {
target: { value: '背景' },
});
expect(
within(sidebar).getByRole('button', { name: '选择图层背景山脉' }),
).toBeTruthy();
expect(
within(sidebar).queryByRole('button', { name: '选择图层角色草图' }),
).toBeNull();
});
it('does not expose internal models or providers through layer search', () => {
render(
<ImageCanvasSidebarView
{...createSidebarProps({
activeSidebarPanel: 'layers',
layers: [
createLayer({
id: 'layer-internal',
title: '透明角色图层',
model: 'segment-common-image',
provider: 'Aliyun Matting',
zIndex: 2,
}),
createLayer({
id: 'layer-visible',
title: '生成角色图层',
model: 'gpt-image-2',
provider: 'VectorEngine',
zIndex: 1,
}),
],
})}
/>,
);
const sidebar = screen.getByRole('complementary', { name: '图片资源栏' });
const search = within(sidebar).getByLabelText('搜索画布图层');
fireEvent.change(search, { target: { value: 'segment-common-image' } });
expect(
within(sidebar).queryByRole('button', {
name: '选择图层透明角色图层',
}),
).toBeNull();
fireEvent.change(search, { target: { value: 'VectorEngine' } });
expect(
within(sidebar).queryByRole('button', {
name: '选择图层生成角色图层',
}),
).toBeNull();
fireEvent.change(search, { target: { value: 'gpt-image-2' } });
expect(
within(sidebar).getByRole('button', {
name: '选择图层生成角色图层',
}),
).toBeTruthy();
});
it('keeps sidebar tab switching and repeated-click closing behavior', () => {
render(<SidebarTabsHarness />);
expect(
screen.queryByRole('complementary', { name: '图片资源栏' }),
).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(screen.getByRole('heading', { name: '素材' })).toBeTruthy();
expect(screen.getByRole('button', { name: '添加账号素材A' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
expect(screen.getByRole('heading', { name: '图层' })).toBeTruthy();
expect(screen.getByRole('button', { name: '选择图层图层B' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '打开图层' }));
expect(
screen.queryByRole('complementary', { name: '图片资源栏' }),
).toBeNull();
});
});