/**
* @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(
);
expect(screen.getByText(/requesting camera access/i)).toBeInTheDocument();
});
it('shows error when permission denied', async () => {
mockGetUserMedia.mockRejectedValue(
new DOMException('Permission denied', 'NotAllowedError')
);
render(
);
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(
);
await waitFor(() => {
expect(screen.getByText(/no camera found/i)).toBeInTheDocument();
});
});
});
describe('Viewfinder', () => {
it('shows viewfinder when camera access granted', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
);
await waitFor(() => {
expect(screen.getByRole('button', { name: /take photo/i })).toBeInTheDocument();
});
});
it('shows cancel button in viewfinder', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
);
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel capture/i })).toBeInTheDocument();
});
});
it('calls onCancel when cancel button clicked', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
);
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(
);
await waitFor(() => {
expect(screen.getByText(/position vin/i)).toBeInTheDocument();
});
});
it('shows receipt guidance when guidanceType is receipt', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
);
await waitFor(() => {
expect(screen.getByText(/position receipt/i)).toBeInTheDocument();
});
});
});
describe('File fallback', () => {
it('shows upload file button in viewfinder', async () => {
mockGetUserMedia.mockResolvedValue(mockMediaStream);
render(
);
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(
);
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(
);
expect(screen.getByText(/drag and drop/i)).toBeInTheDocument();
});
it('shows accepted formats', () => {
render(
);
expect(screen.getByText(/jpeg, png, heic/i)).toBeInTheDocument();
});
it('shows max file size', () => {
render(
);
expect(screen.getByText(/10mb/i)).toBeInTheDocument();
});
it('calls onCancel when cancel clicked', () => {
render(
);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(mockOnCancel).toHaveBeenCalledTimes(1);
});
it('shows error for invalid file type', async () => {
render(
);
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(
);
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(
);
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();
expect(container.firstChild).toBeNull();
});
it('renders VIN guidance with correct description', () => {
render();
expect(screen.getByText(/position vin plate/i)).toBeInTheDocument();
});
it('renders receipt guidance with correct description', () => {
render();
expect(screen.getByText(/position receipt/i)).toBeInTheDocument();
});
it('renders document guidance with correct description', () => {
render();
expect(screen.getByText(/position document/i)).toBeInTheDocument();
});
});