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

418 lines
12 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
createLocalAssetFolder,
deleteAssetFolderLocally,
filterGroupedAssetsBySearch,
groupAssetsByFolder,
layerMatchesSearch,
layerStartsWithSearch,
moveAssetToFolderLocally,
removeAssetById,
removeSelectedAssets,
renameAssetById,
renameAssetFolderById,
replaceLocalAssetFolder,
resolveAssetFolderIdFromPoint,
resolveAssetShowcaseSubmissionState,
resolveDefaultAssetFolder,
resolvePinnedAssetMoveFolderId,
resolvePreferredAssetUploadFolder,
resolveUploadAssetFolder,
toggleAssetFolderCollapsed,
} from './ImageCanvasAssetLibraryModel';
import type {
CanvasLayer,
EditorAsset,
EditorAssetFolder,
} from './ImageCanvasEditorTypes';
function createFolder(
overrides: Partial<EditorAssetFolder> = {},
): EditorAssetFolder {
return {
id: 'project',
label: '项目素材',
collapsed: false,
systemDefault: true,
persisted: true,
...overrides,
};
}
function createAsset(overrides: Partial<EditorAsset> = {}): EditorAsset {
return {
id: 'asset-a',
label: '素材A',
src: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
folderId: 'project',
sourceKind: 'uploaded',
sourceType: 'uploaded',
persisted: true,
...overrides,
};
}
function createLayer(overrides: Partial<CanvasLayer> = {}): CanvasLayer {
return {
id: 'layer-a',
resourceId: 'resource-a',
title: '图层A',
src: 'data:image/png;base64,YQ==',
x: 0,
y: 0,
width: 320,
height: 240,
originalWidth: 320,
originalHeight: 240,
zIndex: 1,
sourceType: 'generated',
...overrides,
};
}
describe('ImageCanvasAssetLibraryModel', () => {
it('groups assets by folder and resolves selectable uploaded assets', () => {
const folders = [
createFolder(),
createFolder({ id: 'folder-role', label: '角色素材' }),
];
const assets = [
createAsset({ id: 'asset-a', folderId: 'project' }),
createAsset({
id: 'asset-b',
label: '素材B',
folderId: 'folder-role',
}),
createAsset({
id: 'built-in',
label: '内置素材',
sourceKind: 'built-in',
}),
];
expect(groupAssetsByFolder(folders, assets)).toMatchObject([
{ id: 'project', assets: [{ id: 'asset-a' }, { id: 'built-in' }] },
{ id: 'folder-role', assets: [{ id: 'asset-b' }] },
]);
});
it('explains why assets cannot be submitted to showcase review', () => {
expect(resolveAssetShowcaseSubmissionState(createAsset()).reason).toBe(
'只有生成素材可以提交精选审核',
);
expect(
resolveAssetShowcaseSubmissionState(
createAsset({ sourceType: 'generated', persisted: false }),
).reason,
).toBe('素材保存完成后才能提交精选审核');
expect(
resolveAssetShowcaseSubmissionState(
createAsset({ sourceType: 'generated', src: '' }),
).reason,
).toBe('没有媒体内容的素材不能提交精选审核');
expect(
resolveAssetShowcaseSubmissionState(
createAsset({
sourceType: 'generated',
showcaseReviewStatus: 'rejected',
}),
),
).toMatchObject({
canSubmit: false,
label: '精选未通过',
reason: '该素材审核未通过,不能重复提交',
});
});
it('allows persisted generated assets to be submitted and keeps prior backend errors visible', () => {
expect(
resolveAssetShowcaseSubmissionState(
createAsset({ sourceType: 'generated' }),
),
).toMatchObject({
canSubmit: true,
label: '提交精选审核',
reason: null,
});
expect(
resolveAssetShowcaseSubmissionState(
createAsset({
sourceType: 'generated',
showcaseSubmitError: '只有生成素材可以提交精选审核',
}),
),
).toMatchObject({
canSubmit: true,
label: '提交精选审核',
reason: '只有生成素材可以提交精选审核',
});
});
it('filters grouped assets by folder and asset search text', () => {
const groupedAssets = groupAssetsByFolder(
[
createFolder(),
createFolder({ id: 'folder-uploaded', label: '上传素材' }),
],
[
createAsset({ id: 'asset-a', label: '草地角色', folderId: 'project' }),
createAsset({
id: 'asset-b',
label: '音效',
folderId: 'folder-uploaded',
model: 'audio1.0',
}),
createAsset({
id: 'asset-c',
label: '角色音效',
folderId: 'folder-uploaded',
}),
],
);
expect(
filterGroupedAssetsBySearch(groupedAssets, 'audio').map((folder) => ({
id: folder.id,
assets: folder.assets.map((asset) => asset.id),
})),
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b'] }]);
expect(
filterGroupedAssetsBySearch(groupedAssets, '音').map((folder) => ({
id: folder.id,
assets: folder.assets.map((asset) => asset.id),
})),
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b', 'asset-c'] }]);
expect(
filterGroupedAssetsBySearch(groupedAssets, '上传').map((folder) => ({
id: folder.id,
assets: folder.assets.map((asset) => asset.id),
})),
).toEqual([{ id: 'folder-uploaded', assets: ['asset-b', 'asset-c'] }]);
});
it('excludes internal processing models and providers from asset and layer search without deleting metadata', () => {
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',
});
const groupedAssets = groupAssetsByFolder(
[createFolder()],
[internalAsset, visibleAsset],
);
const internalLayer = createLayer({
model: 'segment-common-image',
provider: 'Aliyun Matting',
});
const visibleLayer = createLayer({
id: 'layer-visible',
model: 'audio1.0',
provider: 'VectorEngine',
});
expect(filterGroupedAssetsBySearch(groupedAssets, 'bgfilter')).toEqual([]);
expect(filterGroupedAssetsBySearch(groupedAssets, 'vectorengine')).toEqual(
[],
);
expect(
filterGroupedAssetsBySearch(groupedAssets, 'gpt-image-2')[0]?.assets.map(
(asset) => asset.id,
),
).toEqual(['asset-visible']);
expect(layerMatchesSearch(internalLayer, 'segment-common-image')).toBe(
false,
);
expect(layerStartsWithSearch(internalLayer, 'aliyun')).toBe(false);
expect(layerMatchesSearch(visibleLayer, 'audio1.0')).toBe(true);
expect(layerMatchesSearch(visibleLayer, 'vectorengine')).toBe(false);
expect(internalAsset).toMatchObject({
model: 'BgFilter complex',
provider: 'BgFilter',
});
expect(internalLayer).toMatchObject({
model: 'segment-common-image',
provider: 'Aliyun Matting',
});
});
it('renames assets, toggles folders and creates local folders', () => {
expect(
renameAssetById([createAsset()], 'asset-a', '新名字')[0],
).toMatchObject({ label: '新名字' });
expect(
renameAssetFolderById([createFolder()], 'project', '默认素材')[0],
).toMatchObject({ label: '默认素材' });
expect(
toggleAssetFolderCollapsed([createFolder()], 'project')[0],
).toMatchObject({ collapsed: true });
expect(
createLocalAssetFolder({
folderId: 'folder-local',
label: '角色素材',
}),
).toEqual({
id: 'folder-local',
label: '角色素材',
collapsed: false,
systemDefault: false,
persisted: false,
});
});
it('replaces local folder ids in folders and contained assets', () => {
const result = replaceLocalAssetFolder({
folders: [createFolder(), createFolder({ id: 'folder-local' })],
assets: [createAsset({ folderId: 'folder-local' })],
localFolderId: 'folder-local',
persistedFolder: {
folderId: 'folder-role',
label: '角色素材',
collapsed: false,
systemDefault: false,
},
});
expect(result.folders[1]).toEqual({
id: 'folder-role',
label: '角色素材',
collapsed: false,
systemDefault: false,
persisted: true,
});
expect(result.assets[0]).toMatchObject({ folderId: 'folder-role' });
});
it('deletes assets and moves deleted folder contents to the default folder', () => {
const folders = [
createFolder(),
createFolder({ id: 'folder-role', systemDefault: false }),
];
const assets = [
createAsset({ id: 'asset-a', folderId: 'folder-role' }),
createAsset({ id: 'asset-b', folderId: 'project' }),
];
expect(removeAssetById(assets, 'asset-a').map((asset) => asset.id)).toEqual(
['asset-b'],
);
expect(resolveDefaultAssetFolder(folders)?.id).toBe('project');
expect(
resolveUploadAssetFolder([
createFolder(),
createFolder({ id: 'folder-uploaded', label: '上传素材' }),
])?.id,
).toBe('folder-uploaded');
expect(resolvePreferredAssetUploadFolder(folders)?.id).toBe('project');
expect(
deleteAssetFolderLocally({
folders,
assets,
folderId: 'folder-role',
defaultFolderId: 'project',
}),
).toMatchObject({
folders: [{ id: 'project' }],
assets: [
{ id: 'asset-a', folderId: 'project' },
{ id: 'asset-b', folderId: 'project' },
],
});
});
it('removes selected assets', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
];
const removal = removeSelectedAssets(assets, new Set(['asset-b']));
expect(removal.assets.map((asset) => asset.id)).toEqual(['asset-a']);
expect(removal.deletedAssets.map((asset) => asset.id)).toEqual(['asset-b']);
});
it('moves assets between folders locally', () => {
expect(
moveAssetToFolderLocally([createAsset()], 'asset-a', 'folder-role')[0],
).toMatchObject({ folderId: 'folder-role' });
});
it('resolves folder hit targets from pointer coordinates', () => {
const folders = [
{
folderId: 'project',
rect: { left: 10, right: 110, top: 10, bottom: 80 },
},
{
folderId: 'folder-role',
rect: { left: 10, right: 110, top: 81, bottom: 150 },
},
];
expect(
resolveAssetFolderIdFromPoint({
point: { clientX: 40, clientY: 80 },
listRect: { left: 0, right: 140, top: 0, bottom: 180 },
folders,
}),
).toBe('project');
expect(
resolveAssetFolderIdFromPoint({
point: { clientX: 5, clientY: 40 },
listRect: { left: 10, right: 140, top: 0, bottom: 180 },
folders,
}),
).toBeNull();
expect(
resolveAssetFolderIdFromPoint({
point: { clientX: 120, clientY: 40 },
listRect: { left: 0, right: 140, top: 0, bottom: 180 },
folders,
}),
).toBeNull();
});
it('pins asset move folder labels only when their header is outside the list viewport', () => {
const listRect = { left: 0, right: 120, top: 100, bottom: 240 };
expect(
resolvePinnedAssetMoveFolderId({
folderId: 'folder-role',
listRect,
headerRect: { left: 0, right: 120, top: 40, bottom: 80 },
}),
).toBe('folder-role');
expect(
resolvePinnedAssetMoveFolderId({
folderId: 'folder-role',
listRect,
headerRect: { left: 0, right: 120, top: 260, bottom: 300 },
}),
).toBe('folder-role');
expect(
resolvePinnedAssetMoveFolderId({
folderId: 'folder-role',
listRect,
headerRect: { left: 0, right: 120, top: 120, bottom: 150 },
}),
).toBeNull();
expect(
resolvePinnedAssetMoveFolderId({
folderId: null,
listRect,
headerRect: { left: 0, right: 120, top: 40, bottom: 80 },
}),
).toBeNull();
});
});