Files
Genarrative/src/components/image-editor/ImageCanvasAssetRowView.test.tsx
T
k88936 498b905bd8
Project CI / Repository checks (push) Successful in 1m21s
Project CI / Frontend tests (push) Successful in 3m9s
Project CI / Backend tests (push) Successful in 4m2s
Project CI / Native shell tests (push) Successful in 15m48s
优化素材库多选操作 (#114)
<video src="attachments/2377fc8b-50e4-4919-ba41-5027b957709f" title="2026-07-27 16-15-53.mp4" controls></video>

- 实现shift ctrl的范围选扩选
- 实现多选下载(为一个zip)
- 原本有搜索和文件夹折叠展开功能, 我的处理是: 只作为方便查找的用途. 最终操作会有"目前一些(比如待删除)元素被隐藏了,请再确认"的提示.

result:
```shell
root@k88936-t14p: ~/Downloads# unzip 未命名画布-选中素材-20260806-195631.zip
Archive:  未命名画布-选中素材-20260806-195631.zip
   creating: 未命名画布-选中素材/
   creating: 未命名画布-选中素材/images/
   creating: 未命名画布-选中素材/media/
 extracting: 未命名画布-选中素材/media/001-角色动作(原始视频).mp4
   creating: 未命名画布-选中素材/sequences/
   creating: 未命名画布-选中素材/sequences/002-角色动作/
   creating: 未命名画布-选中素材/sequences/002-角色动作/frames/
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-01.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-02.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-03.png
...
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-31.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/frames/frame-32.png
 extracting: 未命名画布-选中素材/sequences/002-角色动作/metadata.json
 extracting: 未命名画布-选中素材/sequences/002-角色动作/skeleton.json
 extracting: 未命名画布-选中素材/sequences/002-角色动作/README.md
 extracting: 未命名画布-选中素材/sequences/002-角色动作/manifest.txt
 extracting: 未命名画布-选中素材/metadata.json
 extracting: 未命名画布-选中素材/manifest.txt
 ```

~~动作的导出存在很大的问题, 在另一个pr #117 解决~~

---------

Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/114
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-08 11:11:25 +08:00

524 lines
15 KiB
TypeScript

/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ImageCanvasAssetRowView,
type ImageCanvasAssetRowViewProps,
} from './ImageCanvasAssetRowView';
import type { EditorAsset } from './ImageCanvasEditorTypes';
const useResolvedAssetReadUrlMock = vi.hoisted(() => vi.fn());
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: useResolvedAssetReadUrlMock,
}));
vi.mock('../common/PlatformMediaFrame', () => ({
PlatformMediaFrame: ({
src,
objectKey,
alt,
previewOverlay,
}: {
src?: string | null;
objectKey?: string | null;
alt: string;
previewOverlay?: ReactNode;
}) => {
const imageSrc =
src?.trim() ||
(objectKey?.trim()
? `https://signed.example.com/${objectKey.trim()}`
: '');
return (
<div className="platform-media-frame">
{imageSrc ? (
<img
src={imageSrc}
alt={alt}
data-object-key={objectKey?.trim() || undefined}
/>
) : null}
{previewOverlay}
</div>
);
},
}));
beforeEach(() => {
useResolvedAssetReadUrlMock.mockImplementation(
(
source: string | null | undefined,
options?: { objectKey?: string | null },
) => ({
resolvedUrl: options?.objectKey
? `https://signed.example.com/${options.objectKey}`
: (source ?? ''),
isResolving: false,
shouldResolve: Boolean(options?.objectKey),
}),
);
});
function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
return {
id: 'asset-1',
label: '账号素材A',
src: '/creation-type-references/puzzle.webp',
width: 640,
height: 512,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
...overrides,
};
}
function renderAssetRow({
asset = createAsset(),
isAssetSelectionMode = false,
selectedAssetIds = new Set<string>(),
renamingAsset = null,
suppressAssetClick = false,
addAssetLayer = vi.fn(),
toggleAssetSelected = vi.fn(),
commitAssetRename = vi.fn(),
deleteUploadedAsset = vi.fn(),
submitAssetToShowcase = vi.fn(),
onDownloadAsset = vi.fn(),
}: {
asset?: EditorAsset;
isAssetSelectionMode?: boolean;
selectedAssetIds?: Set<string>;
renamingAsset?: { assetId: string; value: string } | null;
suppressAssetClick?: boolean;
addAssetLayer?: (asset: EditorAsset) => void;
toggleAssetSelected?: ImageCanvasAssetRowViewProps['toggleAssetSelected'];
commitAssetRename?: (asset: EditorAsset) => void;
deleteUploadedAsset?: (asset: EditorAsset) => void;
submitAssetToShowcase?: (asset: EditorAsset) => void;
onDownloadAsset?: (asset: EditorAsset) => void;
} = {}) {
const props = {
asset,
assetPointerDragRef: { current: null },
suppressAssetClickRef: { current: suppressAssetClick },
isAssetSelectionMode,
selectedAssetIds,
visibleAssetIds: [asset.id],
renamingAsset,
setRenamingAsset: vi.fn(),
setUploadDropTarget: vi.fn(),
setAssetPointerDrag: vi.fn(),
updateAssetMoveDropFolder: vi.fn(),
addUploadedFiles: vi.fn(),
moveAssetToFolder: vi.fn(),
startRenamingAsset: vi.fn(),
commitAssetRename,
deleteUploadedAsset,
submitAssetToShowcase,
toggleAssetSelected,
addAssetLayer,
onDownloadAsset,
};
return {
props,
...render(<ImageCanvasAssetRowView {...props} />),
};
}
describe('ImageCanvasAssetRowView', () => {
it('adds the asset to the canvas in normal mode', () => {
const addAssetLayer = vi.fn();
const asset = createAsset();
renderAssetRow({ asset, addAssetLayer });
fireEvent.click(screen.getByRole('button', { name: '添加账号素材A' }));
expect(addAssetLayer).toHaveBeenCalledWith(asset);
});
it('downloads the asset from the row actions', () => {
const asset = createAsset();
const onDownloadAsset = vi.fn();
renderAssetRow({ asset, onDownloadAsset });
fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' }));
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
});
it('closes the right click menu when row actions run on the same asset', () => {
const asset = createAsset({
sourceResourceId: 'resource-asset-1',
publicShowcaseEnabled: true,
});
const addAssetLayer = vi.fn();
const onDownloadAsset = vi.fn();
renderAssetRow({ asset, addAssetLayer, onDownloadAsset });
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
expect(screen.getByRole('menu')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '下载素材账号素材A' }));
expect(onDownloadAsset).toHaveBeenCalledWith(asset);
expect(screen.queryByRole('menu')).toBeNull();
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
fireEvent.click(screen.getByRole('button', { name: '添加账号素材A' }));
expect(addAssetLayer).toHaveBeenCalledWith(asset);
expect(screen.queryByRole('menu')).toBeNull();
});
it('closes the right click menu when selection mode becomes active', () => {
const { props, rerender } = renderAssetRow();
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
expect(screen.getByRole('menu')).toBeTruthy();
rerender(
<ImageCanvasAssetRowView {...props} isAssetSelectionMode={true} />,
);
expect(screen.queryByRole('menu')).toBeNull();
});
it('selects the asset instead of adding it in selection mode', () => {
const addAssetLayer = vi.fn();
const toggleAssetSelected = vi.fn();
renderAssetRow({
isAssetSelectionMode: true,
addAssetLayer,
toggleAssetSelected,
});
fireEvent.click(screen.getByRole('button', { name: '选择素材账号素材A' }));
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: false,
});
expect(addAssetLayer).not.toHaveBeenCalled();
expect(
screen.queryByRole('button', { name: '下载素材账号素材A' }),
).toBeNull();
});
it('disables rows that are outside the shared selectable-asset boundary', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({
asset: createAsset({ sourceKind: 'built-in' }),
isAssetSelectionMode: true,
toggleAssetSelected,
});
const button = screen.getByRole('button', {
name: '素材账号素材A不可选择',
}) as HTMLButtonElement;
expect(button.disabled).toBe(true);
fireEvent.click(screen.getByText('账号素材A'));
expect(toggleAssetSelected).not.toHaveBeenCalled();
});
it('forwards only the shift modifier used by selection semantics', () => {
const toggleAssetSelected = vi.fn();
renderAssetRow({ isAssetSelectionMode: true, toggleAssetSelected });
fireEvent.click(screen.getByText('账号素材A'), {
ctrlKey: true,
shiftKey: true,
});
expect(toggleAssetSelected).toHaveBeenCalledWith('asset-1', {
visibleAssetIds: ['asset-1'],
shiftKey: true,
});
});
it('blocks internal moves and external file drops in selection mode', () => {
const { container, props } = renderAssetRow({
isAssetSelectionMode: true,
});
const row = container.querySelector<HTMLElement>(
'[data-asset-id="asset-1"]',
);
if (!row) {
throw new Error('asset row should exist');
}
const fileTransfer = {
types: ['Files'],
files: [new File(['image'], '新增素材.png', { type: 'image/png' })],
dropEffect: 'copy',
getData: vi.fn(() => ''),
};
fireEvent.dragOver(row, { dataTransfer: fileTransfer });
fireEvent.drop(row, { dataTransfer: fileTransfer });
const assetTransfer = {
types: ['application/x-genarrative-editor-asset'],
files: [],
dropEffect: 'move',
getData: vi.fn(() => 'asset-other'),
};
fireEvent.dragOver(row, { dataTransfer: assetTransfer });
fireEvent.drop(row, { dataTransfer: assetTransfer });
expect(props.addUploadedFiles).not.toHaveBeenCalled();
expect(props.moveAssetToFolder).not.toHaveBeenCalled();
});
it('renders rename input and commits with Enter', () => {
const commitAssetRename = vi.fn();
renderAssetRow({
renamingAsset: { assetId: 'asset-1', value: '主视觉素材' },
commitAssetRename,
});
const input = screen.getByLabelText('重命名素材账号素材A');
fireEvent.keyDown(input, { key: 'Enter' });
expect(input.className).toContain('platform-text-field');
expect(input.className).toContain(
'image-canvas-editor__asset-rename-input',
);
expect(commitAssetRename).toHaveBeenCalledWith(
expect.objectContaining({ id: 'asset-1' }),
);
});
it('keeps uploading assets disabled and shows progress', () => {
const addAssetLayer = vi.fn();
renderAssetRow({
asset: createAsset({
uploadStatus: 'uploading',
uploadProgress: 42,
uploadMessage: '正在上传',
}),
addAssetLayer,
});
fireEvent.click(screen.getByRole('button', { name: '上传中账号素材A' }));
expect(addAssetLayer).not.toHaveBeenCalled();
expect(screen.getAllByText('正在上传').length).toBeGreaterThan(0);
expect(screen.getAllByText('42%').length).toBeGreaterThan(0);
expect(
screen.getByLabelText('素材账号素材A上传进度').getAttribute('value'),
).toBe('42');
});
it('uses the audio cover thumbnail for audio assets', () => {
renderAssetRow({
asset: createAsset({
label: '胜利音效.mp3',
src: '/generated-character-drafts/editor/asset-library/audio/胜利音效.mp3',
mediaType: 'audio',
}),
});
expect(screen.getByText('音频')).toBeTruthy();
const image = screen.getByRole('img', { name: '素材:胜利音效.mp3' });
expect(image.getAttribute('src')).toBe(
'/creation-home/audio-asset-cover.png',
);
expect(
document.querySelector('.image-canvas-editor__asset-media-overlay'),
).toBeNull();
});
it('uses a stable visual preview for video asset cards without image thumbnails', () => {
renderAssetRow({
asset: createAsset({
label: '生成视频.mp4',
src: '/generated-character-drafts/editor/asset-library/video/demo.mp4',
objectKey:
'generated-character-drafts/editor/asset-library/video/demo.mp4',
mediaType: 'video',
}),
});
expect(screen.getByText('视频')).toBeTruthy();
expect(
screen.queryByRole('img', { name: '素材:生成视频.mp4' }),
).toBeNull();
const video = screen.getByLabelText(
'视频素材预览:生成视频.mp4',
) as HTMLVideoElement;
expect(video.getAttribute('src')).toBe(
'https://signed.example.com/generated-character-drafts/editor/asset-library/video/demo.mp4',
);
});
it('passes objectKey to image thumbnails in the asset library', () => {
renderAssetRow({
asset: createAsset({
label: '规范图',
src: '/generated-character-drafts/editor/spec.png',
objectKey: 'generated-character-drafts/editor/spec.png',
mediaType: 'image',
}),
});
const image = screen.getByRole('img', { name: '素材:规范图' });
expect(image.getAttribute('data-object-key')).toBe(
'generated-character-drafts/editor/spec.png',
);
});
it('renders image thumbnails when the persisted asset only has objectKey', () => {
renderAssetRow({
asset: createAsset({
label: '规范图',
src: '',
objectKey: 'generated-character-drafts/editor/spec.png',
mediaType: 'image',
}),
});
const image = screen.getByRole('img', { name: '素材:规范图' });
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/generated-character-drafts/editor/spec.png',
);
});
it('opens asset actions from the right click menu', () => {
const asset = createAsset({
sourceType: 'generated',
});
const deleteUploadedAsset = vi.fn();
const submitAssetToShowcase = vi.fn();
renderAssetRow({
asset,
deleteUploadedAsset,
submitAssetToShowcase,
});
expect(
screen.queryByRole('button', { name: '删除素材账号素材A' }),
).toBeNull();
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
const submitMenuItem = screen.getByRole('menuitem', {
name: /提交精选审核/u,
});
fireEvent.click(submitMenuItem);
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
expect(deleteUploadedAsset).toHaveBeenCalledWith(asset);
});
it('disables showcase submit when the asset is not generated', () => {
const submitAssetToShowcase = vi.fn();
renderAssetRow({ submitAssetToShowcase });
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
const submitMenuItem = screen.getByRole('menuitem', {
name: /提交精选审核/u,
});
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText('只有生成素材可以提交精选审核')).toBeTruthy();
fireEvent.click(submitMenuItem);
expect(submitAssetToShowcase).not.toHaveBeenCalled();
});
it('shows why generated assets cannot be submitted before they are saved', () => {
const submitAssetToShowcase = vi.fn();
renderAssetRow({
asset: createAsset({
sourceType: 'generated',
persisted: false,
}),
submitAssetToShowcase,
});
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
const submitMenuItem = screen.getByRole('menuitem', {
name: /提交精选审核/u,
});
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText('素材保存完成后才能提交精选审核')).toBeTruthy();
fireEvent.click(submitMenuItem);
expect(submitAssetToShowcase).not.toHaveBeenCalled();
});
it('enables showcase submit for persisted generated assets', () => {
const asset = createAsset({
sourceType: 'generated',
});
const submitAssetToShowcase = vi.fn();
renderAssetRow({ asset, submitAssetToShowcase });
fireEvent.contextMenu(
screen.getByRole('button', { name: '添加账号素材A' }),
{
clientX: 120,
clientY: 80,
},
);
const submitMenuItem = screen.getByRole('menuitem', {
name: /提交精选审核/u,
});
expect((submitMenuItem as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(submitMenuItem);
expect(submitAssetToShowcase).toHaveBeenCalledWith(asset);
});
});