refactor(scanner): extract camera lifecycle hook (Brief 4) (#71)
Move stream start/stop, detection intervals, and tracked-card polling into lib/use-camera-scanner.js. CameraScanner keeps identification UI and disambiguation wiring only. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7f3cf62344
commit
986daaa1f2
4 changed files with 317 additions and 205 deletions
|
|
@ -1,9 +1,5 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { rateLimitCooldownUntil, uploadScanCapture } from '../lib/scan-capture-upload.js';
|
||||
import {
|
||||
detectCardShapesFromFrame,
|
||||
mergeDetectedShapesIntoTrackedCards,
|
||||
} from '../lib/scanner-card-detection.js';
|
||||
import {
|
||||
buildScannedCardPayload,
|
||||
getScanAuthHeaders,
|
||||
|
|
@ -13,59 +9,40 @@ import {
|
|||
VISION_RATE_LIMIT_MS,
|
||||
fetchVisionIdentify,
|
||||
} from '../lib/scanner-card-identify.js';
|
||||
import { useCameraScanner } from '../lib/use-camera-scanner.js';
|
||||
import ScanDisambiguationDialog from './ScanDisambiguationDialog.js';
|
||||
|
||||
export default function CameraScanner({ onCardScanned, onError }) {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [disambiguation, setDisambiguation] = useState(null);
|
||||
const [scanNotice, setScanNotice] = useState(null);
|
||||
const [submittingReview, setSubmittingReview] = useState(false);
|
||||
|
||||
const videoRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const detectionCanvasRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const detectionIntervalRef = useRef(null);
|
||||
const trackingIntervalRef = useRef(null);
|
||||
|
||||
// Card tracking state
|
||||
const [trackedCards, setTrackedCards] = useState([]); // Array of tracked card objects
|
||||
const trackedCardsRef = useRef([]);
|
||||
const nextCardIdRef = useRef(1);
|
||||
const visionCooldownUntilRef = useRef(0);
|
||||
const activeVerificationRef = useRef(0);
|
||||
const lastErrorAtRef = useRef(0);
|
||||
const disambiguationRefineRef = useRef(null);
|
||||
const verificationPausedRef = useRef(false);
|
||||
const onVerifyCardRef = useRef(() => {});
|
||||
|
||||
const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
|
||||
|
||||
// Configure canvas contexts for optimal performance
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
const ctx = canvasRef.current.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
if (detectionCanvasRef.current) {
|
||||
const ctx = detectionCanvasRef.current.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
}, []);
|
||||
verificationPausedRef.current = Boolean(disambiguation);
|
||||
}, [disambiguation]);
|
||||
|
||||
// Continuous shape detection for card-like rectangles
|
||||
const detectCardShapes = () => {
|
||||
if (!videoRef.current || !detectionCanvasRef.current || !isStreaming) return [];
|
||||
return detectCardShapesFromFrame(videoRef.current, detectionCanvasRef.current);
|
||||
};
|
||||
|
||||
const updateTrackedCards = (detectedShapes) => {
|
||||
const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards(
|
||||
trackedCardsRef.current,
|
||||
detectedShapes,
|
||||
nextCardIdRef.current
|
||||
);
|
||||
nextCardIdRef.current = nextCardId;
|
||||
trackedCardsRef.current = cards;
|
||||
setTrackedCards(cards);
|
||||
};
|
||||
const {
|
||||
videoRef,
|
||||
canvasRef,
|
||||
detectionCanvasRef,
|
||||
isStreaming,
|
||||
isDetecting,
|
||||
trackedCards,
|
||||
videoMetrics,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
} = useCameraScanner({
|
||||
onError,
|
||||
verificationPausedRef,
|
||||
onVerifyCard: (card) => onVerifyCardRef.current(card),
|
||||
});
|
||||
|
||||
const emitScannedCard = async (cardTracker, imageData, finalCard, ocrMeta = {}) => {
|
||||
cardTracker.status = 'scanned';
|
||||
|
|
@ -311,167 +288,13 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
}
|
||||
};
|
||||
|
||||
// Start continuous detection
|
||||
const startDetection = () => {
|
||||
if (detectionIntervalRef.current || !isStreaming) return;
|
||||
|
||||
console.log('🎯 Starting continuous card detection...');
|
||||
setIsDetecting(true);
|
||||
|
||||
// Shape detection every 200ms
|
||||
detectionIntervalRef.current = setInterval(() => {
|
||||
const shapes = detectCardShapes();
|
||||
updateTrackedCards(shapes);
|
||||
}, 200);
|
||||
|
||||
// Card verification every 1 second
|
||||
trackingIntervalRef.current = setInterval(() => {
|
||||
const cardsToVerify = trackedCardsRef.current.filter(card =>
|
||||
card.status === 'detecting' &&
|
||||
card.stableCount >= 6 &&
|
||||
card.scanAttempts < 1 &&
|
||||
Date.now() - card.firstSeen > 2500
|
||||
);
|
||||
|
||||
const cardsToProcess = cardsToVerify.slice(0, 1);
|
||||
cardsToProcess.forEach(card => {
|
||||
verifyCardShape(card);
|
||||
});
|
||||
}, 1000); // Back to 1 second intervals
|
||||
};
|
||||
|
||||
// Stop detection
|
||||
const stopDetection = () => {
|
||||
console.log('🛑 Stopping card detection...');
|
||||
setIsDetecting(false);
|
||||
|
||||
if (detectionIntervalRef.current) {
|
||||
clearInterval(detectionIntervalRef.current);
|
||||
detectionIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (trackingIntervalRef.current) {
|
||||
clearInterval(trackingIntervalRef.current);
|
||||
trackingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Clear tracked cards
|
||||
trackedCardsRef.current = [];
|
||||
setTrackedCards([]);
|
||||
};
|
||||
|
||||
// Start camera stream
|
||||
const startCamera = async () => {
|
||||
try {
|
||||
console.log('🎥 Starting camera...');
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
aspectRatio: { ideal: 16/9 }
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📹 Camera stream obtained:', stream);
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = stream;
|
||||
streamRef.current = stream;
|
||||
|
||||
videoRef.current.onloadedmetadata = () => {
|
||||
console.log('📺 Video metadata loaded, attempting to play...');
|
||||
videoRef.current?.play().then(() => {
|
||||
console.log('▶️ Video playback started successfully');
|
||||
setIsStreaming(true);
|
||||
}).catch((err) => {
|
||||
console.error('❌ Video playback failed:', err);
|
||||
onError(`Video playback failed: ${err.message}`);
|
||||
});
|
||||
};
|
||||
|
||||
videoRef.current.onerror = (err) => {
|
||||
console.error('❌ Video element error:', err);
|
||||
onError('Video element error occurred');
|
||||
};
|
||||
|
||||
// Add a fallback timeout
|
||||
setTimeout(() => {
|
||||
if (!isStreaming && videoRef.current && videoRef.current.readyState >= 2) {
|
||||
console.log('🔄 Fallback: Attempting to play video directly...');
|
||||
videoRef.current.play().then(() => {
|
||||
console.log('▶️ Fallback video playback started');
|
||||
setIsStreaming(true);
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
} else {
|
||||
console.error('❌ Video element not available');
|
||||
onError('Video element not available');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Camera access error:', err);
|
||||
onError(`Unable to access camera: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Stop camera stream
|
||||
const stopCamera = () => {
|
||||
setIsStreaming(false);
|
||||
stopDetection();
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isStreaming) return undefined;
|
||||
onVerifyCardRef.current = verifyCardShape;
|
||||
});
|
||||
|
||||
const syncVideoMetrics = () => {
|
||||
setVideoMetrics({
|
||||
width: video.videoWidth || 0,
|
||||
height: video.videoHeight || 0,
|
||||
});
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', syncVideoMetrics);
|
||||
video.addEventListener('resize', syncVideoMetrics);
|
||||
syncVideoMetrics();
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', syncVideoMetrics);
|
||||
video.removeEventListener('resize', syncVideoMetrics);
|
||||
};
|
||||
}, [isStreaming]);
|
||||
|
||||
// Auto-start detection when camera starts
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isDetecting) {
|
||||
// Small delay to let camera stabilize
|
||||
const timerId = setTimeout(() => {
|
||||
startDetection();
|
||||
}, 1000);
|
||||
return () => clearTimeout(timerId);
|
||||
}
|
||||
return undefined;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: start detection once per stream session
|
||||
}, [isStreaming]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopCamera();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount
|
||||
}, []);
|
||||
const foundCardCount = trackedCards.filter(
|
||||
(card) => card.status === 'confirmed' || card.status === 'scanned'
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
|
|
@ -555,8 +378,7 @@ export default function CameraScanner({ onCardScanned, onError }) {
|
|||
<div className="bg-black bg-opacity-80 text-white px-4 py-2 rounded-full text-sm font-medium shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
🎯 Scanning {trackedCards.filter(card => card.status === 'confirmed' || card.status === 'scanned').length > 0 &&
|
||||
`(${trackedCards.filter(card => card.status === 'confirmed' || card.status === 'scanned').length} found)`}
|
||||
🎯 Scanning {foundCardCount > 0 && `(${foundCardCount} found)`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,40 @@ export const DETECTION_CANVAS_HEIGHT = 240;
|
|||
/** Milliseconds before a tracked card is dropped when not re-detected. */
|
||||
export const TRACK_STALE_MS = 3000;
|
||||
|
||||
/** Shape-detection polling interval in the camera scanner hook. */
|
||||
export const SHAPE_DETECTION_INTERVAL_MS = 200;
|
||||
|
||||
/** Verification polling interval in the camera scanner hook. */
|
||||
export const VERIFICATION_INTERVAL_MS = 1000;
|
||||
|
||||
/** Minimum stable frames before a tracked card is sent for identification. */
|
||||
export const MIN_STABLE_COUNT_FOR_VERIFY = 6;
|
||||
|
||||
/** Minimum time (ms) a card must be tracked before verification. */
|
||||
export const MIN_FIRST_SEEN_MS_FOR_VERIFY = 2500;
|
||||
|
||||
/** Delay after camera start before detection loops begin. */
|
||||
export const DETECTION_START_DELAY_MS = 1000;
|
||||
|
||||
/** Tracked cards eligible for one-shot server verification. */
|
||||
export function selectCardsReadyForVerification(
|
||||
trackedCards,
|
||||
now = Date.now(),
|
||||
{
|
||||
minStableCount = MIN_STABLE_COUNT_FOR_VERIFY,
|
||||
maxScanAttempts = 1,
|
||||
minFirstSeenMs = MIN_FIRST_SEEN_MS_FOR_VERIFY,
|
||||
} = {}
|
||||
) {
|
||||
return trackedCards.filter(
|
||||
(card) =>
|
||||
card.status === 'detecting' &&
|
||||
card.stableCount >= minStableCount &&
|
||||
card.scanAttempts < maxScanAttempts &&
|
||||
now - card.firstSeen > minFirstSeenMs
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert detection-canvas coordinates to video pixel coordinates. */
|
||||
export function convertToVideoCoordinates(detection, canvas, video) {
|
||||
const scaleX = video.videoWidth / canvas.width;
|
||||
|
|
|
|||
220
lib/use-camera-scanner.js
Normal file
220
lib/use-camera-scanner.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
detectCardShapesFromFrame,
|
||||
DETECTION_START_DELAY_MS,
|
||||
mergeDetectedShapesIntoTrackedCards,
|
||||
selectCardsReadyForVerification,
|
||||
SHAPE_DETECTION_INTERVAL_MS,
|
||||
VERIFICATION_INTERVAL_MS,
|
||||
} from './scanner-card-detection.js';
|
||||
|
||||
/**
|
||||
* Camera stream + OpenCV shape detection loop for the card scanner.
|
||||
* Identification callbacks stay in the parent component.
|
||||
*/
|
||||
export function useCameraScanner({ onError, onVerifyCard, verificationPausedRef }) {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [isDetecting, setIsDetecting] = useState(false);
|
||||
const [trackedCards, setTrackedCards] = useState([]);
|
||||
const [videoMetrics, setVideoMetrics] = useState({ width: 0, height: 0 });
|
||||
|
||||
const videoRef = useRef(null);
|
||||
const canvasRef = useRef(null);
|
||||
const detectionCanvasRef = useRef(null);
|
||||
const streamRef = useRef(null);
|
||||
const detectionIntervalRef = useRef(null);
|
||||
const trackingIntervalRef = useRef(null);
|
||||
const trackedCardsRef = useRef([]);
|
||||
const nextCardIdRef = useRef(1);
|
||||
const isStreamingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isStreamingRef.current = isStreaming;
|
||||
}, [isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
if (detectionCanvasRef.current) {
|
||||
detectionCanvasRef.current.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const detectCardShapes = () => {
|
||||
if (!videoRef.current || !detectionCanvasRef.current || !isStreamingRef.current) return [];
|
||||
return detectCardShapesFromFrame(videoRef.current, detectionCanvasRef.current);
|
||||
};
|
||||
|
||||
const updateTrackedCards = (detectedShapes) => {
|
||||
const { cards, nextCardId } = mergeDetectedShapesIntoTrackedCards(
|
||||
trackedCardsRef.current,
|
||||
detectedShapes,
|
||||
nextCardIdRef.current
|
||||
);
|
||||
nextCardIdRef.current = nextCardId;
|
||||
trackedCardsRef.current = cards;
|
||||
setTrackedCards(cards);
|
||||
};
|
||||
|
||||
const stopDetection = () => {
|
||||
console.log('🛑 Stopping card detection...');
|
||||
setIsDetecting(false);
|
||||
|
||||
if (detectionIntervalRef.current) {
|
||||
clearInterval(detectionIntervalRef.current);
|
||||
detectionIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (trackingIntervalRef.current) {
|
||||
clearInterval(trackingIntervalRef.current);
|
||||
trackingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
trackedCardsRef.current = [];
|
||||
setTrackedCards([]);
|
||||
};
|
||||
|
||||
const startDetection = () => {
|
||||
if (detectionIntervalRef.current || !isStreamingRef.current) return;
|
||||
|
||||
console.log('🎯 Starting continuous card detection...');
|
||||
setIsDetecting(true);
|
||||
|
||||
detectionIntervalRef.current = setInterval(() => {
|
||||
const shapes = detectCardShapes();
|
||||
updateTrackedCards(shapes);
|
||||
}, SHAPE_DETECTION_INTERVAL_MS);
|
||||
|
||||
trackingIntervalRef.current = setInterval(() => {
|
||||
if (verificationPausedRef?.current) return;
|
||||
|
||||
const cardsToVerify = selectCardsReadyForVerification(trackedCardsRef.current);
|
||||
cardsToVerify.slice(0, 1).forEach((card) => {
|
||||
onVerifyCard?.(card);
|
||||
});
|
||||
}, VERIFICATION_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const stopCamera = () => {
|
||||
setIsStreaming(false);
|
||||
isStreamingRef.current = false;
|
||||
stopDetection();
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startCamera = async () => {
|
||||
try {
|
||||
console.log('🎥 Starting camera...');
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
aspectRatio: { ideal: 16 / 9 },
|
||||
},
|
||||
});
|
||||
|
||||
console.log('📹 Camera stream obtained:', stream);
|
||||
|
||||
if (!videoRef.current) {
|
||||
console.error('❌ Video element not available');
|
||||
onError?.('Video element not available');
|
||||
return;
|
||||
}
|
||||
|
||||
videoRef.current.srcObject = stream;
|
||||
streamRef.current = stream;
|
||||
|
||||
videoRef.current.onloadedmetadata = () => {
|
||||
console.log('📺 Video metadata loaded, attempting to play...');
|
||||
videoRef.current?.play().then(() => {
|
||||
console.log('▶️ Video playback started successfully');
|
||||
setIsStreaming(true);
|
||||
isStreamingRef.current = true;
|
||||
}).catch((err) => {
|
||||
console.error('❌ Video playback failed:', err);
|
||||
onError?.(`Video playback failed: ${err.message}`);
|
||||
});
|
||||
};
|
||||
|
||||
videoRef.current.onerror = (err) => {
|
||||
console.error('❌ Video element error:', err);
|
||||
onError?.('Video element error occurred');
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isStreamingRef.current && videoRef.current?.readyState >= 2) {
|
||||
console.log('🔄 Fallback: Attempting to play video directly...');
|
||||
videoRef.current.play().then(() => {
|
||||
console.log('▶️ Fallback video playback started');
|
||||
setIsStreaming(true);
|
||||
isStreamingRef.current = true;
|
||||
}).catch(console.error);
|
||||
}
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('❌ Camera access error:', err);
|
||||
onError?.(`Unable to access camera: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !isStreaming) return undefined;
|
||||
|
||||
const syncVideoMetrics = () => {
|
||||
setVideoMetrics({
|
||||
width: video.videoWidth || 0,
|
||||
height: video.videoHeight || 0,
|
||||
});
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', syncVideoMetrics);
|
||||
video.addEventListener('resize', syncVideoMetrics);
|
||||
syncVideoMetrics();
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('loadedmetadata', syncVideoMetrics);
|
||||
video.removeEventListener('resize', syncVideoMetrics);
|
||||
};
|
||||
}, [isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreaming || isDetecting) return undefined;
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
startDetection();
|
||||
}, DETECTION_START_DELAY_MS);
|
||||
|
||||
return () => clearTimeout(timerId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- start detection once per stream session
|
||||
}, [isStreaming]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopCamera();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- run stopCamera only on unmount
|
||||
}, []);
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
canvasRef,
|
||||
detectionCanvasRef,
|
||||
isStreaming,
|
||||
isDetecting,
|
||||
trackedCards,
|
||||
videoMetrics,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import {
|
|||
convertToVideoCoordinates,
|
||||
mergeDetectedShapesIntoTrackedCards,
|
||||
rectanglesOverlap,
|
||||
selectCardsReadyForVerification,
|
||||
TRACK_STALE_MS,
|
||||
} from '../../lib/scanner-card-detection.js';
|
||||
|
||||
|
|
@ -89,3 +90,38 @@ describe('mergeDetectedShapesIntoTrackedCards', () => {
|
|||
expect(cards).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectCardsReadyForVerification', () => {
|
||||
it('returns detecting cards that are stable long enough', () => {
|
||||
const now = 10_000;
|
||||
const ready = {
|
||||
id: 1,
|
||||
status: 'detecting',
|
||||
stableCount: 6,
|
||||
scanAttempts: 0,
|
||||
firstSeen: now - 3000,
|
||||
};
|
||||
const tooFresh = {
|
||||
id: 2,
|
||||
status: 'detecting',
|
||||
stableCount: 6,
|
||||
scanAttempts: 0,
|
||||
firstSeen: now - 1000,
|
||||
};
|
||||
|
||||
expect(selectCardsReadyForVerification([ready, tooFresh], now)).toEqual([ready]);
|
||||
});
|
||||
|
||||
it('excludes cards that already attempted verification', () => {
|
||||
const now = 10_000;
|
||||
const retried = {
|
||||
id: 1,
|
||||
status: 'detecting',
|
||||
stableCount: 8,
|
||||
scanAttempts: 1,
|
||||
firstSeen: now - 5000,
|
||||
};
|
||||
|
||||
expect(selectCardsReadyForVerification([retried], now)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue