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
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:
454
backend/tests/api/test_image_position.py
Normal file
454
backend/tests/api/test_image_position.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""Integration tests for image position update endpoint."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from uuid import uuid4
|
||||
|
||||
from app.database.models.user import User
|
||||
from app.database.models.board import Board
|
||||
from app.database.models.image import Image
|
||||
from app.database.models.board_image import BoardImage
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_position(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating image position on board."""
|
||||
# Create a board
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
# Create an image
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
# Add image to board
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update position
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200, "y": 250}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["position"]["x"] == 200
|
||||
assert data["position"]["y"] == 250
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_transformations(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating image transformations."""
|
||||
# Create board, image, and board_image
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update transformations
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={
|
||||
"transformations": {
|
||||
"scale": 1.5,
|
||||
"rotation": 45,
|
||||
"opacity": 0.8,
|
||||
"flipped_h": True,
|
||||
"flipped_v": False,
|
||||
"greyscale": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["transformations"]["scale"] == 1.5
|
||||
assert data["transformations"]["rotation"] == 45
|
||||
assert data["transformations"]["opacity"] == 0.8
|
||||
assert data["transformations"]["flipped_h"] is True
|
||||
assert data["transformations"]["greyscale"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_z_order(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating image Z-order."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update Z-order
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"z_order": 5},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["z_order"] == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_multiple_fields(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating position, transformations, and z-order together."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update everything
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={
|
||||
"position": {"x": 300, "y": 400},
|
||||
"transformations": {"scale": 2.0, "rotation": 90},
|
||||
"z_order": 10,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["position"]["x"] == 300
|
||||
assert data["position"]["y"] == 400
|
||||
assert data["transformations"]["scale"] == 2.0
|
||||
assert data["transformations"]["rotation"] == 90
|
||||
assert data["z_order"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_not_on_board(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating image that's not on the specified board."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
await db.commit()
|
||||
|
||||
# Try to update image that's not on board
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200, "y": 200}},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not on this board" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_invalid_position(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test updating with invalid position data."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Try to update with missing y coordinate
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200}},
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_image_unauthorized(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test that other users cannot update images on boards they don't own."""
|
||||
# Create another user
|
||||
other_user = User(id=uuid4(), email="other@example.com", password_hash="hashed")
|
||||
db.add(other_user)
|
||||
|
||||
# Create board owned by other user
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=other_user.id,
|
||||
title="Other User's Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=other_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{other_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.0,
|
||||
"rotation": 0,
|
||||
"opacity": 1.0,
|
||||
"flipped_h": False,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=0,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Try to update as current user (should fail)
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200, "y": 200}},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_preserves_other_fields(client: AsyncClient, test_user: User, db: AsyncSession):
|
||||
"""Test that updating one field preserves others."""
|
||||
board = Board(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
title="Test Board",
|
||||
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
|
||||
)
|
||||
db.add(board)
|
||||
|
||||
image = Image(
|
||||
id=uuid4(),
|
||||
user_id=test_user.id,
|
||||
filename="test.jpg",
|
||||
storage_path=f"{test_user.id}/test.jpg",
|
||||
file_size=1024,
|
||||
mime_type="image/jpeg",
|
||||
width=800,
|
||||
height=600,
|
||||
metadata={"format": "jpeg", "checksum": "abc123"},
|
||||
)
|
||||
db.add(image)
|
||||
|
||||
board_image = BoardImage(
|
||||
id=uuid4(),
|
||||
board_id=board.id,
|
||||
image_id=image.id,
|
||||
position={"x": 100, "y": 100},
|
||||
transformations={
|
||||
"scale": 1.5,
|
||||
"rotation": 45,
|
||||
"opacity": 0.9,
|
||||
"flipped_h": True,
|
||||
"flipped_v": False,
|
||||
"greyscale": False,
|
||||
},
|
||||
z_order=3,
|
||||
)
|
||||
db.add(board_image)
|
||||
await db.commit()
|
||||
|
||||
# Update only position
|
||||
response = await client.patch(
|
||||
f"/api/images/boards/{board.id}/images/{image.id}",
|
||||
json={"position": {"x": 200, "y": 200}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Position should be updated
|
||||
assert data["position"]["x"] == 200
|
||||
assert data["position"]["y"] == 200
|
||||
|
||||
# Other fields should be preserved
|
||||
assert data["transformations"]["scale"] == 1.5
|
||||
assert data["transformations"]["rotation"] == 45
|
||||
assert data["transformations"]["opacity"] == 0.9
|
||||
assert data["z_order"] == 3
|
||||
|
||||
303
frontend/tests/canvas/drag.test.ts
Normal file
303
frontend/tests/canvas/drag.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
423
frontend/tests/canvas/select.test.ts
Normal file
423
frontend/tests/canvas/select.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,33 +268,33 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Image Positioning & Selection (FR5 - Critical) (Week 5-6)
|
||||
## Phase 7: Image Positioning & Selection (FR5 - Critical) (Week 5-6) ✅ COMPLETE
|
||||
|
||||
**User Story:** Users must be able to freely position and organize images on canvas
|
||||
|
||||
**Independent Test Criteria:**
|
||||
- [ ] Users can drag images to any position
|
||||
- [ ] Images can overlap (Z-order controlled)
|
||||
- [ ] Users can select single/multiple images
|
||||
- [ ] Selection shows visual indicators
|
||||
- [ ] Positions persist in database
|
||||
- [X] Users can drag images to any position
|
||||
- [X] Images can overlap (Z-order controlled)
|
||||
- [X] Users can select single/multiple images
|
||||
- [X] Selection shows visual indicators
|
||||
- [X] Positions persist in database
|
||||
|
||||
**Frontend Tasks:**
|
||||
|
||||
- [ ] T111 [US5] Create Konva Image wrapper in frontend/src/lib/canvas/Image.svelte
|
||||
- [ ] T112 [US5] Implement image dragging in frontend/src/lib/canvas/interactions/drag.ts
|
||||
- [ ] T113 [US5] Implement click selection in frontend/src/lib/canvas/interactions/select.ts
|
||||
- [ ] T114 [US5] Implement selection rectangle (drag-to-select) in frontend/src/lib/canvas/interactions/multiselect.ts
|
||||
- [ ] T115 [US5] Create selection store in frontend/src/lib/stores/selection.ts
|
||||
- [ ] T116 [P] [US5] Create selection visual indicators in frontend/src/lib/canvas/SelectionBox.svelte
|
||||
- [ ] T117 [US5] Implement position sync to backend (debounced) in frontend/src/lib/canvas/sync.ts
|
||||
- [ ] T118 [P] [US5] Write dragging tests in frontend/tests/canvas/drag.test.ts
|
||||
- [ ] T119 [P] [US5] Write selection tests in frontend/tests/canvas/select.test.ts
|
||||
- [X] T111 [US5] Create Konva Image wrapper in frontend/src/lib/canvas/Image.svelte
|
||||
- [X] T112 [US5] Implement image dragging in frontend/src/lib/canvas/interactions/drag.ts
|
||||
- [X] T113 [US5] Implement click selection in frontend/src/lib/canvas/interactions/select.ts
|
||||
- [X] T114 [US5] Implement selection rectangle (drag-to-select) in frontend/src/lib/canvas/interactions/multiselect.ts
|
||||
- [X] T115 [US5] Create selection store in frontend/src/lib/stores/selection.ts
|
||||
- [X] T116 [P] [US5] Create selection visual indicators in frontend/src/lib/canvas/SelectionBox.svelte
|
||||
- [X] T117 [US5] Implement position sync to backend (debounced) in frontend/src/lib/canvas/sync.ts
|
||||
- [X] T118 [P] [US5] Write dragging tests in frontend/tests/canvas/drag.test.ts
|
||||
- [X] T119 [P] [US5] Write selection tests in frontend/tests/canvas/select.test.ts
|
||||
|
||||
**Backend Tasks:**
|
||||
|
||||
- [ ] T120 [US5] Implement image position update endpoint PATCH /boards/{id}/images/{image_id} in backend/app/api/images.py
|
||||
- [ ] T121 [P] [US5] Write integration tests for position updates in backend/tests/api/test_image_position.py
|
||||
- [X] T120 [US5] Implement image position update endpoint PATCH /boards/{id}/images/{image_id} in backend/app/api/images.py
|
||||
- [X] T121 [P] [US5] Write integration tests for position updates in backend/tests/api/test_image_position.py
|
||||
|
||||
**Deliverables:**
|
||||
- Images draggable
|
||||
|
||||
Reference in New Issue
Block a user