import { describe, expect, it } from 'vitest'; import type { EditorAsset, EditorAssetFolder, } from './ImageCanvasEditorTypes'; import { areAllSelectableAssetsSelected, createAssetMarqueeFromPointer, createAssetMarqueeSelectionRect, createLocalAssetFolder, deleteAssetFolderLocally, filterGroupedAssetsBySearch, getSelectableAssets, groupAssetsByFolder, moveAssetToFolderLocally, moveAssetMarqueeToPointer, removeAssetById, removeSelectedAssets, renameAssetById, renameAssetFolderById, replaceLocalAssetFolder, resolveAllAssetSelection, resolveAssetShowcaseSubmissionState, resolveAssetFolderIdFromPoint, resolveDefaultAssetFolder, resolvePreferredAssetUploadFolder, resolvePinnedAssetMoveFolderId, resolveUploadAssetFolder, selectUploadedAssetsInRect, toggleAssetFolderCollapsed, toggleAssetSelection, } from './ImageCanvasAssetLibraryModel'; function createFolder( overrides: Partial = {}, ): EditorAssetFolder { return { id: 'project', label: '项目素材', collapsed: false, systemDefault: true, persisted: true, ...overrides, }; } function createAsset(overrides: Partial = {}): 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, }; } 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('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(), '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']); }); });