- useCropHandles.ts: Hook managing crop bounds state, drag operations, and constraints - 8 draggable handles (4 corners + 4 edges) - Real-time crop bounds calculation during drag - Constrained within image bounds (no dragging outside) - Minimum crop size enforcement (100x100px) - Support for mobile (touch) and desktop (mouse) events - Bounds validation on initialization - ManualCropUI.tsx: Interactive crop preview component - Responsive image display with calculated scaling - Semi-transparent overlay outside crop box with visible bounding box - 8 draggable handles with visual feedback (highlight/scale on hover) - 'Use Full Photo' button to clear crop and show full image - Real-time onCropChange callbacks to parent - Error handling for failed image loads - Touch and mouse event support (desktop + mobile) - TypeScript strict mode compliant - Tests: 26 comprehensive test cases - Hook tests (26 passing): initialization, setCrop, resetCrop, drag operations (corners, edges), constraints, endDrag, edge cases - Component tests (26 passing): rendering, handles, overlay, callbacks, button, error handling, dimensions, touch/mouse support, responsive behavior, size enforcement, bounds display All 364 tests passing (13 test files, zero regressions) Minimum crop size: 100x100px Handle visual feedback on hover/drag TypeScript strict mode: ✓
452 lines
12 KiB
TypeScript
452 lines
12 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { useCropHandles, type CropBounds } from '@/hooks/useCropHandles';
|
|
|
|
describe('useCropHandles Hook', () => {
|
|
const imageDimensions = { width: 800, height: 600 };
|
|
const initialCrop: CropBounds = { x: 100, y: 100, width: 200, height: 200 };
|
|
|
|
describe('Initialization', () => {
|
|
it('should initialize with null crop when no initialCrop provided', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions })
|
|
);
|
|
|
|
expect(result.current.crop).toBeNull();
|
|
expect(result.current.isDragging).toBe(false);
|
|
});
|
|
|
|
it('should initialize with provided initialCrop', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
expect(result.current.crop).toEqual(initialCrop);
|
|
});
|
|
|
|
it('should enforce minimum size on initialCrop', () => {
|
|
const tinyBounds: CropBounds = { x: 50, y: 50, width: 10, height: 10 };
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop: tinyBounds, minSize: 100 })
|
|
);
|
|
|
|
expect(result.current.crop).toEqual({
|
|
x: 50,
|
|
y: 50,
|
|
width: 100,
|
|
height: 100,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('setCrop', () => {
|
|
it('should set crop bounds', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions })
|
|
);
|
|
|
|
const newBounds: CropBounds = { x: 50, y: 50, width: 300, height: 300 };
|
|
|
|
act(() => {
|
|
result.current.setCrop(newBounds);
|
|
});
|
|
|
|
expect(result.current.crop).toEqual(newBounds);
|
|
});
|
|
|
|
it('should clear crop with null', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.setCrop(null);
|
|
});
|
|
|
|
expect(result.current.crop).toBeNull();
|
|
});
|
|
|
|
it('should constrain bounds within image', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions })
|
|
);
|
|
|
|
const outOfBounds: CropBounds = { x: 600, y: 400, width: 400, height: 400 };
|
|
|
|
act(() => {
|
|
result.current.setCrop(outOfBounds);
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBeLessThanOrEqual(imageDimensions.width - result.current.crop!.width);
|
|
expect(result.current.crop!.y).toBeLessThanOrEqual(imageDimensions.height - result.current.crop!.height);
|
|
});
|
|
|
|
it('should enforce minimum size', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, minSize: 100 })
|
|
);
|
|
|
|
const smallBounds: CropBounds = { x: 50, y: 50, width: 20, height: 20 };
|
|
|
|
act(() => {
|
|
result.current.setCrop(smallBounds);
|
|
});
|
|
|
|
expect(result.current.crop!.width).toBeGreaterThanOrEqual(100);
|
|
expect(result.current.crop!.height).toBeGreaterThanOrEqual(100);
|
|
});
|
|
});
|
|
|
|
describe('resetCrop', () => {
|
|
it('should clear crop bounds', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
expect(result.current.crop).not.toBeNull();
|
|
|
|
act(() => {
|
|
result.current.resetCrop();
|
|
});
|
|
|
|
expect(result.current.crop).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('Drag Operations - Corner Handles', () => {
|
|
it('should drag top-left corner inward', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('top-left', 100, 100);
|
|
});
|
|
|
|
expect(result.current.isDragging).toBe(true);
|
|
|
|
act(() => {
|
|
result.current.moveDrag(120, 120);
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBe(120);
|
|
expect(result.current.crop!.y).toBe(120);
|
|
expect(result.current.crop!.width).toBe(180);
|
|
expect(result.current.crop!.height).toBe(180);
|
|
});
|
|
|
|
it('should drag top-right corner', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('top-right', 300, 100);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(350, 130);
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBe(100);
|
|
expect(result.current.crop!.y).toBe(130);
|
|
expect(result.current.crop!.width).toBe(250);
|
|
expect(result.current.crop!.height).toBe(170);
|
|
});
|
|
|
|
it('should drag bottom-right corner', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('bottom-right', 300, 300);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(350, 380);
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBe(100);
|
|
expect(result.current.crop!.y).toBe(100);
|
|
expect(result.current.crop!.width).toBe(250);
|
|
expect(result.current.crop!.height).toBe(280);
|
|
});
|
|
|
|
it('should drag bottom-left corner', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('bottom-left', 100, 300);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(140, 380);
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBe(140);
|
|
expect(result.current.crop!.y).toBe(100);
|
|
expect(result.current.crop!.width).toBe(160);
|
|
expect(result.current.crop!.height).toBe(280);
|
|
});
|
|
});
|
|
|
|
describe('Drag Operations - Edge Handles', () => {
|
|
it('should drag top edge', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('top', 200, 100);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(200, 130);
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBe(100);
|
|
expect(result.current.crop!.y).toBe(130);
|
|
expect(result.current.crop!.width).toBe(200);
|
|
expect(result.current.crop!.height).toBe(170);
|
|
});
|
|
|
|
it('should drag right edge', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('right', 300, 200);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(380, 200);
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBe(100);
|
|
expect(result.current.crop!.y).toBe(100);
|
|
expect(result.current.crop!.width).toBe(280);
|
|
expect(result.current.crop!.height).toBe(200);
|
|
});
|
|
|
|
it('should drag bottom edge', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('bottom', 200, 300);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(200, 380);
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBe(100);
|
|
expect(result.current.crop!.y).toBe(100);
|
|
expect(result.current.crop!.width).toBe(200);
|
|
expect(result.current.crop!.height).toBe(280);
|
|
});
|
|
|
|
it('should drag left edge', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('left', 100, 200);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(140, 200);
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBe(140);
|
|
expect(result.current.crop!.y).toBe(100);
|
|
expect(result.current.crop!.width).toBe(160);
|
|
expect(result.current.crop!.height).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('Drag Constraints', () => {
|
|
it('should not allow dragging outside left boundary', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('left', 100, 200);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(-100, 200); // Try to drag far left
|
|
});
|
|
|
|
expect(result.current.crop!.x).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should not allow dragging outside right boundary', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('right', 300, 200);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(900, 200); // Try to drag far right
|
|
});
|
|
|
|
const { crop } = result.current;
|
|
expect(crop!.x + crop!.width).toBeLessThanOrEqual(imageDimensions.width);
|
|
});
|
|
|
|
it('should not allow dragging outside top boundary', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('top', 200, 100);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(200, -100); // Try to drag far up
|
|
});
|
|
|
|
expect(result.current.crop!.y).toBeGreaterThanOrEqual(0);
|
|
});
|
|
|
|
it('should not allow dragging outside bottom boundary', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('bottom', 200, 300);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(200, 800); // Try to drag far down
|
|
});
|
|
|
|
const { crop } = result.current;
|
|
expect(crop!.y + crop!.height).toBeLessThanOrEqual(imageDimensions.height);
|
|
});
|
|
|
|
it('should not allow crop smaller than minSize', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop, minSize: 100 })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('bottom-right', 300, 300);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(180, 180); // Try to make tiny crop
|
|
});
|
|
|
|
expect(result.current.crop!.width).toBeGreaterThanOrEqual(100);
|
|
expect(result.current.crop!.height).toBeGreaterThanOrEqual(100);
|
|
});
|
|
});
|
|
|
|
describe('Drag End', () => {
|
|
it('should end drag and set isDragging to false', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('top-left', 100, 100);
|
|
});
|
|
|
|
expect(result.current.isDragging).toBe(true);
|
|
|
|
act(() => {
|
|
result.current.moveDrag(120, 120);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.endDrag();
|
|
});
|
|
|
|
expect(result.current.isDragging).toBe(false);
|
|
});
|
|
|
|
it('should preserve crop bounds after endDrag', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('right', 300, 200);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(350, 200);
|
|
});
|
|
|
|
const boundsBeforeEnd = { ...result.current.crop! };
|
|
|
|
act(() => {
|
|
result.current.endDrag();
|
|
});
|
|
|
|
expect(result.current.crop).toEqual(boundsBeforeEnd);
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should not crash when dragging without starting', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, initialCrop })
|
|
);
|
|
|
|
expect(() => {
|
|
act(() => {
|
|
result.current.moveDrag(150, 150);
|
|
});
|
|
}).not.toThrow();
|
|
});
|
|
|
|
it('should handle very large image dimensions', () => {
|
|
const largeDimensions = { width: 10000, height: 8000 };
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions: largeDimensions, initialCrop })
|
|
);
|
|
|
|
act(() => {
|
|
result.current.startDrag('bottom-right', 300, 300);
|
|
});
|
|
|
|
act(() => {
|
|
result.current.moveDrag(5000, 4000);
|
|
});
|
|
|
|
expect(result.current.crop).toBeDefined();
|
|
expect(result.current.crop!.x + result.current.crop!.width).toBeLessThanOrEqual(largeDimensions.width);
|
|
});
|
|
|
|
it('should handle custom minimum size', () => {
|
|
const { result } = renderHook(() =>
|
|
useCropHandles({ imageDimensions, minSize: 50 })
|
|
);
|
|
|
|
const smallBounds: CropBounds = { x: 10, y: 10, width: 20, height: 20 };
|
|
|
|
act(() => {
|
|
result.current.setCrop(smallBounds);
|
|
});
|
|
|
|
expect(result.current.crop!.width).toBeGreaterThanOrEqual(50);
|
|
expect(result.current.crop!.height).toBeGreaterThanOrEqual(50);
|
|
});
|
|
});
|
|
});
|