react-native-audio-browser 0.2.0-next.27 → 0.2.0-next.29

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 (139) hide show
  1. package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  3. package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
  4. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  5. package/ios/AVPlayerEngine.swift +334 -0
  6. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  7. package/ios/Browser/BrowseGate.swift +58 -0
  8. package/ios/Browser/BrowserConfig.swift +31 -89
  9. package/ios/Browser/BrowserManager.swift +68 -487
  10. package/ios/Browser/NavigationErrorState.swift +41 -0
  11. package/ios/Browser/PageResolver.swift +227 -0
  12. package/ios/Browser/RequestLayer.swift +90 -0
  13. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  14. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  15. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  16. package/ios/CarPlay/CarPlayController.swift +42 -1442
  17. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  18. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  19. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  20. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  21. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  22. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  23. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  24. package/ios/CarPlay/CarPlaySession.swift +209 -0
  25. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  26. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  27. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  28. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  29. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  30. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  31. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  32. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  33. package/ios/Extension/URLError+Connectivity.swift +15 -0
  34. package/ios/Http/HttpClient.swift +63 -145
  35. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  36. package/ios/HybridAudioBrowser+Options.swift +144 -0
  37. package/ios/HybridAudioBrowser+Player.swift +397 -0
  38. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  39. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  40. package/ios/HybridAudioBrowser.swift +74 -1687
  41. package/ios/Model/NitroTypeStubs.swift +18 -1
  42. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  43. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  44. package/ios/Observer/ItemObserver.swift +90 -0
  45. package/ios/Observer/PlayerObserver.swift +59 -0
  46. package/ios/Option/SessionCategories.swift +41 -92
  47. package/ios/Player/AudioSessionController.swift +177 -0
  48. package/ios/Player/DeferredSeek.swift +70 -0
  49. package/ios/Player/ErrorStatus.swift +27 -0
  50. package/ios/Player/MediaLoader.swift +24 -11
  51. package/ios/Player/NowPlayingUpdater.swift +157 -58
  52. package/ios/Player/PlaybackFailure.swift +125 -0
  53. package/ios/Player/PlaybackPersistence.swift +73 -0
  54. package/ios/Player/Player.swift +1083 -0
  55. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  56. package/ios/Player/PlayerEngine.swift +89 -0
  57. package/ios/Player/PlayerStatusTypes.swift +2 -6
  58. package/ios/Player/QueueManager.swift +52 -59
  59. package/ios/Player/RetryLoop.swift +261 -0
  60. package/ios/Player/SleepTimerManager.swift +1 -1
  61. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  62. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  63. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  64. package/ios/Tests/DeferredSeekTests.swift +70 -0
  65. package/ios/Tests/EmitterTests.swift +29 -0
  66. package/ios/Tests/GenerationMemoTests.swift +121 -0
  67. package/ios/Tests/LRUCacheTests.swift +4 -5
  68. package/ios/Tests/MediaLoaderTests.swift +12 -33
  69. package/ios/Tests/ObserverTests.swift +88 -0
  70. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  71. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  72. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  73. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  74. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  75. package/ios/Tests/PlayingStateTests.swift +42 -0
  76. package/ios/Tests/QueueManagerTests.swift +76 -107
  77. package/ios/Tests/ReadyValueTests.swift +73 -0
  78. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  79. package/ios/Tests/RemoteCommandTests.swift +2 -4
  80. package/ios/Tests/RequestLayerTests.swift +112 -0
  81. package/ios/Tests/RetryLoopTests.swift +270 -0
  82. package/ios/Tests/Support/TestTypes.swift +111 -112
  83. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  84. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  85. package/ios/TrackSelector.swift +1 -1
  86. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  87. package/ios/Util/Emitter.swift +42 -0
  88. package/ios/Util/GenerationMemo.swift +36 -0
  89. package/ios/Util/LRUCache.swift +23 -162
  90. package/ios/Util/NetworkMonitor.swift +1 -1
  91. package/ios/Util/ReadyValue.swift +56 -0
  92. package/ios/Util/SFSymbolRenderer.swift +35 -29
  93. package/ios/Util/SVGProcessor.swift +0 -10
  94. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  95. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/module/web/NativeAudioBrowser.js +1 -1
  97. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  98. package/lib/typescript/src/types/browser.d.ts +3 -3
  99. package/package.json +1 -1
  100. package/src/types/browser.ts +3 -3
  101. package/src/web/NativeAudioBrowser.ts +1 -1
  102. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  103. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  104. package/ios/Bridge.h +0 -8
  105. package/ios/Browser/MediaResolveComposer.swift +0 -114
  106. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  107. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  108. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  109. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  110. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  111. package/ios/Observer/PlayerStateObserver.swift +0 -50
  112. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  113. package/ios/Option/PitchAlgorithms.swift +0 -17
  114. package/ios/Option/TimeEventFrequency.swift +0 -18
  115. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  116. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  117. package/ios/Player/PlaybackCoordinator.swift +0 -761
  118. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  119. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  120. package/ios/Player/PlaybackStateStore.swift +0 -27
  121. package/ios/Player/PlayingStateManager.swift +0 -33
  122. package/ios/Player/RetryManager.swift +0 -384
  123. package/ios/Player/SeekCompletionHandler.swift +0 -5
  124. package/ios/Player/SleepTimerHandling.swift +0 -28
  125. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  126. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  127. package/ios/Tests/MediaResolveTests.swift +0 -126
  128. package/ios/Tests/OnceValueTests.swift +0 -79
  129. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  130. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  131. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  132. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  133. package/ios/Tests/RetryManagerTests.swift +0 -178
  134. package/ios/TrackPlayer.swift +0 -1042
  135. package/ios/TrackPlayerCallbacks.swift +0 -53
  136. package/ios/Util/MetadataAdapter.swift +0 -131
  137. package/ios/Util/OnceValue.swift +0 -104
  138. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  139. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -1,28 +0,0 @@
1
- import Foundation
2
-
3
- /// Protocol abstracting sleep timer for testability.
4
- /// PlaybackCoordinator uses this instead of SleepTimerManager directly.
5
- @MainActor protocol SleepTimerHandling: AnyObject {
6
- var onComplete: (() -> Void)? { get set }
7
- /// Invoked when the fade-out window of a time-based timer begins.
8
- var onFadeStart: ((_ duration: TimeInterval) -> Void)? { get set }
9
- /// Invoked when a timer is cancelled/replaced while its fade may be running —
10
- /// the listener restores the pre-fade volume.
11
- var onFadeCancel: (() -> Void)? { get set }
12
- @discardableResult func clear() -> Bool
13
- func onTrackChanged()
14
- func onTrackPlayedToEnd()
15
-
16
- // Consumer-facing surface — HybridAudioBrowser drives these through
17
- // TrackPlayer, so they belong here rather than behind a concrete downcast.
18
-
19
- /// Invoked when the timer state changes (set, cleared, completed).
20
- var onChanged: ((SleepTimerState) -> Void)? { get set }
21
- /// The current timer state, or nil when no timer is active.
22
- func get() -> SleepTimerState?
23
- /// Sets a time-based timer, optionally fading out over the last
24
- /// `fadeDuration` seconds.
25
- func set(seconds: TimeInterval, fadeDuration: TimeInterval?)
26
- /// Sets the timer to stop playback when the current track ends.
27
- func setToEndOfTrack()
28
- }
@@ -1,7 +0,0 @@
1
- //
2
- // Use this file to import your target's public headers that you would like to expose to Swift.
3
- //
4
-
5
- #import <React/RCTBridgeModule.h>
6
- #import <React/RCTEventEmitter.h>
7
- #import <React/RCTConvert.h>
@@ -1,327 +0,0 @@
1
- import AVFoundation
2
- import Testing
3
-
4
- @testable import AudioBrowserTestable
5
-
6
- // MARK: - Helpers
7
-
8
- @MainActor
9
- private final class SeekCompletionSpy: SeekCompletionHandler {
10
- var calls: [(seconds: Double, didFinish: Bool)] = []
11
- func handleSeekCompleted(to seconds: Double, didFinish: Bool) {
12
- calls.append((seconds, didFinish))
13
- }
14
- }
15
-
16
- @MainActor
17
- private func makeCoordinator() -> LoadSeekCoordinator {
18
- LoadSeekCoordinator()
19
- }
20
-
21
- // MARK: - Initial State
22
-
23
- @Suite("initial state")
24
- @MainActor
25
- struct InitialStateTests {
26
- @Test func startsIdle() {
27
- let c = makeCoordinator()
28
- guard case .idle = c.state else {
29
- Issue.record("expected .idle, got \(c.state)")
30
- return
31
- }
32
- }
33
-
34
- @Test func pendingTimeIsNil() {
35
- let c = makeCoordinator()
36
- #expect(c.pendingTime == nil)
37
- }
38
-
39
- @Test func shouldDeferReadyTransitionIsFalse() {
40
- let c = makeCoordinator()
41
- #expect(c.shouldDeferReadyTransition == false)
42
- }
43
- }
44
-
45
- // MARK: - Capture
46
-
47
- @Suite("capture")
48
- @MainActor
49
- struct CaptureTests {
50
- @Test func setsStateToPendingSeek() {
51
- let c = makeCoordinator()
52
- c.capture(position: 42.0)
53
- guard case let .pendingSeek(time) = c.state else {
54
- Issue.record("expected .pendingSeek, got \(c.state)")
55
- return
56
- }
57
- #expect(time == 42.0)
58
- }
59
-
60
- @Test func updatesPendingTime() {
61
- let c = makeCoordinator()
62
- c.capture(position: 10.0)
63
- #expect(c.pendingTime == 10.0)
64
- }
65
-
66
- @Test func shouldDeferReadyTransitionBecomesTrue() {
67
- let c = makeCoordinator()
68
- c.capture(position: 5.0)
69
- #expect(c.shouldDeferReadyTransition == true)
70
- }
71
-
72
- @Test func supersedesPreviousPendingSeek() {
73
- let c = makeCoordinator()
74
- c.capture(position: 10.0)
75
- c.capture(position: 20.0)
76
- #expect(c.pendingTime == 20.0)
77
- }
78
-
79
- @Test func overridesSeekInFlight() {
80
- let c = makeCoordinator()
81
- let player = AVPlayer()
82
- c.capture(position: 10.0)
83
- _ = c.executeIfPending(on: player, delegate: nil)
84
- // Now in seekInFlight
85
- guard case .seekInFlight = c.state else {
86
- Issue.record("expected .seekInFlight, got \(c.state)")
87
- return
88
- }
89
- c.capture(position: 30.0)
90
- guard case let .pendingSeek(time) = c.state else {
91
- Issue.record("expected .pendingSeek, got \(c.state)")
92
- return
93
- }
94
- #expect(time == 30.0)
95
- }
96
- }
97
-
98
- // MARK: - executeIfPending
99
-
100
- @Suite("executeIfPending")
101
- @MainActor
102
- struct ExecuteIfPendingTests {
103
- @Test func returnsFalseWhenIdle() {
104
- let c = makeCoordinator()
105
- let player = AVPlayer()
106
- #expect(c.executeIfPending(on: player, delegate: nil) == false)
107
- }
108
-
109
- @Test func returnsTrueWhenPendingSeek() {
110
- let c = makeCoordinator()
111
- let player = AVPlayer()
112
- c.capture(position: 15.0)
113
- #expect(c.executeIfPending(on: player, delegate: nil) == true)
114
- }
115
-
116
- @Test func transitionsToSeekInFlight() {
117
- let c = makeCoordinator()
118
- let player = AVPlayer()
119
- c.capture(position: 15.0)
120
- _ = c.executeIfPending(on: player, delegate: nil)
121
- guard case let .seekInFlight(time) = c.state else {
122
- Issue.record("expected .seekInFlight, got \(c.state)")
123
- return
124
- }
125
- #expect(time == 15.0)
126
- }
127
-
128
- @Test func preservesTimeValueThroughTransition() {
129
- let c = makeCoordinator()
130
- let player = AVPlayer()
131
- c.capture(position: 99.5)
132
- _ = c.executeIfPending(on: player, delegate: nil)
133
- #expect(c.pendingTime == 99.5)
134
- }
135
-
136
- @Test func returnsFalseWhenSeekInFlight() {
137
- let c = makeCoordinator()
138
- let player = AVPlayer()
139
- c.capture(position: 10.0)
140
- _ = c.executeIfPending(on: player, delegate: nil)
141
- // Now in seekInFlight — calling again should return false
142
- #expect(c.executeIfPending(on: player, delegate: nil) == false)
143
- }
144
- }
145
-
146
- // MARK: - seekDidComplete
147
-
148
- @Suite("seekDidComplete")
149
- @MainActor
150
- struct SeekDidCompleteTests {
151
- @Test func fromSeekInFlight_returnsTrue_resetsToIdle() {
152
- let c = makeCoordinator()
153
- let player = AVPlayer()
154
- c.capture(position: 10.0)
155
- _ = c.executeIfPending(on: player, delegate: nil)
156
- #expect(c.seekDidComplete(on: player, delegate: nil) == true)
157
- guard case .idle = c.state else {
158
- Issue.record("expected .idle, got \(c.state)")
159
- return
160
- }
161
- }
162
-
163
- @Test func fromPendingSeek_returnsFalse_reExecutes() {
164
- let c = makeCoordinator()
165
- let player = AVPlayer()
166
- // Start a seek
167
- c.capture(position: 10.0)
168
- _ = c.executeIfPending(on: player, delegate: nil)
169
- // Queue a new seek while in-flight
170
- c.capture(position: 20.0)
171
- // Complete the first seek — should re-execute
172
- #expect(c.seekDidComplete(on: player, delegate: nil) == false)
173
- guard case let .seekInFlight(time) = c.state else {
174
- Issue.record("expected .seekInFlight, got \(c.state)")
175
- return
176
- }
177
- #expect(time == 20.0)
178
- }
179
-
180
- @Test func fromIdle_returnsFalse() {
181
- let c = makeCoordinator()
182
- let player = AVPlayer()
183
- #expect(c.seekDidComplete(on: player, delegate: nil) == false)
184
- }
185
- }
186
-
187
- // MARK: - Reset
188
-
189
- @Suite("reset")
190
- @MainActor
191
- struct ResetTests {
192
- @Test func clearsFromPendingSeekToIdle() {
193
- let c = makeCoordinator()
194
- c.capture(position: 10.0)
195
- c.reset()
196
- guard case .idle = c.state else {
197
- Issue.record("expected .idle, got \(c.state)")
198
- return
199
- }
200
- #expect(c.pendingTime == nil)
201
- }
202
-
203
- @Test func clearsFromSeekInFlightToIdle() {
204
- let c = makeCoordinator()
205
- let player = AVPlayer()
206
- c.capture(position: 10.0)
207
- _ = c.executeIfPending(on: player, delegate: nil)
208
- c.reset()
209
- guard case .idle = c.state else {
210
- Issue.record("expected .idle, got \(c.state)")
211
- return
212
- }
213
- }
214
-
215
- @Test func noopWhenAlreadyIdle() {
216
- let c = makeCoordinator()
217
- c.reset()
218
- guard case .idle = c.state else {
219
- Issue.record("expected .idle, got \(c.state)")
220
- return
221
- }
222
- }
223
-
224
- @Test func invalidatesTheGenerationOfSeeksInFlight() {
225
- let c = makeCoordinator()
226
- let issuedAgainst = c.generation
227
- #expect(c.isCurrentGeneration(issuedAgainst))
228
- c.reset()
229
- #expect(!c.isCurrentGeneration(issuedAgainst))
230
- }
231
-
232
- /// The cross-item race: a seek is in flight when a queue change resets the
233
- /// coordinator and captures the new track's start position. The old item's
234
- /// cancelled completion (now a stale generation) must be dropped — not
235
- /// delivered as a completion that would consume the fresh pending seek.
236
- @Test func staleCompletionAfterResetIsDroppedAndKeepsThePendingSeek() async {
237
- let c = makeCoordinator()
238
- let player = AVPlayer()
239
- let spy = SeekCompletionSpy()
240
-
241
- c.capture(position: 10.0)
242
- _ = c.executeIfPending(on: player, delegate: spy)
243
-
244
- // Queue change: reset, then capture the new track's start position.
245
- c.reset()
246
- c.capture(position: 25.0)
247
-
248
- // Let the (immediately-cancelled, itemless) seek completion drain.
249
- for _ in 0 ..< 20 {
250
- await Task.yield()
251
- }
252
-
253
- #expect(spy.calls.isEmpty)
254
- #expect(c.pendingTime == 25.0)
255
- }
256
- }
257
-
258
- // MARK: - Integration
259
-
260
- @Suite("integration")
261
- @MainActor
262
- struct IntegrationTests {
263
- @Test func fullCycle_capture_execute_complete() {
264
- let c = makeCoordinator()
265
- let player = AVPlayer()
266
-
267
- // Capture
268
- c.capture(position: 30.0)
269
- #expect(c.shouldDeferReadyTransition == true)
270
-
271
- // Execute
272
- let executed = c.executeIfPending(on: player, delegate: nil)
273
- #expect(executed == true)
274
- guard case .seekInFlight = c.state else {
275
- Issue.record("expected .seekInFlight")
276
- return
277
- }
278
-
279
- // Complete
280
- let done = c.seekDidComplete(on: player, delegate: nil)
281
- #expect(done == true)
282
- guard case .idle = c.state else {
283
- Issue.record("expected .idle")
284
- return
285
- }
286
- #expect(c.shouldDeferReadyTransition == false)
287
- }
288
-
289
- @Test func overrideDuringInFlight_capture_execute_capture_complete_reExecute() {
290
- let c = makeCoordinator()
291
- let player = AVPlayer()
292
-
293
- // First seek
294
- c.capture(position: 10.0)
295
- _ = c.executeIfPending(on: player, delegate: nil)
296
- guard case let .seekInFlight(t1) = c.state else {
297
- Issue.record("expected .seekInFlight")
298
- return
299
- }
300
- #expect(t1 == 10.0)
301
-
302
- // Override while in-flight
303
- c.capture(position: 50.0)
304
- guard case let .pendingSeek(t2) = c.state else {
305
- Issue.record("expected .pendingSeek")
306
- return
307
- }
308
- #expect(t2 == 50.0)
309
-
310
- // First seek "completes" — should auto re-execute
311
- let done = c.seekDidComplete(on: player, delegate: nil)
312
- #expect(done == false)
313
- guard case let .seekInFlight(t3) = c.state else {
314
- Issue.record("expected .seekInFlight after re-execute")
315
- return
316
- }
317
- #expect(t3 == 50.0)
318
-
319
- // Second seek completes
320
- let done2 = c.seekDidComplete(on: player, delegate: nil)
321
- #expect(done2 == true)
322
- guard case .idle = c.state else {
323
- Issue.record("expected .idle")
324
- return
325
- }
326
- }
327
- }
@@ -1,126 +0,0 @@
1
- import Testing
2
-
3
- @testable import AudioBrowserTestable
4
-
5
- /// Tests for the media `resolve` layer composition: the variant unwrap (sync vs
6
- /// async arm) and the override-wins merge that layers the resolved config over
7
- /// the base. These mirror what `BrowserManager.resolveMediaTrackConfig` +
8
- /// `mergeRequestConfig` do in production, exercised here against the pure
9
- /// `MediaResolveComposer` (which the Nitro path delegates to / duplicates).
10
- @Suite("MediaResolve")
11
- struct MediaResolveTests {
12
- typealias Config = MediaResolveComposer.RequestConfigLike
13
-
14
- // MARK: - Variant unwrap
15
-
16
- @Test("sync (.first) arm yields its config directly")
17
- func unwrapSync() async throws {
18
- let config = Config(userAgent: "RESOLVED")
19
- let result = try await MediaResolveComposer.unwrap(.sync(config))
20
- #expect(result == config)
21
- }
22
-
23
- @Test("async (.second) arm awaits and yields its config")
24
- func unwrapAsync() async throws {
25
- let config = Config(userAgent: "RESOLVED-ASYNC")
26
- let result = try await MediaResolveComposer.unwrap(
27
- .async {
28
- // Simulate the Promise<RequestConfig> resolving after a hop.
29
- await Task.yield()
30
- return config
31
- },
32
- )
33
- #expect(result == config)
34
- }
35
-
36
- @Test("async arm propagates errors (rejected promise)")
37
- func unwrapAsyncThrows() async {
38
- struct Boom: Error {}
39
- await #expect(throws: Boom.self) {
40
- _ = try await MediaResolveComposer.unwrap(
41
- .async { throw Boom() } as MediaResolveComposer.ResolveVariant<Config>,
42
- )
43
- }
44
- }
45
-
46
- // MARK: - Merge as the final layer
47
-
48
- @Test("resolve userAgent overrides base while keeping baseUrl and path")
49
- func mergeOverridesUserAgent() {
50
- let base = Config(path: "/audio.mp3", baseUrl: "https://base.example.com", userAgent: "BASE")
51
- let resolve = Config(userAgent: "RESOLVED")
52
-
53
- let merged = MediaResolveComposer.merge(base: base, override: resolve)
54
-
55
- #expect(merged.userAgent == "RESOLVED")
56
- #expect(merged.baseUrl == "https://base.example.com")
57
- #expect(merged.path == "/audio.mp3")
58
- }
59
-
60
- @Test("async resolve result merges identically once unwrapped")
61
- func mergeAfterAsyncUnwrap() async throws {
62
- let base = Config(path: "/audio.mp3", baseUrl: "https://base.example.com", userAgent: "BASE")
63
- let resolved = try await MediaResolveComposer.unwrap(
64
- .async { Config(userAgent: "RESOLVED-ASYNC") },
65
- )
66
-
67
- let merged = MediaResolveComposer.merge(base: base, override: resolved)
68
-
69
- #expect(merged.userAgent == "RESOLVED-ASYNC")
70
- #expect(merged.baseUrl == "https://base.example.com")
71
- #expect(merged.path == "/audio.mp3")
72
- }
73
-
74
- @Test("absent resolve is a no-op (base is returned unchanged)")
75
- func absentResolveIsNoOp() {
76
- let base = Config(path: "/audio.mp3", baseUrl: "https://base.example.com", userAgent: "BASE")
77
-
78
- // Production guards `resolve == nil` and returns base untouched; modelled as
79
- // merging an all-nil override, which must leave every field intact.
80
- let merged = MediaResolveComposer.merge(base: base, override: Config())
81
-
82
- #expect(merged == base)
83
- }
84
-
85
- @Test("headers and query merge per-key with override winning")
86
- func mergeDictsPerKey() {
87
- let base = Config(
88
- headers: ["User-Agent": "BASE", "X-Base": "1"],
89
- query: ["a": "1"],
90
- )
91
- let resolve = Config(
92
- headers: ["User-Agent": "RESOLVED", "X-Resolve": "2"],
93
- query: ["b": "2"],
94
- )
95
-
96
- let merged = MediaResolveComposer.merge(base: base, override: resolve)
97
-
98
- #expect(merged.headers == ["User-Agent": "RESOLVED", "X-Base": "1", "X-Resolve": "2"])
99
- #expect(merged.query == ["a": "1", "b": "2"])
100
- }
101
-
102
- @Test("mergeDicts is nil-safe on either side")
103
- func mergeDictsNilSafe() {
104
- #expect(MediaResolveComposer.mergeDicts(nil, nil) == nil)
105
- #expect(MediaResolveComposer.mergeDicts(["a": "1"], nil) == ["a": "1"])
106
- #expect(MediaResolveComposer.mergeDicts(nil, ["b": "2"]) == ["b": "2"])
107
- }
108
-
109
- // MARK: - Run-both resolve composition
110
-
111
- @Test("resolve composition merges async then sync (sync wins); nil when neither")
112
- func composeResolved() {
113
- // Same `combine` production uses (mergeRequestConfig), here over the test type.
114
- let combine: (Config, Config) -> Config = { MediaResolveComposer.merge(base: $0, override: $1) }
115
- #expect(MediaResolveComposer.composeResolved(async: nil, sync: nil, combine: combine) == nil)
116
-
117
- let a = Config(baseUrl: "https://async.example.com", userAgent: "ASYNC")
118
- let s = Config(userAgent: "SYNC")
119
- let merged = MediaResolveComposer.composeResolved(async: a, sync: s, combine: combine)
120
- #expect(merged?.userAgent == "SYNC") // sync wins
121
- #expect(merged?.baseUrl == "https://async.example.com") // async's baseUrl survives
122
-
123
- #expect(MediaResolveComposer.composeResolved(async: a, sync: nil, combine: combine)?.userAgent == "ASYNC")
124
- #expect(MediaResolveComposer.composeResolved(async: nil, sync: s, combine: combine)?.userAgent == "SYNC")
125
- }
126
- }
@@ -1,79 +0,0 @@
1
- import Foundation
2
- import Testing
3
-
4
- @testable import AudioBrowserTestable
5
-
6
- @Suite("OnceValue")
7
- struct OnceValueTests {
8
- @Test func waitReturnsImmediatelyWhenAlreadyResolved() async {
9
- let once = OnceValue<Int>()
10
- once.resolve(7)
11
- let value = await once.wait()
12
- #expect(value == 7)
13
- }
14
-
15
- @Test func waitResumesAllWaitersOnResolve() async {
16
- let once = OnceValue<String>()
17
- async let first = once.wait()
18
- async let second = once.wait()
19
- once.resolve("ready")
20
- let values = await [first, second]
21
- #expect(values == ["ready", "ready"])
22
- }
23
-
24
- @Test func checkResolvesViaProvider() async {
25
- nonisolated(unsafe) var ready: Int?
26
- let once = OnceValue<Int> { ready }
27
- once.check()
28
- ready = 3
29
- once.check()
30
- let value = await once.wait()
31
- #expect(value == 3)
32
- }
33
-
34
- @Test func firstResolveWins() async {
35
- let once = OnceValue<Int>()
36
- once.resolve(1)
37
- once.resolve(2)
38
- let value = await once.wait()
39
- #expect(value == 1)
40
- }
41
- }
42
-
43
- @Suite("OnceValue.wait(timeout:)")
44
- struct OnceValueTimeoutTests {
45
- @Test func returnsValueWhenAlreadyResolved() async {
46
- let once = OnceValue<Int>()
47
- once.resolve(42)
48
- let value = await once.wait(timeout: .milliseconds(50))
49
- #expect(value == 42)
50
- }
51
-
52
- @Test func returnsValueWhenResolvedDuringWait() async {
53
- let once = OnceValue<Int>()
54
- Task {
55
- try? await Task.sleep(for: .milliseconds(10))
56
- once.resolve(42)
57
- }
58
- let value = await once.wait(timeout: .seconds(5))
59
- #expect(value == 42)
60
- }
61
-
62
- @Test func returnsNilOnTimeout() async {
63
- let once = OnceValue<Int>()
64
- let value = await once.wait(timeout: .milliseconds(50))
65
- #expect(value == nil)
66
- }
67
-
68
- @Test func lateResolveAfterTimeoutIsHarmless() async {
69
- let once = OnceValue<Int>()
70
- let value = await once.wait(timeout: .milliseconds(20))
71
- #expect(value == nil)
72
- // The timed-out waiter's continuation is still registered; resolving must
73
- // not double-resume it, and fresh waits must see the value.
74
- once.resolve(9)
75
- try? await Task.sleep(for: .milliseconds(20))
76
- let late = await once.wait(timeout: .milliseconds(50))
77
- #expect(late == 9)
78
- }
79
- }