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

471 lines
14 KiB
TypeScript

/* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react';
import { createRef, type PointerEvent as ReactPointerEvent } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { EditorAsset } from './ImageCanvasEditorTypes';
import {
countHiddenSelectedAssets,
createAssetMarqueeFromPointer,
createAssetMarqueeSelectionRect,
getSelectableAssets,
moveAssetMarqueeToPointer,
selectUploadedAssetsInRect,
useImageCanvasAssetSelection,
} from './useImageCanvasAssetSelection';
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,
};
}
describe('useImageCanvasAssetSelection', () => {
it('counts selected assets outside the current visible scope', () => {
expect(
countHiddenSelectedAssets(new Set(['asset-a', 'asset-b', 'asset-c']), [
'asset-a',
]),
).toBe(2);
});
it('owns selectable-asset validity', () => {
expect(
getSelectableAssets([
createAsset({ id: 'ready' }),
createAsset({ id: 'uploading', uploadStatus: 'uploading' }),
createAsset({ id: 'failed', uploadStatus: 'failed' }),
createAsset({ id: 'empty', src: ' ' }),
createAsset({
id: 'object-key-only',
src: '',
objectKey: 'generated/editor/object-key-only.png',
}),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
]).map((asset) => asset.id),
).toEqual(['ready', 'object-key-only']);
});
it('owns marquee geometry and selectable hit testing', () => {
const marquee = createAssetMarqueeFromPointer({
pointerId: 5,
point: { clientX: 180, clientY: 220 },
containerRect: { left: 100, top: 200 },
});
expect(
moveAssetMarqueeToPointer({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
}),
).toMatchObject({ currentX: 20, currentY: 5 });
const selectionRect = createAssetMarqueeSelectionRect({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
});
expect(selectionRect).toEqual({
left: 120,
right: 180,
top: 205,
bottom: 220,
});
expect([
...selectUploadedAssetsInRect({
assets: [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
createAsset({
id: 'object-key-only',
src: '',
objectKey: 'generated/editor/object-key-only.png',
}),
createAsset({ id: 'built-in', sourceKind: 'built-in' }),
],
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: 'object-key-only',
rect: { left: 25, right: 30, top: 25, bottom: 30 },
},
],
selectionRect: { left: 20, right: 35, top: 20, bottom: 35 },
}),
]).toEqual(['asset-a', 'asset-b', 'object-key-only']);
});
it('keeps marquee geometry aligned with a scrolling asset list', () => {
const container = { scrollLeft: 40, scrollTop: 60 };
const marquee = createAssetMarqueeFromPointer({
pointerId: 9,
point: { clientX: 180, clientY: 220 },
containerRect: { left: 100, top: 200 },
container,
});
expect(marquee).toMatchObject({
startX: 120,
startY: 80,
currentX: 120,
currentY: 80,
});
container.scrollLeft = 50;
container.scrollTop = 70;
expect(
moveAssetMarqueeToPointer({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
container,
}),
).toMatchObject({ currentX: 70, currentY: 75 });
expect(
createAssetMarqueeSelectionRect({
marquee,
point: { clientX: 120, clientY: 205 },
containerRect: { left: 100, top: 200 },
container,
}),
).toEqual({ left: 120, right: 170, top: 205, bottom: 210 });
});
it('supports click, shift and select-all for object-key-only assets', () => {
const assets = [
createAsset({
id: 'object-key-a',
src: '',
objectKey: 'generated/editor/object-key-a.png',
}),
createAsset({
id: 'object-key-b',
src: '',
objectKey: 'generated/editor/object-key-b.png',
}),
];
const visibleAssetIds = getSelectableAssets(assets).map(
(asset) => asset.id,
);
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: createRef<HTMLDivElement>(),
assets,
}),
);
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.toggleAssetSelected('object-key-a', { visibleAssetIds }),
);
act(() =>
result.current.toggleAssetSelected('object-key-b', {
visibleAssetIds,
shiftKey: true,
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'object-key-a',
'object-key-b',
]);
act(() => result.current.toggleAllAssetsSelected(visibleAssetIds));
expect(result.current.selectedAssetIds.size).toBe(0);
act(() => result.current.toggleAllAssetsSelected(visibleAssetIds));
expect([...result.current.selectedAssetIds]).toEqual(visibleAssetIds);
});
it('applies tick, shift and visible select-all as deltas', () => {
const assets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
createAsset({ id: 'asset-c' }),
];
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: createRef<HTMLDivElement>(),
assets,
}),
);
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.toggleAssetSelected('asset-c', {
visibleAssetIds: ['asset-a', 'asset-b', 'asset-c'],
}),
);
act(() =>
result.current.toggleAssetSelected('asset-b', {
visibleAssetIds: ['asset-a', 'asset-b'],
shiftKey: true,
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
]);
act(() =>
result.current.toggleAssetSelected('asset-a', {
visibleAssetIds: ['asset-a', 'asset-b'],
shiftKey: true,
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
'asset-a',
]);
act(() => result.current.toggleAllAssetsSelected(['asset-a']));
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
]);
act(() => result.current.toggleAllAssetsSelected(['asset-a']));
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
'asset-a',
]);
act(() =>
result.current.toggleAssetSelected('asset-stale', {
visibleAssetIds: ['asset-a'],
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'asset-c',
'asset-b',
'asset-a',
]);
});
it('reconciles against selectable assets instead of UI visibility', async () => {
const initialAssets = [
createAsset({ id: 'asset-a' }),
createAsset({ id: 'asset-b' }),
createAsset({ id: 'asset-c' }),
];
const assetListRef = createRef<HTMLDivElement>();
const { result, rerender } = renderHook(
({ assets }: { assets: EditorAsset[] }) =>
useImageCanvasAssetSelection({ assetListRef, assets }),
{ initialProps: { assets: initialAssets } },
);
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.toggleAllAssetsSelected(['asset-a', 'asset-b', 'asset-c']),
);
act(() =>
result.current.toggleAssetSelected('asset-b', {
visibleAssetIds: ['asset-b'],
}),
);
expect([...result.current.selectedAssetIds]).toEqual([
'asset-a',
'asset-c',
]);
rerender({
assets: [createAsset({ id: 'asset-b' }), createAsset({ id: 'asset-c' })],
});
await waitFor(() => {
expect([...result.current.selectedAssetIds]).toEqual(['asset-c']);
expect(result.current.selectedAssets.map((asset) => asset.id)).toEqual([
'asset-c',
]);
});
});
it('owns selection-mode open and close lifecycle', () => {
const onOpenSelectionMode = vi.fn();
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: createRef<HTMLDivElement>(),
assets: [createAsset()],
onOpenSelectionMode,
}),
);
act(() => result.current.openAssetSelectionMode());
expect(result.current.isAssetSelectionMode).toBe(true);
expect(onOpenSelectionMode).toHaveBeenCalledTimes(1);
act(() =>
result.current.toggleAssetSelected('asset-a', {
visibleAssetIds: ['asset-a'],
}),
);
expect([...result.current.selectedAssetIds]).toEqual(['asset-a']);
act(() => result.current.closeAssetSelectionMode());
expect(result.current.isAssetSelectionMode).toBe(false);
expect(result.current.selectedAssetIds.size).toBe(0);
});
it('leaves touch background gestures available for list scrolling', () => {
const assetList = document.createElement('div');
const setPointerCapture = vi.fn();
Object.assign(assetList, { setPointerCapture });
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: { current: assetList },
assets: [createAsset()],
}),
);
const preventDefault = vi.fn();
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.handleAssetMarqueePointerDown({
target: assetList,
pointerType: 'touch',
button: 0,
pointerId: 3,
clientX: 10,
clientY: 20,
ctrlKey: false,
metaKey: false,
preventDefault,
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee).toBeNull();
expect(preventDefault).not.toHaveBeenCalled();
expect(setPointerCapture).not.toHaveBeenCalled();
});
it('commits the pointer-up endpoint but not a cancelled marquee', () => {
const assetList = document.createElement('div');
const assetTarget = document.createElement('div');
assetTarget.dataset.assetId = 'asset-a';
assetTarget.getBoundingClientRect = () =>
({ left: 40, right: 50, top: 40, bottom: 50 }) as DOMRect;
assetList.appendChild(assetTarget);
assetList.getBoundingClientRect = () =>
({ left: 0, right: 100, top: 0, bottom: 100 }) as DOMRect;
Object.assign(assetList, {
setPointerCapture: vi.fn(),
hasPointerCapture: vi.fn(() => true),
releasePointerCapture: vi.fn(),
});
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef: { current: assetList },
assets: [createAsset()],
}),
);
const pointerDown = (pointerId: number) =>
result.current.handleAssetMarqueePointerDown({
target: assetList,
pointerType: 'mouse',
button: 0,
pointerId,
clientX: 10,
clientY: 10,
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn(),
} as unknown as ReactPointerEvent<HTMLDivElement>);
act(() => result.current.openAssetSelectionMode());
act(() => pointerDown(4));
act(() =>
result.current.handleAssetMarqueePointerUp({
pointerId: 4,
clientX: 50,
clientY: 50,
preventDefault: vi.fn(),
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect([...result.current.selectedAssetIds]).toEqual(['asset-a']);
expect(result.current.assetMarquee).toBeNull();
act(() => pointerDown(5));
act(() =>
result.current.handleAssetMarqueePointerMove({
pointerId: 5,
clientX: 50,
clientY: 50,
preventDefault: vi.fn(),
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect([...result.current.selectedAssetIds]).toEqual(['asset-a']);
act(() =>
result.current.handleAssetMarqueePointerCancel({
pointerId: 5,
clientX: 50,
clientY: 50,
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.selectedAssetIds.size).toBe(0);
expect(result.current.assetMarquee).toBeNull();
});
it('clears only the matching marquee when pointer capture is lost', () => {
const assetList = document.createElement('div');
const releasePointerCapture = vi.fn();
Object.assign(assetList, {
setPointerCapture: vi.fn(),
releasePointerCapture,
});
const assetListRef = { current: assetList };
const { result } = renderHook(() =>
useImageCanvasAssetSelection({
assetListRef,
assets: [createAsset()],
}),
);
const target = document.createElement('div');
act(() => result.current.openAssetSelectionMode());
act(() =>
result.current.handleAssetMarqueePointerDown({
target,
button: 0,
pointerId: 7,
clientX: 10,
clientY: 20,
ctrlKey: false,
metaKey: false,
preventDefault: vi.fn(),
} as unknown as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee?.pointerId).toBe(7);
act(() =>
result.current.handleAssetMarqueeLostPointerCapture({
pointerId: 8,
} as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee?.pointerId).toBe(7);
act(() =>
result.current.handleAssetMarqueeLostPointerCapture({
pointerId: 7,
} as ReactPointerEvent<HTMLDivElement>),
);
expect(result.current.assetMarquee).toBeNull();
expect(releasePointerCapture).not.toHaveBeenCalled();
});
});