@@ -8,6 +8,10 @@ jest.mock('react-native-fs', () => ({
88 stat : jest . fn ( ) ,
99} ) ) ;
1010
11+ jest . mock ( '../../../src/services/packManager/validator' , ( ) => ( {
12+ validatePack : jest . fn ( ) ,
13+ } ) ) ;
14+
1115import RNFS from 'react-native-fs' ;
1216import { packManager } from '../../../src/services/packManager' ;
1317
@@ -162,6 +166,139 @@ describe('PackManager', () => {
162166 expect ( result [ 1 ] ) . toEqual ( [ 18 , 19 , 20 ] ) ;
163167 expect ( result [ 2 ] ) . toEqual ( [ 21 , 22 , 23 ] ) ;
164168 } ) ;
169+
170+ it ( 'should throw a RangeError when the requested range exceeds the buffer' , ( ) => {
171+ const embeddingDim = 3 ;
172+ const allEmbeddings = new Float32Array ( [ 0 , 1 , 2 , 3 , 4 , 5 ] ) ; // 2 vectors
173+
174+ const individual = {
175+ id : 'WB-BAD' ,
176+ name : null ,
177+ alternateId : null ,
178+ sex : null ,
179+ lifeStage : null ,
180+ firstSeen : null ,
181+ lastSeen : null ,
182+ encounterCount : 1 ,
183+ embeddingCount : 2 ,
184+ embeddingOffset : 1 , // (1 + 2) * 3 = 9 > 6
185+ referencePhotos : [ ] ,
186+ notes : null ,
187+ } ;
188+
189+ expect ( ( ) =>
190+ packManager . getEmbeddingsForIndividual ( allEmbeddings , individual , embeddingDim ) ,
191+ ) . toThrow ( RangeError ) ;
192+ } ) ;
193+
194+ it ( 'should throw a RangeError for a negative offset' , ( ) => {
195+ const allEmbeddings = new Float32Array ( [ 0 , 1 , 2 ] ) ;
196+
197+ const individual = {
198+ id : 'WB-NEG' ,
199+ name : null ,
200+ alternateId : null ,
201+ sex : null ,
202+ lifeStage : null ,
203+ firstSeen : null ,
204+ lastSeen : null ,
205+ encounterCount : 1 ,
206+ embeddingCount : 1 ,
207+ embeddingOffset : - 1 ,
208+ referencePhotos : [ ] ,
209+ notes : null ,
210+ } ;
211+
212+ expect ( ( ) =>
213+ packManager . getEmbeddingsForIndividual ( allEmbeddings , individual , 3 ) ,
214+ ) . toThrow ( RangeError ) ;
215+ } ) ;
216+ } ) ;
217+
218+ describe ( 'reconcilePacks' , ( ) => {
219+ const { validatePack } = require ( '../../../src/services/packManager/validator' ) ;
220+
221+ const makeStoredPack = ( overrides : Record < string , unknown > = { } ) => ( {
222+ id : 'pack-1' ,
223+ species : 'horse' ,
224+ featureClass : 'horse_wild+face' ,
225+ displayName : 'Horses' ,
226+ wildbookInstanceUrl : 'https://horses.wildbook.org' ,
227+ exportDate : '2026-04-25T00:00:00Z' ,
228+ individualCount : 5 ,
229+ embeddingDim : 2152 ,
230+ embeddingModelVersion : '4.1.0' ,
231+ detectorModelFile : '/mock/packs/horse/models/detector.onnx' ,
232+ embeddingsFile : '/mock/packs/horse/embeddings/embeddings.bin' ,
233+ indexFile : '/mock/packs/horse/embeddings/index.json' ,
234+ referencePhotosDir : '/mock/packs/horse/reference_photos' ,
235+ packDir : '/mock/packs/horse' ,
236+ downloadedAt : '2026-04-25T12:00:00Z' ,
237+ sizeBytes : 9_000_000 ,
238+ ...overrides ,
239+ } ) ;
240+
241+ it ( 'keeps a previously validated intact pack ready using cheap mode' , async ( ) => {
242+ validatePack . mockResolvedValue ( { ok : true , manifest : { } , individuals : [ ] } ) ;
243+ const pack = makeStoredPack ( {
244+ status : 'ready' ,
245+ validatedAt : '2026-04-25T12:00:00Z' ,
246+ } ) ;
247+
248+ const result = await packManager . reconcilePacks ( [ pack ] as never [ ] ) ;
249+
250+ expect ( validatePack ) . toHaveBeenCalledWith ( '/mock/packs/horse' , {
251+ skipChecksums : true ,
252+ } ) ;
253+ expect ( result [ 0 ] . status ) . toBe ( 'ready' ) ;
254+ } ) ;
255+
256+ it ( 'fully validates a pack that was never validated' , async ( ) => {
257+ validatePack . mockResolvedValue ( { ok : true , manifest : { } , individuals : [ ] } ) ;
258+ const pack = makeStoredPack ( ) ;
259+
260+ const result = await packManager . reconcilePacks ( [ pack ] as never [ ] ) ;
261+
262+ expect ( validatePack ) . toHaveBeenCalledWith ( '/mock/packs/horse' , {
263+ skipChecksums : false ,
264+ } ) ;
265+ expect ( result [ 0 ] . status ) . toBe ( 'ready' ) ;
266+ expect ( result [ 0 ] . validatedAt ) . toBeTruthy ( ) ;
267+ } ) ;
268+
269+ it ( 'quarantines a pack that fails validation and records the errors' , async ( ) => {
270+ validatePack . mockResolvedValue ( {
271+ ok : false ,
272+ errors : [
273+ { code : 'checksum-mismatch' , detail : 'embeddings.bin hash differs' } ,
274+ ] ,
275+ } ) ;
276+ const pack = makeStoredPack ( { status : 'ready' , validatedAt : '2026-04-25T12:00:00Z' } ) ;
277+
278+ const result = await packManager . reconcilePacks ( [ pack ] as never [ ] ) ;
279+
280+ expect ( result [ 0 ] . status ) . toBe ( 'quarantined' ) ;
281+ expect ( result [ 0 ] . validationErrors ) . toEqual ( [
282+ 'checksum-mismatch: embeddings.bin hash differs' ,
283+ ] ) ;
284+ } ) ;
285+
286+ it ( 'reconciles each pack independently' , async ( ) => {
287+ validatePack
288+ . mockResolvedValueOnce ( { ok : true , manifest : { } , individuals : [ ] } )
289+ . mockResolvedValueOnce ( {
290+ ok : false ,
291+ errors : [ { code : 'file-missing' , detail : 'embeddings.bin' } ] ,
292+ } ) ;
293+
294+ const result = await packManager . reconcilePacks ( [
295+ makeStoredPack ( { id : 'pack-good' } ) ,
296+ makeStoredPack ( { id : 'pack-bad' , packDir : '/mock/packs/bad' } ) ,
297+ ] as never [ ] ) ;
298+
299+ expect ( result [ 0 ] . status ) . toBe ( 'ready' ) ;
300+ expect ( result [ 1 ] . status ) . toBe ( 'quarantined' ) ;
301+ } ) ;
165302 } ) ;
166303
167304 describe ( 'deletePack' , ( ) => {
0 commit comments