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

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:
Eric Gullickson
2026-02-01 15:05:18 -06:00
parent 42e0fc1fce
commit 7c8b6fda2a
11 changed files with 2439 additions and 0 deletions

View File

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