feat: add camera capture component (refs #66)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m14s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 33s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m14s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 33s
Deploy to Staging / Verify Staging (pull_request) Successful in 2m19s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 8s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped
Implements a reusable React camera capture component with: - getUserMedia API for camera access on mobile and desktop - Translucent aspect-ratio guidance overlays (VIN ~6:1, receipt ~2:3) - Post-capture crop tool with draggable handles - File input fallback for desktop and unsupported browsers - Support for HEIC, JPEG, PNG (sent as-is to server) - Full mobile responsiveness (320px - 1920px) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* @ai-summary Tests for CameraCapture component
|
||||
* @ai-context Validates camera permission, capture flow, cropping, and file fallback
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { CameraCapture } from './CameraCapture';
|
||||
import { FileInputFallback } from './FileInputFallback';
|
||||
import { GuidanceOverlay } from './GuidanceOverlay';
|
||||
|
||||
// Mock MUI useMediaQuery
|
||||
jest.mock('@mui/material', () => ({
|
||||
...jest.requireActual('@mui/material'),
|
||||
useMediaQuery: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
// Mock navigator.mediaDevices
|
||||
const mockGetUserMedia = jest.fn();
|
||||
const mockEnumerateDevices = jest.fn();
|
||||
const mockMediaStream = {
|
||||
getTracks: () => [{ stop: jest.fn() }],
|
||||
getVideoTracks: () => [
|
||||
{
|
||||
getSettings: () => ({ width: 1920, height: 1080 }),
|
||||
stop: jest.fn(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
value: {
|
||||
getUserMedia: mockGetUserMedia,
|
||||
enumerateDevices: mockEnumerateDevices,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockEnumerateDevices.mockResolvedValue([
|
||||
{ kind: 'videoinput', deviceId: 'camera1' },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('CameraCapture', () => {
|
||||
const mockOnCapture = jest.fn();
|
||||
const mockOnCancel = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Permission handling', () => {
|
||||
it('shows loading state while requesting permission', () => {
|
||||
mockGetUserMedia.mockImplementation(() => new Promise(() => {})); // Never resolves
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
expect(screen.getByText(/requesting camera access/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when permission denied', async () => {
|
||||
mockGetUserMedia.mockRejectedValue(
|
||||
new DOMException('Permission denied', 'NotAllowedError')
|
||||
);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/camera permission/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows error when camera unavailable', async () => {
|
||||
mockGetUserMedia.mockRejectedValue(
|
||||
new DOMException('No camera', 'NotFoundError')
|
||||
);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no camera found/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Viewfinder', () => {
|
||||
it('shows viewfinder when camera access granted', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /take photo/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows cancel button in viewfinder', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /cancel capture/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel button clicked', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /cancel capture/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel capture/i }));
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Guidance overlay', () => {
|
||||
it('shows VIN guidance when guidanceType is vin', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture
|
||||
onCapture={mockOnCapture}
|
||||
onCancel={mockOnCancel}
|
||||
guidanceType="vin"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/position vin/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows receipt guidance when guidanceType is receipt', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture
|
||||
onCapture={mockOnCapture}
|
||||
onCancel={mockOnCancel}
|
||||
guidanceType="receipt"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/position receipt/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('File fallback', () => {
|
||||
it('shows upload file button in viewfinder', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('switches to file fallback when upload file clicked', async () => {
|
||||
mockGetUserMedia.mockResolvedValue(mockMediaStream);
|
||||
|
||||
render(
|
||||
<CameraCapture onCapture={mockOnCapture} onCancel={mockOnCancel} />
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /upload file/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /upload file/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/upload image/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileInputFallback', () => {
|
||||
const mockOnFileSelect = jest.fn();
|
||||
const mockOnCancel = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders upload area', () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg', 'image/png']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/drag and drop/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows accepted formats', () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg', 'image/png', 'image/heic']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/jpeg, png, heic/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows max file size', () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/10mb/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCancel when cancel clicked', () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows error for invalid file type', async () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const invalidFile = new File(['test'], 'test.txt', { type: 'text/plain' });
|
||||
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [invalidFile],
|
||||
});
|
||||
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid file type/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows error for file too large', async () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={1024} // 1KB
|
||||
/>
|
||||
);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const largeFile = new File(['x'.repeat(2048)], 'large.jpg', {
|
||||
type: 'image/jpeg',
|
||||
});
|
||||
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [largeFile],
|
||||
});
|
||||
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/file too large/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onFileSelect with valid file', async () => {
|
||||
render(
|
||||
<FileInputFallback
|
||||
onFileSelect={mockOnFileSelect}
|
||||
onCancel={mockOnCancel}
|
||||
acceptedFormats={['image/jpeg']}
|
||||
maxFileSize={10 * 1024 * 1024}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const validFile = new File(['test'], 'test.jpg', { type: 'image/jpeg' });
|
||||
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [validFile],
|
||||
});
|
||||
|
||||
fireEvent.change(input);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnFileSelect).toHaveBeenCalledWith(validFile);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GuidanceOverlay', () => {
|
||||
it('renders nothing when type is none', () => {
|
||||
const { container } = render(<GuidanceOverlay type="none" />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('renders VIN guidance with correct description', () => {
|
||||
render(<GuidanceOverlay type="vin" />);
|
||||
expect(screen.getByText(/position vin plate/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders receipt guidance with correct description', () => {
|
||||
render(<GuidanceOverlay type="receipt" />);
|
||||
expect(screen.getByText(/position receipt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders document guidance with correct description', () => {
|
||||
render(<GuidanceOverlay type="document" />);
|
||||
expect(screen.getByText(/position document/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user