@@ -7,8 +7,8 @@ import type {
77 UploadUpdateItem ,
88} from '@backend/types/ws'
99import { type Image , type Item , UPLOAD_STATUS } from '@frontend/types/image'
10- import { type Mock , beforeAll , beforeEach , describe , expect , it , mock } from 'bun:test'
11- import { ref } from 'vue'
10+ import { type Mock , afterEach , beforeAll , beforeEach , describe , expect , it , mock } from 'bun:test'
11+ import { type EffectScope , effectScope , ref } from 'vue'
1212
1313type BatchesListData = Extract < ServerMessage , { type : 'BATCHES_LIST' } > [ 'data' ]
1414type BatchUploadsListData = Extract < ServerMessage , { type : 'BATCH_UPLOADS_LIST' } > [ 'data' ]
@@ -75,6 +75,7 @@ describe('useCollections Listeners', () => {
7575 let initCollectionsListeners : typeof InitCollectionsListenersType
7676 let listeners : ReturnType < typeof InitCollectionsListenersType >
7777 let store : ReturnType < typeof useCollectionsStore >
78+ let scope : EffectScope
7879
7980 beforeAll ( async ( ) => {
8081 const mod = await import ( '../useCollections' )
@@ -86,7 +87,12 @@ describe('useCollections Listeners', () => {
8687 store = useCollectionsStore ( )
8788 mockSend . mockClear ( )
8889 mockSocketConnected . value = false
89- listeners = initCollectionsListeners ( )
90+ scope = effectScope ( )
91+ listeners = scope . run ( ( ) => initCollectionsListeners ( ) ) !
92+ } )
93+
94+ afterEach ( ( ) => {
95+ scope . stop ( )
9096 } )
9197
9298 describe ( 'onUploadsUpdate' , ( ) => {
@@ -1090,6 +1096,7 @@ describe('useCollections Listeners', () => {
10901096
10911097 describe ( 'onBatchCreated' , ( ) => {
10921098 it ( 'should set batchId and send first slice' , ( ) => {
1099+ mockSocketConnected . value = true
10931100 // Mock selectedItems logic via items
10941101 const newItems : Record < string , Item > = { }
10951102 for ( let i = 0 ; i < 15 ; i ++ ) {
@@ -1110,7 +1117,7 @@ describe('useCollections Listeners', () => {
11101117 listeners . onBatchCreated ( 100 )
11111118
11121119 expect ( store . batchId ) . toBe ( 100 )
1113- expect ( store . uploadSliceIndex ) . toBe ( 0 )
1120+ expect ( store . ackedSliceIds . size ) . toBe ( 0 )
11141121
11151122 expect ( mockSend ) . toHaveBeenCalled ( )
11161123 const calls = ( mockSend as Mock < ( data : unknown ) => void > ) . mock . calls
@@ -1160,7 +1167,32 @@ describe('useCollections Listeners', () => {
11601167 expect ( store . batchId ) . toBe ( 0 )
11611168 } )
11621169
1170+ it ( 'does not send slices while offline, relying on the connected watcher to flush them later' , ( ) => {
1171+ mockSocketConnected . value = false
1172+ const newItems : Record < string , Item > = { }
1173+ for ( let i = 0 ; i < 15 ; i ++ ) {
1174+ const id = `img${ i } `
1175+ newItems [ id ] = createMockItem ( {
1176+ id,
1177+ meta : {
1178+ selected : true ,
1179+ license : '' ,
1180+ description : { value : '' , language : 'en' } ,
1181+ categories : '' ,
1182+ } ,
1183+ image : createMockImage ( { id } ) ,
1184+ } )
1185+ }
1186+ store . replaceItems ( newItems )
1187+
1188+ listeners . onBatchCreated ( 100 )
1189+
1190+ expect ( store . batchId ) . toBe ( 100 )
1191+ expect ( mockSend ) . not . toHaveBeenCalled ( )
1192+ } )
1193+
11631194 it ( `should handle exactly ${ UPLOAD_SLICE_SIZE } selected items (one full slice)` , ( ) => {
1195+ mockSocketConnected . value = true
11641196 const newItems : Record < string , Item > = { }
11651197 for ( let i = 0 ; i < UPLOAD_SLICE_SIZE ; i ++ ) {
11661198 const id = `img${ i } `
@@ -1180,7 +1212,7 @@ describe('useCollections Listeners', () => {
11801212 listeners . onBatchCreated ( 100 )
11811213
11821214 expect ( store . batchId ) . toBe ( 100 )
1183- expect ( store . uploadSliceIndex ) . toBe ( 0 )
1215+ expect ( store . ackedSliceIds . size ) . toBe ( 0 )
11841216 expect ( store . isBatchCreated ) . toBe ( false ) // Not created yet, need to wait for ACK
11851217
11861218 expect ( mockSend ) . toHaveBeenCalled ( )
@@ -1201,6 +1233,7 @@ describe('useCollections Listeners', () => {
12011233 } )
12021234
12031235 it ( `should send all slices immediately for ${ UPLOAD_SLICE_SIZE * 2 } selected items` , ( ) => {
1236+ mockSocketConnected . value = true
12041237 const newItems : Record < string , Item > = { }
12051238 for ( let i = 0 ; i < UPLOAD_SLICE_SIZE * 2 ; i ++ ) {
12061239 const id = `img${ i } `
@@ -1230,9 +1263,8 @@ describe('useCollections Listeners', () => {
12301263 } )
12311264
12321265 describe ( 'onUploadSliceAck' , ( ) => {
1233- it ( 'should increment uploadSliceIndex but not send the next slice ' , ( ) => {
1266+ it ( 'adds the slice id to ackedSliceIds and does not send anything else ' , ( ) => {
12341267 store . batchId = 100
1235- store . uploadSliceIndex = 0
12361268
12371269 const newItems : Record < string , Item > = { }
12381270 for ( let i = 0 ; i < UPLOAD_SLICE_SIZE + 5 ; i ++ ) {
@@ -1252,23 +1284,48 @@ describe('useCollections Listeners', () => {
12521284
12531285 listeners . onUploadSliceAck ( 0 , [ ] )
12541286
1255- expect ( store . uploadSliceIndex ) . toBe ( 1 )
1287+ expect ( store . ackedSliceIds . has ( 0 ) ) . toBe ( true )
12561288 expect ( mockSend ) . not . toHaveBeenCalled ( )
12571289 } )
12581290
1259- it ( 'should not send next slice if index does not match ' , ( ) => {
1291+ it ( 'ignores a duplicate ack for an already-acked slice id ' , ( ) => {
12601292 store . batchId = 100
1261- store . uploadSliceIndex = 1
1293+ store . ackedSliceIds = new Set ( [ 0 ] )
12621294
12631295 listeners . onUploadSliceAck ( 0 , [ ] )
12641296
1265- expect ( store . uploadSliceIndex ) . toBe ( 1 )
1297+ expect ( store . ackedSliceIds . size ) . toBe ( 1 )
12661298 expect ( mockSend ) . not . toHaveBeenCalled ( )
12671299 } )
12681300
1301+ it ( 'acks arriving out of order still complete the batch once all are seen' , ( ) => {
1302+ store . batchId = 100
1303+ const newItems : Record < string , Item > = { }
1304+ for ( let i = 0 ; i < UPLOAD_SLICE_SIZE * 3 ; i ++ ) {
1305+ const id = `img${ i } `
1306+ newItems [ id ] = createMockItem ( {
1307+ id,
1308+ meta : {
1309+ selected : true ,
1310+ license : '' ,
1311+ description : { value : '' , language : 'en' } ,
1312+ categories : '' ,
1313+ } ,
1314+ image : createMockImage ( { id } ) ,
1315+ } )
1316+ }
1317+ store . replaceItems ( newItems )
1318+
1319+ listeners . onUploadSliceAck ( 0 , [ ] )
1320+ listeners . onUploadSliceAck ( 2 , [ ] )
1321+ listeners . onUploadSliceAck ( 1 , [ ] )
1322+
1323+ expect ( store . ackedSliceIds . size ) . toBe ( 3 )
1324+ expect ( store . isBatchCreated ) . toBe ( true )
1325+ } )
1326+
12691327 it ( 'should update item statuses from ACK response' , ( ) => {
12701328 store . batchId = 100
1271- store . uploadSliceIndex = 0
12721329
12731330 const newItems : Record < string , Item > = { }
12741331 for ( let i = 0 ; i < 5 ; i ++ ) {
@@ -1295,7 +1352,7 @@ describe('useCollections Listeners', () => {
12951352
12961353 listeners . onUploadSliceAck ( 0 , ackItems )
12971354
1298- expect ( store . uploadSliceIndex ) . toBe ( 1 )
1355+ expect ( store . ackedSliceIds . has ( 0 ) ) . toBe ( true )
12991356 expect ( store . items . img0 ! . meta . status ) . toBe ( UPLOAD_STATUS . Queued )
13001357 expect ( store . items . img1 ! . meta . status ) . toBe ( UPLOAD_STATUS . Queued )
13011358 expect ( store . items . img2 ! . meta . status ) . toBe ( UPLOAD_STATUS . InProgress )
@@ -1304,6 +1361,7 @@ describe('useCollections Listeners', () => {
13041361 } )
13051362
13061363 it ( 'each sent slice has correct batchid, handler, and up to UPLOAD_SLICE_SIZE items' , ( ) => {
1364+ mockSocketConnected . value = true
13071365 store . input = 'input'
13081366 store . globalLicense = ''
13091367
@@ -1343,57 +1401,43 @@ describe('useCollections Listeners', () => {
13431401
13441402 it ( 'should handle undefined slice ID' , ( ) => {
13451403 store . batchId = 100
1346- store . uploadSliceIndex = 0
13471404
13481405 listeners . onUploadSliceAck ( undefined as unknown as number , [ ] )
13491406
1350- expect ( store . uploadSliceIndex ) . toBe ( 0 )
1407+ expect ( store . ackedSliceIds . size ) . toBe ( 0 )
13511408 expect ( mockSend ) . not . toHaveBeenCalled ( )
13521409 } )
13531410
13541411 it ( 'should handle null slice ID' , ( ) => {
13551412 store . batchId = 100
1356- store . uploadSliceIndex = 0
13571413
13581414 listeners . onUploadSliceAck ( null as unknown as number , [ ] )
13591415
1360- expect ( store . uploadSliceIndex ) . toBe ( 0 )
1416+ expect ( store . ackedSliceIds . size ) . toBe ( 0 )
13611417 expect ( mockSend ) . not . toHaveBeenCalled ( )
13621418 } )
13631419
13641420 it ( 'should handle NaN slice ID' , ( ) => {
13651421 store . batchId = 100
1366- store . uploadSliceIndex = 0
13671422
13681423 listeners . onUploadSliceAck ( NaN , [ ] )
13691424
1370- expect ( store . uploadSliceIndex ) . toBe ( 0 )
1425+ expect ( store . ackedSliceIds . size ) . toBe ( 0 )
13711426 expect ( mockSend ) . not . toHaveBeenCalled ( )
13721427 } )
13731428
13741429 it ( 'should handle negative slice ID' , ( ) => {
13751430 store . batchId = 100
1376- store . uploadSliceIndex = 0
13771431
13781432 listeners . onUploadSliceAck ( - 1 , [ ] )
13791433
1380- expect ( store . uploadSliceIndex ) . toBe ( 0 )
1381- expect ( mockSend ) . not . toHaveBeenCalled ( )
1382- } )
1383-
1384- it ( 'should handle slice ID when uploadSliceIndex is negative' , ( ) => {
1385- store . batchId = 100
1386- store . uploadSliceIndex = - 1
1387-
1388- listeners . onUploadSliceAck ( 0 , [ ] )
1389-
1390- expect ( store . uploadSliceIndex ) . toBe ( - 1 )
1434+ expect ( store . ackedSliceIds . size ) . toBe ( 0 )
13911435 expect ( mockSend ) . not . toHaveBeenCalled ( )
13921436 } )
13931437
13941438 it ( 'should complete all slices and subscribe to batch' , ( ) => {
13951439 store . batchId = 100
1396- store . uploadSliceIndex = 1
1440+ store . ackedSliceIds = new Set ( [ 0 ] )
13971441 store . isLoading = true
13981442
13991443 const newItems : Record < string , Item > = { }
@@ -1414,22 +1458,17 @@ describe('useCollections Listeners', () => {
14141458
14151459 listeners . onUploadSliceAck ( 1 , [ ] )
14161460
1417- expect ( store . uploadSliceIndex ) . toBe ( 2 )
1461+ expect ( store . ackedSliceIds . size ) . toBe ( 2 )
14181462 expect ( store . isBatchCreated ) . toBe ( true )
14191463 expect ( mockSend ) . toHaveBeenCalled ( )
14201464 const calls = ( mockSend as Mock < ( data : unknown ) => void > ) . mock . calls
1421- expect ( calls . length ) . toBeGreaterThan ( 0 )
14221465 const arg = calls [ calls . length - 1 ] ! [ 0 ]
14231466 const sentMsg = arg as ClientMessage
1424-
1425- // With UPLOAD_SLICE_SIZE + 5 items, slice index 2 means start at UPLOAD_SLICE_SIZE * 2,
1426- // which is >= UPLOAD_SLICE_SIZE + 5, so it should complete
1427- if ( sentMsg . type === 'UPLOAD_SLICE' ) {
1428- expect ( sentMsg . data . items ) . toHaveLength ( 0 ) // Empty slice, should trigger subscription
1429- }
1467+ expect ( sentMsg ) . toMatchObject ( { type : 'SUBSCRIBE_BATCH' , data : 100 } )
14301468 } )
14311469
14321470 it ( 'sets copyright_override true when item license is set' , ( ) => {
1471+ mockSocketConnected . value = true
14331472 store . input = 'input'
14341473 store . globalLicense = ''
14351474
@@ -1460,6 +1499,7 @@ describe('useCollections Listeners', () => {
14601499 } )
14611500
14621501 it ( 'sets copyright_override true when globalLicense is set' , ( ) => {
1502+ mockSocketConnected . value = true
14631503 store . input = 'input'
14641504 store . globalLicense = 'CC-BY-4.0'
14651505
@@ -1510,9 +1550,10 @@ describe('useCollections Listeners', () => {
15101550 }
15111551
15121552 it ( 'resends unACKed slices when reconnected during an active batch upload' , ( ) => {
1553+ mockSocketConnected . value = true
15131554 store . replaceItems ( makeItems ( UPLOAD_SLICE_SIZE * 3 ) )
15141555 store . batchId = 100
1515- store . uploadSliceIndex = 1
1556+ store . ackedSliceIds = new Set ( [ 0 ] )
15161557 store . isBatchCreated = false
15171558
15181559 listeners . onSocketReconnect ( )
@@ -1524,7 +1565,20 @@ describe('useCollections Listeners', () => {
15241565 expect ( ( sliceCalls [ 1 ] ! [ 0 ] as UploadSliceMsg ) . data . sliceid ) . toBe ( 2 )
15251566 } )
15261567
1568+ it ( 'does not send anything while still offline' , ( ) => {
1569+ mockSocketConnected . value = false
1570+ store . replaceItems ( makeItems ( UPLOAD_SLICE_SIZE * 3 ) )
1571+ store . batchId = 100
1572+ store . ackedSliceIds = new Set ( [ 0 ] )
1573+ store . isBatchCreated = false
1574+
1575+ listeners . onSocketReconnect ( )
1576+
1577+ expect ( mockSend ) . not . toHaveBeenCalled ( )
1578+ } )
1579+
15271580 it ( 'does nothing when no batch is in progress' , ( ) => {
1581+ mockSocketConnected . value = true
15281582 store . replaceItems ( makeItems ( UPLOAD_SLICE_SIZE * 2 ) )
15291583 store . batchId = null
15301584 store . isBatchCreated = false
@@ -1535,6 +1589,7 @@ describe('useCollections Listeners', () => {
15351589 } )
15361590
15371591 it ( 'does nothing when batch upload is already complete' , ( ) => {
1592+ mockSocketConnected . value = true
15381593 store . replaceItems ( makeItems ( UPLOAD_SLICE_SIZE * 2 ) )
15391594 store . batchId = 100
15401595 store . isBatchCreated = true
@@ -1545,9 +1600,10 @@ describe('useCollections Listeners', () => {
15451600 } )
15461601
15471602 it ( 'does nothing when all slices have been ACKed' , ( ) => {
1603+ mockSocketConnected . value = true
15481604 store . replaceItems ( makeItems ( UPLOAD_SLICE_SIZE ) )
15491605 store . batchId = 100
1550- store . uploadSliceIndex = 1
1606+ store . ackedSliceIds = new Set ( [ 0 ] )
15511607 store . isBatchCreated = false
15521608
15531609 listeners . onSocketReconnect ( )
0 commit comments