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.
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
- 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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import Testing
|
|
2
|
+
|
|
3
|
+
@testable import AudioBrowserTestable
|
|
4
|
+
|
|
5
|
+
private struct Config: RequestFields, Equatable {
|
|
6
|
+
var method: String?
|
|
7
|
+
var path: String?
|
|
8
|
+
var baseUrl: String?
|
|
9
|
+
var headers: [String: String]?
|
|
10
|
+
var query: [String: String]?
|
|
11
|
+
var body: String?
|
|
12
|
+
var contentType: String?
|
|
13
|
+
var userAgent: String?
|
|
14
|
+
|
|
15
|
+
init(
|
|
16
|
+
method: String? = nil,
|
|
17
|
+
path: String? = nil,
|
|
18
|
+
baseUrl: String? = nil,
|
|
19
|
+
headers: [String: String]? = nil,
|
|
20
|
+
query: [String: String]? = nil,
|
|
21
|
+
body: String? = nil,
|
|
22
|
+
contentType: String? = nil,
|
|
23
|
+
userAgent: String? = nil,
|
|
24
|
+
) {
|
|
25
|
+
self.method = method
|
|
26
|
+
self.path = path
|
|
27
|
+
self.baseUrl = baseUrl
|
|
28
|
+
self.headers = headers
|
|
29
|
+
self.query = query
|
|
30
|
+
self.body = body
|
|
31
|
+
self.contentType = contentType
|
|
32
|
+
self.userAgent = userAgent
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Suite("RequestLayer")
|
|
37
|
+
struct RequestLayerTests {
|
|
38
|
+
@Test func mergeLetsTheOverrideWinFieldByField() {
|
|
39
|
+
let base = Config(method: "GET", path: "/a", baseUrl: "https://a", userAgent: "base")
|
|
40
|
+
let merged = RequestLayer.merge(base, Config(path: "/b", userAgent: "over"))
|
|
41
|
+
#expect(merged == Config(method: "GET", path: "/b", baseUrl: "https://a", userAgent: "over"))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Test func mergeCombinesHeadersAndQueryByKey() {
|
|
45
|
+
let base = Config(headers: ["A": "1", "B": "1"], query: ["x": "1"])
|
|
46
|
+
let merged = RequestLayer.merge(base, Config(headers: ["B": "2"], query: ["y": "2"]))
|
|
47
|
+
#expect(merged.headers == ["A": "1", "B": "2"])
|
|
48
|
+
#expect(merged.query == ["x": "1", "y": "2"])
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test func anEmptyOverrideChangesNothing() {
|
|
52
|
+
let base = Config(method: "POST", path: "/a", baseUrl: "https://a", headers: ["A": "1"])
|
|
53
|
+
#expect(RequestLayer.merge(base, Config()) == base)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@Test func applyStaticKeepsTheBasePath() {
|
|
57
|
+
let base = Config(path: "/page", baseUrl: "https://a")
|
|
58
|
+
let applied = RequestLayer.applyStatic(Config(path: "/ignored", baseUrl: "https://b"), to: base)
|
|
59
|
+
#expect(applied == Config(path: "/page", baseUrl: "https://b"))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@Test func transformsWinCompletelyInOrder() async throws {
|
|
63
|
+
let base = Config(path: "/page", baseUrl: "https://a", headers: ["A": "1"])
|
|
64
|
+
let layer = Config(baseUrl: "https://static")
|
|
65
|
+
let applied = try await RequestLayer.apply(
|
|
66
|
+
layer,
|
|
67
|
+
transforms: [
|
|
68
|
+
{ Config(path: $0.path, baseUrl: "https://first") },
|
|
69
|
+
{ Config(path: $0.path, baseUrl: $0.baseUrl, userAgent: "second") },
|
|
70
|
+
],
|
|
71
|
+
to: base,
|
|
72
|
+
)
|
|
73
|
+
#expect(applied == Config(path: "/page", baseUrl: "https://first", userAgent: "second"))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test func withoutTransformsTheStaticFieldsApply() async throws {
|
|
77
|
+
let base = Config(path: "/page", baseUrl: "https://a")
|
|
78
|
+
let applied = try await RequestLayer.apply(Config(baseUrl: "https://b"), transforms: [], to: base)
|
|
79
|
+
#expect(applied == Config(path: "/page", baseUrl: "https://b"))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Test func aFailingTransformThrows() async {
|
|
83
|
+
struct Boom: Error {}
|
|
84
|
+
await #expect(throws: Boom.self) {
|
|
85
|
+
_ = try await RequestLayer.apply(Config(), transforms: [{ _ in throw Boom() }], to: Config())
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Test func mergeResolvedLetsSyncWin() {
|
|
90
|
+
let asyncResolved = Config(baseUrl: "https://async", userAgent: "ASYNC")
|
|
91
|
+
let syncResolved = Config(userAgent: "SYNC")
|
|
92
|
+
#expect(RequestLayer.mergeResolved(async: Config?.none, sync: nil) == nil)
|
|
93
|
+
#expect(RequestLayer.mergeResolved(async: asyncResolved, sync: nil) == asyncResolved)
|
|
94
|
+
#expect(RequestLayer.mergeResolved(async: nil, sync: syncResolved) == syncResolved)
|
|
95
|
+
#expect(
|
|
96
|
+
RequestLayer.mergeResolved(async: asyncResolved, sync: syncResolved)
|
|
97
|
+
== Config(baseUrl: "https://async", userAgent: "SYNC"),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test func buildUrlJoinsBaseUrlPathAndQuery() {
|
|
102
|
+
#expect(RequestLayer.buildUrl(Config(path: "/a", baseUrl: "https://host")) == "https://host/a")
|
|
103
|
+
#expect(RequestLayer.buildUrl(Config(baseUrl: "https://host"), defaultPath: "/d") == "https://host/d")
|
|
104
|
+
#expect(RequestLayer.buildUrl(Config(path: "/a", baseUrl: "https://host", query: ["q": "x"])) == "https://host/a?q=x")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@Test func mergeDictsIsNilSafe() {
|
|
108
|
+
#expect(RequestLayer.mergeDicts(nil, nil) == nil)
|
|
109
|
+
#expect(RequestLayer.mergeDicts(["a": "1"], nil) == ["a": "1"])
|
|
110
|
+
#expect(RequestLayer.mergeDicts(nil, ["b": "2"]) == ["b": "2"])
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
import Testing
|
|
4
|
+
|
|
5
|
+
@testable import AudioBrowserTestable
|
|
6
|
+
|
|
7
|
+
private final class MockNetworkStatus: NetworkStatusProviding, @unchecked Sendable {
|
|
8
|
+
var isOnline: Bool
|
|
9
|
+
init(isOnline: Bool) { self.isOnline = isOnline }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// The two duration budgets: a never-played load gives up fast while online; a
|
|
13
|
+
/// load that produced audio — or a device that is offline — keeps the full
|
|
14
|
+
/// recovery budget. Timing-based: budgets are configured in single-digit
|
|
15
|
+
/// milliseconds so one executed backoff (~1s) exhausts them.
|
|
16
|
+
@Suite("RetryLoop budgets")
|
|
17
|
+
@MainActor
|
|
18
|
+
struct RetryLoopBudgetTests {
|
|
19
|
+
/// Returns the mock too — `networkMonitor` is weak, the test must keep it alive.
|
|
20
|
+
private func makeManager(
|
|
21
|
+
firstConnectMs: Double,
|
|
22
|
+
maxMs: Double = 120_000,
|
|
23
|
+
online: Bool,
|
|
24
|
+
) -> (RetryLoop, MockNetworkStatus) {
|
|
25
|
+
let manager = RetryLoop()
|
|
26
|
+
manager.updatePolicy(from: .second(RetryConfig(
|
|
27
|
+
maxRetries: nil,
|
|
28
|
+
maxRetryDurationMs: maxMs,
|
|
29
|
+
firstConnectMaxRetryDurationMs: firstConnectMs,
|
|
30
|
+
)))
|
|
31
|
+
let network = MockNetworkStatus(isOnline: online)
|
|
32
|
+
manager.networkMonitor = network
|
|
33
|
+
return (manager, network)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/// A dead-on-arrival stream: the first attempt runs, and the budget refuses
|
|
37
|
+
/// a later one. Budgets are checked before AND after each backoff wait, so
|
|
38
|
+
/// the test budget must exceed the first backoff (~1s) for attempt one to
|
|
39
|
+
/// execute, and be exceeded by the cumulative ~2.5s before attempt two ends.
|
|
40
|
+
/// 1800ms sits ~800ms clear of the first backoff (sleep overshoot on a loaded
|
|
41
|
+
/// machine only widens the passing margins, never shrinks them into failure).
|
|
42
|
+
@Test func neverPlayedLoad_givesUpAfterFirstConnectBudget() async {
|
|
43
|
+
let (manager, network) = makeManager(firstConnectMs: 1800, online: true)
|
|
44
|
+
_ = network
|
|
45
|
+
|
|
46
|
+
#expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
|
|
47
|
+
#expect(await manager.attemptRetry(startFromCurrentTime: false) == false)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// The same failure timeline on a load that has produced audio keeps
|
|
51
|
+
/// retrying: only the recovery budget applies.
|
|
52
|
+
@Test func playedLoad_ignoresTheFirstConnectBudget() async {
|
|
53
|
+
let (manager, network) = makeManager(firstConnectMs: 500, online: true)
|
|
54
|
+
_ = network
|
|
55
|
+
manager.hasPlayed = { true }
|
|
56
|
+
|
|
57
|
+
#expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
|
|
58
|
+
#expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// Offline failures must not burn the first-connect budget (a station tapped
|
|
62
|
+
/// in a tunnel parks for connectivity); the budget's clock starts at the
|
|
63
|
+
/// first failure observed online, so a dead station still gets a fast
|
|
64
|
+
/// verdict after restoration.
|
|
65
|
+
@Test func offlineTime_doesNotBurnTheFirstConnectBudget() async {
|
|
66
|
+
// 4s: well above the ~2.3s backoff of the first online attempt (the ladder
|
|
67
|
+
// kept climbing while offline), well below the ~5.6s cumulative after it.
|
|
68
|
+
let (manager, network) = makeManager(firstConnectMs: 4000, online: false)
|
|
69
|
+
|
|
70
|
+
// Offline: the short budget never engages, attempts keep running.
|
|
71
|
+
#expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
|
|
72
|
+
#expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
|
|
73
|
+
|
|
74
|
+
// Connectivity returns: the short clock starts now — one more attempt
|
|
75
|
+
// runs, then the budget is spent.
|
|
76
|
+
network.isOnline = true
|
|
77
|
+
#expect(await manager.attemptRetry(startFromCurrentTime: false) == true)
|
|
78
|
+
#expect(await manager.attemptRetry(startFromCurrentTime: false) == false)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// MARK: - handleError
|
|
83
|
+
|
|
84
|
+
@Suite("RetryLoop.handleError")
|
|
85
|
+
@MainActor
|
|
86
|
+
struct RetryLoopHandleErrorTests {
|
|
87
|
+
/// What the loop reported and did, in order.
|
|
88
|
+
final class Record {
|
|
89
|
+
var retrying: [TrackPlayerError.PlaybackError] = []
|
|
90
|
+
var errors: [TrackPlayerError.PlaybackError] = []
|
|
91
|
+
var reloads: [Bool] = []
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private func makeLoop(retries: Bool = true, delay: TimeInterval = 0) -> (RetryLoop, Record) {
|
|
95
|
+
let loop = makeRetryLoop(retries: retries, delay: delay)
|
|
96
|
+
let record = Record()
|
|
97
|
+
loop.onRetryingError = { record.retrying.append($0) }
|
|
98
|
+
loop.onError = { record.errors.append($0) }
|
|
99
|
+
loop.onRetry = { record.reloads.append($0) }
|
|
100
|
+
return (loop, record)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Test func aNonRetryableErrorSurfacesAtOnce() {
|
|
104
|
+
let (loop, record) = makeLoop()
|
|
105
|
+
loop.handleError(URLError(.badURL), context: .playback)
|
|
106
|
+
#expect(record.errors == [.playbackFailed])
|
|
107
|
+
#expect(record.retrying.isEmpty)
|
|
108
|
+
#expect(loop.pendingTask == nil)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// A disabled policy makes every error terminal: no advisory, no retry task, even for a
|
|
112
|
+
/// retryable failure, which is still classified.
|
|
113
|
+
@Test func aDisabledPolicySurfacesAtOnce() {
|
|
114
|
+
let (loop, record) = makeLoop()
|
|
115
|
+
loop.updatePolicy(from: nil)
|
|
116
|
+
loop.handleError(URLError(.notConnectedToInternet), context: .mediaLoad)
|
|
117
|
+
#expect(record.errors == [.notConnectedToInternet])
|
|
118
|
+
#expect(record.retrying.isEmpty)
|
|
119
|
+
#expect(loop.pendingTask == nil)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Also pins that a finished retry clears its handle: session release waits on it.
|
|
123
|
+
@Test func aRetryableErrorReloadsFromTheContextsPosition() async {
|
|
124
|
+
let (loop, record) = makeLoop()
|
|
125
|
+
loop.handleError(URLError(.timedOut), context: .playback)
|
|
126
|
+
#expect(loop.pendingTask != nil)
|
|
127
|
+
await loop.pendingTask?.value
|
|
128
|
+
loop.handleError(URLError(.timedOut), context: .mediaLoad)
|
|
129
|
+
await loop.pendingTask?.value
|
|
130
|
+
|
|
131
|
+
#expect(record.reloads == [true, false])
|
|
132
|
+
#expect(record.errors.isEmpty)
|
|
133
|
+
#expect(loop.pendingTask == nil)
|
|
134
|
+
#expect(loop.attempts == 2)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/// The advisory goes out before the backoff, carrying the classification a terminal report
|
|
138
|
+
/// would.
|
|
139
|
+
@Test func theAdvisoryPrecedesTheRetry() async {
|
|
140
|
+
let (loop, record) = makeLoop()
|
|
141
|
+
loop.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
|
|
142
|
+
#expect(record.retrying == [.hostUnreachable])
|
|
143
|
+
#expect(record.reloads.isEmpty)
|
|
144
|
+
await loop.pendingTask?.value
|
|
145
|
+
#expect(record.errors.isEmpty)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@Test func givingUpReportsTheAdvisoryThenTheTerminalError() async {
|
|
149
|
+
let (loop, record) = makeLoop(retries: false)
|
|
150
|
+
loop.handleError(URLError(.cannotConnectToHost), context: .mediaLoad)
|
|
151
|
+
await loop.pendingTask?.value
|
|
152
|
+
#expect(record.retrying == [.hostUnreachable])
|
|
153
|
+
#expect(record.errors == [.hostUnreachable])
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// A cancelled retry stays silent: a track change or a stop cancels it, and surfacing its
|
|
157
|
+
/// failure would flip the new track or the stop to .error.
|
|
158
|
+
@Test func aCancelledRetryStaysSilent() async {
|
|
159
|
+
let (loop, record) = makeLoop(delay: 0.05)
|
|
160
|
+
loop.handleError(URLError(.timedOut), context: .playback)
|
|
161
|
+
let task = loop.pendingTask
|
|
162
|
+
loop.cancel()
|
|
163
|
+
#expect(loop.pendingTask == nil)
|
|
164
|
+
await task?.value
|
|
165
|
+
#expect(record.errors.isEmpty)
|
|
166
|
+
#expect(record.reloads.isEmpty)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/// A second failure replaces the retry in flight, which neither reports nor reloads.
|
|
170
|
+
@Test func aNewFailureReplacesTheRetryInFlight() async {
|
|
171
|
+
let (loop, record) = makeLoop(delay: 0.02)
|
|
172
|
+
loop.handleError(URLError(.timedOut), context: .playback)
|
|
173
|
+
let first = loop.pendingTask
|
|
174
|
+
loop.handleError(URLError(.timedOut), context: .playback)
|
|
175
|
+
let second = loop.pendingTask
|
|
176
|
+
await first?.value
|
|
177
|
+
await second?.value
|
|
178
|
+
#expect(record.reloads == [true])
|
|
179
|
+
#expect(record.errors.isEmpty)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@Test func theHTTPStatusReachesTheReport() async {
|
|
183
|
+
let (loop, record) = makeLoop(retries: false)
|
|
184
|
+
loop.handleError(nil, context: .playback, httpStatusCode: 404)
|
|
185
|
+
loop.handleError(URLError(.badURL), context: .playback, httpStatusCode: 503)
|
|
186
|
+
await loop.pendingTask?.value
|
|
187
|
+
#expect(record.errors == [.httpStatus(404), .httpStatus(503)])
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// An unclassifiable failure, retried and given up on, reports the context's fallback.
|
|
191
|
+
@Test func givingUpOnAnOpaqueFailureReportsTheFallback() async {
|
|
192
|
+
let (loop, record) = makeLoop(retries: false)
|
|
193
|
+
loop.handleError(NSError(domain: "CoreMediaErrorDomain", code: -12939), context: .mediaLoad)
|
|
194
|
+
await loop.pendingTask?.value
|
|
195
|
+
#expect(record.errors == [.failedToLoadKeyValue])
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
@Test func resetRefillsTheBudgets() async {
|
|
199
|
+
let (loop, _) = makeLoop()
|
|
200
|
+
loop.handleError(URLError(.timedOut), context: .playback)
|
|
201
|
+
await loop.pendingTask?.value
|
|
202
|
+
#expect(loop.attempts == 1)
|
|
203
|
+
loop.reset()
|
|
204
|
+
#expect(loop.attempts == 0)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
@Test func resetDropsTheRetryInFlightSilently() async {
|
|
208
|
+
let (loop, record) = makeLoop(delay: 0.02)
|
|
209
|
+
loop.handleError(URLError(.timedOut), context: .playback)
|
|
210
|
+
let task = loop.pendingTask
|
|
211
|
+
loop.reset()
|
|
212
|
+
#expect(loop.pendingTask == nil)
|
|
213
|
+
await task?.value
|
|
214
|
+
#expect(record.errors.isEmpty)
|
|
215
|
+
#expect(record.reloads.isEmpty)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// A loop whose backoff waits until cancelled, reporting when it starts waiting.
|
|
219
|
+
private func makeWaitingLoop() -> (RetryLoop, Record, () -> Bool) {
|
|
220
|
+
var waiting = false
|
|
221
|
+
let loop = RetryLoop(sleep: { _ in
|
|
222
|
+
await MainActor.run { waiting = true }
|
|
223
|
+
try await Task.sleep(for: .seconds(60))
|
|
224
|
+
})
|
|
225
|
+
loop.updatePolicy(from: .first(true))
|
|
226
|
+
loop.shouldRetry = { true }
|
|
227
|
+
let record = Record()
|
|
228
|
+
loop.onError = { record.errors.append($0) }
|
|
229
|
+
loop.onRetry = { record.reloads.append($0) }
|
|
230
|
+
return (loop, record, { waiting })
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@Test func aCancelDuringTheBackoffStaysSilent() async {
|
|
234
|
+
let (loop, record, isWaiting) = makeWaitingLoop()
|
|
235
|
+
loop.handleError(URLError(.timedOut), context: .playback)
|
|
236
|
+
let task = loop.pendingTask
|
|
237
|
+
while !isWaiting() {
|
|
238
|
+
await Task.yield()
|
|
239
|
+
}
|
|
240
|
+
loop.cancel()
|
|
241
|
+
await task?.value
|
|
242
|
+
#expect(record.errors.isEmpty)
|
|
243
|
+
#expect(record.reloads.isEmpty)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/// A cancel that lands as the backoff completes (the sleep itself returning normally) still
|
|
247
|
+
/// stops the reload.
|
|
248
|
+
@Test func aCancelAsTheBackoffEndsStopsTheReload() async {
|
|
249
|
+
let holder = LoopHolder()
|
|
250
|
+
let loop = RetryLoop(sleep: { _ in
|
|
251
|
+
await holder.loop?.cancel()
|
|
252
|
+
})
|
|
253
|
+
holder.loop = loop
|
|
254
|
+
loop.updatePolicy(from: .first(true))
|
|
255
|
+
loop.shouldRetry = { true }
|
|
256
|
+
let record = Record()
|
|
257
|
+
loop.onError = { record.errors.append($0) }
|
|
258
|
+
loop.onRetry = { record.reloads.append($0) }
|
|
259
|
+
loop.handleError(URLError(.timedOut), context: .playback)
|
|
260
|
+
let task = loop.pendingTask
|
|
261
|
+
await task?.value
|
|
262
|
+
#expect(record.reloads.isEmpty)
|
|
263
|
+
#expect(record.errors.isEmpty)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@MainActor
|
|
268
|
+
private final class LoopHolder {
|
|
269
|
+
weak var loop: RetryLoop?
|
|
270
|
+
}
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// This file is intentionally empty — mock types live in
|
|
2
|
-
// Model/NitroTypeStubs.swift (part of the AudioBrowserTestable target).
|
|
3
|
-
|
|
4
1
|
@testable import AudioBrowserTestable
|
|
5
2
|
import AVFoundation
|
|
6
3
|
|
|
@@ -28,94 +25,116 @@ func makeMetadataItem(
|
|
|
28
25
|
return item
|
|
29
26
|
}
|
|
30
27
|
|
|
31
|
-
// MARK: - Mock
|
|
28
|
+
// MARK: - Mock PlayerEngine
|
|
32
29
|
|
|
33
30
|
@MainActor
|
|
34
|
-
final class
|
|
35
|
-
|
|
36
|
-
var startPlaybackCallCount = 0
|
|
37
|
-
var pausePlaybackCallCount = 0
|
|
38
|
-
var setTimePitchCallCount = 0
|
|
39
|
-
|
|
40
|
-
func startPlayback() { startPlaybackCallCount += 1 }
|
|
41
|
-
func pausePlayback() { pausePlaybackCallCount += 1 }
|
|
42
|
-
func setTimePitchingAlgorithmForCurrentItem() { setTimePitchCallCount += 1 }
|
|
31
|
+
final class MockPlayerEngine: PlayerEngine {
|
|
32
|
+
var onEvent: (@MainActor (EngineEvent) -> Void)?
|
|
43
33
|
|
|
44
|
-
|
|
45
|
-
|
|
34
|
+
/// Reports `event` as the media stack would.
|
|
35
|
+
func emit(_ event: EngineEvent) {
|
|
36
|
+
onEvent?(event)
|
|
37
|
+
}
|
|
46
38
|
|
|
47
|
-
//
|
|
39
|
+
// The current item
|
|
40
|
+
var hasItem = false
|
|
41
|
+
var hasLoadedAsset = false
|
|
48
42
|
var currentTime: Double = 0
|
|
49
43
|
var duration: Double = 0
|
|
50
44
|
var bufferedPosition: Double = 0
|
|
51
|
-
var
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
var
|
|
55
|
-
var
|
|
56
|
-
|
|
57
|
-
func clearCurrentItem() { clearCurrentItemCallCount += 1 }
|
|
58
|
-
func stopObservingCurrentItem() { stopObservingCurrentItemCallCount += 1 }
|
|
59
|
-
|
|
60
|
-
// Track loading
|
|
61
|
-
var loadTrackCalls: [String] = []
|
|
62
|
-
var loadTrackTracks: [Track] = []
|
|
63
|
-
var reloadTrackCalls: [Bool] = []
|
|
64
|
-
var unloadTrackCallCount = 0
|
|
65
|
-
var cancelMediaLoadingCallCount = 0
|
|
45
|
+
var resumePosition: Double?
|
|
46
|
+
var liveEdge: Double?
|
|
47
|
+
var isPlaybackLikelyToKeepUp = false
|
|
48
|
+
var isAirPlayRoute = false
|
|
49
|
+
var volume: Float = 1
|
|
66
50
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
51
|
+
// Loading
|
|
52
|
+
var resolveAndLoadCalls: [String] = []
|
|
53
|
+
var loadResolvedCount = 0
|
|
54
|
+
var cancelLoadingCount = 0
|
|
55
|
+
var dropAssetCount = 0
|
|
56
|
+
var clearItemCount = 0
|
|
57
|
+
var stopObservingItemCount = 0
|
|
58
|
+
var recreateCount = 0
|
|
59
|
+
|
|
60
|
+
func resolveAndLoad(src: String, track _: Track) { resolveAndLoadCalls.append(src) }
|
|
61
|
+
func loadResolved() { loadResolvedCount += 1 }
|
|
62
|
+
func cancelLoading() { cancelLoadingCount += 1 }
|
|
63
|
+
func dropAsset() { dropAssetCount += 1 }
|
|
64
|
+
func clearItem() { clearItemCount += 1 }
|
|
65
|
+
func stopObservingItem() { stopObservingItemCount += 1 }
|
|
66
|
+
func recreate() { recreateCount += 1 }
|
|
67
|
+
|
|
68
|
+
// Transport
|
|
69
|
+
var startPlaybackCallCount = 0
|
|
70
|
+
var pausePlaybackCallCount = 0
|
|
71
|
+
var setTimePitchCallCount = 0
|
|
72
|
+
var rates: [(rate: Float, playing: Bool)] = []
|
|
73
|
+
var seekCalls: [Double] = []
|
|
74
|
+
/// What a seek completes with; nil leaves it in flight until `completeSeeks`.
|
|
75
|
+
var seekResult: Bool? = true
|
|
76
|
+
|
|
77
|
+
func play() { startPlaybackCallCount += 1 }
|
|
78
|
+
func pause() { pausePlaybackCallCount += 1 }
|
|
79
|
+
func setRate(_ rate: Float, playing: Bool) { rates.append((rate, playing)) }
|
|
80
|
+
private var seeksInFlight: [@MainActor (Bool) -> Void] = []
|
|
81
|
+
|
|
82
|
+
func seek(to seconds: Double, completion: @escaping @MainActor (Bool) -> Void) {
|
|
83
|
+
seekCalls.append(seconds)
|
|
84
|
+
if let seekResult { completion(seekResult) } else { seeksInFlight.append(completion) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/// Lands the seeks left in flight while `seekResult` was nil.
|
|
88
|
+
func completeSeeks(_ finished: Bool) {
|
|
89
|
+
let pending = seeksInFlight
|
|
90
|
+
seeksInFlight = []
|
|
91
|
+
for completion in pending {
|
|
92
|
+
completion(finished)
|
|
93
|
+
}
|
|
70
94
|
}
|
|
71
95
|
|
|
72
|
-
func
|
|
73
|
-
func unloadTrack() { unloadTrackCallCount += 1 }
|
|
74
|
-
func cancelMediaLoading() { cancelMediaLoadingCallCount += 1 }
|
|
75
|
-
|
|
76
|
-
// Seek operations
|
|
77
|
-
var seekToStartCallCount = 0
|
|
78
|
-
var replayCurrentTrackCallCount = 0
|
|
96
|
+
func applyTimePitchAlgorithm() { setTimePitchCallCount += 1 }
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// Now Playing
|
|
98
|
+
// Now playing and remote commands
|
|
99
|
+
var primeCount = 0
|
|
84
100
|
var loadNowPlayingMetadataCalls: [Track] = []
|
|
85
101
|
var clearNowPlayingCallCount = 0
|
|
86
|
-
var updateNowPlayingStateCalls: [Bool] = []
|
|
87
|
-
|
|
88
|
-
func loadNowPlayingMetadata(for track: Track) {
|
|
89
|
-
loadNowPlayingMetadataCalls.append(track)
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
func clearNowPlaying() { clearNowPlayingCallCount += 1 }
|
|
93
|
-
func updateNowPlayingState(playWhenReady: Bool) {
|
|
94
|
-
updateNowPlayingStateCalls.append(playWhenReady)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Remote commands
|
|
98
102
|
var updateRemoteRepeatModeCalls: [RepeatMode] = []
|
|
99
103
|
var updateRemoteShuffleModeCalls: [Bool] = []
|
|
100
|
-
|
|
101
|
-
func updateRemoteRepeatMode(_ mode: RepeatMode) {
|
|
102
|
-
updateRemoteRepeatModeCalls.append(mode)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
func updateRemoteShuffleMode(_ enabled: Bool) {
|
|
106
|
-
updateRemoteShuffleModeCalls.append(enabled)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
104
|
var updateSkipAvailabilityCalls: [(canNext: Bool, canPrevious: Bool)] = []
|
|
105
|
+
|
|
106
|
+
func primeNowPlaying() { primeCount += 1 }
|
|
107
|
+
func loadNowPlayingMetadata(for track: Track) { loadNowPlayingMetadataCalls.append(track) }
|
|
108
|
+
func clearNowPlaying() { clearNowPlayingCallCount += 1 }
|
|
109
|
+
func updateRemoteRepeatMode(_ mode: RepeatMode) { updateRemoteRepeatModeCalls.append(mode) }
|
|
110
|
+
func updateRemoteShuffleMode(_ enabled: Bool) { updateRemoteShuffleModeCalls.append(enabled) }
|
|
110
111
|
func updateSkipAvailability(canNext: Bool, canPrevious: Bool) {
|
|
111
112
|
updateSkipAvailabilityCalls.append((canNext, canPrevious))
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
|
|
116
|
+
/// A Player over `engine` whose retries reload while `retries` and otherwise give up, keeping
|
|
117
|
+
/// its resume snapshot out of the app's defaults.
|
|
118
|
+
@MainActor
|
|
119
|
+
func makePlayer(
|
|
120
|
+
engine: MockPlayerEngine = MockPlayerEngine(),
|
|
121
|
+
retry: RetryLoop = makeRetryLoop(),
|
|
122
|
+
retries: Bool = true,
|
|
123
|
+
sleepTimer: SleepTimerManager = SleepTimerManager(scheduler: ManualSleepTimerScheduler()),
|
|
124
|
+
saveInterval: Duration = .seconds(5),
|
|
125
|
+
) -> Player {
|
|
126
|
+
let player = Player(
|
|
127
|
+
engine: engine, retry: retry, sleepTimerManager: sleepTimer,
|
|
128
|
+
persistenceDefaults: UserDefaults(suiteName: "PlayerTests")!, persistenceSaveInterval: saveInterval,
|
|
129
|
+
)
|
|
130
|
+
retry.shouldRetry = { retries }
|
|
131
|
+
return player
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// MARK: - Mock PlayerCallbacks
|
|
116
135
|
|
|
117
136
|
@MainActor
|
|
118
|
-
final class
|
|
137
|
+
final class MockPlayerCallbacks: PlayerCallbacks {
|
|
119
138
|
var playbackChanges: [Playback] = []
|
|
120
139
|
var activeTrackChanges: [PlaybackActiveTrackChangedEvent] = []
|
|
121
140
|
var progressUpdates: [PlaybackProgressUpdatedEvent] = []
|
|
@@ -123,18 +142,25 @@ final class MockPlaybackCoordinatorCallbacks: PlaybackCoordinatorCallbacks {
|
|
|
123
142
|
var playingStateChanges: [PlayingState] = []
|
|
124
143
|
var queueEndedEvents: [PlaybackQueueEndedEvent] = []
|
|
125
144
|
var queueChanges: [[Track]] = []
|
|
145
|
+
/// The order in which the queue and active-track callbacks arrived.
|
|
146
|
+
var queueAndTrackOrder: [String] = []
|
|
126
147
|
var repeatModeChanges: [RepeatModeChangedEvent] = []
|
|
127
148
|
var shuffleEnabledChanges: [Bool] = []
|
|
128
149
|
var errorEvents: [PlaybackErrorEvent] = []
|
|
129
150
|
var playbackIntervalFiredCount = 0
|
|
130
151
|
var releaseSessionCount = 0
|
|
131
152
|
|
|
153
|
+
/// The order playback, intent and playing-state changes arrived in.
|
|
154
|
+
var publishOrder: [String] = []
|
|
155
|
+
|
|
132
156
|
func playerDidChangePlayback(_ playback: Playback) {
|
|
157
|
+
publishOrder.append("playback")
|
|
133
158
|
playbackChanges.append(playback)
|
|
134
159
|
}
|
|
135
160
|
|
|
136
161
|
func playerDidChangeActiveTrack(_ event: PlaybackActiveTrackChangedEvent) {
|
|
137
162
|
activeTrackChanges.append(event)
|
|
163
|
+
queueAndTrackOrder.append("activeTrack")
|
|
138
164
|
}
|
|
139
165
|
|
|
140
166
|
func playerDidUpdateProgress(_ event: PlaybackProgressUpdatedEvent) {
|
|
@@ -146,10 +172,12 @@ final class MockPlaybackCoordinatorCallbacks: PlaybackCoordinatorCallbacks {
|
|
|
146
172
|
}
|
|
147
173
|
|
|
148
174
|
func playerDidChangePlayWhenReady(_ playWhenReady: Bool) {
|
|
175
|
+
publishOrder.append("playWhenReady")
|
|
149
176
|
playWhenReadyChanges.append(playWhenReady)
|
|
150
177
|
}
|
|
151
178
|
|
|
152
179
|
func playerDidChangePlayingState(_ state: PlayingState) {
|
|
180
|
+
publishOrder.append("playingState")
|
|
153
181
|
playingStateChanges.append(state)
|
|
154
182
|
}
|
|
155
183
|
|
|
@@ -159,6 +187,7 @@ final class MockPlaybackCoordinatorCallbacks: PlaybackCoordinatorCallbacks {
|
|
|
159
187
|
|
|
160
188
|
func playerDidChangeQueue(_ tracks: [Track]) {
|
|
161
189
|
queueChanges.append(tracks)
|
|
190
|
+
queueAndTrackOrder.append("queue")
|
|
162
191
|
}
|
|
163
192
|
|
|
164
193
|
func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent) {
|
|
@@ -176,53 +205,23 @@ final class MockPlaybackCoordinatorCallbacks: PlaybackCoordinatorCallbacks {
|
|
|
176
205
|
func playerShouldReleaseSession() {
|
|
177
206
|
releaseSessionCount += 1
|
|
178
207
|
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// MARK: - Mock SleepTimerHandling
|
|
182
208
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
var onFadeCancel: (() -> Void)?
|
|
188
|
-
var onChanged: ((SleepTimerState) -> Void)?
|
|
189
|
-
var trackChangedCallCount = 0
|
|
190
|
-
var trackPlayedToEndCallCount = 0
|
|
191
|
-
var clearCallCount = 0
|
|
192
|
-
|
|
193
|
-
@discardableResult func clear() -> Bool {
|
|
194
|
-
clearCallCount += 1
|
|
195
|
-
return true
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
func onTrackChanged() { trackChangedCallCount += 1 }
|
|
199
|
-
func onTrackPlayedToEnd() { trackPlayedToEndCallCount += 1 }
|
|
200
|
-
|
|
201
|
-
// Consumer-facing surface — unused by the coordinator tests, present only to
|
|
202
|
-
// satisfy the protocol. Add recorders alongside the tests that need them.
|
|
203
|
-
func get() -> SleepTimerState? { nil }
|
|
204
|
-
func set(seconds _: TimeInterval, fadeDuration _: TimeInterval?) {}
|
|
205
|
-
func setToEndOfTrack() {}
|
|
209
|
+
var sleepTimerChanges: [SleepTimerState?] = []
|
|
210
|
+
func playerDidChangeSleepTimer(_ state: SleepTimerState?) {
|
|
211
|
+
sleepTimerChanges.append(state)
|
|
212
|
+
}
|
|
206
213
|
}
|
|
207
214
|
|
|
208
|
-
// MARK: -
|
|
215
|
+
// MARK: - RetryLoop
|
|
209
216
|
|
|
217
|
+
/// A RetryLoop for tests: enabled, with an instant backoff unless `delay`, whose attempts reload
|
|
218
|
+
/// while `retries` and otherwise give up, as an exhausted budget does.
|
|
210
219
|
@MainActor
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
func isRetryable(_: Error?, httpStatusCode _: Int?) -> Bool { isRetryableResult }
|
|
220
|
-
func attemptRetry(startFromCurrentTime _: Bool) async -> Bool {
|
|
221
|
-
if attemptRetryDelayNs > 0 {
|
|
222
|
-
try? await Task.sleep(nanoseconds: attemptRetryDelayNs)
|
|
223
|
-
}
|
|
224
|
-
return attemptRetryResult
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
func reset() { resetCallCount += 1 }
|
|
220
|
+
func makeRetryLoop(retries: Bool = true, delay: TimeInterval = 0) -> RetryLoop {
|
|
221
|
+
let loop = RetryLoop(sleep: { _ in
|
|
222
|
+
if delay > 0 { try await Task.sleep(for: .seconds(delay)) }
|
|
223
|
+
})
|
|
224
|
+
loop.updatePolicy(from: .first(true))
|
|
225
|
+
loop.shouldRetry = { retries }
|
|
226
|
+
return loop
|
|
228
227
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import Testing
|
|
3
3
|
|
|
4
4
|
/// `{id}` template substitution used by artwork URL resolution
|
|
5
|
-
/// (
|
|
5
|
+
/// (RequestPipeline.substituteTrackId). A track without a non-blank id must leave
|
|
6
6
|
/// the token LITERALLY in place — never substitute an empty string, never skip — so the
|
|
7
7
|
/// resulting request 404s with a self-describing URL (the resolver logs a warning keyed off
|
|
8
8
|
/// `containsToken`). Mirrors Android's `substituteTrackId` / `configContainsIdToken`.
|