/* @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 { 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(), 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(), 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(); 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(), 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), ); 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); act(() => result.current.openAssetSelectionMode()); act(() => pointerDown(4)); act(() => result.current.handleAssetMarqueePointerUp({ pointerId: 4, clientX: 50, clientY: 50, preventDefault: vi.fn(), } as unknown as ReactPointerEvent), ); 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), ); expect([...result.current.selectedAssetIds]).toEqual(['asset-a']); act(() => result.current.handleAssetMarqueePointerCancel({ pointerId: 5, clientX: 50, clientY: 50, } as unknown as ReactPointerEvent), ); 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), ); expect(result.current.assetMarquee?.pointerId).toBe(7); act(() => result.current.handleAssetMarqueeLostPointerCapture({ pointerId: 8, } as ReactPointerEvent), ); expect(result.current.assetMarquee?.pointerId).toBe(7); act(() => result.current.handleAssetMarqueeLostPointerCapture({ pointerId: 7, } as ReactPointerEvent), ); expect(result.current.assetMarquee).toBeNull(); expect(releasePointerCapture).not.toHaveBeenCalled(); }); });