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>
363 lines
9.8 KiB
TypeScript
363 lines
9.8 KiB
TypeScript
/**
|
|
* @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();
|
|
});
|
|
});
|