Files
Genarrative/src/components/image-editor/ImageCanvasSidebarView.test.tsx
T
k88936 aab0b7849b 实现画布素材多选与批量下载
重写素材库的普通点击、Ctrl/Cmd 追加和 Shift 连续范围选择。

清理退出选择模式后的选中状态,并让整行素材统一响应选择操作。

支持单素材直接下载和多素材 ZIP 下载,文件名精确到秒。

固定批量操作栏并修复移动端素材列表可操作空间。

补充选择、导出、侧栏交互测试以及对应技术文档。
2026-07-27 16:04:15 +08:00

436 lines
14 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(),
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(),
reconcileSelectedAssets: 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 visible selections', () => {
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(['asset-1']);
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(closeAssetSelectionMode).toHaveBeenCalledTimes(1);
});
it('opens selection mode through the shared lifecycle and reconciles search visibility', () => {
const openAssetSelectionMode = vi.fn();
const reconcileSelectedAssets = vi.fn();
const folder = createFolder();
render(
<ImageCanvasSidebarView
{...createSidebarProps({
isAssetSelectionMode: false,
openAssetSelectionMode,
reconcileSelectedAssets,
groupedAssets: [
{
...folder,
assets: [
createAsset({ id: 'asset-a', label: '角色' }),
createAsset({ id: 'asset-b', label: '背景' }),
],
},
],
})}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '素材选择模式' }));
expect(openAssetSelectionMode).toHaveBeenCalledTimes(1);
expect(reconcileSelectedAssets).not.toHaveBeenCalled();
});
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('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();
});
});