phase 7
All checks were successful
CI/CD Pipeline / VM Test - backend-integration (push) Successful in 8s
CI/CD Pipeline / VM Test - full-stack (push) Successful in 7s
CI/CD Pipeline / VM Test - performance (push) Successful in 8s
CI/CD Pipeline / VM Test - security (push) Successful in 7s
CI/CD Pipeline / Backend Linting (push) Successful in 3s
CI/CD Pipeline / Frontend Linting (push) Successful in 15s
CI/CD Pipeline / Nix Flake Check (push) Successful in 36s
CI/CD Pipeline / CI Summary (push) Successful in 0s

This commit is contained in:
Danilo Reyes
2025-11-02 14:07:13 -06:00
parent 3700ba02ea
commit cd8ce33f5e
4 changed files with 1197 additions and 17 deletions

View File

@@ -0,0 +1,303 @@
/**
* Tests for canvas image dragging functionality
* Tests drag interactions, position updates, and multi-drag
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Konva from 'konva';
import { get } from 'svelte/store';
import { selection } from '$lib/stores/selection';
import { setupImageDrag, moveImageTo, moveImageBy, isDragging } from '$lib/canvas/interactions/drag';
describe('Image Dragging', () => {
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 and Initialization', () => {
it('sets up drag handlers on image', () => {
const cleanup = setupImageDrag(image, imageId);
expect(image.draggable()).toBe(true);
expect(typeof cleanup).toBe('function');
cleanup();
});
it('cleanup function removes drag handlers', () => {
const cleanup = setupImageDrag(image, imageId);
cleanup();
expect(image.draggable()).toBe(false);
});
it('allows custom drag callbacks', () => {
const onDragMove = vi.fn();
const onDragEnd = vi.fn();
setupImageDrag(image, imageId, onDragMove, onDragEnd);
// Callbacks should be set up
expect(onDragMove).not.toHaveBeenCalled();
expect(onDragEnd).not.toHaveBeenCalled();
});
});
describe('Drag Start', () => {
it('selects image on drag start if not selected', () => {
setupImageDrag(image, imageId);
// Simulate drag start
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
});
it('adds to selection with Ctrl key', () => {
const otherId = 'other-image';
selection.selectOne(otherId);
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: true, metaKey: false },
});
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
expect(selectionState.selectedIds.has(otherId)).toBe(true);
expect(selectionState.selectedIds.size).toBe(2);
});
it('updates drag state', () => {
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
// isDragging should return true during drag
// Note: In actual implementation, this would be checked during dragmove
});
});
describe('Drag Move', () => {
it('calls onDragMove callback with current position', () => {
const onDragMove = vi.fn();
setupImageDrag(image, imageId, onDragMove);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.position({ x: 150, y: 150 });
image.fire('dragmove', {
evt: {},
});
expect(onDragMove).toHaveBeenCalledWith(imageId, 150, 150);
});
it('handles multi-drag when multiple images selected', () => {
selection.selectMultiple([imageId, 'other-image']);
const multiDragHandler = vi.fn();
stage.on('multiDragMove', multiDragHandler);
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.position({ x: 150, y: 150 });
image.fire('dragmove', {
evt: {},
});
// Should fire multiDragMove event for other selected images
expect(multiDragHandler).toHaveBeenCalled();
});
});
describe('Drag End', () => {
it('calls onDragEnd callback with final position', () => {
const onDragEnd = vi.fn();
setupImageDrag(image, imageId, undefined, onDragEnd);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.position({ x: 200, y: 200 });
image.fire('dragend', {
evt: {},
});
expect(onDragEnd).toHaveBeenCalledWith(imageId, 200, 200);
});
it('resets drag state after drag ends', () => {
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.fire('dragend', {
evt: {},
});
expect(isDragging()).toBe(false);
});
});
describe('Programmatic Movement', () => {
it('moveImageTo sets absolute position', () => {
moveImageTo(image, 300, 300);
expect(image.x()).toBe(300);
expect(image.y()).toBe(300);
});
it('moveImageBy moves relative to current position', () => {
image.position({ x: 100, y: 100 });
moveImageBy(image, 50, 50);
expect(image.x()).toBe(150);
expect(image.y()).toBe(150);
});
it('handles negative delta in moveImageBy', () => {
image.position({ x: 100, y: 100 });
moveImageBy(image, -25, -25);
expect(image.x()).toBe(75);
expect(image.y()).toBe(75);
});
it('moveImageTo works with large values', () => {
moveImageTo(image, 10000, 10000);
expect(image.x()).toBe(10000);
expect(image.y()).toBe(10000);
});
});
describe('Edge Cases', () => {
it('handles dragging to negative coordinates', () => {
moveImageTo(image, -100, -100);
expect(image.x()).toBe(-100);
expect(image.y()).toBe(-100);
});
it('handles zero movement', () => {
const initialX = image.x();
const initialY = image.y();
moveImageBy(image, 0, 0);
expect(image.x()).toBe(initialX);
expect(image.y()).toBe(initialY);
});
it('handles rapid position changes', () => {
for (let i = 0; i < 100; i++) {
moveImageBy(image, 1, 1);
}
expect(image.x()).toBeGreaterThan(100);
expect(image.y()).toBeGreaterThan(100);
});
});
describe('Integration with Selection', () => {
it('maintains selection during drag', () => {
selection.selectOne(imageId);
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
image.fire('dragend', {
evt: {},
});
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
});
it('clears other selections when dragging unselected image', () => {
selection.selectMultiple(['other-image-1', 'other-image-2']);
setupImageDrag(image, imageId);
image.fire('dragstart', {
evt: { button: 0, ctrlKey: false, metaKey: false },
});
const selectionState = get(selection);
expect(selectionState.selectedIds.has(imageId)).toBe(true);
expect(selectionState.selectedIds.has('other-image-1')).toBe(false);
expect(selectionState.selectedIds.has('other-image-2')).toBe(false);
expect(selectionState.selectedIds.size).toBe(1);
});
});
});

View File

@@ -0,0 +1,423 @@
/**
* 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);
});
});
});