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