Files
tfm_ainventory/frontend/tests/hooks/useCropHandles.test.ts
Daniel Bedeleanu b2e2daf40d feat(phase2): implement ManualCropUI with drag handles
- 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: ✓
2026-04-21 13:05:37 +03:00

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