Files
Genarrative/src/components/image-editor/useImageCanvasAssetSelection.test.tsx
T
k88936 9afd1ebfdb fix: 修复 objectKey-only 私有素材的多选与批量导出。
统一使用 src || objectKey 判断素材是否可读
已同步导出方案文档
2026-08-04 10:52:15 +08:00

337 lines
9.9 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('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('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();
});
});