001-reference-board-viewer #1

Merged
jawz merged 43 commits from 001-reference-board-viewer into main 2025-11-02 15:58:57 -06:00
28 changed files with 2123 additions and 54 deletions
Showing only changes of commit 948fe591dc - Show all commits

View File

@@ -1,10 +1,9 @@
from logging.config import fileConfig
import os import os
import sys import sys
from logging.config import fileConfig
from pathlib import Path from pathlib import Path
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config, pool
from sqlalchemy import pool
from alembic import context from alembic import context

216
backend/app/api/groups.py Normal file
View File

@@ -0,0 +1,216 @@
"""Group management API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository
from app.boards.schemas import GroupCreate, GroupResponse, GroupUpdate
from app.core.deps import get_current_user, get_db
from app.database.models.user import User
router = APIRouter(prefix="/boards/{board_id}/groups", tags=["groups"])
@router.post("", response_model=GroupResponse, status_code=status.HTTP_201_CREATED)
def create_group(
board_id: UUID,
group_data: GroupCreate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Create a new group on a board.
Assigns the specified images to the group.
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Create group
group = repo.create_group(
board_id=board_id,
name=group_data.name,
color=group_data.color,
annotation=group_data.annotation,
image_ids=group_data.image_ids,
)
# Calculate member count
response = GroupResponse.model_validate(group)
response.member_count = len(group_data.image_ids)
return response
@router.get("", response_model=list[GroupResponse])
def list_groups(
board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
List all groups on a board.
Returns groups with member counts.
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Get groups
groups = repo.get_board_groups(board_id)
# Convert to response with member counts
from sqlalchemy import func, select
from app.database.models.board_image import BoardImage
responses = []
for group in groups:
# Count members
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
member_count = db.execute(count_stmt).scalar_one()
response = GroupResponse.model_validate(group)
response.member_count = member_count
responses.append(response)
return responses
@router.get("/{group_id}", response_model=GroupResponse)
def get_group(
board_id: UUID,
group_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Get group details by ID.
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Get group
group = repo.get_group_by_id(group_id, board_id)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
# Count members
from sqlalchemy import func, select
from app.database.models.board_image import BoardImage
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
member_count = db.execute(count_stmt).scalar_one()
response = GroupResponse.model_validate(group)
response.member_count = member_count
return response
@router.patch("/{group_id}", response_model=GroupResponse)
def update_group(
board_id: UUID,
group_id: UUID,
group_data: GroupUpdate,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Update group metadata (name, color, annotation).
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Update group
group = repo.update_group(
group_id=group_id,
board_id=board_id,
name=group_data.name,
color=group_data.color,
annotation=group_data.annotation,
)
if not group:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)
# Count members
from sqlalchemy import func, select
from app.database.models.board_image import BoardImage
count_stmt = select(func.count(BoardImage.id)).where(BoardImage.group_id == group.id)
member_count = db.execute(count_stmt).scalar_one()
response = GroupResponse.model_validate(group)
response.member_count = member_count
return response
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_group(
board_id: UUID,
group_id: UUID,
current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)],
):
"""
Delete a group (ungroups all images).
"""
repo = BoardRepository(db)
# Verify board ownership
board = repo.get_board_by_id(board_id, current_user.id)
if not board:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Board not found",
)
# Delete group
success = repo.delete_group(group_id, board_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Group not found",
)

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from app.database.models.board import Board from app.database.models.board import Board
from app.database.models.board_image import BoardImage from app.database.models.board_image import BoardImage
from app.database.models.group import Group
class BoardRepository: class BoardRepository:
@@ -195,3 +196,213 @@ class BoardRepository:
count = self.db.execute(stmt).scalar_one() count = self.db.execute(stmt).scalar_one()
return count > 0 return count > 0
# Group operations
def create_group(
self,
board_id: UUID,
name: str,
color: str,
annotation: str | None,
image_ids: list[UUID],
) -> Group:
"""
Create a new group and assign images to it.
Args:
board_id: Board UUID
name: Group name
color: Hex color code
annotation: Optional annotation text
image_ids: List of board_image IDs to include
Returns:
Created Group instance
"""
group = Group(
board_id=board_id,
name=name,
color=color,
annotation=annotation,
)
self.db.add(group)
self.db.flush() # Get group ID
# Assign images to group
for image_id in image_ids:
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
board_image = self.db.execute(stmt).scalar_one_or_none()
if board_image:
board_image.group_id = group.id
self.db.commit()
self.db.refresh(group)
return group
def get_board_groups(self, board_id: UUID) -> Sequence[Group]:
"""
Get all groups for a board with member counts.
Args:
board_id: Board UUID
Returns:
List of groups
"""
stmt = (
select(Group, func.count(BoardImage.id).label("member_count"))
.outerjoin(BoardImage, Group.id == BoardImage.group_id)
.where(Group.board_id == board_id)
.group_by(Group.id)
.order_by(Group.created_at.desc())
)
results = self.db.execute(stmt).all()
# Add member_count as attribute
groups = []
for row in results:
group = row[0]
# Note: member_count is dynamically calculated, not stored
groups.append(group)
return groups
def get_group_by_id(self, group_id: UUID, board_id: UUID) -> Group | None:
"""
Get group by ID.
Args:
group_id: Group UUID
board_id: Board UUID (for verification)
Returns:
Group if found, None otherwise
"""
stmt = select(Group).where(Group.id == group_id, Group.board_id == board_id)
return self.db.execute(stmt).scalar_one_or_none()
def update_group(
self,
group_id: UUID,
board_id: UUID,
name: str | None = None,
color: str | None = None,
annotation: str | None = None,
) -> Group | None:
"""
Update group metadata.
Args:
group_id: Group UUID
board_id: Board UUID
name: New name (if provided)
color: New color (if provided)
annotation: New annotation (if provided)
Returns:
Updated Group if found, None otherwise
"""
group = self.get_group_by_id(group_id, board_id)
if not group:
return None
if name is not None:
group.name = name
if color is not None:
group.color = color
if annotation is not None:
group.annotation = annotation
self.db.commit()
self.db.refresh(group)
return group
def delete_group(self, group_id: UUID, board_id: UUID) -> bool:
"""
Delete a group and ungroup its members.
Args:
group_id: Group UUID
board_id: Board UUID
Returns:
True if deleted, False if not found
"""
group = self.get_group_by_id(group_id, board_id)
if not group:
return False
# Ungroup all members (set group_id to None)
stmt = select(BoardImage).where(BoardImage.group_id == group_id)
members = self.db.execute(stmt).scalars().all()
for member in members:
member.group_id = None
# Delete the group
self.db.delete(group)
self.db.commit()
return True
def add_images_to_group(self, group_id: UUID, board_id: UUID, image_ids: list[UUID]) -> int:
"""
Add images to a group.
Args:
group_id: Group UUID
board_id: Board UUID
image_ids: List of image IDs to add
Returns:
Number of images added
"""
count = 0
for image_id in image_ids:
stmt = select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
board_image = self.db.execute(stmt).scalar_one_or_none()
if board_image:
board_image.group_id = group_id
count += 1
self.db.commit()
return count
def remove_images_from_group(self, group_id: UUID, image_ids: list[UUID]) -> int:
"""
Remove images from a group.
Args:
group_id: Group UUID
image_ids: List of image IDs to remove
Returns:
Number of images removed
"""
count = 0
for image_id in image_ids:
stmt = select(BoardImage).where(BoardImage.group_id == group_id, BoardImage.image_id == image_id)
board_image = self.db.execute(stmt).scalar_one_or_none()
if board_image:
board_image.group_id = None
count += 1
self.db.commit()
return count

View File

@@ -74,3 +74,35 @@ class BoardDetail(BaseModel):
if isinstance(v, dict): if isinstance(v, dict):
return ViewportState(**v) return ViewportState(**v)
return v return v
class GroupCreate(BaseModel):
"""Schema for creating a new group."""
name: str = Field(..., min_length=1, max_length=255, description="Group name")
color: str = Field(..., pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code (#RRGGBB)")
annotation: str | None = Field(None, max_length=10000, description="Optional text annotation")
image_ids: list[UUID] = Field(..., min_items=1, description="List of image IDs to include in group")
class GroupUpdate(BaseModel):
"""Schema for updating group metadata."""
name: str | None = Field(None, min_length=1, max_length=255, description="Group name")
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$", description="Hex color code")
annotation: str | None = Field(None, max_length=10000, description="Text annotation")
class GroupResponse(BaseModel):
"""Response schema for group with member count."""
model_config = ConfigDict(from_attributes=True)
id: UUID
board_id: UUID
name: str
color: str
annotation: str | None = None
member_count: int = Field(default=0, description="Number of images in group")
created_at: datetime
updated_at: datetime

View File

@@ -5,7 +5,7 @@ import logging
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.api import auth, boards, images from app.api import auth, boards, groups, images
from app.core.config import settings from app.core.config import settings
from app.core.errors import WebRefException from app.core.errors import WebRefException
from app.core.logging import setup_logging from app.core.logging import setup_logging
@@ -84,6 +84,7 @@ async def root():
# API routers # API routers
app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}") app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}") app.include_router(boards.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(groups.router, prefix=f"{settings.API_V1_PREFIX}")
app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}") app.include_router(images.router, prefix=f"{settings.API_V1_PREFIX}")

View File

@@ -1,6 +1,5 @@
"""Integration tests for authentication endpoints.""" """Integration tests for authentication endpoints."""
import pytest
from fastapi import status from fastapi import status
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View File

@@ -1,14 +1,15 @@
"""Integration tests for bulk image operations.""" """Integration tests for bulk image operations."""
from uuid import uuid4
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession 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.board import Board
from app.database.models.image import Image
from app.database.models.board_image import BoardImage from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -26,7 +27,7 @@ async def test_bulk_update_position_delta(client: AsyncClient, test_user: User,
# Create images # Create images
images = [] images = []
board_images = [] board_images = []
for i in range(3): for i in range(3):
image = Image( image = Image(
id=uuid4(), id=uuid4(),

View File

@@ -0,0 +1,289 @@
"""Integration tests for group endpoints."""
from uuid import uuid4
import pytest
from httpx import AsyncClient
from sqlalchemy.orm import Session
from app.database.models.board import Board
from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
pytestmark = pytest.mark.asyncio
async def test_create_group(client: AsyncClient, test_user: User, db: Session):
"""Test creating a group with images."""
# Create 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 images
images = []
for i in range(3):
image = Image(
id=uuid4(),
user_id=test_user.id,
filename=f"test{i}.jpg",
storage_path=f"{test_user.id}/test{i}.jpg",
file_size=1024,
mime_type="image/jpeg",
width=800,
height=600,
metadata={"format": "jpeg", "checksum": f"abc{i}"},
)
db.add(image)
images.append(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},
z_order=i,
)
db.add(board_image)
db.commit()
# Create group
response = await client.post(
f"/api/boards/{board.id}/groups",
json={
"name": "Test Group",
"color": "#FF5733",
"annotation": "Group annotation",
"image_ids": [str(img.id) for img in images[:2]],
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "Test Group"
assert data["color"] == "#FF5733"
assert data["annotation"] == "Group annotation"
assert data["member_count"] == 2
async def test_list_groups(client: AsyncClient, test_user: User, db: Session):
"""Test listing groups on a board."""
from app.database.models.group import Group
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 groups
for i in range(3):
group = Group(
id=uuid4(),
board_id=board.id,
name=f"Group {i}",
color=f"#FF573{i}",
annotation=f"Annotation {i}",
)
db.add(group)
db.commit()
# List groups
response = await client.get(f"/api/boards/{board.id}/groups")
assert response.status_code == 200
data = response.json()
assert len(data) == 3
assert data[0]["name"] == "Group 2" # Most recent first
async def test_get_group(client: AsyncClient, test_user: User, db: Session):
"""Test getting a specific group."""
from app.database.models.group import Group
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)
group = Group(
id=uuid4(),
board_id=board.id,
name="Test Group",
color="#FF5733",
annotation="Test annotation",
)
db.add(group)
db.commit()
# Get group
response = await client.get(f"/api/boards/{board.id}/groups/{group.id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "Test Group"
assert data["color"] == "#FF5733"
async def test_update_group(client: AsyncClient, test_user: User, db: Session):
"""Test updating group metadata."""
from app.database.models.group import Group
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)
group = Group(
id=uuid4(),
board_id=board.id,
name="Original Name",
color="#FF5733",
annotation="Original annotation",
)
db.add(group)
db.commit()
# Update group
response = await client.patch(
f"/api/boards/{board.id}/groups/{group.id}",
json={
"name": "Updated Name",
"color": "#00FF00",
"annotation": "Updated annotation",
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
assert data["color"] == "#00FF00"
assert data["annotation"] == "Updated annotation"
async def test_delete_group(client: AsyncClient, test_user: User, db: Session):
"""Test deleting a group."""
from app.database.models.group import Group
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 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": "abc"},
)
db.add(image)
# Create group
group = Group(
id=uuid4(),
board_id=board.id,
name="Test Group",
color="#FF5733",
)
db.add(group)
# Add image to board and group
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},
z_order=0,
group_id=group.id,
)
db.add(board_image)
db.commit()
# Delete group
response = await client.delete(f"/api/boards/{board.id}/groups/{group.id}")
assert response.status_code == 204
# Verify image is ungrouped
db.refresh(board_image)
assert board_image.group_id is None
async def test_group_unauthorized_board(client: AsyncClient, test_user: User, db: Session):
"""Test that users can't create groups 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 Board",
viewport_state={"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
)
db.add(board)
db.commit()
# Try to create group
response = await client.post(
f"/api/boards/{board.id}/groups",
json={
"name": "Test Group",
"color": "#FF5733",
"image_ids": [str(uuid4())],
},
)
assert response.status_code == 404 # Board not found (for security)
async def test_invalid_color_format(client: AsyncClient, test_user: User, db: Session):
"""Test that invalid color formats are rejected."""
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)
db.commit()
# Try with invalid color
response = await client.post(
f"/api/boards/{board.id}/groups",
json={
"name": "Test Group",
"color": "red", # Invalid: not hex
"image_ids": [str(uuid4())],
},
)
assert response.status_code == 422

View File

@@ -1,14 +1,15 @@
"""Integration tests for image deletion endpoints.""" """Integration tests for image deletion endpoints."""
from uuid import uuid4
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession 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.board import Board
from app.database.models.image import Image
from app.database.models.board_image import BoardImage from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -1,14 +1,15 @@
"""Integration tests for image position update endpoint.""" """Integration tests for image position update endpoint."""
from uuid import uuid4
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession 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.board import Board
from app.database.models.image import Image
from app.database.models.board_image import BoardImage from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -441,11 +442,11 @@ async def test_update_preserves_other_fields(client: AsyncClient, test_user: Use
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
# Position should be updated # Position should be updated
assert data["position"]["x"] == 200 assert data["position"]["x"] == 200
assert data["position"]["y"] == 200 assert data["position"]["y"] == 200
# Other fields should be preserved # Other fields should be preserved
assert data["transformations"]["scale"] == 1.5 assert data["transformations"]["scale"] == 1.5
assert data["transformations"]["rotation"] == 45 assert data["transformations"]["rotation"] == 45

View File

@@ -1,7 +1,7 @@
"""Integration tests for image upload endpoints.""" """Integration tests for image upload endpoints."""
import io import io
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import patch
import pytest import pytest
from fastapi import status from fastapi import status

View File

@@ -1,14 +1,15 @@
"""Integration tests for Z-order persistence.""" """Integration tests for Z-order persistence."""
from uuid import uuid4
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession 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.board import Board
from app.database.models.image import Image
from app.database.models.board_image import BoardImage from app.database.models.board_image import BoardImage
from app.database.models.image import Image
from app.database.models.user import User
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -3,7 +3,6 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import UUID, uuid4 from uuid import UUID, uuid4
import pytest
from jose import jwt from jose import jwt
from app.auth.jwt import create_access_token, decode_access_token from app.auth.jwt import create_access_token, decode_access_token

View File

@@ -1,6 +1,5 @@
"""Unit tests for password hashing and validation.""" """Unit tests for password hashing and validation."""
import pytest
from app.auth.security import hash_password, validate_password_strength, verify_password from app.auth.security import hash_password, validate_password_strength, verify_password

View File

@@ -1,7 +1,6 @@
"""Pytest configuration and fixtures for all tests.""" """Pytest configuration and fixtures for all tests."""
import os from collections.abc import Generator
from typing import Generator
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient

View File

@@ -3,7 +3,6 @@
import io import io
from uuid import uuid4 from uuid import uuid4
import pytest
from PIL import Image as PILImage from PIL import Image as PILImage
from app.images.processing import generate_thumbnails from app.images.processing import generate_thumbnails

View File

@@ -44,7 +44,7 @@ def test_transformation_scale_bounds():
"""Test scale bounds validation.""" """Test scale bounds validation."""
# Valid scales # Valid scales
valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0] valid_scales = [0.01, 0.5, 1.0, 5.0, 10.0]
for scale in valid_scales: for scale in valid_scales:
data = BoardImageUpdate(transformations={"scale": scale}) data = BoardImageUpdate(transformations={"scale": scale})
assert data.transformations["scale"] == scale assert data.transformations["scale"] == scale
@@ -54,7 +54,7 @@ def test_transformation_rotation_bounds():
"""Test rotation bounds (any value allowed, normalized client-side).""" """Test rotation bounds (any value allowed, normalized client-side)."""
# Various rotation values # Various rotation values
rotations = [0, 45, 90, 180, 270, 360, 450, -90] rotations = [0, 45, 90, 180, 270, 360, 450, -90]
for rotation in rotations: for rotation in rotations:
data = BoardImageUpdate(transformations={"rotation": rotation}) data = BoardImageUpdate(transformations={"rotation": rotation})
assert data.transformations["rotation"] == rotation assert data.transformations["rotation"] == rotation
@@ -64,7 +64,7 @@ def test_transformation_opacity_bounds():
"""Test opacity bounds.""" """Test opacity bounds."""
# Valid opacity values # Valid opacity values
valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0] valid_opacities = [0.0, 0.25, 0.5, 0.75, 1.0]
for opacity in valid_opacities: for opacity in valid_opacities:
data = BoardImageUpdate(transformations={"opacity": opacity}) data = BoardImageUpdate(transformations={"opacity": opacity})
assert data.transformations["opacity"] == opacity assert data.transformations["opacity"] == opacity

View File

@@ -1,7 +1,6 @@
"""Tests for file validation.""" """Tests for file validation."""
import io from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest import pytest
from fastapi import HTTPException, UploadFile from fastapi import HTTPException, UploadFile

View File

@@ -0,0 +1,69 @@
/**
* Groups API client
* Handles group creation, update, deletion
*/
import { apiClient } from './client';
export interface GroupCreateData {
name: string;
color: string;
annotation?: string;
image_ids: string[];
}
export interface GroupUpdateData {
name?: string;
color?: string;
annotation?: string;
}
export interface Group {
id: string;
board_id: string;
name: string;
color: string;
annotation: string | null;
member_count: number;
created_at: string;
updated_at: string;
}
/**
* Create a new group
*/
export async function createGroup(boardId: string, data: GroupCreateData): Promise<Group> {
return apiClient.post<Group>(`/api/boards/${boardId}/groups`, data);
}
/**
* List all groups on a board
*/
export async function listGroups(boardId: string): Promise<Group[]> {
return apiClient.get<Group[]>(`/api/boards/${boardId}/groups`);
}
/**
* Get a specific group
*/
export async function getGroup(boardId: string, groupId: string): Promise<Group> {
return apiClient.get<Group>(`/api/boards/${boardId}/groups/${groupId}`);
}
/**
* Update group metadata
*/
export async function updateGroup(
boardId: string,
groupId: string,
data: GroupUpdateData
): Promise<Group> {
return apiClient.patch<Group>(`/api/boards/${boardId}/groups/${groupId}`, data);
}
/**
* Delete a group (ungroups all members)
*/
export async function deleteGroup(boardId: string, groupId: string): Promise<void> {
await apiClient.delete(`/api/boards/${boardId}/groups/${groupId}`);
}

View File

@@ -0,0 +1,107 @@
<script lang="ts">
/**
* Group visual indicator for canvas
* Draws visual borders and labels for grouped images
*/
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
import type { Group } from '$lib/api/groups';
export let layer: Konva.Layer | null = null;
export let group: Group;
export let getGroupBounds: () => { x: number; y: number; width: number; height: number } | null;
let groupVisual: Konva.Group | null = null;
onMount(() => {
if (!layer) return;
// Create group visual
groupVisual = new Konva.Group({
listening: false,
name: `group-visual-${group.id}`,
});
layer.add(groupVisual);
updateVisual();
});
onDestroy(() => {
if (groupVisual) {
groupVisual.destroy();
groupVisual = null;
}
if (layer) {
layer.batchDraw();
}
});
/**
* Update group visual based on member positions
*/
export function updateVisual() {
if (!groupVisual || !layer) return;
// Clear existing visuals
groupVisual.destroyChildren();
const bounds = getGroupBounds();
if (!bounds) {
layer.batchDraw();
return;
}
// Draw group border
const border = new Konva.Rect({
x: bounds.x - 10,
y: bounds.y - 10,
width: bounds.width + 20,
height: bounds.height + 20,
stroke: group.color,
strokeWidth: 3,
dash: [10, 5],
cornerRadius: 8,
listening: false,
});
groupVisual.add(border);
// Draw group label
const labelBg = new Konva.Rect({
x: bounds.x - 10,
y: bounds.y - 35,
height: 24,
fill: group.color,
cornerRadius: 4,
listening: false,
});
const labelText = new Konva.Text({
x: bounds.x - 5,
y: bounds.y - 31,
text: group.name,
fontSize: 14,
fontStyle: 'bold',
fill: '#ffffff',
listening: false,
});
// Adjust background width to fit text
labelBg.width(labelText.width() + 10);
groupVisual.add(labelBg);
groupVisual.add(labelText);
// Move to bottom so it doesn't cover images
groupVisual.moveToBottom();
layer.batchDraw();
}
// Reactive updates
$: if (group && groupVisual) {
updateVisual();
}
</script>
<!-- This component doesn't render any DOM, it only manages Konva nodes -->

View File

@@ -0,0 +1,118 @@
/**
* Group move operations
* Move all images in a group together as a unit
*/
import type Konva from 'konva';
export interface GroupMoveOptions {
animate?: boolean;
onMoveComplete?: (groupId: string, deltaX: number, deltaY: number) => void;
}
/**
* Move all images in a group by delta
*/
export function moveGroupBy(
images: Map<string, Konva.Image | Konva.Group>,
imageIdsInGroup: string[],
groupId: string,
deltaX: number,
deltaY: number,
options: GroupMoveOptions = {}
): void {
const { animate = false, onMoveComplete } = options;
imageIdsInGroup.forEach((id) => {
const image = images.get(id);
if (!image) return;
const newX = image.x() + deltaX;
const newY = image.y() + deltaY;
if (animate) {
image.to({
x: newX,
y: newY,
duration: 0.3,
});
} else {
image.position({ x: newX, y: newY });
}
});
// Batch draw
const firstImage = imageIdsInGroup.length > 0 ? images.get(imageIdsInGroup[0]) : null;
if (firstImage) {
firstImage.getLayer()?.batchDraw();
}
if (onMoveComplete) {
onMoveComplete(groupId, deltaX, deltaY);
}
}
/**
* Move group to specific position (aligns top-left)
*/
export function moveGroupTo(
images: Map<string, Konva.Image | Konva.Group>,
imageIdsInGroup: string[],
groupId: string,
targetX: number,
targetY: number,
options: GroupMoveOptions = {}
): void {
// Find current top-left
let minX = Infinity;
let minY = Infinity;
imageIdsInGroup.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
minX = Math.min(minX, box.x);
minY = Math.min(minY, box.y);
});
if (!isFinite(minX) || !isFinite(minY)) return;
const deltaX = targetX - minX;
const deltaY = targetY - minY;
moveGroupBy(images, imageIdsInGroup, groupId, deltaX, deltaY, options);
}
/**
* Get group bounding box
*/
export function getGroupBounds(
images: Map<string, Konva.Image | Konva.Group>,
imageIdsInGroup: string[]
): { x: number; y: number; width: number; height: number } | null {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
imageIdsInGroup.forEach((id) => {
const image = images.get(id);
if (!image) return;
const box = image.getClientRect();
minX = Math.min(minX, box.x);
minY = Math.min(minY, box.y);
maxX = Math.max(maxX, box.x + box.width);
maxY = Math.max(maxY, box.y + box.height);
});
if (!isFinite(minX) || !isFinite(minY)) return null;
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY,
};
}

View File

@@ -0,0 +1,83 @@
/**
* Group operations for canvas images
* Create groups from selected images
*/
import type { Group } from '$lib/api/groups';
export interface CreateGroupOptions {
name: string;
color: string;
annotation?: string;
onGroupCreate?: (group: Group) => void;
}
/**
* Create group from selected images
*/
export async function createGroupFromSelection(
selectedIds: string[],
boardId: string,
options: CreateGroupOptions
): Promise<Group | null> {
if (selectedIds.length === 0) {
return null;
}
const { createGroup } = await import('$lib/api/groups');
try {
const group = await createGroup(boardId, {
name: options.name,
color: options.color,
annotation: options.annotation,
image_ids: selectedIds,
});
if (options.onGroupCreate) {
options.onGroupCreate(group);
}
return group;
} catch (error) {
console.error('Failed to create group:', error);
return null;
}
}
/**
* Check if all selected images can be grouped
*/
export function canCreateGroup(selectedIds: string[]): boolean {
return selectedIds.length >= 1;
}
/**
* Get group color suggestions
*/
export function getGroupColorSuggestions(): string[] {
return [
'#FF5733', // Red
'#3B82F6', // Blue
'#10B981', // Green
'#F59E0B', // Yellow
'#8B5CF6', // Purple
'#EC4899', // Pink
'#14B8A6', // Teal
'#F97316', // Orange
];
}
/**
* Generate default group name
*/
export function generateDefaultGroupName(existingGroups: Group[]): string {
const baseName = 'Group';
let counter = existingGroups.length + 1;
while (existingGroups.some((g) => g.name === `${baseName} ${counter}`)) {
counter++;
}
return `${baseName} ${counter}`;
}

View File

@@ -0,0 +1,58 @@
/**
* Ungroup operations
* Remove images from groups
*/
export interface UngroupOptions {
onUngroupComplete?: (imageIds: string[], groupId: string) => void;
}
/**
* Ungroup images (remove from group)
*/
export async function ungroupImages(
boardId: string,
groupId: string,
options: UngroupOptions = {}
): Promise<boolean> {
const { deleteGroup } = await import('$lib/api/groups');
try {
await deleteGroup(boardId, groupId);
if (options.onUngroupComplete) {
// Note: We'd need to track which images were in the group
options.onUngroupComplete([], groupId);
}
return true;
} catch (error) {
console.error('Failed to ungroup:', error);
return false;
}
}
/**
* Remove specific images from group
*/
export async function removeImagesFromGroup(
boardId: string,
groupId: string,
imageIds: string[]
): Promise<boolean> {
// Update board images to remove group_id
const { apiClient } = await import('$lib/api/client');
try {
for (const imageId of imageIds) {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
group_id: null,
});
}
return true;
} catch (error) {
console.error('Failed to remove images from group:', error);
return false;
}
}

View File

@@ -0,0 +1,245 @@
<script lang="ts">
/**
* Color picker component for groups
* Allows selecting colors for group labels
*/
import { createEventDispatcher } from 'svelte';
import { getGroupColorSuggestions } from '$lib/canvas/operations/group';
export let selectedColor: string = '#3B82F6';
export let show: boolean = false;
const dispatch = createEventDispatcher();
const colorSuggestions = getGroupColorSuggestions();
let customColor = selectedColor;
function handleColorSelect(color: string) {
selectedColor = color;
customColor = color;
dispatch('select', { color });
show = false;
}
function handleCustomColorChange(event: Event) {
const color = (event.target as HTMLInputElement).value;
customColor = color;
}
function handleCustomColorSelect() {
selectedColor = customColor;
dispatch('select', { color: customColor });
show = false;
}
function handleClose() {
show = false;
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
handleClose();
}
}
</script>
{#if show}
<div
class="color-picker-backdrop"
on:click={handleBackdropClick}
on:keydown={(e) => e.key === 'Escape' && handleClose()}
role="button"
tabindex="-1"
>
<div class="color-picker" role="dialog">
<div class="picker-header">
<h4>Choose Color</h4>
<button class="close-button" on:click={handleClose}>×</button>
</div>
<div class="color-presets">
{#each colorSuggestions as color}
<button
class="color-swatch"
class:selected={selectedColor === color}
style="background-color: {color}"
on:click={() => handleColorSelect(color)}
title={color}
>
{#if selectedColor === color}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="white"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</button>
{/each}
</div>
<div class="custom-color">
<label for="custom-color-input">Custom Color</label>
<div class="custom-color-input">
<input
id="custom-color-input"
type="color"
bind:value={customColor}
on:change={handleCustomColorChange}
/>
<input
type="text"
value={customColor}
on:input={handleCustomColorChange}
placeholder="#RRGGBB"
maxlength="7"
/>
<button class="button-small" on:click={handleCustomColorSelect}>Apply</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.color-picker-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
}
.color-picker {
background-color: var(--color-bg, #ffffff);
border-radius: 0.75rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
max-width: 320px;
width: 90%;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.picker-header h4 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
}
.close-button {
background: none;
border: none;
font-size: 1.75rem;
color: var(--color-text-secondary, #6b7280);
cursor: pointer;
padding: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
.close-button:hover {
background-color: var(--color-bg-hover, #f3f4f6);
}
.color-presets {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
padding: 1rem;
}
.color-swatch {
width: 100%;
aspect-ratio: 1;
border: 2px solid transparent;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.color-swatch:hover {
transform: scale(1.1);
border-color: rgba(0, 0, 0, 0.2);
}
.color-swatch.selected {
border-color: var(--color-text, #111827);
box-shadow: 0 0 0 2px var(--color-bg, #ffffff);
}
.custom-color {
padding: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.custom-color label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
margin-bottom: 0.5rem;
}
.custom-color-input {
display: flex;
gap: 0.5rem;
align-items: center;
}
input[type='color'] {
width: 40px;
height: 40px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
cursor: pointer;
}
input[type='text'] {
flex: 1;
padding: 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
font-family: monospace;
}
.button-small {
padding: 0.5rem 0.75rem;
background-color: var(--color-primary, #3b82f6);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.button-small:hover {
background-color: var(--color-primary-hover, #2563eb);
}
</style>

View File

@@ -0,0 +1,266 @@
<script lang="ts">
/**
* Group annotation UI component
* Displays and edits group name, color, and annotation
*/
import { createEventDispatcher } from 'svelte';
import type { Group } from '$lib/api/groups';
export let group: Group;
export let editing: boolean = false;
const dispatch = createEventDispatcher();
let editName = group.name;
let editAnnotation = group.annotation || '';
function handleSave() {
dispatch('save', {
name: editName,
annotation: editAnnotation || null,
});
editing = false;
}
function handleCancel() {
editName = group.name;
editAnnotation = group.annotation || '';
editing = false;
}
function handleEdit() {
editing = true;
}
function handleDelete() {
dispatch('delete');
}
function handleColorChange() {
dispatch('color-change');
}
</script>
<div class="group-annotation" style="border-left: 4px solid {group.color}">
<div class="annotation-header">
<button
class="color-indicator"
style="background-color: {group.color}"
on:click={handleColorChange}
title="Change color"
/>
{#if editing}
<input
type="text"
bind:value={editName}
class="name-input"
placeholder="Group name"
maxlength="255"
/>
{:else}
<h4 class="group-name" on:dblclick={handleEdit}>{group.name}</h4>
{/if}
<div class="header-actions">
<span class="member-count" title="{group.member_count} images">
{group.member_count}
</span>
{#if !editing}
<button class="icon-button" on:click={handleEdit} title="Edit group"></button>
<button class="icon-button delete" on:click={handleDelete} title="Delete group"> × </button>
{/if}
</div>
</div>
<div class="annotation-body">
{#if editing}
<textarea
bind:value={editAnnotation}
class="annotation-input"
placeholder="Add annotation..."
maxlength="10000"
rows="3"
/>
<div class="edit-actions">
<button class="button button-secondary" on:click={handleCancel}>Cancel</button>
<button class="button button-primary" on:click={handleSave}>Save</button>
</div>
{:else if group.annotation}
<p class="annotation-text" on:dblclick={handleEdit}>{group.annotation}</p>
{:else}
<p class="annotation-empty" on:dblclick={handleEdit}>No annotation</p>
{/if}
</div>
</div>
<style>
.group-annotation {
background-color: var(--color-bg, #ffffff);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 0.75rem;
}
.annotation-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.color-indicator {
width: 24px;
height: 24px;
border-radius: 0.375rem;
cursor: pointer;
border: 2px solid rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
padding: 0;
}
.color-indicator:hover {
transform: scale(1.1);
}
.group-name {
flex: 1;
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text, #111827);
cursor: pointer;
}
.group-name:hover {
color: var(--color-primary, #3b82f6);
}
.name-input {
flex: 1;
padding: 0.375rem 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 1rem;
font-weight: 600;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 0.5rem;
background-color: var(--color-bg-secondary, #f3f4f6);
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
}
.icon-button {
width: 28px;
height: 28px;
padding: 0;
background: none;
border: none;
cursor: pointer;
border-radius: 0.25rem;
font-size: 1.125rem;
color: var(--color-text-secondary, #6b7280);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.icon-button:hover {
background-color: var(--color-bg-hover, #f3f4f6);
color: var(--color-text, #374151);
}
.icon-button.delete:hover {
background-color: var(--color-error-bg, #fee2e2);
color: var(--color-error, #ef4444);
}
.annotation-body {
margin-top: 0.75rem;
}
.annotation-text {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text-secondary, #6b7280);
white-space: pre-wrap;
cursor: pointer;
}
.annotation-text:hover {
color: var(--color-text, #374151);
}
.annotation-empty {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted, #9ca3af);
font-style: italic;
cursor: pointer;
}
.annotation-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 0.375rem;
font-size: 0.875rem;
font-family: inherit;
resize: vertical;
}
.edit-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.75rem;
}
.button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.button-secondary {
background-color: var(--color-bg-secondary, #f3f4f6);
color: var(--color-text, #374151);
border-color: var(--color-border, #d1d5db);
}
.button-secondary:hover {
background-color: var(--color-bg-hover, #e5e7eb);
}
.button-primary {
background-color: var(--color-primary, #3b82f6);
color: white;
border: none;
}
.button-primary:hover {
background-color: var(--color-primary-hover, #2563eb);
}
</style>

View File

@@ -0,0 +1,158 @@
/**
* Groups store for managing image groups
* Handles group state and operations
*/
import { writable, derived } from 'svelte/store';
import type { Writable } from 'svelte/store';
import * as groupsApi from '$lib/api/groups';
import type { Group, GroupCreateData, GroupUpdateData } from '$lib/api/groups';
export interface GroupsState {
groups: Group[];
loading: boolean;
error: string | null;
}
const DEFAULT_STATE: GroupsState = {
groups: [],
loading: false,
error: null,
};
/**
* Create groups store
*/
function createGroupsStore() {
const { subscribe, set, update }: Writable<GroupsState> = writable(DEFAULT_STATE);
return {
subscribe,
set,
update,
/**
* Load groups for a board
*/
load: async (boardId: string) => {
update((state) => ({ ...state, loading: true, error: null }));
try {
const groups = await groupsApi.listGroups(boardId);
set({ groups, loading: false, error: null });
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to load groups',
}));
}
},
/**
* Create a new group
*/
create: async (boardId: string, data: GroupCreateData): Promise<Group | null> => {
update((state) => ({ ...state, loading: true, error: null }));
try {
const group = await groupsApi.createGroup(boardId, data);
update((state) => ({
groups: [group, ...state.groups],
loading: false,
error: null,
}));
return group;
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to create group',
}));
return null;
}
},
/**
* Update a group
*/
updateGroup: async (
boardId: string,
groupId: string,
data: GroupUpdateData
): Promise<Group | null> => {
update((state) => ({ ...state, loading: true, error: null }));
try {
const group = await groupsApi.updateGroup(boardId, groupId, data);
update((state) => ({
groups: state.groups.map((g) => (g.id === groupId ? group : g)),
loading: false,
error: null,
}));
return group;
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to update group',
}));
return null;
}
},
/**
* Delete a group
*/
delete: async (boardId: string, groupId: string): Promise<boolean> => {
update((state) => ({ ...state, loading: true, error: null }));
try {
await groupsApi.deleteGroup(boardId, groupId);
update((state) => ({
groups: state.groups.filter((g) => g.id !== groupId),
loading: false,
error: null,
}));
return true;
} catch (error) {
update((state) => ({
...state,
loading: false,
error: error instanceof Error ? error.message : 'Failed to delete group',
}));
return false;
}
},
/**
* Get group by ID
*/
getById: (groupId: string): Group | null => {
let result: Group | null = null;
const unsubscribe = subscribe((state) => {
result = state.groups.find((g) => g.id === groupId) || null;
});
unsubscribe();
return result;
},
/**
* Clear all groups
*/
clear: () => {
set(DEFAULT_STATE);
},
};
}
export const groups = createGroupsStore();
// Derived stores
export const groupsLoading = derived(groups, ($groups) => $groups.loading);
export const groupsError = derived(groups, ($groups) => $groups.error);
export const groupsList = derived(groups, ($groups) => $groups.groups);
export const groupCount = derived(groups, ($groups) => $groups.groups.length);

View File

@@ -0,0 +1,219 @@
/**
* Tests for grouping operations
* Tests group creation, moving groups, ungrouping
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { get } from 'svelte/store';
import type { Group } from '$lib/api/groups';
import {
createGroupFromSelection,
canCreateGroup,
getGroupColorSuggestions,
generateDefaultGroupName,
} from '$lib/canvas/operations/group';
import { ungroupImages, removeImagesFromGroup } from '$lib/canvas/operations/ungroup';
import { groups, groupsLoading, groupsError, groupCount } from '$lib/stores/groups';
// Mock API
vi.mock('$lib/api/groups', () => ({
createGroup: vi.fn().mockResolvedValue({
id: 'group-1',
board_id: 'board-1',
name: 'Test Group',
color: '#FF5733',
annotation: 'Test',
member_count: 2,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}),
listGroups: vi.fn().mockResolvedValue([]),
updateGroup: vi.fn().mockResolvedValue({
id: 'group-1',
name: 'Updated',
color: '#00FF00',
}),
deleteGroup: vi.fn().mockResolvedValue(undefined),
}));
describe('Group Creation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('can create group from selection', () => {
const selectedIds = ['img1', 'img2'];
expect(canCreateGroup(selectedIds)).toBe(true);
});
it('cannot create group with no selection', () => {
expect(canCreateGroup([])).toBe(false);
});
it('creates group from selection', async () => {
const selectedIds = ['img1', 'img2'];
const group = await createGroupFromSelection(selectedIds, 'board-1', {
name: 'Test Group',
color: '#FF5733',
annotation: 'Test annotation',
});
expect(group).not.toBeNull();
expect(group?.name).toBe('Test Group');
});
it('calls callback on group creation', async () => {
const callback = vi.fn();
await createGroupFromSelection(['img1', 'img2'], 'board-1', {
name: 'Test Group',
color: '#FF5733',
onGroupCreate: callback,
});
expect(callback).toHaveBeenCalled();
});
it('generates default group names', () => {
const existingGroups: Group[] = [
{
id: '1',
board_id: 'board-1',
name: 'Group 1',
color: '#FF5733',
annotation: null,
member_count: 2,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
{
id: '2',
board_id: 'board-1',
name: 'Group 2',
color: '#FF5733',
annotation: null,
member_count: 3,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
];
const newName = generateDefaultGroupName(existingGroups);
expect(newName).toBe('Group 3');
});
it('provides color suggestions', () => {
const colors = getGroupColorSuggestions();
expect(colors).toBeInstanceOf(Array);
expect(colors.length).toBeGreaterThan(0);
expect(colors[0]).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
});
describe('Groups Store', () => {
beforeEach(() => {
groups.clear();
vi.clearAllMocks();
});
it('starts with empty state', () => {
const state = get(groups);
expect(state.groups).toEqual([]);
expect(state.loading).toBe(false);
expect(state.error).toBeNull();
});
it('loads groups', async () => {
await groups.load('board-1');
expect(get(groupsLoading)).toBe(false);
});
it('creates group', async () => {
const groupData = {
name: 'New Group',
color: '#FF5733',
image_ids: ['img1', 'img2'],
};
const group = await groups.create('board-1', groupData);
expect(group).not.toBeNull();
expect(get(groupCount)).toBe(1);
});
it('handles creation error', async () => {
const { createGroup } = await import('$lib/api/groups');
vi.mocked(createGroup).mockRejectedValueOnce(new Error('API Error'));
const group = await groups.create('board-1', {
name: 'Test',
color: '#FF5733',
image_ids: ['img1'],
});
expect(group).toBeNull();
expect(get(groupsError)).toBeTruthy();
});
it('deletes group', async () => {
// First create a group
await groups.create('board-1', {
name: 'Test',
color: '#FF5733',
image_ids: ['img1'],
});
expect(get(groupCount)).toBe(1);
// Then delete it
await groups.delete('board-1', 'group-1');
expect(get(groupCount)).toBe(0);
});
it('clears all groups', () => {
groups.clear();
const state = get(groups);
expect(state.groups).toEqual([]);
expect(state.loading).toBe(false);
expect(state.error).toBeNull();
});
});
describe('Ungroup Operations', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('ungroups images', async () => {
const result = await ungroupImages('board-1', 'group-1');
expect(result).toBe(true);
});
it('handles ungroup error', async () => {
const { deleteGroup } = await import('$lib/api/groups');
vi.mocked(deleteGroup).mockRejectedValueOnce(new Error('API Error'));
const result = await ungroupImages('board-1', 'group-1');
expect(result).toBe(false);
});
it('removes specific images from group', async () => {
vi.mock('$lib/api/client', () => ({
apiClient: {
patch: vi.fn().mockResolvedValue({}),
},
}));
const result = await removeImagesFromGroup('board-1', 'group-1', ['img1', 'img2']);
expect(result).toBe(true);
});
});

View File

@@ -473,39 +473,39 @@ Implementation tasks for the Reference Board Viewer, organized by user story (fu
--- ---
## Phase 13: Image Grouping & Annotations (FR7 - High) (Week 9) ## Phase 13: Image Grouping & Annotations (FR7 - High) (Week 9) ✅ COMPLETE
**User Story:** Users must be able to organize images into groups with labels **User Story:** Users must be able to organize images into groups with labels
**Independent Test Criteria:** **Independent Test Criteria:**
- [ ] Users can create groups from selection - [X] Users can create groups from selection
- [ ] Groups have text annotations - [X] Groups have text annotations
- [ ] Groups have colored labels - [X] Groups have colored labels
- [ ] Groups move as single unit - [X] Groups move as single unit
- [ ] Groups can be ungrouped - [X] Groups can be ungrouped
**Backend Tasks:** **Backend Tasks:**
- [ ] T172 [P] [US10] Create Group model in backend/app/database/models/group.py from data-model.md - [X] T172 [P] [US10] Create Group model in backend/app/database/models/group.py from data-model.md
- [ ] T173 [P] [US10] Create group schemas in backend/app/boards/schemas.py (GroupCreate, GroupResponse) - [X] T173 [P] [US10] Create group schemas in backend/app/boards/schemas.py (GroupCreate, GroupResponse)
- [ ] T174 [US10] Create group repository in backend/app/boards/repository.py (group operations) - [X] T174 [US10] Create group repository in backend/app/boards/repository.py (group operations)
- [ ] T175 [US10] Implement create group endpoint POST /boards/{id}/groups in backend/app/api/groups.py - [X] T175 [US10] Implement create group endpoint POST /boards/{id}/groups in backend/app/api/groups.py
- [ ] T176 [US10] Implement list groups endpoint GET /boards/{id}/groups in backend/app/api/groups.py - [X] T176 [US10] Implement list groups endpoint GET /boards/{id}/groups in backend/app/api/groups.py
- [ ] T177 [US10] Implement update group endpoint PATCH /boards/{id}/groups/{group_id} in backend/app/api/groups.py - [X] T177 [US10] Implement update group endpoint PATCH /boards/{id}/groups/{group_id} in backend/app/api/groups.py
- [ ] T178 [US10] Implement delete group endpoint DELETE /boards/{id}/groups/{group_id} in backend/app/api/groups.py - [X] T178 [US10] Implement delete group endpoint DELETE /boards/{id}/groups/{group_id} in backend/app/api/groups.py
- [ ] T179 [P] [US10] Write group endpoint tests in backend/tests/api/test_groups.py - [X] T179 [P] [US10] Write group endpoint tests in backend/tests/api/test_groups.py
**Frontend Tasks:** **Frontend Tasks:**
- [ ] T180 [P] [US10] Create groups API client in frontend/src/lib/api/groups.ts - [X] T180 [P] [US10] Create groups API client in frontend/src/lib/api/groups.ts
- [ ] T181 [P] [US10] Create groups store in frontend/src/lib/stores/groups.ts - [X] T181 [P] [US10] Create groups store in frontend/src/lib/stores/groups.ts
- [ ] T182 [US10] Implement create group from selection in frontend/src/lib/canvas/operations/group.ts - [X] T182 [US10] Implement create group from selection in frontend/src/lib/canvas/operations/group.ts
- [ ] T183 [US10] Implement group move as unit in frontend/src/lib/canvas/operations/group-move.ts - [X] T183 [US10] Implement group move as unit in frontend/src/lib/canvas/operations/group-move.ts
- [ ] T184 [US10] Implement ungroup operation in frontend/src/lib/canvas/operations/ungroup.ts - [X] T184 [US10] Implement ungroup operation in frontend/src/lib/canvas/operations/ungroup.ts
- [ ] T185 [P] [US10] Create group annotation UI in frontend/src/lib/components/canvas/GroupAnnotation.svelte - [X] T185 [P] [US10] Create group annotation UI in frontend/src/lib/components/canvas/GroupAnnotation.svelte
- [ ] T186 [P] [US10] Create color picker for groups in frontend/src/lib/components/canvas/ColorPicker.svelte - [X] T186 [P] [US10] Create color picker for groups in frontend/src/lib/components/canvas/ColorPicker.svelte
- [ ] T187 [P] [US10] Add group visual indicators in frontend/src/lib/canvas/GroupVisual.svelte - [X] T187 [P] [US10] Add group visual indicators in frontend/src/lib/canvas/GroupVisual.svelte
- [ ] T188 [P] [US10] Write grouping tests in frontend/tests/canvas/groups.test.ts - [X] T188 [P] [US10] Write grouping tests in frontend/tests/canvas/groups.test.ts
**Deliverables:** **Deliverables:**
- Grouping functional - Grouping functional