react-native-audio-browser 0.2.0-next.28 → 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.
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
|
@@ -1,549 +0,0 @@
|
|
|
1
|
-
import AVFoundation
|
|
2
|
-
import Foundation
|
|
3
|
-
import Testing
|
|
4
|
-
|
|
5
|
-
@testable import AudioBrowserTestable
|
|
6
|
-
|
|
7
|
-
// MARK: - Mock RetryHandler
|
|
8
|
-
|
|
9
|
-
@MainActor
|
|
10
|
-
private final class MockRetryHandler: RetryHandling {
|
|
11
|
-
var isEnabled = true
|
|
12
|
-
var hasPlayed = false
|
|
13
|
-
var retryableErrors: Set<Int> = []
|
|
14
|
-
var attemptRetryResult = false
|
|
15
|
-
var attemptRetryCallCount = 0
|
|
16
|
-
var lastStartFromCurrentTime: Bool?
|
|
17
|
-
var lastHTTPStatusCode: Int??
|
|
18
|
-
var resetCallCount = 0
|
|
19
|
-
|
|
20
|
-
func isRetryable(_ error: Error?, httpStatusCode: Int?) -> Bool {
|
|
21
|
-
lastHTTPStatusCode = .some(httpStatusCode)
|
|
22
|
-
guard let error else { return false }
|
|
23
|
-
let code = (error as NSError).code
|
|
24
|
-
return retryableErrors.contains(code)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
var attemptRetryDelayNs: UInt64 = 0
|
|
28
|
-
|
|
29
|
-
func attemptRetry(startFromCurrentTime: Bool) async -> Bool {
|
|
30
|
-
attemptRetryCallCount += 1
|
|
31
|
-
lastStartFromCurrentTime = startFromCurrentTime
|
|
32
|
-
if attemptRetryDelayNs > 0 {
|
|
33
|
-
try? await Task.sleep(nanoseconds: attemptRetryDelayNs)
|
|
34
|
-
}
|
|
35
|
-
return attemptRetryResult
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
func reset() {
|
|
39
|
-
resetCallCount += 1
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// MARK: - Error Classification
|
|
44
|
-
|
|
45
|
-
@Suite("PlaybackErrorHandler.classify")
|
|
46
|
-
@MainActor
|
|
47
|
-
struct ClassifyTests {
|
|
48
|
-
@Test func internetError_returnsNotConnectedToInternet() {
|
|
49
|
-
let error = URLError(.notConnectedToInternet)
|
|
50
|
-
let result = PlaybackErrorHandler.classify(error: error, fallback: .playbackFailed)
|
|
51
|
-
#expect(result == .notConnectedToInternet)
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/// `.badURL` rather than `.timedOut`: timeouts now classify as
|
|
55
|
-
/// `.hostUnreachable`, so they no longer exercise the fallback path. Both
|
|
56
|
-
/// fallbacks are asserted to pin that `fallback` is plumbed, not hardcoded.
|
|
57
|
-
@Test func unclassifiableError_returnsFallback() {
|
|
58
|
-
#expect(
|
|
59
|
-
PlaybackErrorHandler.classify(error: URLError(.badURL), fallback: .playbackFailed)
|
|
60
|
-
== .playbackFailed,
|
|
61
|
-
)
|
|
62
|
-
#expect(
|
|
63
|
-
PlaybackErrorHandler.classify(error: URLError(.badURL), fallback: .failedToLoadKeyValue)
|
|
64
|
-
== .failedToLoadKeyValue,
|
|
65
|
-
)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
@Test(arguments: [
|
|
69
|
-
URLError.Code.timedOut,
|
|
70
|
-
.cannotFindHost,
|
|
71
|
-
// A TLS failure counts as unreachable: we never got a usable connection.
|
|
72
|
-
.secureConnectionFailed,
|
|
73
|
-
])
|
|
74
|
-
func transportError_returnsHostUnreachable(code: URLError.Code) {
|
|
75
|
-
let result = PlaybackErrorHandler.classify(error: URLError(code), fallback: .playbackFailed)
|
|
76
|
-
#expect(result == .hostUnreachable)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/// AVFoundation hands back plain `NSError`s rather than bridged `URLError`s.
|
|
80
|
-
/// `as?` covers this, but only for `NSURLErrorDomain` — worth pinning, since
|
|
81
|
-
/// losing it would silently downgrade every real timeout to the fallback.
|
|
82
|
-
@Test func unbridgedUrlDomainError_isClassified() {
|
|
83
|
-
let error = NSError(domain: NSURLErrorDomain, code: URLError.Code.cannotFindHost.rawValue)
|
|
84
|
-
let result = PlaybackErrorHandler.classify(error: error, fallback: .playbackFailed)
|
|
85
|
-
#expect(result == .hostUnreachable)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
@Test func httpStatus_isSurfaced() {
|
|
89
|
-
let result = PlaybackErrorHandler.classify(
|
|
90
|
-
error: nil,
|
|
91
|
-
fallback: .failedToLoadKeyValue,
|
|
92
|
-
httpStatusCode: 404,
|
|
93
|
-
)
|
|
94
|
-
#expect(result == .httpStatus(404))
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/// Also covers the nil-error path: with no error and no usable status, the
|
|
98
|
-
/// context fallback is all that is left.
|
|
99
|
-
@Test func successStatus_returnsFallback() {
|
|
100
|
-
#expect(
|
|
101
|
-
PlaybackErrorHandler.classify(
|
|
102
|
-
error: nil,
|
|
103
|
-
fallback: .failedToLoadKeyValue,
|
|
104
|
-
httpStatusCode: 200,
|
|
105
|
-
) == .failedToLoadKeyValue,
|
|
106
|
-
)
|
|
107
|
-
#expect(
|
|
108
|
-
PlaybackErrorHandler.classify(error: nil, fallback: .playbackFailed) == .playbackFailed,
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/// A transport failure means no response arrived, so a status left over in
|
|
113
|
-
/// the item's error log must not outrank it.
|
|
114
|
-
@Test func transportError_outranksHttpStatus() {
|
|
115
|
-
let result = PlaybackErrorHandler.classify(
|
|
116
|
-
error: URLError(.notConnectedToInternet),
|
|
117
|
-
fallback: .playbackFailed,
|
|
118
|
-
httpStatusCode: 500,
|
|
119
|
-
)
|
|
120
|
-
#expect(result == .notConnectedToInternet)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/// A `URLError` we can't classify must fall *through* to the status check,
|
|
124
|
-
/// not short-circuit to the fallback.
|
|
125
|
-
@Test func unclassifiableTransportError_stillUsesHttpStatus() {
|
|
126
|
-
let result = PlaybackErrorHandler.classify(
|
|
127
|
-
error: URLError(.badURL),
|
|
128
|
-
fallback: .playbackFailed,
|
|
129
|
-
httpStatusCode: 500,
|
|
130
|
-
)
|
|
131
|
-
#expect(result == .httpStatus(500))
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/// The server's own answer is better evidence than "the decoder choked",
|
|
135
|
-
/// which is a downstream symptom of being handed an error page.
|
|
136
|
-
@Test func httpStatus_outranksUnplayableMedia() {
|
|
137
|
-
let error = NSError(
|
|
138
|
-
domain: AVFoundationErrorDomain,
|
|
139
|
-
code: AVError.fileFormatNotRecognized.rawValue,
|
|
140
|
-
)
|
|
141
|
-
let result = PlaybackErrorHandler.classify(
|
|
142
|
-
error: error,
|
|
143
|
-
fallback: .playbackFailed,
|
|
144
|
-
httpStatusCode: 404,
|
|
145
|
-
)
|
|
146
|
-
#expect(result == .httpStatus(404))
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
@Test func unplayableMedia_returnsTrackWasUnplayable() {
|
|
150
|
-
let error = NSError(
|
|
151
|
-
domain: AVFoundationErrorDomain,
|
|
152
|
-
code: AVError.fileFormatNotRecognized.rawValue,
|
|
153
|
-
)
|
|
154
|
-
let result = PlaybackErrorHandler.classify(error: error, fallback: .playbackFailed)
|
|
155
|
-
#expect(result == .trackWasUnplayable)
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/// Being in `AVFoundationErrorDomain` is not enough — `AVError.unknown` is
|
|
159
|
-
/// in-domain but tells us nothing.
|
|
160
|
-
@Test func unrelatedAVError_returnsFallback() {
|
|
161
|
-
let error = NSError(domain: AVFoundationErrorDomain, code: AVError.unknown.rawValue)
|
|
162
|
-
let result = PlaybackErrorHandler.classify(error: error, fallback: .playbackFailed)
|
|
163
|
-
#expect(result == .playbackFailed)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// MARK: - Error Log Status Extraction
|
|
168
|
-
|
|
169
|
-
/// The sole source of every `.httpStatus` classification in production.
|
|
170
|
-
@Suite("PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes:)")
|
|
171
|
-
@MainActor
|
|
172
|
-
struct ErrorLogStatusTests {
|
|
173
|
-
/// `errorStatusCode` carries negative OSStatus values in the same field.
|
|
174
|
-
@Test func skipsNonHttpStatusCodes() {
|
|
175
|
-
#expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: [-12939, -1008]) == nil)
|
|
176
|
-
#expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: [-12939, 404]) == 404)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/// A live stream reconnects; older entries describe attempts that are no
|
|
180
|
-
/// longer why we are failing.
|
|
181
|
-
@Test func prefersTheNewestInRangeStatus() {
|
|
182
|
-
#expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: [500, 404]) == 404)
|
|
183
|
-
#expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: [404, 500, -12939]) == 500)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
@Test func nilWhenNothingQualifies() {
|
|
187
|
-
#expect(PlaybackErrorHandler.httpStatusCode(fromErrorStatusCodes: []) == nil)
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// MARK: - Cross-platform Kind Mapping
|
|
192
|
-
|
|
193
|
-
/// Only the HTTP table is tested: the `kind` switch is exhaustive with no
|
|
194
|
-
/// `default`, so the compiler already forces every new case to be classified
|
|
195
|
-
/// deliberately, and asserting the other arms would just retype the switch.
|
|
196
|
-
/// The status ranges are different — `404, 410` must precede `400...499`.
|
|
197
|
-
@Suite("PlaybackError.kind")
|
|
198
|
-
@MainActor
|
|
199
|
-
struct PlaybackErrorKindTests {
|
|
200
|
-
@Test func httpStatus_mapsToKind() {
|
|
201
|
-
// Not parameterized: the generated Nitro enum is not Sendable, so it
|
|
202
|
-
// cannot cross a `@Test(arguments:)` isolation boundary.
|
|
203
|
-
let expectations: [(Int, PlaybackErrorKind)] = [
|
|
204
|
-
// Ordering: these must not be swallowed by the 400...499 arm.
|
|
205
|
-
(404, .notFound),
|
|
206
|
-
(410, .notFound),
|
|
207
|
-
// Range edges.
|
|
208
|
-
(400, .rejected),
|
|
209
|
-
(499, .rejected),
|
|
210
|
-
(500, .serverError),
|
|
211
|
-
(599, .serverError),
|
|
212
|
-
(302, .unknown),
|
|
213
|
-
(600, .unknown),
|
|
214
|
-
]
|
|
215
|
-
for (status, expected) in expectations {
|
|
216
|
-
#expect(
|
|
217
|
-
TrackPlayerError.PlaybackError.httpStatus(status).kind == expected,
|
|
218
|
-
"HTTP \(status)",
|
|
219
|
-
)
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// `PlaybackErrorContext`'s two properties are asserted through `handleError`
|
|
225
|
-
// below — `lastStartFromCurrentTime` and the surfaced fallback — rather than
|
|
226
|
-
// read off the enum directly, so the tests exercise the path a caller uses.
|
|
227
|
-
|
|
228
|
-
// MARK: - handleError
|
|
229
|
-
|
|
230
|
-
@Suite("handleError")
|
|
231
|
-
@MainActor
|
|
232
|
-
struct HandleErrorTests {
|
|
233
|
-
@Test func nonRetryableError_surfacesImmediately() {
|
|
234
|
-
let mock = MockRetryHandler()
|
|
235
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
236
|
-
var surfacedError: TrackPlayerError.PlaybackError?
|
|
237
|
-
handler.onError = { surfacedError = $0 }
|
|
238
|
-
|
|
239
|
-
let error = URLError(.badURL)
|
|
240
|
-
handler.handleError(error, context: .playback)
|
|
241
|
-
|
|
242
|
-
#expect(surfacedError == .playbackFailed)
|
|
243
|
-
#expect(mock.attemptRetryCallCount == 0)
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
@Test func nonRetryableInternetError_classifiesCorrectly() {
|
|
247
|
-
let mock = MockRetryHandler()
|
|
248
|
-
// Not in retryableErrors set, so isRetryable returns false
|
|
249
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
250
|
-
var surfacedError: TrackPlayerError.PlaybackError?
|
|
251
|
-
handler.onError = { surfacedError = $0 }
|
|
252
|
-
|
|
253
|
-
let error = URLError(.notConnectedToInternet)
|
|
254
|
-
handler.handleError(error, context: .mediaLoad)
|
|
255
|
-
|
|
256
|
-
// Even though not retryable, classify should detect the internet error
|
|
257
|
-
#expect(surfacedError == .notConnectedToInternet)
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/// Also pins that a completed retry task clears its own handle:
|
|
261
|
-
/// `evaluateSessionRelease` gates on `pendingRetryTask == nil`, so a stale
|
|
262
|
-
/// completed task blocked audio-session release for the rest of the track.
|
|
263
|
-
@Test func retryableError_triggersRetry() async {
|
|
264
|
-
let mock = MockRetryHandler()
|
|
265
|
-
mock.retryableErrors = [URLError.Code.timedOut.rawValue]
|
|
266
|
-
mock.attemptRetryResult = true
|
|
267
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
268
|
-
var surfaced: [TrackPlayerError.PlaybackError] = []
|
|
269
|
-
handler.onError = { surfaced.append($0) }
|
|
270
|
-
|
|
271
|
-
let error = URLError(.timedOut)
|
|
272
|
-
handler.handleError(error, context: .playback)
|
|
273
|
-
#expect(handler.pendingRetryTask != nil)
|
|
274
|
-
|
|
275
|
-
// Wait for the retry task to complete
|
|
276
|
-
await handler.pendingRetryTask?.value
|
|
277
|
-
|
|
278
|
-
#expect(mock.attemptRetryCallCount == 1)
|
|
279
|
-
#expect(mock.lastStartFromCurrentTime == true)
|
|
280
|
-
// Retry succeeded, no error surfaced
|
|
281
|
-
#expect(surfaced.isEmpty)
|
|
282
|
-
#expect(handler.pendingRetryTask == nil)
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
/// A cancelled retry must stay silent: track change / stop cancel the task,
|
|
286
|
-
/// but its tail still runs — attemptRetry returns false (wait cancelled) and
|
|
287
|
-
/// the unguarded body surfaced .errorOccurred over the new track / the
|
|
288
|
-
/// deliberate stop.
|
|
289
|
-
@Test func cancelledRetry_staysSilent() async throws {
|
|
290
|
-
let mock = MockRetryHandler()
|
|
291
|
-
mock.retryableErrors = [URLError.Code.timedOut.rawValue]
|
|
292
|
-
mock.attemptRetryResult = false
|
|
293
|
-
mock.attemptRetryDelayNs = 50_000_000
|
|
294
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
295
|
-
var surfaced: [TrackPlayerError.PlaybackError] = []
|
|
296
|
-
handler.onError = { surfaced.append($0) }
|
|
297
|
-
|
|
298
|
-
handler.handleError(URLError(.timedOut), context: .playback)
|
|
299
|
-
let task = handler.pendingRetryTask
|
|
300
|
-
handler.cancelPendingRetry()
|
|
301
|
-
#expect(handler.pendingRetryTask == nil)
|
|
302
|
-
await task?.value
|
|
303
|
-
|
|
304
|
-
#expect(surfaced.isEmpty)
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
/// A second failure while a retry is in flight cancels the first. The
|
|
308
|
-
/// superseded task's tail must not surface an error, and must not null out
|
|
309
|
-
/// the handle belonging to the retry that replaced it.
|
|
310
|
-
@Test func supersedingError_cancelsTheInFlightRetry() async {
|
|
311
|
-
let mock = MockRetryHandler()
|
|
312
|
-
mock.retryableErrors = [URLError.Code.timedOut.rawValue]
|
|
313
|
-
mock.attemptRetryResult = true
|
|
314
|
-
mock.attemptRetryDelayNs = 20_000_000
|
|
315
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
316
|
-
var surfaced: [TrackPlayerError.PlaybackError] = []
|
|
317
|
-
handler.onError = { surfaced.append($0) }
|
|
318
|
-
|
|
319
|
-
handler.handleError(URLError(.timedOut), context: .playback)
|
|
320
|
-
let first = handler.pendingRetryTask
|
|
321
|
-
handler.handleError(URLError(.timedOut), context: .playback)
|
|
322
|
-
let second = handler.pendingRetryTask
|
|
323
|
-
|
|
324
|
-
await first?.value
|
|
325
|
-
await second?.value
|
|
326
|
-
|
|
327
|
-
#expect(mock.attemptRetryCallCount == 2)
|
|
328
|
-
#expect(surfaced.isEmpty)
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
/// The advisory report must precede the retry outcome: it exists so UIs can
|
|
332
|
-
/// show the cause while the backoff runs, and it must carry the same
|
|
333
|
-
/// classification the terminal path would produce.
|
|
334
|
-
@Test func retryableError_reportsRetryingErrorBeforeTheRetryResolves() async {
|
|
335
|
-
let mock = MockRetryHandler()
|
|
336
|
-
mock.retryableErrors = [URLError.Code.cannotConnectToHost.rawValue]
|
|
337
|
-
mock.attemptRetryResult = true
|
|
338
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
339
|
-
var retrying: [TrackPlayerError.PlaybackError] = []
|
|
340
|
-
var surfaced: [TrackPlayerError.PlaybackError] = []
|
|
341
|
-
handler.onRetryingError = { retrying.append($0) }
|
|
342
|
-
handler.onError = { surfaced.append($0) }
|
|
343
|
-
|
|
344
|
-
handler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
|
|
345
|
-
|
|
346
|
-
// Reported synchronously, before the retry task has run at all.
|
|
347
|
-
#expect(retrying == [.hostUnreachable])
|
|
348
|
-
#expect(mock.attemptRetryCallCount == 0)
|
|
349
|
-
|
|
350
|
-
await handler.pendingRetryTask?.value
|
|
351
|
-
// A successful retry surfaces nothing further.
|
|
352
|
-
#expect(surfaced.isEmpty)
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/// Exhaustion after an advisory report still surfaces the terminal error —
|
|
356
|
-
/// the advisory does not consume it.
|
|
357
|
-
@Test func retryExhausted_reportsRetryingThenTerminal() async {
|
|
358
|
-
let mock = MockRetryHandler()
|
|
359
|
-
mock.retryableErrors = [URLError.Code.cannotConnectToHost.rawValue]
|
|
360
|
-
mock.attemptRetryResult = false
|
|
361
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
362
|
-
var retrying: [TrackPlayerError.PlaybackError] = []
|
|
363
|
-
var surfaced: [TrackPlayerError.PlaybackError] = []
|
|
364
|
-
handler.onRetryingError = { retrying.append($0) }
|
|
365
|
-
handler.onError = { surfaced.append($0) }
|
|
366
|
-
|
|
367
|
-
handler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
|
|
368
|
-
await handler.pendingRetryTask?.value
|
|
369
|
-
|
|
370
|
-
#expect(retrying == [.hostUnreachable])
|
|
371
|
-
#expect(surfaced == [.hostUnreachable])
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/// With the policy disabled every error is terminal: no advisory, no retry
|
|
375
|
-
/// task — the error surfaces synchronously even for a retryable class.
|
|
376
|
-
@Test func retryDisabled_skipsTheRetryingReport() {
|
|
377
|
-
let mock = MockRetryHandler()
|
|
378
|
-
mock.isEnabled = false
|
|
379
|
-
mock.retryableErrors = [URLError.Code.cannotConnectToHost.rawValue]
|
|
380
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
381
|
-
var retrying: [TrackPlayerError.PlaybackError] = []
|
|
382
|
-
var surfacedError: TrackPlayerError.PlaybackError?
|
|
383
|
-
handler.onRetryingError = { retrying.append($0) }
|
|
384
|
-
handler.onError = { surfacedError = $0 }
|
|
385
|
-
|
|
386
|
-
handler.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
|
|
387
|
-
|
|
388
|
-
#expect(retrying.isEmpty)
|
|
389
|
-
#expect(handler.pendingRetryTask == nil)
|
|
390
|
-
#expect(surfacedError == .hostUnreachable)
|
|
391
|
-
#expect(mock.attemptRetryCallCount == 0)
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
/// The status reaches `classify` on the non-retryable path. Both call sites
|
|
395
|
-
/// forward it separately; dropping either one is invisible otherwise.
|
|
396
|
-
@Test func nonRetryable_forwardsHttpStatusCode() {
|
|
397
|
-
let mock = MockRetryHandler()
|
|
398
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
399
|
-
var surfacedError: TrackPlayerError.PlaybackError?
|
|
400
|
-
handler.onError = { surfacedError = $0 }
|
|
401
|
-
|
|
402
|
-
handler.handleError(nil, context: .playback, httpStatusCode: 404)
|
|
403
|
-
|
|
404
|
-
#expect(surfacedError == .httpStatus(404))
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/// The same, on the retry-exhausted path.
|
|
408
|
-
@Test func retryExhausted_forwardsHttpStatusCode() async {
|
|
409
|
-
let mock = MockRetryHandler()
|
|
410
|
-
mock.retryableErrors = [URLError.Code.badURL.rawValue]
|
|
411
|
-
mock.attemptRetryResult = false
|
|
412
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
413
|
-
var surfacedError: TrackPlayerError.PlaybackError?
|
|
414
|
-
handler.onError = { surfacedError = $0 }
|
|
415
|
-
|
|
416
|
-
handler.handleError(URLError(.badURL), context: .playback, httpStatusCode: 503)
|
|
417
|
-
await handler.pendingRetryTask?.value
|
|
418
|
-
|
|
419
|
-
#expect(surfacedError == .httpStatus(503))
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/// `.badURL` keeps this focused on the retry-exhaustion path: it is
|
|
423
|
-
/// unclassifiable, so the surfaced error is the context fallback and not a
|
|
424
|
-
/// classification the error itself carried.
|
|
425
|
-
@Test func retryExhausted_surfacesClassifiedError() async {
|
|
426
|
-
let mock = MockRetryHandler()
|
|
427
|
-
mock.retryableErrors = [URLError.Code.badURL.rawValue]
|
|
428
|
-
mock.attemptRetryResult = false
|
|
429
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
430
|
-
var surfacedError: TrackPlayerError.PlaybackError?
|
|
431
|
-
handler.onError = { surfacedError = $0 }
|
|
432
|
-
|
|
433
|
-
let error = URLError(.badURL)
|
|
434
|
-
handler.handleError(error, context: .mediaLoad)
|
|
435
|
-
|
|
436
|
-
await handler.pendingRetryTask?.value
|
|
437
|
-
|
|
438
|
-
#expect(mock.attemptRetryCallCount == 1)
|
|
439
|
-
#expect(mock.lastStartFromCurrentTime == false)
|
|
440
|
-
#expect(surfacedError == .failedToLoadKeyValue)
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
@Test func retryExhausted_internetError_classifiesCorrectly() async {
|
|
444
|
-
let mock = MockRetryHandler()
|
|
445
|
-
mock.retryableErrors = [URLError.Code.notConnectedToInternet.rawValue]
|
|
446
|
-
mock.attemptRetryResult = false
|
|
447
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
448
|
-
var surfacedError: TrackPlayerError.PlaybackError?
|
|
449
|
-
handler.onError = { surfacedError = $0 }
|
|
450
|
-
|
|
451
|
-
let error = URLError(.notConnectedToInternet)
|
|
452
|
-
handler.handleError(error, context: .mediaLoad)
|
|
453
|
-
|
|
454
|
-
await handler.pendingRetryTask?.value
|
|
455
|
-
|
|
456
|
-
// This is the bug fix: previously mediaLoad non-retryable path
|
|
457
|
-
// would always return .failedToLoadKeyValue
|
|
458
|
-
#expect(surfacedError == .notConnectedToInternet)
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
// `cancelPendingRetry` is asserted through `cancelledRetry_staysSilent`, which
|
|
463
|
-
// exercises the cancel *and* the silence it has to buy.
|
|
464
|
-
|
|
465
|
-
// MARK: - resetRetry
|
|
466
|
-
|
|
467
|
-
@Suite("resetRetry")
|
|
468
|
-
@MainActor
|
|
469
|
-
struct ErrorHandlerResetTests {
|
|
470
|
-
/// A restart from terminal error must reset budgets AND mark playback
|
|
471
|
-
/// unproven — keeping `hasPlayed` would grant the tap a 2-minute recovery
|
|
472
|
-
/// window at the one moment the listener is definitely watching.
|
|
473
|
-
@Test func resetForNewLoad_clearsBudgetsAndHasPlayed() {
|
|
474
|
-
let mock = MockRetryHandler()
|
|
475
|
-
mock.hasPlayed = true
|
|
476
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
477
|
-
|
|
478
|
-
handler.resetForNewLoad()
|
|
479
|
-
|
|
480
|
-
#expect(mock.resetCallCount == 1)
|
|
481
|
-
#expect(mock.hasPlayed == false)
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/// A retry is started first: with no pending task the `pendingRetryTask ==
|
|
485
|
-
/// nil` assertion passes even if `resetRetry` drops its `cancelPendingRetry`.
|
|
486
|
-
@Test func resetCancelsAndResetsHandler() async {
|
|
487
|
-
let mock = MockRetryHandler()
|
|
488
|
-
mock.retryableErrors = [URLError.Code.timedOut.rawValue]
|
|
489
|
-
mock.attemptRetryResult = true
|
|
490
|
-
mock.attemptRetryDelayNs = 20_000_000
|
|
491
|
-
let handler = PlaybackErrorHandler(retryHandler: mock)
|
|
492
|
-
|
|
493
|
-
handler.handleError(URLError(.timedOut), context: .playback)
|
|
494
|
-
let task = handler.pendingRetryTask
|
|
495
|
-
#expect(task != nil)
|
|
496
|
-
|
|
497
|
-
handler.resetRetry()
|
|
498
|
-
|
|
499
|
-
#expect(handler.pendingRetryTask == nil)
|
|
500
|
-
#expect(mock.resetCallCount == 1)
|
|
501
|
-
await task?.value
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// MARK: - Nitro Payload
|
|
506
|
-
|
|
507
|
-
@Suite("PlaybackError.toNitroError")
|
|
508
|
-
@MainActor
|
|
509
|
-
struct PlaybackErrorNitroCodeTests {
|
|
510
|
-
@Test func httpStatus_producesTheFullPayload() {
|
|
511
|
-
let payload = TrackPlayerError.PlaybackError.httpStatus(404).toNitroError()
|
|
512
|
-
#expect(payload.kind == .notFound)
|
|
513
|
-
#expect(payload.code == "http-status")
|
|
514
|
-
#expect(payload.message == "Server responded with HTTP 404")
|
|
515
|
-
#expect(payload.statusCode == 404)
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
@Test func nonHttpError_carriesNoStatusCode() {
|
|
519
|
-
#expect(TrackPlayerError.PlaybackError.playbackStalled.toNitroError().statusCode == nil)
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
/// `retrying` is nil — not false — on a terminal payload, so JS consumers
|
|
523
|
-
/// see the field only while it means something.
|
|
524
|
-
@Test func retryingFlag_isNilUnlessSet() {
|
|
525
|
-
#expect(TrackPlayerError.PlaybackError.hostUnreachable.toNitroError().retrying == nil)
|
|
526
|
-
#expect(
|
|
527
|
-
TrackPlayerError.PlaybackError.hostUnreachable.toNitroError(retrying: true).retrying == true,
|
|
528
|
-
)
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/// `code` is an untyped wire contract: nothing on either side of the bridge
|
|
532
|
-
/// catches a typo, and telemetry groups issues by it. Worth an exhaustive
|
|
533
|
-
/// table even though `kind` is what the UI branches on.
|
|
534
|
-
@Test func codes_areStable() {
|
|
535
|
-
let expectations: [(TrackPlayerError.PlaybackError, String)] = [
|
|
536
|
-
(.failedToLoadKeyValue, "failed-to-load"),
|
|
537
|
-
(.invalidSourceUrl("nil"), "invalid-source-url"),
|
|
538
|
-
(.notConnectedToInternet, "not-connected-to-internet"),
|
|
539
|
-
(.hostUnreachable, "host-unreachable"),
|
|
540
|
-
(.httpStatus(500), "http-status"),
|
|
541
|
-
(.playbackFailed, "playback-failed"),
|
|
542
|
-
(.trackWasUnplayable, "track-unplayable"),
|
|
543
|
-
(.playbackStalled, "playback-stalled"),
|
|
544
|
-
]
|
|
545
|
-
for (error, expected) in expectations {
|
|
546
|
-
#expect(error.toNitroError().code == expected, "\(error)")
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import Testing
|
|
3
|
-
|
|
4
|
-
@testable import AudioBrowserTestable
|
|
5
|
-
|
|
6
|
-
@Suite("PlaybackStateStore", .serialized)
|
|
7
|
-
struct PlaybackStateStoreTests {
|
|
8
|
-
private func freshDefaults() -> UserDefaults {
|
|
9
|
-
let suite = "PlaybackStateStoreTests"
|
|
10
|
-
let d = UserDefaults(suiteName: suite)!
|
|
11
|
-
d.removePersistentDomain(forName: suite)
|
|
12
|
-
return d
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
private func sampleTrack() -> JsonTrack {
|
|
16
|
-
JsonTrack(
|
|
17
|
-
id: "abc", path: nil, title: "Test FM", subtitle: nil, artwork: nil,
|
|
18
|
-
artist: "City, Country", albumPath: nil, album: nil, description: nil,
|
|
19
|
-
genre: nil, duration: nil, src: "/listen/abc/channel.mp3", request: nil,
|
|
20
|
-
style: JsonStyle(display: "grid", artworkRendering: "stencil"),
|
|
21
|
-
disabled: false, live: true, icyCharset: "windows-1251",
|
|
22
|
-
)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
@Test func roundTrip_preservesState() {
|
|
26
|
-
let store = PlaybackStateStore(defaults: freshDefaults())
|
|
27
|
-
let state = PersistedPlaybackState(
|
|
28
|
-
track: sampleTrack(), positionMs: nil, repeatMode: "off",
|
|
29
|
-
shuffleEnabled: false, playbackSpeed: 1.0,
|
|
30
|
-
)
|
|
31
|
-
store.save(state)
|
|
32
|
-
let loaded = store.load()
|
|
33
|
-
#expect(loaded?.track.src == "/listen/abc/channel.mp3")
|
|
34
|
-
#expect(loaded?.track.live == true)
|
|
35
|
-
#expect(loaded?.positionMs == nil)
|
|
36
|
-
#expect(loaded?.playbackSpeed == 1.0)
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
@Test func load_isNilBeforeAnySave() {
|
|
40
|
-
#expect(PlaybackStateStore(defaults: freshDefaults()).load() == nil)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
@Test func clear_removesState() {
|
|
44
|
-
let store = PlaybackStateStore(defaults: freshDefaults())
|
|
45
|
-
store.save(PersistedPlaybackState(track: sampleTrack(), positionMs: 5000, repeatMode: "off", shuffleEnabled: false, playbackSpeed: 1.0))
|
|
46
|
-
store.clear()
|
|
47
|
-
#expect(store.load() == nil)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import AVFoundation
|
|
2
|
-
import Testing
|
|
3
|
-
|
|
4
|
-
@testable import AudioBrowserTestable
|
|
5
|
-
|
|
6
|
-
@MainActor
|
|
7
|
-
@Suite("PlayerTimeObserver")
|
|
8
|
-
struct PlayerTimeObserverTests {
|
|
9
|
-
/// An AVPlayer retains its time observers, so one left registered outlives both this object and
|
|
10
|
-
/// the caller's interest in it (#96).
|
|
11
|
-
@Test func unregisteringDropsTheToken() {
|
|
12
|
-
// Held: `avPlayer` is weak, so a temporary is gone before the next line.
|
|
13
|
-
let player = AVPlayer()
|
|
14
|
-
let observer = PlayerTimeObserver(onAudioDidStart: {})
|
|
15
|
-
observer.avPlayer = player
|
|
16
|
-
|
|
17
|
-
observer.registerForBoundaryTimeEvents()
|
|
18
|
-
#expect(observer.boundaryTimeStartObserverToken != nil)
|
|
19
|
-
|
|
20
|
-
observer.unregisterForBoundaryTimeEvents()
|
|
21
|
-
#expect(observer.boundaryTimeStartObserverToken == nil)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/// Swapping players has to unregister from the outgoing one — it is a different AVPlayer, and
|
|
25
|
-
/// the token cannot be removed from it afterwards.
|
|
26
|
-
@Test func swappingPlayersUnregistersFromThePrevious() {
|
|
27
|
-
let first = AVPlayer()
|
|
28
|
-
let second = AVPlayer()
|
|
29
|
-
let observer = PlayerTimeObserver(onAudioDidStart: {})
|
|
30
|
-
observer.avPlayer = first
|
|
31
|
-
observer.registerForBoundaryTimeEvents()
|
|
32
|
-
|
|
33
|
-
observer.avPlayer = second
|
|
34
|
-
|
|
35
|
-
#expect(observer.boundaryTimeStartObserverToken == nil)
|
|
36
|
-
}
|
|
37
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
import Testing
|
|
3
|
-
|
|
4
|
-
@testable import AudioBrowserTestable
|
|
5
|
-
|
|
6
|
-
@Suite("PlayingStateManager")
|
|
7
|
-
struct PlayingStateManagerTests {
|
|
8
|
-
@MainActor
|
|
9
|
-
private func makeManager() -> (manager: PlayingStateManager, changes: () -> [PlayingState]) {
|
|
10
|
-
var recorded: [PlayingState] = []
|
|
11
|
-
let manager = PlayingStateManager { recorded.append($0) }
|
|
12
|
-
return (manager, { recorded })
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
@Test @MainActor
|
|
16
|
-
func playing_whenIntentAndActiveState() {
|
|
17
|
-
let (m, _) = makeManager()
|
|
18
|
-
m.update(playWhenReady: true, state: .playing)
|
|
19
|
-
#expect(m.playing == true)
|
|
20
|
-
#expect(m.buffering == false)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
@Test @MainActor
|
|
24
|
-
func buffering_whenIntentAndLoading() {
|
|
25
|
-
let (m, _) = makeManager()
|
|
26
|
-
m.update(playWhenReady: true, state: .loading)
|
|
27
|
-
#expect(m.playing == true)
|
|
28
|
-
#expect(m.buffering == true)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
@Test @MainActor
|
|
32
|
-
func notPlaying_withoutIntent() {
|
|
33
|
-
let (m, _) = makeManager()
|
|
34
|
-
m.update(playWhenReady: false, state: .playing)
|
|
35
|
-
#expect(m.playing == false)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/// Terminal states are not playable regardless of intent. .stopped was
|
|
39
|
-
/// missing from the exclusion list — masked only by stop() clearing
|
|
40
|
-
/// playWhenReady right after transitioning, which made the gap latent.
|
|
41
|
-
@Test @MainActor
|
|
42
|
-
func notPlaying_inTerminalStates_evenWithIntent() {
|
|
43
|
-
for state in [PlaybackState.none, .stopped, .ended, .error] {
|
|
44
|
-
let (m, _) = makeManager()
|
|
45
|
-
m.update(playWhenReady: true, state: state)
|
|
46
|
-
#expect(m.playing == false, "Expected not playing in \(state) despite intent")
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
@Test @MainActor
|
|
51
|
-
func onChange_firesOnlyOnActualChange() {
|
|
52
|
-
let (m, changes) = makeManager()
|
|
53
|
-
m.update(playWhenReady: true, state: .playing)
|
|
54
|
-
m.update(playWhenReady: true, state: .playing)
|
|
55
|
-
#expect(changes().count == 1)
|
|
56
|
-
}
|
|
57
|
-
}
|