/** * Tests for multi-selection functionality * Tests rectangle selection, Ctrl+A, and bulk operations */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Konva from 'konva'; import { get } from 'svelte/store'; import { selection } from '$lib/stores/selection'; import { setupRectangleSelection, isRectangleSelecting, getCurrentSelectionRect, cancelRectangleSelection, } from '$lib/canvas/interactions/multiselect'; import { setupKeyboardShortcuts, selectAllImages, deselectAllImages } from '$lib/canvas/keyboard'; import { bulkMove, bulkMoveTo, bulkCenterAt, getBulkBounds, } from '$lib/canvas/operations/bulk-move'; import { bulkRotateTo, bulkRotateBy, bulkRotate90CW } from '$lib/canvas/operations/bulk-rotate'; import { bulkScaleTo, bulkScaleBy, bulkDoubleSize } from '$lib/canvas/operations/bulk-scale'; describe('Rectangle Selection', () => { let stage: Konva.Stage; let layer: Konva.Layer; beforeEach(() => { const container = document.createElement('div'); container.id = 'test-container'; document.body.appendChild(container); stage = new Konva.Stage({ container: 'test-container', width: 800, height: 600, }); layer = new Konva.Layer(); stage.add(layer); selection.clearSelection(); }); afterEach(() => { stage.destroy(); document.body.innerHTML = ''; }); it('sets up rectangle selection on stage', () => { const getImageBounds = () => []; const cleanup = setupRectangleSelection(stage, layer, getImageBounds); expect(typeof cleanup).toBe('function'); cleanup(); }); it('starts selecting on background click', () => { const getImageBounds = () => []; setupRectangleSelection(stage, layer, getImageBounds); expect(isRectangleSelecting()).toBe(false); // Note: Actual mouse events would trigger this // This test verifies the function exists }); it('cancels rectangle selection', () => { cancelRectangleSelection(layer); expect(isRectangleSelecting()).toBe(false); expect(getCurrentSelectionRect()).toBeNull(); }); }); describe('Keyboard Shortcuts', () => { beforeEach(() => { selection.clearSelection(); }); it('sets up keyboard shortcuts', () => { const getAllIds = () => ['img1', 'img2', 'img3']; const cleanup = setupKeyboardShortcuts(getAllIds); expect(typeof cleanup).toBe('function'); cleanup(); }); it('selects all images programmatically', () => { const allIds = ['img1', 'img2', 'img3']; selectAllImages(allIds); expect(get(selection).selectedIds.size).toBe(3); }); it('deselects all images programmatically', () => { selection.selectMultiple(['img1', 'img2']); deselectAllImages(); expect(get(selection).selectedIds.size).toBe(0); }); }); describe('Bulk Move Operations', () => { let images: Map; let layer: Konva.Layer; beforeEach(() => { const container = document.createElement('div'); document.body.appendChild(container); const stage = new Konva.Stage({ container, width: 800, height: 600, }); layer = new Konva.Layer(); stage.add(layer); images = new Map(); // Create test images const imageElement = new Image(); imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; ['img1', 'img2', 'img3'].forEach((id, index) => { const img = new Konva.Image({ image: imageElement, x: 100 + index * 150, y: 100, width: 100, height: 100, }); layer.add(img); images.set(id, img); }); layer.draw(); }); afterEach(() => { document.body.innerHTML = ''; }); it('moves multiple images by delta', () => { bulkMove(images, ['img1', 'img2'], 50, 75); expect(images.get('img1')?.x()).toBe(150); expect(images.get('img1')?.y()).toBe(175); expect(images.get('img2')?.x()).toBe(300); expect(images.get('img2')?.y()).toBe(175); expect(images.get('img3')?.x()).toBe(400); // Unchanged }); it('moves multiple images to position', () => { bulkMoveTo(images, ['img1', 'img2'], 200, 200); const img1 = images.get('img1'); const img2 = images.get('img2'); // One of them should be at 200,200 (the top-left one) const minX = Math.min(img1?.x() || 0, img2?.x() || 0); expect(minX).toBe(200); }); it('centers multiple images at point', () => { bulkCenterAt(images, ['img1', 'img2', 'img3'], 400, 300); const bounds = getBulkBounds(images, ['img1', 'img2', 'img3']); expect(bounds).not.toBeNull(); if (bounds) { const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; expect(centerX).toBeCloseTo(400, 0); expect(centerY).toBeCloseTo(300, 0); } }); it('calculates bulk bounds correctly', () => { const bounds = getBulkBounds(images, ['img1', 'img2', 'img3']); expect(bounds).not.toBeNull(); if (bounds) { expect(bounds.x).toBe(100); expect(bounds.width).toBeGreaterThan(300); } }); it('returns null for empty selection', () => { const bounds = getBulkBounds(images, []); expect(bounds).toBeNull(); }); it('calls onMoveComplete callback', () => { const callback = vi.fn(); bulkMove(images, ['img1', 'img2'], 50, 50, { onMoveComplete: callback }); expect(callback).toHaveBeenCalledWith(['img1', 'img2'], 50, 50); }); }); describe('Bulk Rotate Operations', () => { let images: Map; beforeEach(() => { images = new Map(); const imageElement = new Image(); imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; ['img1', 'img2'].forEach((id) => { const img = new Konva.Image({ image: imageElement, width: 100, height: 100, }); images.set(id, img); }); }); it('rotates multiple images to angle', () => { bulkRotateTo(images, ['img1', 'img2'], 45); expect(images.get('img1')?.rotation()).toBe(45); expect(images.get('img2')?.rotation()).toBe(45); }); it('rotates multiple images by delta', () => { images.get('img1')?.rotation(30); images.get('img2')?.rotation(60); bulkRotateBy(images, ['img1', 'img2'], 15); expect(images.get('img1')?.rotation()).toBe(45); expect(images.get('img2')?.rotation()).toBe(75); }); it('rotates 90° clockwise', () => { bulkRotate90CW(images, ['img1', 'img2']); expect(images.get('img1')?.rotation()).toBe(90); expect(images.get('img2')?.rotation()).toBe(90); }); it('calls onRotateComplete callback', () => { const callback = vi.fn(); bulkRotateTo(images, ['img1', 'img2'], 90, { onRotateComplete: callback }); expect(callback).toHaveBeenCalled(); }); }); describe('Bulk Scale Operations', () => { let images: Map; beforeEach(() => { images = new Map(); const imageElement = new Image(); imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; ['img1', 'img2'].forEach((id) => { const img = new Konva.Image({ image: imageElement, width: 100, height: 100, }); images.set(id, img); }); }); it('scales multiple images to factor', () => { bulkScaleTo(images, ['img1', 'img2'], 2.0); expect(Math.abs(images.get('img1')?.scaleX() || 0)).toBe(2.0); expect(Math.abs(images.get('img2')?.scaleX() || 0)).toBe(2.0); }); it('scales multiple images by factor', () => { images.get('img1')?.scale({ x: 1.5, y: 1.5 }); images.get('img2')?.scale({ x: 2.0, y: 2.0 }); bulkScaleBy(images, ['img1', 'img2'], 2.0); expect(Math.abs(images.get('img1')?.scaleX() || 0)).toBe(3.0); expect(Math.abs(images.get('img2')?.scaleX() || 0)).toBe(4.0); }); it('doubles size of multiple images', () => { bulkDoubleSize(images, ['img1', 'img2']); expect(Math.abs(images.get('img1')?.scaleX() || 0)).toBe(2.0); expect(Math.abs(images.get('img2')?.scaleX() || 0)).toBe(2.0); }); it('calls onScaleComplete callback', () => { const callback = vi.fn(); bulkScaleTo(images, ['img1', 'img2'], 1.5, { onScaleComplete: callback }); expect(callback).toHaveBeenCalled(); }); }); describe('Bulk Operations Integration', () => { let images: Map; let layer: Konva.Layer; beforeEach(() => { const container = document.createElement('div'); document.body.appendChild(container); const stage = new Konva.Stage({ container, width: 800, height: 600, }); layer = new Konva.Layer(); stage.add(layer); images = new Map(); const imageElement = new Image(); imageElement.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; ['img1', 'img2', 'img3'].forEach((id, index) => { const img = new Konva.Image({ image: imageElement, x: 100 + index * 150, y: 100, width: 100, height: 100, }); layer.add(img); images.set(id, img); }); layer.draw(); selection.clearSelection(); }); afterEach(() => { document.body.innerHTML = ''; }); it('applies multiple transformations to selection', () => { selection.selectMultiple(['img1', 'img2']); bulkMove(images, ['img1', 'img2'], 50, 50); bulkRotateTo(images, ['img1', 'img2'], 45); bulkScaleTo(images, ['img1', 'img2'], 1.5); const img1 = images.get('img1'); const img2 = images.get('img2'); expect(img1?.x()).toBe(150); expect(img1?.rotation()).toBe(45); expect(Math.abs(img1?.scaleX() || 0)).toBe(1.5); expect(img2?.x()).toBe(300); expect(img2?.rotation()).toBe(45); expect(Math.abs(img2?.scaleX() || 0)).toBe(1.5); }); it('preserves relative positions during bulk operations', () => { const initialDist = (images.get('img2')?.x() || 0) - (images.get('img1')?.x() || 0); bulkMove(images, ['img1', 'img2'], 100, 100); const finalDist = (images.get('img2')?.x() || 0) - (images.get('img1')?.x() || 0); expect(finalDist).toBe(initialDist); }); it('handles empty selection gracefully', () => { bulkMove(images, [], 50, 50); bulkRotateTo(images, [], 90); bulkScaleTo(images, [], 2.0); // Should not throw, images should be unchanged expect(images.get('img1')?.x()).toBe(100); }); }); describe('Keyboard Shortcut Integration', () => { beforeEach(() => { selection.clearSelection(); }); it('Ctrl+A callback receives all IDs', () => { const allIds = ['img1', 'img2', 'img3']; const callback = vi.fn(); const cleanup = setupKeyboardShortcuts(() => allIds, { onSelectAll: callback, }); // Simulate Ctrl+A const event = new KeyboardEvent('keydown', { key: 'a', ctrlKey: true, bubbles: true, }); window.dispatchEvent(event); expect(callback).toHaveBeenCalledWith(allIds); cleanup(); }); it('Escape callback is called on deselect', () => { selection.selectMultiple(['img1', 'img2']); const callback = vi.fn(); const cleanup = setupKeyboardShortcuts(() => [], { onDeselectAll: callback, }); // Simulate Escape const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, }); window.dispatchEvent(event); expect(callback).toHaveBeenCalled(); expect(get(selection).selectedIds.size).toBe(0); cleanup(); }); it('ignores shortcuts when typing in input', () => { const callback = vi.fn(); // Create and focus an input const input = document.createElement('input'); document.body.appendChild(input); input.focus(); const cleanup = setupKeyboardShortcuts(() => ['img1'], { onSelectAll: callback, }); // Try Ctrl+A while focused on input const event = new KeyboardEvent('keydown', { key: 'a', ctrlKey: true, bubbles: true, }); window.dispatchEvent(event); // Callback should not be called expect(callback).not.toHaveBeenCalled(); cleanup(); document.body.removeChild(input); }); });