This commit is contained in:
Danilo Reyes
2025-11-02 14:03:01 -06:00
parent f85ae4d417
commit 3700ba02ea
14 changed files with 3103 additions and 19 deletions

View File

@@ -0,0 +1,203 @@
<script lang="ts">
/**
* Konva Image wrapper component for canvas images
* Wraps a Konva.Image with selection, dragging, and transformation support
*/
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
import { isImageSelected } from '$lib/stores/selection';
import { setupImageDrag } from './interactions/drag';
import { setupImageSelection } from './interactions/select';
// Props
export let id: string; // Board image ID
export let imageUrl: string;
export let x: number = 0;
export let y: number = 0;
export let width: number = 100;
export let height: number = 100;
export let rotation: number = 0;
export let scaleX: number = 1;
export let scaleY: number = 1;
export let opacity: number = 1;
export let layer: Konva.Layer | null = null;
export let zOrder: number = 0;
// Callbacks
export let onDragEnd: ((id: string, x: number, y: number) => void) | undefined = undefined;
export let onSelectionChange: ((id: string, isSelected: boolean) => void) | undefined = undefined;
let imageNode: Konva.Image | null = null;
let imageGroup: Konva.Group | null = null;
let imageObj: HTMLImageElement | null = null;
let cleanupDrag: (() => void) | null = null;
let cleanupSelection: (() => void) | null = null;
let unsubscribeSelection: (() => void) | null = null;
// Subscribe to selection state for this image
$: isSelected = isImageSelected(id);
onMount(() => {
if (!layer) return;
// Load image
imageObj = new Image();
imageObj.crossOrigin = 'Anonymous';
imageObj.onload = () => {
if (!layer || !imageObj) return;
// Create Konva image
imageNode = new Konva.Image({
image: imageObj!,
x: 0,
y: 0,
width,
height,
listening: true,
});
// Create group for image and selection box
imageGroup = new Konva.Group({
x,
y,
rotation,
scaleX,
scaleY,
opacity,
draggable: true,
id: `image-group-${id}`,
});
imageGroup.add(imageNode);
// Set Z-index
imageGroup.zIndex(zOrder);
layer.add(imageGroup);
// Setup interactions
cleanupDrag = setupImageDrag(imageGroup, id, undefined, (imageId, newX, newY) => {
if (onDragEnd) {
onDragEnd(imageId, newX, newY);
}
});
cleanupSelection = setupImageSelection(imageGroup, id, undefined, (imageId, _selected) => {
updateSelectionVisual();
if (onSelectionChange) {
onSelectionChange(imageId, _selected);
}
});
// Subscribe to selection changes for visual updates
unsubscribeSelection = isSelected.subscribe((_selected) => {
updateSelectionVisual();
});
layer.batchDraw();
};
imageObj.src = imageUrl;
});
onDestroy(() => {
// Clean up event listeners
if (cleanupDrag) cleanupDrag();
if (cleanupSelection) cleanupSelection();
if (unsubscribeSelection) unsubscribeSelection();
// Destroy Konva nodes
if (imageNode) imageNode.destroy();
if (imageGroup) imageGroup.destroy();
// Redraw layer
if (layer) layer.batchDraw();
});
/**
* Update selection visual (highlight border)
*/
function updateSelectionVisual() {
if (!imageGroup || !$isSelected) return;
// Remove existing selection box
const existingBox = imageGroup.findOne('.selection-box');
if (existingBox) existingBox.destroy();
if ($isSelected && imageNode) {
// Add selection box
const selectionBox = new Konva.Rect({
x: 0,
y: 0,
width: imageNode.width(),
height: imageNode.height(),
stroke: '#3b82f6',
strokeWidth: 2,
listening: false,
name: 'selection-box',
});
imageGroup.add(selectionBox);
}
if (layer) layer.batchDraw();
}
/**
* Update image position
*/
$: if (imageGroup && (imageGroup.x() !== x || imageGroup.y() !== y)) {
imageGroup.position({ x, y });
if (layer) layer.batchDraw();
}
/**
* Update image transformations
*/
$: if (imageGroup) {
if (imageGroup.rotation() !== rotation) {
imageGroup.rotation(rotation);
if (layer) layer.batchDraw();
}
if (imageGroup.scaleX() !== scaleX || imageGroup.scaleY() !== scaleY) {
imageGroup.scale({ x: scaleX, y: scaleY });
if (layer) layer.batchDraw();
}
if (imageGroup.opacity() !== opacity) {
imageGroup.opacity(opacity);
if (layer) layer.batchDraw();
}
if (imageGroup.zIndex() !== zOrder) {
imageGroup.zIndex(zOrder);
if (layer) layer.batchDraw();
}
}
/**
* Update image dimensions
*/
$: if (imageNode && (imageNode.width() !== width || imageNode.height() !== height)) {
imageNode.size({ width, height });
updateSelectionVisual();
if (layer) layer.batchDraw();
}
/**
* Expose image group for external manipulation
*/
export function getImageGroup(): Konva.Group | null {
return imageGroup;
}
/**
* Expose image node for external manipulation
*/
export function getImageNode(): Konva.Image | null {
return imageNode;
}
</script>
<!-- This component doesn't render any DOM, it only manages Konva nodes -->

View File

@@ -0,0 +1,179 @@
<script lang="ts">
/**
* Selection box visual indicator for canvas
* Displays a border and resize handles around selected images
*/
import { onMount, onDestroy } from 'svelte';
import Konva from 'konva';
import { selection, selectionCount } from '$lib/stores/selection';
export let layer: Konva.Layer | null = null;
export let getImageBounds: (
id: string
) => { x: number; y: number; width: number; height: number } | null;
let selectionGroup: Konva.Group | null = null;
let unsubscribe: (() => void) | null = null;
onMount(() => {
if (!layer) return;
// Create group for selection visuals
selectionGroup = new Konva.Group({
listening: false,
name: 'selection-group',
});
layer.add(selectionGroup);
// Subscribe to selection changes
unsubscribe = selection.subscribe(() => {
updateSelectionVisuals();
});
layer.batchDraw();
});
onDestroy(() => {
if (unsubscribe) unsubscribe();
if (selectionGroup) {
selectionGroup.destroy();
selectionGroup = null;
}
if (layer) layer.batchDraw();
});
/**
* Update selection visual indicators
*/
function updateSelectionVisuals() {
if (!selectionGroup || !layer) return;
// Clear existing visuals
selectionGroup.destroyChildren();
const selectedIds = selection.getSelectedIds();
if (selectedIds.length === 0) {
layer.batchDraw();
return;
}
// Calculate bounding box of all selected images
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
selectedIds.forEach((id) => {
const bounds = getImageBounds(id);
if (bounds) {
minX = Math.min(minX, bounds.x);
minY = Math.min(minY, bounds.y);
maxX = Math.max(maxX, bounds.x + bounds.width);
maxY = Math.max(maxY, bounds.y + bounds.height);
}
});
if (!isFinite(minX) || !isFinite(minY)) {
layer.batchDraw();
return;
}
const width = maxX - minX;
const height = maxY - minY;
// Draw selection border
const border = new Konva.Rect({
x: minX,
y: minY,
width,
height,
stroke: '#3b82f6',
strokeWidth: 2,
dash: [8, 4],
listening: false,
});
selectionGroup.add(border);
// Draw resize handles if single selection
if ($selectionCount === 1) {
const handleSize = 8;
const handlePositions = [
{ x: minX, y: minY, cursor: 'nw-resize' }, // Top-left
{ x: minX + width / 2, y: minY, cursor: 'n-resize' }, // Top-center
{ x: maxX, y: minY, cursor: 'ne-resize' }, // Top-right
{ x: maxX, y: minY + height / 2, cursor: 'e-resize' }, // Right-center
{ x: maxX, y: maxY, cursor: 'se-resize' }, // Bottom-right
{ x: minX + width / 2, y: maxY, cursor: 's-resize' }, // Bottom-center
{ x: minX, y: maxY, cursor: 'sw-resize' }, // Bottom-left
{ x: minX, y: minY + height / 2, cursor: 'w-resize' }, // Left-center
];
handlePositions.forEach((pos) => {
const handle = new Konva.Rect({
x: pos.x - handleSize / 2,
y: pos.y - handleSize / 2,
width: handleSize,
height: handleSize,
fill: '#3b82f6',
stroke: '#ffffff',
strokeWidth: 1,
listening: false,
});
selectionGroup!.add(handle);
});
}
// Draw selection count badge if multiple selection
if ($selectionCount > 1) {
const badgeX = maxX - 30;
const badgeY = minY - 30;
const badge = new Konva.Group({
x: badgeX,
y: badgeY,
listening: false,
});
const badgeBackground = new Konva.Rect({
x: 0,
y: 0,
width: 30,
height: 24,
fill: '#3b82f6',
cornerRadius: 4,
listening: false,
});
const badgeText = new Konva.Text({
x: 0,
y: 0,
width: 30,
height: 24,
text: $selectionCount.toString(),
fontSize: 14,
fill: '#ffffff',
align: 'center',
verticalAlign: 'middle',
listening: false,
});
badge.add(badgeBackground);
badge.add(badgeText);
selectionGroup!.add(badge);
}
layer.batchDraw();
}
/**
* Force update of selection visuals (for external calls)
*/
export function update() {
updateSelectionVisuals();
}
</script>
<!-- This component doesn't render any DOM, it only manages Konva nodes -->

View File

@@ -0,0 +1,184 @@
/**
* Image dragging interactions for canvas
* Handles dragging images to reposition them
*/
import Konva from 'konva';
import { selection } from '$lib/stores/selection';
import { get } from 'svelte/store';
export interface DragState {
isDragging: boolean;
startPos: { x: number; y: number } | null;
draggedImageId: string | null;
}
const dragState: DragState = {
isDragging: false,
startPos: null,
draggedImageId: null,
};
/**
* Setup drag interactions for an image
*/
export function setupImageDrag(
image: Konva.Image | Konva.Group,
imageId: string,
onDragMove?: (imageId: string, x: number, y: number) => void,
onDragEnd?: (imageId: string, x: number, y: number) => void
): () => void {
/**
* Handle drag start
*/
function handleDragStart(e: Konva.KonvaEventObject<DragEvent>) {
dragState.isDragging = true;
dragState.startPos = { x: image.x(), y: image.y() };
dragState.draggedImageId = imageId;
// If dragged image is not selected, select it
const selectionState = get(selection);
if (!selectionState.selectedIds.has(imageId)) {
// Check if Ctrl/Cmd key is pressed
if (e.evt.ctrlKey || e.evt.metaKey) {
selection.addToSelection(imageId);
} else {
selection.selectOne(imageId);
}
}
// Set dragging cursor
const stage = image.getStage();
if (stage) {
stage.container().style.cursor = 'grabbing';
}
}
/**
* Handle drag move
*/
function handleDragMove(_e: Konva.KonvaEventObject<DragEvent>) {
if (!dragState.isDragging) return;
const x = image.x();
const y = image.y();
// Call callback if provided
if (onDragMove) {
onDragMove(imageId, x, y);
}
// If multiple images are selected, move them together
const selectionState = get(selection);
if (selectionState.selectedIds.size > 1 && dragState.startPos) {
const deltaX = x - dragState.startPos.x;
const deltaY = y - dragState.startPos.y;
// Update start position for next delta calculation
dragState.startPos = { x, y };
// Dispatch custom event to move other selected images
const stage = image.getStage();
if (stage) {
stage.fire('multiDragMove', {
draggedImageId: imageId,
deltaX,
deltaY,
selectedIds: Array.from(selectionState.selectedIds),
});
}
}
}
/**
* Handle drag end
*/
function handleDragEnd(_e: Konva.KonvaEventObject<DragEvent>) {
if (!dragState.isDragging) return;
const x = image.x();
const y = image.y();
// Call callback if provided
if (onDragEnd) {
onDragEnd(imageId, x, y);
}
// Reset drag state
dragState.isDragging = false;
dragState.startPos = null;
dragState.draggedImageId = null;
// Reset cursor
const stage = image.getStage();
if (stage) {
stage.container().style.cursor = 'default';
}
}
// Enable dragging
image.draggable(true);
// Attach event listeners
image.on('dragstart', handleDragStart);
image.on('dragmove', handleDragMove);
image.on('dragend', handleDragEnd);
// Return cleanup function
return () => {
image.off('dragstart', handleDragStart);
image.off('dragmove', handleDragMove);
image.off('dragend', handleDragEnd);
image.draggable(false);
};
}
/**
* Move image to specific position (programmatic)
*/
export function moveImageTo(
image: Konva.Image | Konva.Group,
x: number,
y: number,
animate: boolean = false
): void {
if (animate) {
// TODO: Add animation support using Konva.Tween
image.to({
x,
y,
duration: 0.3,
easing: Konva.Easings.EaseOut,
});
} else {
image.position({ x, y });
}
}
/**
* Move image by delta (programmatic)
*/
export function moveImageBy(
image: Konva.Image | Konva.Group,
deltaX: number,
deltaY: number,
animate: boolean = false
): void {
const currentX = image.x();
const currentY = image.y();
moveImageTo(image, currentX + deltaX, currentY + deltaY, animate);
}
/**
* Get current drag state (useful for debugging)
*/
export function getDragState(): DragState {
return { ...dragState };
}
/**
* Check if currently dragging
*/
export function isDragging(): boolean {
return dragState.isDragging;
}

View File

@@ -0,0 +1,234 @@
/**
* Rectangle selection (drag-to-select multiple images)
* Allows selecting multiple images by dragging a selection rectangle
*/
import Konva from 'konva';
import { selection } from '$lib/stores/selection';
export interface SelectionRectangle {
x1: number;
y1: number;
x2: number;
y2: number;
}
export interface MultiSelectState {
isSelecting: boolean;
startPos: { x: number; y: number } | null;
currentRect: SelectionRectangle | null;
}
const multiSelectState: MultiSelectState = {
isSelecting: false,
startPos: null,
currentRect: null,
};
/**
* Setup rectangle selection on stage
*/
export function setupRectangleSelection(
stage: Konva.Stage,
layer: Konva.Layer,
getImageBounds: () => Array<{
id: string;
bounds: { x: number; y: number; width: number; height: number };
}>,
onSelectionChange?: (selectedIds: string[]) => void
): () => void {
let selectionRect: Konva.Rect | null = null;
/**
* Handle mouse/touch down to start selection
*/
function handleMouseDown(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
// Only start rectangle selection if clicking on stage background
if (e.target !== stage) return;
// Only if not pressing Ctrl (that's for pan)
const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
if (isModifierPressed) return;
const pos = stage.getPointerPosition();
if (!pos) return;
// Transform pointer position to account for stage transformations
const transform = stage.getAbsoluteTransform().copy().invert();
const localPos = transform.point(pos);
multiSelectState.isSelecting = true;
multiSelectState.startPos = localPos;
multiSelectState.currentRect = {
x1: localPos.x,
y1: localPos.y,
x2: localPos.x,
y2: localPos.y,
};
// Create visual selection rectangle
selectionRect = new Konva.Rect({
x: localPos.x,
y: localPos.y,
width: 0,
height: 0,
fill: 'rgba(0, 120, 255, 0.1)',
stroke: 'rgba(0, 120, 255, 0.8)',
strokeWidth: 1 / stage.scaleX(), // Adjust for zoom
listening: false,
});
layer.add(selectionRect);
layer.batchDraw();
}
/**
* Handle mouse/touch move to update selection rectangle
*/
function handleMouseMove(_e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
if (!multiSelectState.isSelecting || !multiSelectState.startPos || !selectionRect) return;
const pos = stage.getPointerPosition();
if (!pos) return;
// Transform pointer position
const transform = stage.getAbsoluteTransform().copy().invert();
const localPos = transform.point(pos);
multiSelectState.currentRect = {
x1: multiSelectState.startPos.x,
y1: multiSelectState.startPos.y,
x2: localPos.x,
y2: localPos.y,
};
// Update visual rectangle
const x = Math.min(multiSelectState.startPos.x, localPos.x);
const y = Math.min(multiSelectState.startPos.y, localPos.y);
const width = Math.abs(localPos.x - multiSelectState.startPos.x);
const height = Math.abs(localPos.y - multiSelectState.startPos.y);
selectionRect.x(x);
selectionRect.y(y);
selectionRect.width(width);
selectionRect.height(height);
layer.batchDraw();
}
/**
* Handle mouse/touch up to complete selection
*/
function handleMouseUp(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
if (!multiSelectState.isSelecting || !multiSelectState.currentRect) {
return;
}
// Get all images that intersect with selection rectangle
const selectedIds = getImagesInRectangle(multiSelectState.currentRect, getImageBounds());
// Check if Ctrl/Cmd is pressed for additive selection
const isModifierPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
if (isModifierPressed && selectedIds.length > 0) {
// Add to existing selection
selection.addMultipleToSelection(selectedIds);
} else if (selectedIds.length > 0) {
// Replace selection
selection.selectMultiple(selectedIds);
} else {
// Empty selection - clear
selection.clearSelection();
}
// Call callback
if (onSelectionChange) {
onSelectionChange(selectedIds);
}
// Clean up
if (selectionRect) {
selectionRect.destroy();
selectionRect = null;
layer.batchDraw();
}
multiSelectState.isSelecting = false;
multiSelectState.startPos = null;
multiSelectState.currentRect = null;
}
// Attach event listeners
stage.on('mousedown touchstart', handleMouseDown);
stage.on('mousemove touchmove', handleMouseMove);
stage.on('mouseup touchend', handleMouseUp);
// Return cleanup function
return () => {
stage.off('mousedown touchstart', handleMouseDown);
stage.off('mousemove touchmove', handleMouseMove);
stage.off('mouseup touchend', handleMouseUp);
if (selectionRect) {
selectionRect.destroy();
selectionRect = null;
}
multiSelectState.isSelecting = false;
multiSelectState.startPos = null;
multiSelectState.currentRect = null;
};
}
/**
* Get images that intersect with selection rectangle
*/
function getImagesInRectangle(
rect: SelectionRectangle,
imageBounds: Array<{
id: string;
bounds: { x: number; y: number; width: number; height: number };
}>
): string[] {
const x1 = Math.min(rect.x1, rect.x2);
const y1 = Math.min(rect.y1, rect.y2);
const x2 = Math.max(rect.x1, rect.x2);
const y2 = Math.max(rect.y1, rect.y2);
return imageBounds
.filter((item) => {
const { x, y, width, height } = item.bounds;
// Check if rectangles intersect
return !(x + width < x1 || x > x2 || y + height < y1 || y > y2);
})
.map((item) => item.id);
}
/**
* Check if currently in rectangle selection mode
*/
export function isRectangleSelecting(): boolean {
return multiSelectState.isSelecting;
}
/**
* Get current selection rectangle
*/
export function getCurrentSelectionRect(): SelectionRectangle | null {
return multiSelectState.currentRect ? { ...multiSelectState.currentRect } : null;
}
/**
* Cancel ongoing rectangle selection
*/
export function cancelRectangleSelection(layer: Konva.Layer): void {
multiSelectState.isSelecting = false;
multiSelectState.startPos = null;
multiSelectState.currentRect = null;
// Remove any active selection rectangle
const rects = layer.find('.selection-rect');
rects.forEach((rect) => rect.destroy());
layer.batchDraw();
}

View File

@@ -0,0 +1,157 @@
/**
* Click selection interactions for canvas
* Handles single and multi-select (Ctrl+Click)
*/
import type Konva from 'konva';
import { selection } from '$lib/stores/selection';
import { get } from 'svelte/store';
export interface SelectOptions {
multiSelectKey?: boolean; // Enable Ctrl/Cmd+Click for multi-select
deselectOnBackground?: boolean; // Deselect when clicking empty canvas
}
const DEFAULT_OPTIONS: SelectOptions = {
multiSelectKey: true,
deselectOnBackground: true,
};
/**
* Setup click selection for an image
*/
export function setupImageSelection(
image: Konva.Image | Konva.Group,
imageId: string,
options: SelectOptions = DEFAULT_OPTIONS,
onSelectionChange?: (imageId: string, isSelected: boolean) => void
): () => void {
/**
* Handle click/tap on image
*/
function handleClick(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
e.cancelBubble = true; // Prevent event from reaching stage
const isMultiSelectPressed = 'ctrlKey' in e.evt ? e.evt.ctrlKey || e.evt.metaKey : false;
const selectionState = get(selection);
const isCurrentlySelected = selectionState.selectedIds.has(imageId);
if (options.multiSelectKey && isMultiSelectPressed) {
// Multi-select mode (Ctrl+Click)
if (isCurrentlySelected) {
selection.removeFromSelection(imageId);
if (onSelectionChange) {
onSelectionChange(imageId, false);
}
} else {
selection.addToSelection(imageId);
if (onSelectionChange) {
onSelectionChange(imageId, true);
}
}
} else {
// Single select mode
if (!isCurrentlySelected) {
selection.selectOne(imageId);
if (onSelectionChange) {
onSelectionChange(imageId, true);
}
}
}
}
// Attach click/tap listener
image.on('click tap', handleClick);
// Return cleanup function
return () => {
image.off('click tap', handleClick);
};
}
/**
* Setup background deselection (clicking empty canvas clears selection)
*/
export function setupBackgroundDeselect(stage: Konva.Stage, onDeselect?: () => void): () => void {
/**
* Handle click on stage background
*/
function handleStageClick(e: Konva.KonvaEventObject<MouseEvent | TouchEvent>) {
// Only deselect if clicking on the stage itself (not on any shape)
if (e.target === stage) {
selection.clearSelection();
if (onDeselect) {
onDeselect();
}
}
}
// Attach listener
stage.on('click tap', handleStageClick);
// Return cleanup function
return () => {
stage.off('click tap', handleStageClick);
};
}
/**
* Select image programmatically
*/
export function selectImage(imageId: string, multiSelect: boolean = false): void {
if (multiSelect) {
selection.addToSelection(imageId);
} else {
selection.selectOne(imageId);
}
}
/**
* Deselect image programmatically
*/
export function deselectImage(imageId: string): void {
selection.removeFromSelection(imageId);
}
/**
* Toggle image selection programmatically
*/
export function toggleImageSelection(imageId: string): void {
selection.toggleSelection(imageId);
}
/**
* Select all images programmatically
*/
export function selectAllImages(allImageIds: string[]): void {
selection.selectAll(allImageIds);
}
/**
* Clear all selection programmatically
*/
export function clearAllSelection(): void {
selection.clearSelection();
}
/**
* Get selected images count
*/
export function getSelectedCount(): number {
return selection.getSelectionCount();
}
/**
* Get array of selected image IDs
*/
export function getSelectedImageIds(): string[] {
return selection.getSelectedIds();
}
/**
* Check if an image is selected
*/
export function isImageSelected(imageId: string): boolean {
return selection.isSelected(imageId);
}

View File

@@ -0,0 +1,188 @@
/**
* Position and transformation sync with backend
* Handles debounced persistence of image position changes
*/
import { apiClient } from '$lib/api/client';
// Debounce timeout for position sync (ms)
const SYNC_DEBOUNCE_MS = 500;
interface PendingUpdate {
boardId: string;
imageId: string;
position: { x: number; y: number };
timeout: ReturnType<typeof setTimeout>;
}
// Track pending updates by image ID
const pendingUpdates = new Map<string, PendingUpdate>();
/**
* Schedule position sync for an image (debounced)
*/
export function syncImagePosition(boardId: string, imageId: string, x: number, y: number): void {
// Cancel existing timeout if any
const existing = pendingUpdates.get(imageId);
if (existing) {
clearTimeout(existing.timeout);
}
// Schedule new sync
const timeout = setTimeout(async () => {
await performPositionSync(boardId, imageId, x, y);
pendingUpdates.delete(imageId);
}, SYNC_DEBOUNCE_MS);
pendingUpdates.set(imageId, {
boardId,
imageId,
position: { x, y },
timeout,
});
}
/**
* Perform actual position sync to backend
*/
async function performPositionSync(
boardId: string,
imageId: string,
x: number,
y: number
): Promise<void> {
try {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
position: { x, y },
});
} catch (error) {
console.error('Failed to sync image position:', error);
// Don't throw - this is a background operation
}
}
/**
* Force immediate sync of all pending updates
*/
export async function forceSync(): Promise<void> {
const promises: Promise<void>[] = [];
pendingUpdates.forEach((update) => {
clearTimeout(update.timeout);
promises.push(
performPositionSync(update.boardId, update.imageId, update.position.x, update.position.y)
);
});
pendingUpdates.clear();
await Promise.all(promises);
}
/**
* Force immediate sync for specific image
*/
export async function forceSyncImage(imageId: string): Promise<void> {
const update = pendingUpdates.get(imageId);
if (!update) return;
clearTimeout(update.timeout);
await performPositionSync(update.boardId, update.imageId, update.position.x, update.position.y);
pendingUpdates.delete(imageId);
}
/**
* Cancel pending sync for specific image
*/
export function cancelSync(imageId: string): void {
const update = pendingUpdates.get(imageId);
if (update) {
clearTimeout(update.timeout);
pendingUpdates.delete(imageId);
}
}
/**
* Cancel all pending syncs
*/
export function cancelAllSync(): void {
pendingUpdates.forEach((update) => {
clearTimeout(update.timeout);
});
pendingUpdates.clear();
}
/**
* Get count of pending syncs
*/
export function getPendingSyncCount(): number {
return pendingUpdates.size;
}
/**
* Check if image has pending sync
*/
export function hasPendingSync(imageId: string): boolean {
return pendingUpdates.has(imageId);
}
/**
* Sync image transformations (scale, rotation, etc.)
*/
export async function syncImageTransformations(
boardId: string,
imageId: string,
transformations: {
scale?: number;
rotation?: number;
opacity?: number;
flipped_h?: boolean;
flipped_v?: boolean;
greyscale?: boolean;
}
): Promise<void> {
try {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
transformations,
});
} catch (error) {
console.error('Failed to sync image transformations:', error);
throw error;
}
}
/**
* Sync image Z-order
*/
export async function syncImageZOrder(
boardId: string,
imageId: string,
zOrder: number
): Promise<void> {
try {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
z_order: zOrder,
});
} catch (error) {
console.error('Failed to sync image Z-order:', error);
throw error;
}
}
/**
* Sync image group membership
*/
export async function syncImageGroup(
boardId: string,
imageId: string,
groupId: string | null
): Promise<void> {
try {
await apiClient.patch(`/api/boards/${boardId}/images/${imageId}`, {
group_id: groupId,
});
} catch (error) {
console.error('Failed to sync image group:', error);
throw error;
}
}

View File

@@ -0,0 +1,200 @@
/**
* Selection store for canvas image selection management
* Tracks selected images and provides selection operations
*/
import { writable, derived } from 'svelte/store';
import type { Writable } from 'svelte/store';
export interface SelectedImage {
id: string;
boardImageId: string; // The junction table ID
}
export interface SelectionState {
selectedIds: Set<string>; // Set of board_image IDs
lastSelectedId: string | null; // For shift-click range selection
}
const DEFAULT_SELECTION: SelectionState = {
selectedIds: new Set(),
lastSelectedId: null,
};
/**
* Create selection store with operations
*/
function createSelectionStore() {
const { subscribe, set, update }: Writable<SelectionState> = writable(DEFAULT_SELECTION);
return {
subscribe,
set,
update,
/**
* Select a single image (clears previous selection)
*/
selectOne: (id: string) => {
update(() => ({
selectedIds: new Set([id]),
lastSelectedId: id,
}));
},
/**
* Add image to selection (for Ctrl+Click)
*/
addToSelection: (id: string) => {
update((state) => {
const newSelectedIds = new Set(state.selectedIds);
newSelectedIds.add(id);
return {
selectedIds: newSelectedIds,
lastSelectedId: id,
};
});
},
/**
* Remove image from selection (for Ctrl+Click on selected)
*/
removeFromSelection: (id: string) => {
update((state) => {
const newSelectedIds = new Set(state.selectedIds);
newSelectedIds.delete(id);
return {
selectedIds: newSelectedIds,
lastSelectedId: state.lastSelectedId === id ? null : state.lastSelectedId,
};
});
},
/**
* Toggle selection of an image
*/
toggleSelection: (id: string) => {
update((state) => {
const newSelectedIds = new Set(state.selectedIds);
if (newSelectedIds.has(id)) {
newSelectedIds.delete(id);
return {
selectedIds: newSelectedIds,
lastSelectedId: state.lastSelectedId === id ? null : state.lastSelectedId,
};
} else {
newSelectedIds.add(id);
return {
selectedIds: newSelectedIds,
lastSelectedId: id,
};
}
});
},
/**
* Select multiple images (e.g., from rectangle selection)
*/
selectMultiple: (ids: string[]) => {
update((_state) => ({
selectedIds: new Set(ids),
lastSelectedId: ids.length > 0 ? ids[ids.length - 1] : null,
}));
},
/**
* Add multiple images to selection
*/
addMultipleToSelection: (ids: string[]) => {
update((state) => {
const newSelectedIds = new Set(state.selectedIds);
ids.forEach((id) => newSelectedIds.add(id));
return {
selectedIds: newSelectedIds,
lastSelectedId: ids.length > 0 ? ids[ids.length - 1] : state.lastSelectedId,
};
});
},
/**
* Select all images
*/
selectAll: (allIds: string[]) => {
update(() => ({
selectedIds: new Set(allIds),
lastSelectedId: allIds.length > 0 ? allIds[allIds.length - 1] : null,
}));
},
/**
* Clear all selection
*/
clearSelection: () => {
set(DEFAULT_SELECTION);
},
/**
* Check if an image is selected
*/
isSelected: (id: string): boolean => {
let result = false;
const unsubscribe = subscribe((_state) => {
result = _state.selectedIds.has(id);
});
unsubscribe();
return result;
},
/**
* Get count of selected images
*/
getSelectionCount: (): number => {
let count = 0;
const unsubscribe = subscribe((state) => {
count = state.selectedIds.size;
});
unsubscribe();
return count;
},
/**
* Get array of selected IDs
*/
getSelectedIds: (): string[] => {
let ids: string[] = [];
const unsubscribe = subscribe((state) => {
ids = Array.from(state.selectedIds);
});
unsubscribe();
return ids;
},
};
}
export const selection = createSelectionStore();
// Derived stores for common queries
export const hasSelection = derived(selection, ($selection) => {
return $selection.selectedIds.size > 0;
});
export const selectionCount = derived(selection, ($selection) => {
return $selection.selectedIds.size;
});
export const isSingleSelection = derived(selection, ($selection) => {
return $selection.selectedIds.size === 1;
});
export const isMultiSelection = derived(selection, ($selection) => {
return $selection.selectedIds.size > 1;
});
/**
* Helper to check if an ID is in the selection (reactive)
*/
export function isImageSelected(id: string) {
return derived(selection, ($selection) => {
return $selection.selectedIds.has(id);
});
}

View File

@@ -0,0 +1,627 @@
/**
* Tests for canvas controls (pan, zoom, rotate, reset, fit)
* Tests viewport store and control functions
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { get } from 'svelte/store';
import { viewport, isViewportDefault, isZoomMin, isZoomMax } from '$lib/stores/viewport';
import { panTo, panBy } from '$lib/canvas/controls/pan';
import { zoomTo, zoomBy, zoomIn, zoomOut } from '$lib/canvas/controls/zoom';
import {
rotateTo,
rotateBy,
rotateClockwise,
rotateCounterClockwise,
resetRotation,
rotateTo90,
rotateTo180,
rotateTo270,
} from '$lib/canvas/controls/rotate';
import { resetCamera, resetPan, resetZoom } from '$lib/canvas/controls/reset';
describe('Viewport Store', () => {
beforeEach(() => {
// Reset viewport to default state before each test
viewport.reset();
});
describe('Initialization', () => {
it('starts with default values', () => {
const state = get(viewport);
expect(state).toEqual({
x: 0,
y: 0,
zoom: 1.0,
rotation: 0,
});
});
it('isViewportDefault is true at initialization', () => {
expect(get(isViewportDefault)).toBe(true);
});
it('provides viewport bounds', () => {
const bounds = viewport.getBounds();
expect(bounds).toEqual({
minZoom: 0.1,
maxZoom: 5.0,
minRotation: 0,
maxRotation: 360,
});
});
});
describe('Pan Operations', () => {
it('sets pan position', () => {
viewport.setPan(100, 200);
const state = get(viewport);
expect(state.x).toBe(100);
expect(state.y).toBe(200);
});
it('pans by delta', () => {
viewport.setPan(50, 50);
viewport.panBy(25, 30);
const state = get(viewport);
expect(state.x).toBe(75);
expect(state.y).toBe(80);
});
it('allows negative pan values', () => {
viewport.setPan(-100, -200);
const state = get(viewport);
expect(state.x).toBe(-100);
expect(state.y).toBe(-200);
});
it('handles large pan values', () => {
viewport.setPan(100000, 100000);
const state = get(viewport);
expect(state.x).toBe(100000);
expect(state.y).toBe(100000);
});
});
describe('Zoom Operations', () => {
it('sets zoom level', () => {
viewport.setZoom(2.0);
const state = get(viewport);
expect(state.zoom).toBe(2.0);
});
it('clamps zoom to minimum', () => {
viewport.setZoom(0.05);
const state = get(viewport);
expect(state.zoom).toBe(0.1);
});
it('clamps zoom to maximum', () => {
viewport.setZoom(10.0);
const state = get(viewport);
expect(state.zoom).toBe(5.0);
});
it('zooms by factor', () => {
viewport.setZoom(1.0);
viewport.zoomBy(2.0);
const state = get(viewport);
expect(state.zoom).toBe(2.0);
});
it('zooms to center point', () => {
viewport.setZoom(1.0, 100, 100);
const state = get(viewport);
expect(state.zoom).toBe(1.0);
// Position should remain at center
});
it('isZoomMin reflects minimum zoom', () => {
viewport.setZoom(0.1);
expect(get(isZoomMin)).toBe(true);
viewport.setZoom(1.0);
expect(get(isZoomMin)).toBe(false);
});
it('isZoomMax reflects maximum zoom', () => {
viewport.setZoom(5.0);
expect(get(isZoomMax)).toBe(true);
viewport.setZoom(1.0);
expect(get(isZoomMax)).toBe(false);
});
});
describe('Rotation Operations', () => {
it('sets rotation', () => {
viewport.setRotation(45);
const state = get(viewport);
expect(state.rotation).toBe(45);
});
it('normalizes rotation to 0-360', () => {
viewport.setRotation(450);
expect(get(viewport).rotation).toBe(90);
viewport.setRotation(-90);
expect(get(viewport).rotation).toBe(270);
});
it('rotates by delta', () => {
viewport.setRotation(45);
viewport.rotateBy(15);
expect(get(viewport).rotation).toBe(60);
});
it('handles negative rotation delta', () => {
viewport.setRotation(45);
viewport.rotateBy(-15);
expect(get(viewport).rotation).toBe(30);
});
it('wraps rotation around 360', () => {
viewport.setRotation(350);
viewport.rotateBy(20);
expect(get(viewport).rotation).toBe(10);
});
});
describe('Reset Operations', () => {
it('resets viewport to default', () => {
viewport.setPan(100, 100);
viewport.setZoom(2.0);
viewport.setRotation(45);
viewport.reset();
const state = get(viewport);
expect(state).toEqual({
x: 0,
y: 0,
zoom: 1.0,
rotation: 0,
});
});
it('reset makes isViewportDefault true', () => {
viewport.setPan(100, 100);
expect(get(isViewportDefault)).toBe(false);
viewport.reset();
expect(get(isViewportDefault)).toBe(true);
});
});
describe('Fit to Screen', () => {
it('fits content to screen with default padding', () => {
viewport.fitToScreen(800, 600, 1024, 768);
const state = get(viewport);
expect(state.zoom).toBeGreaterThan(0);
expect(state.rotation).toBe(0); // Rotation reset when fitting
});
it('fits content with custom padding', () => {
viewport.fitToScreen(800, 600, 1024, 768, 100);
const state = get(viewport);
expect(state.zoom).toBeGreaterThan(0);
});
it('handles oversized content', () => {
viewport.fitToScreen(2000, 1500, 1024, 768);
const state = get(viewport);
expect(state.zoom).toBeLessThan(1.0);
});
it('handles undersized content', () => {
viewport.fitToScreen(100, 100, 1024, 768);
const state = get(viewport);
expect(state.zoom).toBeGreaterThan(1.0);
});
it('respects maximum zoom when fitting', () => {
// Very small content that would zoom beyond max
viewport.fitToScreen(10, 10, 1024, 768);
const state = get(viewport);
expect(state.zoom).toBeLessThanOrEqual(5.0);
});
});
describe('Load State', () => {
it('loads partial state', () => {
viewport.loadState({ x: 100, y: 200 });
const state = get(viewport);
expect(state.x).toBe(100);
expect(state.y).toBe(200);
expect(state.zoom).toBe(1.0); // Unchanged
expect(state.rotation).toBe(0); // Unchanged
});
it('loads complete state', () => {
viewport.loadState({
x: 100,
y: 200,
zoom: 2.5,
rotation: 90,
});
const state = get(viewport);
expect(state).toEqual({
x: 100,
y: 200,
zoom: 2.5,
rotation: 90,
});
});
it('clamps loaded zoom to bounds', () => {
viewport.loadState({ zoom: 10.0 });
expect(get(viewport).zoom).toBe(5.0);
viewport.loadState({ zoom: 0.01 });
expect(get(viewport).zoom).toBe(0.1);
});
it('normalizes loaded rotation', () => {
viewport.loadState({ rotation: 450 });
expect(get(viewport).rotation).toBe(90);
viewport.loadState({ rotation: -45 });
expect(get(viewport).rotation).toBe(315);
});
});
describe('State Subscription', () => {
it('notifies subscribers on pan changes', () => {
const subscriber = vi.fn();
const unsubscribe = viewport.subscribe(subscriber);
viewport.setPan(100, 100);
expect(subscriber).toHaveBeenCalled();
unsubscribe();
});
it('notifies subscribers on zoom changes', () => {
const subscriber = vi.fn();
const unsubscribe = viewport.subscribe(subscriber);
viewport.setZoom(2.0);
expect(subscriber).toHaveBeenCalled();
unsubscribe();
});
it('notifies subscribers on rotation changes', () => {
const subscriber = vi.fn();
const unsubscribe = viewport.subscribe(subscriber);
viewport.setRotation(45);
expect(subscriber).toHaveBeenCalled();
unsubscribe();
});
});
});
describe('Pan Controls', () => {
beforeEach(() => {
viewport.reset();
});
describe('Programmatic Pan', () => {
it('panTo sets absolute position', () => {
panTo(100, 200);
const state = get(viewport);
expect(state.x).toBe(100);
expect(state.y).toBe(200);
});
it('panBy moves relative to current position', () => {
panTo(50, 50);
panBy(25, 30);
const state = get(viewport);
expect(state.x).toBe(75);
expect(state.y).toBe(80);
});
it('panBy with negative deltas', () => {
panTo(100, 100);
panBy(-50, -50);
const state = get(viewport);
expect(state.x).toBe(50);
expect(state.y).toBe(50);
});
});
});
describe('Zoom Controls', () => {
beforeEach(() => {
viewport.reset();
});
describe('Programmatic Zoom', () => {
it('zoomTo sets absolute zoom level', () => {
zoomTo(2.5);
expect(get(viewport).zoom).toBe(2.5);
});
it('zoomBy multiplies current zoom', () => {
zoomTo(2.0);
zoomBy(1.5);
expect(get(viewport).zoom).toBe(3.0);
});
it('zoomIn increases zoom', () => {
const initialZoom = get(viewport).zoom;
zoomIn();
expect(get(viewport).zoom).toBeGreaterThan(initialZoom);
});
it('zoomOut decreases zoom', () => {
zoomTo(2.0);
const initialZoom = get(viewport).zoom;
zoomOut();
expect(get(viewport).zoom).toBeLessThan(initialZoom);
});
it('zoomIn respects maximum zoom', () => {
zoomTo(4.9);
zoomIn();
expect(get(viewport).zoom).toBeLessThanOrEqual(5.0);
});
it('zoomOut respects minimum zoom', () => {
zoomTo(0.15);
zoomOut();
expect(get(viewport).zoom).toBeGreaterThanOrEqual(0.1);
});
});
});
describe('Rotate Controls', () => {
beforeEach(() => {
viewport.reset();
});
describe('Programmatic Rotation', () => {
it('rotateTo sets absolute rotation', () => {
rotateTo(90);
expect(get(viewport).rotation).toBe(90);
});
it('rotateBy adds to current rotation', () => {
rotateTo(45);
rotateBy(15);
expect(get(viewport).rotation).toBe(60);
});
it('rotateClockwise rotates by step', () => {
rotateClockwise();
// Default step is 15 degrees
expect(get(viewport).rotation).toBe(15);
});
it('rotateCounterClockwise rotates by negative step', () => {
rotateTo(30);
rotateCounterClockwise();
// Default step is 15 degrees
expect(get(viewport).rotation).toBe(15);
});
it('resetRotation sets to 0', () => {
rotateTo(90);
resetRotation();
expect(get(viewport).rotation).toBe(0);
});
it('rotateTo90 sets to 90 degrees', () => {
rotateTo90();
expect(get(viewport).rotation).toBe(90);
});
it('rotateTo180 sets to 180 degrees', () => {
rotateTo180();
expect(get(viewport).rotation).toBe(180);
});
it('rotateTo270 sets to 270 degrees', () => {
rotateTo270();
expect(get(viewport).rotation).toBe(270);
});
});
});
describe('Reset Controls', () => {
beforeEach(() => {
// Set non-default values
viewport.setPan(100, 200);
viewport.setZoom(2.5);
viewport.setRotation(90);
});
describe('Selective Reset', () => {
it('resetPan only resets position', () => {
resetPan();
const state = get(viewport);
expect(state.x).toBe(0);
expect(state.y).toBe(0);
expect(state.zoom).toBe(2.5); // Unchanged
expect(state.rotation).toBe(90); // Unchanged
});
it('resetZoom only resets zoom', () => {
resetZoom();
const state = get(viewport);
expect(state.x).toBe(100); // Unchanged
expect(state.y).toBe(200); // Unchanged
expect(state.zoom).toBe(1.0);
expect(state.rotation).toBe(90); // Unchanged
});
it('resetRotation (from reset controls) only resets rotation', () => {
resetRotation();
const state = get(viewport);
expect(state.x).toBe(100); // Unchanged
expect(state.y).toBe(200); // Unchanged
expect(state.zoom).toBe(2.5); // Unchanged
expect(state.rotation).toBe(0);
});
it('resetCamera resets everything', () => {
resetCamera();
const state = get(viewport);
expect(state).toEqual({
x: 0,
y: 0,
zoom: 1.0,
rotation: 0,
});
});
});
});
describe('Viewport State Serialization', () => {
beforeEach(() => {
viewport.reset();
});
it('serializes viewport state to JSON', async () => {
const { serializeViewportState } = await import('$lib/stores/viewport');
viewport.setPan(100, 200);
viewport.setZoom(2.0);
viewport.setRotation(45);
const state = get(viewport);
const serialized = serializeViewportState(state);
expect(serialized).toBe(JSON.stringify({ x: 100, y: 200, zoom: 2, rotation: 45 }));
});
it('deserializes viewport state from JSON', async () => {
const { deserializeViewportState } = await import('$lib/stores/viewport');
const json = JSON.stringify({ x: 100, y: 200, zoom: 2.5, rotation: 90 });
const state = deserializeViewportState(json);
expect(state).toEqual({
x: 100,
y: 200,
zoom: 2.5,
rotation: 90,
});
});
it('handles invalid JSON gracefully', async () => {
const { deserializeViewportState } = await import('$lib/stores/viewport');
const state = deserializeViewportState('invalid json');
// Should return default state
expect(state).toEqual({
x: 0,
y: 0,
zoom: 1.0,
rotation: 0,
});
});
it('validates deserialized values', async () => {
const { deserializeViewportState } = await import('$lib/stores/viewport');
const json = JSON.stringify({ x: 100, y: 200, zoom: 10.0, rotation: 450 });
const state = deserializeViewportState(json);
// Zoom should be clamped to max
expect(state.zoom).toBe(5.0);
// Rotation should be normalized to 0-360
expect(state.rotation).toBe(90);
});
it('handles missing fields in JSON', async () => {
const { deserializeViewportState } = await import('$lib/stores/viewport');
const json = JSON.stringify({ x: 100 });
const state = deserializeViewportState(json);
expect(state.x).toBe(100);
expect(state.y).toBe(0); // Default
expect(state.zoom).toBe(1.0); // Default
expect(state.rotation).toBe(0); // Default
});
});
describe('Integration Tests', () => {
beforeEach(() => {
viewport.reset();
});
it('complex viewport manipulation sequence', () => {
// Pan
viewport.setPan(100, 100);
// Zoom
viewport.setZoom(2.0);
// Rotate
viewport.setRotation(45);
// Pan by delta
viewport.panBy(50, 50);
const state = get(viewport);
expect(state.x).toBe(150);
expect(state.y).toBe(150);
expect(state.zoom).toBe(2.0);
expect(state.rotation).toBe(45);
});
it('reset after complex manipulation', () => {
viewport.setPan(100, 100);
viewport.setZoom(3.0);
viewport.setRotation(180);
viewport.reset();
expect(get(isViewportDefault)).toBe(true);
});
it('multiple zoom operations maintain center', () => {
viewport.setZoom(2.0, 500, 500);
viewport.setZoom(1.5, 500, 500);
// Position should adjust to keep point at 500,500 centered
const state = get(viewport);
expect(state.zoom).toBe(1.5);
});
});

View File

@@ -0,0 +1,997 @@
/**
* Component tests for upload components
* Tests FilePicker, DropZone, ProgressBar, and ErrorDisplay Svelte components
*/
import { render, fireEvent, screen, waitFor } from '@testing-library/svelte';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import FilePicker from '$lib/components/upload/FilePicker.svelte';
import DropZone from '$lib/components/upload/DropZone.svelte';
import ProgressBar from '$lib/components/upload/ProgressBar.svelte';
import ErrorDisplay from '$lib/components/upload/ErrorDisplay.svelte';
import type { ImageUploadProgress } from '$lib/types/images';
// Mock the image store functions
vi.mock('$lib/stores/images', () => ({
uploadSingleImage: vi.fn(),
uploadZipFile: vi.fn(),
uploadProgress: {
update: vi.fn(),
},
}));
describe('FilePicker', () => {
let uploadSingleImage: ReturnType<typeof vi.fn>;
let uploadZipFile: ReturnType<typeof vi.fn>;
beforeEach(async () => {
const imageStore = await import('$lib/stores/images');
uploadSingleImage = imageStore.uploadSingleImage;
uploadZipFile = imageStore.uploadZipFile;
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders the file picker button', () => {
render(FilePicker);
const button = screen.getByRole('button', { name: /choose files/i });
expect(button).toBeInTheDocument();
expect(button).not.toBeDisabled();
});
it('renders with custom accept attribute', () => {
render(FilePicker, { props: { accept: 'image/png,.jpg' } });
const button = screen.getByRole('button');
expect(button).toBeInTheDocument();
});
it('renders with multiple attribute by default', () => {
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]');
expect(fileInput).toHaveAttribute('multiple');
});
it('can disable multiple file selection', () => {
const { container } = render(FilePicker, { props: { multiple: false } });
const fileInput = container.querySelector('input[type="file"]');
expect(fileInput).not.toHaveAttribute('multiple');
});
it('hides the file input element', () => {
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]') as HTMLElement;
expect(fileInput).toHaveStyle({ display: 'none' });
});
});
describe('File Selection', () => {
it('opens file picker when button is clicked', async () => {
const { container } = render(FilePicker);
const button = screen.getByRole('button', { name: /choose files/i });
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.fn();
fileInput.click = clickSpy;
await fireEvent.click(button);
expect(clickSpy).toHaveBeenCalledTimes(1);
});
it('handles single image file upload', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container, component } = render(FilePicker);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image content'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledWith(file);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 1 });
});
it('handles multiple image file uploads', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container, component } = render(FilePicker);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const files = [
new File(['image1'], 'test1.jpg', { type: 'image/jpeg' }),
new File(['image2'], 'test2.png', { type: 'image/png' }),
new File(['image3'], 'test3.gif', { type: 'image/gif' }),
];
await fireEvent.change(fileInput, { target: { files } });
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledTimes(3);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 3 });
});
it('handles ZIP file upload', async () => {
uploadZipFile.mockResolvedValue({ success: true });
const { container, component } = render(FilePicker);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['zip content'], 'images.zip', { type: 'application/zip' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(uploadZipFile).toHaveBeenCalledWith(file);
});
expect(uploadSingleImage).not.toHaveBeenCalled();
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
});
it('handles mixed image and ZIP file uploads', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
uploadZipFile.mockResolvedValue({ success: true });
const { container, component } = render(FilePicker);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const files = [
new File(['image'], 'test.jpg', { type: 'image/jpeg' }),
new File(['zip'], 'archive.zip', { type: 'application/zip' }),
new File(['image'], 'test.png', { type: 'image/png' }),
];
await fireEvent.change(fileInput, { target: { files } });
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledTimes(2);
expect(uploadZipFile).toHaveBeenCalledTimes(1);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 3 });
});
it('resets file input after upload', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalled();
});
expect(fileInput.value).toBe('');
});
});
describe('Loading State', () => {
it('shows loading state during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(FilePicker);
const button = screen.getByRole('button');
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
// During upload
expect(button).toBeDisabled();
expect(screen.getByText(/uploading/i)).toBeInTheDocument();
// Wait for upload to complete
await waitFor(() => {
expect(button).not.toBeDisabled();
});
expect(screen.queryByText(/uploading/i)).not.toBeInTheDocument();
});
it('disables button during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(FilePicker);
const button = screen.getByRole('button');
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
expect(button).not.toBeDisabled();
await fireEvent.change(fileInput, { target: { files: [file] } });
expect(button).toBeDisabled();
await waitFor(() => {
expect(button).not.toBeDisabled();
});
});
it('shows spinner during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
const spinner = container.querySelector('.spinner');
expect(spinner).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('.spinner')).not.toBeInTheDocument();
});
});
});
describe('Error Handling', () => {
it('dispatches upload-error event on upload failure', async () => {
uploadSingleImage.mockRejectedValue(new Error('Upload failed'));
const { container, component } = render(FilePicker);
const uploadErrorHandler = vi.fn();
component.$on('upload-error', uploadErrorHandler);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(uploadErrorHandler).toHaveBeenCalledTimes(1);
});
expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({ error: 'Upload failed' });
});
it('re-enables button after error', async () => {
uploadSingleImage.mockRejectedValue(new Error('Upload failed'));
const { container } = render(FilePicker);
const button = screen.getByRole('button');
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
await fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(button).not.toBeDisabled();
});
});
it('handles no files selected gracefully', async () => {
const { container } = render(FilePicker);
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
await fireEvent.change(fileInput, { target: { files: null } });
expect(uploadSingleImage).not.toHaveBeenCalled();
expect(uploadZipFile).not.toHaveBeenCalled();
});
});
});
describe('DropZone', () => {
let uploadSingleImage: ReturnType<typeof vi.fn>;
let uploadZipFile: ReturnType<typeof vi.fn>;
beforeEach(async () => {
const imageStore = await import('$lib/stores/images');
uploadSingleImage = imageStore.uploadSingleImage;
uploadZipFile = imageStore.uploadZipFile;
vi.clearAllMocks();
});
describe('Rendering', () => {
it('renders the drop zone', () => {
render(DropZone);
expect(screen.getByText(/drag and drop images here/i)).toBeInTheDocument();
expect(screen.getByText(/or use the file picker above/i)).toBeInTheDocument();
});
it('shows default state initially', () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone');
expect(dropZone).not.toHaveClass('dragging');
expect(dropZone).not.toHaveClass('uploading');
});
});
describe('Drag and Drop', () => {
it('shows dragging state on drag enter', async () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
await fireEvent.dragEnter(dropZone);
expect(dropZone).toHaveClass('dragging');
expect(screen.getByText(/drop files here/i)).toBeInTheDocument();
});
it('removes dragging state on drag leave', async () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
await fireEvent.dragEnter(dropZone);
expect(dropZone).toHaveClass('dragging');
await fireEvent.dragLeave(dropZone);
expect(dropZone).not.toHaveClass('dragging');
});
it('handles drag over event', async () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true });
const preventDefaultSpy = vi.spyOn(dragOverEvent, 'preventDefault');
dropZone.dispatchEvent(dragOverEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
});
it('handles single image file drop', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container, component } = render(DropZone);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', {
bubbles: true,
cancelable: true,
dataTransfer: new DataTransfer(),
});
Object.defineProperty(dropEvent, 'dataTransfer', {
value: {
files: [file],
},
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledWith(file);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
expect(uploadCompleteHandler.mock.calls[0][0].detail).toEqual({ fileCount: 1 });
});
it('handles multiple image files drop', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container, component } = render(DropZone);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const files = [
new File(['image1'], 'test1.jpg', { type: 'image/jpeg' }),
new File(['image2'], 'test2.png', { type: 'image/png' }),
];
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadSingleImage).toHaveBeenCalledTimes(2);
});
expect(uploadCompleteHandler).toHaveBeenCalledTimes(1);
});
it('handles ZIP file drop', async () => {
uploadZipFile.mockResolvedValue({ success: true });
const { container, component } = render(DropZone);
const uploadCompleteHandler = vi.fn();
component.$on('upload-complete', uploadCompleteHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['zip'], 'images.zip', { type: 'application/zip' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadZipFile).toHaveBeenCalledWith(file);
});
expect(uploadSingleImage).not.toHaveBeenCalled();
});
it('filters out invalid file types', async () => {
const { container, component } = render(DropZone, { props: { accept: 'image/*,.zip' } });
const uploadErrorHandler = vi.fn();
component.$on('upload-error', uploadErrorHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const files = [new File(['text'], 'document.txt', { type: 'text/plain' })];
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadErrorHandler).toHaveBeenCalledTimes(1);
});
expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({
error: 'No valid image files found',
});
expect(uploadSingleImage).not.toHaveBeenCalled();
expect(uploadZipFile).not.toHaveBeenCalled();
});
it('removes dragging state after drop', async () => {
uploadSingleImage.mockResolvedValue({ success: true });
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
await fireEvent.dragEnter(dropZone);
expect(dropZone).toHaveClass('dragging');
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
expect(dropZone).not.toHaveClass('dragging');
});
});
describe('Loading State', () => {
it('shows uploading state during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
expect(dropZone).toHaveClass('uploading');
expect(screen.getByText(/uploading/i)).toBeInTheDocument();
await waitFor(() => {
expect(dropZone).not.toHaveClass('uploading');
});
});
it('shows spinner during upload', async () => {
uploadSingleImage.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ success: true }), 100))
);
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
const spinner = container.querySelector('.spinner-large');
expect(spinner).toBeInTheDocument();
await waitFor(() => {
expect(container.querySelector('.spinner-large')).not.toBeInTheDocument();
});
});
});
describe('Error Handling', () => {
it('dispatches upload-error event on upload failure', async () => {
uploadSingleImage.mockRejectedValue(new Error('Network error'));
const { container, component } = render(DropZone);
const uploadErrorHandler = vi.fn();
component.$on('upload-error', uploadErrorHandler);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(uploadErrorHandler).toHaveBeenCalledTimes(1);
});
expect(uploadErrorHandler.mock.calls[0][0].detail).toEqual({ error: 'Network error' });
});
it('returns to normal state after error', async () => {
uploadSingleImage.mockRejectedValue(new Error('Upload failed'));
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: [file] },
});
await fireEvent(dropZone, dropEvent);
await waitFor(() => {
expect(dropZone).not.toHaveClass('uploading');
});
});
it('handles drop event with no files', async () => {
const { container } = render(DropZone);
const dropZone = container.querySelector('.drop-zone') as HTMLElement;
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(dropEvent, 'dataTransfer', {
value: { files: null },
});
await fireEvent(dropZone, dropEvent);
expect(uploadSingleImage).not.toHaveBeenCalled();
expect(uploadZipFile).not.toHaveBeenCalled();
});
});
});
describe('ProgressBar', () => {
describe('Rendering', () => {
it('renders progress item with filename', () => {
const item: ImageUploadProgress = {
filename: 'test-image.jpg',
status: 'uploading',
progress: 50,
};
render(ProgressBar, { props: { item } });
expect(screen.getByText('test-image.jpg')).toBeInTheDocument();
});
it('shows progress bar for uploading status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'uploading',
progress: 75,
};
const { container } = render(ProgressBar, { props: { item } });
expect(screen.getByText('75%')).toBeInTheDocument();
const progressBar = container.querySelector('.progress-bar-fill') as HTMLElement;
expect(progressBar).toHaveStyle({ width: '75%' });
});
it('shows progress bar for processing status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'processing',
progress: 90,
};
render(ProgressBar, { props: { item } });
expect(screen.getByText('90%')).toBeInTheDocument();
});
it('shows success message for complete status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'complete',
progress: 100,
};
render(ProgressBar, { props: { item } });
expect(screen.getByText(/upload complete/i)).toBeInTheDocument();
expect(screen.queryByText(/\d+%/)).not.toBeInTheDocument();
});
it('shows error message for error status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'error',
progress: 0,
error: 'File too large',
};
render(ProgressBar, { props: { item } });
expect(screen.getByText('File too large')).toBeInTheDocument();
});
it('shows close button for complete status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'complete',
progress: 100,
};
render(ProgressBar, { props: { item } });
const closeButton = screen.getByRole('button', { name: /remove/i });
expect(closeButton).toBeInTheDocument();
});
it('shows close button for error status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'error',
progress: 0,
error: 'Failed',
};
render(ProgressBar, { props: { item } });
const closeButton = screen.getByRole('button', { name: /remove/i });
expect(closeButton).toBeInTheDocument();
});
it('hides close button for uploading status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'uploading',
progress: 50,
};
render(ProgressBar, { props: { item } });
const closeButton = screen.queryByRole('button', { name: /remove/i });
expect(closeButton).not.toBeInTheDocument();
});
});
describe('Status Icons', () => {
it('shows correct icon for uploading status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'uploading',
progress: 50,
};
const { container } = render(ProgressBar, { props: { item } });
const statusIcon = container.querySelector('.status-icon');
expect(statusIcon).toHaveTextContent('⟳');
});
it('shows correct icon for processing status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'processing',
progress: 90,
};
const { container } = render(ProgressBar, { props: { item } });
const statusIcon = container.querySelector('.status-icon');
expect(statusIcon).toHaveTextContent('⟳');
});
it('shows correct icon for complete status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'complete',
progress: 100,
};
const { container } = render(ProgressBar, { props: { item } });
const statusIcon = container.querySelector('.status-icon');
expect(statusIcon).toHaveTextContent('✓');
});
it('shows correct icon for error status', () => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'error',
progress: 0,
error: 'Failed',
};
const { container } = render(ProgressBar, { props: { item } });
const statusIcon = container.querySelector('.status-icon');
expect(statusIcon).toHaveTextContent('✗');
});
});
describe('Remove Functionality', () => {
it('removes item from store when close button is clicked', async () => {
const imageStore = await import('$lib/stores/images');
const updateFn = vi.fn((callback) => callback([]));
imageStore.uploadProgress.update = updateFn;
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'complete',
progress: 100,
};
render(ProgressBar, { props: { item } });
const closeButton = screen.getByRole('button', { name: /remove/i });
await fireEvent.click(closeButton);
expect(updateFn).toHaveBeenCalled();
});
});
describe('Progress Display', () => {
it('shows progress percentage correctly', () => {
const testCases = [0, 25, 50, 75, 100];
testCases.forEach((progress) => {
const item: ImageUploadProgress = {
filename: 'test.jpg',
status: 'uploading',
progress,
};
const { unmount, container } = render(ProgressBar, { props: { item } });
expect(screen.getByText(`${progress}%`)).toBeInTheDocument();
const progressBar = container.querySelector('.progress-bar-fill') as HTMLElement;
expect(progressBar).toHaveStyle({ width: `${progress}%` });
unmount();
});
});
it('truncates long filenames', () => {
const item: ImageUploadProgress = {
filename: 'very-long-filename-that-should-be-truncated-with-ellipsis.jpg',
status: 'uploading',
progress: 50,
};
const { container } = render(ProgressBar, { props: { item } });
const filenameElement = container.querySelector('.filename') as HTMLElement;
expect(filenameElement).toHaveStyle({
overflow: 'hidden',
'text-overflow': 'ellipsis',
'white-space': 'nowrap',
});
});
});
});
describe('ErrorDisplay', () => {
describe('Rendering', () => {
it('renders error message', () => {
render(ErrorDisplay, { props: { error: 'Upload failed' } });
expect(screen.getByText('Upload failed')).toBeInTheDocument();
});
it('renders with error icon', () => {
const { container } = render(ErrorDisplay, { props: { error: 'Test error' } });
const icon = container.querySelector('.error-icon svg');
expect(icon).toBeInTheDocument();
});
it('has proper ARIA role', () => {
render(ErrorDisplay, { props: { error: 'Test error' } });
const errorDisplay = screen.getByRole('alert');
expect(errorDisplay).toBeInTheDocument();
});
it('shows dismiss button by default', () => {
render(ErrorDisplay, { props: { error: 'Test error' } });
const dismissButton = screen.getByRole('button', { name: /dismiss error/i });
expect(dismissButton).toBeInTheDocument();
});
it('hides dismiss button when dismissible is false', () => {
render(ErrorDisplay, { props: { error: 'Test error', dismissible: false } });
const dismissButton = screen.queryByRole('button', { name: /dismiss error/i });
expect(dismissButton).not.toBeInTheDocument();
});
});
describe('Dismiss Functionality', () => {
it('dispatches dismiss event when button is clicked', async () => {
const { component } = render(ErrorDisplay, { props: { error: 'Test error' } });
const dismissHandler = vi.fn();
component.$on('dismiss', dismissHandler);
const dismissButton = screen.getByRole('button', { name: /dismiss error/i });
await fireEvent.click(dismissButton);
expect(dismissHandler).toHaveBeenCalledTimes(1);
});
it('does not dispatch dismiss event when dismissible is false', () => {
const { component } = render(ErrorDisplay, {
props: { error: 'Test error', dismissible: false },
});
const dismissHandler = vi.fn();
component.$on('dismiss', dismissHandler);
// No dismiss button should exist
const dismissButton = screen.queryByRole('button', { name: /dismiss error/i });
expect(dismissButton).not.toBeInTheDocument();
});
});
describe('Error Messages', () => {
it('handles short error messages', () => {
render(ErrorDisplay, { props: { error: 'Error' } });
expect(screen.getByText('Error')).toBeInTheDocument();
});
it('handles long error messages', () => {
const longError =
'This is a very long error message that contains detailed information about what went wrong during the upload process. It should be displayed correctly with proper line wrapping.';
render(ErrorDisplay, { props: { error: longError } });
expect(screen.getByText(longError)).toBeInTheDocument();
});
it('handles error messages with special characters', () => {
const errorWithSpecialChars = "File 'test.jpg' couldn't be uploaded: size > 50MB";
render(ErrorDisplay, { props: { error: errorWithSpecialChars } });
expect(screen.getByText(errorWithSpecialChars)).toBeInTheDocument();
});
it('handles empty error messages', () => {
render(ErrorDisplay, { props: { error: '' } });
const errorMessage = screen.getByRole('alert');
expect(errorMessage).toBeInTheDocument();
});
});
describe('Styling', () => {
it('applies error styling classes', () => {
const { container } = render(ErrorDisplay, { props: { error: 'Test error' } });
const errorDisplay = container.querySelector('.error-display');
expect(errorDisplay).toBeInTheDocument();
expect(errorDisplay).toHaveClass('error-display');
});
it('has proper visual hierarchy', () => {
const { container } = render(ErrorDisplay, { props: { error: 'Test error' } });
const errorIcon = container.querySelector('.error-icon');
const errorContent = container.querySelector('.error-content');
const dismissButton = container.querySelector('.dismiss-button');
expect(errorIcon).toBeInTheDocument();
expect(errorContent).toBeInTheDocument();
expect(dismissButton).toBeInTheDocument();
});
});
});