// @vitest-environment jsdom import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; vi.mock('../../lib/scanner-card-detection.js', () => ({ detectCardShapesFromFrame: vi.fn(() => []), DETECTION_START_DELAY_MS: 10_000, mergeDetectedShapesIntoTrackedCards: vi.fn(() => ({ cards: [], nextCardId: 1 })), selectCardsReadyForVerification: vi.fn(() => []), SHAPE_DETECTION_INTERVAL_MS: 10_000, VERIFICATION_INTERVAL_MS: 10_000, })); import { useCameraScanner } from '../../lib/use-camera-scanner.js'; const SESSION_DEVICE_KEY = 'scanner:last-camera-device-id'; function createMockStream() { const track = { stop: vi.fn() }; return { getTracks: () => [track], }; } function attachMockVideo(hookResult) { const video = document.createElement('video'); video.play = vi.fn().mockResolvedValue(undefined); Object.defineProperty(video, 'readyState', { value: 2, configurable: true }); hookResult.videoRef.current = video; return video; } async function startCameraWithMetadata(hookResult) { const video = attachMockVideo(hookResult); await act(async () => { await hookResult.startCamera(); }); await act(async () => { video.onloadedmetadata?.(); await Promise.resolve(); }); } describe('useCameraScanner — device picker', () => { let getUserMedia; let enumerateDevices; beforeEach(() => { sessionStorage.clear(); getUserMedia = vi.fn().mockResolvedValue(createMockStream()); enumerateDevices = vi.fn().mockResolvedValue([ { deviceId: 'cam-a', kind: 'videoinput', label: 'Desk Webcam', }, { deviceId: 'cam-b', kind: 'videoinput', label: 'USB Camera', }, { deviceId: 'mic-1', kind: 'audioinput', label: 'Built-in Mic', }, ]); Object.defineProperty(global.navigator, 'mediaDevices', { configurable: true, value: { getUserMedia, enumerateDevices, }, }); }); afterEach(() => { vi.clearAllMocks(); sessionStorage.clear(); }); it('starts with loading picker status and message', () => { const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); expect(result.current.devicePickerStatus).toBe('loading'); expect(result.current.devicePickerMessage).toBe('Detecting cameras…'); }); it('enumerates video inputs after stream start', async () => { const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); await startCameraWithMetadata(result.current); expect(enumerateDevices).toHaveBeenCalled(); expect(result.current.videoDevices).toEqual([ { deviceId: 'cam-a', kind: 'videoinput', label: 'Desk Webcam', }, { deviceId: 'cam-b', kind: 'videoinput', label: 'USB Camera', }, ]); expect(result.current.devicePickerStatus).toBe('ready'); expect(result.current.devicePickerMessage).toBe(''); }); it('uses deviceId exact constraints when selectedDeviceId is set', async () => { sessionStorage.setItem(SESSION_DEVICE_KEY, 'cam-b'); const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); expect(result.current.selectedDeviceId).toBe('cam-b'); await startCameraWithMetadata(result.current); expect(getUserMedia).toHaveBeenCalledWith({ video: { deviceId: { exact: 'cam-b' }, width: { ideal: 1280 }, height: { ideal: 720 }, }, }); }); it('uses facingMode constraints when no selectedDeviceId is set', async () => { const { result } = renderHook(() => useCameraScanner({ onError: vi.fn(), facingMode: 'user' }) ); await startCameraWithMetadata(result.current); expect(getUserMedia).toHaveBeenCalledWith({ video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 }, aspectRatio: { ideal: 16 / 9 }, }, }); }); it('persists selectedDeviceId to sessionStorage', async () => { const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); await act(async () => { result.current.setSelectedDeviceId('cam-a'); }); expect(result.current.selectedDeviceId).toBe('cam-a'); expect(sessionStorage.getItem(SESSION_DEVICE_KEY)).toBe('cam-a'); }); it('restarts the stream when selectedDeviceId changes while streaming', async () => { const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); await startCameraWithMetadata(result.current); getUserMedia.mockClear(); await act(async () => { result.current.setSelectedDeviceId('cam-b'); }); expect(getUserMedia).toHaveBeenCalledWith({ video: { deviceId: { exact: 'cam-b' }, width: { ideal: 1280 }, height: { ideal: 720 }, }, }); }); it('switchFacingMode still toggles facingMode when no deviceId is forced', async () => { const { result } = renderHook(() => useCameraScanner({ onError: vi.fn(), facingMode: 'environment' }) ); await startCameraWithMetadata(result.current); getUserMedia.mockClear(); await act(async () => { result.current.switchFacingMode(); }); expect(result.current.facingMode).toBe('user'); expect(getUserMedia).toHaveBeenCalledWith({ video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 }, aspectRatio: { ideal: 16 / 9 }, }, }); }); it('reports empty picker status when no video inputs are found', async () => { enumerateDevices.mockResolvedValue([ { deviceId: 'mic-1', kind: 'audioinput', label: 'Mic' }, ]); const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); await startCameraWithMetadata(result.current); expect(result.current.devicePickerStatus).toBe('empty'); expect(result.current.devicePickerMessage).toBe( 'No camera found. Connect a webcam or use Upload Image.' ); expect(result.current.videoDevices).toEqual([]); }); it('reports denied picker status when getUserMedia throws NotAllowedError', async () => { const deniedError = new Error('Permission denied'); deniedError.name = 'NotAllowedError'; getUserMedia.mockRejectedValueOnce(deniedError); const onError = vi.fn(); const { result } = renderHook(() => useCameraScanner({ onError })); await act(async () => { attachMockVideo(result.current); await result.current.startCamera(); }); expect(result.current.devicePickerStatus).toBe('denied'); expect(result.current.devicePickerMessage).toBe( 'Camera access blocked. Allow camera permission or use Upload Image.' ); expect(onError).toHaveBeenCalled(); }); it('reports error picker status when enumerateDevices is unavailable', async () => { Object.defineProperty(global.navigator, 'mediaDevices', { configurable: true, value: { getUserMedia, }, }); const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); await startCameraWithMetadata(result.current); expect(result.current.devicePickerStatus).toBe('error'); expect(result.current.devicePickerMessage).toBe( "Couldn't list cameras. Try again or use Upload Image." ); }); it('falls back to the first device when persisted id is missing', async () => { sessionStorage.setItem(SESSION_DEVICE_KEY, 'missing-device'); const { result } = renderHook(() => useCameraScanner({ onError: vi.fn() })); await startCameraWithMetadata(result.current); expect(result.current.selectedDeviceId).toBe('cam-a'); expect(sessionStorage.getItem(SESSION_DEVICE_KEY)).toBe('cam-a'); }); });