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

This commit is contained in:
Danilo Reyes
2025-11-02 14:26:15 -06:00
parent ce0b692aee
commit 3eb3d977f9
18 changed files with 3079 additions and 32 deletions

View File

@@ -0,0 +1,443 @@
/**
* Tests for clipboard operations (copy, cut, paste)
* Tests clipboard store and operations
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { get } from 'svelte/store';
import {
clipboard,
hasClipboardContent,
clipboardCount,
isCutOperation,
} from '$lib/stores/clipboard';
import type { ClipboardImageData } from '$lib/stores/clipboard';
import {
copySelectedImages,
copyImages,
copySingleImage,
hasClipboardContent as hasContent,
getClipboardCount,
} from '$lib/canvas/clipboard/copy';
import { cutSelectedImages, cutImages, cutSingleImage } from '$lib/canvas/clipboard/cut';
import {
pasteFromClipboard,
pasteAtPosition,
canPaste,
getPastePreview,
} from '$lib/canvas/clipboard/paste';
import { selection } from '$lib/stores/selection';
import { viewport } from '$lib/stores/viewport';
describe('Clipboard Store', () => {
beforeEach(() => {
clipboard.clear();
selection.clearSelection();
});
it('starts empty', () => {
const state = get(clipboard);
expect(state.images).toEqual([]);
expect(state.operation).toBeNull();
});
it('stores copied images', () => {
const images: ClipboardImageData[] = [
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
];
clipboard.copy(images);
const state = get(clipboard);
expect(state.images).toHaveLength(1);
expect(state.operation).toBe('copy');
});
it('stores cut images', () => {
const images: ClipboardImageData[] = [
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
];
clipboard.cut(images);
const state = get(clipboard);
expect(state.images).toHaveLength(1);
expect(state.operation).toBe('cut');
});
it('clears clipboard', () => {
const images: ClipboardImageData[] = [
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
];
clipboard.copy(images);
clipboard.clear();
const state = get(clipboard);
expect(state.images).toEqual([]);
expect(state.operation).toBeNull();
});
it('hasClipboardContent reflects state', () => {
expect(get(hasClipboardContent)).toBe(false);
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
expect(get(hasClipboardContent)).toBe(true);
});
it('clipboardCount reflects count', () => {
expect(get(clipboardCount)).toBe(0);
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
{
boardImageId: 'bi2',
imageId: 'img2',
position: { x: 200, y: 200 },
transformations: {},
zOrder: 1,
},
]);
expect(get(clipboardCount)).toBe(2);
});
it('isCutOperation reflects operation type', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
expect(get(isCutOperation)).toBe(false);
clipboard.cut([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
expect(get(isCutOperation)).toBe(true);
});
});
describe('Copy Operations', () => {
beforeEach(() => {
clipboard.clear();
selection.clearSelection();
});
it('copies selected images', () => {
selection.selectMultiple(['img1', 'img2']);
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const count = copySelectedImages(getImageData);
expect(count).toBe(2);
expect(get(clipboardCount)).toBe(2);
expect(get(isCutOperation)).toBe(false);
});
it('copies specific images', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const count = copyImages(['img1', 'img2', 'img3'], getImageData);
expect(count).toBe(3);
expect(get(clipboardCount)).toBe(3);
});
it('copies single image', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const success = copySingleImage(getImageData, 'img1');
expect(success).toBe(true);
expect(get(clipboardCount)).toBe(1);
});
it('returns 0 when copying empty selection', () => {
const getImageData = (): ClipboardImageData | null => null;
const count = copySelectedImages(getImageData);
expect(count).toBe(0);
});
it('hasClipboardContent returns true after copy', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
copyImages(['img1'], getImageData);
expect(hasContent()).toBe(true);
expect(getClipboardCount()).toBe(1);
});
});
describe('Cut Operations', () => {
beforeEach(() => {
clipboard.clear();
selection.clearSelection();
});
it('cuts selected images', () => {
selection.selectMultiple(['img1', 'img2']);
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const count = cutSelectedImages(getImageData);
expect(count).toBe(2);
expect(get(clipboardCount)).toBe(2);
expect(get(isCutOperation)).toBe(true);
});
it('cuts specific images', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const count = cutImages(['img1', 'img2'], getImageData);
expect(count).toBe(2);
expect(get(isCutOperation)).toBe(true);
});
it('cuts single image', () => {
const getImageData = (id: string): ClipboardImageData | null => ({
boardImageId: `bi-${id}`,
imageId: id,
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
});
const success = cutSingleImage(getImageData, 'img1');
expect(success).toBe(true);
expect(get(clipboardCount)).toBe(1);
expect(get(isCutOperation)).toBe(true);
});
});
describe('Paste Operations', () => {
beforeEach(() => {
clipboard.clear();
viewport.reset();
});
it('pastes images at viewport center', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
{
boardImageId: 'bi2',
imageId: 'img2',
position: { x: 200, y: 100 },
transformations: {},
zOrder: 1,
},
]);
const pasted = pasteFromClipboard(800, 600);
expect(pasted).toHaveLength(2);
expect(pasted[0].newPosition).toBeDefined();
expect(pasted[1].newPosition).toBeDefined();
});
it('pastes at specific position', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
const pasted = pasteAtPosition(500, 500);
expect(pasted).toHaveLength(1);
expect(pasted[0].newPosition.x).toBe(500);
expect(pasted[0].newPosition.y).toBe(500);
});
it('preserves relative positions when pasting', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
{
boardImageId: 'bi2',
imageId: 'img2',
position: { x: 200, y: 150 },
transformations: {},
zOrder: 1,
},
]);
const pasted = pasteAtPosition(0, 0);
// Relative distance should be preserved
const deltaX1 = pasted[0].newPosition.x;
const deltaX2 = pasted[1].newPosition.x;
expect(deltaX2 - deltaX1).toBe(100); // Original was 200 - 100 = 100
});
it('clears clipboard after cut paste', () => {
clipboard.cut([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
pasteAtPosition(200, 200);
expect(get(hasClipboardContent)).toBe(false);
});
it('preserves clipboard after copy paste', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
pasteAtPosition(200, 200);
expect(get(hasClipboardContent)).toBe(true);
});
it('returns empty array when pasting empty clipboard', () => {
const pasted = pasteFromClipboard(800, 600);
expect(pasted).toEqual([]);
});
it('canPaste reflects clipboard state', () => {
expect(canPaste()).toBe(false);
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
expect(canPaste()).toBe(true);
});
it('getPastePreview shows where images will be pasted', () => {
clipboard.copy([
{
boardImageId: 'bi1',
imageId: 'img1',
position: { x: 100, y: 100 },
transformations: {},
zOrder: 0,
},
]);
const preview = getPastePreview(800, 600);
expect(preview).toHaveLength(1);
expect(preview[0]).toHaveProperty('x');
expect(preview[0]).toHaveProperty('y');
});
});

View File

@@ -0,0 +1,478 @@
/**
* 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<string, Konva.Image>;
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<string, Konva.Image>;
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<string, Konva.Image>;
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<string, Konva.Image>;
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);
});
});