265 lines
7.7 KiB
JavaScript
265 lines
7.7 KiB
JavaScript
|
|
// @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');
|
||
|
|
});
|
||
|
|
});
|