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,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

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);
});
});
});

View File

@@ -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