@@ -299,18 +299,121 @@ describe('WildlifePipeline', () => {
299299 expect ( mockExtractEmbedding ) . toHaveBeenCalledTimes ( 2 ) ;
300300 } ) ;
301301
302- it ( 'should propagate errors when detector fails' , async ( ) => {
303- mockRunDetection . mockRejectedValueOnce ( new Error ( 'Detector ONNX error' ) ) ;
302+ it ( 'should record a detector failure as an error and keep other species results' , async ( ) => {
303+ const detectionResult = {
304+ boundingBox : { x : 0.1 , y : 0.2 , width : 0.3 , height : 0.4 } ,
305+ species : 'giraffe' ,
306+ confidence : 0.9 ,
307+ } ;
308+ mockRunDetection
309+ . mockRejectedValueOnce ( new Error ( 'Detector ONNX error' ) )
310+ . mockResolvedValueOnce ( { results : [ detectionResult ] , inferenceTimeMs : 100 } ) ;
311+ mockExtractEmbedding . mockResolvedValue ( {
312+ embedding : [ 0.1 , 0.2 , 0.3 ] ,
313+ inferenceTimeMs : 50 ,
314+ } ) ;
315+ mockMatchEmbedding . mockReturnValue ( [ ] ) ;
304316
305- const config = makeSpeciesConfig ( ) ;
306- await expect (
307- wildlifePipeline . processPhoto ( {
308- photoUri : 'file:///photos/fail.jpg' ,
309-
310- speciesConfigs : [ config ] ,
311- miewidModelPath : '/models/miewid.onnx' ,
312- } ) ,
313- ) . rejects . toThrow ( 'Detector ONNX error' ) ;
317+ const result = await wildlifePipeline . processPhoto ( {
318+ photoUri : 'file:///photos/partial.jpg' ,
319+ speciesConfigs : [
320+ makeSpeciesConfig ( { species : 'zebra_plains' } ) ,
321+ makeSpeciesConfig ( {
322+ packId : 'pack-giraffe' ,
323+ species : 'giraffe' ,
324+ detectorModelPath : '/models/giraffe_detector.onnx' ,
325+ } ) ,
326+ ] ,
327+ miewidModelPath : '/models/miewid.onnx' ,
328+ } ) ;
329+
330+ expect ( result . detections ) . toHaveLength ( 1 ) ;
331+ expect ( result . detections [ 0 ] . species ) . toBe ( 'giraffe' ) ;
332+ expect ( result . errors ) . toHaveLength ( 1 ) ;
333+ expect ( result . errors [ 0 ] ) . toMatchObject ( {
334+ species : 'zebra_plains' ,
335+ stage : 'detector' ,
336+ message : expect . stringContaining ( 'Detector ONNX error' ) ,
337+ } ) ;
338+ } ) ;
339+
340+ it ( 'should record a per-detection failure and keep the remaining detections' , async ( ) => {
341+ const makeDetection = ( x : number ) => ( {
342+ boundingBox : { x, y : 0.2 , width : 0.3 , height : 0.4 } ,
343+ species : 'zebra_plains' ,
344+ confidence : 0.9 ,
345+ } ) ;
346+ mockRunDetection . mockResolvedValueOnce ( {
347+ results : [ makeDetection ( 0.1 ) , makeDetection ( 0.5 ) ] ,
348+ inferenceTimeMs : 100 ,
349+ } ) ;
350+ mockExtractEmbedding
351+ . mockRejectedValueOnce ( new Error ( 'embedding blew up' ) )
352+ . mockResolvedValueOnce ( { embedding : [ 0.1 , 0.2 , 0.3 ] , inferenceTimeMs : 50 } ) ;
353+ mockMatchEmbedding . mockReturnValue ( [ ] ) ;
354+
355+ const result = await wildlifePipeline . processPhoto ( {
356+ photoUri : 'file:///photos/two-animals.jpg' ,
357+ speciesConfigs : [ makeSpeciesConfig ( ) ] ,
358+ miewidModelPath : '/models/miewid.onnx' ,
359+ } ) ;
360+
361+ expect ( result . detections ) . toHaveLength ( 1 ) ;
362+ expect ( result . errors ) . toHaveLength ( 1 ) ;
363+ expect ( result . errors [ 0 ] ) . toMatchObject ( {
364+ species : 'zebra_plains' ,
365+ stage : 'embedding' ,
366+ message : expect . stringContaining ( 'embedding blew up' ) ,
367+ } ) ;
368+ } ) ;
369+
370+ it ( 'should fail fast with an embedding-model error when MiewID cannot load' , async ( ) => {
371+ // MiewID is the first load attempt (fail-fast contract)
372+ mockIsModelLoaded . mockReturnValueOnce ( false ) ;
373+ mockLoadModel . mockRejectedValueOnce ( new Error ( 'MiewID file corrupt' ) ) ;
374+
375+ const result = await wildlifePipeline . processPhoto ( {
376+ photoUri : 'file:///photos/test.jpg' ,
377+ speciesConfigs : [ makeSpeciesConfig ( ) ] ,
378+ miewidModelPath : '/models/miewid.onnx' ,
379+ } ) ;
380+
381+ expect ( result . detections ) . toHaveLength ( 0 ) ;
382+ expect ( result . errors ) . toHaveLength ( 1 ) ;
383+ expect ( result . errors [ 0 ] ) . toMatchObject ( {
384+ species : null ,
385+ stage : 'embedding-model' ,
386+ message : expect . stringContaining ( 'MiewID file corrupt' ) ,
387+ } ) ;
388+ // No detector work is wasted when embeddings are impossible
389+ expect ( mockRunDetection ) . not . toHaveBeenCalled ( ) ;
390+ } ) ;
391+
392+ it ( 'should return an empty errors array on full success' , async ( ) => {
393+ mockRunDetection . mockResolvedValueOnce ( {
394+ results : [
395+ {
396+ boundingBox : { x : 0.1 , y : 0.2 , width : 0.3 , height : 0.4 } ,
397+ species : 'zebra_plains' ,
398+ confidence : 0.9 ,
399+ } ,
400+ ] ,
401+ inferenceTimeMs : 100 ,
402+ } ) ;
403+ mockExtractEmbedding . mockResolvedValueOnce ( {
404+ embedding : [ 0.1 , 0.2 , 0.3 ] ,
405+ inferenceTimeMs : 50 ,
406+ } ) ;
407+ mockMatchEmbedding . mockReturnValue ( [ ] ) ;
408+
409+ const result = await wildlifePipeline . processPhoto ( {
410+ photoUri : 'file:///photos/test.jpg' ,
411+ speciesConfigs : [ makeSpeciesConfig ( ) ] ,
412+ miewidModelPath : '/models/miewid.onnx' ,
413+ } ) ;
414+
415+ expect ( result . errors ) . toEqual ( [ ] ) ;
416+ expect ( result . detections ) . toHaveLength ( 1 ) ;
314417 } ) ;
315418
316419 it ( 'should accumulate inference time from detection and embedding' , async ( ) => {
0 commit comments