72acd0ec05
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/120 Co-authored-by: Linghong <ink29535@proton.me> Co-committed-by: Linghong <ink29535@proton.me>
509 lines
15 KiB
TypeScript
509 lines
15 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
areAllSelectableAssetsSelected,
|
|
createAssetMarqueeFromPointer,
|
|
createAssetMarqueeSelectionRect,
|
|
createLocalAssetFolder,
|
|
deleteAssetFolderLocally,
|
|
filterGroupedAssetsBySearch,
|
|
getSelectableAssets,
|
|
groupAssetsByFolder,
|
|
layerMatchesSearch,
|
|
layerStartsWithSearch,
|
|
moveAssetMarqueeToPointer,
|
|
moveAssetToFolderLocally,
|
|
removeAssetById,
|
|
removeSelectedAssets,
|
|
renameAssetById,
|
|
renameAssetFolderById,
|
|
replaceLocalAssetFolder,
|
|
resolveAllAssetSelection,
|
|
resolveAssetFolderIdFromPoint,
|
|
resolveAssetShowcaseSubmissionState,
|
|
resolveDefaultAssetFolder,
|
|
resolvePinnedAssetMoveFolderId,
|
|
resolvePreferredAssetUploadFolder,
|
|
resolveUploadAssetFolder,
|
|
selectUploadedAssetsInRect,
|
|
toggleAssetFolderCollapsed,
|
|
toggleAssetSelection,
|
|
} 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' }] },
|
|
]);
|
|
expect(getSelectableAssets(assets).map((asset) => asset.id)).toEqual([
|
|
'asset-a',
|
|
'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('toggles selection, selects all uploaded assets and removes selected assets', () => {
|
|
const assets = [
|
|
createAsset({ id: 'asset-a' }),
|
|
createAsset({ id: 'asset-b' }),
|
|
];
|
|
const selected = toggleAssetSelection(new Set<string>(), 'asset-a');
|
|
|
|
expect([...selected]).toEqual(['asset-a']);
|
|
expect([...toggleAssetSelection(selected, 'asset-a')]).toEqual([]);
|
|
expect(areAllSelectableAssetsSelected(assets, new Set(['asset-a']))).toBe(
|
|
false,
|
|
);
|
|
const allSelected = resolveAllAssetSelection({
|
|
allSelectableAssetsSelected: false,
|
|
selectableAssets: assets,
|
|
});
|
|
expect([...allSelected]).toEqual(['asset-a', '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();
|
|
});
|
|
|
|
it('updates marquee geometry from pointer coordinates and normalizes reverse drags', () => {
|
|
const marquee = createAssetMarqueeFromPointer({
|
|
pointerId: 5,
|
|
point: { clientX: 180, clientY: 220 },
|
|
containerRect: { left: 100, top: 200 },
|
|
});
|
|
|
|
expect(marquee).toEqual({
|
|
pointerId: 5,
|
|
startX: 80,
|
|
startY: 20,
|
|
currentX: 80,
|
|
currentY: 20,
|
|
});
|
|
expect(
|
|
moveAssetMarqueeToPointer({
|
|
marquee,
|
|
point: { clientX: 120, clientY: 205 },
|
|
containerRect: { left: 100, top: 200 },
|
|
}),
|
|
).toMatchObject({ currentX: 20, currentY: 5 });
|
|
expect(
|
|
createAssetMarqueeSelectionRect({
|
|
marquee,
|
|
point: { clientX: 120, clientY: 205 },
|
|
containerRect: { left: 100, top: 200 },
|
|
}),
|
|
).toEqual({
|
|
left: 120,
|
|
right: 180,
|
|
top: 205,
|
|
bottom: 220,
|
|
});
|
|
});
|
|
|
|
it('selects only uploaded assets intersecting the marquee rectangle', () => {
|
|
const assets = [
|
|
createAsset({ id: 'asset-a' }),
|
|
createAsset({ id: 'asset-b' }),
|
|
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
|
|
];
|
|
const selectedIds = selectUploadedAssetsInRect({
|
|
assets,
|
|
assetTargets: [
|
|
{
|
|
assetId: 'asset-a',
|
|
rect: { left: 10, right: 20, top: 10, bottom: 20 },
|
|
},
|
|
{
|
|
assetId: 'asset-b',
|
|
rect: { left: 30, right: 40, top: 30, bottom: 40 },
|
|
},
|
|
{
|
|
assetId: 'built-in',
|
|
rect: { left: 15, right: 25, top: 15, bottom: 25 },
|
|
},
|
|
{
|
|
assetId: 'missing',
|
|
rect: { left: 15, right: 25, top: 15, bottom: 25 },
|
|
},
|
|
],
|
|
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
|
|
});
|
|
|
|
expect([...selectedIds]).toEqual(['asset-a', 'asset-b']);
|
|
});
|
|
});
|