/** * Tests for canvas image selection functionality * Tests click selection, multi-select, and background deselection */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import Konva from 'konva'; import { get } from 'svelte/store'; import { selection } from '$lib/stores/selection'; import { setupImageSelection, setupBackgroundDeselect, selectImage, deselectImage, toggleImageSelection, selectAllImages, clearAllSelection, getSelectedCount, getSelectedImageIds, isImageSelected, } from '$lib/canvas/interactions/select'; describe('Image Selection', () => { let stage: Konva.Stage; let layer: Konva.Layer; let image: Konva.Image; let imageId: string; beforeEach(() => { // Create container const container = document.createElement('div'); container.id = 'test-container'; document.body.appendChild(container); // Create stage and layer stage = new Konva.Stage({ container: 'test-container', width: 800, height: 600, }); layer = new Konva.Layer(); stage.add(layer); // Create test image const imageElement = new Image(); imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; image = new Konva.Image({ image: imageElement, x: 100, y: 100, width: 200, height: 200, }); layer.add(image); layer.draw(); imageId = 'test-image-1'; // Reset selection selection.clearSelection(); }); afterEach(() => { stage.destroy(); document.body.innerHTML = ''; }); describe('Setup', () => { it('sets up click handler on image', () => { const cleanup = setupImageSelection(image, imageId); expect(typeof cleanup).toBe('function'); cleanup(); }); it('cleanup function removes click handlers', () => { const cleanup = setupImageSelection(image, imageId); // Select the image image.fire('click', { evt: { ctrlKey: false, metaKey: false } }); expect(get(selection).selectedIds.has(imageId)).toBe(true); // Clean up cleanup(); // Clear selection selection.clearSelection(); // Click should no longer work image.fire('click', { evt: { ctrlKey: false, metaKey: false } }); expect(get(selection).selectedIds.has(imageId)).toBe(false); }); }); describe('Single Click Selection', () => { it('selects image on click', () => { setupImageSelection(image, imageId); image.fire('click', { evt: { ctrlKey: false, metaKey: false } }); const selectionState = get(selection); expect(selectionState.selectedIds.has(imageId)).toBe(true); expect(selectionState.selectedIds.size).toBe(1); }); it('replaces selection when clicking different image', () => { selection.selectOne('other-image'); setupImageSelection(image, imageId); image.fire('click', { evt: { ctrlKey: false, metaKey: false } }); const selectionState = get(selection); expect(selectionState.selectedIds.has(imageId)).toBe(true); expect(selectionState.selectedIds.has('other-image')).toBe(false); expect(selectionState.selectedIds.size).toBe(1); }); it('calls onSelectionChange callback', () => { const callback = vi.fn(); setupImageSelection(image, imageId, undefined, callback); image.fire('click', { evt: { ctrlKey: false, metaKey: false } }); expect(callback).toHaveBeenCalledWith(imageId, true); }); it('does not deselect on second click without Ctrl', () => { setupImageSelection(image, imageId); image.fire('click', { evt: { ctrlKey: false, metaKey: false } }); image.fire('click', { evt: { ctrlKey: false, metaKey: false } }); const selectionState = get(selection); expect(selectionState.selectedIds.has(imageId)).toBe(true); }); }); describe('Multi-Select (Ctrl+Click)', () => { it('adds to selection with Ctrl+Click', () => { selection.selectOne('other-image'); setupImageSelection(image, imageId); image.fire('click', { evt: { ctrlKey: true, metaKey: false } }); const selectionState = get(selection); expect(selectionState.selectedIds.has(imageId)).toBe(true); expect(selectionState.selectedIds.has('other-image')).toBe(true); expect(selectionState.selectedIds.size).toBe(2); }); it('removes from selection with Ctrl+Click on selected image', () => { selection.selectMultiple([imageId, 'other-image']); setupImageSelection(image, imageId); image.fire('click', { evt: { ctrlKey: true, metaKey: false } }); const selectionState = get(selection); expect(selectionState.selectedIds.has(imageId)).toBe(false); expect(selectionState.selectedIds.has('other-image')).toBe(true); expect(selectionState.selectedIds.size).toBe(1); }); it('works with Cmd key (metaKey) on Mac', () => { selection.selectOne('other-image'); setupImageSelection(image, imageId); image.fire('click', { evt: { ctrlKey: false, metaKey: true } }); const selectionState = get(selection); expect(selectionState.selectedIds.has(imageId)).toBe(true); expect(selectionState.selectedIds.size).toBe(2); }); it('calls callback with correct state when adding to selection', () => { const callback = vi.fn(); setupImageSelection(image, imageId, undefined, callback); image.fire('click', { evt: { ctrlKey: true, metaKey: false } }); expect(callback).toHaveBeenCalledWith(imageId, true); }); it('calls callback with correct state when removing from selection', () => { const callback = vi.fn(); selection.selectOne(imageId); setupImageSelection(image, imageId, undefined, callback); image.fire('click', { evt: { ctrlKey: true, metaKey: false } }); expect(callback).toHaveBeenCalledWith(imageId, false); }); }); describe('Background Deselection', () => { it('clears selection when clicking stage background', () => { selection.selectMultiple([imageId, 'other-image']); setupBackgroundDeselect(stage); // Simulate click on stage (not on shape) stage.fire('click', { target: stage, evt: {}, }); const selectionState = get(selection); expect(selectionState.selectedIds.size).toBe(0); }); it('does not clear selection when clicking on shape', () => { selection.selectMultiple([imageId, 'other-image']); setupBackgroundDeselect(stage); // Simulate click on shape stage.fire('click', { target: image, evt: {}, }); const selectionState = get(selection); expect(selectionState.selectedIds.size).toBe(2); }); it('calls onDeselect callback when background clicked', () => { const callback = vi.fn(); selection.selectOne(imageId); setupBackgroundDeselect(stage, callback); stage.fire('click', { target: stage, evt: {}, }); expect(callback).toHaveBeenCalled(); }); it('cleanup removes background deselect handler', () => { selection.selectOne(imageId); const cleanup = setupBackgroundDeselect(stage); cleanup(); stage.fire('click', { target: stage, evt: {}, }); const selectionState = get(selection); expect(selectionState.selectedIds.size).toBe(1); }); }); describe('Programmatic Selection', () => { it('selectImage selects single image', () => { selectImage(imageId); expect(isImageSelected(imageId)).toBe(true); expect(getSelectedCount()).toBe(1); }); it('selectImage with multiSelect adds to selection', () => { selectImage('image-1'); selectImage('image-2', true); expect(getSelectedCount()).toBe(2); expect(getSelectedImageIds()).toEqual(['image-1', 'image-2']); }); it('deselectImage removes from selection', () => { selection.selectMultiple([imageId, 'other-image']); deselectImage(imageId); expect(isImageSelected(imageId)).toBe(false); expect(isImageSelected('other-image')).toBe(true); }); it('toggleImageSelection toggles state', () => { toggleImageSelection(imageId); expect(isImageSelected(imageId)).toBe(true); toggleImageSelection(imageId); expect(isImageSelected(imageId)).toBe(false); }); it('selectAllImages selects all provided IDs', () => { const allIds = ['img-1', 'img-2', 'img-3', 'img-4']; selectAllImages(allIds); expect(getSelectedCount()).toBe(4); expect(getSelectedImageIds()).toEqual(allIds); }); it('clearAllSelection clears everything', () => { selection.selectMultiple(['img-1', 'img-2', 'img-3']); clearAllSelection(); expect(getSelectedCount()).toBe(0); expect(getSelectedImageIds()).toEqual([]); }); }); describe('Query Functions', () => { it('getSelectedCount returns correct count', () => { expect(getSelectedCount()).toBe(0); selection.selectOne(imageId); expect(getSelectedCount()).toBe(1); selection.addToSelection('other-image'); expect(getSelectedCount()).toBe(2); }); it('getSelectedImageIds returns array of IDs', () => { selection.selectMultiple(['img-1', 'img-2', 'img-3']); const ids = getSelectedImageIds(); expect(Array.isArray(ids)).toBe(true); expect(ids.length).toBe(3); expect(ids).toContain('img-1'); expect(ids).toContain('img-2'); expect(ids).toContain('img-3'); }); it('isImageSelected returns correct boolean', () => { expect(isImageSelected(imageId)).toBe(false); selection.selectOne(imageId); expect(isImageSelected(imageId)).toBe(true); selection.clearSelection(); expect(isImageSelected(imageId)).toBe(false); }); }); describe('Edge Cases', () => { it('handles selecting non-existent image', () => { selectImage('non-existent-id'); expect(getSelectedCount()).toBe(1); expect(isImageSelected('non-existent-id')).toBe(true); }); it('handles deselecting non-selected image', () => { deselectImage('not-selected-id'); expect(getSelectedCount()).toBe(0); }); it('handles toggling same image multiple times', () => { toggleImageSelection(imageId); toggleImageSelection(imageId); toggleImageSelection(imageId); expect(isImageSelected(imageId)).toBe(true); }); it('handles empty array in selectAllImages', () => { selectAllImages([]); expect(getSelectedCount()).toBe(0); }); it('handles large selection sets', () => { const largeSet = Array.from({ length: 1000 }, (_, i) => `img-${i}`); selectAllImages(largeSet); expect(getSelectedCount()).toBe(1000); }); }); describe('Touch Events', () => { it('handles tap event same as click', () => { setupImageSelection(image, imageId); image.fire('tap', { evt: { ctrlKey: false, metaKey: false } }); expect(isImageSelected(imageId)).toBe(true); }); it('prevents event bubbling to stage', () => { setupImageSelection(image, imageId); setupBackgroundDeselect(stage); const clickEvent = new Event('click', { bubbles: true, cancelable: true }); Object.defineProperty(clickEvent, 'cancelBubble', { writable: true, value: false, }); image.fire('click', { evt: { ctrlKey: false, metaKey: false, ...clickEvent, }, }); // Image should be selected expect(isImageSelected(imageId)).toBe(true); }); }); });