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
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:
443
frontend/tests/canvas/clipboard.test.ts
Normal file
443
frontend/tests/canvas/clipboard.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
478
frontend/tests/canvas/multiselect.test.ts
Normal file
478
frontend/tests/canvas/multiselect.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user