diff --git a/backend/tests/api/test_image_position.py b/backend/tests/api/test_image_position.py new file mode 100644 index 0000000..a4bb4bd --- /dev/null +++ b/backend/tests/api/test_image_position.py @@ -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 + diff --git a/frontend/tests/canvas/drag.test.ts b/frontend/tests/canvas/drag.test.ts new file mode 100644 index 0000000..b9f9d0e --- /dev/null +++ b/frontend/tests/canvas/drag.test.ts @@ -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); + }); + }); +}); + diff --git a/frontend/tests/canvas/select.test.ts b/frontend/tests/canvas/select.test.ts new file mode 100644 index 0000000..127ddcc --- /dev/null +++ b/frontend/tests/canvas/select.test.ts @@ -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); + }); + }); +}); + diff --git a/specs/001-reference-board-viewer/tasks.md b/specs/001-reference-board-viewer/tasks.md index a1fe335..e254836 100644 --- a/specs/001-reference-board-viewer/tasks.md +++ b/specs/001-reference-board-viewer/tasks.md @@ -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