@rntp/player 5.0.0-beta.3 → 5.0.0-beta.5

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.
Files changed (61) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +107 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/BrowseTree.kt +51 -20
  6. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  14. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  15. package/android/src/test/resources/robolectric.properties +1 -0
  16. package/ios/CarPlay/RNTPCarPlaySceneDelegate.swift +43 -14
  17. package/ios/TrackPlayer.swift +46 -101
  18. package/ios/TrackPlayerBridge.mm +2 -0
  19. package/ios/player/AVPlayerEngine.swift +46 -32
  20. package/ios/player/AudioCache.swift +34 -0
  21. package/ios/player/AudioPlayer.swift +36 -21
  22. package/ios/player/CacheProxyServer.swift +429 -0
  23. package/ios/player/DownloadCoordinator.swift +242 -0
  24. package/ios/player/Preloader.swift +21 -90
  25. package/ios/player/SleepTimerController.swift +147 -0
  26. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  27. package/ios/tests/AudioPlayerTests.swift +6 -0
  28. package/ios/tests/CacheProxyServerTests.swift +403 -0
  29. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  30. package/ios/tests/LocalAudioServer.swift +171 -0
  31. package/ios/tests/MockPlayerEngine.swift +1 -0
  32. package/ios/tests/QueueManagerTests.swift +6 -0
  33. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  34. package/ios/tests/SleepTimerTests.swift +70 -0
  35. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  36. package/lib/commonjs/audio.js +39 -4
  37. package/lib/commonjs/audio.js.map +1 -1
  38. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  39. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  40. package/lib/module/NativeTrackPlayer.js.map +1 -1
  41. package/lib/module/audio.js +37 -4
  42. package/lib/module/audio.js.map +1 -1
  43. package/lib/module/interfaces/PlayerConfig.js +1 -1
  44. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  45. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  46. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  47. package/lib/typescript/src/audio.d.ts +16 -4
  48. package/lib/typescript/src/audio.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/BrowseTree.d.ts +35 -5
  50. package/lib/typescript/src/interfaces/BrowseTree.d.ts.map +1 -1
  51. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  52. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  53. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
  54. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  55. package/package.json +4 -1
  56. package/src/NativeTrackPlayer.ts +4 -0
  57. package/src/audio.ts +37 -4
  58. package/src/interfaces/BrowseTree.ts +40 -5
  59. package/src/interfaces/MediaItem.ts +4 -1
  60. package/src/interfaces/PlayerConfig.ts +22 -3
  61. package/ios/player/CachingResourceLoader.swift +0 -273
@@ -0,0 +1,403 @@
1
+ //
2
+ // Copyright (c) Double Symmetry GmbH
3
+ // Commercial use requires a license. See https://rntp.dev/pricing
4
+ //
5
+
6
+ import XCTest
7
+ @testable import PlayerCore
8
+
9
+ final class CacheProxyServerTests: XCTestCase {
10
+ private var cacheDir: URL!
11
+ private var cache: AudioCache!
12
+ private var coordinator: DownloadCoordinator!
13
+ private var proxy: CacheProxyServer!
14
+
15
+ override func setUp() {
16
+ super.setUp()
17
+ cacheDir = FileManager.default.temporaryDirectory
18
+ .appendingPathComponent("CacheProxyServerTests-\(UUID().uuidString)", isDirectory: true)
19
+ cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
20
+ coordinator = DownloadCoordinator(cache: cache)
21
+ proxy = try! CacheProxyServer(coordinator: coordinator, cache: cache)
22
+ proxy.start()
23
+ }
24
+
25
+ override func tearDown() {
26
+ proxy.stop()
27
+ coordinator.cancelAll()
28
+ proxy = nil
29
+ coordinator = nil
30
+ try? FileManager.default.removeItem(at: cacheDir)
31
+ super.tearDown()
32
+ }
33
+
34
+ // MARK: - Helpers
35
+
36
+ /// Perform a synchronous HTTP GET through the proxy and return (data, response).
37
+ private func fetch(_ url: URL, timeout: TimeInterval = 10.0) throws -> (Data, HTTPURLResponse) {
38
+ let expectation = self.expectation(description: "fetch \(url)")
39
+ var resultData: Data?
40
+ var resultResponse: HTTPURLResponse?
41
+ var resultError: Error?
42
+
43
+ let task = URLSession.shared.dataTask(with: url) { data, response, error in
44
+ resultData = data
45
+ resultResponse = response as? HTTPURLResponse
46
+ resultError = error
47
+ expectation.fulfill()
48
+ }
49
+ task.resume()
50
+ wait(for: [expectation], timeout: timeout)
51
+
52
+ if let error = resultError { throw error }
53
+ return (resultData ?? Data(), resultResponse!)
54
+ }
55
+
56
+ // MARK: - 1. Cache miss with Content-Length
57
+
58
+ func testCacheMissWithContentLength() throws {
59
+ let testData = Data(repeating: 0xAA, count: 4096)
60
+ let server = try LocalAudioServer(
61
+ audioData: testData,
62
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
63
+ )
64
+ server.start()
65
+ defer { server.stop() }
66
+
67
+ let upstreamURL = server.url
68
+ let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
69
+
70
+ let (data, response) = try fetch(proxyURL)
71
+
72
+ XCTAssertEqual(response.statusCode, 200)
73
+ XCTAssertEqual(data, testData, "Proxy should return the full audio data")
74
+
75
+ // Verify it was cached
76
+ let key = cache.cacheKey(for: upstreamURL)
77
+ // Give a moment for the download completion handler to finalize
78
+ Thread.sleep(forTimeInterval: 0.5)
79
+ XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
80
+ XCTAssertTrue(cache.isFullyCached(key: key))
81
+ }
82
+
83
+ // MARK: - 2. Cache miss without Content-Length
84
+
85
+ func testCacheMissWithoutContentLength() throws {
86
+ let testData = Data(repeating: 0xBB, count: 2048)
87
+ let server = try LocalAudioServer(
88
+ audioData: testData,
89
+ options: .init(includeContentLength: false, contentType: "audio/aac")
90
+ )
91
+ server.start()
92
+ defer { server.stop() }
93
+
94
+ let upstreamURL = server.url
95
+ let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
96
+
97
+ let (data, response) = try fetch(proxyURL)
98
+
99
+ XCTAssertEqual(response.statusCode, 200)
100
+ XCTAssertEqual(data, testData)
101
+
102
+ // Content length should be finalized after download completes
103
+ let key = cache.cacheKey(for: upstreamURL)
104
+ Thread.sleep(forTimeInterval: 0.5)
105
+ let info = cache.contentInfo(for: key)
106
+ XCTAssertNotNil(info)
107
+ XCTAssertEqual(info?.contentLength, Int64(testData.count),
108
+ "Content length should be finalized after download")
109
+ }
110
+
111
+ // MARK: - 3. Cache hit
112
+
113
+ func testCacheHitServesFromDisk() throws {
114
+ let testData = Data(repeating: 0xCC, count: 1024)
115
+ let fakeURL = URL(string: "https://example.com/cached-track.mp3")!
116
+ let key = cache.cacheKey(for: fakeURL)
117
+
118
+ // Pre-populate cache
119
+ let info = AudioCache.ContentInfo(
120
+ contentType: "audio/mpeg",
121
+ contentLength: Int64(testData.count),
122
+ isByteRangeAccessSupported: false
123
+ )
124
+ cache.storeContentInfo(info, for: key, url: fakeURL)
125
+ cache.appendData(testData, for: key)
126
+
127
+ let proxyURL = proxy.proxyURL(for: fakeURL, headers: nil)
128
+ let (data, response) = try fetch(proxyURL)
129
+
130
+ XCTAssertEqual(response.statusCode, 200)
131
+ XCTAssertEqual(data, testData)
132
+ // Cache hit should include Content-Length
133
+ let contentLength = response.value(forHTTPHeaderField: "Content-Length")
134
+ XCTAssertEqual(contentLength, "\(testData.count)")
135
+ }
136
+
137
+ // MARK: - 4. Partial cache + resume
138
+
139
+ func testPartialCacheResumesDownload() throws {
140
+ let testData = Data(repeating: 0xDD, count: 4000)
141
+ let server = try LocalAudioServer(
142
+ audioData: testData,
143
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
144
+ )
145
+ server.start()
146
+ defer { server.stop() }
147
+
148
+ let upstreamURL = server.url
149
+ let key = cache.cacheKey(for: upstreamURL)
150
+
151
+ // Pre-populate partial cache (first 1000 bytes)
152
+ let partialData = testData.prefix(1000)
153
+ let info = AudioCache.ContentInfo(
154
+ contentType: "audio/mpeg",
155
+ contentLength: Int64(testData.count),
156
+ isByteRangeAccessSupported: false
157
+ )
158
+ cache.storeContentInfo(info, for: key, url: upstreamURL)
159
+ cache.appendData(partialData, for: key)
160
+
161
+ let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
162
+ let (data, response) = try fetch(proxyURL)
163
+
164
+ XCTAssertEqual(response.statusCode, 200)
165
+ // The proxy should serve the full data (partial cached + remainder from upstream).
166
+ // Note: LocalAudioServer doesn't support Range requests, so it returns the full file.
167
+ // The DownloadCoordinator appends only new data beyond what's cached.
168
+ // However, since LocalAudioServer returns 200 (not 206), the coordinator will
169
+ // append the full response. The proxy streams whatever is in the cache.
170
+ // The important thing is the client gets data and no error occurs.
171
+ XCTAssertGreaterThanOrEqual(data.count, testData.count)
172
+
173
+ Thread.sleep(forTimeInterval: 0.5)
174
+ XCTAssertGreaterThanOrEqual(cache.cachedBytes(for: key), Int64(testData.count))
175
+ }
176
+
177
+ // MARK: - 5. Upstream failure
178
+
179
+ func testUpstreamFailureReturns502() throws {
180
+ // Use a URL that will fail (nothing listening on this port)
181
+ let badURL = URL(string: "http://localhost:1/nonexistent.mp3")!
182
+ let proxyURL = proxy.proxyURL(for: badURL, headers: nil)
183
+
184
+ let (_, response) = try fetch(proxyURL)
185
+
186
+ XCTAssertEqual(response.statusCode, 502, "Should return 502 for upstream failure")
187
+ }
188
+
189
+ // MARK: - 6. Preload then play (shared download)
190
+
191
+ func testPreloadThenPlaySharesDownload() throws {
192
+ let testData = Data(repeating: 0xEE, count: 8000)
193
+ let server = try LocalAudioServer(
194
+ audioData: testData,
195
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
196
+ )
197
+ server.start()
198
+ defer { server.stop() }
199
+
200
+ let upstreamURL = server.url
201
+
202
+ // Start a preload via the coordinator directly
203
+ let preloadDone = expectation(description: "preload done")
204
+ coordinator.download(url: upstreamURL, headers: nil) { result in
205
+ if case .success = result { preloadDone.fulfill() }
206
+ }
207
+
208
+ // Immediately make a proxy request — it should attach to the same download
209
+ let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
210
+ let (data, response) = try fetch(proxyURL)
211
+
212
+ wait(for: [preloadDone], timeout: 10.0)
213
+
214
+ XCTAssertEqual(response.statusCode, 200)
215
+ XCTAssertEqual(data, testData)
216
+
217
+ // Only one download should have been active
218
+ let key = cache.cacheKey(for: upstreamURL)
219
+ XCTAssertTrue(cache.isFullyCached(key: key))
220
+ }
221
+
222
+ // MARK: - 7. Large cached file serves completely via serveFromCache
223
+
224
+ func testLargeCacheHitServesCompletely() throws {
225
+ // Simulate a fully-cached podcast file (50MB).
226
+ // This tests the serveFromCache 64KB chunked send path.
227
+ let size = 50 * 1024 * 1024
228
+ let testData = Data(repeating: 0x66, count: size)
229
+ let url = URL(string: "https://example.com/big-podcast.mp3")!
230
+ let key = cache.cacheKey(for: url)
231
+
232
+ // Pre-populate cache.
233
+ cache = AudioCache(maxSizeBytes: Int64(size + 1024 * 1024), directory: cacheDir)
234
+ let info = AudioCache.ContentInfo(
235
+ contentType: "audio/mpeg",
236
+ contentLength: Int64(size),
237
+ isByteRangeAccessSupported: true
238
+ )
239
+ cache.storeContentInfo(info, for: key, url: url)
240
+ cache.appendData(testData, for: key)
241
+
242
+ // Re-create proxy with the larger cache.
243
+ proxy.stop()
244
+ coordinator = DownloadCoordinator(cache: cache)
245
+ proxy = try! CacheProxyServer(coordinator: coordinator, cache: cache)
246
+ proxy.start()
247
+
248
+ let proxyURL = proxy.proxyURL(for: url, headers: nil)
249
+ let (data, response) = try fetch(proxyURL, timeout: 30.0)
250
+
251
+ XCTAssertEqual(response.statusCode, 200)
252
+ XCTAssertEqual(data.count, size,
253
+ "All \(size) bytes should be served from cache (no Content-Length mismatch)")
254
+ }
255
+
256
+ // MARK: - 8. Large file over slow network streams completely
257
+
258
+ func testLargeFileOverSlowNetworkStreamsCompletely() throws {
259
+ // 5MB file with simulated slow network (64KB chunks, 10ms delay each).
260
+ // This is ~780 chunks taking ~8 seconds — enough to expose races
261
+ // between the coordinator writing to cache and the proxy reading it.
262
+ let size = 5 * 1024 * 1024
263
+ let testData = Data(repeating: 0x55, count: size)
264
+ let upstream = try LocalAudioServer(
265
+ audioData: testData,
266
+ options: .init(includeContentLength: true, contentType: "audio/mpeg", simulateSlowNetwork: true)
267
+ )
268
+ upstream.start()
269
+ defer { upstream.stop() }
270
+
271
+ let upstreamURL = upstream.url
272
+ let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
273
+ let (data, response) = try fetch(proxyURL, timeout: 30.0)
274
+
275
+ XCTAssertEqual(response.statusCode, 200)
276
+ XCTAssertEqual(data.count, size,
277
+ "All \(size) bytes should be delivered over slow connection")
278
+
279
+ let key = cache.cacheKey(for: upstreamURL)
280
+ XCTAssertTrue(cache.isFullyCached(key: key))
281
+ }
282
+
283
+ // MARK: - 9. Large file streams completely from upstream
284
+
285
+ func testLargeFileStreamsCompletely() throws {
286
+ // Simulate a podcast-sized file (10MB — large enough to expose
287
+ // chunking race conditions).
288
+ let testData = Data(repeating: 0x77, count: 10 * 1024 * 1024)
289
+ let upstream = try LocalAudioServer(
290
+ audioData: testData,
291
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
292
+ )
293
+ upstream.start()
294
+ defer { upstream.stop() }
295
+
296
+ let upstreamURL = upstream.url
297
+ let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
298
+ let (data, response) = try fetch(proxyURL, timeout: 30.0)
299
+
300
+ XCTAssertEqual(response.statusCode, 200)
301
+ XCTAssertEqual(data.count, testData.count,
302
+ "All bytes should be delivered (no Content-Length mismatch)")
303
+
304
+ let key = cache.cacheKey(for: upstreamURL)
305
+ XCTAssertTrue(cache.isFullyCached(key: key))
306
+ XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
307
+ }
308
+
309
+ // MARK: - 8. Cache hit after full download serves correctly
310
+
311
+ func testCacheHitAfterDownload() throws {
312
+ let testData = Data(repeating: 0x88, count: 512 * 1024)
313
+ let upstream = try LocalAudioServer(
314
+ audioData: testData,
315
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
316
+ )
317
+ upstream.start()
318
+
319
+ let upstreamURL = upstream.url
320
+ let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
321
+
322
+ // First fetch — downloads and caches.
323
+ let (data1, _) = try fetch(proxyURL)
324
+ XCTAssertEqual(data1.count, testData.count)
325
+
326
+ // Stop upstream — second fetch must come from cache.
327
+ upstream.stop()
328
+
329
+ let (data2, response2) = try fetch(proxyURL)
330
+ XCTAssertEqual(response2.statusCode, 200)
331
+ XCTAssertEqual(data2.count, testData.count)
332
+ XCTAssertEqual(data2, testData, "Cache hit should serve identical data")
333
+ }
334
+
335
+ // MARK: - 10. Preload then play serves from cache
336
+
337
+ func testPreloadThenPlayServesFromCache() throws {
338
+ let testData = Data(repeating: 0x99, count: 4096)
339
+ let upstream = try LocalAudioServer(
340
+ audioData: testData,
341
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
342
+ )
343
+ upstream.start()
344
+ defer { upstream.stop() }
345
+
346
+ let upstreamURL = upstream.url
347
+ let key = cache.cacheKey(for: upstreamURL)
348
+
349
+ // Preload via coordinator (simulating TrackPlayer.preload)
350
+ let preloadDone = expectation(description: "preload complete")
351
+ coordinator.download(url: upstreamURL, headers: nil, cacheKey: key) { _ in
352
+ preloadDone.fulfill()
353
+ }
354
+ wait(for: [preloadDone], timeout: 5.0)
355
+
356
+ XCTAssertTrue(cache.isFullyCached(key: key))
357
+
358
+ // Now fetch via proxy (simulating playback) — should be a cache hit
359
+ let proxyURL = proxy.proxyURL(for: upstreamURL, headers: nil)
360
+ let (data, response) = try fetch(proxyURL)
361
+
362
+ XCTAssertEqual(response.statusCode, 200)
363
+ XCTAssertEqual(data.count, testData.count)
364
+ }
365
+
366
+ // MARK: - 9. Range request on cache hit serves correct slice
367
+
368
+ func testRangeRequestOnCacheHit() throws {
369
+ let testData = Data((0..<1024).map { UInt8($0 % 256) })
370
+ let url = URL(string: "https://example.com/range-test.mp3")!
371
+ let key = cache.cacheKey(for: url)
372
+
373
+ // Pre-populate full cache.
374
+ let info = AudioCache.ContentInfo(
375
+ contentType: "audio/mpeg",
376
+ contentLength: Int64(testData.count),
377
+ isByteRangeAccessSupported: true
378
+ )
379
+ cache.storeContentInfo(info, for: key, url: url)
380
+ cache.appendData(testData, for: key)
381
+
382
+ // Request bytes 500-799.
383
+ let proxyURL = proxy.proxyURL(for: url, headers: nil)
384
+ var request = URLRequest(url: proxyURL)
385
+ request.setValue("bytes=500-799", forHTTPHeaderField: "Range")
386
+
387
+ let expectation = self.expectation(description: "range fetch")
388
+ var resultData: Data?
389
+ var resultResponse: HTTPURLResponse?
390
+
391
+ let task = URLSession.shared.dataTask(with: request) { data, response, _ in
392
+ resultData = data
393
+ resultResponse = response as? HTTPURLResponse
394
+ expectation.fulfill()
395
+ }
396
+ task.resume()
397
+ wait(for: [expectation], timeout: 5.0)
398
+
399
+ XCTAssertEqual(resultResponse?.statusCode, 206)
400
+ XCTAssertEqual(resultData?.count, 300)
401
+ XCTAssertEqual(resultData, testData.subdata(in: 500..<800))
402
+ }
403
+ }
@@ -0,0 +1,197 @@
1
+ //
2
+ // Copyright (c) Double Symmetry GmbH
3
+ // Commercial use requires a license. See https://rntp.dev/pricing
4
+ //
5
+
6
+ import XCTest
7
+ @testable import PlayerCore
8
+
9
+ final class DownloadCoordinatorTests: XCTestCase {
10
+ private var cacheDir: URL!
11
+ private var cache: AudioCache!
12
+ private var coordinator: DownloadCoordinator!
13
+
14
+ override func setUp() {
15
+ super.setUp()
16
+ cacheDir = FileManager.default.temporaryDirectory
17
+ .appendingPathComponent("DownloadCoordinatorTests-\(UUID().uuidString)", isDirectory: true)
18
+ cache = AudioCache(maxSizeBytes: 10 * 1024 * 1024, directory: cacheDir)
19
+ coordinator = DownloadCoordinator(cache: cache)
20
+ }
21
+
22
+ override func tearDown() {
23
+ coordinator = nil
24
+ try? FileManager.default.removeItem(at: cacheDir)
25
+ super.tearDown()
26
+ }
27
+
28
+ func testDownloadCachesDataAndFinalizesContentLength() throws {
29
+ let testData = Data(repeating: 0xAB, count: 1000)
30
+ let server = try LocalAudioServer(
31
+ audioData: testData,
32
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
33
+ )
34
+ server.start()
35
+ defer { server.stop() }
36
+
37
+ let url = server.url
38
+ let key = cache.cacheKey(for: url)
39
+ let done = expectation(description: "download complete")
40
+
41
+ coordinator.download(url: url, headers: nil) { result in
42
+ if case .success = result { done.fulfill() }
43
+ }
44
+
45
+ wait(for: [done], timeout: 5.0)
46
+
47
+ XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
48
+ let info = cache.contentInfo(for: key)
49
+ XCTAssertNotNil(info)
50
+ XCTAssertEqual(info?.contentLength, Int64(testData.count))
51
+ }
52
+
53
+ func testDownloadWithoutContentLengthFinalizesOnCompletion() throws {
54
+ let testData = Data(repeating: 0xCD, count: 500)
55
+ let server = try LocalAudioServer(
56
+ audioData: testData,
57
+ options: .init(includeContentLength: false, contentType: "audio/aac")
58
+ )
59
+ server.start()
60
+ defer { server.stop() }
61
+
62
+ let url = server.url
63
+ let key = cache.cacheKey(for: url)
64
+ let done = expectation(description: "download complete")
65
+
66
+ coordinator.download(url: url, headers: nil) { result in
67
+ if case .success = result { done.fulfill() }
68
+ }
69
+
70
+ wait(for: [done], timeout: 5.0)
71
+
72
+ XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
73
+ let info = cache.contentInfo(for: key)
74
+ XCTAssertNotNil(info)
75
+ XCTAssertEqual(info?.contentLength, Int64(testData.count),
76
+ "Content length should be finalized to actual byte count")
77
+ }
78
+
79
+ func testDuplicateDownloadReusesActiveTask() throws {
80
+ let testData = Data(repeating: 0xEF, count: 2000)
81
+ let server = try LocalAudioServer(
82
+ audioData: testData,
83
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
84
+ )
85
+ server.start()
86
+ defer { server.stop() }
87
+
88
+ let url = server.url
89
+ let key = cache.cacheKey(for: url)
90
+
91
+ let done1 = expectation(description: "first subscriber done")
92
+ let done2 = expectation(description: "second subscriber done")
93
+
94
+ coordinator.download(url: url, headers: nil) { _ in done1.fulfill() }
95
+ coordinator.download(url: url, headers: nil) { _ in done2.fulfill() }
96
+
97
+ wait(for: [done1, done2], timeout: 5.0)
98
+
99
+ // Data should only be cached once (not duplicated)
100
+ XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
101
+ }
102
+
103
+ func testCancellingOneSubscriberDoesNotStopDownload() throws {
104
+ let testData = Data(repeating: 0x11, count: 1000)
105
+ let server = try LocalAudioServer(
106
+ audioData: testData,
107
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
108
+ )
109
+ server.start()
110
+ defer { server.stop() }
111
+
112
+ let url = server.url
113
+ let key = cache.cacheKey(for: url)
114
+
115
+ let done = expectation(description: "second subscriber done")
116
+
117
+ let token1 = coordinator.download(url: url, headers: nil) { _ in }
118
+ coordinator.download(url: url, headers: nil) { _ in done.fulfill() }
119
+ coordinator.cancelDownload(token: token1)
120
+
121
+ wait(for: [done], timeout: 5.0)
122
+ XCTAssertEqual(cache.cachedBytes(for: key), Int64(testData.count))
123
+ }
124
+
125
+ func testCancellingAllSubscribersCancelsDownload() throws {
126
+ let testData = Data(repeating: 0x22, count: 1000)
127
+ let server = try LocalAudioServer(
128
+ audioData: testData,
129
+ options: .init(includeContentLength: true, contentType: "audio/mpeg")
130
+ )
131
+ server.start()
132
+ defer { server.stop() }
133
+
134
+ let url = server.url
135
+
136
+ let token1 = coordinator.download(url: url, headers: nil) { _ in }
137
+ let token2 = coordinator.download(url: url, headers: nil) { _ in }
138
+ coordinator.cancelDownload(token: token1)
139
+ coordinator.cancelDownload(token: token2)
140
+
141
+ // Give time for cancellation to propagate
142
+ Thread.sleep(forTimeInterval: 0.5)
143
+
144
+ XCTAssertTrue(coordinator.activeDownloadCount == 0)
145
+ }
146
+
147
+ func testDownloadStopsAtMaxBytes() throws {
148
+ // Use 200 KB so the slow-network path sends multiple 64 KB chunks,
149
+ // giving the coordinator a chance to cancel mid-stream.
150
+ let totalBytes = 200 * 1024
151
+ let testData = Data(repeating: 0xBB, count: totalBytes)
152
+ let server = try LocalAudioServer(
153
+ audioData: testData,
154
+ options: .init(includeContentLength: true, contentType: "audio/mpeg", simulateSlowNetwork: true)
155
+ )
156
+ server.start()
157
+ defer { server.stop() }
158
+
159
+ let url = server.url
160
+ let key = cache.cacheKey(for: url)
161
+ // Stop after ~70 KB — well into the second 64 KB chunk but short of the full 200 KB.
162
+ let maxBytes: Int64 = 70 * 1024
163
+ let done = expectation(description: "download stopped at limit")
164
+
165
+ coordinator.download(url: url, headers: nil, cacheKey: key, maxBytes: maxBytes) { result in
166
+ if case .success = result { done.fulfill() }
167
+ }
168
+
169
+ wait(for: [done], timeout: 10.0)
170
+
171
+ let cached = cache.cachedBytes(for: key)
172
+ // Should be at least maxBytes but not the full file.
173
+ // The coordinator checks after each data chunk, so it may slightly overshoot.
174
+ XCTAssertGreaterThanOrEqual(cached, maxBytes)
175
+ XCTAssertLessThan(cached, Int64(testData.count),
176
+ "Should not have downloaded the full file")
177
+ }
178
+
179
+ func testFullyCachedURLSkipsDownload() throws {
180
+ let testData = Data(repeating: 0x33, count: 100)
181
+ let url = URL(string: "https://example.com/cached.mp3")!
182
+ let key = cache.cacheKey(for: url)
183
+
184
+ // Pre-populate cache
185
+ let info = AudioCache.ContentInfo(contentType: "audio/mpeg", contentLength: 100, isByteRangeAccessSupported: false)
186
+ cache.storeContentInfo(info, for: key, url: url)
187
+ cache.appendData(testData, for: key)
188
+
189
+ let done = expectation(description: "immediate completion")
190
+ coordinator.download(url: url, headers: nil) { result in
191
+ if case .success = result { done.fulfill() }
192
+ }
193
+
194
+ wait(for: [done], timeout: 1.0)
195
+ XCTAssertEqual(coordinator.activeDownloadCount, 0, "No download should have started")
196
+ }
197
+ }