071faa482c
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口 完成项目 TypeScript/Prettier 与 Rust 全量格式化 修复 Pingora expected executable 门禁的空白敏感误报 同步开发运维文档与 AGC skill pack 格式化忽略规则
528 lines
16 KiB
TypeScript
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();
|
|
});
|
|
});
|