deckhearth/test/lib/use-camera-scanner.test.js

265 lines
7.7 KiB
JavaScript
Raw Normal View History

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