Files
Genarrative/src/components/image-editor/ImageCanvasAssetLibraryModel.test.ts
T
lhk229 72acd0ec05
Project CI / Frontend tests (push) Successful in 3m40s
Project CI / Backend tests (push) Successful in 4m11s
Project CI / Repository checks (push) Successful in 1m19s
Project CI / Native shell tests (push) Successful in 12m36s
停止向用户泄露抠图模型 (#120)
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>
2026-07-31 16:40:43 +08:00

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']);
});
});