react-native-audio-browser 0.2.0-next.31 → 0.2.0-next.33

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 (44) hide show
  1. package/ios/AVPlayerEngine.swift +4 -8
  2. package/ios/Browser/BrowserConfig.swift +0 -5
  3. package/ios/Browser/BrowserError.swift +16 -0
  4. package/ios/Browser/BrowserManager.swift +36 -141
  5. package/ios/Browser/BrowserPathHelper.swift +25 -12
  6. package/ios/Browser/FavoriteSet.swift +46 -0
  7. package/ios/Browser/JsonModels.swift +172 -381
  8. package/ios/Browser/PageResolver.swift +16 -96
  9. package/ios/Browser/RequestLayer.swift +97 -2
  10. package/ios/Browser/RequestPipeline.swift +78 -227
  11. package/ios/Browser/SectionScope.swift +25 -0
  12. package/ios/Browser/SimpleRouter.swift +73 -144
  13. package/ios/Extension/SearchParams+Query.swift +28 -0
  14. package/ios/Extension/Track+Copying.swift +3 -1
  15. package/ios/Extension/Track+Identity.swift +7 -0
  16. package/ios/Http/HttpClient.swift +12 -30
  17. package/ios/HybridAudioBrowser+Browser.swift +5 -21
  18. package/ios/HybridAudioBrowser+Player.swift +3 -3
  19. package/ios/Model/NitroTypeStubs.swift +78 -0
  20. package/ios/Player/AudioSessionController.swift +2 -0
  21. package/ios/Player/ErrorStatus.swift +25 -2
  22. package/ios/Player/MediaLoader.swift +23 -71
  23. package/ios/Player/NowPlayingLines.swift +36 -0
  24. package/ios/Player/NowPlayingUpdater.swift +27 -85
  25. package/ios/Player/Player.swift +108 -261
  26. package/ios/Player/RetryLoop.swift +26 -0
  27. package/ios/Player/SleepTimerManager.swift +13 -15
  28. package/ios/Player/VolumeFader.swift +5 -0
  29. package/ios/Tests/BrowserPathHelperTests.swift +33 -0
  30. package/ios/Tests/ErrorStatusTests.swift +50 -0
  31. package/ios/Tests/FavoriteSetTests.swift +60 -0
  32. package/ios/Tests/JsonModelsTests.swift +30 -0
  33. package/ios/Tests/MediaLoaderTests.swift +26 -21
  34. package/ios/Tests/NowPlayingLinesTests.swift +43 -0
  35. package/ios/Tests/PlayerPolicyTests.swift +19 -3
  36. package/ios/Tests/PlayerTests.swift +81 -74
  37. package/ios/Tests/RequestLayerTests.swift +116 -0
  38. package/ios/Tests/RetryLoopTests.swift +35 -0
  39. package/ios/Tests/SearchParamsQueryTests.swift +48 -0
  40. package/ios/Tests/SectionScopeTests.swift +53 -0
  41. package/ios/Tests/SimpleRouterTests.swift +38 -3
  42. package/ios/Tests/SleepTimerManagerTests.swift +65 -41
  43. package/ios/Tests/Support/TestTypes.swift +2 -2
  44. package/package.json +1 -1
@@ -8,14 +8,14 @@ private func makeCoordinator() -> (
8
8
  player: Player,
9
9
  engine: MockPlayerEngine,
10
10
  callbacks: MockPlayerCallbacks,
11
- sleepTimer: SleepTimerManager,
11
+ clock: ManualSleepTimerScheduler,
12
12
  ) {
13
- let sleepTimer = SleepTimerManager(scheduler: ManualSleepTimerScheduler())
13
+ let clock = ManualSleepTimerScheduler()
14
14
  let engine = MockPlayerEngine()
15
- let player = makePlayer(engine: engine, retries: false, sleepTimer: sleepTimer)
15
+ let player = makePlayer(engine: engine, retries: false, sleepTimerScheduler: clock)
16
16
  let callbacks = MockPlayerCallbacks()
17
17
  player.callbacks = callbacks
18
- return (player, engine, callbacks, sleepTimer)
18
+ return (player, engine, callbacks, clock)
19
19
  }
20
20
 
21
21
  /// Helper to load a track into the player's queue so it has a currentTrack.
@@ -275,7 +275,7 @@ struct ObserverGuardTests {
275
275
  eh.currentTime = 99.8
276
276
  eh.duration = 100
277
277
 
278
- c.avPlayerDidChangeTimeControlStatus(.paused)
278
+ eh.emit(.timeControlStatus(.paused, waitingForNoItem: false))
279
279
 
280
280
  // State should remain .playing — the pause near track end is ignored
281
281
  #expect(c.state == .playing)
@@ -289,7 +289,7 @@ struct ObserverGuardTests {
289
289
  eh.currentTime = 0
290
290
  eh.duration = 0
291
291
 
292
- c.avPlayerDidChangeTimeControlStatus(.paused)
292
+ eh.emit(.timeControlStatus(.paused, waitingForNoItem: false))
293
293
 
294
294
  #expect(c.state == .none)
295
295
  }
@@ -303,7 +303,7 @@ struct ObserverGuardTests {
303
303
  eh.duration = 100
304
304
  c.setPlayWhenReady(false)
305
305
 
306
- c.avPlayerDidChangeTimeControlStatus(.paused)
306
+ eh.emit(.timeControlStatus(.paused, waitingForNoItem: false))
307
307
 
308
308
  #expect(c.state == .paused)
309
309
  }
@@ -317,7 +317,7 @@ struct ObserverGuardTests {
317
317
  eh.duration = 100
318
318
  c.setPlayWhenReady(true)
319
319
 
320
- c.avPlayerDidChangeTimeControlStatus(.paused)
320
+ eh.emit(.timeControlStatus(.paused, waitingForNoItem: false))
321
321
 
322
322
  // playWhenReady is true — pause is ignored (likely buffering/seeking)
323
323
  #expect(c.state == .playing)
@@ -325,11 +325,11 @@ struct ObserverGuardTests {
325
325
 
326
326
  @Test @MainActor
327
327
  func timeControlStatus_ignored_duringLoading() {
328
- let (c, _, cb, _) = makeCoordinator()
328
+ let (c, eh, cb, _) = makeCoordinator()
329
329
  c.transition(.trackLoading)
330
330
  cb.playbackChanges.removeAll()
331
331
 
332
- c.avPlayerDidChangeTimeControlStatus(.paused)
332
+ eh.emit(.timeControlStatus(.paused, waitingForNoItem: false))
333
333
 
334
334
  #expect(c.state == .loading)
335
335
  #expect(cb.playbackChanges.isEmpty)
@@ -341,7 +341,7 @@ struct ObserverGuardTests {
341
341
  c.transition(.avPlayerPlaying)
342
342
  eh.hasLoadedAsset = false
343
343
 
344
- c.avPlayerDidChangeTimeControlStatus(.waitingToPlayAtSpecifiedRate)
344
+ eh.emit(.timeControlStatus(.waitingToPlayAtSpecifiedRate, waitingForNoItem: false))
345
345
 
346
346
  #expect(c.state == .playing) // unchanged
347
347
  }
@@ -352,18 +352,18 @@ struct ObserverGuardTests {
352
352
  c.transition(.avPlayerPlaying)
353
353
  eh.hasLoadedAsset = true
354
354
 
355
- c.avPlayerDidChangeTimeControlStatus(.waitingToPlayAtSpecifiedRate)
355
+ eh.emit(.timeControlStatus(.waitingToPlayAtSpecifiedRate, waitingForNoItem: false))
356
356
 
357
357
  #expect(c.state == .buffering)
358
358
  }
359
359
 
360
360
  @Test @MainActor
361
361
  func playbackLikelyToKeepUp_false_ignored() {
362
- let (c, _, cb, _) = makeCoordinator()
362
+ let (c, eh, cb, _) = makeCoordinator()
363
363
  c.transition(.trackLoading)
364
364
  cb.playbackChanges.removeAll()
365
365
 
366
- c.avItemDidUpdatePlaybackLikelyToKeepUp(false)
366
+ eh.emit(.likelyToKeepUp(false))
367
367
 
368
368
  #expect(c.state == .loading)
369
369
  #expect(cb.playbackChanges.isEmpty)
@@ -374,28 +374,29 @@ struct ObserverGuardTests {
374
374
  let (c, eh, _, _) = makeCoordinator()
375
375
  c.transition(.trackLoading)
376
376
  eh.hasLoadedAsset = true
377
+ eh.hasItem = true
377
378
 
378
- c.avItemDidUpdatePlaybackLikelyToKeepUp(true)
379
+ eh.emit(.likelyToKeepUp(true))
379
380
 
380
381
  #expect(c.state == .ready)
381
382
  }
382
383
 
383
384
  @Test @MainActor
384
385
  func audioDidStart_ignored_duringLoading() {
385
- let (c, _, _, _) = makeCoordinator()
386
+ let (c, eh, _, _) = makeCoordinator()
386
387
  c.transition(.trackLoading)
387
388
 
388
- c.audioDidStart()
389
+ eh.emit(.audioDidStart)
389
390
 
390
391
  #expect(c.state == .loading)
391
392
  }
392
393
 
393
394
  @Test @MainActor
394
395
  func audioDidStart_transitionsToPlaying() {
395
- let (c, _, _, _) = makeCoordinator()
396
+ let (c, eh, _, _) = makeCoordinator()
396
397
  c.transition(.bufferingSufficient)
397
398
 
398
- c.audioDidStart()
399
+ eh.emit(.audioDidStart)
399
400
 
400
401
  #expect(c.state == .playing)
401
402
  }
@@ -604,7 +605,7 @@ struct HandleTrackDidPlayToEndTimeTests {
604
605
  c.repeatMode = .track
605
606
  eh.startPlaybackCallCount = 0
606
607
 
607
- c.handleTrackDidPlayToEndTime()
608
+ eh.emit(.playedToEnd)
608
609
 
609
610
  #expect(eh.seekCalls == [0])
610
611
  #expect(eh.startPlaybackCallCount == 1)
@@ -622,7 +623,7 @@ struct HandleTrackDidPlayToEndTimeTests {
622
623
  c.repeatMode = .track
623
624
  c.setPlayWhenReady(false)
624
625
 
625
- c.handleTrackDidPlayToEndTime()
626
+ eh.emit(.playedToEnd)
626
627
 
627
628
  #expect(cb.queueEndedEvents.isEmpty)
628
629
  #expect(eh.seekCalls.isEmpty)
@@ -635,7 +636,7 @@ struct HandleTrackDidPlayToEndTimeTests {
635
636
  loadTrack(c)
636
637
  c.repeatMode = .off
637
638
 
638
- c.handleTrackDidPlayToEndTime()
639
+ eh.emit(.playedToEnd)
639
640
 
640
641
  #expect(c.state == .ended)
641
642
  #expect(cb.queueEndedEvents.count >= 1)
@@ -643,7 +644,7 @@ struct HandleTrackDidPlayToEndTimeTests {
643
644
 
644
645
  @Test @MainActor
645
646
  func midQueue_advancesToNext() {
646
- let (c, _, cb, _) = makeCoordinator()
647
+ let (c, eh, cb, _) = makeCoordinator()
647
648
  let tracks = [
648
649
  Track(id: "t1", src: "https://example.com/1.mp3", title: "Track 1"),
649
650
  Track(id: "t2", src: "https://example.com/2.mp3", title: "Track 2"),
@@ -651,9 +652,9 @@ struct HandleTrackDidPlayToEndTimeTests {
651
652
  c.setQueue(tracks)
652
653
  cb.activeTrackChanges.removeAll()
653
654
 
654
- c.handleTrackDidPlayToEndTime()
655
+ eh.emit(.playedToEnd)
655
656
 
656
- #expect(c.currentIndex == 1)
657
+ #expect(c.queue.currentIndex == 1)
657
658
  #expect(cb.activeTrackChanges.count == 1)
658
659
  #expect(cb.activeTrackChanges.first?.track?.id == "t2")
659
660
  }
@@ -672,7 +673,7 @@ struct NaturalEndPlayIntentTests {
672
673
  let (c, eh, _, _) = makeCoordinator()
673
674
  startPlaying(c, eh)
674
675
 
675
- c.handleTrackDidPlayToEndTime()
676
+ eh.emit(.playedToEnd)
676
677
 
677
678
  #expect(c.state == .ended)
678
679
  #expect(c.playWhenReady == false)
@@ -684,7 +685,7 @@ struct NaturalEndPlayIntentTests {
684
685
  startPlaying(c, eh)
685
686
  callbacks.releaseSessionCount = 0
686
687
 
687
- c.handleTrackDidPlayToEndTime()
688
+ eh.emit(.playedToEnd)
688
689
 
689
690
  #expect(callbacks.releaseSessionCount == 1)
690
691
  }
@@ -695,7 +696,7 @@ struct NaturalEndPlayIntentTests {
695
696
  func playAfterNaturalEnd_reloadsFromStart() {
696
697
  let (c, eh, _, _) = makeCoordinator()
697
698
  startPlaying(c, eh)
698
- c.handleTrackDidPlayToEndTime()
699
+ eh.emit(.playedToEnd)
699
700
  eh.resumePosition = 300
700
701
  let resolvesBefore = eh.resolveAndLoadCalls.count
701
702
 
@@ -712,10 +713,10 @@ struct NaturalEndPlayIntentTests {
712
713
  func strayPauseAfterNaturalEnd_staysEnded() {
713
714
  let (c, eh, _, _) = makeCoordinator()
714
715
  startPlaying(c, eh)
715
- c.handleTrackDidPlayToEndTime()
716
+ eh.emit(.playedToEnd)
716
717
  #expect(c.state == .ended)
717
718
 
718
- c.avPlayerDidChangeTimeControlStatus(.paused)
719
+ eh.emit(.timeControlStatus(.paused, waitingForNoItem: false))
719
720
 
720
721
  #expect(c.state == .ended)
721
722
  }
@@ -724,7 +725,7 @@ struct NaturalEndPlayIntentTests {
724
725
  func interruptionAfterNaturalEnd_doesNotArmResume() {
725
726
  let (c, eh, _, _) = makeCoordinator()
726
727
  startPlaying(c, eh)
727
- c.handleTrackDidPlayToEndTime()
728
+ eh.emit(.playedToEnd)
728
729
  let resolvesBefore = eh.resolveAndLoadCalls.count
729
730
 
730
731
  c.handleInterruptionBegan()
@@ -760,10 +761,10 @@ struct HealthyPlaybackRetryResetTests {
760
761
  func sustainedPlayback_resetsRetryBudget() async {
761
762
  let (player, retry, _) = await makeAfterOneRetry()
762
763
  #expect(retry.attempts == 1)
763
- player.healthyPlaybackDuration = 0.01
764
+ retry.healthyPlaybackDuration = .milliseconds(10)
764
765
 
765
766
  player.transition(.avPlayerPlaying)
766
- await player.healthyPlaybackTask?.value
767
+ await retry.healthyPlaybackTask?.value
767
768
 
768
769
  #expect(retry.attempts == 0)
769
770
  }
@@ -773,11 +774,11 @@ struct HealthyPlaybackRetryResetTests {
773
774
  @Test @MainActor
774
775
  func refill_skipsWhileRetryIsPending() async {
775
776
  let (player, retry, _) = await makeAfterOneRetry(delay: 1)
776
- player.healthyPlaybackDuration = 0.01
777
+ retry.healthyPlaybackDuration = .milliseconds(10)
777
778
 
778
779
  player.transition(.avPlayerPlaying)
779
780
  retry.handleError(URLError(.timedOut), context: .playback)
780
- await player.healthyPlaybackTask?.value
781
+ await retry.healthyPlaybackTask?.value
781
782
 
782
783
  #expect(retry.pendingTask != nil)
783
784
  retry.cancel()
@@ -787,10 +788,10 @@ struct HealthyPlaybackRetryResetTests {
787
788
  func playbackInterruptedBeforeThreshold_doesNotReset() async {
788
789
  let (player, retry, engine) = await makeAfterOneRetry()
789
790
  engine.hasLoadedAsset = true
790
- player.healthyPlaybackDuration = 0.5
791
+ retry.healthyPlaybackDuration = .milliseconds(500)
791
792
 
792
793
  player.transition(.avPlayerPlaying)
793
- let refill = player.healthyPlaybackTask
794
+ let refill = retry.healthyPlaybackTask
794
795
  player.transition(.avPlayerWaiting) // stalls before the threshold
795
796
 
796
797
  // Leaving .playing cancels the refill; awaiting the cancelled task is the
@@ -805,44 +806,54 @@ struct HealthyPlaybackRetryResetTests {
805
806
 
806
807
  @Suite("Player - sleep timer fade")
807
808
  struct SleepTimerFadeTests {
809
+ /// A 20 s timer whose 10 s fade has begun.
810
+ @MainActor
811
+ private func startFade(_ c: Player, _ clock: ManualSleepTimerScheduler) {
812
+ c.sleepTimerManager.set(seconds: 20, fadeDuration: 10)
813
+ clock.advance(to: 10)
814
+ }
815
+
808
816
  @Test @MainActor
809
817
  func fadeStart_capturesPreFadeVolume() {
810
- let (c, eh, _, st) = makeCoordinator()
818
+ let (c, eh, _, clock) = makeCoordinator()
811
819
  eh.volume = 0.8
812
820
 
813
- st.onFadeStart?(10)
821
+ startFade(c, clock)
822
+ eh.volume = 0.1 // mid-fade
814
823
 
815
- #expect(c.volumeFader.isActive)
816
- #expect(c.volumeFader.originalVolume == 0.8)
824
+ #expect(c.sleepTimerManager.isFading)
825
+ c.sleepTimerManager.clear()
826
+ #expect(eh.volume == 0.8)
817
827
  }
818
828
 
819
829
  @Test @MainActor
820
830
  func completion_pausesThenRestoresPreFadeVolume() {
821
- let (c, eh, _, st) = makeCoordinator()
831
+ let (c, eh, _, clock) = makeCoordinator()
822
832
  loadTrack(c)
823
833
  c.setPlayWhenReady(true)
824
834
  c.transition(.bufferingSufficient) // leave .loading so pause reaches the player
825
835
  eh.pausePlaybackCallCount = 0
826
836
 
827
- st.onFadeStart?(10)
837
+ startFade(c, clock)
828
838
  eh.volume = 0.2 // mid-fade
829
839
 
830
- st.onComplete?()
840
+ clock.advance(to: 20)
831
841
 
832
842
  #expect(c.playWhenReady == false)
833
843
  #expect(eh.pausePlaybackCallCount == 1)
834
844
  #expect(eh.volume == 1.0)
835
- #expect(!c.volumeFader.isActive)
845
+ #expect(!c.sleepTimerManager.isFading)
836
846
  }
837
847
 
838
848
  @Test @MainActor
839
849
  func completion_withoutFade_justPauses() {
840
- let (c, eh, _, st) = makeCoordinator()
850
+ let (c, eh, _, clock) = makeCoordinator()
841
851
  loadTrack(c)
842
852
  c.setPlayWhenReady(true)
843
853
  eh.volume = 0.8
854
+ c.sleepTimerManager.set(seconds: 20)
844
855
 
845
- st.onComplete?()
856
+ clock.advance(to: 20)
846
857
 
847
858
  #expect(c.playWhenReady == false)
848
859
  #expect(eh.volume == 0.8) // untouched — no fade ran
@@ -850,41 +861,40 @@ struct SleepTimerFadeTests {
850
861
 
851
862
  @Test @MainActor
852
863
  func fadeCancel_restoresPreFadeVolume() {
853
- let (c, eh, _, st) = makeCoordinator()
864
+ let (c, eh, _, clock) = makeCoordinator()
854
865
  eh.volume = 0.8
855
866
 
856
- st.onFadeStart?(10)
867
+ startFade(c, clock)
857
868
  eh.volume = 0.1 // mid-fade
858
869
 
859
- st.onFadeCancel?()
870
+ c.sleepTimerManager.set(seconds: 60)
860
871
 
861
872
  #expect(eh.volume == 0.8)
862
- #expect(!c.volumeFader.isActive)
873
+ #expect(!c.sleepTimerManager.isFading)
863
874
  }
864
875
 
865
876
  @Test @MainActor
866
877
  func pauseDuringFade_clearsSleepTimer() {
867
- let (c, _, _, st) = makeCoordinator()
878
+ let (c, _, _, clock) = makeCoordinator()
868
879
  loadTrack(c)
869
880
  c.setPlayWhenReady(true)
870
- st.set(seconds: 60, fadeDuration: 10)
871
881
 
872
- st.onFadeStart?(10)
882
+ startFade(c, clock)
873
883
  c.pause()
874
884
 
875
- #expect(st.get() == nil)
885
+ #expect(c.sleepTimerManager.get() == nil)
876
886
  }
877
887
 
878
888
  @Test @MainActor
879
889
  func pauseWithoutFade_leavesSleepTimerAlone() {
880
- let (c, _, _, st) = makeCoordinator()
890
+ let (c, _, _, _) = makeCoordinator()
881
891
  loadTrack(c)
882
892
  c.setPlayWhenReady(true)
883
- st.set(seconds: 60, fadeDuration: 10)
893
+ c.sleepTimerManager.set(seconds: 60, fadeDuration: 10)
884
894
 
885
895
  c.pause()
886
896
 
887
- #expect(st.get() != nil)
897
+ #expect(c.sleepTimerManager.get() != nil)
888
898
  }
889
899
  }
890
900
 
@@ -922,7 +932,7 @@ struct InterruptionTests {
922
932
  let (c, eh, _, _) = makeCoordinator()
923
933
  startPlaying(c, eh)
924
934
 
925
- c.avPlayerDidChangeTimeControlStatus(.paused)
935
+ eh.emit(.timeControlStatus(.paused, waitingForNoItem: false))
926
936
  #expect(c.state == .playing) // swallowed — the symptom this fix targets
927
937
 
928
938
  c.handleInterruptionBegan()
@@ -1041,7 +1051,7 @@ struct InterruptionTests {
1041
1051
  startPlaying(c, eh)
1042
1052
  // Pause before the interruption (e.g. user already paused).
1043
1053
  c.setPlayWhenReady(false)
1044
- c.avPlayerDidChangeTimeControlStatus(.paused)
1054
+ eh.emit(.timeControlStatus(.paused, waitingForNoItem: false))
1045
1055
  #expect(c.state == .paused)
1046
1056
 
1047
1057
  c.handleInterruptionBegan()
@@ -1144,10 +1154,10 @@ struct LoadTests {
1144
1154
 
1145
1155
  @Test @MainActor
1146
1156
  func aNewTrackStartsAnUnprovenLoad() {
1147
- let (c, _, _, _) = makeCoordinator()
1157
+ let (c, eh, _, _) = makeCoordinator()
1148
1158
  c.setQueue([track("a"), track("b")])
1149
1159
  let first = c.load
1150
- c.audioDidStart()
1160
+ eh.emit(.audioDidStart)
1151
1161
  #expect(c.load?.hasPlayed == true)
1152
1162
 
1153
1163
  c.next()
@@ -1160,9 +1170,9 @@ struct LoadTests {
1160
1170
  /// recovery budget, must survive it.
1161
1171
  @Test @MainActor
1162
1172
  func aRetryResetKeepsTheLoad() {
1163
- let (c, _, _, _) = makeCoordinator()
1173
+ let (c, eh, _, _) = makeCoordinator()
1164
1174
  c.setQueue([track("a")])
1165
- c.audioDidStart()
1175
+ eh.emit(.audioDidStart)
1166
1176
  let load = c.load
1167
1177
 
1168
1178
  c.retry.reset()
@@ -1171,16 +1181,13 @@ struct LoadTests {
1171
1181
  #expect(c.load?.hasPlayed == true)
1172
1182
  }
1173
1183
 
1174
- @Test @MainActor
1175
- func theNowPlayingPrimeRunsOncePerLoad() {
1176
- let (c, _, _, _) = makeCoordinator()
1177
- c.setQueue([track("a")])
1178
- #expect(c.claimNowPlayingPrime())
1179
- #expect(!c.claimNowPlayingPrime())
1180
-
1181
- c.beginLoad()
1182
-
1183
- #expect(c.claimNowPlayingPrime())
1184
+ @Test
1185
+ func theNowPlayingPrimeIsClaimedOnce() {
1186
+ var load = Player.Load(id: 1)
1187
+ let first = load.claimNowPlayingPrime()
1188
+ let second = load.claimNowPlayingPrime()
1189
+ #expect(first)
1190
+ #expect(!second)
1184
1191
  }
1185
1192
  }
1186
1193
 
@@ -109,4 +109,120 @@ struct RequestLayerTests {
109
109
  #expect(RequestLayer.mergeDicts(["a": "1"], nil) == ["a": "1"])
110
110
  #expect(RequestLayer.mergeDicts(nil, ["b": "2"]) == ["b": "2"])
111
111
  }
112
+
113
+ @Test func baseSetsOnlyThePath() {
114
+ #expect(Config.base(path: "/a") == Config(path: "/a"))
115
+ }
116
+
117
+ // MARK: - artwork
118
+
119
+ private func artwork(
120
+ _ artwork: Config = Config(),
121
+ over base: Config = Config(path: "/art.png"),
122
+ resolve: (nonisolated(nonsending)() async throws -> Config?)? = nil,
123
+ trackHasArtwork: Bool = true,
124
+ sizeQuery: [String: String] = [:],
125
+ transforms: [nonisolated(nonsending)(Config) async throws -> Config] = [],
126
+ id: String? = nil,
127
+ ) async throws -> Config? {
128
+ try await RequestLayer.artwork(
129
+ artwork, over: base, resolve: resolve, trackHasArtwork: trackHasArtwork,
130
+ sizeQuery: sizeQuery, transforms: transforms, id: id,
131
+ )
132
+ }
133
+
134
+ @Test func artworkStaticFieldsMergeOverTheBasePathIncluded() async throws {
135
+ let result = try await artwork(
136
+ Config(path: "/artwork/{id}", baseUrl: "https://img", headers: ["B": "2"]),
137
+ over: Config(path: "/art.png", baseUrl: "https://api", headers: ["A": "1"]),
138
+ id: "42",
139
+ )
140
+ #expect(result == Config(path: "/artwork/42", baseUrl: "https://img", headers: ["A": "1", "B": "2"]))
141
+ }
142
+
143
+ @Test func theResolvedConfigWinsOverTheStaticFields() async throws {
144
+ let result = try await artwork(
145
+ Config(baseUrl: "https://static", userAgent: "static"),
146
+ resolve: { Config(baseUrl: "https://resolved") },
147
+ )
148
+ #expect(result == Config(path: "/art.png", baseUrl: "https://resolved", userAgent: "static"))
149
+ }
150
+
151
+ @Test func aResolverThatProducesNothingKeepsTheTracksArtwork() async throws {
152
+ let result = try await artwork(Config(baseUrl: "https://img"), resolve: { nil })
153
+ #expect(result == Config(path: "/art.png", baseUrl: "https://img"))
154
+ }
155
+
156
+ @Test func aResolverThatProducesNothingForATrackWithoutArtworkIsNoArtwork() async throws {
157
+ let result = try await artwork(
158
+ Config(path: "/fallback.png"), over: Config(), resolve: { nil }, trackHasArtwork: false,
159
+ )
160
+ #expect(result == nil)
161
+ }
162
+
163
+ @Test func theSizeQueryMergesBeforeTheTransformsRun() async throws {
164
+ var seen: Config?
165
+ let result = try await artwork(
166
+ over: Config(path: "/art.png", query: ["v": "1"]),
167
+ sizeQuery: ["w": "300"],
168
+ transforms: [{ seen = $0; return $0 }],
169
+ )
170
+ #expect(seen?.query == ["v": "1", "w": "300"])
171
+ #expect(result?.query == ["v": "1", "w": "300"])
172
+ }
173
+
174
+ @Test func anEmptySizeQueryLeavesNoQuery() async throws {
175
+ #expect(try await artwork()?.query == nil)
176
+ }
177
+
178
+ @Test func transformsRunInOrderBeforeTheIdIsFilled() async throws {
179
+ let result = try await artwork(
180
+ transforms: [
181
+ { Config(path: "/t/{id}", baseUrl: $0.baseUrl) },
182
+ { Config(path: $0.path, baseUrl: "https://second", headers: ["X-Id": "{id}"], query: ["id": "{id}"]) },
183
+ ],
184
+ id: "7",
185
+ )
186
+ #expect(result == Config(path: "/t/7", baseUrl: "https://second", headers: ["X-Id": "7"], query: ["id": "7"]))
187
+ }
188
+
189
+ @Test func withoutAnIdTheTokenStays() async throws {
190
+ let result = try await artwork(Config(path: "/artwork/{id}"), id: nil)
191
+ #expect(result?.path == "/artwork/{id}")
192
+ #expect(RequestLayer.hasUnfilledIdToken(result!))
193
+ #expect(!RequestLayer.hasUnfilledIdToken(Config(path: "/artwork/1")))
194
+ #expect(RequestLayer.hasUnfilledIdToken(Config(headers: ["X": "{id}"])))
195
+ }
196
+
197
+ @Test func noPathLeftIsNoArtwork() async throws {
198
+ #expect(try await artwork(over: Config(baseUrl: "https://img")) == nil)
199
+ #expect(try await artwork(transforms: [{ _ in Config(baseUrl: "https://img") }]) == nil)
200
+ }
201
+
202
+ @Test func sizeQueryNamesTheContextsWholePixels() {
203
+ let params = ImageQueryParams(width: "w", height: "h")
204
+ #expect(RequestLayer.sizeQuery(params, ImageContext(width: 300.7, height: 200)) == ["w": "300", "h": "200"])
205
+ #expect(RequestLayer.sizeQuery(ImageQueryParams(width: "w"), ImageContext(width: 300, height: 200)) == ["w": "300"])
206
+ #expect(RequestLayer.sizeQuery(params, ImageContext(width: nil, height: 200)) == ["h": "200"])
207
+ #expect(RequestLayer.sizeQuery(params, nil) == [:])
208
+ #expect(RequestLayer.sizeQuery(nil, ImageContext(width: 300, height: 200)) == [:])
209
+ }
210
+
211
+ @Test func imageHeadersAddTheUserAgentUnlessAHeaderSetsIt() {
212
+ #expect(RequestLayer.imageHeaders(Config()) == nil)
213
+ #expect(RequestLayer.imageHeaders(Config(userAgent: "UA")) == ["User-Agent": "UA"])
214
+ #expect(RequestLayer.imageHeaders(Config(headers: ["User-Agent": "mine"], userAgent: "UA")) == ["User-Agent": "mine"])
215
+ }
216
+
217
+ @Test func imageHeadersAddTheContentTypeUnlessAHeaderSetsIt() {
218
+ #expect(RequestLayer.imageHeaders(Config(contentType: "image/png")) == ["Content-Type": "image/png"])
219
+ #expect(
220
+ RequestLayer.imageHeaders(Config(headers: ["Content-Type": "mine"], contentType: "image/png"))
221
+ == ["Content-Type": "mine"],
222
+ )
223
+ #expect(
224
+ RequestLayer.imageHeaders(Config(contentType: "image/png", userAgent: "UA"))
225
+ == ["Content-Type": "image/png", "User-Agent": "UA"],
226
+ )
227
+ }
112
228
  }
@@ -268,3 +268,38 @@ struct RetryLoopHandleErrorTests {
268
268
  private final class LoopHolder {
269
269
  weak var loop: RetryLoop?
270
270
  }
271
+
272
+ @Suite("RetryLoop healthy playback")
273
+ @MainActor
274
+ struct RetryLoopHealthyPlaybackTests {
275
+ private func makeAfterOneRetry() async -> RetryLoop {
276
+ let loop = makeRetryLoop()
277
+ loop.handleError(URLError(.timedOut), context: .playback)
278
+ await loop.pendingTask?.value
279
+ return loop
280
+ }
281
+
282
+ @Test func audiblePlaybackForTheWindowRefillsTheBudgets() async {
283
+ let loop = await makeAfterOneRetry()
284
+ #expect(loop.attempts == 1)
285
+ loop.healthyPlaybackDuration = .milliseconds(10)
286
+
287
+ loop.playbackBecameAudible()
288
+ await loop.healthyPlaybackTask?.value
289
+
290
+ #expect(loop.attempts == 0)
291
+ }
292
+
293
+ @Test func stoppingBeforeTheWindowKeepsTheBudgets() async {
294
+ let loop = await makeAfterOneRetry()
295
+ loop.healthyPlaybackDuration = .milliseconds(500)
296
+
297
+ loop.playbackBecameAudible()
298
+ let refill = loop.healthyPlaybackTask
299
+ loop.playbackStoppedBeingAudible()
300
+ await refill?.value
301
+
302
+ #expect(loop.attempts == 1)
303
+ #expect(loop.healthyPlaybackTask == nil)
304
+ }
305
+ }
@@ -0,0 +1,48 @@
1
+ import Testing
2
+
3
+ @testable import AudioBrowserTestable
4
+
5
+ struct SearchParamsQueryTests {
6
+ private func params(
7
+ _ query: String,
8
+ mode: SearchMode? = nil,
9
+ genre: String? = nil,
10
+ artist: String? = nil,
11
+ album: String? = nil,
12
+ title: String? = nil,
13
+ playlist: String? = nil,
14
+ reference: MediaReference = .unknown,
15
+ ) -> SearchParams {
16
+ SearchParams(
17
+ mode: mode, query: query, genre: genre, artist: artist, album: album,
18
+ title: title, playlist: playlist, reference: reference,
19
+ )
20
+ }
21
+
22
+ @Test func aPlainSearchSendsOnlyQ() {
23
+ #expect(params("jazz").queryParams == ["q": "jazz"])
24
+ }
25
+
26
+ @Test func everySetFilterIsSent() {
27
+ let all = params(
28
+ "x", mode: .musicVideo, genre: "g", artist: "a", album: "al", title: "t", playlist: "p", reference: .my,
29
+ )
30
+ #expect(all.queryParams == [
31
+ "q": "x", "mode": "music-video", "genre": "g", "artist": "a", "album": "al",
32
+ "title": "t", "playlist": "p", "reference": "my",
33
+ ])
34
+ }
35
+
36
+ @Test func anUnknownReferenceIsNotSent() {
37
+ #expect(params("x", reference: .unknown).queryParams["reference"] == nil)
38
+ }
39
+
40
+ @Test func theCacheKeyIsSortedAndDelimited() {
41
+ #expect(params("jazz", genre: "jazz").cacheKey == "genre=jazz\u{1}q=jazz")
42
+ }
43
+
44
+ @Test func searchesWithTheSameQueryButDifferentFiltersHaveDifferentKeys() {
45
+ #expect(params("jazz").cacheKey != params("jazz", genre: "jazz").cacheKey)
46
+ #expect(params("a", artist: "b").cacheKey != params("a", album: "b").cacheKey)
47
+ }
48
+ }