@xiboplayer/cache 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,79 +1,132 @@
1
1
  /**
2
- * DownloadManager Tests
2
+ * DownloadManager Tests — Flat Queue Architecture
3
3
  *
4
- * Contract-based testing for DownloadTask, DownloadQueue, and DownloadManager
5
- * Tests state machines, concurrency control, and error handling
4
+ * Tests for:
5
+ * - DownloadTask: Single-fetch unit (one HTTP request)
6
+ * - FileDownload: Orchestrator (HEAD + creates tasks)
7
+ * - DownloadQueue: Flat queue with single concurrency limit
8
+ * - DownloadManager: Public facade
6
9
  */
7
10
 
8
11
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
9
- import { DownloadTask, DownloadQueue, DownloadManager } from './download-manager.js';
12
+ import { DownloadTask, FileDownload, DownloadQueue, DownloadManager, LayoutTaskBuilder, BARRIER, PRIORITY } from './download-manager.js';
10
13
  import { mockFetch, mockChunkedFetch, createTestBlob, waitFor, createSpy } from './test-utils.js';
11
14
 
15
+ // ============================================================================
16
+ // DownloadTask — Single HTTP fetch unit
17
+ // ============================================================================
18
+
12
19
  describe('DownloadTask', () => {
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ });
23
+
13
24
  describe('State Machine', () => {
14
25
  it('should start in pending state', () => {
15
26
  const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
16
27
 
17
- // Post-condition: Initial state
18
28
  expect(task.state).toBe('pending');
19
- expect(task.downloadedBytes).toBe(0);
20
- expect(task.totalBytes).toBe(0);
21
- expect(task.waiters.length).toBe(0);
29
+ expect(task.blob).toBeNull();
30
+ expect(task.chunkIndex).toBeNull();
22
31
  });
23
32
 
24
33
  it('should transition pending -> downloading -> complete', async () => {
25
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
26
34
  const testBlob = createTestBlob(1024);
35
+ const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
27
36
 
28
37
  mockFetch({
29
- 'HEAD http://test.com/file.mp4': {
30
- headers: { 'Content-Length': '1024' }
31
- },
32
- 'GET http://test.com/file.mp4': {
33
- blob: testBlob
34
- }
38
+ 'GET http://test.com/file.mp4': { blob: testBlob }
35
39
  });
36
40
 
37
- // Pre-condition
38
- expect(task.state).toBe('pending');
39
-
40
- // Start download
41
- const promise = task.start();
42
-
43
- // Should be downloading (but completes quickly in tests)
44
- await promise;
41
+ await task.start();
45
42
 
46
- // Post-condition
47
43
  expect(task.state).toBe('complete');
48
- expect(task.downloadedBytes).toBe(1024);
49
- expect(task.totalBytes).toBe(1024);
44
+ expect(task.blob).toBeInstanceOf(Blob);
45
+ expect(task.blob.size).toBe(1024);
50
46
  });
51
47
 
52
- it('should transition pending -> downloading -> failed on error', async () => {
48
+ it('should transition to failed on HTTP error after retries', async () => {
53
49
  const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
54
50
 
55
51
  mockFetch({
56
- 'HEAD http://test.com/file.mp4': {
57
- ok: false,
58
- status: 500,
59
- statusText: 'Server Error'
60
- }
52
+ 'GET http://test.com/file.mp4': { ok: false, status: 500 }
61
53
  });
62
54
 
63
- // Pre-condition
64
- expect(task.state).toBe('pending');
55
+ await expect(task.start()).rejects.toThrow('Fetch failed: 500');
56
+ expect(task.state).toBe('failed');
57
+ }, 5000); // Retry backoff: 500ms + 1s + 1.5s = 3s
58
+ });
59
+
60
+ describe('Range Requests', () => {
61
+ it('should send Range header for chunk tasks', async () => {
62
+ const sourceBlob = createTestBlob(200);
63
+ const task = new DownloadTask(
64
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4' },
65
+ { chunkIndex: 0, rangeStart: 0, rangeEnd: 99 }
66
+ );
65
67
 
66
- // Start download
67
- await expect(task.start()).rejects.toThrow();
68
+ const fetchMock = mockChunkedFetch(sourceBlob);
68
69
 
69
- // Post-condition
70
- expect(task.state).toBe('failed');
70
+ await task.start();
71
+
72
+ expect(task.chunkIndex).toBe(0);
73
+ expect(task.blob.size).toBe(100);
74
+
75
+ // Verify Range header was sent
76
+ const call = fetchMock.mock.calls.find(c => c[1]?.headers?.Range);
77
+ expect(call[1].headers.Range).toBe('bytes=0-99');
71
78
  });
72
79
  });
73
80
 
74
- describe('wait()', () => {
75
- it('should satisfy contract: returns Promise<Blob> when complete', async () => {
81
+ describe('Retry Logic', () => {
82
+ it('should retry on failure with exponential backoff', async () => {
83
+ const testBlob = createTestBlob(1024);
84
+ let attempts = 0;
85
+
86
+ global.fetch = vi.fn(async () => {
87
+ attempts++;
88
+ if (attempts < 3) {
89
+ return { ok: false, status: 503, headers: { get: () => null } };
90
+ }
91
+ return {
92
+ ok: true, status: 200,
93
+ headers: { get: () => null },
94
+ blob: () => Promise.resolve(testBlob)
95
+ };
96
+ });
97
+
76
98
  const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
99
+ await task.start();
100
+
101
+ expect(task.state).toBe('complete');
102
+ expect(attempts).toBe(3);
103
+ }, 5000); // Retry backoff: 500ms + 1s = 1.5s before 3rd attempt
104
+ });
105
+ });
106
+
107
+ // ============================================================================
108
+ // FileDownload — Orchestrator
109
+ // ============================================================================
110
+
111
+ describe('FileDownload', () => {
112
+ afterEach(() => {
113
+ vi.restoreAllMocks();
114
+ });
115
+
116
+ describe('State Machine', () => {
117
+ it('should start in pending state', () => {
118
+ const file = new FileDownload({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
119
+
120
+ expect(file.state).toBe('pending');
121
+ expect(file.downloadedBytes).toBe(0);
122
+ expect(file.totalBytes).toBe(0);
123
+ expect(file.totalChunks).toBe(0);
124
+ expect(file.onChunkDownloaded).toBeNull();
125
+ });
126
+ });
127
+
128
+ describe('Small File Downloads (≤ 100MB)', () => {
129
+ it('should create single task and resolve wait() with blob', async () => {
77
130
  const testBlob = createTestBlob(1024);
78
131
 
79
132
  mockFetch({
@@ -81,21 +134,66 @@ describe('DownloadTask', () => {
81
134
  'GET http://test.com/file.mp4': { blob: testBlob }
82
135
  });
83
136
 
84
- // Start download
85
- const startPromise = task.start();
137
+ const file = new FileDownload(
138
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4' }
139
+ );
140
+
141
+ // Simulate what DownloadQueue does
142
+ const mockQueue = { enqueueChunkTasks: vi.fn(), processQueue: vi.fn() };
143
+ mockQueue.enqueueChunkTasks.mockImplementation((tasks) => {
144
+ // Immediately start tasks (simulate queue processing)
145
+ for (const task of tasks) {
146
+ task.start()
147
+ .then(() => task._parentFile.onTaskComplete(task))
148
+ .catch(err => task._parentFile.onTaskFailed(task, err));
149
+ }
150
+ });
151
+
152
+ await file.prepare(mockQueue);
153
+
154
+ expect(file.tasks.length).toBe(1);
155
+ expect(file.tasks[0].chunkIndex).toBeNull(); // Full file, not a chunk
86
156
 
87
- // Wait for completion
88
- const blob = await task.wait();
157
+ const blob = await file.wait();
89
158
 
90
- // Post-condition: Returns blob
159
+ expect(file.state).toBe('complete');
91
160
  expect(blob).toBeInstanceOf(Blob);
92
161
  expect(blob.size).toBe(1024);
162
+ expect(file.downloadedBytes).toBe(1024);
163
+ });
164
+
165
+ it('should update downloadedBytes correctly', async () => {
166
+ const testBlob = createTestBlob(5000);
93
167
 
94
- await startPromise; // Ensure start completes
168
+ mockFetch({
169
+ 'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '5000' } },
170
+ 'GET http://test.com/file.mp4': { blob: testBlob }
171
+ });
172
+
173
+ const file = new FileDownload(
174
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4' }
175
+ );
176
+
177
+ const mockQueue = {
178
+ enqueueChunkTasks: vi.fn((tasks) => {
179
+ for (const task of tasks) {
180
+ task.start()
181
+ .then(() => task._parentFile.onTaskComplete(task))
182
+ .catch(err => task._parentFile.onTaskFailed(task, err));
183
+ }
184
+ })
185
+ };
186
+
187
+ await file.prepare(mockQueue);
188
+ await file.wait();
189
+
190
+ expect(file.downloadedBytes).toBe(5000);
191
+ expect(file.totalBytes).toBe(5000);
95
192
  });
193
+ });
96
194
 
97
- it('should support multiple waiters', async () => {
98
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
195
+ describe('wait()', () => {
196
+ it('should support multiple concurrent waiters', async () => {
99
197
  const testBlob = createTestBlob(1024);
100
198
 
101
199
  mockFetch({
@@ -103,127 +201,449 @@ describe('DownloadTask', () => {
103
201
  'GET http://test.com/file.mp4': { blob: testBlob }
104
202
  });
105
203
 
106
- // Multiple waiters before start
107
- const waiter1 = task.wait();
108
- const waiter2 = task.wait();
109
- const waiter3 = task.wait();
204
+ const file = new FileDownload(
205
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4' }
206
+ );
207
+
208
+ // Set up waiters before download starts
209
+ const waiter1 = file.wait();
210
+ const waiter2 = file.wait();
211
+ const waiter3 = file.wait();
110
212
 
111
- expect(task.waiters.length).toBe(3);
213
+ const mockQueue = {
214
+ enqueueChunkTasks: vi.fn((tasks) => {
215
+ for (const task of tasks) {
216
+ task.start()
217
+ .then(() => task._parentFile.onTaskComplete(task))
218
+ .catch(err => task._parentFile.onTaskFailed(task, err));
219
+ }
220
+ })
221
+ };
112
222
 
113
- // Start download
114
- await task.start();
223
+ await file.prepare(mockQueue);
115
224
 
116
225
  // All waiters resolve with same blob
117
226
  const [blob1, blob2, blob3] = await Promise.all([waiter1, waiter2, waiter3]);
118
-
119
227
  expect(blob1).toBe(blob2);
120
228
  expect(blob2).toBe(blob3);
121
- expect(task.waiters.length).toBe(0);
229
+ expect(blob1.size).toBe(1024);
122
230
  });
123
231
 
124
- it('should return immediately if already complete', async () => {
125
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
126
- const testBlob = createTestBlob(1024);
127
-
128
- mockFetch({
129
- 'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
130
- 'GET http://test.com/file.mp4': { blob: testBlob }
232
+ it('should still create download task when HEAD fails (graceful fallback)', async () => {
233
+ // HEAD failure is non-fatal the GET will be attempted
234
+ const testBlob = createTestBlob(512);
235
+ global.fetch = vi.fn(async (url, opts) => {
236
+ if (opts?.method === 'HEAD') {
237
+ return { ok: false, status: 404, headers: { get: () => null } };
238
+ }
239
+ return {
240
+ ok: true, status: 200,
241
+ headers: { get: () => null },
242
+ blob: () => Promise.resolve(testBlob)
243
+ };
131
244
  });
132
245
 
133
- await task.start();
246
+ const file = new FileDownload(
247
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4' }
248
+ );
134
249
 
135
- // Post-condition: wait() after completion returns immediately
136
- const blob = await task.wait();
137
- expect(blob).toBeInstanceOf(Blob);
250
+ const waiter1 = file.wait();
251
+ const waiter2 = file.wait();
252
+
253
+ const mockQueue = {
254
+ enqueueChunkTasks: vi.fn((tasks) => {
255
+ for (const task of tasks) {
256
+ task.start()
257
+ .then(() => task._parentFile.onTaskComplete(task))
258
+ .catch(err => task._parentFile.onTaskFailed(task, err));
259
+ }
260
+ })
261
+ };
262
+ await file.prepare(mockQueue);
263
+
264
+ // Both waiters resolve (GET succeeds despite HEAD failing)
265
+ const [blob1, blob2] = await Promise.all([waiter1, waiter2]);
266
+ expect(blob1).toBe(blob2);
267
+ expect(file.state).toBe('complete');
138
268
  });
269
+ });
139
270
 
140
- it('should reject all waiters on failure', async () => {
141
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
271
+ describe('Error Handling', () => {
272
+ it('should handle network errors gracefully', async () => {
273
+ global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
142
274
 
143
- mockFetch({
144
- 'HEAD http://test.com/file.mp4': { ok: false, status: 404 }
275
+ const file = new FileDownload(
276
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4' }
277
+ );
278
+
279
+ const mockQueue = { enqueueChunkTasks: vi.fn() };
280
+ await file.prepare(mockQueue);
281
+
282
+ await expect(file.wait()).rejects.toThrow('Network error');
283
+ expect(file.state).toBe('failed');
284
+ });
285
+
286
+ it('should fail when both HEAD and GET fail', async () => {
287
+ // When HEAD fails (size unknown) and GET also fails → download fails
288
+ global.fetch = vi.fn(async () => {
289
+ return { ok: false, status: 500, headers: { get: () => null } };
145
290
  });
146
291
 
147
- const waiter1 = task.wait();
148
- const waiter2 = task.wait();
292
+ const file = new FileDownload(
293
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4' }
294
+ );
295
+
296
+ const mockQueue = {
297
+ enqueueChunkTasks: vi.fn((tasks) => {
298
+ for (const task of tasks) {
299
+ task.start()
300
+ .then(() => task._parentFile.onTaskComplete(task))
301
+ .catch(err => task._parentFile.onTaskFailed(task, err));
302
+ }
303
+ })
304
+ };
305
+ await file.prepare(mockQueue);
306
+
307
+ await expect(file.wait()).rejects.toThrow('Fetch failed: 500');
308
+ expect(file.state).toBe('failed');
309
+ }, 10000);
310
+ });
311
+ });
312
+
313
+ // ============================================================================
314
+ // FileDownload - Skip HEAD when size is known
315
+ // ============================================================================
149
316
 
150
- // Start (will fail)
151
- try {
152
- await task.start();
153
- } catch (e) {
154
- // Expected
317
+ describe('FileDownload - Skip HEAD', () => {
318
+ afterEach(() => {
319
+ vi.restoreAllMocks();
320
+ });
321
+
322
+ it('should skip HEAD when fileInfo.size is provided', async () => {
323
+ const testBlob = createTestBlob(1024);
324
+ const fetchSpy = vi.fn(async (url, opts) => {
325
+ return {
326
+ ok: true, status: 200,
327
+ headers: { get: () => null },
328
+ blob: () => Promise.resolve(testBlob)
329
+ };
330
+ });
331
+ global.fetch = fetchSpy;
332
+
333
+ const file = new FileDownload(
334
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4', size: 1024 }
335
+ );
336
+
337
+ const mockQueue = {
338
+ enqueueChunkTasks: vi.fn((tasks) => {
339
+ for (const task of tasks) {
340
+ task.start()
341
+ .then(() => task._parentFile.onTaskComplete(task))
342
+ .catch(err => task._parentFile.onTaskFailed(task, err));
343
+ }
344
+ })
345
+ };
346
+
347
+ await file.prepare(mockQueue);
348
+ await file.wait();
349
+
350
+ // No HEAD request should have been made — only GET
351
+ const headCalls = fetchSpy.mock.calls.filter(c => c[1]?.method === 'HEAD');
352
+ expect(headCalls.length).toBe(0);
353
+ expect(file.totalBytes).toBe(1024);
354
+ expect(file.state).toBe('complete');
355
+ });
356
+
357
+ it('should fall back to HEAD when size is 0', async () => {
358
+ const testBlob = createTestBlob(2048);
359
+ global.fetch = vi.fn(async (url, opts) => {
360
+ if (opts?.method === 'HEAD') {
361
+ return {
362
+ ok: true, status: 200,
363
+ headers: { get: (name) => name === 'Content-Length' ? '2048' : 'video/mp4' }
364
+ };
155
365
  }
366
+ return {
367
+ ok: true, status: 200,
368
+ headers: { get: () => null },
369
+ blob: () => Promise.resolve(testBlob)
370
+ };
371
+ });
372
+
373
+ const file = new FileDownload(
374
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4', size: 0 }
375
+ );
376
+
377
+ const mockQueue = {
378
+ enqueueChunkTasks: vi.fn((tasks) => {
379
+ for (const task of tasks) {
380
+ task.start()
381
+ .then(() => task._parentFile.onTaskComplete(task))
382
+ .catch(err => task._parentFile.onTaskFailed(task, err));
383
+ }
384
+ })
385
+ };
386
+
387
+ await file.prepare(mockQueue);
388
+ await file.wait();
156
389
 
157
- // All waiters rejected
158
- await expect(waiter1).rejects.toThrow();
159
- await expect(waiter2).rejects.toThrow();
160
- expect(task.waiters.length).toBe(0);
390
+ // HEAD was called as fallback
391
+ const headCalls = global.fetch.mock.calls.filter(c => c[1]?.method === 'HEAD');
392
+ expect(headCalls.length).toBe(1);
393
+ expect(file.totalBytes).toBe(2048);
394
+ });
395
+
396
+ it('should fall back to HEAD when size is missing', async () => {
397
+ const testBlob = createTestBlob(512);
398
+ global.fetch = vi.fn(async (url, opts) => {
399
+ if (opts?.method === 'HEAD') {
400
+ return {
401
+ ok: true, status: 200,
402
+ headers: { get: (name) => name === 'Content-Length' ? '512' : null }
403
+ };
404
+ }
405
+ return {
406
+ ok: true, status: 200,
407
+ headers: { get: () => null },
408
+ blob: () => Promise.resolve(testBlob)
409
+ };
161
410
  });
411
+
412
+ const file = new FileDownload(
413
+ { id: '1', type: 'media', path: 'http://test.com/file.mp4' }
414
+ );
415
+
416
+ const mockQueue = {
417
+ enqueueChunkTasks: vi.fn((tasks) => {
418
+ for (const task of tasks) {
419
+ task.start()
420
+ .then(() => task._parentFile.onTaskComplete(task))
421
+ .catch(err => task._parentFile.onTaskFailed(task, err));
422
+ }
423
+ })
424
+ };
425
+
426
+ await file.prepare(mockQueue);
427
+
428
+ const headCalls = global.fetch.mock.calls.filter(c => c[1]?.method === 'HEAD');
429
+ expect(headCalls.length).toBe(1);
162
430
  });
163
431
 
164
- describe('Small File Downloads (<100MB)', () => {
165
- it('should download in single request', async () => {
166
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/small.mp4' });
167
- const testBlob = createTestBlob(10 * 1024 * 1024); // 10MB
432
+ it('should infer content type from file extension', async () => {
433
+ global.fetch = vi.fn(async () => ({
434
+ ok: true, status: 200,
435
+ headers: { get: () => null },
436
+ blob: () => Promise.resolve(createTestBlob(100))
437
+ }));
438
+
439
+ const file = new FileDownload(
440
+ { id: '1', type: 'media', path: 'http://test.com/image.png', size: 100 }
441
+ );
442
+
443
+ const mockQueue = {
444
+ enqueueChunkTasks: vi.fn((tasks) => {
445
+ for (const task of tasks) {
446
+ task.start()
447
+ .then(() => task._parentFile.onTaskComplete(task))
448
+ .catch(err => task._parentFile.onTaskFailed(task, err));
449
+ }
450
+ })
451
+ };
168
452
 
169
- const fetchMock = mockFetch({
170
- 'HEAD http://test.com/small.mp4': {
171
- headers: { 'Content-Length': String(10 * 1024 * 1024) }
172
- },
173
- 'GET http://test.com/small.mp4': { blob: testBlob }
174
- });
453
+ await file.prepare(mockQueue);
175
454
 
176
- await task.start();
455
+ expect(file._contentType).toBe('image/png');
456
+ });
457
+ });
177
458
 
178
- // Verify single GET request (plus HEAD)
179
- const getCalls = fetchMock.mock.calls.filter(call => {
180
- const options = call[1];
181
- return !options || options.method !== 'HEAD';
182
- });
183
- expect(getCalls.length).toBe(1);
184
- expect(task.blob.size).toBe(10 * 1024 * 1024);
459
+ // ============================================================================
460
+ // FileDownload - Progressive Streaming
461
+ // ============================================================================
462
+
463
+ describe('FileDownload - Progressive Streaming', () => {
464
+ afterEach(() => {
465
+ vi.restoreAllMocks();
466
+ });
467
+
468
+ describe('onChunkDownloaded callback', () => {
469
+ it('should initialize onChunkDownloaded as null', () => {
470
+ const file = new FileDownload({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
471
+ expect(file.onChunkDownloaded).toBeNull();
185
472
  });
186
473
 
187
- it('should update downloadedBytes correctly', async () => {
188
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
189
- const testBlob = createTestBlob(5000);
474
+ it('should call onChunkDownloaded for each chunk during chunked download', async () => {
475
+ // 200MB file will use chunks (threshold is 100MB)
476
+ const fileSize = 200 * 1024 * 1024;
477
+ const sourceBlob = createTestBlob(fileSize, 'video/mp4');
190
478
 
191
- mockFetch({
192
- 'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '5000' } },
193
- 'GET http://test.com/file.mp4': { blob: testBlob }
479
+ mockChunkedFetch(sourceBlob);
480
+
481
+ const file = new FileDownload(
482
+ { id: '1', type: 'media', path: 'http://test.com/big.mp4' },
483
+ { chunkSize: 50 * 1024 * 1024 }
484
+ );
485
+
486
+ const chunkCalls = [];
487
+ file.onChunkDownloaded = vi.fn(async (index, blob, total) => {
488
+ chunkCalls.push({ index, size: blob.size, total });
194
489
  });
195
490
 
196
- await task.start();
491
+ const mockQueue = {
492
+ enqueueChunkTasks: vi.fn((tasks) => {
493
+ for (const task of tasks) {
494
+ task.start()
495
+ .then(() => task._parentFile.onTaskComplete(task))
496
+ .catch(err => task._parentFile.onTaskFailed(task, err));
497
+ }
498
+ })
499
+ };
500
+
501
+ await file.prepare(mockQueue);
502
+ await file.wait();
503
+
504
+ // 200MB / 50MB = 4 chunks
505
+ expect(file.onChunkDownloaded).toHaveBeenCalledTimes(4);
506
+ expect(chunkCalls.length).toBe(4);
507
+
508
+ for (const call of chunkCalls) {
509
+ expect(call.total).toBe(4);
510
+ expect(call.size).toBeGreaterThan(0);
511
+ }
512
+
513
+ // All chunk indices should be present
514
+ const indices = chunkCalls.map(c => c.index).sort();
515
+ expect(indices).toEqual([0, 1, 2, 3]);
516
+ });
517
+
518
+ it('should return empty blob when onChunkDownloaded is set', async () => {
519
+ const fileSize = 200 * 1024 * 1024;
520
+ const sourceBlob = createTestBlob(fileSize, 'video/mp4');
521
+
522
+ mockChunkedFetch(sourceBlob);
523
+
524
+ const file = new FileDownload(
525
+ { id: '1', type: 'media', path: 'http://test.com/big.mp4' },
526
+ { chunkSize: 50 * 1024 * 1024 }
527
+ );
528
+
529
+ file.onChunkDownloaded = vi.fn(async () => {});
530
+
531
+ const mockQueue = {
532
+ enqueueChunkTasks: vi.fn((tasks) => {
533
+ for (const task of tasks) {
534
+ task.start()
535
+ .then(() => task._parentFile.onTaskComplete(task))
536
+ .catch(err => task._parentFile.onTaskFailed(task, err));
537
+ }
538
+ })
539
+ };
540
+
541
+ await file.prepare(mockQueue);
542
+ const blob = await file.wait();
197
543
 
198
- // Invariant: downloadedBytes = totalBytes after completion
199
- expect(task.downloadedBytes).toBe(task.totalBytes);
200
- expect(task.downloadedBytes).toBe(5000);
544
+ expect(blob.size).toBe(0);
545
+ expect(file.state).toBe('complete');
201
546
  });
202
- });
203
547
 
204
- describe('Error Handling', () => {
205
- it('should handle network errors gracefully', async () => {
206
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
548
+ it('should return full blob when onChunkDownloaded is NOT set', async () => {
549
+ const fileSize = 200 * 1024 * 1024;
550
+ const sourceBlob = createTestBlob(fileSize, 'video/mp4');
207
551
 
208
- global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
552
+ mockChunkedFetch(sourceBlob);
209
553
 
210
- await expect(task.start()).rejects.toThrow('Network error');
211
- expect(task.state).toBe('failed');
554
+ const file = new FileDownload(
555
+ { id: '1', type: 'media', path: 'http://test.com/big.mp4' },
556
+ { chunkSize: 50 * 1024 * 1024 }
557
+ );
558
+
559
+ // No callback → traditional reassembly
560
+ const mockQueue = {
561
+ enqueueChunkTasks: vi.fn((tasks) => {
562
+ for (const task of tasks) {
563
+ task.start()
564
+ .then(() => task._parentFile.onTaskComplete(task))
565
+ .catch(err => task._parentFile.onTaskFailed(task, err));
566
+ }
567
+ })
568
+ };
569
+
570
+ await file.prepare(mockQueue);
571
+ const blob = await file.wait();
572
+
573
+ expect(blob.size).toBe(fileSize);
574
+ expect(file.state).toBe('complete');
212
575
  });
213
576
 
214
- it('should handle HTTP errors', async () => {
215
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
577
+ it('should not call onChunkDownloaded for small files (single request)', async () => {
578
+ const fileSize = 10 * 1024 * 1024; // 10MB - below 100MB threshold
579
+ const sourceBlob = createTestBlob(fileSize);
216
580
 
217
- mockFetch({
218
- 'HEAD http://test.com/file.mp4': { ok: false, status: 404, statusText: 'Not Found' }
581
+ mockChunkedFetch(sourceBlob);
582
+
583
+ const file = new FileDownload(
584
+ { id: '1', type: 'media', path: 'http://test.com/small.mp4' },
585
+ { chunkSize: 50 * 1024 * 1024 }
586
+ );
587
+
588
+ file.onChunkDownloaded = vi.fn(async () => {});
589
+
590
+ const mockQueue = {
591
+ enqueueChunkTasks: vi.fn((tasks) => {
592
+ for (const task of tasks) {
593
+ task.start()
594
+ .then(() => task._parentFile.onTaskComplete(task))
595
+ .catch(err => task._parentFile.onTaskFailed(task, err));
596
+ }
597
+ })
598
+ };
599
+
600
+ await file.prepare(mockQueue);
601
+ const blob = await file.wait();
602
+
603
+ // Small file uses single task → callback not called
604
+ expect(file.onChunkDownloaded).not.toHaveBeenCalled();
605
+ expect(blob.size).toBe(fileSize);
606
+ });
607
+
608
+ it('should handle async callback errors gracefully', async () => {
609
+ const fileSize = 200 * 1024 * 1024;
610
+ const sourceBlob = createTestBlob(fileSize, 'video/mp4');
611
+
612
+ mockChunkedFetch(sourceBlob);
613
+
614
+ const file = new FileDownload(
615
+ { id: '1', type: 'media', path: 'http://test.com/big.mp4' },
616
+ { chunkSize: 50 * 1024 * 1024 }
617
+ );
618
+
619
+ // Callback throws — should not crash download
620
+ file.onChunkDownloaded = vi.fn(async () => {
621
+ throw new Error('Cache storage failed');
219
622
  });
220
623
 
221
- await expect(task.start()).rejects.toThrow('HEAD request failed: 404');
222
- expect(task.state).toBe('failed');
624
+ const mockQueue = {
625
+ enqueueChunkTasks: vi.fn((tasks) => {
626
+ for (const task of tasks) {
627
+ task.start()
628
+ .then(() => task._parentFile.onTaskComplete(task))
629
+ .catch(err => task._parentFile.onTaskFailed(task, err));
630
+ }
631
+ })
632
+ };
633
+
634
+ await file.prepare(mockQueue);
635
+ await file.wait();
636
+
637
+ expect(file.state).toBe('complete');
638
+ expect(file.onChunkDownloaded).toHaveBeenCalledTimes(4);
223
639
  });
224
640
  });
225
641
  });
226
642
 
643
+ // ============================================================================
644
+ // DownloadQueue
645
+ // ============================================================================
646
+
227
647
  describe('DownloadQueue', () => {
228
648
  afterEach(() => {
229
649
  vi.restoreAllMocks();
@@ -264,15 +684,11 @@ describe('DownloadQueue', () => {
264
684
  queue.enqueue({ id: '4', type: 'media', path: 'http://test.com/file4.mp4' });
265
685
  queue.enqueue({ id: '5', type: 'media', path: 'http://test.com/file5.mp4' });
266
686
 
267
- // Wait a bit for processQueue to run
268
- await new Promise(resolve => setTimeout(resolve, 50));
687
+ // Wait for HEAD requests + queue processing to start
688
+ await new Promise(resolve => setTimeout(resolve, 200));
269
689
 
270
690
  // Invariant: running <= concurrency
271
691
  expect(queue.running).toBeLessThanOrEqual(2);
272
- expect(queue.running).toBeGreaterThan(0); // Some should be running
273
-
274
- // Queue should have pending items
275
- expect(queue.queue.length + queue.running).toBeGreaterThan(2);
276
692
  });
277
693
 
278
694
  it('should process queue as tasks complete', async () => {
@@ -288,43 +704,59 @@ describe('DownloadQueue', () => {
288
704
  'GET http://test.com/file3.mp4': { blob: testBlob }
289
705
  });
290
706
 
291
- const task1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
292
- const task2 = queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
293
- const task3 = queue.enqueue({ id: '3', type: 'media', path: 'http://test.com/file3.mp4' });
707
+ const file1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
708
+ const file2 = queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
709
+ const file3 = queue.enqueue({ id: '3', type: 'media', path: 'http://test.com/file3.mp4' });
294
710
 
295
711
  // Wait for all to complete
296
- await Promise.all([task1.wait(), task2.wait(), task3.wait()]);
712
+ await Promise.all([file1.wait(), file2.wait(), file3.wait()]);
297
713
 
298
- // Post-condition: all complete
714
+ // Post-condition: all complete, files stay in active until removeCompleted()
299
715
  expect(queue.running).toBe(0);
300
716
  expect(queue.queue.length).toBe(0);
717
+ expect(queue.active.size).toBe(3);
718
+
719
+ // Simulate caller removing after caching
720
+ queue.removeCompleted('media/1');
721
+ queue.removeCompleted('media/2');
722
+ queue.removeCompleted('media/3');
301
723
  expect(queue.active.size).toBe(0);
302
724
  });
303
725
  });
304
726
 
305
727
  describe('Idempotent Enqueue', () => {
306
- it('should return same task for duplicate URLs', async () => {
728
+ it('should return same FileDownload for duplicate file IDs', async () => {
307
729
  const queue = new DownloadQueue();
308
730
 
309
- // Mock fetch to prevent actual downloads
310
731
  const testBlob = createTestBlob(1024);
311
732
  mockFetch({
312
733
  'HEAD http://test.com/file.mp4': { headers: { 'Content-Length': '1024' } },
313
734
  'GET http://test.com/file.mp4': { blob: testBlob }
314
735
  });
315
736
 
316
- const task1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
317
- const task2 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
737
+ const file1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
738
+ const file2 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
318
739
 
319
- // Should be same task instance
320
- expect(task1).toBe(task2);
740
+ expect(file1).toBe(file2);
321
741
  expect(queue.active.size).toBe(1);
742
+ });
743
+
744
+ it('should deduplicate same file with different signed URLs', () => {
745
+ const queue = new DownloadQueue();
746
+ const testBlob = createTestBlob(1024);
747
+ mockFetch({
748
+ 'HEAD http://test.com/file.mp4?token=abc': { headers: { 'Content-Length': '1024' } },
749
+ 'GET http://test.com/file.mp4?token=abc': { blob: testBlob }
750
+ });
322
751
 
323
- // Queue length might be 0 if the task already started
324
- expect(queue.queue.length + queue.running).toBeGreaterThanOrEqual(0);
752
+ const file1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4?token=abc' });
753
+ const file2 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4?token=xyz' });
754
+
755
+ expect(file1).toBe(file2);
756
+ expect(queue.active.size).toBe(1);
325
757
  });
326
758
 
327
- it('should create different tasks for different URLs', () => {
759
+ it('should create different FileDownloads for different file IDs', () => {
328
760
  const queue = new DownloadQueue();
329
761
  const testBlob = createTestBlob(1024);
330
762
  mockFetch({
@@ -334,16 +766,16 @@ describe('DownloadQueue', () => {
334
766
  'GET http://test.com/file2.mp4': { blob: testBlob }
335
767
  });
336
768
 
337
- const task1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
338
- const task2 = queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
769
+ const file1 = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file1.mp4' });
770
+ const file2 = queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
339
771
 
340
- expect(task1).not.toBe(task2);
772
+ expect(file1).not.toBe(file2);
341
773
  expect(queue.active.size).toBe(2);
342
774
  });
343
775
  });
344
776
 
345
777
  describe('getTask()', () => {
346
- it('should return active task', () => {
778
+ it('should return active FileDownload', () => {
347
779
  const queue = new DownloadQueue();
348
780
  const testBlob = createTestBlob(1024);
349
781
  mockFetch({
@@ -351,23 +783,20 @@ describe('DownloadQueue', () => {
351
783
  'GET http://test.com/file.mp4': { blob: testBlob }
352
784
  });
353
785
 
354
- const task = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
355
- const retrieved = queue.getTask('http://test.com/file.mp4');
786
+ const file = queue.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
787
+ const retrieved = queue.getTask('media/1');
356
788
 
357
- expect(retrieved).toBe(task);
789
+ expect(retrieved).toBe(file);
358
790
  });
359
791
 
360
792
  it('should return null for non-existent task', () => {
361
793
  const queue = new DownloadQueue();
362
-
363
- const retrieved = queue.getTask('http://test.com/nonexistent.mp4');
364
-
365
- expect(retrieved).toBeNull();
794
+ expect(queue.getTask('media/999')).toBeNull();
366
795
  });
367
796
  });
368
797
 
369
798
  describe('clear()', () => {
370
- it('should clear queue and active tasks', () => {
799
+ it('should clear queue and active files', () => {
371
800
  const queue = new DownloadQueue();
372
801
  const testBlob = createTestBlob(1024);
373
802
  mockFetch({
@@ -383,10 +812,8 @@ describe('DownloadQueue', () => {
383
812
  queue.enqueue({ id: '2', type: 'media', path: 'http://test.com/file2.mp4' });
384
813
  queue.enqueue({ id: '3', type: 'media', path: 'http://test.com/file3.mp4' });
385
814
 
386
- // Clear
387
815
  queue.clear();
388
816
 
389
- // Post-condition: everything cleared
390
817
  expect(queue.queue.length).toBe(0);
391
818
  expect(queue.active.size).toBe(0);
392
819
  expect(queue.running).toBe(0);
@@ -394,6 +821,93 @@ describe('DownloadQueue', () => {
394
821
  });
395
822
  });
396
823
 
824
+ // ============================================================================
825
+ // DownloadQueue - Priority
826
+ // ============================================================================
827
+
828
+ describe('DownloadQueue - Priority', () => {
829
+ afterEach(() => {
830
+ vi.restoreAllMocks();
831
+ });
832
+
833
+ describe('prioritize()', () => {
834
+ it('should move queued file tasks to front', () => {
835
+ const queue = new DownloadQueue({ concurrency: 0 }); // Prevent auto-start
836
+
837
+ // Create FileDownloads and their tasks manually
838
+ const file1 = new FileDownload({ id: '1', type: 'media', path: 'http://a' });
839
+ const file2 = new FileDownload({ id: '2', type: 'media', path: 'http://b' });
840
+ const file3 = new FileDownload({ id: '3', type: 'media', path: 'http://c' });
841
+ file3.totalChunks = 1;
842
+
843
+ const task1 = new DownloadTask({ id: '1', type: 'media', path: 'http://a' });
844
+ task1._parentFile = file1;
845
+ const task2 = new DownloadTask({ id: '2', type: 'media', path: 'http://b' });
846
+ task2._parentFile = file2;
847
+ const task3 = new DownloadTask({ id: '3', type: 'media', path: 'http://c' });
848
+ task3._parentFile = file3;
849
+
850
+ queue.queue = [task1, task2, task3];
851
+ queue.active.set('media/1', file1);
852
+ queue.active.set('media/2', file2);
853
+ queue.active.set('media/3', file3);
854
+
855
+ // task3 is at position 2 → prioritize it
856
+ const found = queue.prioritize('media', '3');
857
+
858
+ expect(found).toBe(true);
859
+ expect(queue.queue[0]).toBe(task3);
860
+ expect(queue.queue[1]).toBe(task1);
861
+ expect(queue.queue[2]).toBe(task2);
862
+ });
863
+
864
+ it('should return true if file is already downloading', () => {
865
+ const queue = new DownloadQueue();
866
+ const file = new FileDownload({ id: '5', type: 'media', path: 'http://x' });
867
+ file.state = 'downloading';
868
+ queue.active.set('media/5', file);
869
+
870
+ const found = queue.prioritize('media', '5');
871
+ expect(found).toBe(true);
872
+ });
873
+
874
+ it('should return false if file not found', () => {
875
+ const queue = new DownloadQueue();
876
+ expect(queue.prioritize('media', '999')).toBe(false);
877
+ });
878
+
879
+ it('should sort chunk 0 and last chunk to absolute front', () => {
880
+ const queue = new DownloadQueue({ concurrency: 0 });
881
+
882
+ const file = new FileDownload({ id: '1', type: 'media', path: 'http://a' });
883
+ file.totalChunks = 5;
884
+ queue.active.set('media/1', file);
885
+
886
+ // Create chunk tasks in shuffled order
887
+ const chunks = [2, 4, 0, 1, 3].map(idx => {
888
+ const task = new DownloadTask(
889
+ { id: '1', type: 'media', path: 'http://a' },
890
+ { chunkIndex: idx, rangeStart: idx * 100, rangeEnd: (idx + 1) * 100 - 1 }
891
+ );
892
+ task._parentFile = file;
893
+ return task;
894
+ });
895
+
896
+ queue.queue = [...chunks];
897
+
898
+ queue.prioritize('media', '1');
899
+
900
+ // All tasks boosted to high priority, stable sort preserves insertion order
901
+ expect(queue.queue.every(t => t._priority >= 2)).toBe(true); // PRIORITY.high
902
+ expect(queue.queue.length).toBe(5);
903
+ });
904
+ });
905
+ });
906
+
907
+ // ============================================================================
908
+ // DownloadManager
909
+ // ============================================================================
910
+
397
911
  describe('DownloadManager', () => {
398
912
  afterEach(() => {
399
913
  vi.restoreAllMocks();
@@ -408,10 +922,10 @@ describe('DownloadManager', () => {
408
922
  });
409
923
  const manager = new DownloadManager({ concurrency: 4 });
410
924
 
411
- const task = manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
925
+ const file = manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
412
926
 
413
- expect(task).toBeInstanceOf(DownloadTask);
414
- expect(manager.queue.active.has('http://test.com/file.mp4')).toBe(true);
927
+ expect(file).toBeInstanceOf(FileDownload);
928
+ expect(manager.queue.active.has('media/1')).toBe(true);
415
929
  });
416
930
 
417
931
  it('should delegate getTask to queue', () => {
@@ -422,10 +936,10 @@ describe('DownloadManager', () => {
422
936
  });
423
937
  const manager = new DownloadManager();
424
938
 
425
- const task = manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
426
- const retrieved = manager.getTask('http://test.com/file.mp4');
939
+ const file = manager.enqueue({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
940
+ const retrieved = manager.getTask('media/1');
427
941
 
428
- expect(retrieved).toBe(task);
942
+ expect(retrieved).toBe(file);
429
943
  });
430
944
 
431
945
  it('should delegate getProgress to queue', () => {
@@ -473,254 +987,545 @@ describe('DownloadManager', () => {
473
987
  expect(manager.queue.chunkSize).toBe(25 * 1024 * 1024);
474
988
  });
475
989
 
476
- it('should pass chunksPerFile to queue', () => {
477
- const manager = new DownloadManager({ chunksPerFile: 8 });
478
-
479
- expect(manager.queue.chunksPerFile).toBe(8);
480
- });
481
-
482
990
  it('should use defaults if not specified', () => {
483
991
  const manager = new DownloadManager();
484
992
 
485
- expect(manager.queue.concurrency).toBe(4); // DEFAULT_CONCURRENCY
993
+ expect(manager.queue.concurrency).toBe(6); // DEFAULT_CONCURRENCY
486
994
  expect(manager.queue.chunkSize).toBe(50 * 1024 * 1024); // DEFAULT_CHUNK_SIZE
487
995
  });
488
996
  });
489
997
  });
490
998
 
491
999
  // ============================================================================
492
- // Progressive Chunk Streaming Tests
1000
+ // Resume Support
493
1001
  // ============================================================================
494
1002
 
495
- describe('DownloadTask - Progressive Streaming', () => {
1003
+ describe('FileDownload - Resume', () => {
496
1004
  afterEach(() => {
497
1005
  vi.restoreAllMocks();
498
1006
  });
499
1007
 
500
- describe('onChunkDownloaded callback', () => {
501
- it('should initialize onChunkDownloaded as null', () => {
502
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
1008
+ it('should skip cached chunks when resuming', async () => {
1009
+ const fileSize = 200 * 1024 * 1024; // 200MB = 4 chunks
1010
+ const sourceBlob = createTestBlob(fileSize, 'video/mp4');
503
1011
 
504
- expect(task.onChunkDownloaded).toBeNull();
505
- });
1012
+ mockChunkedFetch(sourceBlob);
506
1013
 
507
- it('should allow setting onChunkDownloaded before start', () => {
508
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://test.com/file.mp4' });
509
- const callback = vi.fn();
1014
+ // Chunks 0 and 1 already cached
1015
+ const file = new FileDownload(
1016
+ { id: '1', type: 'media', path: 'http://test.com/big.mp4', skipChunks: new Set([0, 1]) },
1017
+ { chunkSize: 50 * 1024 * 1024 }
1018
+ );
510
1019
 
511
- task.onChunkDownloaded = callback;
1020
+ const mockQueue = {
1021
+ enqueueChunkTasks: vi.fn((tasks) => {
1022
+ for (const task of tasks) {
1023
+ task.start()
1024
+ .then(() => task._parentFile.onTaskComplete(task))
1025
+ .catch(err => task._parentFile.onTaskFailed(task, err));
1026
+ }
1027
+ })
1028
+ };
512
1029
 
513
- expect(task.onChunkDownloaded).toBe(callback);
514
- });
1030
+ await file.prepare(mockQueue);
515
1031
 
516
- it('should call onChunkDownloaded for each chunk during chunked download', async () => {
517
- // 200MB file → will use downloadChunks (threshold is 100MB)
518
- const fileSize = 200 * 1024 * 1024;
519
- const sourceBlob = createTestBlob(fileSize, 'video/mp4');
1032
+ // Should only create tasks for chunks 2 and 3
1033
+ expect(file.tasks.length).toBe(2);
1034
+ expect(file.tasks[0].chunkIndex).toBe(2);
1035
+ expect(file.tasks[1].chunkIndex).toBe(3);
520
1036
 
521
- const task = new DownloadTask(
522
- { id: '1', type: 'media', path: 'http://test.com/big.mp4' },
523
- { chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
524
- );
1037
+ // All tasks should be normal priority (resume mode)
1038
+ expect(file.tasks.every(t => t._priority === 0)).toBe(true); // PRIORITY.normal
525
1039
 
526
- mockChunkedFetch(sourceBlob);
1040
+ await file.wait();
527
1041
 
528
- const chunkCalls = [];
529
- task.onChunkDownloaded = vi.fn(async (index, blob, total) => {
530
- chunkCalls.push({ index, size: blob.size, total });
531
- });
1042
+ expect(file.state).toBe('complete');
1043
+ // downloadedBytes includes skipped chunks
1044
+ expect(file.downloadedBytes).toBeGreaterThan(0);
1045
+ });
532
1046
 
533
- await task.start();
1047
+ it('should resolve immediately when all chunks cached', async () => {
1048
+ const fileSize = 200 * 1024 * 1024;
1049
+ const sourceBlob = createTestBlob(fileSize, 'video/mp4');
534
1050
 
535
- // 200MB / 50MB = 4 chunks
536
- expect(task.onChunkDownloaded).toHaveBeenCalledTimes(4);
537
- expect(chunkCalls.length).toBe(4);
1051
+ mockChunkedFetch(sourceBlob);
538
1052
 
539
- // Each callback receives (chunkIndex, chunkBlob, totalChunks)
540
- for (const call of chunkCalls) {
541
- expect(call.total).toBe(4);
542
- expect(call.size).toBeGreaterThan(0);
543
- }
1053
+ // All 4 chunks already cached
1054
+ const file = new FileDownload(
1055
+ { id: '1', type: 'media', path: 'http://test.com/big.mp4', skipChunks: new Set([0, 1, 2, 3]) },
1056
+ { chunkSize: 50 * 1024 * 1024 }
1057
+ );
544
1058
 
545
- // All chunk indices should be present (order may vary due to parallelism)
546
- const indices = chunkCalls.map(c => c.index).sort();
547
- expect(indices).toEqual([0, 1, 2, 3]);
548
- });
1059
+ const mockQueue = { enqueueChunkTasks: vi.fn() };
549
1060
 
550
- it('should return empty blob when onChunkDownloaded is set', async () => {
551
- const fileSize = 200 * 1024 * 1024;
552
- const sourceBlob = createTestBlob(fileSize, 'video/mp4');
1061
+ await file.prepare(mockQueue);
553
1062
 
554
- const task = new DownloadTask(
555
- { id: '1', type: 'media', path: 'http://test.com/big.mp4' },
556
- { chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
557
- );
1063
+ // No tasks should be created
1064
+ expect(file.tasks.length).toBe(0);
1065
+ expect(mockQueue.enqueueChunkTasks).not.toHaveBeenCalled();
558
1066
 
559
- mockChunkedFetch(sourceBlob);
1067
+ const blob = await file.wait();
1068
+ expect(blob.size).toBe(0); // Empty blob — data already in cache
1069
+ expect(file.state).toBe('complete');
1070
+ });
1071
+ });
560
1072
 
561
- // Set callback → triggers empty blob return path
562
- task.onChunkDownloaded = vi.fn(async () => {});
1073
+ // ============================================================================
1074
+ // BARRIER Hard gate in download queue
1075
+ // ============================================================================
563
1076
 
564
- await task.start();
1077
+ describe('BARRIER', () => {
1078
+ it('should be a unique Symbol', () => {
1079
+ expect(typeof BARRIER).toBe('symbol');
1080
+ expect(BARRIER.toString()).toContain('BARRIER');
1081
+ });
1082
+ });
565
1083
 
566
- // Post-condition: blob should be empty (data was handled by callbacks)
567
- expect(task.blob.size).toBe(0);
568
- expect(task.state).toBe('complete');
1084
+ describe('DownloadQueue - Barrier Support', () => {
1085
+ afterEach(() => {
1086
+ vi.restoreAllMocks();
1087
+ });
1088
+
1089
+ /**
1090
+ * Helper: create a DownloadTask with a mock parent FileDownload.
1091
+ * The task is in 'pending' state and can be started via _startTask().
1092
+ */
1093
+ function createMockTask(fileId, opts = {}) {
1094
+ const fileInfo = { id: fileId, type: 'media', path: `http://test.com/${fileId}.mp4` };
1095
+ const file = new FileDownload(fileInfo);
1096
+ file._runningCount = 0;
1097
+ file.totalChunks = opts.totalChunks ?? 1;
1098
+ const task = new DownloadTask(fileInfo, {
1099
+ chunkIndex: opts.chunkIndex ?? null,
1100
+ rangeStart: opts.rangeStart ?? null,
1101
+ rangeEnd: opts.rangeEnd ?? null
1102
+ });
1103
+ task._parentFile = file;
1104
+ task._priority = opts.priority ?? PRIORITY.normal;
1105
+ return task;
1106
+ }
1107
+
1108
+ describe('enqueueOrderedTasks()', () => {
1109
+ it('should push tasks and barriers preserving order', () => {
1110
+ const queue = new DownloadQueue({ concurrency: 1 });
1111
+ // Override processQueue to prevent auto-start for this test
1112
+ queue.processQueue = vi.fn();
1113
+
1114
+ const t1 = createMockTask('1');
1115
+ const t2 = createMockTask('2');
1116
+ const t3 = createMockTask('3');
1117
+
1118
+ queue.enqueueOrderedTasks([t1, t2, BARRIER, t3]);
1119
+
1120
+ expect(queue.queue.length).toBe(4);
1121
+ expect(queue.queue[0]).toBe(t1);
1122
+ expect(queue.queue[1]).toBe(t2);
1123
+ expect(queue.queue[2]).toBe(BARRIER);
1124
+ expect(queue.queue[3]).toBe(t3);
569
1125
  });
1126
+ });
570
1127
 
571
- it('should return full blob when onChunkDownloaded is NOT set', async () => {
572
- const fileSize = 200 * 1024 * 1024;
573
- const sourceBlob = createTestBlob(fileSize, 'video/mp4');
1128
+ describe('processQueue() with barriers', () => {
1129
+ it('should stop at barrier when tasks are in-flight', () => {
1130
+ const queue = new DownloadQueue({ concurrency: 6 });
1131
+ const startedTasks = [];
1132
+
1133
+ // Override _startTask to track starts without actual HTTP
1134
+ queue._startTask = (task) => {
1135
+ queue.running++;
1136
+ task._parentFile._runningCount++;
1137
+ startedTasks.push(task);
1138
+ };
1139
+
1140
+ const t1 = createMockTask('1');
1141
+ const t2 = createMockTask('2');
1142
+ const t3 = createMockTask('3'); // Should NOT start (behind barrier)
1143
+
1144
+ queue.enqueueOrderedTasks([t1, t2, BARRIER, t3]);
1145
+ queue.processQueue();
1146
+
1147
+ // t1 and t2 should start, t3 should NOT (barrier blocks)
1148
+ expect(startedTasks.length).toBe(2);
1149
+ expect(startedTasks).toContain(t1);
1150
+ expect(startedTasks).toContain(t2);
1151
+ expect(startedTasks).not.toContain(t3);
1152
+ // Barrier and t3 remain in queue
1153
+ expect(queue.queue.length).toBe(2);
1154
+ expect(queue.queue[0]).toBe(BARRIER);
1155
+ expect(queue.queue[1]).toBe(t3);
1156
+ });
574
1157
 
575
- const task = new DownloadTask(
576
- { id: '1', type: 'media', path: 'http://test.com/big.mp4' },
577
- { chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
578
- );
1158
+ it('should pass through barrier when no tasks are in-flight', () => {
1159
+ const queue = new DownloadQueue({ concurrency: 6 });
1160
+ const startedTasks = [];
579
1161
 
580
- mockChunkedFetch(sourceBlob);
1162
+ queue._startTask = (task) => {
1163
+ queue.running++;
1164
+ task._parentFile._runningCount++;
1165
+ startedTasks.push(task);
1166
+ };
581
1167
 
582
- // No callback set → traditional reassembly
583
- await task.start();
1168
+ const t1 = createMockTask('1');
584
1169
 
585
- // Post-condition: blob contains the full file
586
- expect(task.blob.size).toBe(fileSize);
587
- expect(task.state).toBe('complete');
1170
+ // Barrier at front with nothing running → should pass through
1171
+ queue.enqueueOrderedTasks([BARRIER, t1]);
1172
+ queue.processQueue();
1173
+
1174
+ expect(startedTasks.length).toBe(1);
1175
+ expect(startedTasks[0]).toBe(t1);
1176
+ expect(queue.queue.length).toBe(0);
588
1177
  });
589
1178
 
590
- it('should not call onChunkDownloaded for small files (single request)', async () => {
591
- const fileSize = 10 * 1024 * 1024; // 10MB - below 100MB threshold
592
- const sourceBlob = createTestBlob(fileSize);
1179
+ it('should process tasks after barrier completes', () => {
1180
+ const queue = new DownloadQueue({ concurrency: 6 });
1181
+ const startedTasks = [];
593
1182
 
594
- const task = new DownloadTask(
595
- { id: '1', type: 'media', path: 'http://test.com/small.mp4' },
596
- { chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
597
- );
1183
+ queue._startTask = (task) => {
1184
+ queue.running++;
1185
+ task._parentFile._runningCount++;
1186
+ queue._activeTasks.push(task);
1187
+ startedTasks.push(task);
1188
+ };
598
1189
 
599
- mockChunkedFetch(sourceBlob);
1190
+ const t1 = createMockTask('1');
1191
+ const t2 = createMockTask('2');
600
1192
 
601
- task.onChunkDownloaded = vi.fn(async () => {});
1193
+ queue.enqueueOrderedTasks([t1, BARRIER, t2]);
1194
+ queue.processQueue();
602
1195
 
603
- await task.start();
1196
+ // Only t1 should start (barrier blocks t2)
1197
+ expect(startedTasks.length).toBe(1);
1198
+ expect(startedTasks[0]).toBe(t1);
604
1199
 
605
- // Small file uses downloadFull, not downloadChunks → callback not called
606
- expect(task.onChunkDownloaded).not.toHaveBeenCalled();
607
- // But the blob should still contain data
608
- expect(task.blob.size).toBe(fileSize);
609
- });
1200
+ // Simulate t1 completing
1201
+ queue.running--;
1202
+ t1._parentFile._runningCount--;
1203
+ queue._activeTasks = [];
610
1204
 
611
- it('should handle async callback errors gracefully', async () => {
612
- const fileSize = 200 * 1024 * 1024;
613
- const sourceBlob = createTestBlob(fileSize, 'video/mp4');
1205
+ // Re-process: barrier should lift, t2 should start
1206
+ queue.processQueue();
614
1207
 
615
- const task = new DownloadTask(
616
- { id: '1', type: 'media', path: 'http://test.com/big.mp4' },
617
- { chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
618
- );
1208
+ expect(startedTasks.length).toBe(2);
1209
+ expect(startedTasks[1]).toBe(t2);
1210
+ expect(queue.queue.length).toBe(0);
1211
+ });
619
1212
 
620
- mockChunkedFetch(sourceBlob);
1213
+ it('should keep slots empty when barrier is hit (no fill-ahead)', () => {
1214
+ const queue = new DownloadQueue({ concurrency: 4 });
1215
+ const startedTasks = [];
621
1216
 
622
- // Callback throws should not crash download
623
- task.onChunkDownloaded = vi.fn(async () => {
624
- throw new Error('Cache storage failed');
625
- });
1217
+ queue._startTask = (task) => {
1218
+ queue.running++;
1219
+ task._parentFile._runningCount++;
1220
+ startedTasks.push(task);
1221
+ };
626
1222
 
627
- // Download should still complete despite callback errors
628
- await task.start();
1223
+ const t1 = createMockTask('1');
1224
+ const t2 = createMockTask('2');
1225
+ const t3 = createMockTask('3');
629
1226
 
630
- expect(task.state).toBe('complete');
631
- expect(task.onChunkDownloaded).toHaveBeenCalledTimes(4);
1227
+ // 2 tasks above barrier, 1 below. With concurrency=4, 2 slots should stay empty.
1228
+ queue.enqueueOrderedTasks([t1, t2, BARRIER, t3]);
1229
+ queue.processQueue();
1230
+
1231
+ expect(startedTasks.length).toBe(2);
1232
+ expect(queue.running).toBe(2);
1233
+ // 2 of 4 slots are empty — barrier enforced
632
1234
  });
633
1235
 
634
- it('should resolve waiters with empty blob when callback is set', async () => {
635
- const fileSize = 200 * 1024 * 1024;
636
- const sourceBlob = createTestBlob(fileSize, 'video/mp4');
1236
+ it('should handle consecutive barriers', () => {
1237
+ const queue = new DownloadQueue({ concurrency: 6 });
1238
+ const startedTasks = [];
637
1239
 
638
- const task = new DownloadTask(
639
- { id: '1', type: 'media', path: 'http://test.com/big.mp4' },
640
- { chunkSize: 50 * 1024 * 1024, chunksPerFile: 4 }
641
- );
1240
+ queue._startTask = (task) => {
1241
+ queue.running++;
1242
+ task._parentFile._runningCount++;
1243
+ startedTasks.push(task);
1244
+ };
642
1245
 
643
- mockChunkedFetch(sourceBlob);
1246
+ const t1 = createMockTask('1');
1247
+ const t2 = createMockTask('2');
644
1248
 
645
- task.onChunkDownloaded = vi.fn(async () => {});
1249
+ queue.enqueueOrderedTasks([t1, BARRIER, BARRIER, t2]);
1250
+ queue.processQueue();
646
1251
 
647
- // Set up waiter before start
648
- const waiterPromise = task.wait();
1252
+ // t1 starts, both barriers block t2
1253
+ expect(startedTasks.length).toBe(1);
1254
+ expect(startedTasks[0]).toBe(t1);
649
1255
 
650
- await task.start();
1256
+ // Complete t1
1257
+ queue.running = 0;
1258
+ queue.processQueue();
651
1259
 
652
- const result = await waiterPromise;
653
- // Waiter gets the empty blob (data already handled by callbacks)
654
- expect(result.size).toBe(0);
1260
+ // Both barriers should be consumed, t2 starts
1261
+ expect(startedTasks.length).toBe(2);
1262
+ expect(startedTasks[1]).toBe(t2);
655
1263
  });
656
1264
  });
1265
+
1266
+ describe('urgentChunk() bypasses barriers', () => {
1267
+ it('should move urgent chunk to front of queue past barriers', () => {
1268
+ const queue = new DownloadQueue({ concurrency: 1 });
1269
+ // Prevent processQueue from actually starting tasks
1270
+ queue.processQueue = vi.fn();
1271
+
1272
+ const file = new FileDownload({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
1273
+ file._runningCount = 0;
1274
+ file.totalChunks = 5;
1275
+ queue.active.set('media/1', file);
1276
+
1277
+ // Create tasks with barrier between them
1278
+ const chunk0 = new DownloadTask(
1279
+ { id: '1', type: 'media', path: 'http://test.com/1.mp4' },
1280
+ { chunkIndex: 0 }
1281
+ );
1282
+ chunk0._parentFile = file;
1283
+ const chunk3 = new DownloadTask(
1284
+ { id: '1', type: 'media', path: 'http://test.com/1.mp4' },
1285
+ { chunkIndex: 3 }
1286
+ );
1287
+ chunk3._parentFile = file;
1288
+
1289
+ queue.queue = [chunk0, BARRIER, chunk3];
1290
+
1291
+ // Urgent chunk 3 — should move to front, past barrier
1292
+ const acted = queue.urgentChunk('media', '1', 3);
1293
+
1294
+ expect(acted).toBe(true);
1295
+ expect(queue.queue[0]).toBe(chunk3);
1296
+ expect(queue.queue[0]._priority).toBe(PRIORITY.urgent);
1297
+ // chunk0 and barrier should still be there
1298
+ expect(queue.queue.length).toBe(3);
1299
+ });
1300
+ });
1301
+
657
1302
  });
658
1303
 
659
1304
  // ============================================================================
660
- // DownloadQueue Priority Tests
1305
+ // LayoutTaskBuilder Smart builder, dumb queue
661
1306
  // ============================================================================
662
1307
 
663
- describe('DownloadQueue - Priority', () => {
1308
+ describe('LayoutTaskBuilder', () => {
664
1309
  afterEach(() => {
665
1310
  vi.restoreAllMocks();
666
1311
  });
667
1312
 
668
- describe('prioritize()', () => {
669
- it('should move queued file to front', () => {
670
- const queue = new DownloadQueue({ concurrency: 0 }); // Concurrency 0 prevents auto-start
1313
+ /**
1314
+ * Helper: create a queue for LayoutTaskBuilder tests.
1315
+ * concurrency=0 prevents auto-processing of any tasks that leak into the real queue.
1316
+ */
1317
+ function createTestQueue(opts = {}) {
1318
+ return new DownloadQueue({ concurrency: 0, ...opts });
1319
+ }
1320
+
1321
+ describe('addFile()', () => {
1322
+ it('should register file in queue.active and return FileDownload', () => {
1323
+ const queue = createTestQueue();
1324
+ const builder = new LayoutTaskBuilder(queue);
1325
+
1326
+ const file = builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
1327
+
1328
+ expect(file).toBeInstanceOf(FileDownload);
1329
+ expect(file.state).toBe('pending');
1330
+ expect(queue.active.has('media/1')).toBe(true);
1331
+ expect(queue.active.get('media/1')).toBe(file);
1332
+ });
671
1333
 
672
- // Enqueue 3 files (none will start due to concurrency 0)
673
- // Actually concurrency 0 means the while loop never runs in processQueue
674
- // But we need concurrency > 0 to create tasks. Let me just use a high enough count.
675
- // Instead, create tasks manually
676
- const task1 = new DownloadTask({ id: '1', type: 'media', path: 'http://a' });
677
- const task2 = new DownloadTask({ id: '2', type: 'media', path: 'http://b' });
678
- const task3 = new DownloadTask({ id: '3', type: 'media', path: 'http://c' });
1334
+ it('should deduplicate same file', () => {
1335
+ const queue = createTestQueue();
1336
+ const builder = new LayoutTaskBuilder(queue);
679
1337
 
680
- queue.queue = [task1, task2, task3];
681
- queue.active.set('http://a', task1);
682
- queue.active.set('http://b', task2);
683
- queue.active.set('http://c', task3);
1338
+ const file1 = builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
1339
+ const file2 = builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
684
1340
 
685
- // task3 is at position 2 → prioritize it
686
- const found = queue.prioritize('media', '3');
1341
+ expect(file1).toBe(file2);
1342
+ expect(queue.active.size).toBe(1);
1343
+ });
687
1344
 
688
- expect(found).toBe(true);
689
- expect(queue.queue[0]).toBe(task3); // Now at front
690
- expect(queue.queue[1]).toBe(task1);
691
- expect(queue.queue[2]).toBe(task2);
1345
+ it('should refresh URL with later expiry on dedup', () => {
1346
+ const queue = createTestQueue();
1347
+ const builder = new LayoutTaskBuilder(queue);
1348
+
1349
+ builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4?X-Amz-Expires=1000' });
1350
+ const file2 = builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4?X-Amz-Expires=2000' });
1351
+
1352
+ expect(file2.fileInfo.path).toContain('X-Amz-Expires=2000');
692
1353
  });
1354
+ });
693
1355
 
694
- it('should return true if file is already at front', () => {
695
- const queue = new DownloadQueue();
696
- const task = new DownloadTask({ id: '1', type: 'media', path: 'http://a' });
697
- queue.queue = [task];
698
- queue.active.set('http://a', task);
1356
+ describe('build()', () => {
1357
+ it('should run HEAD requests and return sorted tasks', async () => {
1358
+ const queue = createTestQueue();
1359
+ const builder = new LayoutTaskBuilder(queue);
1360
+
1361
+ const testBlob = createTestBlob(1024);
1362
+ mockFetch({
1363
+ 'HEAD http://test.com/1.mp4': { headers: { 'Content-Length': '1024' } },
1364
+ 'HEAD http://test.com/2.mp4': { headers: { 'Content-Length': '2048' } }
1365
+ });
699
1366
 
700
- const found = queue.prioritize('media', '1');
1367
+ builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.mp4' });
1368
+ builder.addFile({ id: '2', type: 'media', path: 'http://test.com/2.mp4' });
701
1369
 
702
- expect(found).toBe(true);
703
- expect(queue.queue[0]).toBe(task);
1370
+ const tasks = await builder.build();
1371
+
1372
+ expect(tasks.length).toBe(2);
1373
+ // Sorted smallest→largest
1374
+ expect(tasks[0].fileInfo.id).toBe('1');
1375
+ expect(tasks[1].fileInfo.id).toBe('2');
704
1376
  });
705
1377
 
706
- it('should return false if file not found', () => {
707
- const queue = new DownloadQueue();
1378
+ it('should sort non-chunked smallest→largest', async () => {
1379
+ const queue = createTestQueue();
1380
+ const builder = new LayoutTaskBuilder(queue);
708
1381
 
709
- const found = queue.prioritize('media', '999');
1382
+ mockFetch({
1383
+ 'HEAD http://test.com/big.jpg': { headers: { 'Content-Length': '50000' } },
1384
+ 'HEAD http://test.com/small.jpg': { headers: { 'Content-Length': '500' } },
1385
+ 'HEAD http://test.com/med.jpg': { headers: { 'Content-Length': '5000' } }
1386
+ });
710
1387
 
711
- expect(found).toBe(false);
1388
+ builder.addFile({ id: 'big', type: 'media', path: 'http://test.com/big.jpg' });
1389
+ builder.addFile({ id: 'small', type: 'media', path: 'http://test.com/small.jpg' });
1390
+ builder.addFile({ id: 'med', type: 'media', path: 'http://test.com/med.jpg' });
1391
+
1392
+ const tasks = await builder.build();
1393
+
1394
+ expect(tasks.length).toBe(3);
1395
+ expect(tasks[0].fileInfo.id).toBe('small');
1396
+ expect(tasks[1].fileInfo.id).toBe('med');
1397
+ expect(tasks[2].fileInfo.id).toBe('big');
712
1398
  });
713
1399
 
714
- it('should return true if file is already downloading', () => {
715
- const queue = new DownloadQueue();
716
- const task = new DownloadTask({ id: '5', type: 'media', path: 'http://x' });
717
- task.state = 'downloading';
718
- queue.active.set('http://x', task);
719
- // Not in queue (already started)
1400
+ it('should place chunk-0 and chunk-last before BARRIER, remaining after', async () => {
1401
+ const queue = createTestQueue();
1402
+ const builder = new LayoutTaskBuilder(queue);
720
1403
 
721
- const found = queue.prioritize('media', '5');
1404
+ // 200MB file = 4 chunks (50MB each, threshold 100MB)
1405
+ const fileSize = 200 * 1024 * 1024;
1406
+ const sourceBlob = createTestBlob(fileSize, 'video/mp4');
1407
+ mockChunkedFetch(sourceBlob);
722
1408
 
723
- expect(found).toBe(true);
1409
+ builder.addFile({ id: '1', type: 'media', path: 'http://test.com/big.mp4' });
1410
+
1411
+ const tasks = await builder.build();
1412
+
1413
+ // Should have: chunk-0, chunk-3(last), BARRIER, chunk-1, chunk-2
1414
+ const barrierIdx = tasks.indexOf(BARRIER);
1415
+ expect(barrierIdx).toBe(2); // After chunk-0 and chunk-3
1416
+
1417
+ // Before barrier: chunk-0 and chunk-3 (last)
1418
+ expect(tasks[0].chunkIndex).toBe(0);
1419
+ expect(tasks[1].chunkIndex).toBe(3);
1420
+
1421
+ // After barrier: remaining chunks (1, 2) sorted by index
1422
+ const afterBarrier = tasks.slice(barrierIdx + 1).filter(t => t !== BARRIER);
1423
+ expect(afterBarrier.map(t => t.chunkIndex)).toEqual([1, 2]);
1424
+ });
1425
+
1426
+ it('should not add BARRIER when no chunked files', async () => {
1427
+ const queue = createTestQueue();
1428
+ const builder = new LayoutTaskBuilder(queue);
1429
+
1430
+ mockFetch({
1431
+ 'HEAD http://test.com/1.jpg': { headers: { 'Content-Length': '1024' } }
1432
+ });
1433
+
1434
+ builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.jpg' });
1435
+
1436
+ const tasks = await builder.build();
1437
+
1438
+ expect(tasks.length).toBe(1);
1439
+ expect(tasks.includes(BARRIER)).toBe(false);
1440
+ });
1441
+
1442
+ it('should return empty array for empty layout', async () => {
1443
+ const queue = createTestQueue();
1444
+ const builder = new LayoutTaskBuilder(queue);
1445
+
1446
+ const tasks = await builder.build();
1447
+
1448
+ expect(tasks).toEqual([]);
1449
+ });
1450
+ });
1451
+
1452
+ describe('build() with size from RequiredFiles (no HEAD)', () => {
1453
+ it('should build instantly when fileInfo.size is provided', async () => {
1454
+ const queue = createTestQueue();
1455
+ const builder = new LayoutTaskBuilder(queue);
1456
+
1457
+ // No fetch mock needed — size is provided, HEAD is skipped
1458
+ const fetchSpy = vi.fn();
1459
+ global.fetch = fetchSpy;
1460
+
1461
+ builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.jpg', size: 1024 });
1462
+ builder.addFile({ id: '2', type: 'media', path: 'http://test.com/2.jpg', size: 2048 });
1463
+
1464
+ const tasks = await builder.build();
1465
+
1466
+ // No HEAD requests
1467
+ const headCalls = fetchSpy.mock.calls.filter(c => c[1]?.method === 'HEAD');
1468
+ expect(headCalls.length).toBe(0);
1469
+
1470
+ // Tasks still sorted smallest→largest
1471
+ expect(tasks.length).toBe(2);
1472
+ expect(tasks[0].fileInfo.id).toBe('1');
1473
+ expect(tasks[1].fileInfo.id).toBe('2');
1474
+ });
1475
+
1476
+ it('should chunk large files using declared size', async () => {
1477
+ const queue = createTestQueue();
1478
+ const builder = new LayoutTaskBuilder(queue);
1479
+
1480
+ const fetchSpy = vi.fn();
1481
+ global.fetch = fetchSpy;
1482
+
1483
+ // 200MB file declared via size — should chunk without HEAD
1484
+ const fileSize = 200 * 1024 * 1024;
1485
+ builder.addFile({ id: '1', type: 'media', path: 'http://test.com/big.mp4', size: fileSize });
1486
+
1487
+ const tasks = await builder.build();
1488
+
1489
+ // No HEAD
1490
+ expect(fetchSpy).not.toHaveBeenCalled();
1491
+
1492
+ // Should have chunks: chunk-0, chunk-3(last), BARRIER, chunk-1, chunk-2
1493
+ const barrierIdx = tasks.indexOf(BARRIER);
1494
+ expect(barrierIdx).toBe(2);
1495
+ expect(tasks[0].chunkIndex).toBe(0);
1496
+ expect(tasks[1].chunkIndex).toBe(3);
1497
+ });
1498
+ });
1499
+
1500
+ describe('integration with enqueueOrderedTasks()', () => {
1501
+ it('should produce tasks that the queue can process', async () => {
1502
+ const queue = new DownloadQueue({ concurrency: 6 });
1503
+ const startedTasks = [];
1504
+
1505
+ // Track which tasks get started
1506
+ queue._startTask = (task) => {
1507
+ queue.running++;
1508
+ task._parentFile._runningCount++;
1509
+ startedTasks.push(task);
1510
+ };
1511
+
1512
+ const builder = new LayoutTaskBuilder(queue);
1513
+
1514
+ mockFetch({
1515
+ 'HEAD http://test.com/1.jpg': { headers: { 'Content-Length': '1024' } },
1516
+ 'HEAD http://test.com/2.jpg': { headers: { 'Content-Length': '2048' } }
1517
+ });
1518
+
1519
+ builder.addFile({ id: '1', type: 'media', path: 'http://test.com/1.jpg' });
1520
+ builder.addFile({ id: '2', type: 'media', path: 'http://test.com/2.jpg' });
1521
+
1522
+ const tasks = await builder.build();
1523
+ queue.enqueueOrderedTasks(tasks);
1524
+
1525
+ expect(startedTasks.length).toBe(2);
1526
+ // Smallest file starts first (sorted by builder)
1527
+ expect(startedTasks[0].fileInfo.id).toBe('1');
1528
+ expect(startedTasks[1].fileInfo.id).toBe('2');
724
1529
  });
725
1530
  });
726
1531
  });