diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Pillarbox.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Pillarbox.xcscheme
index 9787d909..2fe926da 100644
--- a/.swiftpm/xcode/xcshareddata/xcschemes/Pillarbox.xcscheme
+++ b/.swiftpm/xcode/xcshareddata/xcschemes/Pillarbox.xcscheme
@@ -185,6 +185,78 @@
region = "CH"
codeCoverageEnabled = "YES">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Void
+
+ fileprivate init(name: ComScoreHit.Name, evaluate: @escaping (ComScoreLabels) -> Void) {
+ self.name = name
+ self.evaluate = evaluate
+ }
+
+ static func match(hit: ComScoreHit, with expectation: Self) -> Bool {
+ guard hit.name == expectation.name else { return false }
+ expectation.evaluate(hit.labels)
+ return true
+ }
+}
+
+extension ComScoreHitExpectation: CustomDebugStringConvertible {
+ var debugDescription: String {
+ name.rawValue
+ }
+}
+
+extension ComScoreTestCase {
+ func play(evaluate: @escaping (ComScoreLabels) -> Void = { _ in }) -> ComScoreHitExpectation {
+ ComScoreHitExpectation(name: .play, evaluate: evaluate)
+ }
+
+ func playrt(evaluate: @escaping (ComScoreLabels) -> Void = { _ in }) -> ComScoreHitExpectation {
+ ComScoreHitExpectation(name: .playrt, evaluate: evaluate)
+ }
+
+ func pause(evaluate: @escaping (ComScoreLabels) -> Void = { _ in }) -> ComScoreHitExpectation {
+ ComScoreHitExpectation(name: .pause, evaluate: evaluate)
+ }
+
+ func end(evaluate: @escaping (ComScoreLabels) -> Void = { _ in }) -> ComScoreHitExpectation {
+ ComScoreHitExpectation(name: .end, evaluate: evaluate)
+ }
+
+ func view(evaluate: @escaping (ComScoreLabels) -> Void = { _ in }) -> ComScoreHitExpectation {
+ ComScoreHitExpectation(name: .view, evaluate: evaluate)
+ }
+}
diff --git a/Tests/AnalyticsTests/ComScore/ComScorePageViewTests.swift b/Tests/AnalyticsTests/ComScore/ComScorePageViewTests.swift
new file mode 100644
index 00000000..4e8e80da
--- /dev/null
+++ b/Tests/AnalyticsTests/ComScore/ComScorePageViewTests.swift
@@ -0,0 +1,332 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Nimble
+import PillarboxCircumspect
+import UIKit
+
+private class AutomaticMockViewController: UIViewController, PageViewTracking {
+ private var pageName: String {
+ title ?? "automatic"
+ }
+
+ var comScorePageView: ComScorePageView {
+ .init(name: pageName)
+ }
+
+ var commandersActPageView: CommandersActPageView {
+ .init(name: pageName, type: "type")
+ }
+
+ init(title: String? = nil) {
+ super.init(nibName: nil, bundle: nil)
+ self.title = title
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+}
+
+private class ManualMockViewController: UIViewController, PageViewTracking {
+ private var pageName: String {
+ "manual"
+ }
+
+ var comScorePageView: ComScorePageView {
+ .init(name: pageName)
+ }
+
+ var commandersActPageView: CommandersActPageView {
+ .init(name: pageName, type: "type")
+ }
+
+ var isTrackedAutomatically: Bool {
+ false
+ }
+}
+
+final class ComScorePageViewTests: ComScoreTestCase {
+ func testGlobals() {
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c2).to(equal("6036016"))
+ expect(labels.ns_ap_an).to(equal("xctest"))
+ expect(labels.c8).to(equal("name"))
+ expect(labels.ns_st_mp).to(beNil())
+ expect(labels.ns_st_mv).to(beNil())
+ expect(labels.mp_brand).to(equal("SRG"))
+ expect(labels.mp_v).notTo(beEmpty())
+ expect(labels.cs_ucfr).to(beEmpty())
+ }
+ ) {
+ Analytics.shared.trackPageView(
+ comScore: .init(name: "name"),
+ commandersAct: .init(name: "name", type: "type")
+ )
+ }
+ }
+
+ func testBlankTitle() {
+ guard nimbleThrowAssertionsAvailable() else { return }
+ expect(Analytics.shared.trackPageView(
+ comScore: .init(name: " "),
+ commandersAct: .init(name: "name", type: "type")
+ )).to(throwAssertion())
+ }
+
+ func testCustomLabels() {
+ expectAtLeastHits(
+ view { labels in
+ expect(labels["key"]).to(equal("value"))
+ }
+ ) {
+ Analytics.shared.trackPageView(
+ comScore: .init(name: "name", labels: ["key": "value"]),
+ commandersAct: .init(name: "name", type: "type")
+ )
+ }
+ }
+
+ func testCustomLabelsForbiddenOverrides() {
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("name"))
+ expect(labels.cs_ucfr).to(beEmpty())
+ }
+ ) {
+ Analytics.shared.trackPageView(
+ comScore: .init(name: "name", labels: [
+ "c8": "overridden_title",
+ "cs_ucfr": "42"
+ ]),
+ commandersAct: .init(name: "name", type: "type")
+ )
+ }
+ }
+
+ func testDefaultProtocolImplementation() {
+ let viewController = AutomaticMockViewController()
+ expect(viewController.isTrackedAutomatically).to(beTrue())
+ }
+
+ func testCustomProtocolImplementation() {
+ let viewController = ManualMockViewController()
+ expect(viewController.isTrackedAutomatically).to(beFalse())
+ }
+
+ func testAutomaticTrackingWithoutProtocolImplementation() {
+ let viewController = UIViewController()
+ expectNoHits(during: .seconds(2)) {
+ viewController.simulateViewAppearance()
+ }
+ }
+
+ func testManualTrackingWithoutProtocolImplementation() {
+ let viewController = UIViewController()
+ expectNoHits(during: .seconds(2)) {
+ viewController.trackPageView()
+ }
+ }
+
+ func testAutomaticTrackingWhenViewAppears() {
+ let viewController = AutomaticMockViewController()
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("automatic"))
+ }
+ ) {
+ viewController.simulateViewAppearance()
+ }
+ }
+
+ func testSinglePageViewWhenContainerViewAppears() {
+ let viewController = AutomaticMockViewController()
+ let navigationController = UINavigationController(rootViewController: viewController)
+ expectHits(
+ view { labels in
+ expect(labels.c8).to(equal("automatic"))
+ },
+ during: .seconds(2)
+ ) {
+ navigationController.simulateViewAppearance()
+ viewController.simulateViewAppearance()
+ }
+ }
+
+ func testAutomaticTrackingWhenActiveViewInParentAppears() {
+ let viewController1 = AutomaticMockViewController(title: "title1")
+ let viewController2 = AutomaticMockViewController(title: "title2")
+ let tabBarController = UITabBarController()
+ tabBarController.viewControllers = [viewController1, viewController2]
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("title1"))
+ }
+ ) {
+ viewController1.simulateViewAppearance()
+ }
+ }
+
+ func testAutomaticTrackingWhenInactiveViewInParentAppears() {
+ let viewController1 = AutomaticMockViewController(title: "title1")
+ let viewController2 = AutomaticMockViewController(title: "title2")
+ let tabBarController = UITabBarController()
+ tabBarController.viewControllers = [viewController1, viewController2]
+ expectNoHits(during: .seconds(2)) {
+ viewController2.simulateViewAppearance()
+ }
+ }
+
+ func testAutomaticTrackingWhenViewAppearsInActiveHierarchy() {
+ let viewController1 = AutomaticMockViewController(title: "title1")
+ let viewController2 = AutomaticMockViewController(title: "title2")
+ let tabBarController = UITabBarController()
+ tabBarController.viewControllers = [
+ UINavigationController(rootViewController: viewController1),
+ UINavigationController(rootViewController: viewController2)
+ ]
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("title1"))
+ }
+ ) {
+ viewController1.simulateViewAppearance()
+ }
+ }
+
+ func testAutomaticTrackingWhenViewAppearsInInactiveHierarchy() {
+ let viewController1 = AutomaticMockViewController(title: "title1")
+ let viewController2 = AutomaticMockViewController(title: "title2")
+ let tabBarController = UITabBarController()
+ tabBarController.viewControllers = [
+ UINavigationController(rootViewController: viewController1),
+ UINavigationController(rootViewController: viewController2)
+ ]
+ expectNoHits(during: .seconds(2)) {
+ viewController2.simulateViewAppearance()
+ }
+ }
+
+ func testManualTracking() {
+ let viewController = ManualMockViewController()
+ expectNoHits(during: .seconds(2)) {
+ viewController.simulateViewAppearance()
+ }
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("manual"))
+ }
+ ) {
+ viewController.trackPageView()
+ }
+ }
+
+ func testRecursiveAutomaticTrackingOnViewController() {
+ let viewController = AutomaticMockViewController()
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("automatic"))
+ }
+ ) {
+ viewController.recursivelyTrackAutomaticPageViews()
+ }
+ }
+
+ func testRecursiveAutomaticTrackingOnNavigationController() {
+ let viewController = UINavigationController(rootViewController: AutomaticMockViewController(title: "root"))
+ viewController.pushViewController(AutomaticMockViewController(title: "pushed"), animated: false)
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("pushed"))
+ }
+ ) {
+ viewController.recursivelyTrackAutomaticPageViews()
+ }
+ }
+
+ func testRecursiveAutomaticTrackingOnPageViewController() {
+ let viewController = UIPageViewController()
+ viewController.setViewControllers([AutomaticMockViewController()], direction: .forward, animated: false)
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("automatic"))
+ }
+ ) {
+ viewController.recursivelyTrackAutomaticPageViews()
+ }
+ }
+
+ func testRecursiveAutomaticTrackingOnSplitViewController() {
+ let viewController = UISplitViewController()
+ viewController.viewControllers = [
+ AutomaticMockViewController(title: "title1"),
+ AutomaticMockViewController(title: "title2")
+ ]
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("title1"))
+ }
+ ) {
+ viewController.recursivelyTrackAutomaticPageViews()
+ }
+ }
+
+ func testRecursiveAutomaticTrackingOnTabBarController() {
+ let viewController = UITabBarController()
+ viewController.viewControllers = [
+ AutomaticMockViewController(title: "title1"),
+ AutomaticMockViewController(title: "title2"),
+ AutomaticMockViewController(title: "title3")
+ ]
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("title1"))
+ }
+ ) {
+ viewController.recursivelyTrackAutomaticPageViews()
+ }
+ }
+
+ func testRecursiveAutomaticTrackingOnWindow() {
+ let window = UIWindow()
+ window.makeKeyAndVisible()
+ window.rootViewController = AutomaticMockViewController()
+ expectAtLeastHits(
+ view { labels in
+ expect(labels.c8).to(equal("automatic"))
+ }
+ ) {
+ window.recursivelyTrackAutomaticPageViews()
+ }
+ }
+
+ func testRecursiveAutomaticTrackingOnWindowWithModalPresentation() {
+ let window = UIWindow()
+ let rootViewController = AutomaticMockViewController(title: "root")
+ window.makeKeyAndVisible()
+ window.rootViewController = rootViewController
+ rootViewController.present(AutomaticMockViewController(title: "modal"), animated: false)
+ expectHits(
+ view { labels in
+ expect(labels.c8).to(equal("modal"))
+ },
+ during: .seconds(2)
+ ) {
+ window.recursivelyTrackAutomaticPageViews()
+ }
+ }
+}
+
+private extension UIViewController {
+ func simulateViewAppearance() {
+ beginAppearanceTransition(true, animated: false)
+ endAppearanceTransition()
+ }
+}
diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTestCase.swift b/Tests/AnalyticsTests/ComScore/ComScoreTestCase.swift
new file mode 100644
index 00000000..5c76107d
--- /dev/null
+++ b/Tests/AnalyticsTests/ComScore/ComScoreTestCase.swift
@@ -0,0 +1,78 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Dispatch
+import PillarboxCircumspect
+
+class ComScoreTestCase: TestCase {}
+
+extension ComScoreTestCase {
+ /// Collects hits emitted by comScore during some time interval and matches them against expectations.
+ ///
+ /// A network connection is required by the comScore SDK to properly emit hits.
+ func expectHits(
+ _ expectations: ComScoreHitExpectation...,
+ during interval: DispatchTimeInterval = .seconds(20),
+ file: StaticString = #file,
+ line: UInt = #line,
+ while executing: (() -> Void)? = nil
+ ) {
+ AnalyticsListener.captureComScoreHits { publisher in
+ expectPublished(
+ values: expectations,
+ from: publisher,
+ to: ComScoreHitExpectation.match,
+ during: interval,
+ file: file,
+ line: line,
+ while: executing
+ )
+ }
+ }
+
+ /// Expects hits emitted by comScore during some time interval and matches them against expectations.
+ ///
+ /// A network connection is required by the comScore SDK to properly emit hits.
+ func expectAtLeastHits(
+ _ expectations: ComScoreHitExpectation...,
+ timeout: DispatchTimeInterval = .seconds(20),
+ file: StaticString = #file,
+ line: UInt = #line,
+ while executing: (() -> Void)? = nil
+ ) {
+ AnalyticsListener.captureComScoreHits { publisher in
+ expectAtLeastPublished(
+ values: expectations,
+ from: publisher,
+ to: ComScoreHitExpectation.match,
+ timeout: timeout,
+ file: file,
+ line: line,
+ while: executing
+ )
+ }
+ }
+
+ /// Expects no hits emitted by comScore during some time interval.
+ func expectNoHits(
+ during interval: DispatchTimeInterval = .seconds(20),
+ file: StaticString = #file,
+ line: UInt = #line,
+ while executing: (() -> Void)? = nil
+ ) {
+ AnalyticsListener.captureComScoreHits { publisher in
+ expectNothingPublished(
+ from: publisher,
+ during: interval,
+ file: file,
+ line: line,
+ while: executing
+ )
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift
new file mode 100644
index 00000000..b391dd75
--- /dev/null
+++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerDvrPropertiesTests.swift
@@ -0,0 +1,93 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import CoreMedia
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+final class ComScoreTrackerDvrPropertiesTests: ComScoreTestCase {
+ func testOnDemand() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_ldw).to(equal(0))
+ expect(labels.ns_st_ldo).to(equal(0))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testLive() {
+ let player = Player(item: .simple(
+ url: Stream.live.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_ldw).to(equal(0))
+ expect(labels.ns_st_ldo).to(equal(0))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testDvrAtLiveEdge() {
+ let player = Player(item: .simple(
+ url: Stream.dvr.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_ldo).to(equal(0))
+ expect(labels.ns_st_ldw).to(equal(Stream.dvr.duration.seconds))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testDvrAwayFromLiveEdge() {
+ let player = Player(item: .simple(
+ url: Stream.dvr.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ pause { labels in
+ expect(labels.ns_st_ldo).to(equal(0))
+ expect(labels.ns_st_ldw).to(equal(Stream.dvr.duration.seconds))
+ },
+ play { labels in
+ expect(labels.ns_st_ldo).to(beCloseTo(10, within: 3))
+ expect(labels.ns_st_ldw).to(equal(Stream.dvr.duration.seconds))
+ }
+ ) {
+ player.seek(at(player.time() - CMTime(value: 10, timescale: 1)))
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerMetadataTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerMetadataTests.swift
new file mode 100644
index 00000000..92e43ce9
--- /dev/null
+++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerMetadataTests.swift
@@ -0,0 +1,60 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+final class ComScoreTrackerMetadataTests: ComScoreTestCase {
+ func testMetadata() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in
+ ["meta_1": "custom-1", "meta_2": "42"]
+ }
+ ]
+ ))
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels["meta_1"]).to(equal("custom-1"))
+ expect(labels["meta_2"]).to(equal(42))
+ expect(labels["cs_ucfr"]).to(beEmpty())
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testEmptyMetadata() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in [:] }
+ ]
+ ))
+
+ expectNoHits(during: .seconds(3)) {
+ player.play()
+ }
+ }
+
+ func testNoMetadata() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in [:] }
+ ]
+ ))
+
+ expectNoHits(during: .seconds(3)) {
+ player.play()
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerPlaybackSpeedTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerPlaybackSpeedTests.swift
new file mode 100644
index 00000000..5ccc00f1
--- /dev/null
+++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerPlaybackSpeedTests.swift
@@ -0,0 +1,31 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+final class ComScoreTrackerPlaybackSpeedTests: ComScoreTestCase {
+ func testRateAtStart() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ player.setDesiredPlaybackSpeed(0.5)
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_rt).to(equal(50))
+ }
+ ) {
+ player.play()
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerRateTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerRateTests.swift
new file mode 100644
index 00000000..fb88e1be
--- /dev/null
+++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerRateTests.swift
@@ -0,0 +1,65 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+final class ComScoreTrackerRateTests: ComScoreTestCase {
+ func testInitialRate() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_rt).to(equal(200))
+ }
+ ) {
+ player.setDesiredPlaybackSpeed(2)
+ player.play()
+ }
+ }
+
+ func testWhenDifferentRateApplied() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ playrt { labels in
+ expect(labels.ns_st_rt).to(equal(200))
+ }
+ ) {
+ player.setDesiredPlaybackSpeed(2)
+ }
+ }
+
+ func testWhenSameRateApplied() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ expectNoHits(during: .seconds(2)) {
+ player.setDesiredPlaybackSpeed(1)
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerSeekTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerSeekTests.swift
new file mode 100644
index 00000000..e7e3d963
--- /dev/null
+++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerSeekTests.swift
@@ -0,0 +1,60 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import CoreMedia
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+final class ComScoreTrackerSeekTests: ComScoreTestCase {
+ func testSeekWhilePlaying() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ pause { labels in
+ expect(labels.ns_st_po).to(beCloseTo(0, within: 0.5))
+ },
+ play { labels in
+ expect(labels.ns_st_po).to(beCloseTo(7, within: 0.5))
+ }
+ ) {
+ player.seek(at(.init(value: 7, timescale: 1)))
+ }
+ }
+
+ func testSeekWhilePaused() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+
+ expect(player.playbackState).toEventually(equal(.paused))
+
+ expectNoHits(during: .seconds(2)) {
+ player.seek(at(.init(value: 7, timescale: 1)))
+ }
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_po).to(beCloseTo(7, within: 0.5))
+ }
+ ) {
+ player.play()
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift b/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift
new file mode 100644
index 00000000..a9082760
--- /dev/null
+++ b/Tests/AnalyticsTests/ComScore/ComScoreTrackerTests.swift
@@ -0,0 +1,237 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import CoreMedia
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+// Testing comScore end events is a bit tricky:
+// 1. Apparently comScore will never emit events if a play event is followed by an end event within ~5 seconds. For
+// this reason all tests checking end events must wait ~5 seconds after a play event.
+// 2. End events are emitted automatically to close a session when the `SCORStreamingAnalytics` is destroyed. Since
+// we are not notifying the end event ourselves in such cases we cannot customize the end event labels directly.
+// Fortunately we can customize them indirectly, though, since the end event inherits labels from a former event.
+// Thus, to test end events resulting from tracker deallocation we need to have another event sent within the same
+// expectation first so that the end event is provided a listener identifier.
+final class ComScoreTrackerTests: ComScoreTestCase {
+ func testGlobals() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_mp).to(equal("Pillarbox"))
+ expect(labels.ns_st_mv).to(equal(PackageInfo.version))
+ expect(labels.cs_ucfr).to(beEmpty())
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testInitiallyPlaying() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_po).to(beCloseTo(0, within: 2))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testInitiallyPaused() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ expectNoHits(during: .seconds(2)) {
+ player.pause()
+ }
+ }
+
+ func testPauseDuringPlayback() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.time().seconds).toEventually(beGreaterThan(1))
+
+ expectAtLeastHits(
+ pause { labels in
+ expect(labels.ns_st_po).to(beCloseTo(1, within: 0.5))
+ }
+ ) {
+ player.pause()
+ }
+ }
+
+ func testPlaybackEnd() {
+ let player = Player(item: .simple(
+ // See 1. at the top of this file.
+ url: Stream.mediumOnDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ play(),
+ end { labels in
+ expect(labels.ns_st_po).to(beCloseTo(Stream.mediumOnDemand.duration.seconds, within: 0.5))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testDestroyPlayerDuringPlayback() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ play(),
+ end { labels in
+ expect(labels.ns_st_po).to(beCloseTo(5, within: 0.5))
+ }
+ ) {
+ // See 2. at the top of this file.
+ player?.play()
+ // See 1. at the top of this file.
+ expect(player?.time().seconds).toEventually(beGreaterThan(5))
+ player = nil
+ }
+ }
+
+ func testFailure() {
+ let player = Player(item: .simple(
+ url: Stream.unavailable.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ expectNoHits(during: .seconds(3)) {
+ player.play()
+ }
+ }
+
+ func testDisableTrackingDuringPlayback() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(play(), end()) {
+ // See 2. at the top of this file.
+ player.play()
+ // See 1. at the top of this file.
+ expect(player.time().seconds).toEventually(beGreaterThan(5))
+ player.isTrackingEnabled = false
+ }
+ }
+
+ func testEnableTrackingDuringPlayback() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.isTrackingEnabled = false
+
+ expectNoHits(during: .seconds(2)) {
+ player.play()
+ }
+
+ expectAtLeastHits(play()) {
+ player.isTrackingEnabled = true
+ }
+ }
+
+ func testInitialPlaybackRate() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_rt).to(equal(200))
+ }
+ ) {
+ player.setDesiredPlaybackSpeed(2)
+ player.play()
+ }
+ }
+
+ func testOnDemandStartAtGivenPosition() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ],
+ configuration: .init(position: at(.init(value: 100, timescale: 1)))
+ ))
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.ns_st_po).to(beCloseTo(100, within: 5))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testReplay() {
+ let player = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ ComScoreTracker.adapter { _ in .test }
+ ]
+ ))
+ var ns_st_id: String?
+ expectAtLeastHits(
+ play { labels in
+ expect(labels["media_title"]).to(equal("name"))
+ expect(labels.ns_st_id).notTo(beNil())
+ ns_st_id = labels.ns_st_id
+ },
+ end()
+ ) {
+ player.play()
+ }
+ expectAtLeastHits(
+ play { labels in
+ expect(labels["media_title"]).to(equal("name"))
+ expect(labels.ns_st_id).notTo(beNil())
+ expect(labels.ns_st_id).notTo(equal(ns_st_id))
+ }
+ ) {
+ player.replay()
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActEventTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActEventTests.swift
new file mode 100644
index 00000000..0bee15fd
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActEventTests.swift
@@ -0,0 +1,100 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Nimble
+import PillarboxCircumspect
+import TCServerSide
+
+final class CommandersActEventTests: CommandersActTestCase {
+ func testMergingWithGlobals() {
+ let event = CommandersActEvent(
+ name: "name",
+ labels: [
+ "event-label": "event",
+ "common-label": "event"
+ ]
+ )
+ let globals = CommandersActGlobals(
+ consentServices: ["service1,service2,service3"],
+ labels: [
+ "globals-label": "globals",
+ "common-label": "globals"
+ ]
+ )
+
+ expect(event.merging(globals: globals).labels).to(equal([
+ "consent_services": "service1,service2,service3",
+ "globals-label": "globals",
+ "event-label": "event",
+ "common-label": "globals"
+ ]))
+ }
+
+ func testBlankName() {
+ guard nimbleThrowAssertionsAvailable() else { return }
+ expect(Analytics.shared.sendEvent(commandersAct: .init(name: " "))).to(throwAssertion())
+ }
+
+ func testName() {
+ expectAtLeastHits(custom(name: "name")) {
+ Analytics.shared.sendEvent(commandersAct: .init(name: "name"))
+ }
+ }
+
+ func testCustomLabels() {
+ expectAtLeastHits(
+ custom(name: "name") { labels in
+ // Use `media_player_display`, a media-only key, so that its value can be parsed.
+ expect(labels.media_player_display).to(equal("value"))
+ }
+ ) {
+ Analytics.shared.sendEvent(commandersAct: .init(
+ name: "name",
+ labels: ["media_player_display": "value"]
+ ))
+ }
+ }
+
+ func testUniqueIdentifier() {
+ let identifier = TCPredefinedVariables.sharedInstance().uniqueIdentifier()
+ expectAtLeastHits(
+ custom(name: "name") { labels in
+ expect(labels.context.device.sdk_id).to(equal(identifier))
+ expect(labels.user.consistent_anonymous_id).to(equal(identifier))
+ }
+ ) {
+ Analytics.shared.sendEvent(commandersAct: .init(name: "name"))
+ }
+ }
+
+ func testGlobals() {
+ expectAtLeastHits(
+ custom(name: "name") { labels in
+ expect(labels.consent_services).to(equal("service1,service2,service3"))
+ }
+ ) {
+ Analytics.shared.sendEvent(commandersAct: .init(name: "name"))
+ }
+ }
+
+ func testCustomLabelsForbiddenOverrides() {
+ expectAtLeastHits(
+ custom(name: "name") { labels in
+ expect(labels.consent_services).to(equal("service1,service2,service3"))
+ }
+ ) {
+ Analytics.shared.sendEvent(commandersAct: .init(
+ name: "name",
+ labels: [
+ "event_name": "overridden_name",
+ "consent_services": "service42"
+ ]
+ ))
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActHeartbeatTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActHeartbeatTests.swift
new file mode 100644
index 00000000..497cc2f8
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActHeartbeatTests.swift
@@ -0,0 +1,88 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Combine
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+final class CommandersActHeartbeatTests: CommandersActTestCase {
+ private var cancellables = Set()
+
+ private static func player(from stream: PillarboxStreams.Stream, into cancellables: inout Set) -> Player {
+ let heartbeat = CommandersActHeartbeat(delay: 0.1, posInterval: 0.1, uptimeInterval: 0.2)
+ let player = Player(item: .simple(url: stream.url))
+ player.propertiesPublisher
+ .sink { properties in
+ heartbeat.update(with: properties) { properties in
+ ["media_volume": properties.isMuted ? "0" : "100"]
+ }
+ }
+ .store(in: &cancellables)
+ return player
+ }
+
+ override func tearDown() {
+ super.tearDown()
+ cancellables = []
+ }
+
+ func testNoHeartbeatInitially() {
+ _ = Self.player(from: .onDemand, into: &cancellables)
+ expectNoHits(during: .milliseconds(300))
+ }
+
+ func testOnDemandHeartbeatAfterPlay() {
+ let player = Self.player(from: .onDemand, into: &cancellables)
+ expectAtLeastHits(pos(), pos()) {
+ player.play()
+ }
+ }
+
+ func testLiveHeartbeatAfterPlay() {
+ let player = Self.player(from: .live, into: &cancellables)
+ expectAtLeastHits(pos(), uptime(), pos(), pos(), uptime()) {
+ player.play()
+ }
+ }
+
+ func testDvrHeartbeatAfterPlay() {
+ let player = Self.player(from: .dvr, into: &cancellables)
+ expectAtLeastHits(pos(), uptime(), pos(), pos(), uptime()) {
+ player.play()
+ }
+ }
+
+ func testNoHeartbeatAfterPause() {
+ let player = Self.player(from: .onDemand, into: &cancellables)
+ expectAtLeastHits(pos()) {
+ player.play()
+ }
+ expectNoHits(during: .milliseconds(300)) {
+ player.pause()
+ }
+ }
+
+ func testHeartbeatPropertyUpdate() {
+ let player = Self.player(from: .onDemand, into: &cancellables)
+ expectAtLeastHits(
+ pos { labels in
+ expect(labels.media_volume).notTo(equal(0))
+ }
+ ) {
+ player.play()
+ }
+ expectAtLeastHits(
+ pos { labels in
+ expect(labels.media_volume).to(equal(0))
+ }
+ ) {
+ player.isMuted = true
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActHitExpectation.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActHitExpectation.swift
new file mode 100644
index 00000000..3ba8bcb5
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActHitExpectation.swift
@@ -0,0 +1,68 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import PillarboxAnalytics
+
+/// Describes a Commanders Act stream hit expectation.
+struct CommandersActHitExpectation {
+ private let name: CommandersActHit.Name
+ private let evaluate: (CommandersActLabels) -> Void
+
+ fileprivate init(name: CommandersActHit.Name, evaluate: @escaping (CommandersActLabels) -> Void) {
+ self.name = name
+ self.evaluate = evaluate
+ }
+
+ static func match(hit: CommandersActHit, with expectation: Self) -> Bool {
+ guard hit.name == expectation.name else { return false }
+ expectation.evaluate(hit.labels)
+ return true
+ }
+}
+
+extension CommandersActHitExpectation: CustomDebugStringConvertible {
+ var debugDescription: String {
+ name.rawValue
+ }
+}
+
+extension CommandersActTestCase {
+ func play(evaluate: @escaping (CommandersActLabels) -> Void = { _ in }) -> CommandersActHitExpectation {
+ CommandersActHitExpectation(name: .play, evaluate: evaluate)
+ }
+
+ func pause(evaluate: @escaping (CommandersActLabels) -> Void = { _ in }) -> CommandersActHitExpectation {
+ CommandersActHitExpectation(name: .pause, evaluate: evaluate)
+ }
+
+ func seek(evaluate: @escaping (CommandersActLabels) -> Void = { _ in }) -> CommandersActHitExpectation {
+ CommandersActHitExpectation(name: .seek, evaluate: evaluate)
+ }
+
+ func stop(evaluate: @escaping (CommandersActLabels) -> Void = { _ in }) -> CommandersActHitExpectation {
+ CommandersActHitExpectation(name: .stop, evaluate: evaluate)
+ }
+
+ func eof(evaluate: @escaping (CommandersActLabels) -> Void = { _ in }) -> CommandersActHitExpectation {
+ CommandersActHitExpectation(name: .eof, evaluate: evaluate)
+ }
+
+ func pos(evaluate: @escaping (CommandersActLabels) -> Void = { _ in }) -> CommandersActHitExpectation {
+ CommandersActHitExpectation(name: .pos, evaluate: evaluate)
+ }
+
+ func uptime(evaluate: @escaping (CommandersActLabels) -> Void = { _ in }) -> CommandersActHitExpectation {
+ CommandersActHitExpectation(name: .uptime, evaluate: evaluate)
+ }
+
+ func page_view(evaluate: @escaping (CommandersActLabels) -> Void = { _ in }) -> CommandersActHitExpectation {
+ CommandersActHitExpectation(name: .page_view, evaluate: evaluate)
+ }
+
+ func custom(name: String, evaluate: @escaping (CommandersActLabels) -> Void = { _ in }) -> CommandersActHitExpectation {
+ CommandersActHitExpectation(name: .custom(name), evaluate: evaluate)
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActPageViewTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActPageViewTests.swift
new file mode 100644
index 00000000..7aa5c7fa
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActPageViewTests.swift
@@ -0,0 +1,183 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Nimble
+import PillarboxCircumspect
+
+final class CommandersActPageViewTests: CommandersActTestCase {
+ func testMergingWithGlobals() {
+ let pageView = CommandersActPageView(
+ name: "name",
+ type: "type",
+ labels: [
+ "pageview-label": "pageview",
+ "common-label": "pageview"
+ ]
+ )
+ let globals = CommandersActGlobals(
+ consentServices: ["service1,service2,service3"],
+ labels: [
+ "globals-label": "globals",
+ "common-label": "globals"
+ ]
+ )
+
+ expect(pageView.merging(globals: globals).labels).to(equal([
+ "consent_services": "service1,service2,service3",
+ "globals-label": "globals",
+ "pageview-label": "pageview",
+ "common-label": "globals"
+ ]))
+ }
+
+ func testLabels() {
+ expectAtLeastHits(
+ page_view { labels in
+ expect(labels.page_type).to(equal("type"))
+ expect(labels.page_name).to(equal("name"))
+ expect(labels.navigation_level_0).to(beNil())
+ expect(labels.navigation_level_1).to(equal("level_1"))
+ expect(labels.navigation_level_2).to(equal("level_2"))
+ expect(labels.navigation_level_3).to(equal("level_3"))
+ expect(labels.navigation_level_4).to(equal("level_4"))
+ expect(labels.navigation_level_5).to(equal("level_5"))
+ expect(labels.navigation_level_6).to(equal("level_6"))
+ expect(labels.navigation_level_7).to(equal("level_7"))
+ expect(labels.navigation_level_8).to(equal("level_8"))
+ expect(labels.navigation_level_9).to(beNil())
+ expect(["phone", "tablet", "tvbox", "phone"]).to(contain([labels.navigation_device]))
+ expect(labels.app_library_version).to(equal(Analytics.version))
+ expect(labels.navigation_app_site_name).to(equal("site"))
+ expect(labels.navigation_property_type).to(equal("app"))
+ expect(labels.navigation_bu_distributer).to(equal("SRG"))
+ expect(labels.consent_services).to(equal("service1,service2,service3"))
+ }
+ ) {
+ Analytics.shared.trackPageView(
+ comScore: .init(name: "name"),
+ commandersAct: .init(
+ name: "name",
+ type: "type",
+ levels: [
+ "level_1",
+ "level_2",
+ "level_3",
+ "level_4",
+ "level_5",
+ "level_6",
+ "level_7",
+ "level_8"
+ ]
+ )
+ )
+ }
+ }
+
+ func testBlankTitle() {
+ guard nimbleThrowAssertionsAvailable() else { return }
+ expect(Analytics.shared.trackPageView(
+ comScore: .init(name: "name"),
+ commandersAct: .init(name: " ", type: "type")
+ )).to(throwAssertion())
+ }
+
+ func testBlankType() {
+ guard nimbleThrowAssertionsAvailable() else { return }
+ expect(Analytics.shared.trackPageView(
+ comScore: .init(name: "name"),
+ commandersAct: .init(name: "name", type: " ")
+ )).to(throwAssertion())
+ }
+
+ func testBlankLevels() {
+ expectAtLeastHits(
+ page_view { labels in
+ expect(labels.page_type).to(equal("type"))
+ expect(labels.page_name).to(equal("name"))
+ expect(labels.navigation_level_1).to(beNil())
+ expect(labels.navigation_level_2).to(beNil())
+ expect(labels.navigation_level_3).to(beNil())
+ expect(labels.navigation_level_4).to(beNil())
+ expect(labels.navigation_level_5).to(beNil())
+ expect(labels.navigation_level_6).to(beNil())
+ expect(labels.navigation_level_7).to(beNil())
+ expect(labels.navigation_level_8).to(beNil())
+ }
+ ) {
+ Analytics.shared.trackPageView(
+ comScore: .init(name: "name"),
+ commandersAct: .init(
+ name: "name",
+ type: "type",
+ levels: [
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " ",
+ " "
+ ]
+ )
+ )
+ }
+ }
+
+ func testCustomLabels() {
+ expectAtLeastHits(
+ page_view { labels in
+ // Use `media_player_display`, a media-only key, so that its value can be parsed.
+ expect(labels.media_player_display).to(equal("value"))
+ }
+ ) {
+ Analytics.shared.trackPageView(
+ comScore: .init(name: "name"),
+ commandersAct: .init(
+ name: "name",
+ type: "type",
+ labels: ["media_player_display": "value"]
+ )
+ )
+ }
+ }
+
+ func testGlobals() {
+ expectAtLeastHits(
+ page_view { labels in
+ expect(labels.consent_services).to(equal("service1,service2,service3"))
+ }
+ ) {
+ Analytics.shared.trackPageView(
+ comScore: .init(name: "name"),
+ commandersAct: .init(name: "name", type: "type")
+ )
+ }
+ }
+
+ func testLabelsForbiddenOverrides() {
+ expectAtLeastHits(
+ page_view { labels in
+ expect(labels.page_name).to(equal("name"))
+ expect(labels.consent_services).to(equal("service1,service2,service3"))
+ }
+ ) {
+ Analytics.shared.trackPageView(
+ comScore: .init(name: "name"),
+ commandersAct: .init(
+ name: "name",
+ type: "type",
+ labels: [
+ "page_name": "overridden_title",
+ "consent_services": "service42"
+ ]
+ )
+ )
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTestCase.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTestCase.swift
new file mode 100644
index 00000000..b43edc44
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTestCase.swift
@@ -0,0 +1,74 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Dispatch
+import PillarboxCircumspect
+
+class CommandersActTestCase: TestCase {}
+
+extension CommandersActTestCase {
+ /// Collects hits emitted by Commanders Act during some time interval and matches them against expectations.
+ func expectHits(
+ _ expectations: CommandersActHitExpectation...,
+ during interval: DispatchTimeInterval = .seconds(20),
+ file: StaticString = #file,
+ line: UInt = #line,
+ while executing: (() -> Void)? = nil
+ ) {
+ AnalyticsListener.captureCommandersActHits { publisher in
+ expectPublished(
+ values: expectations,
+ from: publisher,
+ to: CommandersActHitExpectation.match,
+ during: interval,
+ file: file,
+ line: line,
+ while: executing
+ )
+ }
+ }
+
+ /// Expects hits emitted by Commanders Act during some time interval and matches them against expectations.
+ func expectAtLeastHits(
+ _ expectations: CommandersActHitExpectation...,
+ timeout: DispatchTimeInterval = .seconds(20),
+ file: StaticString = #file,
+ line: UInt = #line,
+ while executing: (() -> Void)? = nil
+ ) {
+ AnalyticsListener.captureCommandersActHits { publisher in
+ expectAtLeastPublished(
+ values: expectations,
+ from: publisher,
+ to: CommandersActHitExpectation.match,
+ timeout: timeout,
+ file: file,
+ line: line,
+ while: executing
+ )
+ }
+ }
+
+ /// Expects no hits emitted by Commanders Act during some time interval.
+ func expectNoHits(
+ during interval: DispatchTimeInterval = .seconds(20),
+ file: StaticString = #file,
+ line: UInt = #line,
+ while executing: (() -> Void)? = nil
+ ) {
+ AnalyticsListener.captureCommandersActHits { publisher in
+ expectNothingPublished(
+ from: publisher,
+ during: interval,
+ file: file,
+ line: line,
+ while: executing
+ )
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift
new file mode 100644
index 00000000..529456ec
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerDvrPropertiesTests.swift
@@ -0,0 +1,126 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxPlayer
+import PillarboxStreams
+
+final class CommandersActTrackerDvrPropertiesTests: CommandersActTestCase {
+ func testOnDemand() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.media_timeshift).to(beNil())
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testLive() {
+ let player = Player(item: .simple(
+ url: Stream.live.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.media_timeshift).to(equal(0))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testDvrAtLiveEdge() {
+ let player = Player(item: .simple(
+ url: Stream.dvr.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.media_timeshift).to(equal(0))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testDvrAwayFromLiveEdge() {
+ let player = Player(item: .simple(
+ url: Stream.dvr.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ seek { labels in
+ expect(labels.media_timeshift).to(equal(0))
+ },
+ play { labels in
+ expect(labels.media_timeshift).to(beCloseTo(4, within: 2))
+ }
+ ) {
+ player.seek(at(player.time() - CMTime(value: 4, timescale: 1)))
+ }
+ }
+
+ func testDestroyPlayerDuringPlayback() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.dvr.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player?.play()
+ expect(player?.playbackState).toEventually(equal(.playing))
+
+ player?.pause()
+ wait(for: .seconds(2))
+
+ expectAtLeastHits(
+ stop { labels in
+ expect(labels.media_position).to(equal(0))
+ }
+ ) {
+ player = nil
+ }
+ }
+
+ func testDestroyPlayerWhileInitiallyPaused() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.dvr.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+ expect(player?.playbackState).toEventually(equal(.paused))
+
+ expectNoHits(during: .seconds(5)) {
+ player = nil
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerMetadataTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerMetadataTests.swift
new file mode 100644
index 00000000..4735135e
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerMetadataTests.swift
@@ -0,0 +1,143 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+final class CommandersActTrackerMetadataTests: CommandersActTestCase {
+ func testWhenInitialized() {
+ var player: Player?
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.media_player_display).to(equal("Pillarbox"))
+ expect(labels.media_player_version).to(equal(PackageInfo.version))
+ expect(labels.media_volume).notTo(beNil())
+ expect(labels.media_title).to(equal("name"))
+ expect(labels.media_audio_track).to(equal("UND"))
+ expect(labels.consent_services).to(equal("service1,service2,service3"))
+ }
+ ) {
+ player = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+ player?.setDesiredPlaybackSpeed(0.5)
+ player?.play()
+ }
+ }
+
+ func testWhenDestroyed() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player?.play()
+ expect(player?.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ stop { labels in
+ expect(labels.media_player_display).to(equal("Pillarbox"))
+ expect(labels.media_player_version).to(equal(PackageInfo.version))
+ expect(labels.media_volume).notTo(beNil())
+ expect(labels.media_title).to(equal("name"))
+ expect(labels.media_audio_track).to(equal("UND"))
+ }
+ ) {
+ player = nil
+ }
+ }
+
+ func testMuted() {
+ var player: Player?
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.media_volume).to(equal(0))
+ }
+ ) {
+ player = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+ player?.isMuted = true
+ player?.play()
+ }
+ }
+
+ func testAudioTrack() {
+ let player = Player(item: .simple(
+ url: Stream.onDemandWithOptions.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.setMediaSelection(preferredLanguages: ["fr"], for: .audible)
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ pause { labels in
+ expect(labels.media_audio_track).to(equal("FR"))
+ }
+ ) {
+ player.pause()
+ }
+ }
+
+ func testSubtitlesOff() {
+ let player = Player(item: .simple(
+ url: Stream.onDemandWithOptions.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+ player.select(mediaOption: .off, for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toEventually(equal(.off))
+
+ expectAtLeastHits(
+ pause { labels in
+ expect(labels.media_subtitles_on).to(beFalse())
+ }
+ ) {
+ player.pause()
+ }
+ }
+
+ func testSubtitlesOn() {
+ let player = Player(item: .simple(
+ url: Stream.onDemandWithOptions.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.setMediaSelection(preferredLanguages: ["fr"], for: .legible)
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ pause { labels in
+ expect(labels.media_subtitles_on).to(beTrue())
+ expect(labels.media_subtitle_selection).to(equal("FR"))
+ }
+ ) {
+ player.pause()
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerPositionTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerPositionTests.swift
new file mode 100644
index 00000000..5c870d27
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerPositionTests.swift
@@ -0,0 +1,139 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxPlayer
+import PillarboxStreams
+
+final class CommandersActTrackerPositionTests: CommandersActTestCase {
+ func testLivePlayback() {
+ let player = Player(item: .simple(
+ url: Stream.live.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+ wait(for: .seconds(2))
+
+ expectAtLeastHits(
+ pause { labels in
+ expect(labels.media_position).to(equal(2))
+ }
+ ) {
+ player.pause()
+ }
+ }
+
+ func testDvrPlayback() {
+ let player = Player(item: .simple(
+ url: Stream.dvr.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+ wait(for: .seconds(2))
+
+ expectAtLeastHits(
+ pause { labels in
+ expect(labels.media_position).to(equal(2))
+ }
+ ) {
+ player.pause()
+ }
+ }
+
+ func testSeekDuringDvrPlayback() {
+ let player = Player(item: .simple(
+ url: Stream.dvr.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ seek { labels in
+ expect(labels.media_position).to(equal(0))
+ },
+ play { labels in
+ expect(labels.media_position).to(equal(0))
+ }
+ ) {
+ player.seek(at(.init(value: 7, timescale: 1)))
+ }
+ }
+
+ func testDestroyDuringLivePlayback() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.live.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player?.play()
+ expect(player?.playbackState).toEventually(equal(.playing))
+ wait(for: .seconds(2))
+
+ expectAtLeastHits(
+ stop { labels in
+ expect(labels.media_position).to(equal(2))
+ }
+ ) {
+ player = nil
+ }
+ }
+
+ func testDestroyDuringDvrPlayback() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.dvr.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player?.play()
+ expect(player?.playbackState).toEventually(equal(.playing))
+ wait(for: .seconds(2))
+
+ expectAtLeastHits(
+ stop { labels in
+ expect(labels.media_position).to(equal(2))
+ }
+ ) {
+ player = nil
+ }
+ }
+
+ func testOnDemandStartAtGivenPosition() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ],
+ configuration: .init(position: at(.init(value: 100, timescale: 1)))
+ ))
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.media_position).to(equal(100))
+ }
+ ) {
+ player.play()
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerSeekTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerSeekTests.swift
new file mode 100644
index 00000000..599d8aa6
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerSeekTests.swift
@@ -0,0 +1,83 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+final class CommandersActTrackerSeekTests: CommandersActTestCase {
+ func testSeekWhilePlaying() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ seek { labels in
+ expect(labels.media_position).to(equal(0))
+ },
+ play { labels in
+ expect(labels.media_position).to(equal(7))
+ }
+ ) {
+ player.seek(at(.init(value: 7, timescale: 1)))
+ }
+ }
+
+ func testSeekWhilePaused() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ expect(player.playbackState).toEventually(equal(.paused))
+
+ expectNoHits(during: .seconds(2)) {
+ player.seek(at(.init(value: 7, timescale: 1)))
+ }
+
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.media_position).to(equal(7))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testDestroyPlayerWhileSeeking() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player?.play()
+ expect(player?.playbackState).toEventually(equal(.playing))
+
+ expectAtLeastHits(
+ seek { labels in
+ expect(labels.media_position).to(equal(0))
+ },
+ stop { labels in
+ expect(labels.media_position).to(equal(7))
+ }
+ ) {
+ player?.seek(at(.init(value: 7, timescale: 1)))
+ player = nil
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerTests.swift b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerTests.swift
new file mode 100644
index 00000000..b5e47067
--- /dev/null
+++ b/Tests/AnalyticsTests/CommandersAct/CommandersActTrackerTests.swift
@@ -0,0 +1,208 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxAnalytics
+
+import Nimble
+import PillarboxPlayer
+import PillarboxStreams
+
+final class CommandersActTrackerTests: CommandersActTestCase {
+ func testInitiallyPlaying() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ play { labels in
+ expect(labels.media_position).to(equal(0))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testInitiallyPaused() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in [:] }
+ ]
+ ))
+ expectNoHits(during: .seconds(2)) {
+ player.pause()
+ }
+ }
+
+ func testPauseDuringPlayback() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player.play()
+ expect(player.time().seconds).toEventually(beGreaterThan(1))
+
+ expectAtLeastHits(
+ pause { labels in
+ expect(labels.media_position).to(equal(1))
+ }
+ ) {
+ player.pause()
+ }
+ }
+
+ func testPlaybackEnd() {
+ let player = Player(item: .simple(
+ url: Stream.mediumOnDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ play(),
+ eof { labels in
+ expect(labels.media_position).to(equal(Int(Stream.mediumOnDemand.duration.seconds)))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testDestroyPlayerDuringPlayback() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+
+ player?.play()
+ expect(player?.time().seconds).toEventually(beGreaterThan(5))
+
+ expectAtLeastHits(
+ stop { labels in
+ expect(labels.media_position).to(equal(5))
+ }
+ ) {
+ player = nil
+ }
+ }
+
+ func testDestroyPlayerDuringPlaybackAtNonStandardPlaybackSpeed() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in .test }
+ ]
+ ))
+ player?.setDesiredPlaybackSpeed(2)
+
+ player?.play()
+ expect(player?.time().seconds).toEventually(beGreaterThan(2))
+
+ expectAtLeastHits(
+ stop { labels in
+ expect(labels.media_position).to(equal(2))
+ }
+ ) {
+ player = nil
+ }
+ }
+
+ func testDestroyPlayerAfterPlayback() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in [:] }
+ ]
+ ))
+
+ expectAtLeastHits(play(), eof()) {
+ player?.play()
+ }
+
+ expectNoHits(during: .seconds(2)) {
+ player = nil
+ }
+ }
+
+ func testFailure() {
+ let player = Player(item: .simple(
+ url: Stream.unavailable.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in [:] }
+ ]
+ ))
+ expectNoHits(during: .seconds(3)) {
+ player.play()
+ }
+ }
+
+ func testDisableTrackingDuringPlayback() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in [:] }
+ ]
+ ))
+
+ player.play()
+ expect(player.time().seconds).toEventually(beGreaterThan(5))
+
+ expectAtLeastHits(stop()) {
+ player.isTrackingEnabled = false
+ }
+ }
+
+ func testEnableTrackingDuringPlayback() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in [:] }
+ ]
+ ))
+
+ player.isTrackingEnabled = false
+
+ expectNoHits(during: .seconds(2)) {
+ player.play()
+ }
+
+ expectAtLeastHits(play()) {
+ player.isTrackingEnabled = true
+ }
+ }
+
+ func testEnableTrackingAgainWhilePaused() {
+ let player = Player()
+ player.append(.simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ CommandersActTracker.adapter { _ in [:] }
+ ]
+ ))
+
+ expectAtLeastHits(play()) {
+ player.play()
+ }
+ expectAtLeastHits(stop()) {
+ player.isTrackingEnabled = false
+ }
+
+ player.pause()
+ expect(player.playbackState).toEventually(equal(.paused))
+
+ expectAtLeastHits(play()) {
+ player.isTrackingEnabled = true
+ player.play()
+ }
+ }
+}
diff --git a/Tests/AnalyticsTests/Extensions/Dictionary.swift b/Tests/AnalyticsTests/Extensions/Dictionary.swift
new file mode 100644
index 00000000..7a81bfee
--- /dev/null
+++ b/Tests/AnalyticsTests/Extensions/Dictionary.swift
@@ -0,0 +1,9 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+extension [String: String] {
+ static let test = ["media_title": "name"]
+}
diff --git a/Tests/AnalyticsTests/TestCase.swift b/Tests/AnalyticsTests/TestCase.swift
new file mode 100644
index 00000000..a6d6572e
--- /dev/null
+++ b/Tests/AnalyticsTests/TestCase.swift
@@ -0,0 +1,45 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import Nimble
+import PillarboxAnalytics
+import XCTest
+
+private final class TestCaseDataSource: AnalyticsDataSource {
+ var comScoreGlobals: ComScoreGlobals {
+ .init(consent: .unknown, labels: [:])
+ }
+
+ var commandersActGlobals: CommandersActGlobals {
+ .init(consentServices: ["service1", "service2", "service3"], labels: [:])
+ }
+}
+
+/// A simple test suite with more tolerant Nimble settings. Beware that `toAlways` and `toNever` expectations appearing
+/// in tests will use the same value by default and should likely always provide an explicit `until` parameter.
+class TestCase: XCTestCase {
+ private static let dataSource = TestCaseDataSource()
+
+ override class func setUp() {
+ PollingDefaults.timeout = .seconds(20)
+ PollingDefaults.pollInterval = .milliseconds(100)
+ try? Analytics.shared.start(
+ with: .init(vendor: .SRG, sourceKey: .developmentSourceKey, appSiteName: "site"),
+ dataSource: dataSource
+ )
+ }
+
+ override class func tearDown() {
+ PollingDefaults.timeout = .seconds(1)
+ PollingDefaults.pollInterval = .milliseconds(10)
+ }
+
+ override func setUp() {
+ waitUntil { done in
+ AnalyticsListener.start(completion: done)
+ }
+ }
+}
diff --git a/Tests/CircumspectTests/ComparatorTests.swift b/Tests/CircumspectTests/ComparatorTests.swift
new file mode 100644
index 00000000..d3124016
--- /dev/null
+++ b/Tests/CircumspectTests/ComparatorTests.swift
@@ -0,0 +1,20 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Nimble
+import XCTest
+
+final class ComparatorTests: XCTestCase {
+ func testClose() {
+ expect(beClose(within: 0.1)(0.3, 0.3)).to(beTrue())
+ }
+
+ func testDistant() {
+ expect(beClose(within: 0.1)(0.3, 0.5)).to(beFalse())
+ }
+}
diff --git a/Tests/CircumspectTests/Counter.swift b/Tests/CircumspectTests/Counter.swift
new file mode 100644
index 00000000..5caae25d
--- /dev/null
+++ b/Tests/CircumspectTests/Counter.swift
@@ -0,0 +1,20 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import Combine
+import Foundation
+
+final class Counter: ObservableObject {
+ @Published var count = 0
+
+ init() {
+ Timer.publish(every: 0.2, on: .main, in: .common)
+ .autoconnect()
+ .map { _ in 1 }
+ .scan(0) { $0 + $1 }
+ .assign(to: &$count)
+ }
+}
diff --git a/Tests/CircumspectTests/Expectations/ExpectAtLeastPublishedTests.swift b/Tests/CircumspectTests/Expectations/ExpectAtLeastPublishedTests.swift
new file mode 100644
index 00000000..28455cf7
--- /dev/null
+++ b/Tests/CircumspectTests/Expectations/ExpectAtLeastPublishedTests.swift
@@ -0,0 +1,64 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Combine
+import XCTest
+
+final class ExpectAtLeastPublishedTests: XCTestCase {
+ func testExpectAtLeastEqualPublishedValues() {
+ expectAtLeastEqualPublished(
+ values: [1, 2, 3, 4, 5],
+ from: [1, 2, 3, 4, 5].publisher
+ )
+ }
+
+ func testExpectAtLeastEqualPublishedValuesWhileExecuting() {
+ let subject = PassthroughSubject()
+ expectAtLeastEqualPublished(
+ values: [4, 7],
+ from: subject
+ ) {
+ subject.send(4)
+ subject.send(7)
+ subject.send(completion: .finished)
+ }
+ }
+
+ func testExpectAtLeastEqualPublishedNextValues() {
+ expectAtLeastEqualPublishedNext(
+ values: [2, 3, 4, 5],
+ from: [1, 2, 3, 4, 5].publisher
+ )
+ }
+
+ func testExpectAtLeastEqualPublishedNextValuesWhileExecuting() {
+ let subject = PassthroughSubject()
+ expectAtLeastEqualPublishedNext(
+ values: [7, 8],
+ from: subject
+ ) {
+ subject.send(4)
+ subject.send(7)
+ subject.send(8)
+ subject.send(completion: .finished)
+ }
+ }
+
+ func testExpectAtLeastEqualFollowingExpectEqual() {
+ let publisher = PassthroughSubject()
+ expectEqualPublished(values: [1, 2], from: publisher, during: .milliseconds(100)) {
+ publisher.send(1)
+ publisher.send(2)
+ }
+ expectAtLeastEqualPublished(values: [3, 4, 5], from: publisher) {
+ publisher.send(3)
+ publisher.send(4)
+ publisher.send(5)
+ }
+ }
+}
diff --git a/Tests/CircumspectTests/Expectations/ExpectNothingPublishedTests.swift b/Tests/CircumspectTests/Expectations/ExpectNothingPublishedTests.swift
new file mode 100644
index 00000000..71d7d9cd
--- /dev/null
+++ b/Tests/CircumspectTests/Expectations/ExpectNothingPublishedTests.swift
@@ -0,0 +1,24 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Combine
+import XCTest
+
+final class ExpectNothingPublishedTests: XCTestCase {
+ func testExpectNothingPublished() {
+ let subject = PassthroughSubject()
+ expectNothingPublished(from: subject, during: .seconds(1))
+ }
+
+ func testExpectNothingPublishedNext() {
+ let subject = PassthroughSubject()
+ expectNothingPublishedNext(from: subject, during: .seconds(1)) {
+ subject.send(4)
+ }
+ }
+}
diff --git a/Tests/CircumspectTests/Expectations/ExpectNotificationsTests.swift b/Tests/CircumspectTests/Expectations/ExpectNotificationsTests.swift
new file mode 100644
index 00000000..302c4227
--- /dev/null
+++ b/Tests/CircumspectTests/Expectations/ExpectNotificationsTests.swift
@@ -0,0 +1,34 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import XCTest
+
+final class ExpectNotificationsTests: XCTestCase {
+ func testExpectAtLeastReceivedNotifications() {
+ expectAtLeastReceived(
+ notifications: [
+ Notification(name: .testNotification, object: self)
+ ],
+ for: [.testNotification]
+ ) {
+ NotificationCenter.default.post(name: .testNotification, object: self)
+ }
+ }
+
+ func testExpectReceivedNotificationsDuringInterval() {
+ expectReceived(
+ notifications: [
+ Notification(name: .testNotification, object: self)
+ ],
+ for: [.testNotification],
+ during: .milliseconds(500)
+ ) {
+ NotificationCenter.default.post(name: .testNotification, object: self)
+ }
+ }
+}
diff --git a/Tests/CircumspectTests/Expectations/ExpectOnlyPublishedTests.swift b/Tests/CircumspectTests/Expectations/ExpectOnlyPublishedTests.swift
new file mode 100644
index 00000000..2fb55a85
--- /dev/null
+++ b/Tests/CircumspectTests/Expectations/ExpectOnlyPublishedTests.swift
@@ -0,0 +1,51 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Combine
+import XCTest
+
+final class ExpectOnlyPublishedTests: XCTestCase {
+ func testExpectOnlyEqualPublishedValues() {
+ expectOnlyEqualPublished(
+ values: [1, 2, 3, 4, 5],
+ from: [1, 2, 3, 4, 5].publisher
+ )
+ }
+
+ func testExpectOnlyEqualPublishedValuesWhileExecuting() {
+ let subject = PassthroughSubject()
+ expectOnlyEqualPublished(
+ values: [4, 7],
+ from: subject
+ ) {
+ subject.send(4)
+ subject.send(7)
+ subject.send(completion: .finished)
+ }
+ }
+
+ func testExpectOnlyEqualPublishedNextValues() {
+ expectOnlyEqualPublishedNext(
+ values: [2, 3, 4, 5],
+ from: [1, 2, 3, 4, 5].publisher
+ )
+ }
+
+ func testExpectOnlyEqualPublishedNextValuesWhileExecuting() {
+ let subject = PassthroughSubject()
+ expectOnlyEqualPublishedNext(
+ values: [7, 8],
+ from: subject
+ ) {
+ subject.send(4)
+ subject.send(7)
+ subject.send(8)
+ subject.send(completion: .finished)
+ }
+ }
+}
diff --git a/Tests/CircumspectTests/Expectations/ExpectPublishedTests.swift b/Tests/CircumspectTests/Expectations/ExpectPublishedTests.swift
new file mode 100644
index 00000000..19460c7f
--- /dev/null
+++ b/Tests/CircumspectTests/Expectations/ExpectPublishedTests.swift
@@ -0,0 +1,56 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Combine
+import XCTest
+
+final class ExpectPublishedTests: XCTestCase {
+ func testExpectEqualPublishedValuesDuringInterval() {
+ let counter = Counter()
+ expectEqualPublished(
+ values: [0, 1, 2],
+ from: counter.$count,
+ during: .milliseconds(500)
+ )
+ }
+
+ func testExpectEqualPublishedValuesDuringIntervalWhileExecuting() {
+ let subject = PassthroughSubject()
+ expectEqualPublished(
+ values: [4, 7, 8],
+ from: subject,
+ during: .milliseconds(500)
+ ) {
+ subject.send(4)
+ subject.send(7)
+ subject.send(8)
+ }
+ }
+
+ func testExpectEqualPublishedNextValuesDuringInterval() {
+ let counter = Counter()
+ expectEqualPublishedNext(
+ values: [1, 2],
+ from: counter.$count,
+ during: .milliseconds(500)
+ )
+ }
+
+ func testExpectEqualPublishedNextValuesDuringIntervalWhileExecuting() {
+ let subject = PassthroughSubject()
+ expectEqualPublishedNext(
+ values: [7, 8],
+ from: subject,
+ during: .milliseconds(500)
+ ) {
+ subject.send(4)
+ subject.send(7)
+ subject.send(8)
+ }
+ }
+}
diff --git a/Tests/CircumspectTests/Expectations/ExpectResultTests.swift b/Tests/CircumspectTests/Expectations/ExpectResultTests.swift
new file mode 100644
index 00000000..bede2084
--- /dev/null
+++ b/Tests/CircumspectTests/Expectations/ExpectResultTests.swift
@@ -0,0 +1,24 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Combine
+import XCTest
+
+final class ExpectResultTests: XCTestCase {
+ func testExpectSuccess() {
+ expectSuccess(from: Empty())
+ }
+
+ func testExpectFailure() {
+ expectFailure(from: Fail(error: StructError()))
+ }
+
+ func testExpectFailureWithError() {
+ expectFailure(StructError(), from: Fail(error: StructError()))
+ }
+}
diff --git a/Tests/CircumspectTests/Expectations/ExpectValueTests.swift b/Tests/CircumspectTests/Expectations/ExpectValueTests.swift
new file mode 100644
index 00000000..9fe7cdb0
--- /dev/null
+++ b/Tests/CircumspectTests/Expectations/ExpectValueTests.swift
@@ -0,0 +1,40 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Combine
+import XCTest
+
+private class Object: ObservableObject {
+ @Published var value = 0
+}
+
+final class ExpectValueTests: XCTestCase {
+ func testSingleValue() {
+ expectValue(from: Just(1))
+ }
+
+ func testMultipleValues() {
+ expectValue(from: [1, 2, 3].publisher)
+ }
+
+ func testSingleChange() {
+ let object = Object()
+ expectChange(from: object) {
+ object.value = 1
+ }
+ }
+
+ func testMultipleChanges() {
+ let object = Object()
+ expectChange(from: object) {
+ object.value = 1
+ object.value = 2
+ object.value = 3
+ }
+ }
+}
diff --git a/Tests/CircumspectTests/ObservableObjectTests.swift b/Tests/CircumspectTests/ObservableObjectTests.swift
new file mode 100644
index 00000000..5c23cfc0
--- /dev/null
+++ b/Tests/CircumspectTests/ObservableObjectTests.swift
@@ -0,0 +1,70 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Nimble
+import XCTest
+
+private class TestObservableObject: ObservableObject {
+ @Published var publishedProperty1 = 1
+ @Published var publishedProperty2 = "a"
+
+ var nonPublishedProperty: Int {
+ publishedProperty1 * 2
+ }
+}
+
+final class ObservableObjectTests: XCTestCase {
+ func testNonPublishedPropertyInitialValue() {
+ let object = TestObservableObject()
+ expectAtLeastEqualPublished(
+ values: [2],
+ from: object.changePublisher(at: \.nonPublishedProperty)
+ )
+ }
+
+ func testPublishedPropertyInitialValue() {
+ let object = TestObservableObject()
+ expectAtLeastEqualPublished(
+ values: [1],
+ from: object.changePublisher(at: \.publishedProperty1)
+ )
+ }
+
+ func testNonPublishedPropertyChanges() {
+ let object = TestObservableObject()
+ expectAtLeastEqualPublished(
+ values: [2, 8, 8],
+ from: object.changePublisher(at: \.nonPublishedProperty)
+ ) {
+ object.publishedProperty1 = 4
+ object.publishedProperty2 = "b"
+ }
+ }
+
+ func testPublishedPropertyChanges() {
+ let object = TestObservableObject()
+ expectAtLeastEqualPublished(
+ values: [1, 3, 3, 3],
+ from: object.changePublisher(at: \.publishedProperty1)
+ ) {
+ object.publishedProperty1 = 2
+ object.publishedProperty1 = 3
+ object.publishedProperty2 = "b"
+ }
+ }
+
+ func testDeallocation() {
+ var object: TestObservableObject? = TestObservableObject()
+ _ = object?.changePublisher(at: \.nonPublishedProperty)
+ weak var weakObject = object
+ autoreleasepool {
+ object = nil
+ }
+ expect(weakObject).to(beNil())
+ }
+}
diff --git a/Tests/CircumspectTests/PublishersTests.swift b/Tests/CircumspectTests/PublishersTests.swift
new file mode 100644
index 00000000..4878f69a
--- /dev/null
+++ b/Tests/CircumspectTests/PublishersTests.swift
@@ -0,0 +1,114 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Combine
+import Nimble
+import XCTest
+
+final class PublisherTests: XCTestCase {
+ func testWaitForSuccessResult() {
+ let values = try? waitForResult(from: [1, 2, 3, 4, 5].publisher).get()
+ expect(values).to(equal([1, 2, 3, 4, 5]))
+ }
+
+ func testWaitForSuccessResultWhileExecuting() {
+ let subject = PassthroughSubject()
+ let values = try? waitForResult(from: subject) {
+ subject.send(4)
+ subject.send(7)
+ subject.send(completion: .finished)
+ }.get()
+ expect(values).to(equal([4, 7]))
+ }
+
+ func testWaitForFailureResult() {
+ let values = try? waitForResult(from: Fail(error: StructError())).get()
+ expect(values).to(beNil())
+ }
+
+ func testWaitForOutput() throws {
+ let values = try waitForOutput(from: [1, 2, 3].publisher)
+ expect(values).to(equal([1, 2, 3]))
+ }
+
+ func testWaitForOutputWhileExecuting() throws {
+ let subject = PassthroughSubject()
+ let values = try waitForOutput(from: subject) {
+ subject.send(4)
+ subject.send(7)
+ subject.send(completion: .finished)
+ }
+ expect(values).to(equal([4, 7]))
+ }
+
+ func testWaitForSingleOutput() throws {
+ let value = try waitForSingleOutput(from: [1].publisher)
+ expect(value).to(equal(1))
+ }
+
+ func testWaitForSingleOutputWhileExecuting() throws {
+ let subject = PassthroughSubject()
+ let value = try waitForSingleOutput(from: subject) {
+ subject.send(4)
+ subject.send(completion: .finished)
+ }
+ expect(value).to(equal(4))
+ }
+
+ func testWaitForFailure() throws {
+ let error = try waitForFailure(from: Fail(error: StructError()))
+ expect(error).notTo(beNil())
+ }
+
+ func testWaitForFailureWhileExecuting() throws {
+ let subject = PassthroughSubject()
+ let error = try waitForFailure(from: Fail(error: StructError())) {
+ subject.send(4)
+ subject.send(7)
+ subject.send(completion: .failure(StructError()))
+ }
+ expect(error).notTo(beNil())
+ }
+
+ func testCollectOutput() {
+ let counter = Counter()
+ let values = collectOutput(from: counter.$count, during: .milliseconds(500))
+ expect(values).to(equal([0, 1, 2]))
+ }
+
+ func testCollectOutputWhileExecuting() {
+ let subject = PassthroughSubject()
+ let values = collectOutput(from: subject, during: .milliseconds(500)) {
+ subject.send(4)
+ subject.send(7)
+ }
+ expect(values).to(equal([4, 7]))
+ }
+
+ func testCollectOutputImmediately() {
+ let values = collectOutput(
+ from: [1, 2, 3, 4, 5].publisher,
+ during: .never
+ )
+ expect(values).to(equal([1, 2, 3, 4, 5]))
+ }
+
+ func testCollectFirst() throws {
+ let values = try waitForOutput(
+ from: [1, 2, 3, 4, 5].publisher.collectFirst(3)
+ ).flatMap { $0 }
+ expect(values).to(equal([1, 2, 3]))
+ }
+
+ func testCollectNext() throws {
+ let values = try waitForOutput(
+ from: [1, 2, 3, 4, 5].publisher.collectNext(3)
+ ).flatMap { $0 }
+ expect(values).to(equal([2, 3, 4]))
+ }
+}
diff --git a/Tests/CircumspectTests/SimilarityTests.swift b/Tests/CircumspectTests/SimilarityTests.swift
new file mode 100644
index 00000000..b359d9e7
--- /dev/null
+++ b/Tests/CircumspectTests/SimilarityTests.swift
@@ -0,0 +1,55 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Nimble
+import XCTest
+
+final class SimilarityTests: XCTestCase {
+ func testOperatorForInstances() {
+ expect(NamedPerson(name: "Alice") ~~ NamedPerson(name: "alice")).to(beTrue())
+ expect(NamedPerson(name: "Alice") ~~ NamedPerson(name: "bob")).to(beFalse())
+ }
+
+ func testOperatorForOptionals() {
+ let alice1: NamedPerson? = NamedPerson(name: "Alice")
+ let alice2: NamedPerson? = NamedPerson(name: "alice")
+ let bob = NamedPerson(name: "Bob")
+ expect(alice1 ~~ alice2).to(beTrue())
+ expect(alice1 ~~ bob).to(beFalse())
+ }
+
+ func testOperatorForArrays() {
+ let array1 = [NamedPerson(name: "Alice"), NamedPerson(name: "Bob")]
+ let array2 = [NamedPerson(name: "alice"), NamedPerson(name: "bob")]
+ let array3 = [NamedPerson(name: "bob"), NamedPerson(name: "alice")]
+ expect(array1 ~~ array2).to(beTrue())
+ expect(array1 ~~ array3).to(beFalse())
+ }
+
+ func testBeSimilarForInstances() {
+ expect(NamedPerson(name: "Alice")).to(beSimilarTo(NamedPerson(name: "alice")))
+ expect(NamedPerson(name: "Alice")).notTo(beSimilarTo(NamedPerson(name: "bob")))
+ }
+
+ func testBeSimilarForOptionals() {
+ let alice1: NamedPerson? = NamedPerson(name: "Alice")
+ let alice2: NamedPerson? = NamedPerson(name: "alice")
+ let bob = NamedPerson(name: "Bob")
+ expect(alice1).to(beSimilarTo(alice2))
+ expect(alice1).notTo(beNil())
+ expect(alice1).notTo(beSimilarTo(bob))
+ }
+
+ func testBeSimilarForArrays() {
+ let array1 = [NamedPerson(name: "Alice"), NamedPerson(name: "Bob")]
+ let array2 = [NamedPerson(name: "alice"), NamedPerson(name: "bob")]
+ let array3 = [NamedPerson(name: "bob"), NamedPerson(name: "alice")]
+ expect(array1).to(beSimilarTo(array2))
+ expect(array1).notTo(beSimilarTo(array3))
+ }
+}
diff --git a/Tests/CircumspectTests/TimeIntervalTests.swift b/Tests/CircumspectTests/TimeIntervalTests.swift
new file mode 100644
index 00000000..4f9f082f
--- /dev/null
+++ b/Tests/CircumspectTests/TimeIntervalTests.swift
@@ -0,0 +1,20 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCircumspect
+
+import Dispatch
+import Nimble
+import XCTest
+
+final class TimeIntervalTests: XCTestCase {
+ func testDoubleConversion() {
+ expect(DispatchTimeInterval.seconds(1).double()).to(equal(1))
+ expect(DispatchTimeInterval.milliseconds(1_000).double()).to(equal(1))
+ expect(DispatchTimeInterval.microseconds(1_000_000).double()).to(equal(1))
+ expect(DispatchTimeInterval.nanoseconds(1_000_000_000).double()).to(equal(1))
+ }
+}
diff --git a/Tests/CircumspectTests/Tools.swift b/Tests/CircumspectTests/Tools.swift
new file mode 100644
index 00000000..c35423dd
--- /dev/null
+++ b/Tests/CircumspectTests/Tools.swift
@@ -0,0 +1,22 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import Foundation
+import PillarboxCircumspect
+
+struct StructError: Error {}
+
+struct NamedPerson: Similar {
+ let name: String
+
+ static func ~~ (lhs: Self, rhs: Self) -> Bool {
+ lhs.name.localizedCaseInsensitiveContains(rhs.name)
+ }
+}
+
+extension Notification.Name {
+ static let testNotification = Notification.Name("TestNotification")
+}
diff --git a/Tests/CoreBusinessTests/AkamaiURLCodingTests.swift b/Tests/CoreBusinessTests/AkamaiURLCodingTests.swift
new file mode 100644
index 00000000..ab3a58ab
--- /dev/null
+++ b/Tests/CoreBusinessTests/AkamaiURLCodingTests.swift
@@ -0,0 +1,82 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCoreBusiness
+
+import Nimble
+import XCTest
+
+final class AkamaiURLCodingTests: XCTestCase {
+ private static let uuid = "E621E1F8-C36C-495A-93FC-0C247A3E6E5F"
+
+ func testEncoding() {
+ expect(AkamaiURLCoding.encodeUrl(
+ URL(string: "http://www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(equal(URL(string: "akamai+E621E1F8-C36C-495A-93FC-0C247A3E6E5F+http://www.server.com/playlist.m3u8?param1=value1¶m2=value2")))
+
+ expect(AkamaiURLCoding.encodeUrl(
+ URL(string: "https://www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(equal(URL(string: "akamai+E621E1F8-C36C-495A-93FC-0C247A3E6E5F+https://www.server.com/playlist.m3u8?param1=value1¶m2=value2")))
+ }
+
+ func testFailedEncoding() {
+ expect(AkamaiURLCoding.encodeUrl(
+ URL(string: "//www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(equal(URL(string: "//www.server.com/playlist.m3u8?param1=value1¶m2=value2")))
+ }
+
+ func testDecoding() {
+ expect(AkamaiURLCoding.decodeUrl(
+ URL(string: "akamai+E621E1F8-C36C-495A-93FC-0C247A3E6E5F+http://www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(equal(URL(string: "http://www.server.com/playlist.m3u8?param1=value1¶m2=value2")))
+
+ expect(AkamaiURLCoding.decodeUrl(
+ URL(string: "akamai+E621E1F8-C36C-495A-93FC-0C247A3E6E5F+https://www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(equal(URL(string: "https://www.server.com/playlist.m3u8?param1=value1¶m2=value2")))
+ }
+
+ func testFailedDecoding() {
+ expect(AkamaiURLCoding.decodeUrl(
+ URL(string: "http://www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(beNil())
+
+ expect(AkamaiURLCoding.decodeUrl(
+ URL(string: "https://www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(beNil())
+
+ expect(AkamaiURLCoding.decodeUrl(
+ URL(string: "custom://www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(beNil())
+
+ expect(AkamaiURLCoding.decodeUrl(
+ URL(string: "//www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(beNil())
+
+ expect(AkamaiURLCoding.decodeUrl(
+ URL(string: "akamai+1111111-1111-1111-1111-111111111111+https://www.server.com/playlist.m3u8?param1=value1¶m2=value2")!,
+ id: UUID(uuidString: Self.uuid)!
+ ))
+ .to(beNil())
+ }
+}
diff --git a/Tests/CoreBusinessTests/DataProviderTests.swift b/Tests/CoreBusinessTests/DataProviderTests.swift
new file mode 100644
index 00000000..8c95d437
--- /dev/null
+++ b/Tests/CoreBusinessTests/DataProviderTests.swift
@@ -0,0 +1,25 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCoreBusiness
+
+import PillarboxCircumspect
+import XCTest
+
+final class DataProviderTests: XCTestCase {
+ func testExistingMediaMetadata() {
+ expectSuccess(
+ from: DataProvider().mediaCompositionPublisher(forUrn: "urn:rts:video:6820736")
+ )
+ }
+
+ func testNonExistingMediaMetadata() {
+ expectFailure(
+ DataError.http(withStatusCode: 404),
+ from: DataProvider().mediaCompositionPublisher(forUrn: "urn:rts:video:unknown")
+ )
+ }
+}
diff --git a/Tests/CoreBusinessTests/ErrorsTests.swift b/Tests/CoreBusinessTests/ErrorsTests.swift
new file mode 100644
index 00000000..50cb74ae
--- /dev/null
+++ b/Tests/CoreBusinessTests/ErrorsTests.swift
@@ -0,0 +1,20 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCoreBusiness
+
+import Nimble
+import XCTest
+
+final class ErrorTests: XCTestCase {
+ func testHttpError() {
+ expect(DataError.http(withStatusCode: 404)).notTo(beNil())
+ }
+
+ func testNotHttpNSError() {
+ expect(DataError.http(withStatusCode: 200)).to(beNil())
+ }
+}
diff --git a/Tests/CoreBusinessTests/HTTPURLResponseTests.swift b/Tests/CoreBusinessTests/HTTPURLResponseTests.swift
new file mode 100644
index 00000000..ef384e0f
--- /dev/null
+++ b/Tests/CoreBusinessTests/HTTPURLResponseTests.swift
@@ -0,0 +1,28 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCoreBusiness
+
+import Nimble
+import XCTest
+
+final class HTTPURLResponseTests: XCTestCase {
+ func testFixedLocalizedStringForValidStatusCode() {
+ expect(HTTPURLResponse.fixedLocalizedString(forStatusCode: 404)).to(equal("Not found"))
+ }
+
+ func testFixedLocalizedStringForInvalidStatusCode() {
+ expect(HTTPURLResponse.fixedLocalizedString(forStatusCode: 956)).to(equal("Server error"))
+ }
+
+ func testNetworkLocalizedStringForValidKey() {
+ expect(HTTPURLResponse.coreNetworkLocalizedString(forKey: "not found")).to(equal("Not found"))
+ }
+
+ func testNetworkLocalizedStringForInvalidKey() {
+ expect(HTTPURLResponse.coreNetworkLocalizedString(forKey: "Some key which does not exist")).to(equal("Unknown error."))
+ }
+}
diff --git a/Tests/CoreBusinessTests/MediaMetadataTests.swift b/Tests/CoreBusinessTests/MediaMetadataTests.swift
new file mode 100644
index 00000000..6df3cf9e
--- /dev/null
+++ b/Tests/CoreBusinessTests/MediaMetadataTests.swift
@@ -0,0 +1,76 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCoreBusiness
+
+import Nimble
+import XCTest
+
+final class MediaMetadataTests: XCTestCase {
+ private static func metadata(_ kind: Mock.MediaCompositionKind) throws -> MediaMetadata {
+ try MediaMetadata(
+ mediaCompositionResponse: .init(
+ mediaComposition: Mock.mediaComposition(kind),
+ response: .init()
+ ),
+ dataProvider: DataProvider()
+ )
+ }
+
+ func testStandardMetadata() throws {
+ let metadata = try Self.metadata(.onDemand)
+ expect(metadata.title).to(equal("Yadebat"))
+ expect(metadata.subtitle).to(equal("On réunit des ex après leur rupture"))
+ expect(metadata.description).to(equal("""
+ Dans ce nouvel épisode de YADEBAT, Mélissa réunit 3 couples qui se sont séparés récemment. \
+ Elles les a questionné en face à face pour connaître leurs différents ressentis et réactions.
+ """))
+ expect(metadata.episodeInformation).to(equal(.long(season: 2, episode: 12)))
+ }
+
+ func testRedundantMetadata() throws {
+ let metadata = try Self.metadata(.redundant)
+ expect(metadata.title).to(equal("19h30"))
+ expect(metadata.subtitle).to(contain("February"))
+ expect(metadata.description).to(beNil())
+ expect(metadata.episodeInformation).to(beNil())
+ }
+
+ func testLiveMetadata() throws {
+ let metadata = try Self.metadata(.live)
+ expect(metadata.title).to(equal("La 1ère en direct"))
+ expect(metadata.subtitle).to(beNil())
+ expect(metadata.description).to(beNil())
+ expect(metadata.episodeInformation).to(beNil())
+ }
+
+ func testMainChapter() throws {
+ let metadata = try Self.metadata(.onDemand)
+ expect(metadata.mainChapter.urn).to(equal(metadata.mediaComposition.chapterUrn))
+ }
+
+ func testChapters() throws {
+ let metadata = try Self.metadata(.mixed)
+ expect(metadata.chapters).to(haveCount(10))
+ }
+
+ func testAudioChapterRemoval() throws {
+ let metadata = try Self.metadata(.audioChapters)
+ expect(metadata.chapters).to(beEmpty())
+ }
+
+ func testAnalytics() throws {
+ let metadata = try Self.metadata(.onDemand)
+ expect(metadata.analyticsData).notTo(beEmpty())
+ expect(metadata.analyticsMetadata).notTo(beEmpty())
+ }
+
+ func testMissingChapterAnalytics() throws {
+ let metadata = try Self.metadata(.missingAnalytics)
+ expect(metadata.analyticsData).to(beEmpty())
+ expect(metadata.analyticsMetadata).to(beEmpty())
+ }
+}
diff --git a/Tests/CoreBusinessTests/Mock.swift b/Tests/CoreBusinessTests/Mock.swift
new file mode 100644
index 00000000..88ef7dbe
--- /dev/null
+++ b/Tests/CoreBusinessTests/Mock.swift
@@ -0,0 +1,27 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCoreBusiness
+
+import Foundation
+import UIKit
+
+enum Mock {
+ enum MediaCompositionKind: String {
+ case missingAnalytics
+ case drm
+ case live
+ case onDemand
+ case redundant
+ case mixed
+ case audioChapters
+ }
+
+ static func mediaComposition(_ kind: MediaCompositionKind = .onDemand) -> MediaComposition {
+ let data = NSDataAsset(name: "MediaComposition_\(kind.rawValue)", bundle: .module)!.data
+ return try! DataProvider.decoder().decode(MediaComposition.self, from: data)
+ }
+}
diff --git a/Tests/CoreBusinessTests/PublishersTests.swift b/Tests/CoreBusinessTests/PublishersTests.swift
new file mode 100644
index 00000000..610e95c8
--- /dev/null
+++ b/Tests/CoreBusinessTests/PublishersTests.swift
@@ -0,0 +1,20 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCoreBusiness
+
+import PillarboxCircumspect
+import XCTest
+
+final class PublishersTests: XCTestCase {
+ func testHttpError() {
+ expectFailure(
+ DataError.http(withStatusCode: 404),
+ from: URLSession(configuration: .default).dataTaskPublisher(for: URL(string: "http://localhost:8123/not_found")!)
+ .mapHttpErrors()
+ )
+ }
+}
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/Contents.json b/Tests/CoreBusinessTests/Resources.xcassets/Contents.json
new file mode 100644
index 00000000..73c00596
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_audioChapters.dataset/Contents.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_audioChapters.dataset/Contents.json
new file mode 100644
index 00000000..2ac61c6c
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_audioChapters.dataset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "data" : [
+ {
+ "filename" : "urn_rts_audio_13598743.json",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_audioChapters.dataset/urn_rts_audio_13598743.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_audioChapters.dataset/urn_rts_audio_13598743.json
new file mode 100644
index 00000000..d6b7b9a1
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_audioChapters.dataset/urn_rts_audio_13598743.json
@@ -0,0 +1,752 @@
+{
+ "chapterUrn" : "urn:rts:audio:13598743",
+ "episode" : {
+ "id" : "13598757",
+ "title" : "Forum du 12.12.2022",
+ "publishedDate" : "2022-12-12T18:00:00+01:00",
+ "imageUrl" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/16x9",
+ "imageTitle" : "Logo Forum [RTS]"
+ },
+ "show" : {
+ "id" : "1784426",
+ "vendor" : "RTS",
+ "transmission" : "RADIO",
+ "urn" : "urn:rts:show:radio:1784426",
+ "title" : "Forum",
+ "lead" : "7 jours sur 7, Forum questionne en direct les acteurs de l’actualité, ouvre le débat sur les controverses qui animent la vie politique, culturelle et économique.",
+ "description" : "7 jours sur 7, Forum questionne en direct les acteurs de l'actualité, ouvre le débat sur les controverses qui animent la vie politique, culturelle et économique. C'est un lieu d'écoute, d'échanges, de remise en question. Forum propose chaque soir un regard attentif et acéré sur l’actualité suisse et internationale.",
+ "imageUrl" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/16x9",
+ "imageTitle" : "Logo Forum [RTS]",
+ "bannerImageUrl" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/3x1",
+ "posterImageUrl" : "https://ws.srf.ch/asset/image/audio/e0322b37-5697-474d-93ac-19a4044a6a24/POSTER.jpg",
+ "posterImageIsFallbackUrl" : true,
+ "homepageUrl" : "https://details.rts.ch/la-1ere/programmes/forum/",
+ "podcastSubscriptionUrl" : "https://www.rts.ch/la-1ere/programmes/forum/podcast/",
+ "primaryChannelId" : "a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "primaryChannelUrn" : "urn:rts:channel:radio:a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "audioDescriptionAvailable" : false,
+ "subtitlesAvailable" : false,
+ "multiAudioLanguagesAvailable" : false,
+ "allowIndexing" : false
+ },
+ "channel" : {
+ "id" : "a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:channel:radio:a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "title" : "La 1ère",
+ "imageUrl" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/16x9",
+ "imageTitle" : "Logo Forum [RTS]",
+ "transmission" : "RADIO"
+ },
+ "chapterList" : [ {
+ "id" : "13598743",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598743",
+ "title" : "Forum - Présenté par Tania Sazpinar et Esther Coquoz",
+ "imageUrl" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/16x9",
+ "imageTitle" : "Logo Forum [RTS]",
+ "type" : "EPISODE",
+ "date" : "2022-12-12T18:00:00+01:00",
+ "duration" : 3600000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598743/d96d95c9-23cf-38b8-92af-42e89cd85afa.mp3",
+ "playableAbroad" : true,
+ "socialCountList" : [ {
+ "key" : "srgView",
+ "value" : 898
+ }, {
+ "key" : "srgLike",
+ "value" : 0
+ }, {
+ "key" : "fbShare",
+ "value" : 0
+ }, {
+ "key" : "twitterShare",
+ "value" : 0
+ }, {
+ "key" : "googleShare",
+ "value" : 0
+ }, {
+ "key" : "whatsAppShare",
+ "value" : 0
+ } ],
+ "displayable" : true,
+ "position" : 0,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Forum - Présenté par Tania Sazpinar et Esther Coquoz",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598743",
+ "media_episode_length" : "3600",
+ "media_segment_length" : "3600",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "long",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598743",
+ "media_sub_set_id" : "EPISODE"
+ },
+ "eventData" : "$ec997ecbd9874fae$258ca058508c83574cb22919f3635503768c1a1c0add2b04ca590b77d2b9457e657aacd76afc90a50061e17ac60c3e1b67605cb41d8dbc3fcfb4eda6081a00dad23404d36ad5c0847b7094c4dbdf712baff4d617d6908953f61b3fd1052da55e58b1762651dc68ab1edc98f3895efd8b3de1ef01a92d0dee673380413069693faf88e3d89798afe7702404805004026b09179557a9338275a432ee9565179bf6551b3364914984da3c5bc88caa91d9b58e6cd3d2373d8ff4526bfc407467ed82cd9235d6718249b36b1d16bf2711443d330799f7a4daa3a739e3b314cf29569e2f064917e6b7adfb7a7ef08b0a415a0dc51b17bcf404d74b84c0a8eedc6c0d1b8c119db4f09d0a9e5e819aafddb6e721cad6e712beb3499f590d293a523af2d5c1529292ca9c2be172e43c321c199e55604299c18745e27c6e6a8f8ab728d0e7484e4932e10e9e1d5a4698bc146261d54a537619f59cd4677770cbd389f454732c002e3598846ce835295afe13ad03e2",
+ "fullLengthMarkIn" : 0,
+ "fullLengthMarkOut" : 0,
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598743/d96d95c9-23cf-38b8-92af-42e89cd85afa.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598743/d96d95c9-23cf-38b8-92af-42e89cd85afa.mp3"
+ }
+ } ]
+ }, {
+ "id" : "13622547",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13622547",
+ "title" : "Forum (vidéo) - Présenté par Tania Sazpinar et Esther Coquoz",
+ "imageUrl" : "https://www.rts.ch/2022/12/12/23/08/13622546.image/16x9",
+ "imageTitle" : "Forum (vidéo) - Présenté par Tania Sazpinar et Esther Coquoz [RTS]",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:00:00+01:00",
+ "duration" : 3600840,
+ "validFrom" : "2022-12-12T19:00:00+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 1,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Forum (vidéo) - Présenté par Tania Sazpinar et Esther Coquoz",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "13622547",
+ "ns_st_el" : "3600840",
+ "ns_st_cl" : "3600840",
+ "ns_st_sl" : "3600840",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc12",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Forum (vidéo) - Présenté par Tania Sazpinar et Esther Coquoz",
+ "media_type" : "Video",
+ "media_segment_id" : "13622547",
+ "media_episode_length" : "3601",
+ "media_segment_length" : "3601",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "long",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13622547",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$f992085ed4fa2c25$c6998ff87df1d9ea9b7cb76e4b23da9ccb9d0748c5f7c36f018bc86158a004d801a0736b824af35c828a67092a5607683d005f30cb1d4e9a4d6a98640119a7376fe4018ca8b0a34f7d08970687edc20b19931236b201953449b0c2fed80dc7807f436c2e96b8636a941cf7ad31e297d87411254cd1c9c1e876dc1269b9ff899bfed9c36153b83a4d8aaf09953bdc0e89f808894cdd69d0a83089a730d6d73bfa6561ea171738e61b27961494af846a485a55e415fae74b124d3f81c0276fa75c9f37af3d9c0f5711fa062e15e232bdce3eddebc1d02eb77f236dd10c12aac38ebceb53beaba79f683f2295fa3abf3c1a014f075500913f9c14ffd6c48e4c01dbf9ddb044db5d35fa0aebf1ff3bb177b790a979301a88013d8d3aa4fd3b0350f27a9f443dc4a89b67eb55965ad027f8a0958055b4e6e82a9a15ea22192d9273cf0d6adf93159f41e566749d6a485971fd3229d78144f708ff746967c267ac2c65999de99e444684cdeedbe0f4ae8beece",
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/13622547/ba5e0176-bace-398f-9d89-ed0a3f18dab4/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/13622547/ba5e0176-bace-398f-9d89-ed0a3f18dab4/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9",
+ "spriteSheet" : {
+ "urn" : "urn:rts:video:13622547",
+ "rows" : 26,
+ "columns" : 20,
+ "thumbnailHeight" : 84,
+ "thumbnailWidth" : 150,
+ "interval" : 7000,
+ "url" : "https://il.srgssr.ch/spritesheet/urn/rts/video/13622547/sprite-13622547.jpeg"
+ }
+ }, {
+ "id" : "13598744",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598744",
+ "title" : "Les hautes instances européennes expriment leur inquiétude après les accusations de corruption",
+ "imageUrl" : "https://www.rts.ch/2022/10/18/17/58/13474798.image/16x9",
+ "imageTitle" : "Ursula von der Leyen au Parlement européen à Strasbourg, 18.10.2022. [Jean-Francois Badias - AP/Keystone]",
+ "imageCopyright" : "Jean-Francois Badias - AP/Keystone",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:02:00+01:00",
+ "duration" : 237000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598744/23c3b7cd-a474-3fa6-9961-9019cf0a94e8.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 2,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Les hautes instances européennes expriment leur inquiétude après les accusations de corruption",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598744",
+ "media_episode_length" : "237",
+ "media_segment_length" : "237",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598744",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$ddaa9deb763583f4$71f264168ee2b6b5831574deab90274752575e6b3e61cb945aca3ecdc7ccbfa7d48f87832df12d11e604d7bceeb871dab04acb165b95ea842780df4c7568b481603d3113ff7cdfe19680137b9c06d932c5a4b231bb81f7084de9e10f2a752289ce267e170ee986c43a5cc3841d88a5e6049e4f3d86e209cc9b6c1bdc262b7bd0a3b1db4abd997a9c7bad3c8ae64bd89b7d6d5683b00c4a5760118d8358d85fbd0a89e6a528ab345690c378fec7e87bd9c48408f407663e3f8d19fa80c948c3b4f93f58dab1a6e03b21505c6bca8599acb6ed1ee77a9a83fce9311edb23d47c4b3805a8bf7442f804e1adc3e115772228a7297f7c50406503f4a23c1f0a27f3c1d9093f6773843e37ffd58210088c9c2888af8f2180a4d54ad4d1ab254b91daee6cda26cfc329ee3b5fbc965df3e7da45ca1702e966b6951897fd0aa4c0618feb71143238bebe84812ecb0926a99ad00a27bf71793ff3d5b6537f53a2aab99ac4f02f64710b645c3306dfe53948ec4539",
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598744/23c3b7cd-a474-3fa6-9961-9019cf0a94e8.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598744/23c3b7cd-a474-3fa6-9961-9019cf0a94e8.mp3"
+ }
+ } ]
+ }, {
+ "id" : "13598745",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598745",
+ "title" : "Yves Bertoncini s’exprime sur les cas de corruptions présumés au Parlement européen",
+ "description" : "Interview de Yves Bertoncini, enseignant en affaires européennes à l'école de commerce de Paris, et consultant en affaires européennes.",
+ "imageUrl" : "https://www.rts.ch/2020/07/19/16/43/11478416.image/16x9",
+ "imageTitle" : "Yves Bertoncini, président du Mouvement européen en France. [eesc.europa.eu]",
+ "imageCopyright" : "eesc.europa.eu",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:03:00+01:00",
+ "duration" : 336000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598745/2da36319-1f31-36af-9f09-e9d86f157e40.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 3,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Yves Bertoncini s’exprime sur les cas de corruptions présumés au Parlement européen",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598745",
+ "media_episode_length" : "336",
+ "media_segment_length" : "336",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598745",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$6affd6172a1fb2b5$298147a56fd0d5dfd2ba9795c9a5c69c8ba397d86fdde5696924cb66475b623869a91bd789ded60b18d93bf43d5f241bfae6d903d3bd3a547c45fcebc0b1a672c3f6088457ad57ed4b312a965c190f9cabe26f33b0bf4e66e7ce2d775adb39bbe0213138ec69e0a35e374f3295700742dd4a4f394811b916dd8825ed91aaa740058954798c880696c944cc25b81e144a9888e68c8d31191cb739b6949079d7c70ac153f2e81cd36782ad53ec72c94c5f277fa69ae077ad79739e0e3bf43207939bf1b960f0d1402598eaedd36e0542cd4c7d370104b08c0305f21541dda6a18c11d8133f2f64026010d5b1ba681dc9fbc07f0b1b9c173459df97488ebe374dc758b9a4828b12456c892259428fd9d04174d7d2ea40a45fb1fb6d85135347b118a7ccfaa99a2262fdc3c7d828562154bf8ff163ac9e9bc9555123d8f1af656bdd98c3cf4aa0e3f2dba4dafd0dc72a285139502b271fbdfd7ea5cf22abfd3265119858ccdb2473b4f49226f02ac93b31e7",
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598745/2da36319-1f31-36af-9f09-e9d86f157e40.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598745/2da36319-1f31-36af-9f09-e9d86f157e40.mp3"
+ }
+ } ]
+ }, {
+ "id" : "13598746",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598746",
+ "title" : "Première journée d’audience à Bellinzone pour le terroriste du kebab de Morges",
+ "imageUrl" : "https://www.rts.ch/2022/12/12/20/04/13622249.image/16x9",
+ "imageTitle" : "Première journée d’audience à Bellinzone pour le terroriste de Morges. [Linda Graedel - Keystone]",
+ "imageCopyright" : "Linda Graedel - Keystone",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:04:00+01:00",
+ "duration" : 161000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598746/ac7ee7e2-5b20-359f-9655-f9e814bee447.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 4,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Première journée d’audience à Bellinzone pour le terroriste du kebab de Morges",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598746",
+ "media_episode_length" : "161",
+ "media_segment_length" : "161",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598746",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$cf0f417e58a57656$75222e3db44c918c724e36471224ff8a7439cde9f3b249451df03ce7df4cb7a89bf6ac20089a37a66981de5c753a64a8fb34ee58db2062215983622a2184a953e6ea811782b27b8ce2f4c49cd73244812108447dda38bc9185cf35bc06895b04015ced91c13bf1e1d9f284e4878d326251a5d5beec438aadc91d460baf68eb333d892e36ffa49bb1cba06fab9d45590bfb5860467b482832760ed71a65c39175c2c24fb3602bc67afb3c9817aad42532caba64b693f5c445de5c8d712ed2a5b81863d00e48c7a50d12845aac4445c41113813db784ed2a6235c4047ae6a4ffd481917e73b1503892c84b63bb813dce3ef6b234ea20c4bbaafe957bbaf284fac50a148911e79093154475c8cbea03b9e74d848d900dac16ade67042c53e8319b7c8e549969f8bf1e306eb2b7cabb0d1df7568f34eafcb4e027fea412b11f99a4515562691cbaab1b9918f205c4a5a92f0265cd5fa6826af40ac88efe4a8b8b56372d9f2f35fe2c20c1c2f4ae844c85b69",
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598746/ac7ee7e2-5b20-359f-9655-f9e814bee447.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598746/ac7ee7e2-5b20-359f-9655-f9e814bee447.mp3"
+ }
+ } ]
+ }, {
+ "id" : "13598747",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598747",
+ "title" : "Le nombre de jeunes femmes hospitalisées pour des troubles psychiques a largement augmenté: interview de Anne Edan",
+ "description" : "Interview de Anne Edan, responsable de Malatavie, Unité de crise-Partenariat Public privé HUG-Children Action",
+ "imageUrl" : "https://www.rts.ch/2022/12/12/18/43/13622215.image/16x9",
+ "imageTitle" : "La doctoresse Anne Edan est médecin responsable de Malatavie (HUG). [RTS]",
+ "imageCopyright" : "RTS",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:05:00+01:00",
+ "duration" : 306000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598747/603ff318-7020-3b44-8ea8-efc1f500758d.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 5,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Le nombre de jeunes femmes hospitalisées pour des troubles psychiques a largement augmenté: interview de Anne Edan",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598747",
+ "media_episode_length" : "306",
+ "media_segment_length" : "306",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598747",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$ebf7057806b66ff5$20cff69bc7509ca8a40119107dd01414fcac55cf2a2d4aa9bb7e13a8b50745fe7f1c7b6690ff41722a2b227ce4fea003cb98af73d0cda6a5216a2857e56b3df8542ca270e551ccbab0439e0142230090cc385cf6e4eb28bcf25cac1bba91696a9d85c9bceac0919559b07cacc6b584fcb7bc4f1b2b66e64a621d1c43b996a41ce5f89d125d42d251bd808d5f4ee2556c2743944e7caaa5629971a24aa99c6961c15756f77043c5bad622c87083b65decf3a97a80695a13a107fdef6d23d58c2ffa39e53344352734a8c627cad9f72796eb3207d06880bad20c616938c6ae37a9d274c0354776c078e7fc3cd01ee6af765282fbc2d02fafe6b6e6f96ed73f921465dbe73297e63cf496b40e1599ed37f1dab991f1aa9b39a64e68942523eb60f83aed372115739566f13e426d959f9414110ef3a632e4d333bbb3c667c2d7a9661a682d6099dbb53743633b47581d8ec96077ced60f6792d520da15cf1ce0d1c3129dcbafd0980faa1449bfbfa48050e9",
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598747/603ff318-7020-3b44-8ea8-efc1f500758d.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598747/603ff318-7020-3b44-8ea8-efc1f500758d.mp3"
+ }
+ } ]
+ }, {
+ "id" : "13598748",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598748",
+ "title" : "Réforme du 2e pilier: la question des compensations financières pour la génération transitoire divise les Chambres fédérales",
+ "imageUrl" : "https://www.rts.ch/2022/12/05/17/48/13601356.image/16x9",
+ "imageTitle" : "La transformation numérique de l'administration divise les Chambres fédérales. [RTS]",
+ "imageCopyright" : "RTS",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:06:00+01:00",
+ "duration" : 151000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598748/c8364401-7593-3c61-aefb-cd206fcb4101.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 6,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Réforme du 2e pilier: la question des compensations financières pour la génération transitoire divise les Chambres fédérales",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598748",
+ "media_episode_length" : "151",
+ "media_segment_length" : "151",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598748",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$a4182629afa7c2d7$16cd8d9d1c92ece2ffd7db8649edfadfea3904afc9ba1d4acf8ef3bd38f50d80ea9b8b13038e6521e9f2d6949d97e226e9d9beae7c7e2289ccf429b551bc38ebaf7aab6821edc5396ee0547a188641d561cdf82e9c3700ec70be619740ca1493281ead1465c0c49996ff707f3d38bc97a88f14c19aa8e5200153b5e4e898261cd18e1a7e3e61094a134241756a6f8558463477fb51e09598a50bcb8c8b3fbd841170bff51c516ad6ace9d97989a2b78c3705a60ff211606781ecc296d949ace022ddac0e8736f2f499f827cb58469f723a0b708242f33cd5d9bef8ed4d0ae8ddf6f446de47eaf128949ca72970cb7a036996df5d2f43f952f44325c507ae2b9e8e56d468c4361d16e317cc338dd713dcdbae946f9288ec141bdd44f9eb30b325fd71229e850330754a92d0f88309117990bc9d33e4183c82ea103a1cd478b9111aef3db2fad3c7ef50cc4c4cad18a5395d8e547a36dbb70997d1588eac3bfed8c64fce49128575fd2331060377af7625",
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598748/c8364401-7593-3c61-aefb-cd206fcb4101.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598748/c8364401-7593-3c61-aefb-cd206fcb4101.mp3"
+ }
+ } ]
+ }, {
+ "id" : "13598749",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598749",
+ "title" : "Retour sur l’élection d’Éric Ciotti à la tête du parti français Les Républicains",
+ "imageUrl" : "https://www.rts.ch/2022/12/12/17/37/13622160.image/16x9",
+ "imageTitle" : "Éric Ciotti est élu à la tête du parti français Les Républicains le 11 décembre 2022. [Christophe Petit Tesson - EPA/Keystone]",
+ "imageCopyright" : "Christophe Petit Tesson - EPA/Keystone",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:07:00+01:00",
+ "duration" : 166000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598749/8c057540-b74b-35f8-9790-64821c110eb9.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 7,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Retour sur l’élection d’Éric Ciotti à la tête du parti français Les Républicains",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598749",
+ "media_episode_length" : "166",
+ "media_segment_length" : "166",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598749",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$d8edc280c72c858e$645dcf60f54f9a71c47bb237ec2674bcc3713eabb9a5f1f3008fed53e99ccdadeded7697f0ba048e8dccc1367486f718c29c59448c392eeedc4267eb44eb799b5845d710f92771a5e91d59cc2a5dce42b5a91fa430eaaaeecd08e332b8ce5cbfe036afdc99aab2c06c002af485d354fa1a4636126a069cd11cab33e12baad3d762462eaa03a543dda98c7aa52e8d4545378e49dddde527d463f4b3a8ff44ba1c9a399aaa97101f8c341dbda37164c9d43df821681c19abd73e2bc83fcf404dd436d2f8b64503f548ceb42672e58566696da69314cc56462b0818740b47f34079bcb604e12d46dd27e7b4c13b9b1519e9fa228b61973d1604ee3787fe2660fd51ba1c30f79be516edf7355751f1b096e38e425079f998aaad023de2dd8a9a754e4f445752c79dac4545400958de48acd769a2eb64e5579a0cc5bba86752c05f1abcab04d254fe1bbcf1488b66bff49634784ed1ae381c1604e2d612917f5fe58213221fd67378b5dd92d38da9b02c8ac7",
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598749/8c057540-b74b-35f8-9790-64821c110eb9.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598749/8c057540-b74b-35f8-9790-64821c110eb9.mp3"
+ }
+ } ]
+ }, {
+ "id" : "13598750",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598750",
+ "title" : "Vincent Baudriller s’exprime sur la nomination de la Française Séverine Chavrier à la tête de la Comédie à Genève",
+ "description" : "Interview de Vincent Baudriller, directeur du Théâtre de Vidy.",
+ "imageUrl" : "https://www.rts.ch/2020/04/14/16/05/11246790.image/16x9",
+ "imageTitle" : "Vincent Baudriller. [Jean-Christophe Bott - Keystone]",
+ "imageCopyright" : "Jean-Christophe Bott - Keystone",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:08:00+01:00",
+ "duration" : 402000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598750/4f917b97-c0c7-3775-9564-5fc8cb11a5b8.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 8,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Vincent Baudriller s’exprime sur la nomination de la Française Séverine Chavrier à la tête de la Comédie à Genève",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598750",
+ "media_episode_length" : "402",
+ "media_segment_length" : "402",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598750",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$eae27a0140800d45$b6ba151f5298efd03d42142e78f964c7e451e7511b1f3ff5fff3dd6b4cda7136fc5a1ad7ac52128003d431a94c222d30740781e2bcdd2ced46f84dc828c967ec5fbc3eeb32df9678c036ecdffd97b0ccf581a492e0819c575dedb053c44c45ae824e6035416f975a7ade48492d7c98c469d646f859e27de83c80020de052f5b136539c97b0772adf35f120ff09c3feccb5ac20892ac5002d3c10eb28e6dbfa7f9f598089fa93c004a4939b3d654c71459542a4c8bdf11354c4a762013ad47d3f3bba9dbfece5677d500a2031206c93df103bfb66663499a1f73f38ae449f0871beb73ef585612d016bd881fdc8bb35e0429499b2049dd1ad0880a62239b46f197ade98d6848e8aa2f17797f28b533f60554fa7b062d5b7b47d069bba7fee70687327f27e81d2aed628c09c925e2c5c6e86ccd7cc30158ca20a7a94d12cb0c98e9722d7f350bd8bd18a3f095842bb4d7733be89733d8710608a9bf0f87cdae636f5cfbb33f0d1386a4994938dd5f7d526",
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598750/4f917b97-c0c7-3775-9564-5fc8cb11a5b8.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598750/4f917b97-c0c7-3775-9564-5fc8cb11a5b8.mp3"
+ }
+ } ]
+ }, {
+ "id" : "13598751",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598751",
+ "title" : "Forum des idées - Le Campus pour la démocratie veut rendre la démocratie plus accessible aux citoyens suisses",
+ "description" : "Interview de Catherine Carron, représentante romande du Campus pour la démocratie, plate-forme nationale de l'éducation à la citoyenneté et à la participation politique.",
+ "imageUrl" : "https://www.rts.ch/2023/05/31/13/06/13531758.image/16x9",
+ "imageTitle" : "Le Palais fédéral à Berne. [Peter Klaunzer - Keystone]",
+ "imageCopyright" : "Peter Klaunzer - Keystone",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:09:00+01:00",
+ "duration" : 379000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598751/c2311297-1727-36ec-87d8-dcf686e443a0.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 9,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Forum des idées - Le Campus pour la démocratie veut rendre la démocratie plus accessible aux citoyens suisses",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598751",
+ "media_episode_length" : "379",
+ "media_segment_length" : "379",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598751",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$cfff2b2de5e31883$8435e642492008527864da938d268db4ff9ad63541b83d0192ac5da8fba432287d015b7f7ee0a9cd15e07c53a739c75e1a75edb0ca8b960f08c0f28c67fce3decbb41279f1966a042f1e211d31087178834bcff57323e5f167b2d401f85116c07881ce0946db23d45d0210ee3fa2ce47d758f1ffd33ace7764de97c0026530c94629151151a21478d9811a1fc40a1480624caafb03b4ffc7f5d60cebae02f1bfde44250293515a95466c093c684e8cae9e17abf7e23d1689dab081d2f2789dc457208dda279b0a0c91455a9f49ffdef96f6a13bd34c41181177737de10ac66672eb546990ec2e5e0a5f59471838e544628660638665621802751beb522788dc0abe15fc010ea722c8671f1bf0fa97ad13fc1bc01a5bbf6b355cce414a04c65f5cf03cd78757e7f43cdda95046b734dab8f169a4e3338ef1e3634064cd20c065a3037ce8a27c54f9cafe95a44608f0d3e014df1a0b5be43ccdea3e5817a85b15759f7c12eece51ade1619eac3c840f62b",
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598751/c2311297-1727-36ec-87d8-dcf686e443a0.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598751/c2311297-1727-36ec-87d8-dcf686e443a0.mp3"
+ }
+ } ]
+ }, {
+ "id" : "13598756",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:13598756",
+ "title" : "Le grand débat - Faut-il se méfier de TikTok?",
+ "description" : "Débat entre Laurence Allard, maîtresse de conférences en Sciences de la Communication à la Sorbonne-Nouvelle et sociologue des usages numériques, Charles Thibout, politiste, chercheur à l'IRIS et à l'Université Paris 1, spécialiste de la géopolitique des entreprises de nouvelles technologies, et Yaniv Benhamou, professeur en droit du numérique à la Faculté de droit et au Digital Law Center de l’Université de Genève et avocat en droit des nouvelles technologies.",
+ "imageUrl" : "https://www.rts.ch/2022/12/12/19/02/13622244.image/16x9",
+ "imageTitle" : "Débat entre Laurence Allard, maîtresse de conférences en Sciences de la Communication à la Sorbonne-Nouvelle et sociologue des usages numériques, Charles Thibout, politiste, chercheur à l'IRIS et à l'Université Paris 1, spécialiste de la géopolitique des entreprises de nouvelles technologies, et Yaniv Benhamou, professeur en droit du numérique à la Faculté de droit et au Digital Law Center de l’Université de Genève et avocat en droit des nouvelles technologies. [RTS - RTS]",
+ "imageCopyright" : "RTS - RTS",
+ "type" : "CLIP",
+ "date" : "2022-12-12T18:14:00+01:00",
+ "duration" : 1286000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/13598756/e6723f95-82a7-3a9f-97fd-c2c6a0421d28.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:audio:13598743",
+ "position" : 10,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Le grand débat - Faut-il se méfier de TikTok?",
+ "media_type" : "Audio",
+ "media_segment_id" : "13598756",
+ "media_episode_length" : "1286",
+ "media_segment_length" : "1286",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "long",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:13598756",
+ "media_sub_set_id" : "CLIP"
+ },
+ "eventData" : "$05d56fc176b428a7$c4d4c80e459350910101e4a51f2103e4d1decd01ac68bf302012719250e82ebba3730b1e526100865258028134005197e2ca53e885fc5c1596af2b03bfa87ab00717fd1e776a2117ff6de14c74135d21a7e75cfd5f78f34fc4bc05c5f6d6e9d4131f83701dba8d5bb403b1f70c0d6844c9ba1143024dfd8dfb51632ca593c515a42885e33e7d5c5a52530d8e728d7af5950bdf2b53be9513a0219ae62ddfb028dd10b9c99a8852d60fad788d02105300a1db890c61f57fa2756de8b1b7d66e20214996f035c7d2777e10f3a565e4bd9baa26431e49f2cf78c359dd46cb20d166fb60ad10a8e865065e4dc6881028931fb21ddc2aa708cfd2c1addf35e1fd54e733da0813c27b1be77aaeed5b08932c3c08a9feddd92cc294f05734b0ea2cc96f6b69d2a4608f90ec41045c560580782548de98c42c39cd7dfb61d1878b927371afc194d99a266b39ff8b1ada6a2e0120076f53bf8ddc4ed33a530c234c63c8c005c76a06888964520e2cef23ef655e8e",
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/13598756/e6723f95-82a7-3a9f-97fd-c2c6a0421d28.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/13598756/e6723f95-82a7-3a9f-97fd-c2c6a0421d28.mp3"
+ }
+ } ]
+ } ],
+ "analyticsData" : {
+ "srg_pr_id" : "13598757",
+ "srg_plid" : "1784426",
+ "ns_st_pl" : "Forum",
+ "ns_st_pr" : "Forum du 12.12.2022",
+ "ns_st_dt" : "2022-12-12",
+ "ns_st_ddt" : "2022-12-12",
+ "ns_st_tdt" : "2022-12-12",
+ "ns_st_tm" : "18:00",
+ "ns_st_tep" : "*null",
+ "ns_st_li" : "0",
+ "ns_st_stc" : "0867",
+ "ns_st_st" : "RTS Online",
+ "ns_st_tpr" : "1784426",
+ "ns_st_en" : "*null",
+ "ns_st_ge" : "*null",
+ "ns_st_ia" : "*null",
+ "ns_st_ce" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc",
+ "srg_unit" : "RTS",
+ "srg_c1" : "full",
+ "srg_c2" : "la-1ere_programmes_forum",
+ "srg_c3" : "LA 1ÈRE",
+ "srg_aod_prid" : "13598757"
+ },
+ "analyticsMetadata" : {
+ "media_episode_id" : "13598757",
+ "media_show_id" : "1784426",
+ "media_show" : "Forum",
+ "media_episode" : "Forum du 12.12.2022",
+ "media_is_livestream" : "false",
+ "media_full_length" : "full",
+ "media_enterprise_units" : "RTS",
+ "media_joker1" : "full",
+ "media_joker2" : "la-1ere_programmes_forum",
+ "media_joker3" : "LA 1ÈRE",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_thumbnail" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/16x9/scale/width/344",
+ "media_publication_date" : "2022-12-12",
+ "media_publication_time" : "18:00:00",
+ "media_publication_datetime" : "2022-12-12T18:00:00+01:00",
+ "media_tv_date" : "2022-12-12",
+ "media_tv_time" : "18:00:00",
+ "media_tv_datetime" : "2022-12-12T18:00:00+01:00",
+ "media_content_group" : "Forum,Programmes,La 1ère",
+ "media_channel_id" : "a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "media_channel_cs" : "0867",
+ "media_channel_name" : "La 1ère",
+ "media_since_publication_d" : "496",
+ "media_since_publication_h" : "11919"
+ }
+}
\ No newline at end of file
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_drm.dataset/Contents.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_drm.dataset/Contents.json
new file mode 100644
index 00000000..d5ec4402
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_drm.dataset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "data" : [
+ {
+ "filename" : "MediaComposition_drm.json",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_drm.dataset/MediaComposition_drm.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_drm.dataset/MediaComposition_drm.json
new file mode 100644
index 00000000..b261eecd
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_drm.dataset/MediaComposition_drm.json
@@ -0,0 +1,315 @@
+
+{
+ "chapterUrn" : "urn:rts:video:13548828",
+ "episode" : {
+ "id" : "13435837",
+ "title" : "Top Models",
+ "lead" : "8846",
+ "publishedDate" : "2022-11-18T11:44:09+01:00",
+ "imageUrl" : "https://www.rts.ch/2022/11/18/08/05/13548823.image/16x9",
+ "imageTitle" : "Top Models [RTS]"
+ },
+ "show" : {
+ "id" : "532539",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:show:tv:532539",
+ "title" : "Top Models",
+ "lead" : "Drames, paillettes et glamour au coeur de Los Angeles.\n\nDu lundi au vendredi à 11h45 sur RTS Un. L'épisode du jour est disponible en \"preview\" sur Play RTS 24h avant la diffusion antenne, puis à revoir durant 30 jours.",
+ "description" : "Drames, paillettes et glamour au coeur de Los Angeles. Du lundi au vendredi à 11h45 sur RTS Un. L'épisode du jour est disponible en \"preview\" sur Play RTS 24h avant la diffusion antenne, puis à revoir durant 30 jours.",
+ "imageUrl" : "https://www.rts.ch/2022/04/26/11/09/11507387.image/16x9",
+ "imageTitle" : "Top Models. [RTS/Monty Brinton/CBS/Courtesy of Sony Pictures of Televisions]",
+ "bannerImageUrl" : "https://www.rts.ch/2022/04/26/11/09/11507387.image/3x1",
+ "posterImageUrl" : "https://www.rts.ch/2022/04/26/10/03/12155676.image/2x3",
+ "posterImageIsFallbackUrl" : false,
+ "homepageUrl" : "https://details.rts.ch/emissions/series",
+ "primaryChannelId" : "143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "primaryChannelUrn" : "urn:rts:channel:tv:143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "availableAudioLanguageList" : [ {
+ "locale" : "fr",
+ "language" : "Français"
+ }, {
+ "locale" : "en",
+ "language" : "English"
+ } ],
+ "availableVideoQualityList" : [ "SD" ],
+ "audioDescriptionAvailable" : false,
+ "subtitlesAvailable" : true,
+ "multiAudioLanguagesAvailable" : true,
+ "topicList" : [ {
+ "id" : "2386",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:2386",
+ "title" : "Top Models"
+ }, {
+ "id" : "2383",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:2383",
+ "title" : "Séries"
+ }, {
+ "id" : "2026",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:2026",
+ "title" : "Émissions"
+ } ],
+ "allowIndexing" : false
+ },
+ "channel" : {
+ "id" : "143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:channel:tv:143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "title" : "RTS 1",
+ "imageUrl" : "https://www.rts.ch/2022/04/26/11/09/11507387.image/16x9",
+ "imageUrlRaw" : "https://il.srgssr.ch/image-service/dynamic/8eebe5.svg",
+ "imageTitle" : "Top Models. [RTS/Monty Brinton/CBS/Courtesy of Sony Pictures of Televisions]",
+ "transmission" : "TV"
+ },
+ "chapterList" : [ {
+ "id" : "13548828",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13548828",
+ "title" : "8846",
+ "imageUrl" : "https://www.rts.ch/2022/11/18/08/05/13548823.image/16x9",
+ "imageTitle" : "8846 [RTS]",
+ "type" : "EPISODE",
+ "date" : "2022-11-18T11:44:09+01:00",
+ "duration" : 1259200,
+ "validFrom" : "2022-11-17T12:11:44+01:00",
+ "validTo" : "2022-12-18T12:11:44+01:00",
+ "playableAbroad" : false,
+ "socialCountList" : [ {
+ "key" : "srgView",
+ "value" : 4779
+ }, {
+ "key" : "srgLike",
+ "value" : 0
+ }, {
+ "key" : "fbShare",
+ "value" : 1
+ }, {
+ "key" : "twitterShare",
+ "value" : 1
+ }, {
+ "key" : "googleShare",
+ "value" : 0
+ }, {
+ "key" : "whatsAppShare",
+ "value" : 3
+ } ],
+ "displayable" : true,
+ "position" : 0,
+ "noEmbed" : true,
+ "analyticsData" : {
+ "ns_st_ep" : "8846",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "13548828",
+ "ns_st_el" : "1259200",
+ "ns_st_cl" : "1259200",
+ "ns_st_sl" : "1259200",
+ "srg_mgeobl" : "true",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc12",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "8846",
+ "media_type" : "Video",
+ "media_segment_id" : "13548828",
+ "media_episode_length" : "1259",
+ "media_segment_length" : "1259",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "long",
+ "media_is_geoblocked" : "true",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13548828"
+ },
+ "eventData" : "$fbb22ba2ca84dbae$9405a51d787a879d706b2354bbe054bb494dcba5fd4e5769985cb35df800bd3325fe61c69bb52ac217a0e5d5b5f9b679087b77d331f294e663042184283ec77ab2e349cef8cf00412fb057769142e4cfd03afa06e50c39a5d077821cb998a3bd728622d3c9f41f836b9268736db5f9ce11bc5091de7fe319ff4e8e4d1a801c0c9a1ab2f9e435038338d3be4cb1f956ec177901145ae0bc284d967a4c6240d1a70de139c4b06b7bc85b4cafe61ac36f659bca7e579e4be756e89bbab9fb583908afa5f327d5d77e0a6b931ff245fd91c703ff97a02224b62cc09d30d4ec3ef276f994212f5a009521c58b3f9b08b95197d71b3abb0df932802466511d069318acc4205cdc1e73a144ce24caa1becb10acd9afe26c7243e8c8f4ae5bccc28aa813c99a0a8759aa7078f62e4f3c4b8c1722690e48fa1bdc1dab50aaf79d39d9a9b3f035b6d6e2fbc746600fbdbf65a41896dabf07a929fb3182dded8644fa14683a998e9e1b3ce808431d8bceda1bbbd2bf",
+ "resourceList" : [ {
+ "url" : "https://rtsvod-euwe.akamaized.net:443/2a438722-f0c3-4653-8528-6d4dc11543e7/RTSVOD-11c75e3a-3091.ism/manifest(format=mpd-time-csf,encryption=cenc)",
+ "drmList" : [ {
+ "type" : "PLAYREADY",
+ "licenseUrl" : "https://srg.live.ott.irdeto.com/licenseServer/playready/v1/SRG/license?contentId=RTSVOD"
+ }, {
+ "type" : "WIDEVINE",
+ "licenseUrl" : "https://srg.live.ott.irdeto.com/licenseServer/widevine/v1/SRG/license?contentId=RTSVOD"
+ } ],
+ "quality" : "HD",
+ "protocol" : "DASH",
+ "encoding" : "H264",
+ "mimeType" : "application/dash+xml",
+ "presentation" : "DEFAULT",
+ "streaming" : "DASH",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "DASH"
+ }, {
+ "locale" : "en",
+ "language" : "English",
+ "source" : "DASH"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "DASH",
+ "type" : "SDH"
+ }, {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "DASH",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rtsvod-euwe.akamaized.net:443/2a438722-f0c3-4653-8528-6d4dc11543e7/RTSVOD-11c75e3a-3091.ism/manifest(format=mpd-time-csf,encryption=cenc)"
+ }
+ }, {
+ "url" : "https://rtsvod-euwe.akamaized.net:443/2a438722-f0c3-4653-8528-6d4dc11543e7/RTSVOD-11c75e3a-3091.ism/manifest(format=m3u8-aapl,encryption=cbcs-aapl)",
+ "drmList" : [ {
+ "type" : "FAIRPLAY",
+ "licenseUrl" : "https://srg.live.ott.irdeto.com/licenseServer/streaming/v1/SRG/getckc?contentId=RTSVOD&keyId=6470ddd4-63ab-4d1c-972a-f91b10278eba",
+ "certificateUrl" : "https://srg.live.ott.irdeto.com/licenseServer/streaming/v1/SRG/getcertificate?applicationId=live"
+ } ],
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "MPEG2_TS",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ }, {
+ "locale" : "en",
+ "language" : "English",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ }, {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rtsvod-euwe.akamaized.net:443/2a438722-f0c3-4653-8528-6d4dc11543e7/RTSVOD-11c75e3a-3091.ism/manifest(format=m3u8-aapl,encryption=cbcs-aapl)"
+ }
+ } ],
+ "aspectRatio" : "16:9",
+ "timeIntervalList" : [ {
+ "type" : "CLOSING_CREDITS",
+ "markIn" : 1233720,
+ "markOut" : 1259200
+ } ]
+ } ],
+ "topicList" : [ {
+ "id" : "2386",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:2386",
+ "title" : "Top Models"
+ }, {
+ "id" : "2383",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:2383",
+ "title" : "Séries"
+ }, {
+ "id" : "2026",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:2026",
+ "title" : "Émissions"
+ } ],
+ "analyticsData" : {
+ "srg_pr_id" : "13435837",
+ "srg_plid" : "532539",
+ "ns_st_pl" : "Top Models",
+ "ns_st_pr" : "Top Models du 18.11.2022",
+ "ns_st_dt" : "2022-11-18",
+ "ns_st_ddt" : "2022-11-17",
+ "ns_st_tdt" : "2022-11-18",
+ "ns_st_tm" : "11:44:09",
+ "ns_st_tep" : "500386540",
+ "ns_st_li" : "0",
+ "ns_st_stc" : "0867",
+ "ns_st_st" : "RTS Online",
+ "ns_st_tpr" : "532539",
+ "ns_st_en" : "*null",
+ "ns_st_ge" : "*null",
+ "ns_st_ia" : "*null",
+ "ns_st_ce" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc",
+ "srg_unit" : "RTS",
+ "srg_c1" : "full",
+ "srg_c2" : "video_plus7_series_top-models",
+ "srg_c3" : "RTS 1",
+ "srg_tv_id" : "500386540"
+ },
+ "analyticsMetadata" : {
+ "media_episode_id" : "13435837",
+ "media_show_id" : "532539",
+ "media_show" : "Top Models",
+ "media_episode" : "Top Models du 18.11.2022",
+ "media_is_livestream" : "false",
+ "media_full_length" : "full",
+ "media_enterprise_units" : "RTS",
+ "media_joker1" : "full",
+ "media_joker2" : "video_plus7_series_top-models",
+ "media_joker3" : "RTS 1",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_tv_id" : "500386540",
+ "media_thumbnail" : "https://www.rts.ch/2022/11/18/08/05/13548823.image/16x9/scale/width/344",
+ "media_publication_date" : "2022-11-17",
+ "media_publication_time" : "12:11:44",
+ "media_publication_datetime" : "2022-11-17T12:11:44+01:00",
+ "media_tv_date" : "2022-11-18",
+ "media_tv_time" : "11:44:09",
+ "media_tv_datetime" : "2022-11-18T11:44:09+01:00",
+ "media_content_group" : "Top Models,Séries,Émissions",
+ "media_channel_id" : "143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "media_channel_cs" : "0867",
+ "media_channel_name" : "RTS 1",
+ "media_since_publication_d" : "0",
+ "media_since_publication_h" : "-3"
+ }
+}
\ No newline at end of file
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_live.dataset/Contents.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_live.dataset/Contents.json
new file mode 100644
index 00000000..1f3ee82c
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_live.dataset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "data" : [
+ {
+ "filename" : "urn_rts_audio_3262320.json",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_live.dataset/urn_rts_audio_3262320.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_live.dataset/urn_rts_audio_3262320.json
new file mode 100644
index 00000000..8971ba65
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_live.dataset/urn_rts_audio_3262320.json
@@ -0,0 +1,143 @@
+{
+ "chapterUrn" : "urn:rts:audio:3262320",
+ "episode" : {
+ "id" : "3262332",
+ "title" : "La 1ère en direct",
+ "publishedDate" : "2011-07-11T14:18:47+02:00",
+ "imageUrl" : "https://www.rts.ch/2020/05/18/14/24/11333295.image/16x9",
+ "imageTitle" : "Chaîne La 1ère"
+ },
+ "show" : {
+ "id" : "3262333",
+ "vendor" : "RTS",
+ "transmission" : "RADIO",
+ "urn" : "urn:rts:show:radio:3262333",
+ "title" : "La 1ère en direct",
+ "imageUrl" : "https://www.rts.ch/2020/05/18/14/24/11333295.image/16x9",
+ "imageTitle" : "Chaîne La 1ère",
+ "bannerImageUrl" : "https://www.rts.ch/2020/05/18/14/24/11333295.image/3x1",
+ "posterImageUrl" : "https://ws.srf.ch/asset/image/audio/e0322b37-5697-474d-93ac-19a4044a6a24/POSTER.jpg",
+ "posterImageIsFallbackUrl" : true,
+ "primaryChannelId" : "a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "primaryChannelUrn" : "urn:rts:channel:radio:a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "audioDescriptionAvailable" : false,
+ "subtitlesAvailable" : false,
+ "multiAudioLanguagesAvailable" : false,
+ "allowIndexing" : false
+ },
+ "channel" : {
+ "id" : "a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:channel:radio:a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "title" : "La 1ere",
+ "imageUrl" : "https://www.rts.ch/2020/05/18/14/24/11333295.image/16x9",
+ "imageTitle" : "Chaîne La 1ère",
+ "transmission" : "RADIO"
+ },
+ "chapterList" : [ {
+ "id" : "3262320",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:3262320",
+ "title" : "La 1ère en direct",
+ "imageUrl" : "https://www.rts.ch/2020/05/18/14/24/11333295.image/16x9",
+ "imageTitle" : "Chaîne La 1ère",
+ "type" : "LIVESTREAM",
+ "date" : "2011-07-11T14:18:47+02:00",
+ "duration" : 0,
+ "playableAbroad" : true,
+ "displayable" : true,
+ "position" : 0,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Livestream",
+ "media_type" : "Audio",
+ "media_segment_id" : "3262320",
+ "media_episode_length" : "0",
+ "media_segment_length" : "0",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "infinit.livestream",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:3262320"
+ },
+ "fullLengthMarkIn" : 0,
+ "fullLengthMarkOut" : 0,
+ "resourceList" : [ {
+ "url" : "https://lsaplus.swisstxt.ch/audio/la-1ere_96.stream/playlist.m3u8?",
+ "quality" : "HD",
+ "protocol" : "HLS-DVR",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : true,
+ "live" : true,
+ "mediaContainer" : "MPEG2_TS",
+ "audioCodec" : "AAC",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://lsaplus.swisstxt.ch/audio/la-1ere_96.stream/playlist.m3u8?"
+ },
+ "streamOffset" : 55000
+ } ]
+ } ],
+ "analyticsData" : {
+ "srg_pr_id" : "3262332",
+ "srg_plid" : "3262333",
+ "ns_st_pl" : "Livestream",
+ "ns_st_pr" : "La 1ère en direct",
+ "ns_st_dt" : "2011-07-11",
+ "ns_st_ddt" : "2011-07-11",
+ "ns_st_tdt" : "2011-07-11",
+ "ns_st_tm" : "14:18:47",
+ "ns_st_tep" : "*null",
+ "ns_st_li" : "1",
+ "ns_st_stc" : "0867",
+ "ns_st_st" : "La 1ere",
+ "ns_st_tpr" : "1423878",
+ "ns_st_en" : "*null",
+ "ns_st_ge" : "*null",
+ "ns_st_ia" : "*null",
+ "ns_st_ce" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc",
+ "srg_unit" : "RTS",
+ "srg_c1" : "live",
+ "srg_c2" : "rts.ch_audio_la-1ere",
+ "srg_c3" : "LA 1ÈRE",
+ "srg_aod_prid" : "3262332"
+ },
+ "analyticsMetadata" : {
+ "media_episode_id" : "3262332",
+ "media_show_id" : "1423878",
+ "media_show" : "On en parle",
+ "media_episode" : "La 1ère en direct",
+ "media_is_livestream" : "true",
+ "media_full_length" : "full",
+ "media_enterprise_units" : "RTS",
+ "media_joker1" : "live",
+ "media_joker2" : "rts.ch_audio_la-1ere",
+ "media_joker3" : "LA 1ÈRE",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_thumbnail" : "https://www.rts.ch/2020/05/18/14/24/11333295.image/16x9/scale/width/344",
+ "media_publication_date" : "2011-07-11",
+ "media_publication_time" : "14:18:47",
+ "media_publication_datetime" : "2011-07-11T14:18:47+02:00",
+ "media_tv_date" : "2011-07-11",
+ "media_tv_time" : "14:18:47",
+ "media_tv_datetime" : "2011-07-11T14:18:47+02:00",
+ "media_content_group" : "La 1ère",
+ "media_channel_id" : "a9e7621504c6959e35c3ecbe7f6bed0446cdf8da",
+ "media_channel_cs" : "0867",
+ "media_channel_name" : "La 1ere",
+ "media_since_publication_d" : "4074",
+ "media_since_publication_h" : "97795"
+ }
+}
\ No newline at end of file
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_missingAnalytics.dataset/Contents.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_missingAnalytics.dataset/Contents.json
new file mode 100644
index 00000000..56c7e33d
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_missingAnalytics.dataset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "data" : [
+ {
+ "filename" : "urn_rts_video_13360574.json",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_missingAnalytics.dataset/urn_rts_video_13360574.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_missingAnalytics.dataset/urn_rts_video_13360574.json
new file mode 100644
index 00000000..51f9103a
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_missingAnalytics.dataset/urn_rts_video_13360574.json
@@ -0,0 +1,179 @@
+{
+ "chapterUrn" : "urn:rts:video:13360574",
+ "episode" : {
+ "id" : "13360565",
+ "title" : "Yadebat",
+ "publishedDate" : "2022-09-05T16:30:00+02:00",
+ "imageUrl" : "https://www.rts.ch/2022/09/05/18/20/13360573.image/16x9",
+ "imageTitle" : "On réunit des ex après leur rupture [RTS]"
+ },
+ "show" : {
+ "id" : "10174267",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:show:tv:10174267",
+ "title" : "Yadebat",
+ "lead" : "Une série qui te donne la parole, pour laisser entendre ton avis sur les débats de société animée par Melissa.",
+ "description" : "Une série qui te donne la parole, pour laisser entendre ton avis sur les débats de société animée par Melissa.",
+ "imageUrl" : "https://www.rts.ch/2020/01/10/11/14/10520588.image/16x9",
+ "imageTitle" : "Yadebat - Tataki [RTS]",
+ "posterImageUrl" : "https://ws.srf.ch/asset/image/audio/e0322b37-5697-474d-93ac-19a4044a6a24/POSTER.jpg",
+ "posterImageIsFallbackUrl" : true,
+ "audioDescriptionAvailable" : false,
+ "subtitlesAvailable" : false,
+ "multiAudioLanguagesAvailable" : false,
+ "topicList" : [ {
+ "id" : "59952",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:59952",
+ "title" : "Yadebat"
+ }, {
+ "id" : "54537",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:54537",
+ "title" : "Tataki"
+ } ],
+ "allowIndexing" : false
+ },
+ "chapterList" : [ {
+ "id" : "13360574",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13360574",
+ "title" : "On réunit des ex après leur rupture",
+ "description" : "Dans ce nouvel épisode de YADEBAT, Mélissa réunit 3 couples qui se sont séparés récemment. Elles les a questionné en face à face pour connaître leurs différents ressentis et réactions.",
+ "imageUrl" : "https://www.rts.ch/2022/09/05/18/20/13360573.image/16x9",
+ "imageTitle" : "On réunit des ex après leur rupture [RTS]",
+ "type" : "EPISODE",
+ "date" : "2022-09-05T16:30:00+02:00",
+ "duration" : 902360,
+ "validFrom" : "2022-09-05T16:30:00+02:00",
+ "validTo" : "2100-01-01T23:59:59+01:00",
+ "playableAbroad" : true,
+ "socialCountList" : [ {
+ "key" : "srgView",
+ "value" : 17
+ }, {
+ "key" : "srgLike",
+ "value" : 0
+ }, {
+ "key" : "fbShare",
+ "value" : 0
+ }, {
+ "key" : "twitterShare",
+ "value" : 0
+ }, {
+ "key" : "googleShare",
+ "value" : 0
+ }, {
+ "key" : "whatsAppShare",
+ "value" : 0
+ } ],
+ "displayable" : true,
+ "position" : 0,
+ "noEmbed" : false,
+ "eventData" : "$27549332a83ca6ac$64b181b51953d6ed48de11986513e2f93922eb3d4315e6d5ad8189e1fe38d933c011ba7ded29e3d757ba1e566e76d65d97c8f0cd0735cc47b1cb3e5cf091c89c8d6c18ff31e19e3d7509cbf826c0c156fd10b8908ebe481aaf7282de102e92342ffb36b52df58453b40d64883f720fb3eddd38b595ddf6961acc4bc33abb3f2c49b7d90b52a35239f0209caa3ebc532e6a95315bd382bc08f2b78af2ec23c3f7e7917de924cb7f85b8aedac2fdafd027fe3880e07f3a0ba05f43d0ce601a1d2c7b756012c8820e12eef32fb9c0e1f532cce31cf1be738a9d6c05555857700fc5e1f0e1bd9886f06c55f5e731a66daa09be035e5ef53a4da159a7d3943a67ebaa1ac1302ad3ff046739eb185d78737e1543e7788d4edd9858af0e6846460106e954e8f1176cf60876aad36646c11a3b3a824ab54433f99c4576accea86e2b853c",
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/13360574/447e0958-42a8-3bdd-8365-95d54031e605/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/13360574/447e0958-42a8-3bdd-8365-95d54031e605/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9",
+ "spriteSheet" : {
+ "urn" : "urn:rts:video:13360574",
+ "rows" : 23,
+ "columns" : 20,
+ "thumbnailHeight" : 84,
+ "thumbnailWidth" : 150,
+ "interval" : 2000,
+ "url" : "https://il.srgssr.ch/spritesheet/urn/rts/video/13360574/sprite-13360574.jpeg"
+ }
+ } ],
+ "topicList" : [ {
+ "id" : "59952",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:59952",
+ "title" : "Yadebat"
+ }, {
+ "id" : "54537",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:54537",
+ "title" : "Tataki"
+ } ],
+ "analyticsData" : {
+ "srg_pr_id" : "13360565",
+ "srg_plid" : "10174267",
+ "ns_st_pl" : "Yadebat",
+ "ns_st_pr" : "Yadebat du 05.09.2022",
+ "ns_st_dt" : "2022-09-05",
+ "ns_st_ddt" : "2022-09-05",
+ "ns_st_tdt" : "*null",
+ "ns_st_tm" : "*null",
+ "ns_st_tep" : "500418168",
+ "ns_st_li" : "0",
+ "ns_st_stc" : "0867",
+ "ns_st_st" : "RTS Online",
+ "ns_st_tpr" : "10174267",
+ "ns_st_en" : "*null",
+ "ns_st_ge" : "*null",
+ "ns_st_ia" : "*null",
+ "ns_st_ce" : "1",
+ "ns_st_cdm" : "eo",
+ "ns_st_cmt" : "ec",
+ "srg_unit" : "RTS",
+ "srg_c1" : "full",
+ "srg_c2" : "video_tataki_yadebat",
+ "srg_c3" : "RTS.ch",
+ "srg_tv_id" : "500418168"
+ },
+ "analyticsMetadata" : {
+ "media_episode_id" : "13360565",
+ "media_show_id" : "10174267",
+ "media_show" : "Yadebat",
+ "media_episode" : "Yadebat du 05.09.2022",
+ "media_is_livestream" : "false",
+ "media_full_length" : "full",
+ "media_enterprise_units" : "RTS",
+ "media_joker1" : "full",
+ "media_joker2" : "video_tataki_yadebat",
+ "media_joker3" : "RTS.ch",
+ "media_is_web_only" : "true",
+ "media_production_source" : "produced.for.web",
+ "media_tv_id" : "500418168",
+ "media_thumbnail" : "https://www.rts.ch/2022/09/05/18/20/13360573.image/16x9/scale/width/344",
+ "media_publication_date" : "2022-09-05",
+ "media_publication_time" : "16:30:00",
+ "media_publication_datetime" : "2022-09-05T16:30:00+02:00",
+ "media_content_group" : "Yadebat,Tataki",
+ "media_since_publication_d" : "0",
+ "media_since_publication_h" : "19"
+ }
+}
\ No newline at end of file
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_mixed.dataset/Contents.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_mixed.dataset/Contents.json
new file mode 100644
index 00000000..be7832a3
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_mixed.dataset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "data" : [
+ {
+ "filename" : "urn_rts_video_14827796.json",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_mixed.dataset/urn_rts_video_14827796.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_mixed.dataset/urn_rts_video_14827796.json
new file mode 100644
index 00000000..be1d38a6
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_mixed.dataset/urn_rts_video_14827796.json
@@ -0,0 +1,1258 @@
+{
+ "chapterUrn" : "urn:rts:video:14827796",
+ "episode" : {
+ "id" : "14718074",
+ "title" : "Forum",
+ "lead" : "Forum du 10.04.2024",
+ "publishedDate" : "2024-04-10T18:00:00+02:00",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827795.image/16x9",
+ "imageTitle" : "Forum [RTS]"
+ },
+ "show" : {
+ "id" : "9933104",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:show:tv:9933104",
+ "title" : "Forum",
+ "lead" : "7 jours sur 7, Forum questionne en direct les acteurs de l’actualité, ouvre le débat sur les controverses qui animent la vie politique, culturelle et économique.",
+ "description" : "7 jours sur 7, Forum questionne en direct les acteurs de l'actualité, ouvre le débat sur les controverses qui animent la vie politique, culturelle et économique. C'est un lieu d'écoute, d'échanges, de remise en question. Forum propose chaque soir un regard attentif et acéré sur l’actualité suisse et internationale.",
+ "imageUrl" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/16x9",
+ "imageTitle" : "Logo Forum [RTS]",
+ "bannerImageUrl" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/3x1",
+ "posterImageUrl" : "https://www.rts.ch/2024/02/22/13/58/12399002.image/2x3",
+ "posterImageIsFallbackUrl" : false,
+ "homepageUrl" : "https://details.rts.ch/la-1ere/programmes/forum/",
+ "primaryChannelId" : "d7dfff28deee44e1d3c49a3d37d36d492b29671b",
+ "primaryChannelUrn" : "urn:rts:channel:tv:d7dfff28deee44e1d3c49a3d37d36d492b29671b",
+ "availableAudioLanguageList" : [ {
+ "locale" : "fr",
+ "language" : "Français"
+ } ],
+ "availableVideoQualityList" : [ "SD", "HD" ],
+ "audioDescriptionAvailable" : false,
+ "subtitlesAvailable" : true,
+ "multiAudioLanguagesAvailable" : false,
+ "topicList" : [ {
+ "id" : "49683",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:49683",
+ "title" : "Forum"
+ }, {
+ "id" : "16202",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:16202",
+ "title" : "La 1ère"
+ } ],
+ "allowIndexing" : false
+ },
+ "channel" : {
+ "id" : "d7dfff28deee44e1d3c49a3d37d36d492b29671b",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:channel:tv:d7dfff28deee44e1d3c49a3d37d36d492b29671b",
+ "title" : "RTS 2",
+ "imageUrl" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/16x9",
+ "imageUrlRaw" : "https://il.srgssr.ch/image-service/dynamic/c915e35.svg",
+ "imageTitle" : "Logo Forum [RTS]",
+ "transmission" : "TV"
+ },
+ "chapterList" : [ {
+ "id" : "14827796",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827796",
+ "title" : "Forum (vidéo) - Présenté par Thibaut Schaller et Renaud Malik",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827795.image/16x9",
+ "imageTitle" : "Forum (vidéo) - Présenté par Thibaut Schaller et Renaud Malik [RTS]",
+ "type" : "EPISODE",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 3599720,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "socialCountList" : [ {
+ "key" : "srgView",
+ "value" : 1212
+ }, {
+ "key" : "srgLike",
+ "value" : 0
+ }, {
+ "key" : "fbShare",
+ "value" : 0
+ }, {
+ "key" : "twitterShare",
+ "value" : 0
+ }, {
+ "key" : "googleShare",
+ "value" : 0
+ }, {
+ "key" : "whatsAppShare",
+ "value" : 2
+ } ],
+ "displayable" : true,
+ "position" : 0,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Forum (vidéo) - Présenté par Thibaut Schaller et Renaud Malik",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827796",
+ "ns_st_el" : "3599720",
+ "ns_st_cl" : "3599720",
+ "ns_st_sl" : "3599720",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc12",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Forum (vidéo) - Présenté par Thibaut Schaller et Renaud Malik",
+ "media_type" : "Video",
+ "media_segment_id" : "14827796",
+ "media_episode_length" : "3600",
+ "media_segment_length" : "3600",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "long",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827796",
+ "media_sub_set_id" : "EPISODE",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$639edb61591433fa$39dd7d5589c57c9d9e934d9b277655679f3ccc1188abc26332eb015bf8eb84749d1d4eade6218a91e7fa548fb8de0f5304154a3212400eb6e288c54acb84175d5176a476c815cabd7786be1e6f1fb06a9f6cd699995fb9e0338af76422d6b8cfd56f32634f999dff81476e0eb174d284ea765538b8cd7e9eea02572602725f07a08b873078b5ee9e76231fe34cb6bc2ee20f961289e9f59fb8b87255d04d2938f23e51aa62a340545f28850c9b272644b0de206dd2664284733ce12297efd04c0ce6da56f3cfa50bb82380510ea739d0ccb7a83dcfd5ec198dae564d0ed6c58315c9317342395edade408b0abba2c4935c924663a4ad37e8c606b77bcb9a54ecfa5136f2f86e6e9a0074b18e3cdb0c211a26e8a48fdab82a1fb9d375219f986bbfd51fe1ae40b412b206027e47c0f5c50cf5b1b3bd0607b047b15cc5c75aeb4dc9ba90f24b2e3ae4105d524fbff21c2b4128adeb3d51d27d334a629f75148b8a286714d05e57b742e4eb09b82e126777",
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827796/a07f41f3-987b-3d1e-af10-b9e7220429db/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827796/a07f41f3-987b-3d1e-af10-b9e7220429db/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9",
+ "spriteSheet" : {
+ "urn" : "urn:rts:video:14827796",
+ "rows" : 30,
+ "columns" : 20,
+ "thumbnailHeight" : 84,
+ "thumbnailWidth" : 150,
+ "interval" : 6000,
+ "url" : "https://il.srgssr.ch/spritesheet/urn/rts/video/14827796/sprite-14827796.jpeg"
+ }
+ }, {
+ "id" : "14827774",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827774",
+ "title" : "La Suisse organise une conférence de haut niveau sur la paix en Ukraine qui se déroulera mi-juin",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827765.image/16x9",
+ "imageTitle" : "La Suisse organise une conférence de haut niveau sur la paix en Ukraine qui se déroulera mi-juin [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 208800,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 1,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "La Suisse organise une conférence de haut niveau sur la paix en Ukraine qui se déroulera mi-juin",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827774",
+ "ns_st_el" : "208800",
+ "ns_st_cl" : "208800",
+ "ns_st_sl" : "208800",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "La Suisse organise une conférence de haut niveau sur la paix en Ukraine qui se déroulera mi-juin",
+ "media_type" : "Video",
+ "media_segment_id" : "14827774",
+ "media_episode_length" : "209",
+ "media_segment_length" : "209",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827774",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$3d2421a5fd090279$f1b0b1930c0e0152328c4bede0613736bad4bff09afc6b35fbe2824b8dda4b36ceeebde74eb0f673f6bf0c63bb5d8e943014b8f08fb9678e82c47c205aa66ff887c808e5160e93c610e2da6677d4e01daa331a8bac1d47103ab130151385d47c7519b994f643035e58aee846dad0492b95795ebec6a3ddc4b17be7a03b4e894e43662db6784e803aeec556fd5fb329735f6ca1a4a3a645bf5c647854807d1d655a1ab2e49b8461e42df64afe3aa11649074b7eff32f8d9dcd3c24a4b82d67775987485392f460281d789acff0663d3506dc172775ac74b15ca678a3468a93b52a432c287b4c588ab850149210b224a29c55fbfeeda57c5dd258ce3e33cce59770e506ca75b22e57831deb364554977e6ab9d00c7cccd49d6ef7f0e54cd6bfed5c53d3ea030e6b78fd65d65ea88a5dff5a6c34a8f3eed897bd3363cb2f5fda76661f56b311831c4fd3c039f3d6872c02b1c97e61ba312585965f2ff7f11a0a80c3bad513b8a3dc0b2f723700d1adabe86",
+ "fullLengthMarkIn" : 99000,
+ "fullLengthMarkOut" : 307800,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827774/d39bbda9-5f74-3c41-a670-d55be1a1f0e3/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827774/d39bbda9-5f74-3c41-a670-d55be1a1f0e3/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827776",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827776",
+ "title" : "Conférence de haut niveau sur la paix en Ukraine: les réactions de Cédric Wermuth et Pascal Broulis",
+ "description" : "Réactions de Cédric Wermuth, co-président du Parti socialiste suisse, et Pascal Broulis, conseiller aux Etats PLR vaudois.",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827775.image/16x9",
+ "imageTitle" : "Conférence de haut niveau sur la paix en Ukraine: les réactions de Cédric Wermuth et Pascal Broulis [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 135200,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 2,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Conférence de haut niveau sur la paix en Ukraine: les réactions de Cédric Wermuth et Pascal Broulis",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827776",
+ "ns_st_el" : "135200",
+ "ns_st_cl" : "135200",
+ "ns_st_sl" : "135200",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Conférence de haut niveau sur la paix en Ukraine: les réactions de Cédric Wermuth et Pascal Broulis",
+ "media_type" : "Video",
+ "media_segment_id" : "14827776",
+ "media_episode_length" : "135",
+ "media_segment_length" : "135",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827776",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$210802670c7f1a3d$710d33293247a3c70783e81fd407a86ee614cda7351b3039aa31ed5f0b1384ec1484092aa0c186828ce2022949336c51b4ccc9434526d02e41b6bdc7882a27206b879dbd73c0d18b21ef3ea018f8a9df9613e1980e14d1f079a8379fd4163c7533831c8b4988e078fa50eb54520ac9181ca02acda9be74dcf2d03862bb547aba11389a8025fadc6771a6e13628dcb9a23809d65d8265f33e692724c0af89def4cfb12f67bdc3fb2c48f38c021c26e105d480162e8f7300dfe0d5b8e1945f1a0a8891c1d828b3c9d606763b4137a7536282870c2f297f5b1b12db616226fe42501c1604aef292fa78f7b9856398307174082e84fd5351635a0a824352c73340b53cbc041cd4fda9dfe2da63c0182f4b5e3398ed9debec5f3d369bda7bb58b5123625358ff825d940d823691454ac208ed7fa04a62272d06de690a2a8ba19103ea8a9747a972cc01a17f120c99a6e3a993854ebd513e5e9907255317c242528da31621298ab8d2e725ff49b48be7969324",
+ "fullLengthMarkIn" : 307800,
+ "fullLengthMarkOut" : 443000,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827776/3ecdae9b-b4f9-3492-9b16-4b2c7fa09706/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827776/3ecdae9b-b4f9-3492-9b16-4b2c7fa09706/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827778",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827778",
+ "title" : "Le Conseil fédéral propose des mesures pour mieux encadrer les grandes banques",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827777.image/16x9",
+ "imageTitle" : "Le Conseil fédéral propose des mesures pour mieux encadrer les grandes banques [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 215680,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 3,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Le Conseil fédéral propose des mesures pour mieux encadrer les grandes banques",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827778",
+ "ns_st_el" : "215680",
+ "ns_st_cl" : "215680",
+ "ns_st_sl" : "215680",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Le Conseil fédéral propose des mesures pour mieux encadrer les grandes banques",
+ "media_type" : "Video",
+ "media_segment_id" : "14827778",
+ "media_episode_length" : "216",
+ "media_segment_length" : "216",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827778",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$068c9a1aad2071b1$e23321f435dd224b565339c3e79a5d70bd795bbedbe5d325618e06d353e522e945f9a6e5ae4fe4c9329aed070a53cbf7ef2232c4357149b7049384c2b66f75c9b58605cc32d00fbbbb1505d14cae448a35655e772f00780b67538ac74c37306328a2cbb668663b24f71aff30803bafc8c6aea3028bee42335598ce904bdd084aed55eb69054d933ad063195a40121f833c54248ebeedf027957fb049d72f8817274cb395da9a58c4a74a9cf5ddef3090496ae6afcff92a9a9e0555dd3aba8d4dee4443b34599ca286d5dc788da5ecec07062afac5959bb997833d899b46bdbfd69c7aa1f5010354f223b2cd5afb77c930cafe2d3d9c0e421d2aeb5ed12b96c61e5a7937b40de5b44a0a85e67ceaad94e356ae3847fa4c24e98b915dd43507adc5e50f8885066bb68142b642641ac8e0648aae561a2e2e99d211551063be17003b7a779606668acc98beff509fc82bf1f8356b44c08e480ece9f1613651de6ea2e2e9b93a79b7953df9fd89d0a9f4162b",
+ "fullLengthMarkIn" : 443000,
+ "fullLengthMarkOut" : 658680,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827778/7b0ecad4-504d-3c45-9639-f5a8d1d6e3b0/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827778/7b0ecad4-504d-3c45-9639-f5a8d1d6e3b0/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827780",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827780",
+ "title" : "Mesures pour encadrer les grandes banques: réactions de de Cédric Wermuth et Pascal Broulis",
+ "description" : "Réactions de Cédric Wermuth, co-président du Parti socialiste suisse, et Pascal Broulis, conseiller aux Etats PLR vaudois.",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827779.image/16x9",
+ "imageTitle" : "Mesures pour encadrer les grandes banques: réactions de de Cédric Wermuth et Pascal Broulis [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 431720,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 4,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Mesures pour encadrer les grandes banques: réactions de de Cédric Wermuth et Pascal Broulis",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827780",
+ "ns_st_el" : "431720",
+ "ns_st_cl" : "431720",
+ "ns_st_sl" : "431720",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Mesures pour encadrer les grandes banques: réactions de de Cédric Wermuth et Pascal Broulis",
+ "media_type" : "Video",
+ "media_segment_id" : "14827780",
+ "media_episode_length" : "432",
+ "media_segment_length" : "432",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827780",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$6e3534588d6725f4$4703f9023b457157ed443aa7b95fa82dd3f7a70d60dabecced85efebda6f83a468e3375da9dae4a8327eedf7f62305e318b50cddb9d92d7d28119086670b06108021e18ce8d37e8e000630c47f9a94e4b601b2917d8693b7d997d15e9948468f97c491375e6cb40dc8409fcff1c60206d03c0397b889260eb9b0b159fdc8baa32e7d654cca711037d7ad651dbd52f22b84faf5d8ea350d53fd9f87490bb41b3aae02cf8f2c84b96bfc1ccbb5efd44fee7940c554539c19aeb08f4a6435b53a217412807d8c78f03c4b322b8586edb5c04f2fd25d7f54eccf063dc1ff02c07c3aef43ca0026718943d25f537b87eb7ff129eb63f1dd1ed271559817e92ef8f6d30074b4dc41dcb86b9699934b371400e70d95f1a144ff80155c73281d238dc95fa2ab7c18c030d3579c7b42b0c635194cc22ee249eea8323c710dcb3c2f12827c3b7d34cfeee716b29f6f603e6f7bc189dc40e620832d7891d7d35f489ea820626cb7ab263c0ca8ceaf0bd94b55910784",
+ "fullLengthMarkIn" : 658680,
+ "fullLengthMarkOut" : 1090400,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827780/1c34db09-0de5-3c67-acb0-017db627e3a0/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827780/1c34db09-0de5-3c67-acb0-017db627e3a0/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827782",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827782",
+ "title" : "Le recours à l'intelligence artificielle par l'armée israélienne: interview de Jean-Marc Rickli",
+ "description" : "Interview de Jean-Marc Rickli, responsable du département des risques mondiaux et émergents au centre genevois de politique de sécurité (GCSP).",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827781.image/16x9",
+ "imageTitle" : "Le recours à l'intelligence artificielle par l'armée israélienne: interview de Jean-Marc Rickli [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 389200,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 5,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Le recours à l'intelligence artificielle par l'armée israélienne: interview de Jean-Marc Rickli",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827782",
+ "ns_st_el" : "389200",
+ "ns_st_cl" : "389200",
+ "ns_st_sl" : "389200",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Le recours à l'intelligence artificielle par l'armée israélienne: interview de Jean-Marc Rickli",
+ "media_type" : "Video",
+ "media_segment_id" : "14827782",
+ "media_episode_length" : "389",
+ "media_segment_length" : "389",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827782",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$5ddcf9d04706d658$e5ae57d14ede33563e7155b90bdf29a6c962a6722fc91671678a47d0866d80da9a1b8b6d95d78bfaa81e5db63f24f9571b685ba492542696157005d4441dbd494e13bee4d890cccf7ca0758d0420fd9c72dc9570d3c76c68ab73177b640886b68d7ae30ba4718bc74fe08d53fb150b2d4604657cc8b5b5c0f8de1f5d565c6a8ac93c302d01f654a63e9ff2f38b9dde31e253bfa2a8cd32a540236ca0b4ff2dbb26cf4528666e711441b1dd80378263cbf03a096b0b38873988ea33d92fe86ae8b8a096f9e1fbf38d9f92d3b56664787d8d7ece67b8250d8597e111e3640a157e8d983bec6a8e9899ebc8e3e22ba950c0506d36f07fc5dc4e8dcee0a3141f8cbcfdf0c1a87d1e494d2b5942eac62dbd7ef09225761db130b1ded7de4df6bed9ae6f51f90ae2841462c7a15615bce14836d670d01b76e4ab134aacb619f38ad2f5765c49af7cec668978cca36e8dc99ec270921496cfdd879b27696fe348f7874c34887cfe881715a32724d9c9958b372e",
+ "fullLengthMarkIn" : 1090400,
+ "fullLengthMarkOut" : 1479600,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827782/11ab086f-9d6c-3d0f-9d2d-ab838ad3f19d/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827782/11ab086f-9d6c-3d0f-9d2d-ab838ad3f19d/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827784",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827784",
+ "title" : "Pacte migratoire européen: les Vingt-Sept durcissent les contrôles des arrivées aux frontières",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827783.image/16x9",
+ "imageTitle" : "Pacte migratoire européen: les Vingt-Sept durcissent les contrôles des arrivées aux frontières [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 146640,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 6,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Pacte migratoire européen: les Vingt-Sept durcissent les contrôles des arrivées aux frontières",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827784",
+ "ns_st_el" : "146640",
+ "ns_st_cl" : "146640",
+ "ns_st_sl" : "146640",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Pacte migratoire européen: les Vingt-Sept durcissent les contrôles des arrivées aux frontières",
+ "media_type" : "Video",
+ "media_segment_id" : "14827784",
+ "media_episode_length" : "147",
+ "media_segment_length" : "147",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827784",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$7a293f98d6d07f8b$c9d11b497a448ec03e333750802081d0b53616f8e53f7cdd51c6486ac2cba23360936e017cb4347b8bb4d72161855972c603454ddc5b432e8606034ef571d2314eb287246663590cd2c426d8f73732cac751f17d9fea844cb1501b6c503046bb7fef09fee75ba43919313bddd04a3724f21ee832e99193ef90b62fce096fa9906888f02cce712964ec13bf4be2cdb5b31d1dacb665d9cd007e1a3c74be7fd61377bf5fb65459fd3e0ddefd308e669e4f38a50c5bba6c3271d8aa8c618b9fa915fc57d8c8d83333b51866c5feab4344d50b447cfdef60655d506c59f2b496f9415eae94eda6c300f83ef4bdb76257aaac89d8dc50bb63fe06bc6f162bb872e22c97c9b733ce94cb82bfc94d5334e713f9f84cce3a332a8d6a95fec4c7c82cc7581b40c7476d6ea3e0debaf5578c0b460d340caf8f14d57e71c6fbe0e24d14370a8ebd9aa0c14b906a054ffbf70fc81163837f285654e44c708a6cfeb37308fa000db09ee396bfb3e7e1248ee2a97f98af",
+ "fullLengthMarkIn" : 1479600,
+ "fullLengthMarkOut" : 1626240,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827784/bdcfc85d-6017-3f28-8334-19fd74b1c4e3/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827784/bdcfc85d-6017-3f28-8334-19fd74b1c4e3/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827786",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827786",
+ "title" : "Jura: un ressortissant français s'est vu refuser la naturalisation suisse car il tondait sa pelouse le dimanche",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827785.image/16x9",
+ "imageTitle" : "Jura: un ressortissant français s'est vu refuser la naturalisation suisse car il tondait sa pelouse le dimanche [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 174080,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 7,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Jura: un ressortissant français s'est vu refuser la naturalisation suisse car il tondait sa pelouse le dimanche",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827786",
+ "ns_st_el" : "174080",
+ "ns_st_cl" : "174080",
+ "ns_st_sl" : "174080",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Jura: un ressortissant français s'est vu refuser la naturalisation suisse car il tondait sa pelouse le dimanche",
+ "media_type" : "Video",
+ "media_segment_id" : "14827786",
+ "media_episode_length" : "174",
+ "media_segment_length" : "174",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827786",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$b41d27c3cc43eac9$7b12fe99ba916fd8c5531b966fbc6f00cd1bf8601a8e86e4565b07dcfa760a57fed7fb1aba47c5bf171cb3f190976ed1556afdc765e79b6256dcb853289980d4f6b7ab672d940543579492b30ab510141e0060858cad4d9ef72dbc4ec91698013c0ceb9bfa1b57f7a8ce5a1f6102542868a0aaac711b75f8b08aa7760df8cd86dc880d13e4daecd814160711622fe496d2ab8dd0bcda0ed8f3fb1c3b7650ffc6e07e14199e06f3906a1624a81b0f8d607dc6903d2008825e549f086b003a9e25cdb20dd3f509dd2b29de1c1a43210e89fbab98d42a015106f9f45c088dffaf3aaf3b513c2a613ecb4231df79ecceeef35983e3d009bc253b50ab0e91fa46cd24532756471ea1a961db173b171f0b5d3ff59be4a62152804ed3aa866a7586ed6f40d9f02b051d7851e2e7a76677ef642dd29af0207775255393bc9383a5253832ae42afc022533b7eb4c9780d59235a9ed9b04239bdf011102d2735621e81865d0b86d7d700b01ba4fa5291e82e08717e",
+ "fullLengthMarkIn" : 1626240,
+ "fullLengthMarkOut" : 1800320,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827786/3324a962-78c5-32a3-a8be-6df0ff97db17/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827786/3324a962-78c5-32a3-a8be-6df0ff97db17/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827788",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827788",
+ "title" : "Copinage, népotisme: quelles règles s'appliquent dans l'administration?",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827787.image/16x9",
+ "imageTitle" : "Copinage, népotisme: quelles règles s'appliquent dans l'administration? [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 189960,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 8,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Copinage, népotisme: quelles règles s'appliquent dans l'administration?",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827788",
+ "ns_st_el" : "189960",
+ "ns_st_cl" : "189960",
+ "ns_st_sl" : "189960",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Copinage, népotisme: quelles règles s'appliquent dans l'administration?",
+ "media_type" : "Video",
+ "media_segment_id" : "14827788",
+ "media_episode_length" : "190",
+ "media_segment_length" : "190",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827788",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$cebd9d8e209f9f72$8f7e44e8842283b05aecaac5de32c1b31c6b54ccc538ebc1b493a1d30b484f78e50d1bced6c31655a4fbc1b54e9aa65d3171ad3243af84516d5d87405a7f1f7ccf2744bad835d7b15386bffda048b2e9cc2d63b6e091c4e33d71a2e47917403201bfe0d209409bb2bbb6a3536a7f756e95a64b53d2261df72dd2d38bec51691112d9f2b2838a810d6d033e60709f8cb19447f450e21cac720289d700d4ada620469120134802b2421c8c4f48ea06adfbeeff6af6291166f2c21ab253aab3f0195c545d2a456044adf15784b42c990e5ad43b2cfa47007f6e439859ab27ee2c769a92ce1ec02c40f003dc76fa6e16cd1e1a80f220cfbc821ffb8bcee90243b635a8c7c0af1eaf67bf1d34293a3c2247a37c34570e4c671fd80733b9c6e46964fa353117c642ae975db7b31dbaf05dc7f9842f4039e31e7d3a6fc2e3094cf0e0635ec714a84b3636e0bbf99e23493cd86e6696894239f3d32e9b18581d7d84a51d3d9fb8484a369156bc7881a3e14aeb94",
+ "fullLengthMarkIn" : 1800320,
+ "fullLengthMarkOut" : 1990280,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827788/a7f8c778-9de2-38a5-8b1a-92c668746054/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827788/a7f8c778-9de2-38a5-8b1a-92c668746054/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827790",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827790",
+ "title" : "Hockey sur glace: fin de saison pour Fribourg-Gottéron ce soir face à Lausanne?",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827789.image/16x9",
+ "imageTitle" : "Hockey sur glace: fin de saison pour Fribourg-Gottéron ce soir face à Lausanne? [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 131840,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 9,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Hockey sur glace: fin de saison pour Fribourg-Gottéron ce soir face à Lausanne?",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827790",
+ "ns_st_el" : "131840",
+ "ns_st_cl" : "131840",
+ "ns_st_sl" : "131840",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Hockey sur glace: fin de saison pour Fribourg-Gottéron ce soir face à Lausanne?",
+ "media_type" : "Video",
+ "media_segment_id" : "14827790",
+ "media_episode_length" : "132",
+ "media_segment_length" : "132",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827790",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$2d3cf00ff55412b5$2f4336501473190b1fe37356cd47edb03dc54a41f59a9a9459d4046610427b68b36280b927492d45acb239c29e076010beead9d7429ed417b9dc5a003f4b8e6865a8dfe7df6e8419774e038639aa069e0037122289c33e85cd5682eb199f2049d49b7166d6aef9130c8d7bd0bb08d24ddd0d63fe6766468ad28c479c95cc57d66e1a7aa2f58bf2298461f77688345d40d7438779381639a115e1f7ea672ff445c7efeec82f4fd16f4bb161d4f3021d277eb8a788ba7273d7e1a0e5738a87c364a10115395b40236f502e8acb4cf2e2b9817b23b79a15bc7a7ada2934fc23ed7aa7f5e560ad76c751eb409c7606d3467e6a9ff1dea3790170c3d6668330bee3e09f970ebf424697f73200c6f7c2c03ab49aee60d2c7510fb3cfb6070387324d49dceddb4c6b0bb03f8e7fbfde18c987fa2816f0846da671aeafae890e7681ec379afff649cd0b9a9395209b93373edb270b01c38e1f3e9fb180b6a31366042297c4dc1d5ffae5cc8c7a2c48152c8e1e03",
+ "fullLengthMarkIn" : 2004520,
+ "fullLengthMarkOut" : 2136360,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827790/f3ee717e-1234-339d-b370-2b91e09e33c4/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827790/f3ee717e-1234-339d-b370-2b91e09e33c4/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827792",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827792",
+ "title" : "Le grand débat - Faut-il sauver les jobs d'été?",
+ "description" : "Débat entre les députés genevois Caroline Renold (PS) et Jean-Marc Guinchard (Centre), et Sylvain Weber, professeur à la Haute école de gestion de Genève.",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827791.image/16x9",
+ "imageTitle" : "Le grand débat - Faut-il sauver les jobs d'été? [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 1071000,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 10,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Le grand débat - Faut-il sauver les jobs d'été?",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827792",
+ "ns_st_el" : "1071000",
+ "ns_st_cl" : "1071000",
+ "ns_st_sl" : "1071000",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc12",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Le grand débat - Faut-il sauver les jobs d'été?",
+ "media_type" : "Video",
+ "media_segment_id" : "14827792",
+ "media_episode_length" : "1071",
+ "media_segment_length" : "1071",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "long",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827792",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$45a21bdfa3fde5e1$7ad9a53938ab0577fbcca7109ec84f74b0c6990bb249ddab37baf116d840a4b2373efbe794b027213f30a4df44105572d370af89d17416c5693289ce3238e8f81058612a823918ea4bf8c93f364f2f308d841cdfbdc5d7b7e71dc54c84d5e1f115d01a82e9c250623c9cc209b68f9f6aa9afe19a2b35f8641368fc375fb3ba874243da940b0be273d334f290f22d9545d60c9193c00bc5d73e76250f8fe988839d38e5ca596c6c3f4c45d9a6fe1a5a0cd0323d9f1cf19ec14941971abd9346ecc0183ebe4905debd36b9e592383e16209b0fed0f664fc3babdff3d5d3c9d618b2249990e1a3fe5e6b635bf6967c2186e696a44f7b2bf7ea1aa23230cc6c7411e1d477edd85d80a4d6660dabdf9e621960e833c8914694c1b0cc808ec214b7e96b8fd62bd1785c1bb2012635b3bb37185e537400cf4923ec62b41e2507629ef9c35434efc4492be56d7a23325167f83f7895482185662547452c90f20db79b720161a89008effa74ef43b66484797e95e",
+ "fullLengthMarkIn" : 2142080,
+ "fullLengthMarkOut" : 3213080,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827792/4def5e57-2634-3f0f-97a8-cd19b96bd2ef/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827792/4def5e57-2634-3f0f-97a8-cd19b96bd2ef/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14827794",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:14827794",
+ "title" : "Forum des idées - L'Université de Lausanne veut rendre la science plus utile aux citoyens",
+ "description" : "Interview de Cléolia Sabot, coordinatrice d'Interface, le Fonds de soutien à la recherche partenariale de l'Unil.",
+ "imageUrl" : "https://www.rts.ch/2024/04/10/19/51/14827793.image/16x9",
+ "imageTitle" : "Forum des idées - L'Université de Lausanne veut rendre la science plus utile aux citoyens [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 335160,
+ "validFrom" : "2024-04-10T19:00:00+02:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "position" : 11,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "Forum des idées - L'Université de Lausanne veut rendre la science plus utile aux citoyens",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "14827794",
+ "ns_st_el" : "335160",
+ "ns_st_cl" : "335160",
+ "ns_st_sl" : "335160",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc11",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "Forum des idées - L'Université de Lausanne veut rendre la science plus utile aux citoyens",
+ "media_type" : "Video",
+ "media_segment_id" : "14827794",
+ "media_episode_length" : "335",
+ "media_segment_length" : "335",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:14827794",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$1be907c9fe4c81d9$1327cda5d0b97592ab053853b4aebb4e163333dffe62d17cbc2dbe50c87fc6c252dca6a57b91d22c9fe05891ebd8e9ac8cfe8f6d906826259f49ef35c7cec418e30e88b451677989f78a15ab3c40c8533a2b9f0abe21a102c653ebc5ac32e1aa7b65b553895923f1c9f6d601a6a9ca50a6ad5b19ff867063b6d1b10746d82d81c215146081c26458572f242ded2833b64dddc959bd9947f0adf80cef893a4fb9046077db73f4d265d713d956d73bfde3cfb5251e41bc38f0f1e61260f4265a3609b52c418ab9c1aaf7e35db1e952b5b33a21ba49d937955056823c2dea32a493cf2df63b348d303d97afde95e7b55a24546102b6341bd4b81bf8183c842c01e6b30532a296a1e8076aadbea22635b29dc37272b3690df4b4253fc70db8015310e8d07a9c03fe6354734fad839bb164ad7d0862b67f048df4244f5084862e5e3ea81949b669e0db43e50b479c7ee8ec10163ed05c88b07d468df463171ce5bc8b24caa0c159587aa47e90e85868b7d092",
+ "fullLengthMarkIn" : 3218240,
+ "fullLengthMarkOut" : 3553400,
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/14827794/8e8efcc7-37f6-3078-bf7e-5894324b29e6/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/14827794/8e8efcc7-37f6-3078-bf7e-5894324b29e6/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9"
+ }, {
+ "id" : "14812522",
+ "mediaType" : "AUDIO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:audio:14812522",
+ "title" : "Forum - Présenté par Thibaut Schaller et Renaud Malik",
+ "imageUrl" : "https://www.rts.ch/2023/09/28/17/49/1784333.image/16x9",
+ "imageTitle" : "Logo Forum [RTS]",
+ "type" : "CLIP",
+ "date" : "2024-04-10T18:00:00+02:00",
+ "duration" : 3600000,
+ "podcastHdUrl" : "https://rts-aod-dd.akamaized.net/ww/14812522/b3fde219-dd00-3bfa-a085-a90ee4c9969a.mp3",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:14827796",
+ "position" : 12,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Forum - Présenté par Thibaut Schaller et Renaud Malik",
+ "media_type" : "Audio",
+ "media_segment_id" : "14812522",
+ "media_episode_length" : "3600",
+ "media_segment_length" : "3600",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "long",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:audio:14812522",
+ "media_sub_set_id" : "CLIP",
+ "media_topic_list" : "urn:rts:topic:tv:49683,urn:rts:topic:tv:16202"
+ },
+ "eventData" : "$94d0a22d6ce4f018$cce0e3446e92b4d9024e6a2e2e390087e63c86fd4586474c28ce7ece2806db8ddc68336257833a0c54c98ed43e6e283e8f321b56b0ee6bd47f56acfe2f12ffc438ca2c71ba9406d02c1a0be9c0f4f929f54107d9065f94b78b464aa90711ef056b97eebad09da193c1f16040d7df5d7a0983166d7139e57dbfed5951dd29c5761969b9412266cd2baa7f8fd0efbedb05ed2489d61dc08ed1c51768e997c989e69d055a645d3ed277c2a2837d7317141bb6e1bb2a2c204d4574a0b99be034c7af81e7331c459f48b17725aca6ad1fe179035ce3d61853623a06306b30b89f48f69932de9a0e61829d9b5b678ce0e662a9378a9a2fa10914586793b6efbabb9bc91863306cc39b1534ce39fc17632f59c2461d1ccfded9504fa7810e6c2b4f2cc3c3932fa54d8c4e262ac6fa82b358542e55b07302655a89e6eb5485a5b6dcf2e9febc6167095269892f133b74bea6596c173fdc8cfef634e6e8d0fb1f4982e4c81aded9d224122ded855676dbe6d59206",
+ "fullLengthMarkIn" : 0,
+ "fullLengthMarkOut" : 0,
+ "resourceList" : [ {
+ "url" : "https://rts-aod-dd.akamaized.net/ww/14812522/b3fde219-dd00-3bfa-a085-a90ee4c9969a.mp3",
+ "quality" : "HQ",
+ "protocol" : "HTTPS",
+ "encoding" : "MP3",
+ "mimeType" : "audio/mpeg",
+ "presentation" : "DEFAULT",
+ "streaming" : "PROGRESSIVE",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "NONE",
+ "audioCodec" : "MP3",
+ "videoCodec" : "NONE",
+ "tokenType" : "NONE",
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HQ",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-aod-dd.akamaized.net/ww/14812522/b3fde219-dd00-3bfa-a085-a90ee4c9969a.mp3"
+ }
+ } ]
+ } ],
+ "topicList" : [ {
+ "id" : "49683",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:49683",
+ "title" : "Forum"
+ }, {
+ "id" : "16202",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:16202",
+ "title" : "La 1ère"
+ } ],
+ "analyticsData" : {
+ "srg_pr_id" : "14718074",
+ "srg_plid" : "9933104",
+ "ns_st_pl" : "Forum",
+ "ns_st_pr" : "Forum du 10.04.2024",
+ "ns_st_dt" : "2024-04-10",
+ "ns_st_ddt" : "2024-04-10",
+ "ns_st_tdt" : "2024-04-10",
+ "ns_st_tm" : "18:00",
+ "ns_st_tep" : "500531747",
+ "ns_st_li" : "0",
+ "ns_st_stc" : "0867",
+ "ns_st_st" : "RTS Online",
+ "ns_st_tpr" : "9933104",
+ "ns_st_en" : "*null",
+ "ns_st_ge" : "*null",
+ "ns_st_ia" : "*null",
+ "ns_st_ce" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc",
+ "srg_unit" : "RTS",
+ "srg_c1" : "full",
+ "srg_c2" : "video_la-1ere_forum",
+ "srg_c3" : "RTS 2",
+ "srg_tv_id" : "500531747",
+ "srg_aod_prid" : "14718074"
+ },
+ "analyticsMetadata" : {
+ "media_episode_id" : "14718074",
+ "media_show_id" : "9933104",
+ "media_show" : "Forum",
+ "media_episode" : "Forum du 10.04.2024",
+ "media_is_livestream" : "false",
+ "media_full_length" : "full",
+ "media_enterprise_units" : "RTS",
+ "media_joker1" : "full",
+ "media_joker2" : "video_la-1ere_forum",
+ "media_joker3" : "RTS 2",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_tv_id" : "500531747",
+ "media_thumbnail" : "https://www.rts.ch/2024/04/10/19/51/14827795.image/16x9/scale/width/344",
+ "media_publication_date" : "2024-04-10",
+ "media_publication_time" : "19:00:00",
+ "media_publication_datetime" : "2024-04-10T19:00:00+02:00",
+ "media_tv_date" : "2024-04-10",
+ "media_tv_time" : "18:00:00",
+ "media_tv_datetime" : "2024-04-10T18:00:00+02:00",
+ "media_content_group" : "Forum,La 1ère",
+ "media_channel_id" : "d7dfff28deee44e1d3c49a3d37d36d492b29671b",
+ "media_channel_cs" : "0867",
+ "media_channel_name" : "RTS 2",
+ "media_since_publication_d" : "11",
+ "media_since_publication_h" : "278"
+ }
+}
\ No newline at end of file
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_onDemand.dataset/Contents.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_onDemand.dataset/Contents.json
new file mode 100644
index 00000000..56c7e33d
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_onDemand.dataset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "data" : [
+ {
+ "filename" : "urn_rts_video_13360574.json",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_onDemand.dataset/urn_rts_video_13360574.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_onDemand.dataset/urn_rts_video_13360574.json
new file mode 100644
index 00000000..7fe752b9
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_onDemand.dataset/urn_rts_video_13360574.json
@@ -0,0 +1,210 @@
+{
+ "chapterUrn" : "urn:rts:video:13360574",
+ "episode" : {
+ "number": 12,
+ "seasonNumber": 2,
+ "id" : "13360565",
+ "title" : "Yadebat",
+ "publishedDate" : "2022-09-05T16:30:00+02:00",
+ "imageUrl" : "https://www.rts.ch/2022/09/05/18/20/13360573.image/16x9",
+ "imageTitle" : "On réunit des ex après leur rupture [RTS]"
+ },
+ "show" : {
+ "id" : "10174267",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:show:tv:10174267",
+ "title" : "Yadebat",
+ "lead" : "Une série qui te donne la parole, pour laisser entendre ton avis sur les débats de société animée par Melissa.",
+ "description" : "Une série qui te donne la parole, pour laisser entendre ton avis sur les débats de société animée par Melissa.",
+ "imageUrl" : "https://www.rts.ch/2020/01/10/11/14/10520588.image/16x9",
+ "imageTitle" : "Yadebat - Tataki [RTS]",
+ "posterImageUrl" : "https://ws.srf.ch/asset/image/audio/e0322b37-5697-474d-93ac-19a4044a6a24/POSTER.jpg",
+ "posterImageIsFallbackUrl" : true,
+ "audioDescriptionAvailable" : false,
+ "subtitlesAvailable" : false,
+ "multiAudioLanguagesAvailable" : false,
+ "topicList" : [ {
+ "id" : "59952",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:59952",
+ "title" : "Yadebat"
+ }, {
+ "id" : "54537",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:54537",
+ "title" : "Tataki"
+ } ],
+ "allowIndexing" : false
+ },
+ "chapterList" : [ {
+ "id" : "13360574",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13360574",
+ "title" : "On réunit des ex après leur rupture",
+ "description" : "Dans ce nouvel épisode de YADEBAT, Mélissa réunit 3 couples qui se sont séparés récemment. Elles les a questionné en face à face pour connaître leurs différents ressentis et réactions.",
+ "imageUrl" : "https://www.rts.ch/2022/09/05/18/20/13360573.image/16x9",
+ "imageTitle" : "On réunit des ex après leur rupture [RTS]",
+ "type" : "EPISODE",
+ "date" : "2022-09-05T16:30:00+02:00",
+ "duration" : 902360,
+ "validFrom" : "2022-09-05T16:30:00+02:00",
+ "validTo" : "2100-01-01T23:59:59+01:00",
+ "playableAbroad" : true,
+ "socialCountList" : [ {
+ "key" : "srgView",
+ "value" : 17
+ }, {
+ "key" : "srgLike",
+ "value" : 0
+ }, {
+ "key" : "fbShare",
+ "value" : 0
+ }, {
+ "key" : "twitterShare",
+ "value" : 0
+ }, {
+ "key" : "googleShare",
+ "value" : 0
+ }, {
+ "key" : "whatsAppShare",
+ "value" : 0
+ } ],
+ "displayable" : true,
+ "position" : 0,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "On réunit des ex après leur rupture",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "13360574",
+ "ns_st_el" : "902360",
+ "ns_st_cl" : "902360",
+ "ns_st_sl" : "902360",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "1",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc12",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "eo",
+ "ns_st_cmt" : "ec"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "On réunit des ex après leur rupture",
+ "media_type" : "Video",
+ "media_segment_id" : "13360574",
+ "media_episode_length" : "902",
+ "media_segment_length" : "902",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "1",
+ "media_duration_category" : "long",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "true",
+ "media_production_source" : "produced.for.web",
+ "media_urn" : "urn:rts:video:13360574"
+ },
+ "eventData" : "$27549332a83ca6ac$64b181b51953d6ed48de11986513e2f93922eb3d4315e6d5ad8189e1fe38d933c011ba7ded29e3d757ba1e566e76d65d97c8f0cd0735cc47b1cb3e5cf091c89c8d6c18ff31e19e3d7509cbf826c0c156fd10b8908ebe481aaf7282de102e92342ffb36b52df58453b40d64883f720fb3eddd38b595ddf6961acc4bc33abb3f2c49b7d90b52a35239f0209caa3ebc532e6a95315bd382bc08f2b78af2ec23c3f7e7917de924cb7f85b8aedac2fdafd027fe3880e07f3a0ba05f43d0ce601a1d2c7b756012c8820e12eef32fb9c0e1f532cce31cf1be738a9d6c05555857700fc5e1f0e1bd9886f06c55f5e731a66daa09be035e5ef53a4da159a7d3943a67ebaa1ac1302ad3ff046739eb185d78737e1543e7788d4edd9858af0e6846460106e954e8f1176cf60876aad36646c11a3b3a824ab54433f99c4576accea86e2b853c",
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/13360574/447e0958-42a8-3bdd-8365-95d54031e605/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/13360574/447e0958-42a8-3bdd-8365-95d54031e605/master.m3u8"
+ }
+ } ],
+ "aspectRatio" : "16:9",
+ "spriteSheet" : {
+ "urn" : "urn:rts:video:13360574",
+ "rows" : 23,
+ "columns" : 20,
+ "thumbnailHeight" : 84,
+ "thumbnailWidth" : 150,
+ "interval" : 2000,
+ "url" : "https://il.srgssr.ch/spritesheet/urn/rts/video/13360574/sprite-13360574.jpeg"
+ }
+ } ],
+ "topicList" : [ {
+ "id" : "59952",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:59952",
+ "title" : "Yadebat"
+ }, {
+ "id" : "54537",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:54537",
+ "title" : "Tataki"
+ } ],
+ "analyticsData" : {
+ "srg_pr_id" : "13360565",
+ "srg_plid" : "10174267",
+ "ns_st_pl" : "Yadebat",
+ "ns_st_pr" : "Yadebat du 05.09.2022",
+ "ns_st_dt" : "2022-09-05",
+ "ns_st_ddt" : "2022-09-05",
+ "ns_st_tdt" : "*null",
+ "ns_st_tm" : "*null",
+ "ns_st_tep" : "500418168",
+ "ns_st_li" : "0",
+ "ns_st_stc" : "0867",
+ "ns_st_st" : "RTS Online",
+ "ns_st_tpr" : "10174267",
+ "ns_st_en" : "*null",
+ "ns_st_ge" : "*null",
+ "ns_st_ia" : "*null",
+ "ns_st_ce" : "1",
+ "ns_st_cdm" : "eo",
+ "ns_st_cmt" : "ec",
+ "srg_unit" : "RTS",
+ "srg_c1" : "full",
+ "srg_c2" : "video_tataki_yadebat",
+ "srg_c3" : "RTS.ch",
+ "srg_tv_id" : "500418168"
+ },
+ "analyticsMetadata" : {
+ "media_episode_id" : "13360565",
+ "media_show_id" : "10174267",
+ "media_show" : "Yadebat",
+ "media_episode" : "Yadebat du 05.09.2022",
+ "media_is_livestream" : "false",
+ "media_full_length" : "full",
+ "media_enterprise_units" : "RTS",
+ "media_joker1" : "full",
+ "media_joker2" : "video_tataki_yadebat",
+ "media_joker3" : "RTS.ch",
+ "media_is_web_only" : "true",
+ "media_production_source" : "produced.for.web",
+ "media_tv_id" : "500418168",
+ "media_thumbnail" : "https://www.rts.ch/2022/09/05/18/20/13360573.image/16x9/scale/width/344",
+ "media_publication_date" : "2022-09-05",
+ "media_publication_time" : "16:30:00",
+ "media_publication_datetime" : "2022-09-05T16:30:00+02:00",
+ "media_content_group" : "Yadebat,Tataki",
+ "media_since_publication_d" : "0",
+ "media_since_publication_h" : "19"
+ }
+}
\ No newline at end of file
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_redundant.dataset/Contents.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_redundant.dataset/Contents.json
new file mode 100644
index 00000000..414c11c3
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_redundant.dataset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "data" : [
+ {
+ "filename" : "urn_rts_video_13763072.json",
+ "idiom" : "universal",
+ "universal-type-identifier" : "public.json"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_redundant.dataset/urn_rts_video_13763072.json b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_redundant.dataset/urn_rts_video_13763072.json
new file mode 100644
index 00000000..c0f2de89
--- /dev/null
+++ b/Tests/CoreBusinessTests/Resources.xcassets/MediaComposition_redundant.dataset/urn_rts_video_13763072.json
@@ -0,0 +1,690 @@
+{
+ "chapterUrn" : "urn:rts:video:13763072",
+ "episode" : {
+ "id" : "13646015",
+ "title" : "19h30",
+ "publishedDate" : "2023-02-06T19:30:00+01:00",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763071.image/16x9",
+ "imageTitle" : "19h30 [RTS]"
+ },
+ "show" : {
+ "id" : "105932",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:show:tv:105932",
+ "title" : "19h30",
+ "lead" : "L'édition du soir du téléjournal.",
+ "imageUrl" : "https://www.rts.ch/2019/08/28/11/33/10667272.image/16x9",
+ "imageTitle" : "RTS Info - Le 19h30, avec nouveau logo RTS Info (la mise en ligne le lundi 26 août 2019) [RTS]",
+ "bannerImageUrl" : "https://www.rts.ch/2019/08/28/11/33/10667272.image/3x1",
+ "posterImageUrl" : "https://www.rts.ch/2021/08/05/18/12/12396566.image/2x3",
+ "posterImageIsFallbackUrl" : false,
+ "primaryChannelId" : "143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "primaryChannelUrn" : "urn:rts:channel:tv:143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "availableAudioLanguageList" : [ {
+ "locale" : "fr",
+ "language" : "Français"
+ } ],
+ "availableVideoQualityList" : [ "SD", "HD" ],
+ "audioDescriptionAvailable" : false,
+ "subtitlesAvailable" : true,
+ "multiAudioLanguagesAvailable" : false,
+ "topicList" : [ {
+ "id" : "908",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:908",
+ "title" : "19h30"
+ }, {
+ "id" : "904",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:904",
+ "title" : "Vidéos"
+ }, {
+ "id" : "665",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:665",
+ "title" : "Info"
+ } ],
+ "allowIndexing" : true
+ },
+ "channel" : {
+ "id" : "143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:channel:tv:143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "title" : "RTS 1",
+ "imageUrl" : "https://www.rts.ch/2019/08/28/11/33/10667272.image/16x9",
+ "imageUrlRaw" : "https://il.srgssr.ch/image-service/dynamic/8eebe5.svg",
+ "imageTitle" : "RTS Info - Le 19h30, avec nouveau logo RTS Info (la mise en ligne le lundi 26 août 2019) [RTS]",
+ "transmission" : "TV"
+ },
+ "chapterList" : [ {
+ "id" : "13763072",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763072",
+ "title" : "19h30",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763071.image/16x9",
+ "imageTitle" : "19h30 [RTS]",
+ "type" : "EPISODE",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 1857560,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "socialCountList" : [ {
+ "key" : "srgView",
+ "value" : 13340
+ }, {
+ "key" : "srgLike",
+ "value" : 0
+ }, {
+ "key" : "fbShare",
+ "value" : 1
+ }, {
+ "key" : "twitterShare",
+ "value" : 0
+ }, {
+ "key" : "googleShare",
+ "value" : 0
+ }, {
+ "key" : "whatsAppShare",
+ "value" : 34
+ } ],
+ "displayable" : true,
+ "position" : 0,
+ "noEmbed" : false,
+ "analyticsData" : {
+ "ns_st_ep" : "19h30",
+ "ns_st_ty" : "Video",
+ "ns_st_ci" : "13763072",
+ "ns_st_el" : "1857560",
+ "ns_st_cl" : "1857560",
+ "ns_st_sl" : "1857560",
+ "srg_mgeobl" : "false",
+ "ns_st_tp" : "13",
+ "ns_st_cn" : "1",
+ "ns_st_ct" : "vc12",
+ "ns_st_pn" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc"
+ },
+ "analyticsMetadata" : {
+ "media_segment" : "19h30",
+ "media_type" : "Video",
+ "media_segment_id" : "13763072",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "1858",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "long",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763072"
+ },
+ "eventData" : "$9a6505bcccb854bf$a0ec7b2518b7ce6260492f932bbe32902090d850691731a263a076b740edfd09021e43de81ef4cbec8fd112788116eec867927a2eaea7aed9f5df592d48fed9209a004578d1192ac68df0f063ca108cee4c7e783890e1c1af04fdc95de08a3515919f0910f4804d6d0f6d90182f46894a40c6f254b132655c1d4e7c2a532312a9999a945b0d5edb4f21bbe1f7e7f12cc7a484b000984d395b8ac3f3222433004536c0a7233874ef4ae80cbc4d6f5dc3e9952a8ad986666021bb3b9849ae83b86b163cc7e0ef8617f7cfabac9d12e649ad4dc395a4f8c5e12ec9b865d7d1ae28802977ff0d268032cf7ef7209711b75459705353edf342f05f01a5dcf3853dfb2e46bb7adb5852fc6d9ca115877b3d08a22b3a822c751ee88b0c279dcdadf16604b3c7c73cb2f8e58156cd2de4b78cd1d6fe0f57b400088a5a892d365086f75e3ce0dcf35fe7af7bf6221a679b639ec8141ff5d019cbf4cb520663f95fd1d93c52c4edab3440fdfcb1d4a27b4d442f8cf",
+ "resourceList" : [ {
+ "url" : "https://rts-vod-amd.akamaized.net/ww/13763072/11def5d2-733d-3f82-bb0a-90492ff637d2/master.m3u8",
+ "quality" : "HD",
+ "protocol" : "HLS",
+ "encoding" : "H264",
+ "mimeType" : "application/x-mpegURL",
+ "presentation" : "DEFAULT",
+ "streaming" : "HLS",
+ "dvr" : false,
+ "live" : false,
+ "mediaContainer" : "FMP4",
+ "audioCodec" : "AAC",
+ "videoCodec" : "H264",
+ "tokenType" : "NONE",
+ "audioTrackList" : [ {
+ "locale" : "fr",
+ "language" : "Français",
+ "source" : "HLS"
+ } ],
+ "subtitleInformationList" : [ {
+ "locale" : "fr",
+ "language" : "Français (SDH)",
+ "source" : "HLS",
+ "type" : "SDH"
+ } ],
+ "analyticsData" : {
+ "srg_mqual" : "HD",
+ "srg_mpres" : "DEFAULT"
+ },
+ "analyticsMetadata" : {
+ "media_streaming_quality" : "HD",
+ "media_special_format" : "DEFAULT",
+ "media_url" : "https://rts-vod-amd.akamaized.net/ww/13763072/11def5d2-733d-3f82-bb0a-90492ff637d2/master.m3u8"
+ }
+ } ],
+ "segmentList" : [ {
+ "id" : "13763046",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763046",
+ "title" : "L'est de la Turquie dévasté par un séisme de 7,8 Le bilan pourrait atteindre plusieurs milliers de morts.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763036.image/16x9",
+ "imageTitle" : "L'est de la Turquie dévasté par un séisme de 7,8 Le bilan pourrait atteindre plusieurs milliers de morts. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 131840,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 1,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "L'est de la Turquie dévasté par un séisme de 7,8 Le bilan pourrait atteindre plusieurs milliers de morts.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763046",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "132",
+ "media_number_of_segment_selected" : "1",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763046"
+ },
+ "eventData" : "$b6e9570e7c7c008b$b4b2b89162a5acc3d5e1a659fcdc259315f6fd1b5a19421d31a9a21dc075474b61de92c4cfca39722266b88e2925cb5eb10604e19c9d21dd67ff275340d75dd0a18c55c053ab20150494704581ca4b4b6368f51a8abd08dd432b1a088564873db897ffc9309bec2b48278d935942394d1ac3cc7b08a8a447ec96de6ad790dfaf6473b176e75df4b2f7fd5c32fbc9a5edb0422be37476f62c8d842980a63b7555a1414fdec97a2a1f3617b4ac1fe37b071e01fc1f5056f6b2e8744155f50e46fa0cfe5c0e14a3d121e2da0f152ef9d69eb575d3785858bc207d9082e6dec5e7dfa0ac4c11602a275b6f9e4b5a74a813f2e1796915e45b75e37c1a88c5037e4f5a3b40781b5daac89cfe065b4b167f43068d5bcc1116cc39ec76beaa314ac488d3818e4d4589bbb13796cd7e981e240bcfd7db0cfe9cbb3272e0a3a0190f06356546f1e695f64ede1f7daac3ca0128895bf02bd1cfc576b71db7dcbc403b5cdee266fdc579a35ee688770323980a976e9a",
+ "markIn" : 101360,
+ "markOut" : 233200
+ }, {
+ "id" : "13763048",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763048",
+ "title" : "Tremblement de terre en Turquie et en Syrie : les explications de Mayalen de Castelbajac, correspondante en Turquie",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763047.image/16x9",
+ "imageTitle" : "Tremblement de terre en Turquie et en Syrie : les explications de Mayalen de Castelbajac, correspondante en Turquie [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 156840,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 2,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Tremblement de terre en Turquie et en Syrie : les explications de Mayalen de Castelbajac, correspondante en Turquie",
+ "media_type" : "Video",
+ "media_segment_id" : "13763048",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "157",
+ "media_number_of_segment_selected" : "2",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763048"
+ },
+ "eventData" : "$16a882befb748245$5aee998923fd67269f5f08d0d1fadd76d80802de2d0a37a81b9a6717c04d4c0f3abe45a31eec7fd840e415b4f7754c8fa9cc6c9d2b151c54d4339fa875f8d9825111ae41e913a219f1d52a27e817949c4bef16015726f7e14940f1756909338ac60c9fe606a8fc03ea972d111a45c3c7ef29474d1d9a9d1804be0d79ca4aedbd50b7dfcadbca48e82eed99a5d8d7059d46c49f6fda33bb8aa075d11aaf1e76c8cfff482715b0804b6dafaa97e871a5ab8bbc1e9a00c0aa0b6048a739544ab53710afc9bad895836701d9cb63ee0f7c38b23a0a74ed545cfd91d1e925296c4e93ac6d9d5ef4f266491e660e036efd5e5da1b25e4bbd1d0f7e2e7cbdaae0dae09999c5df2cc2c5afa232ced5c3cf924a909e0703e5ae8fe1737cdb5e1b2a119c772b433e00a328b5f3896f67a127a2a16167b0ced53377fdeaca1e9d7738ee7d2aeaaf5a1d63e4960efa45823452ffbe44d8aa77d5bb9369b45ae3e4a584b30be93a13139d069bfe017e0ddf93463b0c4b",
+ "markIn" : 233200,
+ "markOut" : 390040
+ }, {
+ "id" : "13763050",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763050",
+ "title" : "En Syrie, le tremblement de terre qui a frappé le pays lundi a touché une région déjà ravagée par la guerre civile.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763049.image/16x9",
+ "imageTitle" : "En Syrie, le tremblement de terre qui a frappé le pays lundi a touché une région déjà ravagée par la guerre civile. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 119160,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 3,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "En Syrie, le tremblement de terre qui a frappé le pays lundi a touché une région déjà ravagée par la guerre civile.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763050",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "119",
+ "media_number_of_segment_selected" : "3",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763050"
+ },
+ "eventData" : "$00cf3e37b9871701$24ad456ebd36455798644ce1372a18e8d12d9d9b5accfa63ace5251bee2c67447b0aad1a07d0c881363adaf5ada12655ed9d8f36824338a57262908f068dfcbc58825092f9cf528fe435a7bc531700aab0215d3f59609e1217682de7fa93830afa0b82c561cec4ee006793772e56c18dbf64aa3009059dd1fcb0ae390a298d9b48ddb0995bf36aa7fa5a540eab7a81d814a9d6c35e4e2eaff212e6b32a16d2787d6d0a7ef8fb3c1b24d7f7039426b336e85ace49b973d37df9db3e4aa995f04ee5988b0dcae50fafe0cd567d0a5acbb37bd7697141013597f1edbb296ee9902faccb91d820b2c42411beae9d31158ade8999e129a3211951de6787ee88f123c9d4fc6ca6479700a60c53720f36d20234f69321813935acf74137b5fde0838a8552b46b03c5e7e6926f268c71f278a8b70725cdc491948de9200b0de5893df4da7a964ab8465b68847ab01b80fc06e120cd8d87250d62ebc9f71e20b880fe3fe4365029cd0677ee7aec2281b3cfeee538",
+ "markIn" : 390040,
+ "markOut" : 509200
+ }, {
+ "id" : "13763052",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763052",
+ "title" : "Tremblement de terre en Turquie et en Syrie : La Suisse a annoncé qu’elle allait envoyer des équipes et des chiens de sauvetage. Ils doivent s’envoler lundi soir pour la Turquie.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763051.image/16x9",
+ "imageTitle" : "Tremblement de terre en Turquie et en Syrie : La Suisse a annoncé qu’elle allait envoyer des équipes et des chiens de sauvetage. Ils doivent s’envoler lundi soir pour la Turquie. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 105080,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 4,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Tremblement de terre en Turquie et en Syrie : La Suisse a annoncé qu’elle allait envoyer des équipes et des chiens de sauvetage. Ils doivent s’envoler lundi soir pour la Turquie.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763052",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "105",
+ "media_number_of_segment_selected" : "4",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763052"
+ },
+ "eventData" : "$459879157b1c695b$dcdf6794f1f24da7e77c37e9debba2aa96d38384400cbf1db6a9b821d960c3b40b183a3392ccfd2860649c70e37dfc0feb55db43644a75386ffca3a115815e924028dcccbabfefa4722c3e20ac42f57dd7b6dfab5f6e630cd97c268d682a4fc10a2cd6ff00eba8e3da8488ca7fac957dd2f90e1c2ca93ec7fcbaa9e083026b50a1c3fbcf1751f0968ddc5f831c6ec638f8bf18fb8c33b325cf8953d2df6f2176cfa86323c79687955c6bc8ea1fd082a81f883d574f8a37d5d6442a754821bf1a31f97e8494c93ed8a4e03c21a1019988e282e64ad98eca4e1d2a49993817df6c99027de09f26eaa49faf8995c7358bbc9307a1a021ca2646f4748f442d33da0c5020fd19cc7f17defeef18becadbd8aafcffefc94747cd11fa9fce0fc96f0f7f251fea090c54b581a6547cb9d0c1509a2cc2b5ab402946d5f5cce429e2d0399c8e8f91b5f658914cf1e994549a3b1cc0856c165e2eb93050cdda86cafa42a31c9a12437f528fe6cfc81b9dc396b214a9",
+ "markIn" : 509200,
+ "markOut" : 614280
+ }, {
+ "id" : "13763054",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763054",
+ "title" : "Séisme en Turquie et en Syrie : Olivier Hagon, chef du groupe \"Santé\" du corps suisse d'aide humanitaire, revient sur l'importance d'une aide internationale immédiate",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763053.image/16x9",
+ "imageTitle" : "Séisme en Turquie et en Syrie : Olivier Hagon, chef du groupe \"Santé\" du corps suisse d'aide humanitaire, revient sur l'importance d'une aide internationale immédiate [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 149480,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 5,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Séisme en Turquie et en Syrie : Olivier Hagon, chef du groupe \"Santé\" du corps suisse d'aide humanitaire, revient sur l'importance d'une aide internationale immédiate",
+ "media_type" : "Video",
+ "media_segment_id" : "13763054",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "149",
+ "media_number_of_segment_selected" : "5",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763054"
+ },
+ "eventData" : "$cb47ec31639040f6$4e51360d4cbc0a2dd71b960ed6935727f221e27fda235c10f508a6be94a473a66fa15f3e037a5ba3a5e5e99bad571dc30773c88fce53a5e8da1bb794e127230881e5beb9e4d012b4ee423f9722013f8d58574deaaf30c82e9549f8bfc757a9b613b1f573ab2b0984b37e08d975032ed117af8765a317a491e6df21efe2b3a120cc1f7759130afbea7aae2fc3a0bb1b273068fb1d33ab1271c03441518129b8f7861aad4ee68b19719c1553a01dc3d83ab8dbd18eea5578280b5833324f196e49f9897c8512071493e5c01b7c67a42079cb40a13a8c3d87ad6935840388391444b059a1891f9c0bee6565aebac761000a05fc4a6063a1aff5b3072e64744a547203fb859ada45fc65579657fba1336155ecb295d7a38a137756bbba1f4451eb4b4499d7806741e8ebcd15e4681f65f760b07b05c85490293d3063fa4aaa9a5ace0c879509669996155fe2bcd8544b05ebde74a2bc1a3cd9bf3de38d7ef073afe35feeab42c8c7eaf53c7cc447bce133e8",
+ "markIn" : 614280,
+ "markOut" : 763760
+ }, {
+ "id" : "13763056",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763056",
+ "title" : "Une délégation de parlementaires suisses a rendu visite à la Présidente taiwanaise.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763055.image/16x9",
+ "imageTitle" : "Une délégation de parlementaires suisses a rendu visite à la Présidente taiwanaise. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 110360,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 6,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Une délégation de parlementaires suisses a rendu visite à la Présidente taiwanaise.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763056",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "110",
+ "media_number_of_segment_selected" : "6",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763056"
+ },
+ "eventData" : "$57ef956cdc01fb68$1e76173b238e6d873218d416f667a7d312b45a5dea058751206f20fc6db32f6d02ac1a5fecfb6ddeaa7bac331fbb48e37a1b59dca16ddf947ded0e9fe0b7c77a50e5235f61a15e149457f2765284302a87b1379ee40e386e3a52aba94dc80ef80f7e421187c414eeb2a2a87cc4a880e5370e95098b822083d6d0371bc7d1cf27171490d0fb55fec43807fb0dc5b65bea0ff42f862f5475824d5df8443b1604e9e115bc6b3c6972733498579f641461c5f6c70d45d0cac604989623e105c981a359e509832bd307f21cc7bab262922fdb487160aa9a372138fdcada9c2bfcd95ecb4e30a0f10c9fb7c41da2767f54a0f0909804e76696739587371b77bc7c63d32bb820d47414adbc194441d38fc65e8072e90ec9b5d3500d6a7eb5767de11ba18554008cf9fe8c31710e9d03b6848e5ff4b7e92619433bea9a65f000ac3067b97e0554d975a863e0241c21fa730c721dbcaa9397f36f3e88e9eba30607bcf53bc6c1c0a8cefd6908382113552fd4b21c",
+ "markIn" : 776200,
+ "markOut" : 886560
+ }, {
+ "id" : "13763058",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763058",
+ "title" : "À Hong-Kong, alors que la plupart des opposants à Pékin ont été réduits au silence, quelques-uns résistent encore",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763057.image/16x9",
+ "imageTitle" : "À Hong-Kong, alors que la plupart des opposants à Pékin ont été réduits au silence, quelques-uns résistent encore [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 177720,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 7,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "À Hong-Kong, alors que la plupart des opposants à Pékin ont été réduits au silence, quelques-uns résistent encore",
+ "media_type" : "Video",
+ "media_segment_id" : "13763058",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "178",
+ "media_number_of_segment_selected" : "7",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763058"
+ },
+ "eventData" : "$1a93330bbc15fad9$a604aa17bcf32efc7fb057fd07f1f8c7bac3b6925cd34e46a03f3f895b025dd320d415f97a6392be0d4e95d7f7351310c07f19a343834504ad59d1ec82c85905b0fd0c83185ce812bfe40d582ef9de50da2a2b747a3617052f1121181336eae0b6df069741128683556f64465c7515849ae1a705fe5e37bc912fad9c336fdf7c652c4f6a4d64c9010e259df08b6f605ff475f84c929933bff673dae4b72004c760409b3a5066145a338897c751f12678820b1876376b80c9eb005abb3062d696b1f5d856c8000fb2b7feb6439d047decf0ecedde3d74fcc684cb44de45b067b0196b99917164f8439c4d606b2d7e78a035a08b2c1b3b8d30915054f973e27df03abc3112769fffb0057145033738dc1dacda9fdc0a9fa0c59941e7955f2936b6557ddf05570312b43658a5254ad322401817f2023bc407297cfcf2c1c697fa72f995d005dbc2d71949edab436df50faa3a4c74b7c961ad7322dcdbac76587244f228965f6b4154ca0e821b842b8bc54a",
+ "markIn" : 916160,
+ "markOut" : 1093880
+ }, {
+ "id" : "13763060",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763060",
+ "title" : "La nouvelle carte d'identité suisse sera disponible dès le 3 mars prochain.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763059.image/16x9",
+ "imageTitle" : "La nouvelle carte d'identité suisse sera disponible dès le 3 mars prochain. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 79280,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 8,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "La nouvelle carte d'identité suisse sera disponible dès le 3 mars prochain.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763060",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "79",
+ "media_number_of_segment_selected" : "8",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763060"
+ },
+ "eventData" : "$c95a60ec09271449$74c6da37289f167a2751a1955cbc5d68462366528ebaae5f2d716c8e6809d3a93cff003b8dc4f5e7d931dc6d54b4ce33c65395cefbe99eed9483c6c48f84fb3ea69b83158653bb88bd229d5e816400a949aad113bbe7e0f0b7b4b35a5b50ad29be6f9fec4feaac7278ff990a0b9f234980096272cf7ce165c22ff17444012c6807326b97e9eb257dd962c0b0b6b547b2c50b2412506ace70e230e8dcb29607cab31a6640a8c2ff4493c8cc7e8eaf7332c54dd6a42dcbb825328e630eb1967bd62f36d0f13608f2ffd729feb62f1ca22fd416e32ee12d60e2ff73f3bd3f3dd64f49dd769d04e2d8bb8b0227723552d1d1275a26095ece3acc543f383700ab0806c24447d862ace9dee6eeb9228a5bb70998a486f2581b6b40e6c9f785ff68d9ee504223bae916d24ec87d204cb9e281d76897859a8c1207fc0aa8572adfceca3979abf4834605dada7950598df31045f6cad6b3780e83dafa51f4624f24d6552291351108201a93d38b739c21fa8ce717",
+ "markIn" : 1093880,
+ "markOut" : 1173160
+ }, {
+ "id" : "13763062",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763062",
+ "title" : "Vive controverse à Neuchâtel. L'Eglise réformée ne veut plus de cérémonies laïques dans ses temples.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763061.image/16x9",
+ "imageTitle" : "Vive controverse à Neuchâtel. L'Eglise réformée ne veut plus de cérémonies laïques dans ses temples. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 131520,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 9,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Vive controverse à Neuchâtel. L'Eglise réformée ne veut plus de cérémonies laïques dans ses temples.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763062",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "132",
+ "media_number_of_segment_selected" : "9",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763062"
+ },
+ "eventData" : "$fe5ff11ee266bee8$617478588c4826d351ae27d63c295aa6ecb7098c5aede3501f3eb710ce7338119fdb9fc91b88dd26503c23ee5decdc1752f22c695968dffcec3db8034f7b3a552a9f371800d6d1ec7926a834dae805ff90911adace6c570443209ed1d0baf130c98573c7c7235b1da9786928263a5ba5558ee76d21f5529d296491c4e3bfc9c0488dd9098000dbc492453b95da65d64ac87d589f0960d2810cabe922ed9bda080e5c2b13fbfea894dc0ba460172e4a6b21dd1ca17427b63f0f41156d7ffe1091636ecc1d162fff2888a7212cb424e4aeab7097681a4e7b61cd522f60fad3b740e38e5b52b4450fccca9539eb57d666ffabd5bf0fe1dcf99b0e15032d18f8fe27935a19679e8f5a6426af9fad82ff081943616fd8483e81dd17ae31a797dd6fa9554a52eec6793b222f9220e342651c64df65f4b50b17348233257a94832a33e7a0d502881f2ee1dadfc58ae584ef6c95c0116971aa91588d1992c89ab67caecde0989a4273aa5e1a49ef3329908904bf",
+ "markIn" : 1173160,
+ "markOut" : 1304680
+ }, {
+ "id" : "13763064",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763064",
+ "title" : "Michel Kocher, journaliste à RTSreligion, commente la décision de l'Église réformée neuchâteloise de ne plus accueillir de cérémonies laïques dans ses temples.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763063.image/16x9",
+ "imageTitle" : "Michel Kocher, journaliste à RTSreligion, commente la décision de l'Église réformée neuchâteloise de ne plus accueillir de cérémonies laïques dans ses temples. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 142600,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 10,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Michel Kocher, journaliste à RTSreligion, commente la décision de l'Église réformée neuchâteloise de ne plus accueillir de cérémonies laïques dans ses temples.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763064",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "143",
+ "media_number_of_segment_selected" : "10",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763064"
+ },
+ "eventData" : "$b4e9b17cb149766c$104065454cabe642af9c753e7e05443dfea2ca49820e1dc89e286604535af353ebe1c52da3eb2fef21689581e9727c535cd70b6842488e8f785ce2927465f966d73120a483c5e03e304dc7dcf91dfa23831085d275f49fe0139166e070ce6957c8eebf6f0767eb9449b6b5e6a6ae469f6a2c493dfaf176ef343819d62de8861183bedc43521ddb3497798f2fce203121aeab9d56ded99e8879f03e4de082786ec8b9209d7eb2e4f07123f610e226b380f39a83db742a78ac270ef27534e55adf294f436aee1e4b6de7164a48d12d6addff6f07ed5a714f66f00ea6efd6ca97171ecaf0a6bb0ae78f31dee52480ad8afad009ad1df0b99ee06571656bbb7f4c21469c874d941f8326579d9f9de593f09a490e6a4c57dbd0f9cf3a991533f6b0aa7371203e7263d4a07a1bfe151562ce9a48664548a74fb3d33bd42b08093a3b12ae53f1b0d390ed2ab26027346551ea4e03663069fe23363968350bc309070723f4146d626cfac7399940fea3f22282e1",
+ "markIn" : 1304680,
+ "markOut" : 1447280
+ }, {
+ "id" : "13763066",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763066",
+ "title" : "2 mois après l'entrée en vigueur de la \"Lex Booking\", notre enquête montre qu'il est avantageux de réserver son hôtel en direct.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763065.image/16x9",
+ "imageTitle" : "2 mois après l'entrée en vigueur de la \"Lex Booking\", notre enquête montre qu'il est avantageux de réserver son hôtel en direct. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 130560,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 11,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "2 mois après l'entrée en vigueur de la \"Lex Booking\", notre enquête montre qu'il est avantageux de réserver son hôtel en direct.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763066",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "131",
+ "media_number_of_segment_selected" : "11",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763066"
+ },
+ "eventData" : "$2ece6a538f114427$0e716d1d8e4a1cdd5fc88b1400fd4317d5674af7d51adc5ad25446c6953385c3799d71633e54d4a6b010c46e253fb4af972e56f993e9fced0362f7ea4fc1a32a892624bcb5ede4db30f4e18de68cde5267345a301e324aaa5f6fc606cf18c60f935a29d5d71a21845cb331ba9e434eff25e23830f367f3e5f7a12d55e2c3e9cada84b7689d7b4bb3326d781882f7e21d79a706a4ee07719aa2d3ddd415725dea049f630093af6ce1d68ff536418c36907f4ad57f631ef9ba9b1829f11f6bc8919ec133aedea7187e757adb3a96cdd05726cf705d622613413458e47f21dbcc491b5f82589135ecdd98de7565cc7912d25d1778299b50550f7670ac38740c71f3d69286f30a6d1e241e1183235a134e8a6816b4a9face4c1c72c81694225a708c8a6f7404ca6ceaf8782f97b548cac69dfb5e30075f5774d2e6133f5ae142dad60fe4cf6b2cf10bc5bfb786eba33b02e1a08578f0353ff44d5cbf6e9c71ca9e9807ca5b63cd11ca493eaaefbc3d197ccc",
+ "markIn" : 1447280,
+ "markOut" : 1577840
+ }, {
+ "id" : "13763068",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763068",
+ "title" : "Première journée au championnats du monde de ski et première médaille suisse avec l’argent de Wendy Holdener en combiné.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763067.image/16x9",
+ "imageTitle" : "Première journée au championnats du monde de ski et première médaille suisse avec l’argent de Wendy Holdener en combiné. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 119760,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : false,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 12,
+ "noEmbed" : true,
+ "analyticsMetadata" : {
+ "media_segment" : "Première journée au championnats du monde de ski et première médaille suisse avec l’argent de Wendy Holdener en combiné.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763068",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "120",
+ "media_number_of_segment_selected" : "12",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "true",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763068"
+ },
+ "eventData" : "$b0e5010f0d20637f$3e77d084182f46934ad9ee16de6e15e80d37d5b528ed652e9b894d96a68b663e98eff6318fe88f9dbb8f52011572a40379bef3dcec053b35b0595bc91f75375c046fdca30575850a7fbca5d4221bd1ff95547d0ae81a058182d277c3704d03e72a7b6e9a022b9a246433341f3b31dd50d1304515ab0c5afc4e5c68d98566ec1c2522a629253f6294ef89db7eb28ac6c21bc88b19affb75ff5fd35a519fbb0bc5aab8236776203b200e9ea78b657500a6203fefb750e2f307fb0197b6fa40288361d44686aa7543bee9ba3471d9fd9ecd4dd80067108ab7ac943b8be9e4e210100c991334c68837e365bcb3b086cf063cd9db9eec0180c64bc6e834436f9367ae85ccf512cab3df1a61ccd9dffb72d9654b19b7ea6e375eda7fa25af868a9ef9d2d592fea7b04c13f0ee2e721c3756f80d887640505f383436f1c84680430ac3a92d5a68fc9a059eea55f2d130bf94d87e7b5b083e4c5494e5bf64a0fcb48c92ab49e892c01d95cd726cd7e08ffaca8a0",
+ "markIn" : 1581720,
+ "markOut" : 1701480
+ }, {
+ "id" : "13763070",
+ "mediaType" : "VIDEO",
+ "vendor" : "RTS",
+ "urn" : "urn:rts:video:13763070",
+ "title" : "Michel Simonet, le célèbre balayeur fribourgeois, cumule 4 millions de vue sur Instagram avec une vidéo racontant son quotidien.",
+ "imageUrl" : "https://www.rts.ch/2023/02/06/21/06/13763069.image/16x9",
+ "imageTitle" : "Michel Simonet, le célèbre balayeur fribourgeois, cumule 4 millions de vue sur Instagram avec une vidéo racontant son quotidien. [RTS]",
+ "type" : "CLIP",
+ "date" : "2023-02-06T19:30:00+01:00",
+ "duration" : 128800,
+ "validFrom" : "2023-02-06T20:05:19+01:00",
+ "playableAbroad" : true,
+ "displayable" : true,
+ "fullLengthUrn" : "urn:rts:video:13763072",
+ "position" : 13,
+ "noEmbed" : false,
+ "analyticsMetadata" : {
+ "media_segment" : "Michel Simonet, le célèbre balayeur fribourgeois, cumule 4 millions de vue sur Instagram avec une vidéo racontant son quotidien.",
+ "media_type" : "Video",
+ "media_segment_id" : "13763070",
+ "media_episode_length" : "1858",
+ "media_segment_length" : "129",
+ "media_number_of_segment_selected" : "13",
+ "media_number_of_segments_total" : "13",
+ "media_duration_category" : "short",
+ "media_is_geoblocked" : "false",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_urn" : "urn:rts:video:13763070"
+ },
+ "eventData" : "$4c2cb6787916e0cf$7656f4a41f1c6fa32d08561727846b3bb5351df71cbb9ee961ae30c670a6a17cabe57da7e6f183f391290366827ce367208076430e6de3593c8ef9a39588edc4d7faaac91cc9d7433f01a8e3e98366a39b4f312fb237046101672c15d441d82b798a5ea698d2358485def04e8e66e32dd835bfcba7ce04611b1787e29e7bb0f2f0f15ee75b5d6ba756154913c2a50752d89d2bacdfa85398f694031cd39549c74db85fed1562d9b86daf0c8da686a56f89782b4d7786631cbb5993a45aab579fa2b0b5f3fb7de747dcfffefcc544cb3bf3d147861ed8d026262b07810b775603c7040b6521fdc5b33fbc5a9ec5e078e6112272ae73f4bd97c9a7f367bc6ec718239994397aabac1787800819a8adefc349e4b92438fcaf316959f8e8dcdde47cae695cfdc3ae7d124d2de58925eb313894d1acf5ef693b4627e588f4bd494ce7ece42436cce6b5de66dfdb234389d5743a9b843bc25ff8234f80ad7d15646e066c4f26f76a097137d3b0f9fd137365c6",
+ "markIn" : 1701480,
+ "markOut" : 1830280
+ } ],
+ "aspectRatio" : "16:9",
+ "spriteSheet" : {
+ "urn" : "urn:rts:video:13763072",
+ "rows" : 24,
+ "columns" : 20,
+ "thumbnailHeight" : 84,
+ "thumbnailWidth" : 150,
+ "interval" : 4000,
+ "url" : "https://il.srgssr.ch/spritesheet/urn/rts/video/13763072/sprite-13763072.jpeg"
+ }
+ } ],
+ "topicList" : [ {
+ "id" : "908",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:908",
+ "title" : "19h30"
+ }, {
+ "id" : "904",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:904",
+ "title" : "Vidéos"
+ }, {
+ "id" : "665",
+ "vendor" : "RTS",
+ "transmission" : "TV",
+ "urn" : "urn:rts:topic:tv:665",
+ "title" : "Info"
+ } ],
+ "analyticsData" : {
+ "srg_pr_id" : "13646015",
+ "srg_plid" : "105932",
+ "ns_st_pl" : "19h30",
+ "ns_st_pr" : "19h30 du 06.02.2023",
+ "ns_st_dt" : "2023-02-06",
+ "ns_st_ddt" : "2023-02-06",
+ "ns_st_tdt" : "2023-02-06",
+ "ns_st_tm" : "19:30:00",
+ "ns_st_tep" : "500434867",
+ "ns_st_li" : "0",
+ "ns_st_stc" : "0867",
+ "ns_st_st" : "RTS Online",
+ "ns_st_tpr" : "105932",
+ "ns_st_en" : "*null",
+ "ns_st_ge" : "*null",
+ "ns_st_ia" : "*null",
+ "ns_st_ce" : "1",
+ "ns_st_cdm" : "to",
+ "ns_st_cmt" : "fc",
+ "srg_unit" : "RTS",
+ "srg_c1" : "full",
+ "srg_c2" : "video_info_journal-19h30",
+ "srg_c3" : "RTS 1",
+ "srg_tv_id" : "500434867"
+ },
+ "analyticsMetadata" : {
+ "media_episode_id" : "13646015",
+ "media_show_id" : "105932",
+ "media_show" : "19h30",
+ "media_episode" : "19h30 du 06.02.2023",
+ "media_is_livestream" : "false",
+ "media_full_length" : "full",
+ "media_enterprise_units" : "RTS",
+ "media_joker1" : "full",
+ "media_joker2" : "video_info_journal-19h30",
+ "media_joker3" : "RTS 1",
+ "media_is_web_only" : "false",
+ "media_production_source" : "produced.for.broadcasting",
+ "media_tv_id" : "500434867",
+ "media_thumbnail" : "https://www.rts.ch/2023/02/06/21/06/13763071.image/16x9/scale/width/344",
+ "media_publication_date" : "2023-02-06",
+ "media_publication_time" : "20:05:19",
+ "media_publication_datetime" : "2023-02-06T20:05:19+01:00",
+ "media_tv_date" : "2023-02-06",
+ "media_tv_time" : "19:30:00",
+ "media_tv_datetime" : "2023-02-06T19:30:00+01:00",
+ "media_content_group" : "19h30,Vidéos,Info",
+ "media_channel_id" : "143932a79bb5a123a646b68b1d1188d7ae493e5b",
+ "media_channel_cs" : "0867",
+ "media_channel_name" : "RTS 1",
+ "media_since_publication_d" : "6",
+ "media_since_publication_h" : "163"
+ }
+}
\ No newline at end of file
diff --git a/Tests/CoreTests/AccumulatePublisherTests.swift b/Tests/CoreTests/AccumulatePublisherTests.swift
new file mode 100644
index 00000000..c32a22ec
--- /dev/null
+++ b/Tests/CoreTests/AccumulatePublisherTests.swift
@@ -0,0 +1,186 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import PillarboxCircumspect
+import XCTest
+
+final class AccumulatePublisherTests: XCTestCase {
+ private static func publisher(at index: Int) -> AnyPublisher {
+ precondition(index > 0)
+ return Just(index)
+ .delay(for: .seconds(Double(index) * 0.1), scheduler: DispatchQueue.main)
+ .eraseToAnyPublisher()
+ }
+
+ private static func prependedPublisher(at index: Int) -> AnyPublisher {
+ precondition(index > 0)
+ return Just(index)
+ .delay(for: .seconds(Double(index) * 0.1), scheduler: DispatchQueue.main)
+ .prepend(0)
+ .eraseToAnyPublisher()
+ }
+
+ func testSyntaxWithoutTypeErasure() {
+ expectOnlyEqualPublished(
+ values: [
+ [1, 2, 3]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Just(1),
+ Just(2),
+ Just(3)
+ )
+ )
+ }
+
+ func testAccumulateOne() {
+ expectOnlyEqualPublished(
+ values: [
+ [1]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.publisher(at: 1)
+ )
+ )
+ }
+
+ func testAccumulateTwo() {
+ expectOnlyEqualPublished(
+ values: [
+ [1, 2]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.publisher(at: 1),
+ Self.publisher(at: 2)
+ )
+ )
+ }
+
+ func testAccumulateThree() {
+ expectOnlyEqualPublished(
+ values: [
+ [1, 2, 3]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.publisher(at: 1),
+ Self.publisher(at: 2),
+ Self.publisher(at: 3)
+ )
+ )
+ }
+
+ func testAccumulateFour() {
+ expectOnlyEqualPublished(
+ values: [
+ [1, 2, 3, 4]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.publisher(at: 1),
+ Self.publisher(at: 2),
+ Self.publisher(at: 3),
+ Self.publisher(at: 4)
+ )
+ )
+ }
+
+ func testAccumulateFive() {
+ expectOnlyEqualPublished(
+ values: [
+ [1, 2, 3, 4, 5]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.publisher(at: 1),
+ Self.publisher(at: 2),
+ Self.publisher(at: 3),
+ Self.publisher(at: 4),
+ Self.publisher(at: 5)
+ )
+ )
+ }
+
+ func testAccumulateOnePrepended() {
+ expectOnlyEqualPublished(
+ values: [
+ [0],
+ [1]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.prependedPublisher(at: 1)
+ )
+ )
+ }
+
+ func testAccumulateTwoPrepended() {
+ expectOnlyEqualPublished(
+ values: [
+ [0, 0],
+ [1, 0],
+ [1, 2]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.prependedPublisher(at: 1),
+ Self.prependedPublisher(at: 2)
+ )
+ )
+ }
+
+ func testAccumulateThreePrepended() {
+ expectOnlyEqualPublished(
+ values: [
+ [0, 0, 0],
+ [1, 0, 0],
+ [1, 2, 0],
+ [1, 2, 3]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.prependedPublisher(at: 1),
+ Self.prependedPublisher(at: 2),
+ Self.prependedPublisher(at: 3)
+ )
+ )
+ }
+
+ func testAccumulateFourPrepended() {
+ expectOnlyEqualPublished(
+ values: [
+ [0, 0, 0, 0],
+ [1, 0, 0, 0],
+ [1, 2, 0, 0],
+ [1, 2, 3, 0],
+ [1, 2, 3, 4]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.prependedPublisher(at: 1),
+ Self.prependedPublisher(at: 2),
+ Self.prependedPublisher(at: 3),
+ Self.prependedPublisher(at: 4)
+ )
+ )
+ }
+
+ func testAccumulateFivePrepended() {
+ expectOnlyEqualPublished(
+ values: [
+ [0, 0, 0, 0, 0],
+ [1, 0, 0, 0, 0],
+ [1, 2, 0, 0, 0],
+ [1, 2, 3, 0, 0],
+ [1, 2, 3, 4, 0],
+ [1, 2, 3, 4, 5]
+ ],
+ from: Publishers.AccumulateLatestMany(
+ Self.prependedPublisher(at: 1),
+ Self.prependedPublisher(at: 2),
+ Self.prependedPublisher(at: 3),
+ Self.prependedPublisher(at: 4),
+ Self.prependedPublisher(at: 5)
+ )
+ )
+ }
+}
diff --git a/Tests/CoreTests/ArrayTests.swift b/Tests/CoreTests/ArrayTests.swift
new file mode 100644
index 00000000..d840ea30
--- /dev/null
+++ b/Tests/CoreTests/ArrayTests.swift
@@ -0,0 +1,24 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Nimble
+import PillarboxCircumspect
+import XCTest
+
+final class ArrayTests: XCTestCase {
+ func testRemoveDuplicates() {
+ expect([1, 2, 3, 4].removeDuplicates()).to(equalDiff([1, 2, 3, 4]))
+ expect([1, 2, 1, 4].removeDuplicates()).to(equalDiff([1, 2, 4]))
+ }
+
+ func testSafeIndex() {
+ expect([1, 2, 3][safeIndex: 0]).to(equal(1))
+ expect([1, 2, 3][safeIndex: -1]).to(beNil())
+ expect([1, 2, 3][safeIndex: 3]).to(beNil())
+ }
+}
diff --git a/Tests/CoreTests/CombineLatestTests.swift b/Tests/CoreTests/CombineLatestTests.swift
new file mode 100644
index 00000000..b6f83f66
--- /dev/null
+++ b/Tests/CoreTests/CombineLatestTests.swift
@@ -0,0 +1,64 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import PillarboxCircumspect
+import XCTest
+
+final class CombineLatestPublisherTests: XCTestCase {
+ func testOutput5() {
+ expectOnlyEqualPublished(
+ values: [
+ [1, 2, 3, 4, 5]
+ ],
+ from: Publishers.CombineLatest5(
+ Just(1),
+ Just(2),
+ Just(3),
+ Just(4),
+ Just(5)
+ )
+ .map { [$0.0, $0.1, $0.2, $0.3, $0.4] }
+ )
+ }
+
+ func testOutput6() {
+ expectOnlyEqualPublished(
+ values: [
+ [1, 2, 3, 4, 5, 6]
+ ],
+ from: Publishers.CombineLatest6(
+ Just(1),
+ Just(2),
+ Just(3),
+ Just(4),
+ Just(5),
+ Just(6)
+ )
+ .map { [$0.0, $0.1, $0.2, $0.3, $0.4, $0.5] }
+ )
+ }
+
+ func testOutput7() {
+ expectOnlyEqualPublished(
+ values: [
+ [1, 2, 3, 4, 5, 6, 7]
+ ],
+ from: Publishers.CombineLatest7(
+ Just(1),
+ Just(2),
+ Just(3),
+ Just(4),
+ Just(5),
+ Just(6),
+ Just(7)
+ )
+ .map { [$0.0, $0.1, $0.2, $0.3, $0.4, $0.5, $0.6] }
+ )
+ }
+}
diff --git a/Tests/CoreTests/ComparableTests.swift b/Tests/CoreTests/ComparableTests.swift
new file mode 100644
index 00000000..6546fc69
--- /dev/null
+++ b/Tests/CoreTests/ComparableTests.swift
@@ -0,0 +1,20 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Nimble
+import XCTest
+
+final class ComparableTests: XCTestCase {
+ func testClamped() {
+ expect((-1).clamped(to: 0...1)).to(equal(0))
+ expect(0.clamped(to: 0...1)).to(equal(0))
+ expect(0.5.clamped(to: 0...1)).to(equal(0.5))
+ expect(1.clamped(to: 0...1)).to(equal(1))
+ expect(2.clamped(to: 0...1)).to(equal(1))
+ }
+}
diff --git a/Tests/CoreTests/DemandBufferTests.swift b/Tests/CoreTests/DemandBufferTests.swift
new file mode 100644
index 00000000..7b8386c6
--- /dev/null
+++ b/Tests/CoreTests/DemandBufferTests.swift
@@ -0,0 +1,81 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import Nimble
+import PillarboxCircumspect
+import XCTest
+
+final class DemandBufferTests: XCTestCase {
+ func testEmptyBuffer() {
+ let buffer = DemandBuffer()
+ expect(buffer.values).to(beEmpty())
+ expect(buffer.requested).to(equal(Subscribers.Demand.none))
+ }
+
+ func testPrefilledBuffer() {
+ let buffer: DemandBuffer = [1, 2]
+ expect(buffer.values).to(equalDiff([1, 2]))
+ }
+
+ func testLimitedRequestWithEmptyBuffer() {
+ let buffer = DemandBuffer()
+ expect(buffer.request(.max(2))).to(beEmpty())
+ expect(buffer.requested).to(equal(.max(2)))
+ }
+
+ func testLimitedRequestWithPartiallyFilledBuffer() {
+ let buffer: DemandBuffer = [1, 2]
+ expect(buffer.request(.max(10))).to(equalDiff([1, 2]))
+ expect(buffer.requested).to(equal(.max(8)))
+ }
+
+ func testLimitedRequestWithFullyFilledBuffer() {
+ let buffer: DemandBuffer = [1, 2, 3, 4]
+ expect(buffer.request(.max(2))).to(equalDiff([1, 2]))
+ expect(buffer.requested).to(equal(.max(0)))
+ expect(buffer.append(5)).to(beEmpty())
+ }
+
+ func testUnlimitedRequestWithEmptyBuffer() {
+ let buffer = DemandBuffer()
+ expect(buffer.request(.unlimited)).to(beEmpty())
+ expect(buffer.requested).to(equal(.unlimited))
+ }
+
+ func testUnlimitedRequestWithFilledBuffer() {
+ let buffer: DemandBuffer = [1, 2]
+ expect(buffer.request(.unlimited)).to(equalDiff([1, 2]))
+ expect(buffer.requested).to(equal(.unlimited))
+ }
+
+ func testAppendWithPendingLimitedRequest() {
+ let buffer = DemandBuffer()
+ expect(buffer.request(.max(2))).to(beEmpty())
+ expect(buffer.append(1)).to(equalDiff([1]))
+ expect(buffer.append(2)).to(equalDiff([2]))
+ expect(buffer.requested).to(equal(.max(0)))
+ expect(buffer.append(3)).to(beEmpty())
+ }
+
+ func testAppendWithPendingUnlimitedRequest() {
+ let buffer = DemandBuffer()
+ expect(buffer.request(.unlimited)).to(beEmpty())
+ expect(buffer.append(1)).to(equalDiff([1]))
+ expect(buffer.append(2)).to(equalDiff([2]))
+ }
+
+ func testThreadSafety() {
+ let buffer = DemandBuffer([0...1000])
+ for _ in 0..<100 {
+ DispatchQueue.global().async {
+ _ = buffer.request(.unlimited)
+ }
+ }
+ }
+}
diff --git a/Tests/CoreTests/DispatchPublisherTests.swift b/Tests/CoreTests/DispatchPublisherTests.swift
new file mode 100644
index 00000000..0cfd45a6
--- /dev/null
+++ b/Tests/CoreTests/DispatchPublisherTests.swift
@@ -0,0 +1,93 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import Nimble
+import PillarboxCircumspect
+import XCTest
+
+final class DispatchPublisherTests: XCTestCase {
+ private var cancellables = Set()
+
+ func testReceiveOnMainThreadFromMainThread() {
+ var value = 0
+ Just(3)
+ .receiveOnMainThread()
+ .sink { i in
+ expect(Thread.isMainThread).to(beTrue())
+ value = i
+ }
+ .store(in: &cancellables)
+ expect(value).to(equal(3))
+ }
+
+ func testReceiveOnMainThreadFromBackgroundThread() {
+ var value = 0
+ Just(3)
+ .receive(on: DispatchQueue(label: "com.srgssr.pillarbox-tests"))
+ .receiveOnMainThread()
+ .sink { i in
+ expect(Thread.isMainThread).to(beTrue())
+ value = i
+ }
+ .store(in: &cancellables)
+ expect(value).to(equal(0))
+ }
+
+ func testStandardReceiveOnMainThreadFromMainThread() {
+ var value = 0
+ Just(3)
+ .receive(on: DispatchQueue.main)
+ .sink { i in
+ expect(Thread.isMainThread).to(beTrue())
+ value = i
+ }
+ .store(in: &cancellables)
+ expect(value).to(equal(0))
+ }
+
+ func testStandardReceiveOnMainThreadFromBackgroundThread() {
+ var value = 0
+ Just(3)
+ .receive(on: DispatchQueue(label: "com.srgssr.pillarbox-tests"))
+ .receive(on: DispatchQueue.main)
+ .sink { i in
+ expect(Thread.isMainThread).to(beTrue())
+ value = i
+ }
+ .store(in: &cancellables)
+ expect(value).to(equal(0))
+ }
+
+ func testReceiveOnMainThreadReceivesAllOutputFromMainThread() {
+ let publisher = [1, 2, 3].publisher
+ .receiveOnMainThread()
+ expectOnlyEqualPublished(values: [1, 2, 3], from: publisher)
+ }
+
+ func testReceiveOnMainThreadReceivesAllOutputFromBackgroundThreads() {
+ let publisher = [1, 2, 3].publisher
+ .receive(on: DispatchQueue(label: "com.srgssr.pillarbox-tests"))
+ .receiveOnMainThread()
+ expectOnlyEqualPublished(values: [1, 2, 3], from: publisher)
+ }
+
+ func testDelayIfNeededOutputOrderingWithNonZeroDelay() {
+ let delayedPublisher = [1, 2, 3].publisher
+ .delayIfNeeded(for: 0.1, scheduler: DispatchQueue.main)
+ let subject = CurrentValueSubject(0)
+ expectEqualPublished(values: [0, 1, 2, 3], from: Publishers.Merge(delayedPublisher, subject), during: .milliseconds(100))
+ }
+
+ func testDelayIfNeededOutputOrderingWithZeroDelay() {
+ let delayedPublisher = [1, 2, 3].publisher
+ .delayIfNeeded(for: 0, scheduler: DispatchQueue.main)
+ let subject = CurrentValueSubject(0)
+ expectEqualPublished(values: [1, 2, 3, 0], from: Publishers.Merge(delayedPublisher, subject), during: .milliseconds(100))
+ }
+}
diff --git a/Tests/CoreTests/LimitedBufferTests.swift b/Tests/CoreTests/LimitedBufferTests.swift
new file mode 100644
index 00000000..0267be95
--- /dev/null
+++ b/Tests/CoreTests/LimitedBufferTests.swift
@@ -0,0 +1,31 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Nimble
+import PillarboxCircumspect
+import XCTest
+
+final class LimitedBufferTests: XCTestCase {
+ func testBufferWithZeroSize() {
+ let buffer = LimitedBuffer(size: 0)
+ expect(buffer.values).to(beEmpty())
+ buffer.append(1)
+ expect(buffer.values).to(beEmpty())
+ }
+
+ func testBufferWithFiniteSize() {
+ let buffer = LimitedBuffer(size: 2)
+ expect(buffer.values).to(beEmpty())
+ buffer.append(1)
+ expect(buffer.values).to(equalDiff([1]))
+ buffer.append(2)
+ expect(buffer.values).to(equalDiff([1, 2]))
+ buffer.append(3)
+ expect(buffer.values).to(equalDiff([2, 3]))
+ }
+}
diff --git a/Tests/CoreTests/MeasurePublisherTests.swift b/Tests/CoreTests/MeasurePublisherTests.swift
new file mode 100644
index 00000000..505ff21f
--- /dev/null
+++ b/Tests/CoreTests/MeasurePublisherTests.swift
@@ -0,0 +1,36 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import PillarboxCircumspect
+import XCTest
+
+final class MeasurePublisherTests: XCTestCase {
+ func testWithSingleEvent() {
+ let publisher = Just(1)
+ .delay(for: .milliseconds(500), scheduler: DispatchQueue.main)
+ .measureDateInterval()
+ .map(\.duration)
+ expectPublished(values: [0.5], from: publisher, to: beClose(within: 0.1), during: .seconds(1))
+ }
+
+ func testWithMultipleEvents() {
+ let publisher = [1, 2].publisher
+ .delay(for: .milliseconds(500), scheduler: DispatchQueue.main)
+ .measureDateInterval()
+ .map(\.duration)
+ expectPublished(values: [0.5, 0], from: publisher, to: beClose(within: 0.1), during: .seconds(1))
+ }
+
+ func testWithoutEvents() {
+ let publisher = Empty()
+ .delay(for: .milliseconds(500), scheduler: DispatchQueue.main)
+ .measureDateInterval()
+ expectNothingPublished(from: publisher, during: .seconds(1))
+ }
+}
diff --git a/Tests/CoreTests/NotificationPublisherDeallocationTests.swift b/Tests/CoreTests/NotificationPublisherDeallocationTests.swift
new file mode 100644
index 00000000..9621f133
--- /dev/null
+++ b/Tests/CoreTests/NotificationPublisherDeallocationTests.swift
@@ -0,0 +1,43 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Nimble
+import PillarboxCircumspect
+import XCTest
+
+final class NotificationPublisherDeallocationTests: XCTestCase {
+ func testReleaseWithObject() throws {
+ let notificationCenter = NotificationCenter.default
+ var object: TestObject? = TestObject()
+ let publisher = notificationCenter.weakPublisher(for: .testNotification, object: object).first()
+
+ weak var weakObject = object
+ try autoreleasepool {
+ try waitForOutput(from: publisher) {
+ notificationCenter.post(name: .testNotification, object: object)
+ }
+ object = nil
+ }
+ expect(weakObject).to(beNil())
+ }
+
+ func testReleaseWithNSObject() throws {
+ let notificationCenter = NotificationCenter.default
+ var object: TestNSObject? = TestNSObject()
+ let publisher = notificationCenter.weakPublisher(for: .testNotification, object: object).first()
+
+ weak var weakObject = object
+ try autoreleasepool {
+ try waitForOutput(from: publisher) {
+ notificationCenter.post(name: .testNotification, object: object)
+ }
+ object = nil
+ }
+ expect(weakObject).to(beNil())
+ }
+}
diff --git a/Tests/CoreTests/NotificationPublisherTests.swift b/Tests/CoreTests/NotificationPublisherTests.swift
new file mode 100644
index 00000000..83236880
--- /dev/null
+++ b/Tests/CoreTests/NotificationPublisherTests.swift
@@ -0,0 +1,47 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Nimble
+import PillarboxCircumspect
+import XCTest
+
+final class NotificationPublisherTests: XCTestCase {
+ func testWithObject() throws {
+ let object = TestObject()
+ let notificationCenter = NotificationCenter.default
+ try waitForOutput(from: notificationCenter.weakPublisher(for: .testNotification, object: object).first()) {
+ notificationCenter.post(name: .testNotification, object: object)
+ }
+ }
+
+ func testWithNSObject() throws {
+ let object = TestNSObject()
+ let notificationCenter = NotificationCenter.default
+ try waitForOutput(from: notificationCenter.weakPublisher(for: .testNotification, object: object).first()) {
+ notificationCenter.post(name: .testNotification, object: object)
+ }
+ }
+
+ func testAfterObjectRelease() {
+ let notificationCenter = NotificationCenter.default
+ var object: TestObject? = TestObject()
+ let publisher = notificationCenter.weakPublisher(for: .testNotification, object: object).first()
+
+ weak var weakObject = object
+ autoreleasepool {
+ object = nil
+ }
+ expect(weakObject).to(beNil())
+
+ // We were interested in notifications from `object` only. After its release we should not receive other
+ // notifications from any other source anymore.
+ expectNothingPublished(from: publisher, during: .seconds(1)) {
+ notificationCenter.post(name: .testNotification, object: nil)
+ }
+ }
+}
diff --git a/Tests/CoreTests/PublishAndRepeatOnOutputFromTests.swift b/Tests/CoreTests/PublishAndRepeatOnOutputFromTests.swift
new file mode 100644
index 00000000..1f5a2aae
--- /dev/null
+++ b/Tests/CoreTests/PublishAndRepeatOnOutputFromTests.swift
@@ -0,0 +1,38 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import PillarboxCircumspect
+import XCTest
+
+final class PublishAndRepeatOnOutputFromTests: XCTestCase {
+ private let trigger = Trigger()
+
+ func testNoSignal() {
+ let publisher = Publishers.PublishAndRepeat(onOutputFrom: Optional>.none) {
+ Just("out")
+ }
+ expectAtLeastEqualPublished(values: ["out"], from: publisher)
+ }
+
+ func testInactiveSignal() {
+ let publisher = Publishers.PublishAndRepeat(onOutputFrom: trigger.signal(activatedBy: 1)) {
+ Just("out")
+ }
+ expectAtLeastEqualPublished(values: ["out"], from: publisher)
+ }
+
+ func testActiveSignal() {
+ let publisher = Publishers.PublishAndRepeat(onOutputFrom: trigger.signal(activatedBy: 1)) {
+ Just("out")
+ }
+ expectAtLeastEqualPublished(values: ["out", "out"], from: publisher) { [trigger] in
+ trigger.activate(for: 1)
+ }
+ }
+}
diff --git a/Tests/CoreTests/PublishOnOutputFromTests.swift b/Tests/CoreTests/PublishOnOutputFromTests.swift
new file mode 100644
index 00000000..68e5963e
--- /dev/null
+++ b/Tests/CoreTests/PublishOnOutputFromTests.swift
@@ -0,0 +1,38 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import PillarboxCircumspect
+import XCTest
+
+final class PublishOnOutputFromTests: XCTestCase {
+ private let trigger = Trigger()
+
+ func testNoSignal() {
+ let publisher = Publishers.Publish(onOutputFrom: Optional>.none) {
+ Just("out")
+ }
+ expectNothingPublished(from: publisher, during: .seconds(1))
+ }
+
+ func testInactiveSignal() {
+ let publisher = Publishers.Publish(onOutputFrom: trigger.signal(activatedBy: 1)) {
+ Just("out")
+ }
+ expectNothingPublished(from: publisher, during: .seconds(1))
+ }
+
+ func testActiveSignal() {
+ let publisher = Publishers.Publish(onOutputFrom: trigger.signal(activatedBy: 1)) {
+ Just("out")
+ }
+ expectAtLeastEqualPublished(values: ["out"], from: publisher) { [trigger] in
+ trigger.activate(for: 1)
+ }
+ }
+}
diff --git a/Tests/CoreTests/RangeReplaceableCollectionTests.swift b/Tests/CoreTests/RangeReplaceableCollectionTests.swift
new file mode 100644
index 00000000..4d23c50f
--- /dev/null
+++ b/Tests/CoreTests/RangeReplaceableCollectionTests.swift
@@ -0,0 +1,51 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Nimble
+import PillarboxCircumspect
+import XCTest
+
+final class RangeReplaceableCollectionTests: XCTestCase {
+ func testMoveForward() {
+ var array = [1, 2, 3, 4, 5, 6, 7]
+ array.move(from: 2, to: 5)
+ expect(array).to(equalDiff([1, 2, 4, 5, 3, 6, 7]))
+ }
+
+ func testMoveBackward() {
+ var array = [1, 2, 3, 4, 5, 6, 7]
+ array.move(from: 5, to: 2)
+ expect(array).to(equalDiff([1, 2, 6, 3, 4, 5, 7]))
+ }
+
+ func testMoveToEnd() {
+ var array = [1, 2, 3, 4, 5, 6, 7]
+ array.move(from: 2, to: 7)
+ expect(array).to(equalDiff([1, 2, 4, 5, 6, 7, 3]))
+ }
+
+ func testMoveSameItem() {
+ var array = [1, 2, 3, 4, 5, 6, 7]
+ array.move(from: 2, to: 2)
+ expect(array).to(equalDiff([1, 2, 3, 4, 5, 6, 7]))
+ }
+
+ func testMoveFromInvalidIndex() {
+ guard nimbleThrowAssertionsAvailable() else { return }
+ var array = [1, 2, 3, 4, 5, 6, 7]
+ expect(array.move(from: -1, to: 2)).to(throwAssertion())
+ expect(array.move(from: 8, to: 2)).to(throwAssertion())
+ }
+
+ func testMoveToInvalidIndex() {
+ guard nimbleThrowAssertionsAvailable() else { return }
+ var array = [1, 2, 3, 4, 5, 6, 7]
+ expect(array.move(from: 2, to: -1)).to(throwAssertion())
+ expect(array.move(from: 2, to: 8)).to(throwAssertion())
+ }
+}
diff --git a/Tests/CoreTests/ReplaySubjectTests.swift b/Tests/CoreTests/ReplaySubjectTests.swift
new file mode 100644
index 00000000..6395e55c
--- /dev/null
+++ b/Tests/CoreTests/ReplaySubjectTests.swift
@@ -0,0 +1,170 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import Nimble
+import PillarboxCircumspect
+import XCTest
+
+final class ReplaySubjectTests: XCTestCase {
+ func testEmptyBufferOfZero() {
+ let subject = ReplaySubject(bufferSize: 0)
+ expectNothingPublished(from: subject, during: .milliseconds(100))
+ }
+
+ func testEmptyBufferOfTwo() {
+ let subject = ReplaySubject(bufferSize: 2)
+ expectNothingPublished(from: subject, during: .milliseconds(100))
+ }
+
+ func testFilledBufferOfZero() {
+ let subject = ReplaySubject(bufferSize: 0)
+ subject.send(1)
+ expectNothingPublished(from: subject, during: .milliseconds(100))
+ }
+
+ func testFilledBufferOfTwo() {
+ let subject = ReplaySubject(bufferSize: 2)
+ subject.send(1)
+ subject.send(2)
+ subject.send(3)
+ expectEqualPublished(values: [2, 3], from: subject, during: .milliseconds(100))
+ }
+
+ func testNewValuesWithBufferOfZero() {
+ let subject = ReplaySubject(bufferSize: 0)
+ subject.send(1)
+ expectEqualPublished(values: [2, 3], from: subject, during: .milliseconds(100)) {
+ subject.send(2)
+ subject.send(3)
+ }
+ }
+
+ func testNewValuesWithBufferOfTwo() {
+ let subject = ReplaySubject(bufferSize: 2)
+ subject.send(1)
+ subject.send(2)
+ subject.send(3)
+ expectEqualPublished(values: [2, 3, 4, 5], from: subject, during: .milliseconds(100)) {
+ subject.send(4)
+ subject.send(5)
+ }
+ }
+
+ func testMultipleSubscribers() {
+ let subject = ReplaySubject(bufferSize: 2)
+ subject.send(1)
+ subject.send(2)
+ subject.send(3)
+ expectEqualPublished(values: [2, 3], from: subject, during: .milliseconds(100))
+ expectEqualPublished(values: [2, 3], from: subject, during: .milliseconds(100))
+ }
+
+ func testSubscriptionRelease() {
+ let subject = ReplaySubject(bufferSize: 1)
+ subject.send(1)
+
+ _ = subject.sink { _ in }
+
+ expect(subject.subscriptions).to(beEmpty())
+ }
+
+ func testNewValuesWithMultipleSubscribers() {
+ let subject = ReplaySubject(bufferSize: 2)
+ subject.send(1)
+ subject.send(2)
+ subject.send(3)
+ expectEqualPublished(values: [2, 3, 4], from: subject, during: .milliseconds(100)) {
+ subject.send(4)
+ }
+ expectEqualPublished(values: [3, 4], from: subject, during: .milliseconds(100))
+ }
+
+ func testCompletion() {
+ let subject = ReplaySubject(bufferSize: 2)
+ expectOnlyEqualPublished(values: [1], from: subject) {
+ subject.send(1)
+ subject.send(completion: .finished)
+ }
+ }
+
+ func testNoValueAfterCompletion() {
+ let subject = ReplaySubject(bufferSize: 2)
+ subject.send(1)
+ subject.send(completion: .finished)
+ subject.send(2)
+ expectEqualPublished(values: [1], from: subject, during: .milliseconds(100))
+ }
+
+ func testCompletionWithMultipleSubscribers() {
+ let subject = ReplaySubject(bufferSize: 2)
+ expectOnlyEqualPublished(values: [1], from: subject) {
+ subject.send(1)
+ subject.send(completion: .finished)
+ }
+ expectOnlyEqualPublished(values: [1], from: subject)
+ }
+
+ func testRequestLessValuesThanAvailable() {
+ let subject = ReplaySubject(bufferSize: 3)
+ subject.send(1)
+ subject.send(2)
+ subject.send(3)
+
+ var results = [Int]()
+ subject
+ .subscribe(AnySubscriber(
+ receiveSubscription: { subscription in
+ subscription.request(.max(2))
+ },
+ receiveValue: { value in
+ results.append(value)
+ return .none
+ },
+ receiveCompletion: { _ in }
+ ))
+ expect(results).to(equalDiff([1, 2]))
+ }
+
+ func testThreadSafety() {
+ let replaySubject = ReplaySubject(bufferSize: 3)
+ for i in 0..<100 {
+ DispatchQueue.global().async {
+ replaySubject.send(i)
+ }
+ }
+ }
+
+ func testDeliveryOrderInRecursiveScenario() {
+ let subject = ReplaySubject(bufferSize: 1)
+ var cancellables = Set()
+
+ var values: [String] = []
+
+ subject.sink { i in
+ values.append("A\(i)")
+ }
+ .store(in: &cancellables)
+
+ subject.sink { i in
+ values.append("B\(i)")
+ if i == 1 {
+ subject.send(2)
+ }
+ }
+ .store(in: &cancellables)
+
+ subject.sink { i in
+ values.append("C\(i)")
+ }
+ .store(in: &cancellables)
+
+ subject.send(1)
+ expect(values).to(equalDiff(["A1", "B1", "A2", "B2", "C1", "C2"]))
+ }
+}
diff --git a/Tests/CoreTests/SlicePublisherTests.swift b/Tests/CoreTests/SlicePublisherTests.swift
new file mode 100644
index 00000000..30caeca8
--- /dev/null
+++ b/Tests/CoreTests/SlicePublisherTests.swift
@@ -0,0 +1,27 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import PillarboxCircumspect
+import XCTest
+
+private struct Person: Equatable {
+ let firstName: String
+ let lastName: String
+}
+
+final class SlicePublisherTests: XCTestCase {
+ func testDelivery() {
+ let publisher = [
+ Person(firstName: "Jane", lastName: "Doe"),
+ Person(firstName: "Jane", lastName: "Smith"),
+ Person(firstName: "John", lastName: "Bridges")
+ ].publisher.slice(at: \.firstName)
+ expectEqualPublished(values: ["Jane", "John"], from: publisher)
+ }
+}
diff --git a/Tests/CoreTests/StopwatchTests.swift b/Tests/CoreTests/StopwatchTests.swift
new file mode 100644
index 00000000..4da359cf
--- /dev/null
+++ b/Tests/CoreTests/StopwatchTests.swift
@@ -0,0 +1,70 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Nimble
+import XCTest
+
+final class StopwatchTests: XCTestCase {
+ func testCreation() {
+ let stopwatch = Stopwatch()
+ wait(for: .milliseconds(500))
+ expect(stopwatch.time()).to(equal(0))
+ }
+
+ func testStart() {
+ let stopwatch = Stopwatch()
+ stopwatch.start()
+ wait(for: .milliseconds(500))
+ expect(stopwatch.time() * 1000).to(beCloseTo(500, within: 100))
+ }
+
+ func testStartAndStop() {
+ let stopwatch = Stopwatch()
+ stopwatch.start()
+ wait(for: .milliseconds(200))
+ stopwatch.stop()
+ wait(for: .milliseconds(200))
+ expect(stopwatch.time() * 1000).to(beCloseTo(200, within: 100))
+ }
+
+ func testStopWithoutStart() {
+ let stopwatch = Stopwatch()
+ stopwatch.stop()
+ wait(for: .milliseconds(200))
+ expect(stopwatch.time() * 1000).to(beCloseTo(0, within: 100))
+ }
+
+ func testReset() {
+ let stopwatch = Stopwatch()
+ stopwatch.start()
+ wait(for: .milliseconds(200))
+ stopwatch.reset()
+ wait(for: .milliseconds(100))
+ expect(stopwatch.time()).to(equal(0))
+ }
+
+ func testMultipleStarts() {
+ let stopwatch = Stopwatch()
+ stopwatch.start()
+ wait(for: .milliseconds(200))
+ stopwatch.start()
+ wait(for: .milliseconds(200))
+ expect(stopwatch.time() * 1000).to(beCloseTo(400, within: 100))
+ }
+
+ func testAccumulation() {
+ let stopwatch = Stopwatch()
+ stopwatch.start()
+ wait(for: .milliseconds(200))
+ stopwatch.stop()
+ wait(for: .milliseconds(200))
+ stopwatch.start()
+ wait(for: .milliseconds(200))
+ expect(stopwatch.time() * 1000).to(beCloseTo(400, within: 100))
+ }
+}
diff --git a/Tests/CoreTests/TimeTests.swift b/Tests/CoreTests/TimeTests.swift
new file mode 100644
index 00000000..f1c1ca18
--- /dev/null
+++ b/Tests/CoreTests/TimeTests.swift
@@ -0,0 +1,78 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import CoreMedia
+import Nimble
+import XCTest
+
+final class TimeTests: XCTestCase {
+ func testCloseWithFiniteTimes() {
+ expect(CMTime.close(within: 0)(CMTime.zero, .zero)).to(beTrue())
+ expect(CMTime.close(within: 0.5)(CMTime.zero, .zero)).to(beTrue())
+
+ expect(CMTime.close(within: 0.5)(CMTime(value: 2, timescale: 1), CMTime(value: 2, timescale: 1))).to(beTrue())
+ expect(CMTime.close(within: 0.5)(CMTime(value: 2, timescale: 1), CMTime(value: 200, timescale: 100))).to(beTrue())
+ expect(CMTime.close(within: 0.5)(CMTime.zero, CMTime(value: 1, timescale: 2))).to(beTrue())
+ expect(CMTime.close(within: 0.5)(CMTime.zero, CMTime(value: 2, timescale: 1))).to(beFalse())
+
+ expect(CMTime.close(within: 0)(CMTime.zero, CMTime(value: 1, timescale: 10000))).to(beFalse())
+ }
+
+ func testCloseWithPositiveInfiniteValues() {
+ expect(CMTime.close(within: 0)(CMTime.positiveInfinity, .positiveInfinity)).to(beTrue())
+ expect(CMTime.close(within: 0.5)(CMTime.positiveInfinity, .positiveInfinity)).to(beTrue())
+
+ expect(CMTime.close(within: 10000)(CMTime.positiveInfinity, .zero)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.positiveInfinity, .negativeInfinity)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.positiveInfinity, .indefinite)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.positiveInfinity, .invalid)).to(beFalse())
+ }
+
+ func testCloseWithMinusInfiniteValues() {
+ expect(CMTime.close(within: 0)(CMTime.negativeInfinity, .negativeInfinity)).to(beTrue())
+ expect(CMTime.close(within: 0.5)(CMTime.negativeInfinity, .negativeInfinity)).to(beTrue())
+
+ expect(CMTime.close(within: 10000)(CMTime.negativeInfinity, .zero)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.negativeInfinity, .positiveInfinity)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.negativeInfinity, .indefinite)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.negativeInfinity, .invalid)).to(beFalse())
+ }
+
+ func testCloseWithIndefiniteValues() {
+ expect(CMTime.close(within: 0)(CMTime.indefinite, .indefinite)).to(beTrue())
+ expect(CMTime.close(within: 0.5)(CMTime.indefinite, .indefinite)).to(beTrue())
+
+ expect(CMTime.close(within: 10000)(CMTime.indefinite, .zero)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.indefinite, .positiveInfinity)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.indefinite, .negativeInfinity)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.indefinite, .invalid)).to(beFalse())
+ }
+
+ func testCloseWithInvalidValues() {
+ expect(CMTime.close(within: 0)(CMTime.invalid, .invalid)).to(beTrue())
+ expect(CMTime.close(within: 0.5)(CMTime.invalid, .invalid)).to(beTrue())
+
+ expect(CMTime.close(within: 10000)(CMTime.invalid, .zero)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.invalid, .positiveInfinity)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.invalid, .negativeInfinity)).to(beFalse())
+ expect(CMTime.close(within: 10000)(CMTime.invalid, .indefinite)).to(beFalse())
+ }
+
+ func testTimeRangeIsValidAndNotEmpty() {
+ expect(CMTimeRange.invalid.isValidAndNotEmpty).to(beFalse())
+ expect(CMTimeRange.zero.isValidAndNotEmpty).to(beFalse())
+ expect(CMTimeRange(
+ start: CMTime(value: 1, timescale: 1),
+ end: CMTime(value: 1, timescale: 1)
+ ).isValidAndNotEmpty).to(beFalse())
+ expect(CMTimeRange(
+ start: CMTime(value: 0, timescale: 1),
+ end: CMTime(value: 1, timescale: 1)
+ ).isValidAndNotEmpty).to(beTrue())
+ }
+}
diff --git a/Tests/CoreTests/Tools.swift b/Tests/CoreTests/Tools.swift
new file mode 100644
index 00000000..53b84ec1
--- /dev/null
+++ b/Tests/CoreTests/Tools.swift
@@ -0,0 +1,21 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import Foundation
+
+final class TestNSObject: NSObject {}
+
+final class TestObject {
+ let identifier: String
+
+ init(identifier: String = UUID().uuidString) {
+ self.identifier = identifier
+ }
+}
+
+extension Notification.Name {
+ static let testNotification = Notification.Name("TestNotification")
+}
diff --git a/Tests/CoreTests/TriggerTests.swift b/Tests/CoreTests/TriggerTests.swift
new file mode 100644
index 00000000..493f225d
--- /dev/null
+++ b/Tests/CoreTests/TriggerTests.swift
@@ -0,0 +1,47 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import PillarboxCircumspect
+import XCTest
+
+final class TriggerTests: XCTestCase {
+ func testInactive() {
+ let trigger = Trigger()
+ expectNothingPublished(from: trigger.signal(activatedBy: 1), during: .seconds(1))
+ }
+
+ func testActiveWithSignal() {
+ let trigger = Trigger()
+ expectAtLeastEqualPublished(values: ["out"], from: trigger.signal(activatedBy: 1).map { _ in "out" }) {
+ trigger.activate(for: 1)
+ }
+ }
+
+ func testMultipleActivations() {
+ let trigger = Trigger()
+ expectAtLeastEqualPublished(values: ["out", "out"], from: trigger.signal(activatedBy: 1).map { _ in "out" }) {
+ trigger.activate(for: 1)
+ trigger.activate(for: 1)
+ }
+ }
+
+ func testDifferentActivationIndex() {
+ let trigger = Trigger()
+ expectNothingPublished(from: trigger.signal(activatedBy: 1), during: .seconds(1)) {
+ trigger.activate(for: 2)
+ }
+ }
+
+ func testHashableActivationIndex() {
+ let trigger = Trigger()
+ expectEqualPublished(values: ["out"], from: trigger.signal(activatedBy: "index").map { _ in "out" }, during: .seconds(1)) {
+ trigger.activate(for: "index")
+ }
+ }
+}
diff --git a/Tests/CoreTests/WaitPublisherTests.swift b/Tests/CoreTests/WaitPublisherTests.swift
new file mode 100644
index 00000000..d5fe9b8b
--- /dev/null
+++ b/Tests/CoreTests/WaitPublisherTests.swift
@@ -0,0 +1,25 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import PillarboxCircumspect
+import XCTest
+
+final class WaitPublisherTests: XCTestCase {
+ func testWait() {
+ let signal = PassthroughSubject()
+
+ let publisher = Just("Received")
+ .wait(untilOutputFrom: signal)
+ expectNothingPublished(from: publisher, during: .milliseconds(100))
+
+ expectEqualPublished(values: ["Received"], from: publisher, during: .milliseconds(100)) {
+ signal.send(())
+ }
+ }
+}
diff --git a/Tests/CoreTests/WeakCapturePublisherTests.swift b/Tests/CoreTests/WeakCapturePublisherTests.swift
new file mode 100644
index 00000000..49b55c97
--- /dev/null
+++ b/Tests/CoreTests/WeakCapturePublisherTests.swift
@@ -0,0 +1,39 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import Nimble
+import XCTest
+
+final class WeakCapturePublisherTests: XCTestCase {
+ func testDeallocation() {
+ var object: TestObject? = TestObject()
+ let publisher = Just("output")
+ .weakCapture(object)
+
+ weak var weakObject = object
+ autoreleasepool {
+ object = nil
+ }
+ expect(weakObject).to(beNil())
+
+ expectNothingPublished(from: publisher, during: .seconds(1))
+ }
+
+ func testDelivery() {
+ let object = TestObject(identifier: "weak_capture")
+ let publisher = Just("output")
+ .weakCapture(object, at: \.identifier)
+ expectAtLeastPublished(
+ values: [("output", "weak_capture")],
+ from: publisher
+ ) { output1, output2 in
+ output1.0 == output2.0 && output1.1 == output2.1
+ }
+ }
+}
diff --git a/Tests/CoreTests/WithPreviousPublisherTests.swift b/Tests/CoreTests/WithPreviousPublisherTests.swift
new file mode 100644
index 00000000..7cd92f10
--- /dev/null
+++ b/Tests/CoreTests/WithPreviousPublisherTests.swift
@@ -0,0 +1,45 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxCore
+
+import Combine
+import PillarboxCircumspect
+import XCTest
+
+final class WithPreviousPublisherTests: XCTestCase {
+ func testEmpty() {
+ expectNothingPublished(from: Empty().withPrevious(), during: .seconds(1))
+ }
+
+ func testPreviousValues() {
+ expectAtLeastEqualPublished(
+ values: [nil, 1, 2, 3, 4],
+ from: (1...5).publisher.withPrevious().map(\.previous)
+ )
+ }
+
+ func testCurrentValues() {
+ expectAtLeastEqualPublished(
+ values: [1, 2, 3, 4, 5],
+ from: (1...5).publisher.withPrevious().map(\.current)
+ )
+ }
+
+ func testOptionalPreviousValues() {
+ expectAtLeastEqualPublished(
+ values: [-1, 1, 2, 3, 4],
+ from: (1...5).publisher.withPrevious(-1).map(\.previous)
+ )
+ }
+
+ func testOptionalCurrentValues() {
+ expectAtLeastEqualPublished(
+ values: [1, 2, 3, 4, 5],
+ from: (1...5).publisher.withPrevious(-1).map(\.current)
+ )
+ }
+}
diff --git a/Tests/MonitoringTests/MetricHitExpectation.swift b/Tests/MonitoringTests/MetricHitExpectation.swift
new file mode 100644
index 00000000..9b6f33ce
--- /dev/null
+++ b/Tests/MonitoringTests/MetricHitExpectation.swift
@@ -0,0 +1,67 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxMonitoring
+
+private struct _MetricHitExpectation: MetricHitExpectation where Data: Encodable {
+ let eventName: EventName
+ private let evaluate: (MetricPayload) -> Void
+
+ init(eventName: EventName, evaluate: @escaping (MetricPayload) -> Void) {
+ self.eventName = eventName
+ self.evaluate = evaluate
+ }
+
+ func evaluate(_ data: MetricPayload) {
+ evaluate(data)
+ }
+}
+
+protocol MetricHitExpectation {
+ associatedtype Data: Encodable
+
+ var eventName: EventName { get }
+
+ func evaluate(_ data: MetricPayload)
+}
+
+private extension MetricHitExpectation {
+ func match(payload: any Encodable, with expectation: any MetricHitExpectation) -> Bool {
+ guard let payload = payload as? MetricPayload, payload.eventName == expectation.eventName else {
+ return false
+ }
+ evaluate(payload)
+ return true
+ }
+}
+
+extension _MetricHitExpectation: CustomDebugStringConvertible {
+ var debugDescription: String {
+ eventName.rawValue
+ }
+}
+
+func match(payload: any Encodable, with expectation: any MetricHitExpectation) -> Bool {
+ expectation.match(payload: payload, with: expectation)
+}
+
+extension MonitoringTestCase {
+ func error(evaluate: @escaping (MetricPayload) -> Void = { _ in }) -> some MetricHitExpectation {
+ _MetricHitExpectation(eventName: .error, evaluate: evaluate)
+ }
+
+ func heartbeat(evaluate: @escaping (MetricPayload) -> Void = { _ in }) -> some MetricHitExpectation {
+ _MetricHitExpectation(eventName: .heartbeat, evaluate: evaluate)
+ }
+
+ func start(evaluate: @escaping (MetricPayload) -> Void = { _ in }) -> some MetricHitExpectation {
+ _MetricHitExpectation(eventName: .start, evaluate: evaluate)
+ }
+
+ func stop(evaluate: @escaping (MetricPayload) -> Void = { _ in }) -> some MetricHitExpectation {
+ _MetricHitExpectation(eventName: .stop, evaluate: evaluate)
+ }
+}
diff --git a/Tests/MonitoringTests/MetricPayload.swift b/Tests/MonitoringTests/MetricPayload.swift
new file mode 100644
index 00000000..d18f3a8d
--- /dev/null
+++ b/Tests/MonitoringTests/MetricPayload.swift
@@ -0,0 +1,13 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxMonitoring
+
+extension MetricPayload: CustomDebugStringConvertible {
+ public var debugDescription: String {
+ eventName.rawValue
+ }
+}
diff --git a/Tests/MonitoringTests/MetricsTracker.swift b/Tests/MonitoringTests/MetricsTracker.swift
new file mode 100644
index 00000000..0a7a580d
--- /dev/null
+++ b/Tests/MonitoringTests/MetricsTracker.swift
@@ -0,0 +1,27 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import Foundation
+import PillarboxMonitoring
+
+extension MetricsTracker.Configuration {
+ static let test = MetricsTracker.Configuration(
+ serviceUrl: URL(string: "https://localhost/ingest")!
+ )
+
+ static let heartbeatTest = MetricsTracker.Configuration(
+ serviceUrl: URL(string: "https://localhost/ingest")!,
+ heartbeatInterval: 1
+ )
+}
+
+extension MetricsTracker.Metadata {
+ static let test = MetricsTracker.Metadata(
+ identifier: "identifier",
+ metadataUrl: URL(string: "https://localhost/metadata.json"),
+ assetUrl: URL(string: "https://localhost/asset.m3u8")
+ )
+}
diff --git a/Tests/MonitoringTests/MetricsTrackerTests.swift b/Tests/MonitoringTests/MetricsTrackerTests.swift
new file mode 100644
index 00000000..aa549333
--- /dev/null
+++ b/Tests/MonitoringTests/MetricsTrackerTests.swift
@@ -0,0 +1,225 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxMonitoring
+
+import Nimble
+import PillarboxCircumspect
+import PillarboxPlayer
+import PillarboxStreams
+import XCTest
+
+final class MetricsTrackerTests: MonitoringTestCase {
+ func testEntirePlayback() {
+ let player = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .test) { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ start(),
+ heartbeat(),
+ stop { payload in
+ expect(payload.data.position).to(beCloseTo(1000, within: 100))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testError() {
+ let player = Player(item: .simple(
+ url: Stream.unavailable.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .test) { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ start(),
+ error { payload in
+ let data = payload.data
+ expect(data.name).to(equal("NSURLErrorDomain(-1100)"))
+ expect(data.message).to(equal("The requested URL was not found on this server."))
+ expect(data.position).to(beNil())
+ expect(data.vpn).to(beFalse())
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testNoStopWithoutStart() {
+ var player: Player? = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .test) { _ in .test }
+ ]
+ ))
+ _ = player
+ expectNoHits(during: .milliseconds(500)) {
+ player = nil
+ }
+ }
+
+ func testHeartbeats() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .heartbeatTest) { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(start(), heartbeat(), heartbeat()) {
+ player.play()
+ }
+ }
+
+ func testSessionIdentifierRenewalWhenReplayingAfterEnd() {
+ let player = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .test) { _ in .test }
+ ]
+ ))
+ var sessionId: String?
+ expectAtLeastHits(
+ start { payload in
+ sessionId = payload.sessionId
+ },
+ heartbeat { payload in
+ expect(payload.sessionId).to(equal(sessionId))
+ },
+ stop { payload in
+ expect(payload.sessionId).to(equal(sessionId))
+ }
+ ) {
+ player.play()
+ }
+ expectAtLeastHits(
+ start { payload in
+ expect(payload.sessionId).notTo(equal(sessionId))
+ }
+ ) {
+ player.replay()
+ }
+ }
+
+ func testSessionIdentifierRenewalWhenReplayingAfterFatalError() {
+ let player = Player(item: .simple(
+ url: Stream.unavailable.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .test) { _ in .test }
+ ]
+ ))
+ var sessionId: String?
+ expectAtLeastHits(
+ start { payload in
+ sessionId = payload.sessionId
+ },
+ error { payload in
+ expect(payload.sessionId).to(equal(sessionId))
+ }
+ )
+ expectAtLeastHits(
+ start { payload in
+ expect(payload.sessionId).notTo(equal(sessionId))
+ }
+ ) {
+ player.replay()
+ }
+ }
+
+ func testSessionIdentifierClearedAfterPlaybackEnd() {
+ let player = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .test) { _ in .test }
+ ]
+ ))
+ expectAtLeastHits(
+ start(),
+ heartbeat(),
+ stop()
+ ) {
+ player.play()
+ }
+ expect(player.currentSessionIdentifiers(trackedBy: MetricsTracker.self)).to(beEmpty())
+ }
+
+ func testSessionIdentifierPersistenceAfterFatalError() {
+ let player = Player(item: .simple(
+ url: Stream.unavailable.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .test) { _ in .test }
+ ]
+ ))
+ var sessionId: String?
+ expectAtLeastHits(
+ start { payload in
+ sessionId = payload.sessionId
+ },
+ error()
+ )
+ expect(player.currentSessionIdentifiers(trackedBy: MetricsTracker.self)).to(equalDiff([sessionId!]))
+ }
+
+ func testPayloads() {
+ let player = Player(item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .test) { .test }
+ ]
+ ))
+ expectAtLeastHits(
+ start { payload in
+ expect(payload.version).to(equal(1))
+
+ let data = payload.data
+
+ let device = data.device
+ expect(device.id).notTo(beNil())
+ expect(device.model).notTo(beNil())
+ expect(device.type).notTo(beNil())
+
+ let media = data.media
+ expect(media.assetUrl).to(equal(URL(string: "https://localhost/asset.m3u8")))
+ expect(media.id).to(equal("identifier"))
+ expect(media.metadataUrl).to(equal(URL(string: "https://localhost/metadata.json")))
+ expect(media.origin).notTo(beNil())
+
+ let os = data.os
+ expect(os.name).notTo(beNil())
+ expect(os.version).notTo(beNil())
+
+ let player = data.player
+ expect(player.name).to(equal("Pillarbox"))
+ expect(player.version).to(equal(Player.version))
+ },
+ heartbeat { payload in
+ expect(payload.version).to(equal(1))
+
+ let data = payload.data
+ expect(data.airplay).to(beFalse())
+ expect(data.streamType).to(equal("On-demand"))
+ }
+ ) {
+ player.play()
+ }
+ }
+
+ func testRepeatOne() {
+ let player = Player(item: .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ MetricsTracker.adapter(configuration: .test) { _ in .test }
+ ]
+ ))
+ player.repeatMode = .one
+ expectAtLeastHits(start(), heartbeat(), stop(), start(), heartbeat(), stop()) {
+ player.play()
+ }
+ }
+}
diff --git a/Tests/MonitoringTests/MonitoringTestCase.swift b/Tests/MonitoringTests/MonitoringTestCase.swift
new file mode 100644
index 00000000..c219fe26
--- /dev/null
+++ b/Tests/MonitoringTests/MonitoringTestCase.swift
@@ -0,0 +1,75 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxMonitoring
+
+import Dispatch
+import PillarboxCircumspect
+import XCTest
+
+class MonitoringTestCase: XCTestCase {}
+
+extension MonitoringTestCase {
+ /// Collects metric hits during some time interval and matches them against expectations.
+ func expectHits(
+ _ expectations: any MetricHitExpectation...,
+ during interval: DispatchTimeInterval = .seconds(20),
+ file: StaticString = #file,
+ line: UInt = #line,
+ while executing: (() -> Void)? = nil
+ ) {
+ MetricHitListener.captureMetricHits { publisher in
+ expectPublished(
+ values: expectations,
+ from: publisher,
+ to: match(payload:with:),
+ during: interval,
+ file: file,
+ line: line,
+ while: executing
+ )
+ }
+ }
+
+ /// Expects metric hits during some time interval and matches them against expectations.
+ func expectAtLeastHits(
+ _ expectations: any MetricHitExpectation...,
+ timeout: DispatchTimeInterval = .seconds(20),
+ file: StaticString = #file,
+ line: UInt = #line,
+ while executing: (() -> Void)? = nil
+ ) {
+ MetricHitListener.captureMetricHits { publisher in
+ expectAtLeastPublished(
+ values: expectations,
+ from: publisher,
+ to: match(payload:with:),
+ timeout: timeout,
+ file: file,
+ line: line,
+ while: executing
+ )
+ }
+ }
+
+ /// Expects no metric hits during some time interval.
+ func expectNoHits(
+ during interval: DispatchTimeInterval = .seconds(20),
+ file: StaticString = #file,
+ line: UInt = #line,
+ while executing: (() -> Void)? = nil
+ ) {
+ MetricHitListener.captureMetricHits { publisher in
+ expectNothingPublished(
+ from: publisher,
+ during: interval,
+ file: file,
+ line: line,
+ while: executing
+ )
+ }
+ }
+}
diff --git a/Tests/MonitoringTests/TrackingSessionTests.swift b/Tests/MonitoringTests/TrackingSessionTests.swift
new file mode 100644
index 00000000..2032f791
--- /dev/null
+++ b/Tests/MonitoringTests/TrackingSessionTests.swift
@@ -0,0 +1,41 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxMonitoring
+
+import Nimble
+import XCTest
+
+final class TrackingSessionTests: XCTestCase {
+ func testEmpty() {
+ let session = TrackingSession()
+ expect(session.id).to(beNil())
+ expect(session.isStarted).to(beFalse())
+ }
+
+ func testStart() {
+ var session = TrackingSession()
+ session.start()
+ expect(session.id).notTo(beNil())
+ expect(session.isStarted).to(beTrue())
+ }
+
+ func testStop() {
+ var session = TrackingSession()
+ session.start()
+ session.stop()
+ expect(session.id).notTo(beNil())
+ expect(session.isStarted).to(beFalse())
+ }
+
+ func testReset() {
+ var session = TrackingSession()
+ session.start()
+ session.reset()
+ expect(session.id).to(beNil())
+ expect(session.isStarted).to(beFalse())
+ }
+}
diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatAllUpdateTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatAllUpdateTests.swift
new file mode 100644
index 00000000..e8f55d8d
--- /dev/null
+++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatAllUpdateTests.swift
@@ -0,0 +1,189 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxCircumspect
+
+final class AVPlayerItemRepeatAllUpdateTests: TestCase {
+ func testPlayerItemsWithoutCurrentItem() {
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2"),
+ .test(id: "3"),
+ .test(id: "4"),
+ .test(id: "5")
+ ]
+ let currentContents: [AssetContent] = [
+ .test(id: "A"),
+ .test(id: "B"),
+ .test(id: "C")
+ ]
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: nil,
+ repeatMode: .all,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B"), UUID("C"), UUID("A")]))
+ }
+
+ func testPlayerItemsWithPreservedCurrentItem() {
+ let currentItemContent = AssetContent.test(id: "3")
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2"),
+ currentItemContent,
+ .test(id: "4"),
+ .test(id: "5")
+ ]
+ let currentContents = [
+ .test(id: "A"),
+ currentItemContent,
+ .test(id: "B"),
+ .test(id: "C")
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .all,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("B"), UUID("C"), UUID("A")]))
+ expect(items.first).to(equal(currentItem))
+ }
+
+ func testPlayerItemsWithPreservedCurrentItemAtEnd() {
+ let currentItemContent = AssetContent.test(id: "3")
+ let previousContents = [
+ .test(id: "1"),
+ .test(id: "2"),
+ currentItemContent,
+ .test(id: "4"),
+ .test(id: "5")
+ ]
+ let currentContents = [
+ .test(id: "A"),
+ .test(id: "B"),
+ .test(id: "C"),
+ currentItemContent
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .all,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("A")]))
+ expect(items.first).to(equal(currentItem))
+ }
+
+ func testPlayerItemsWithUnknownCurrentItem() {
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2")
+ ]
+ let currentContents: [AssetContent] = [
+ .test(id: "A"),
+ .test(id: "B")
+ ]
+ let unknownItem = AssetContent.test(id: "1").playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: unknownItem,
+ repeatMode: .all,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B"), UUID("A")]))
+ }
+
+ func testPlayerItemsWithCurrentItemReplacedByAnotherItem() {
+ let currentItemContent = AssetContent.test(id: "1")
+ let otherContent = AssetContent.test(id: "2")
+ let previousContents = [
+ currentItemContent,
+ otherContent,
+ .test(id: "3")
+ ]
+ let currentContents = [
+ .test(id: "3"),
+ otherContent,
+ .test(id: "C")
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .all,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("2"), UUID("C"), UUID("3")]))
+ }
+
+ func testPlayerItemsWithUpdatedCurrentItem() {
+ let currentItemContent = AssetContent.test(id: "1")
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2"),
+ .test(id: "3")
+ ]
+ let currentContents = [
+ currentItemContent,
+ .test(id: "2"),
+ .test(id: "3")
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .all,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("1"), UUID("2"), UUID("3"), UUID("1")]))
+ expect(items.first).to(equal(currentItem))
+ }
+
+ func testPlayerItemsLength() {
+ let currentContents: [AssetContent] = [
+ .test(id: "A"),
+ .test(id: "B"),
+ .test(id: "C"),
+ .test(id: "D")
+ ]
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: [],
+ currentItem: nil,
+ repeatMode: .all,
+ length: 2,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B")]))
+ }
+}
diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOffUpdateTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOffUpdateTests.swift
new file mode 100644
index 00000000..8930e0bf
--- /dev/null
+++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOffUpdateTests.swift
@@ -0,0 +1,189 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxCircumspect
+
+final class AVPlayerItemRepeatOffUpdateTests: TestCase {
+ func testPlayerItemsWithoutCurrentItem() {
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2"),
+ .test(id: "3"),
+ .test(id: "4"),
+ .test(id: "5")
+ ]
+ let currentContents: [AssetContent] = [
+ .test(id: "A"),
+ .test(id: "B"),
+ .test(id: "C")
+ ]
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: nil,
+ repeatMode: .off,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B"), UUID("C")]))
+ }
+
+ func testPlayerItemsWithPreservedCurrentItem() {
+ let currentItemContent = AssetContent.test(id: "3")
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2"),
+ currentItemContent,
+ .test(id: "4"),
+ .test(id: "5")
+ ]
+ let currentContents = [
+ .test(id: "A"),
+ currentItemContent,
+ .test(id: "B"),
+ .test(id: "C")
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .off,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("B"), UUID("C")]))
+ expect(items.first).to(equal(currentItem))
+ }
+
+ func testPlayerItemsWithPreservedCurrentItemAtEnd() {
+ let currentItemContent = AssetContent.test(id: "3")
+ let previousContents = [
+ .test(id: "1"),
+ .test(id: "2"),
+ currentItemContent,
+ .test(id: "4"),
+ .test(id: "5")
+ ]
+ let currentContents = [
+ .test(id: "A"),
+ .test(id: "B"),
+ .test(id: "C"),
+ currentItemContent
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .off,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("3")]))
+ expect(items.first).to(equal(currentItem))
+ }
+
+ func testPlayerItemsWithUnknownCurrentItem() {
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2")
+ ]
+ let currentContents: [AssetContent] = [
+ .test(id: "A"),
+ .test(id: "B")
+ ]
+ let unknownItem = AssetContent.test(id: "1").playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: unknownItem,
+ repeatMode: .off,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B")]))
+ }
+
+ func testPlayerItemsWithCurrentItemReplacedByAnotherItem() {
+ let currentItemContent = AssetContent.test(id: "1")
+ let otherContent = AssetContent.test(id: "2")
+ let previousContents = [
+ currentItemContent,
+ otherContent,
+ .test(id: "3")
+ ]
+ let currentContents = [
+ .test(id: "3"),
+ otherContent,
+ .test(id: "C")
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .off,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("2"), UUID("C")]))
+ }
+
+ func testPlayerItemsWithUpdatedCurrentItem() {
+ let currentItemContent = AssetContent.test(id: "1")
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2"),
+ .test(id: "3")
+ ]
+ let currentContents = [
+ currentItemContent,
+ .test(id: "2"),
+ .test(id: "3")
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .off,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("1"), UUID("2"), UUID("3")]))
+ expect(items.first).to(equal(currentItem))
+ }
+
+ func testPlayerItemsLength() {
+ let currentContents: [AssetContent] = [
+ .test(id: "A"),
+ .test(id: "B"),
+ .test(id: "C"),
+ .test(id: "D")
+ ]
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: [],
+ currentItem: nil,
+ repeatMode: .off,
+ length: 2,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("B")]))
+ }
+}
diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOneUpdateTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOneUpdateTests.swift
new file mode 100644
index 00000000..3c863d83
--- /dev/null
+++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemRepeatOneUpdateTests.swift
@@ -0,0 +1,189 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxCircumspect
+
+final class AVPlayerItemRepeatOneUpdateTests: TestCase {
+ func testPlayerItemsWithoutCurrentItem() {
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2"),
+ .test(id: "3"),
+ .test(id: "4"),
+ .test(id: "5")
+ ]
+ let currentContents: [AssetContent] = [
+ .test(id: "A"),
+ .test(id: "B"),
+ .test(id: "C")
+ ]
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: nil,
+ repeatMode: .one,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("A"), UUID("B"), UUID("C")]))
+ }
+
+ func testPlayerItemsWithPreservedCurrentItem() {
+ let currentItemContent = AssetContent.test(id: "3")
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2"),
+ currentItemContent,
+ .test(id: "4"),
+ .test(id: "5")
+ ]
+ let currentContents = [
+ .test(id: "A"),
+ currentItemContent,
+ .test(id: "B"),
+ .test(id: "C")
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .one,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("3"), UUID("B"), UUID("C")]))
+ expect(items.first).to(equal(currentItem))
+ }
+
+ func testPlayerItemsWithPreservedCurrentItemAtEnd() {
+ let currentItemContent = AssetContent.test(id: "3")
+ let previousContents = [
+ .test(id: "1"),
+ .test(id: "2"),
+ currentItemContent,
+ .test(id: "4"),
+ .test(id: "5")
+ ]
+ let currentContents = [
+ .test(id: "A"),
+ .test(id: "B"),
+ .test(id: "C"),
+ currentItemContent
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .one,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("3"), UUID("3")]))
+ expect(items.first).to(equal(currentItem))
+ }
+
+ func testPlayerItemsWithUnknownCurrentItem() {
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2")
+ ]
+ let currentContents: [AssetContent] = [
+ .test(id: "A"),
+ .test(id: "B")
+ ]
+ let unknownItem = AssetContent.test(id: "1").playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: unknownItem,
+ repeatMode: .one,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("A"), UUID("B")]))
+ }
+
+ func testPlayerItemsWithCurrentItemReplacedByAnotherItem() {
+ let currentItemContent = AssetContent.test(id: "1")
+ let otherContent = AssetContent.test(id: "2")
+ let previousContents = [
+ currentItemContent,
+ otherContent,
+ .test(id: "3")
+ ]
+ let currentContents = [
+ .test(id: "3"),
+ otherContent,
+ .test(id: "C")
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .one,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("2"), UUID("2"), UUID("C")]))
+ }
+
+ func testPlayerItemsWithUpdatedCurrentItem() {
+ let currentItemContent = AssetContent.test(id: "1")
+ let previousContents: [AssetContent] = [
+ .test(id: "1"),
+ .test(id: "2"),
+ .test(id: "3")
+ ]
+ let currentContents = [
+ currentItemContent,
+ .test(id: "2"),
+ .test(id: "3")
+ ]
+ let currentItem = currentItemContent.playerItem(configuration: .default, limits: .none)
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: previousContents,
+ currentItem: currentItem,
+ repeatMode: .one,
+ length: .max,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("1"), UUID("1"), UUID("2"), UUID("3")]))
+ expect(items.first).to(equal(currentItem))
+ }
+
+ func testPlayerItemsLength() {
+ let currentContents: [AssetContent] = [
+ .test(id: "A"),
+ .test(id: "B"),
+ .test(id: "C"),
+ .test(id: "D")
+ ]
+ let items = AVPlayerItem.playerItems(
+ for: currentContents,
+ replacing: [],
+ currentItem: nil,
+ repeatMode: .one,
+ length: 2,
+ configuration: .default,
+ limits: .none
+ )
+ expect(items.map(\.id)).to(equalDiff([UUID("A"), UUID("A")]))
+ }
+}
diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift
new file mode 100644
index 00000000..07de1710
--- /dev/null
+++ b/Tests/PlayerTests/AVPlayer/AVPlayerItemTests.swift
@@ -0,0 +1,101 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxStreams
+
+final class AVPlayerItemTests: TestCase {
+ func testNonLoadedItem() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ expect(item.timeRange).toAlways(equal(.invalid), until: .seconds(1))
+ }
+
+ func testOnDemand() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ _ = AVPlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+ }
+
+ func testPlayerItemsWithRepeatOff() {
+ let items = [
+ PlayerItem.simple(url: Stream.onDemand.url),
+ PlayerItem.simple(url: Stream.shortOnDemand.url),
+ PlayerItem.simple(url: Stream.live.url)
+ ]
+ expect {
+ AVPlayerItem.playerItems(
+ from: items,
+ after: 0,
+ repeatMode: .off,
+ length: .max,
+ reload: false,
+ configuration: .default,
+ limits: .none
+ )
+ .compactMap(\.url)
+ }
+ .toEventually(equal([
+ Stream.onDemand.url,
+ Stream.shortOnDemand.url,
+ Stream.live.url
+ ]))
+ }
+
+ func testPlayerItemsWithRepeatOne() {
+ let items = [
+ PlayerItem.simple(url: Stream.onDemand.url),
+ PlayerItem.simple(url: Stream.shortOnDemand.url),
+ PlayerItem.simple(url: Stream.live.url)
+ ]
+ expect {
+ AVPlayerItem.playerItems(
+ from: items,
+ after: 0,
+ repeatMode: .one,
+ length: .max,
+ reload: false,
+ configuration: .default,
+ limits: .none
+ )
+ .compactMap(\.url)
+ }
+ .toEventually(equal([
+ Stream.onDemand.url,
+ Stream.onDemand.url,
+ Stream.shortOnDemand.url,
+ Stream.live.url
+ ]))
+ }
+
+ func testPlayerItemsWithRepeatAll() {
+ let items = [
+ PlayerItem.simple(url: Stream.onDemand.url),
+ PlayerItem.simple(url: Stream.shortOnDemand.url),
+ PlayerItem.simple(url: Stream.live.url)
+ ]
+ expect {
+ AVPlayerItem.playerItems(
+ from: items,
+ after: 0,
+ repeatMode: .all,
+ length: .max,
+ reload: false,
+ configuration: .default,
+ limits: .none
+ )
+ .compactMap(\.url)
+ }
+ .toEventually(equal([
+ Stream.onDemand.url,
+ Stream.shortOnDemand.url,
+ Stream.live.url,
+ Stream.onDemand.url
+ ]))
+ }
+}
diff --git a/Tests/PlayerTests/AVPlayer/AVPlayerTests.swift b/Tests/PlayerTests/AVPlayer/AVPlayerTests.swift
new file mode 100644
index 00000000..d7a7351e
--- /dev/null
+++ b/Tests/PlayerTests/AVPlayer/AVPlayerTests.swift
@@ -0,0 +1,50 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxStreams
+
+final class AVPlayerTests: TestCase {
+ func testTimeRangeWhenEmpty() {
+ let player = AVPlayer()
+ expect(player.timeRange).to(equal(.invalid))
+ }
+
+ func testTimeRangeForOnDemand() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = AVPlayer(playerItem: item)
+ expect(player.timeRange).toEventually(equal(CMTimeRange(start: .zero, duration: Stream.onDemand.duration)))
+ }
+
+ func testDurationWhenEmpty() {
+ let player = AVPlayer()
+ expect(player.duration).to(equal(.invalid))
+ }
+
+ func testDurationForOnDemand() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = AVPlayer(playerItem: item)
+ expect(player.duration).to(equal(.invalid))
+ expect(player.duration).toEventually(equal(Stream.onDemand.duration))
+ }
+
+ func testDurationForLive() {
+ let item = AVPlayerItem(url: Stream.live.url)
+ let player = AVPlayer(playerItem: item)
+ expect(player.duration).to(equal(.invalid))
+ expect(player.duration).toEventually(equal(.indefinite))
+ }
+
+ func testDurationForDvr() {
+ let item = AVPlayerItem(url: Stream.dvr.url)
+ let player = AVPlayer(playerItem: item)
+ expect(player.duration).to(equal(.invalid))
+ expect(player.duration).toEventually(equal(.indefinite))
+ }
+}
diff --git a/Tests/PlayerTests/Asset/AssetCreationTests.swift b/Tests/PlayerTests/Asset/AssetCreationTests.swift
new file mode 100644
index 00000000..6e6def6b
--- /dev/null
+++ b/Tests/PlayerTests/Asset/AssetCreationTests.swift
@@ -0,0 +1,29 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class AssetCreationTests: TestCase {
+ func testSimpleAsset() {
+ let asset = Asset.simple(url: Stream.onDemand.url)
+ expect(asset.resource).to(equal(.simple(url: Stream.onDemand.url)))
+ }
+
+ func testCustomAsset() {
+ let delegate = ResourceLoaderDelegateMock()
+ let asset = Asset.custom(url: Stream.onDemand.url, delegate: delegate)
+ expect(asset.resource).to(equal(.custom(url: Stream.onDemand.url, delegate: delegate)))
+ }
+
+ func testEncryptedAsset() {
+ let delegate = ContentKeySessionDelegateMock()
+ let asset = Asset.encrypted(url: Stream.onDemand.url, delegate: delegate)
+ expect(asset.resource).to(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate)))
+ }
+}
diff --git a/Tests/PlayerTests/Asset/AssetMetadataMock.swift b/Tests/PlayerTests/Asset/AssetMetadataMock.swift
new file mode 100644
index 00000000..37943c2f
--- /dev/null
+++ b/Tests/PlayerTests/Asset/AssetMetadataMock.swift
@@ -0,0 +1,23 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import PillarboxPlayer
+
+struct AssetMetadataMock: Decodable {
+ let title: String
+ let subtitle: String?
+
+ init(title: String, subtitle: String? = nil) {
+ self.title = title
+ self.subtitle = subtitle
+ }
+}
+
+extension AssetMetadataMock: AssetMetadata {
+ var playerMetadata: PlayerMetadata {
+ .init(title: title, subtitle: subtitle)
+ }
+}
diff --git a/Tests/PlayerTests/Asset/ResourceItemTests.swift b/Tests/PlayerTests/Asset/ResourceItemTests.swift
new file mode 100644
index 00000000..78521d4c
--- /dev/null
+++ b/Tests/PlayerTests/Asset/ResourceItemTests.swift
@@ -0,0 +1,41 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ResourceItemTests: TestCase {
+ func testNativePlayerItem() {
+ let item = Resource.simple(url: Stream.onDemand.url).playerItem(configuration: .default, limits: .none)
+ _ = AVPlayer(playerItem: item)
+ expectAtLeastEqualPublished(
+ values: [false, true],
+ from: item.publisher(for: \.isPlaybackLikelyToKeepUp)
+ )
+ }
+
+ func testLoadingPlayerItem() {
+ let item = Resource.loading.playerItem(configuration: .default, limits: .none)
+ _ = AVPlayer(playerItem: item)
+ expectAtLeastEqualPublished(
+ values: [false],
+ from: item.publisher(for: \.isPlaybackLikelyToKeepUp)
+ )
+ }
+
+ func testFailingPlayerItem() {
+ let item = Resource.failing(error: StructError()).playerItem(configuration: .default, limits: .none)
+ _ = AVPlayer(playerItem: item)
+ expectEqualPublished(
+ values: [.unknown],
+ from: item.statusPublisher(),
+ during: .seconds(1)
+ )
+ }
+}
diff --git a/Tests/PlayerTests/AudioSession/AVAudioSessionNotificationTests.swift b/Tests/PlayerTests/AudioSession/AVAudioSessionNotificationTests.swift
new file mode 100644
index 00000000..b0e5489c
--- /dev/null
+++ b/Tests/PlayerTests/AudioSession/AVAudioSessionNotificationTests.swift
@@ -0,0 +1,71 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFAudio
+import Nimble
+
+final class AVAudioSessionNotificationTests: TestCase {
+ override func setUp() {
+ AVAudioSession.enableUpdateNotifications()
+ try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .default, policy: .default, options: [])
+ }
+
+ func testUpdateWithSetCategoryModePolicyOptions() throws {
+ let audioSession = AVAudioSession.sharedInstance()
+ expect {
+ try audioSession.setCategory(.playback, mode: .default, policy: .default, options: [.duckOthers])
+ }.to(postNotifications(equal([
+ Notification(name: .didUpdateAudioSessionOptions, object: audioSession)
+ ])))
+ }
+
+ func testNoUpdateWithSetCategoryModePolicyOptions() throws {
+ let audioSession = AVAudioSession.sharedInstance()
+ expect {
+ try audioSession.setCategory(.playback, mode: .default, policy: .default, options: [])
+ }.notTo(postNotifications(equal([
+ Notification(name: .didUpdateAudioSessionOptions, object: audioSession)
+ ])))
+ }
+
+ func testUpdateWithSetCategoryModeOptions() throws {
+ let audioSession = AVAudioSession.sharedInstance()
+ expect {
+ try audioSession.setCategory(.playback, mode: .default, options: [.duckOthers])
+ }.to(postNotifications(equal([
+ Notification(name: .didUpdateAudioSessionOptions, object: audioSession)
+ ])))
+ }
+
+ func testNoUpdateWithSetCategoryModeOptions() throws {
+ let audioSession = AVAudioSession.sharedInstance()
+ expect {
+ try audioSession.setCategory(.playback, mode: .default, options: [])
+ }.notTo(postNotifications(equal([
+ Notification(name: .didUpdateAudioSessionOptions, object: audioSession)
+ ])))
+ }
+
+ func testUpdateWithSetCategoryOptions() throws {
+ let audioSession = AVAudioSession.sharedInstance()
+ expect {
+ try audioSession.setCategory(.playback, options: [.duckOthers])
+ }.to(postNotifications(equal([
+ Notification(name: .didUpdateAudioSessionOptions, object: audioSession)
+ ])))
+ }
+
+ func testNoUpdateWithSetCategoryOptions() throws {
+ let audioSession = AVAudioSession.sharedInstance()
+ expect {
+ try audioSession.setCategory(.playback, options: [])
+ }.notTo(postNotifications(equal([
+ Notification(name: .didUpdateAudioSessionOptions, object: audioSession)
+ ])))
+ }
+}
diff --git a/Tests/PlayerTests/Extensions/AVPlayerItem.swift b/Tests/PlayerTests/Extensions/AVPlayerItem.swift
new file mode 100644
index 00000000..31d5751d
--- /dev/null
+++ b/Tests/PlayerTests/Extensions/AVPlayerItem.swift
@@ -0,0 +1,13 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import AVFoundation
+
+extension AVPlayerItem {
+ var url: URL? {
+ (asset as? AVURLAsset)?.url
+ }
+}
diff --git a/Tests/PlayerTests/Extensions/AssetContent.swift b/Tests/PlayerTests/Extensions/AssetContent.swift
new file mode 100644
index 00000000..51d39a87
--- /dev/null
+++ b/Tests/PlayerTests/Extensions/AssetContent.swift
@@ -0,0 +1,16 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Foundation
+import PillarboxStreams
+
+extension AssetContent {
+ static func test(id: Character) -> Self {
+ AssetContent(id: UUID(id), resource: .simple(url: Stream.onDemand.url), metadata: .empty, configuration: .default, dateInterval: nil)
+ }
+}
diff --git a/Tests/PlayerTests/Extensions/MetricEvent.swift b/Tests/PlayerTests/Extensions/MetricEvent.swift
new file mode 100644
index 00000000..8294d7ff
--- /dev/null
+++ b/Tests/PlayerTests/Extensions/MetricEvent.swift
@@ -0,0 +1,16 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+private struct AnyError: Error {}
+
+extension MetricEvent {
+ static let anyMetadata = Self(kind: .metadata(experience: .init(), service: .init()))
+ static let anyAsset = Self(kind: .asset(experience: .init()))
+ static let anyFailure = Self(kind: .failure(AnyError()))
+ static let anyWarning = Self(kind: .warning(AnyError()))
+}
diff --git a/Tests/PlayerTests/Extensions/Player.swift b/Tests/PlayerTests/Extensions/Player.swift
new file mode 100644
index 00000000..667f9715
--- /dev/null
+++ b/Tests/PlayerTests/Extensions/Player.swift
@@ -0,0 +1,15 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Foundation
+
+extension Player {
+ var urls: [URL] {
+ queuePlayer.items().compactMap(\.url)
+ }
+}
diff --git a/Tests/PlayerTests/Extensions/UUID.swift b/Tests/PlayerTests/Extensions/UUID.swift
new file mode 100644
index 00000000..5c30bf11
--- /dev/null
+++ b/Tests/PlayerTests/Extensions/UUID.swift
@@ -0,0 +1,21 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import Foundation
+
+extension UUID {
+ init(_ char: Character) {
+ self.init(
+ uuidString: """
+ \(String(repeating: char, count: 8))\
+ -\(String(repeating: char, count: 4))\
+ -\(String(repeating: char, count: 4))\
+ -\(String(repeating: char, count: 4))\
+ -\(String(repeating: char, count: 12))
+ """
+ )!
+ }
+}
diff --git a/Tests/PlayerTests/MediaSelection/AVMediaSelectionGroupTests.swift b/Tests/PlayerTests/MediaSelection/AVMediaSelectionGroupTests.swift
new file mode 100644
index 00000000..d6f2d98e
--- /dev/null
+++ b/Tests/PlayerTests/MediaSelection/AVMediaSelectionGroupTests.swift
@@ -0,0 +1,58 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+
+final class AVMediaSelectionGroupTests: TestCase {
+ func testPreferredMediaSelectionOptionsWithCharacteristics() {
+ let options: [AVMediaSelectionOptionMock] = [
+ .init(displayName: "Option 1 (music)", languageCode: "fr", characteristics: [.describesMusicAndSoundForAccessibility]),
+ .init(displayName: "Option 2", languageCode: "fr", characteristics: []),
+ .init(displayName: "Option 3 (music)", languageCode: "en", characteristics: [.describesMusicAndSoundForAccessibility]),
+ .init(displayName: "Option 4", languageCode: "it", characteristics: [])
+ ]
+
+ expect(
+ AVMediaSelectionGroup.preferredMediaSelectionOptions(
+ from: options,
+ withMediaCharacteristics: [.describesMusicAndSoundForAccessibility]
+ )
+ .map(\.displayName)
+ .sorted()
+ )
+ .to(equal([
+ "Option 1 (music)",
+ "Option 3 (music)",
+ "Option 4"
+ ]))
+ }
+
+ func testPreferredMediaSelectionOptionsWithoutCharacteristics() {
+ let options: [AVMediaSelectionOptionMock] = [
+ .init(displayName: "Option 1 (music)", languageCode: "fr", characteristics: [.describesMusicAndSoundForAccessibility]),
+ .init(displayName: "Option 2", languageCode: "fr", characteristics: []),
+ .init(displayName: "Option 3 (music)", languageCode: "en", characteristics: [.describesMusicAndSoundForAccessibility]),
+ .init(displayName: "Option 4", languageCode: "it", characteristics: [])
+ ]
+
+ expect(
+ AVMediaSelectionGroup.preferredMediaSelectionOptions(
+ from: options,
+ withoutMediaCharacteristics: [.describesMusicAndSoundForAccessibility]
+ )
+ .map(\.displayName)
+ .sorted()
+ )
+ .to(equal([
+ "Option 2",
+ "Option 3 (music)",
+ "Option 4"
+ ]))
+ }
+}
diff --git a/Tests/PlayerTests/MediaSelection/AVMediaSelectionOptionTests.swift b/Tests/PlayerTests/MediaSelection/AVMediaSelectionOptionTests.swift
new file mode 100644
index 00000000..870c8b51
--- /dev/null
+++ b/Tests/PlayerTests/MediaSelection/AVMediaSelectionOptionTests.swift
@@ -0,0 +1,30 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+
+final class AVMediaSelectionOptionTests: TestCase {
+ func testSortedOptions() {
+ let option1 = AVMediaSelectionOptionMock(displayName: "English")
+ let option2 = AVMediaSelectionOptionMock(displayName: "French")
+ expect(option1 < option2).to(beTrue())
+ }
+
+ func testEqualOptions() {
+ let option1 = AVMediaSelectionOptionMock(displayName: "English")
+ let option2 = AVMediaSelectionOptionMock(displayName: "English")
+ expect(option1 < option2).to(beFalse())
+ }
+
+ func testSortedOptionsWithOriginal() {
+ let option1 = AVMediaSelectionOptionMock(displayName: "English")
+ let option2 = AVMediaSelectionOptionMock(displayName: "French", characteristics: [.isOriginalContent])
+ expect(option2 < option1).to(beTrue())
+ }
+}
diff --git a/Tests/PlayerTests/MediaSelection/MediaSelectionTests.swift b/Tests/PlayerTests/MediaSelection/MediaSelectionTests.swift
new file mode 100644
index 00000000..f4a8a097
--- /dev/null
+++ b/Tests/PlayerTests/MediaSelection/MediaSelectionTests.swift
@@ -0,0 +1,297 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class MediaSelectionTests: TestCase {
+ func testCharacteristicsAndOptionsWhenEmpty() {
+ let player = Player()
+ expect(player.mediaSelectionCharacteristics).toAlways(beEmpty(), until: .seconds(2))
+ expect(player.mediaSelectionOptions(for: .audible)).to(beEmpty())
+ expect(player.mediaSelectionOptions(for: .legible)).to(beEmpty())
+ expect(player.mediaSelectionOptions(for: .visual)).to(beEmpty())
+ }
+
+ func testCharacteristicsAndOptionsWhenAvailable() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionCharacteristics).toEventually(equal([.audible, .legible]))
+ expect(player.mediaSelectionOptions(for: .audible)).notTo(beEmpty())
+ expect(player.mediaSelectionOptions(for: .legible)).notTo(beEmpty())
+ expect(player.mediaSelectionOptions(for: .visual)).to(beEmpty())
+ }
+
+ func testCharacteristicsAndOptionsWhenFailed() {
+ let player = Player(item: .simple(url: Stream.unavailable.url))
+ expect(player.mediaSelectionCharacteristics).toAlways(beEmpty(), until: .seconds(2))
+ expect(player.mediaSelectionOptions(for: .audible)).to(beEmpty())
+ expect(player.mediaSelectionOptions(for: .legible)).to(beEmpty())
+ expect(player.mediaSelectionOptions(for: .visual)).to(beEmpty())
+ }
+
+ func testCharacteristicsAndOptionsWhenExhausted() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionCharacteristics).toEventuallyNot(beEmpty())
+ player.play()
+ expect(player.mediaSelectionCharacteristics).toEventually(beEmpty())
+ }
+
+ func testCharacteristicsAndOptionsWhenUnavailable() {
+ let player = Player(item: .simple(url: Stream.onDemandWithoutOptions.url))
+ expect(player.mediaSelectionCharacteristics).toAlways(beEmpty(), until: .seconds(2))
+ expect(player.mediaSelectionOptions(for: .audible)).to(beEmpty())
+ expect(player.mediaSelectionOptions(for: .legible)).to(beEmpty())
+ expect(player.mediaSelectionOptions(for: .visual)).to(beEmpty())
+ }
+
+ func testCharacteristicsAndOptionsUpdateWhenAdvancingToNextItem() {
+ let player = Player(items: [
+ .simple(url: Stream.onDemandWithOptions.url),
+ .simple(url: Stream.onDemandWithoutOptions.url)
+ ])
+ expect(player.mediaSelectionCharacteristics).toEventuallyNot(beEmpty())
+ player.advanceToNextItem()
+ expect(player.mediaSelectionCharacteristics).toEventually(beEmpty())
+ }
+
+ func testSingleAudibleOptionIsNeverReturned() {
+ let player = Player(item: .simple(url: Stream.onDemandWithSingleAudibleOption.url))
+ expect(player.mediaSelectionCharacteristics).toEventually(equal([.audible]))
+ expect(player.mediaSelectionOptions(for: .audible)).to(beEmpty())
+ }
+
+ func testLegibleOptionsMustNotContainForcedSubtitles() {
+ let player = Player(item: .simple(url: Stream.onDemandWithForcedAndUnforcedLegibleOptions.url))
+ expect(player.mediaSelectionCharacteristics).toEventually(equal([.audible, .legible]))
+ expect(player.mediaSelectionOptions(for: .legible)).to(haveCount(6))
+ }
+
+ func testInitialAudibleOption() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en"))
+ expect(player.currentMediaOption(for: .audible)).to(haveLanguageIdentifier("en"))
+ }
+
+ func testInitialLegibleOptionWithAlwaysOnAccessibilityDisplayType() {
+ MediaAccessibilityDisplayType.alwaysOn(languageCode: "ja").apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("ja"))
+ expect(player.currentMediaOption(for: .legible)).to(haveLanguageIdentifier("ja"))
+ }
+
+ func testInitialLegibleOptionWithAutomaticAccessibilityDisplayType() {
+ MediaAccessibilityDisplayType.automatic.apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.automatic))
+ expect(player.currentMediaOption(for: .legible)).to(equal(.off))
+ }
+
+ func testInitialLegibleOptionWithForcedOnlyAccessibilityDisplayType() {
+ MediaAccessibilityDisplayType.forcedOnly.apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.off))
+ expect(player.currentMediaOption(for: .legible)).to(equal(.off))
+ }
+
+ func testInitialAudibleOptionWithoutAvailableOptions() {
+ let player = Player(item: .simple(url: Stream.onDemandWithoutOptions.url))
+ expect(player.selectedMediaOption(for: .audible)).toAlways(equal(.off), until: .seconds(2))
+ expect(player.currentMediaOption(for: .audible)).to(equal(.off))
+ }
+
+ func testInitialLegibleOptionWithoutAvailableOptions() {
+ MediaAccessibilityDisplayType.forcedOnly.apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithoutOptions.url))
+ expect(player.selectedMediaOption(for: .legible)).toAlways(equal(.off), until: .seconds(2))
+ expect(player.currentMediaOption(for: .legible)).to(equal(.off))
+ }
+
+ func testAudibleOptionUpdateWhenAdvancingToNextItem() {
+ let player = Player(items: [
+ .simple(url: Stream.onDemandWithOptions.url),
+ .simple(url: Stream.onDemandWithoutOptions.url)
+ ])
+ expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en"))
+ player.advanceToNextItem()
+ expect(player.selectedMediaOption(for: .audible)).toEventually(equal(.off))
+ }
+
+ func testLegibleOptionUpdateWhenAdvancingToNextItem() {
+ MediaAccessibilityDisplayType.alwaysOn(languageCode: "fr").apply()
+
+ let player = Player(items: [
+ .simple(url: Stream.onDemandWithOptions.url),
+ .simple(url: Stream.onDemandWithoutOptions.url)
+ ])
+ expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("fr"))
+ player.advanceToNextItem()
+ expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.off))
+ }
+
+ // When using AirPlay the receiver might offer forced subtitle selection, thus changing subtitles externally. In
+ // this case the perceived selected option must be `.off`.
+ @MainActor
+ func testLegibleOptionStaysOffEvenIfForcedSubtitlesAreEnabledExternally() async throws {
+ MediaAccessibilityDisplayType.alwaysOn(languageCode: "ja").apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithForcedAndUnforcedLegibleOptions.url))
+ await expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty())
+
+ let group = try await player.group(for: .legible)!
+ let option = AVMediaSelectionGroup.mediaSelectionOptions(
+ from: group.options,
+ withMediaCharacteristics: [.containsOnlyForcedSubtitles]
+ )
+ .first { option in
+ option.languageIdentifier == "ja"
+ }!
+
+ // Simulates an external change using the low-level player API directly.
+ player.systemPlayer.currentItem?.select(option, in: group)
+
+ await expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.off))
+ }
+
+ func testSelectAudibleOnOption() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: player.mediaSelectionOptions(for: .audible).first { option in
+ option.languageIdentifier == "fr"
+ }!, for: .audible)
+ expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("fr"))
+ expect(player.currentMediaOption(for: .audible)).to(haveLanguageIdentifier("fr"))
+ }
+
+ func testSelectAudibleAutomaticOptionDoesNothing() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: .automatic, for: .audible)
+ expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en"))
+ expect(player.currentMediaOption(for: .audible)).to(haveLanguageIdentifier("en"))
+ }
+
+ func testSelectAudibleOffOptionDoesNothing() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: .off, for: .audible)
+ expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en"))
+ expect(player.currentMediaOption(for: .audible)).to(haveLanguageIdentifier("en"))
+ }
+
+ func testSelectLegibleOnOption() {
+ MediaAccessibilityDisplayType.forcedOnly.apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: player.mediaSelectionOptions(for: .legible).first { option in
+ option.languageIdentifier == "ja"
+ }!, for: .legible)
+ expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("ja"))
+ expect(player.currentMediaOption(for: .legible)).to(haveLanguageIdentifier("ja"))
+ }
+
+ func testSelectLegibleAutomaticOption() {
+ MediaAccessibilityDisplayType.forcedOnly.apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: .automatic, for: .legible)
+ expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.automatic))
+ expect(player.currentMediaOption(for: .legible)).to(equal(.off))
+ }
+
+ func testSelectLegibleOffOption() {
+ MediaAccessibilityDisplayType.automatic.apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: .off, for: .legible)
+ expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.off))
+ expect(player.currentMediaOption(for: .legible)).to(equal(.off))
+ }
+
+ func testAudibleSelectionIsPreservedBetweenItems() {
+ MediaAccessibilityDisplayType.alwaysOn(languageCode: "en").apply()
+
+ let player = Player(items: [
+ .simple(url: Stream.onDemandWithOptions.url),
+ .simple(url: Stream.onDemandWithOptions.url)
+ ])
+ expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: player.mediaSelectionOptions(for: .audible).first { option in
+ option.languageIdentifier == "fr"
+ }!, for: .audible)
+ expect(player.currentMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("fr"))
+
+ player.advanceToNextItem()
+ expect(player.currentMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("fr"))
+ }
+
+ func testLegibleSelectionIsPreservedBetweenItems() {
+ MediaAccessibilityDisplayType.alwaysOn(languageCode: "en").apply()
+
+ let player = Player(items: [
+ .simple(url: Stream.onDemandWithOptions.url),
+ .simple(url: Stream.onDemandWithOptions.url)
+ ])
+ expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: player.mediaSelectionOptions(for: .legible).first { option in
+ option.languageIdentifier == "fr"
+ }!, for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("fr"))
+
+ player.advanceToNextItem()
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("fr"))
+ }
+
+ func testLegibleOptionSwitchFromOffToAutomatic() {
+ MediaAccessibilityDisplayType.forcedOnly.apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty())
+ player.select(mediaOption: .automatic, for: .legible)
+
+ expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.automatic))
+ expect(player.currentMediaOption(for: .legible)).to(equal(.off))
+ }
+
+ func testObservabilityWhenTogglingBetweenOffAndAutomatic() {
+ MediaAccessibilityDisplayType.forcedOnly.apply()
+
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty())
+
+ expectChange(from: player) {
+ player.select(mediaOption: .automatic, for: .legible)
+ }
+ expectChange(from: player) {
+ player.select(mediaOption: .off, for: .legible)
+ }
+ }
+}
+
+private extension Player {
+ func group(for characteristic: AVMediaCharacteristic) async throws -> AVMediaSelectionGroup? {
+ guard let item = systemPlayer.currentItem else { return nil }
+ return try await item.asset.loadMediaSelectionGroup(for: characteristic)
+ }
+}
diff --git a/Tests/PlayerTests/MediaSelection/PreferredLanguagesForMediaSelectionTests.swift b/Tests/PlayerTests/MediaSelection/PreferredLanguagesForMediaSelectionTests.swift
new file mode 100644
index 00000000..624dae8a
--- /dev/null
+++ b/Tests/PlayerTests/MediaSelection/PreferredLanguagesForMediaSelectionTests.swift
@@ -0,0 +1,144 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxStreams
+
+final class PreferredLanguagesForMediaSelectionTests: TestCase {
+ func testAudibleOptionMatchesAvailablePreferredLanguage() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ player.setMediaSelection(preferredLanguages: ["fr"], for: .audible)
+ expect(player.selectedMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("fr"))
+ }
+
+ func testLegibleOptionMatchesAvailablePreferredLanguage() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ player.setMediaSelection(preferredLanguages: ["fr"], for: .legible)
+ expect(player.selectedMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("fr"))
+ }
+
+ func testAudibleOptionIgnoresInvalidPreferredLanguage() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ player.setMediaSelection(preferredLanguages: ["xy"], for: .audible)
+ expect(player.currentMediaOption(for: .audible)).toNever(haveLanguageIdentifier("xy"), until: .seconds(2))
+ }
+
+ func testLegibleOptionIgnoresInvalidPreferredLanguage() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ player.setMediaSelection(preferredLanguages: ["xy"], for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toNever(haveLanguageIdentifier("xy"), until: .seconds(2))
+ }
+
+ func testAudibleOptionIgnoresUnsupportedPreferredLanguage() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ player.setMediaSelection(preferredLanguages: ["it"], for: .audible)
+ expect(player.currentMediaOption(for: .audible)).toNever(haveLanguageIdentifier("it"), until: .seconds(2))
+ }
+
+ func testLegibleOptionIgnoresUnsupportedPreferredLanguage() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ player.setMediaSelection(preferredLanguages: ["it"], for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toNever(haveLanguageIdentifier("it"), until: .seconds(2))
+ }
+
+ func testPreferredAudibleLanguageOverrideSelection() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .audible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: player.mediaSelectionOptions(for: .audible).first { option in
+ option.languageIdentifier == "fr"
+ }!, for: .audible)
+
+ player.setMediaSelection(preferredLanguages: ["en"], for: .audible)
+ expect(player.currentMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en"))
+ }
+
+ func testPreferredLegibleLanguageOverrideSelection() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ expect(player.mediaSelectionOptions(for: .legible)).toEventuallyNot(beEmpty())
+
+ player.select(mediaOption: player.mediaSelectionOptions(for: .legible).first { option in
+ option.languageIdentifier == "ja"
+ }!, for: .legible)
+
+ player.setMediaSelection(preferredLanguages: ["fr"], for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("fr"))
+ }
+
+ func testPreferredAudibleLanguageIsPreservedBetweenItems() {
+ let player = Player(items: [
+ .simple(url: Stream.onDemandWithOptions.url),
+ .simple(url: Stream.onDemandWithOptions.url)
+ ])
+ player.setMediaSelection(preferredLanguages: ["fr"], for: .audible)
+ expect(player.currentMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("fr"))
+
+ player.advanceToNextItem()
+ expect(player.currentMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("fr"))
+ }
+
+ func testPreferredLegibleLanguageIsPreservedBetweenItems() {
+ let player = Player(items: [
+ .simple(url: Stream.onDemandWithOptions.url),
+ .simple(url: Stream.onDemandWithOptions.url)
+ ])
+ player.setMediaSelection(preferredLanguages: ["fr"], for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("fr"))
+
+ player.advanceToNextItem()
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("fr"))
+ }
+
+ func testPreferredLegibleLanguageAcrossItems() {
+ let player = Player(items: [
+ .simple(url: Stream.onDemandWithOptions.url),
+ .simple(url: Stream.onDemandWithManyLegibleAndAudibleOptions.url)
+ ])
+
+ player.setMediaSelection(preferredLanguages: ["en"], for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("en"))
+
+ player.advanceToNextItem()
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("en"))
+
+ player.setMediaSelection(preferredLanguages: ["it"], for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("it"))
+
+ player.returnToPrevious()
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("en"))
+ }
+
+ func testSelectLegibleOffOptionWithPreferredLanguage() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+
+ player.setMediaSelection(preferredLanguages: ["en"], for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("en"))
+
+ player.select(mediaOption: .off, for: .legible)
+ expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.off))
+ }
+
+ func testSelectLegibleAutomaticOptionWithPreferredLanguage() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+
+ player.setMediaSelection(preferredLanguages: ["en"], for: .legible)
+ expect(player.currentMediaOption(for: .legible)).toEventually(haveLanguageIdentifier("en"))
+
+ player.select(mediaOption: .automatic, for: .legible)
+ expect(player.selectedMediaOption(for: .legible)).toEventually(equal(.automatic))
+ }
+
+ func testMediaSelectionReset() {
+ let player = Player(item: .simple(url: Stream.onDemandWithOptions.url))
+ player.setMediaSelection(preferredLanguages: ["fr"], for: .audible)
+ expect(player.currentMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("fr"))
+ player.setMediaSelection(preferredLanguages: [], for: .audible)
+ expect(player.currentMediaOption(for: .audible)).toEventually(haveLanguageIdentifier("en"))
+ }
+}
diff --git a/Tests/PlayerTests/Metrics/AccessLogEventTests.swift b/Tests/PlayerTests/Metrics/AccessLogEventTests.swift
new file mode 100644
index 00000000..85730db5
--- /dev/null
+++ b/Tests/PlayerTests/Metrics/AccessLogEventTests.swift
@@ -0,0 +1,59 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+
+final class AccessLogEventTests: TestCase {
+ func testNegativeValues() {
+ let event = AccessLogEvent(
+ uri: nil,
+ serverAddress: nil,
+ playbackStartDate: nil,
+ playbackSessionId: nil,
+ playbackStartOffset: -1,
+ playbackType: nil,
+ startupTime: -1,
+ observedBitrateStandardDeviation: -1,
+ indicatedBitrate: -1,
+ observedBitrate: -1,
+ averageAudioBitrate: -1,
+ averageVideoBitrate: -1,
+ indicatedAverageBitrate: -1,
+ numberOfServerAddressChanges: -1,
+ mediaRequestsWWAN: -1,
+ transferDuration: -1,
+ numberOfBytesTransferred: -1,
+ numberOfMediaRequests: -1,
+ playbackDuration: -1,
+ numberOfDroppedVideoFrames: -1,
+ numberOfStalls: -1,
+ segmentsDownloadedDuration: -1,
+ downloadOverdue: -1,
+ switchBitrate: -1
+ )
+ expect(event.playbackStartOffset).to(beNil())
+ expect(event.startupTime).to(beNil())
+ expect(event.observedBitrateStandardDeviation).to(beNil())
+ expect(event.indicatedBitrate).to(beNil())
+ expect(event.observedBitrate).to(beNil())
+ expect(event.averageAudioBitrate).to(beNil())
+ expect(event.averageVideoBitrate).to(beNil())
+ expect(event.indicatedAverageBitrate).to(beNil())
+ expect(event.numberOfServerAddressChanges).to(equal(0))
+ expect(event.mediaRequestsWWAN).to(equal(0))
+ expect(event.transferDuration).to(equal(0))
+ expect(event.numberOfBytesTransferred).to(equal(0))
+ expect(event.numberOfMediaRequests).to(equal(0))
+ expect(event.playbackDuration).to(equal(0))
+ expect(event.numberOfDroppedVideoFrames).to(equal(0))
+ expect(event.numberOfStalls).to(equal(0))
+ expect(event.segmentsDownloadedDuration).to(equal(0))
+ expect(event.downloadOverdue).to(equal(0))
+ expect(event.switchBitrate).to(equal(0))
+ }
+}
diff --git a/Tests/PlayerTests/Metrics/MetricsCollectorEventsTests.swift b/Tests/PlayerTests/Metrics/MetricsCollectorEventsTests.swift
new file mode 100644
index 00000000..5b0d2b4c
--- /dev/null
+++ b/Tests/PlayerTests/Metrics/MetricsCollectorEventsTests.swift
@@ -0,0 +1,43 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class MetricsCollectorEventsTests: TestCase {
+ func testUnbound() {
+ let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4))
+ expect(metricsCollector.metricEvents).toAlways(beEmpty(), until: .milliseconds(500))
+ }
+
+ func testEmptyPlayer() {
+ let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4))
+ metricsCollector.player = Player()
+ expect(metricsCollector.metricEvents).toAlways(beEmpty(), until: .milliseconds(500))
+ }
+
+ func testPausedPlayer() {
+ let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4))
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ metricsCollector.player = player
+ expect(metricsCollector.metricEvents).toEventuallyNot(beEmpty())
+ }
+
+ func testPlayerSetToNil() {
+ let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ metricsCollector.player = player
+ player.play()
+ expect(metricsCollector.metricEvents).toEventuallyNot(beEmpty())
+
+ metricsCollector.player = nil
+ expect(metricsCollector.metricEvents).to(beEmpty())
+ }
+}
diff --git a/Tests/PlayerTests/Metrics/MetricsCollectorTests.swift b/Tests/PlayerTests/Metrics/MetricsCollectorTests.swift
new file mode 100644
index 00000000..b3ffa351
--- /dev/null
+++ b/Tests/PlayerTests/Metrics/MetricsCollectorTests.swift
@@ -0,0 +1,78 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class MetricsCollectorTests: TestCase {
+ func testUnbound() {
+ let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [[]],
+ from: metricsCollector.$metrics
+ .map { $0.compactMap(\.uri) }
+ .removeDuplicates()
+ )
+ }
+
+ func testEmptyPlayer() {
+ let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [[]],
+ from: metricsCollector.$metrics
+ .map { $0.compactMap(\.uri) }
+ .removeDuplicates()
+ ) {
+ metricsCollector.player = Player()
+ }
+ }
+
+ func testPausedPlayer() {
+ let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [[]],
+ from: metricsCollector.$metrics
+ .map { $0.compactMap(\.uri) }
+ .removeDuplicates()
+ ) {
+ metricsCollector.player = player
+ }
+ }
+
+ func testPlayback() {
+ let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [[], [Stream.onDemand.url.absoluteString]],
+ from: metricsCollector.$metrics
+ .map { $0.compactMap(\.uri) }
+ .removeDuplicates()
+ ) {
+ metricsCollector.player = player
+ player.play()
+ }
+ }
+
+ func testPlayerSetToNil() {
+ let metricsCollector = MetricsCollector(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ metricsCollector.player = player
+ player.play()
+ expect(metricsCollector.metrics).toEventuallyNot(beEmpty())
+
+ metricsCollector.player = nil
+ expect(metricsCollector.metrics).to(beEmpty())
+ }
+}
diff --git a/Tests/PlayerTests/Metrics/MetricsStateTests.swift b/Tests/PlayerTests/Metrics/MetricsStateTests.swift
new file mode 100644
index 00000000..8639a8ba
--- /dev/null
+++ b/Tests/PlayerTests/Metrics/MetricsStateTests.swift
@@ -0,0 +1,115 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+
+final class MetricsStateTests: TestCase {
+ // swiftlint:disable:next function_body_length
+ func testMetrics() {
+ let state = MetricsState(with: [
+ .init(
+ uri: "uri",
+ serverAddress: "serverAddress",
+ playbackStartDate: Date(timeIntervalSince1970: 1),
+ playbackSessionId: "playbackSessionId",
+ playbackStartOffset: 2,
+ playbackType: "playbackType",
+ startupTime: 3,
+ observedBitrateStandardDeviation: 4,
+ indicatedBitrate: 5,
+ observedBitrate: 6,
+ averageAudioBitrate: 7,
+ averageVideoBitrate: 8,
+ indicatedAverageBitrate: 9,
+ numberOfServerAddressChanges: 10,
+ mediaRequestsWWAN: 11,
+ transferDuration: 12,
+ numberOfBytesTransferred: 13,
+ numberOfMediaRequests: 14,
+ playbackDuration: 15,
+ numberOfDroppedVideoFrames: 16,
+ numberOfStalls: 17,
+ segmentsDownloadedDuration: 18,
+ downloadOverdue: 19,
+ switchBitrate: 20
+ )
+ ], at: .init(value: 12, timescale: 1))
+
+ let metrics = state.metrics(from: .empty)
+ expect(metrics.playbackStartDate).to(equal(Date(timeIntervalSince1970: 1)))
+ expect(metrics.time).to(equal(.init(value: 12, timescale: 1)))
+ expect(metrics.uri).to(equal("uri"))
+ expect(metrics.serverAddress).to(equal("serverAddress"))
+ expect(metrics.playbackSessionId).to(equal("playbackSessionId"))
+ expect(metrics.playbackStartOffset).to(equal(2))
+ expect(metrics.playbackType).to(equal("playbackType"))
+ expect(metrics.startupTime).to(equal(3))
+ expect(metrics.observedBitrateStandardDeviation).to(equal(4))
+ expect(metrics.indicatedBitrate).to(equal(5))
+ expect(metrics.observedBitrate).to(equal(6))
+ expect(metrics.averageAudioBitrate).to(equal(7))
+ expect(metrics.averageVideoBitrate).to(equal(8))
+ expect(metrics.indicatedAverageBitrate).to(equal(9))
+
+ expect(metrics.increment.numberOfServerAddressChanges).to(equal(10))
+ expect(metrics.increment.mediaRequestsWWAN).to(equal(11))
+ expect(metrics.increment.transferDuration).to(equal(12))
+ expect(metrics.increment.numberOfBytesTransferred).to(equal(13))
+ expect(metrics.increment.numberOfMediaRequests).to(equal(14))
+ expect(metrics.increment.playbackDuration).to(equal(15))
+ expect(metrics.increment.numberOfDroppedVideoFrames).to(equal(16))
+ expect(metrics.increment.numberOfStalls).to(equal(17))
+ expect(metrics.increment.segmentsDownloadedDuration).to(equal(18))
+ expect(metrics.increment.downloadOverdue).to(equal(19))
+ expect(metrics.increment.switchBitrate).to(equal(20))
+
+ expect(metrics.total.numberOfServerAddressChanges).to(equal(10))
+ expect(metrics.total.mediaRequestsWWAN).to(equal(11))
+ expect(metrics.total.transferDuration).to(equal(12))
+ expect(metrics.total.numberOfBytesTransferred).to(equal(13))
+ expect(metrics.total.numberOfMediaRequests).to(equal(14))
+ expect(metrics.total.playbackDuration).to(equal(15))
+ expect(metrics.total.numberOfDroppedVideoFrames).to(equal(16))
+ expect(metrics.total.numberOfStalls).to(equal(17))
+ expect(metrics.total.segmentsDownloadedDuration).to(equal(18))
+ expect(metrics.total.downloadOverdue).to(equal(19))
+ expect(metrics.total.switchBitrate).to(equal(20))
+ }
+}
+
+private extension AccessLogEvent {
+ init(numberOfStalls: Int = -1) {
+ self.init(
+ uri: nil,
+ serverAddress: nil,
+ playbackStartDate: nil,
+ playbackSessionId: nil,
+ playbackStartOffset: -1,
+ playbackType: nil,
+ startupTime: -1,
+ observedBitrateStandardDeviation: -1,
+ indicatedBitrate: -1,
+ observedBitrate: -1,
+ averageAudioBitrate: -1,
+ averageVideoBitrate: -1,
+ indicatedAverageBitrate: -1,
+ numberOfServerAddressChanges: -1,
+ mediaRequestsWWAN: -1,
+ transferDuration: -1,
+ numberOfBytesTransferred: -1,
+ numberOfMediaRequests: -1,
+ playbackDuration: -1,
+ numberOfDroppedVideoFrames: -1,
+ numberOfStalls: numberOfStalls,
+ segmentsDownloadedDuration: -1,
+ downloadOverdue: -1,
+ switchBitrate: -1
+ )
+ }
+}
diff --git a/Tests/PlayerTests/Player/BlockedTimeRangeTests.swift b/Tests/PlayerTests/Player/BlockedTimeRangeTests.swift
new file mode 100644
index 00000000..460410b7
--- /dev/null
+++ b/Tests/PlayerTests/Player/BlockedTimeRangeTests.swift
@@ -0,0 +1,80 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+private let kBlockedTimeRange = CMTimeRange(start: .init(value: 20, timescale: 1), end: .init(value: 60, timescale: 1))
+private let kOverlappingBlockedTimeRange = CMTimeRange(start: .init(value: 50, timescale: 1), end: .init(value: 100, timescale: 1))
+private let kNestedBlockedTimeRange = CMTimeRange(start: .init(value: 30, timescale: 1), end: .init(value: 50, timescale: 1))
+
+private struct MetadataWithBlockedTimeRange: AssetMetadata {
+ var playerMetadata: PlayerMetadata {
+ .init(timeRanges: [
+ .init(kind: .blocked, start: kBlockedTimeRange.start, end: kBlockedTimeRange.end)
+ ])
+ }
+}
+
+private struct MetadataWithOverlappingBlockedTimeRanges: AssetMetadata {
+ var playerMetadata: PlayerMetadata {
+ .init(timeRanges: [
+ .init(kind: .blocked, start: kBlockedTimeRange.start, end: kBlockedTimeRange.end),
+ .init(kind: .blocked, start: kOverlappingBlockedTimeRange.start, end: kOverlappingBlockedTimeRange.end)
+ ])
+ }
+}
+
+private struct MetadataWithNestedBlockedTimeRanges: AssetMetadata {
+ var playerMetadata: PlayerMetadata {
+ .init(timeRanges: [
+ .init(kind: .blocked, start: kBlockedTimeRange.start, end: kBlockedTimeRange.end),
+ .init(kind: .blocked, start: kNestedBlockedTimeRange.start, end: kNestedBlockedTimeRange.end)
+ ])
+ }
+}
+
+final class BlockedTimeRangeTests: TestCase {
+ func testSeekInBlockedTimeRange() {
+ let player = Player(item: .simple(url: Stream.onDemand.url, metadata: MetadataWithBlockedTimeRange()))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.seek(at(.init(value: 30, timescale: 1)))
+ expect(kBlockedTimeRange.containsTime(player.time())).toNever(beTrue(), until: .seconds(2))
+ expect(player.time()).to(equal(kBlockedTimeRange.end))
+ }
+
+ func testSeekInOverlappingBlockedTimeRange() {
+ let player = Player(item: .simple(url: Stream.onDemand.url, metadata: MetadataWithOverlappingBlockedTimeRanges()))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.seek(at(.init(value: 30, timescale: 1)))
+ expect(kOverlappingBlockedTimeRange.containsTime(player.time())).toNever(beTrue(), until: .seconds(2))
+ expect(player.time()).to(equal(kOverlappingBlockedTimeRange.end))
+ }
+
+ func testSeekInNestedBlockedTimeRange() {
+ let player = Player(item: .simple(url: Stream.onDemand.url, metadata: MetadataWithNestedBlockedTimeRanges()))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.seek(at(.init(value: 40, timescale: 1)))
+ expect(kNestedBlockedTimeRange.containsTime(player.time())).toNever(beTrue(), until: .seconds(2))
+ expect(player.time()).to(equal(kBlockedTimeRange.end))
+ }
+
+ func testBlockedTimeRangeTraversal() {
+ let configuration = PlayerItemConfiguration(position: at(.init(value: 29, timescale: 1)))
+ let player = Player(item: .simple(url: Stream.onDemand.url, metadata: MetadataWithBlockedTimeRange(), configuration: configuration))
+ player.play()
+ expect(player.time()).toEventually(beGreaterThan(kBlockedTimeRange.end))
+ }
+
+ func testOnDemandStartInBlockedTimeRange() {
+ let configuration = PlayerItemConfiguration(position: at(.init(value: 30, timescale: 1)))
+ let player = Player(item: .simple(url: Stream.onDemand.url, metadata: MetadataWithBlockedTimeRange(), configuration: configuration))
+ expect(player.time()).toEventually(equal(kBlockedTimeRange.end))
+ }
+}
diff --git a/Tests/PlayerTests/Player/ErrorTests.swift b/Tests/PlayerTests/Player/ErrorTests.swift
index 5e3d4c0f..f42096e9 100644
--- a/Tests/PlayerTests/Player/ErrorTests.swift
+++ b/Tests/PlayerTests/Player/ErrorTests.swift
@@ -11,10 +11,40 @@ import Foundation
import Nimble
import PillarboxCircumspect
import PillarboxStreams
-import XCTest
-final class ErrorTests: XCTestCase {
- func testTruth() {
- expect(true).to(beTrue())
+final class ErrorTests: TestCase {
+ private static func errorCodePublisher(for player: Player) -> AnyPublisher {
+ player.$error
+ .map { error in
+ guard let error else { return nil }
+ return .init(rawValue: (error as NSError).code)
+ }
+ .eraseToAnyPublisher()
+ }
+
+ func testNoStream() {
+ let player = Player()
+ expectNothingPublishedNext(from: player.$error, during: .milliseconds(500))
+ }
+
+ func testValidStream() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expectNothingPublishedNext(from: player.$error, during: .milliseconds(500))
+ }
+
+ func testInvalidStream() {
+ let player = Player(item: .simple(url: Stream.unavailable.url))
+ expectEqualPublishedNext(
+ values: [.init(rawValue: NSURLErrorFileDoesNotExist)],
+ from: Self.errorCodePublisher(for: player),
+ during: .seconds(1)
+ )
+ }
+
+ func testReset() {
+ let player = Player(item: .simple(url: Stream.unavailable.url))
+ expect(player.error).toEventuallyNot(beNil())
+ player.removeAllItems()
+ expect(player.error).toEventually(beNil())
}
}
diff --git a/Tests/PlayerTests/Player/PlaybackSpeedUpdateTests.swift b/Tests/PlayerTests/Player/PlaybackSpeedUpdateTests.swift
new file mode 100644
index 00000000..c749f3c7
--- /dev/null
+++ b/Tests/PlayerTests/Player/PlaybackSpeedUpdateTests.swift
@@ -0,0 +1,39 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+
+final class PlaybackSpeedUpdateTests: TestCase {
+ func testUpdateIndefiniteWithValue() {
+ let speed = PlaybackSpeed.indefinite
+ let updatedSpeed = speed.updated(with: .value(2))
+ expect(updatedSpeed.value).to(equal(2))
+ expect(updatedSpeed.range).to(beNil())
+ }
+
+ func testUpdateIndefiniteWithRange() {
+ let speed = PlaybackSpeed.indefinite
+ let updatedSpeed = speed.updated(with: .range(0...2))
+ expect(updatedSpeed.value).to(equal(1))
+ expect(updatedSpeed.range).to(equal(0...2))
+ }
+
+ func testUpdateDefiniteWithSameRange() {
+ let speed = PlaybackSpeed(value: 2, range: 0...2)
+ let updatedSpeed = speed.updated(with: .range(0...2))
+ expect(updatedSpeed.value).to(equal(2))
+ expect(updatedSpeed.range).to(equal(0...2))
+ }
+
+ func testUpdateDefiniteWithIndefiniteRange() {
+ let speed = PlaybackSpeed(value: 2, range: 0...2)
+ let updatedSpeed = speed.updated(with: .range(nil))
+ expect(updatedSpeed.value).to(equal(1))
+ expect(updatedSpeed.range).to(beNil())
+ }
+}
diff --git a/Tests/PlayerTests/Player/PlaybackTests.swift b/Tests/PlayerTests/Player/PlaybackTests.swift
new file mode 100644
index 00000000..e1f46b28
--- /dev/null
+++ b/Tests/PlayerTests/Player/PlaybackTests.swift
@@ -0,0 +1,48 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import PillarboxCircumspect
+import PillarboxStreams
+import XCTest
+
+final class PlaybackTests: XCTestCase {
+ private func playbackStatePublisher(for player: Player) -> AnyPublisher {
+ player.propertiesPublisher
+ .slice(at: \.playbackState)
+ .eraseToAnyPublisher()
+ }
+
+ func testHLS() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [.idle, .paused],
+ from: playbackStatePublisher(for: player)
+ )
+ }
+
+ func testMP3() {
+ let item = PlayerItem.simple(url: Stream.mp3.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [.idle, .paused],
+ from: playbackStatePublisher(for: player)
+ )
+ }
+
+ func testUnknown() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ expectEqualPublished(
+ values: [.idle],
+ from: playbackStatePublisher(for: player),
+ during: .seconds(1)
+ )
+ }
+}
diff --git a/Tests/PlayerTests/Player/PlayerTests.swift b/Tests/PlayerTests/Player/PlayerTests.swift
new file mode 100644
index 00000000..dd51a251
--- /dev/null
+++ b/Tests/PlayerTests/Player/PlayerTests.swift
@@ -0,0 +1,76 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class PlayerTests: TestCase {
+ func testDeallocation() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ var player: Player? = Player(item: item)
+
+ weak var weakPlayer = player
+ autoreleasepool {
+ player = nil
+ }
+ expect(weakPlayer).to(beNil())
+ }
+
+ func testTimesWhenEmpty() {
+ let player = Player()
+ expect(player.time()).toAlways(equal(.invalid), until: .seconds(1))
+ }
+
+ func testTimesInEmptyRange() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ expect(player.seekableTimeRange).toEventuallyNot(equal(.invalid))
+ player.play()
+ expect(player.time()).toNever(equal(.invalid), until: .seconds(1))
+ }
+
+ func testMetadataUpdatesMustNotChangePlayerItem() {
+ let player = Player(item: .mock(url: Stream.onDemand.url, withMetadataUpdateAfter: 1))
+ expect(player.queuePlayer.currentItem?.url).toEventually(equal(Stream.onDemand.url))
+ let currentItem = player.queuePlayer.currentItem
+ expect(player.queuePlayer.currentItem).toAlways(equal(currentItem), until: .seconds(2))
+ }
+
+ func testRetrieveCurrentValueOnSubscription() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.properties.isBuffering).toEventually(beFalse())
+ expectEqualPublished(
+ values: [false],
+ from: player.propertiesPublisher.slice(at: \.isBuffering),
+ during: .seconds(1)
+ )
+ }
+
+ func testPreloadedItems() {
+ let player = Player(
+ items: [
+ .simple(url: Stream.onDemand.url),
+ .simple(url: Stream.onDemand.url),
+ .simple(url: Stream.onDemand.url)
+ ]
+ )
+ let expectedResources: [Resource] = [
+ .simple(url: Stream.onDemand.url),
+ .simple(url: Stream.onDemand.url),
+ .loading
+ ]
+ expect(player.items.map(\.content.resource)).toEventually(beSimilarTo(expectedResources))
+ expect(player.items.map(\.content.resource)).toAlways(beSimilarTo(expectedResources), until: .seconds(1))
+ }
+
+ func testNoMetricsWhenFailed() {
+ let player = Player(item: .failing(loadedAfter: 0.1))
+ expect(player.properties.metrics()).toAlways(beNil(), until: .seconds(1))
+ }
+}
diff --git a/Tests/PlayerTests/Player/QueueTests.swift b/Tests/PlayerTests/Player/QueueTests.swift
new file mode 100644
index 00000000..a6806eed
--- /dev/null
+++ b/Tests/PlayerTests/Player/QueueTests.swift
@@ -0,0 +1,184 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class QueueTests: TestCase {
+ func testWhenEmpty() {
+ let player = Player()
+ expect(player.urls).to(beEmpty())
+ expect(player.currentItem).to(beNil())
+ }
+
+ func testPlayableItem() {
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(item: item)
+ expect(player.urls).toEventually(equal([
+ Stream.shortOnDemand.url
+ ]))
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testEntirePlayback() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ player.play()
+ expect(player.urls).toEventually(beEmpty())
+ expect(player.currentItem).to(beNil())
+ }
+
+ func testFailingUnavailableItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ // Item is consumed by `AVQueuePlayer` for some reason.
+ expect(player.urls).toEventually(beEmpty())
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testFailingUnauthorizedItem() {
+ let item = PlayerItem.simple(url: Stream.unauthorized.url)
+ let player = Player(item: item)
+ expect(player.urls).toEventually(equal([
+ Stream.unauthorized.url
+ ]))
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testFailingMp3Item() {
+ let item = PlayerItem.simple(url: Stream.unavailableMp3.url)
+ let player = Player(item: item)
+ expect(player.urls).toEventually(equal([
+ Stream.unavailableMp3.url
+ ]))
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testBetweenPlayableItems() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.play()
+
+ expect(player.urls).toEventually(equal([
+ Stream.shortOnDemand.url,
+ Stream.onDemand.url
+ ]))
+ expect(player.currentItem).to(equal(item1))
+
+ expect(player.urls).toEventually(equal([
+ Stream.onDemand.url
+ ]))
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testFailingUnavailableItemFollowedByPlayableItem() {
+ let item1 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ // Item is consumed by `AVQueuePlayer` for some reason.
+ expect(player.urls).toEventually(beEmpty())
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testFailingUnauthorizedItemFollowedByPlayableItem() {
+ let item1 = PlayerItem.simple(url: Stream.unauthorized.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.urls).toEventually(equal([
+ Stream.unauthorized.url
+ ]))
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testFailingMp3ItemFollowedByPlayableItem() {
+ let item1 = PlayerItem.simple(url: Stream.unavailableMp3.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.urls).toEventually(equal([
+ Stream.unavailableMp3.url
+ ]))
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testFailingItemUnavailableBetweenPlayableItems() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.play()
+ expect(player.urls).toEventually(beEmpty())
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testFailingMp3ItemBetweenPlayableItems() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailableMp3.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.play()
+ expect(player.urls).toEventually(beEmpty())
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testPlayableItemReplacingFailingUnavailableItem() {
+ let player = Player(item: .simple(url: Stream.unavailable.url))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ player.items = [item]
+ expect(player.urls).toEventually(equal([
+ Stream.onDemand.url
+ ]))
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testPlayableItemReplacingFailingUnauthorizedItem() {
+ let player = Player(item: .simple(url: Stream.unauthorized.url))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ player.items = [item]
+ expect(player.urls).toEventually(equal([
+ Stream.onDemand.url
+ ]))
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testPlayableItemReplacingFailingMp3Item() {
+ let player = Player(item: .simple(url: Stream.unavailableMp3.url))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ player.items = [item]
+ expect(player.urls).toEventually(equal([
+ Stream.onDemand.url
+ ]))
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testReplaceCurrentItem() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ player.items = [item]
+ expect(player.urls).toEventually(equal([
+ Stream.onDemand.url
+ ]))
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testRemoveCurrentItemFollowedByPlayableItem() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.remove(player.items.first!)
+ expect(player.urls).toEventually(equal([
+ Stream.onDemand.url
+ ]))
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testRemoveAllItems() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ player.removeAllItems()
+ expect(player.urls).to(beEmpty())
+ }
+}
diff --git a/Tests/PlayerTests/Player/ReplayChecksTests.swift b/Tests/PlayerTests/Player/ReplayChecksTests.swift
new file mode 100644
index 00000000..6a6f3f73
--- /dev/null
+++ b/Tests/PlayerTests/Player/ReplayChecksTests.swift
@@ -0,0 +1,76 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class ReplayChecksTests: TestCase {
+ func testEmptyPlayer() {
+ let player = Player()
+ expect(player.canReplay()).to(beFalse())
+ }
+
+ func testWithOneGoodItem() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expect(player.canReplay()).to(beFalse())
+ }
+
+ func testWithOneGoodItemPlayedEntirely() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ player.play()
+ expect(player.canReplay()).toEventually(beTrue())
+ }
+
+ func testWithOneBadItemConsumed() {
+ // This item is consumed by the player when failing.
+ let player = Player(item: .simple(url: Stream.unavailable.url))
+ expect(player.canReplay()).toEventually(beTrue())
+ }
+
+ func testWithOneBadItemNotConsumed() {
+ // This item is not consumed by the player when failing (for an unknown reason).
+ let player = Player(item: .simple(url: Stream.unauthorized.url))
+ expect(player.canReplay()).toEventually(beTrue())
+ }
+
+ func testWithManyGoodItems() {
+ let player = Player(items: [
+ .simple(url: Stream.shortOnDemand.url),
+ .simple(url: Stream.shortOnDemand.url)
+ ])
+ player.play()
+ expect(player.canReplay()).toEventually(beTrue())
+ }
+
+ func testWithManyBadItems() {
+ let player = Player(items: [
+ .simple(url: Stream.unavailable.url),
+ .simple(url: Stream.unavailable.url)
+ ])
+ player.play()
+ expect(player.canReplay()).toEventually(beTrue())
+ }
+
+ func testWithOneGoodItemAndOneBadItem() {
+ let player = Player(items: [
+ .simple(url: Stream.shortOnDemand.url),
+ .simple(url: Stream.unavailable.url)
+ ])
+ player.play()
+ expect(player.canReplay()).toEventually(beTrue())
+ }
+
+ func testWithOneLongGoodItemAndOneBadItem() {
+ let player = Player(items: [
+ .simple(url: Stream.onDemand.url),
+ .simple(url: Stream.unavailable.url)
+ ])
+ player.play()
+ expect(player.canReplay()).toNever(beTrue(), until: .milliseconds(500))
+ }
+}
diff --git a/Tests/PlayerTests/Player/ReplayTests.swift b/Tests/PlayerTests/Player/ReplayTests.swift
new file mode 100644
index 00000000..1553af19
--- /dev/null
+++ b/Tests/PlayerTests/Player/ReplayTests.swift
@@ -0,0 +1,77 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class ReplayTests: TestCase {
+ func testWithOneGoodItem() {
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(item: item)
+ player.replay()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testWithOneGoodItemPlayedEntirely() {
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(item: item)
+ player.play()
+ expect(player.currentItem).toEventually(beNil())
+ player.replay()
+ expect(player.currentItem).toEventually(equal(item))
+ }
+
+ func testWithOneBadItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ expect(player.currentItem).toAlways(equal(item), until: .milliseconds(500))
+ player.replay()
+ expect(player.currentItem).toAlways(equal(item), until: .milliseconds(500))
+ }
+
+ func testWithManyGoodItems() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(items: [item1, item2])
+ player.play()
+ expect(player.currentItem).toEventually(equal(item2))
+ player.replay()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testWithManyBadItems() {
+ let item1 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(items: [item1, item2])
+ player.play()
+ expect(player.currentItem).toAlways(equal(item1), until: .milliseconds(500))
+ player.replay()
+ expect(player.currentItem).toAlways(equal(item1), until: .milliseconds(500))
+ }
+
+ func testWithOneGoodItemAndOneBadItem() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(items: [item1, item2])
+ player.play()
+ expect(player.currentItem).toEventually(equal(item2))
+ player.replay()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testResumePlaybackIfNeeded() {
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(item: item)
+ player.play()
+ expect(player.currentItem).toEventually(beNil())
+ player.pause()
+ player.replay()
+ expect(player.currentItem).toEventually(equal(item))
+ expect(player.playbackState).toEventually(equal(.playing))
+ }
+}
diff --git a/Tests/PlayerTests/Player/SeekChecksTests.swift b/Tests/PlayerTests/Player/SeekChecksTests.swift
new file mode 100644
index 00000000..dccc164a
--- /dev/null
+++ b/Tests/PlayerTests/Player/SeekChecksTests.swift
@@ -0,0 +1,54 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class SeekChecksTests: TestCase {
+ func testCannotSeekWithEmptyPlayer() {
+ let player = Player()
+ expect(player.canSeek(to: .zero)).to(beFalse())
+ }
+
+ func testCanSeekInTimeRange() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canSeek(to: CMTimeMultiplyByFloat64(Stream.onDemand.duration, multiplier: 0.5))).to(beTrue())
+ }
+
+ func testCannotSeekInEmptyTimeRange() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canSeek(to: .zero)).to(beFalse())
+ }
+
+ func testCanSeekToTimeRangeStart() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canSeek(to: player.seekableTimeRange.start)).to(beTrue())
+ }
+
+ func testCanSeekToTimeRangeEnd() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canSeek(to: player.seekableTimeRange.end)).to(beTrue())
+ }
+
+ func testCannotSeekBeforeTimeRangeStart() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canSeek(to: CMTime(value: -10, timescale: 1))).to(beFalse())
+ }
+
+ func testCannotSeekAfterTimeRangeEnd() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canSeek(to: player.seekableTimeRange.end + CMTime(value: 1, timescale: 1))).to(beFalse())
+ }
+}
diff --git a/Tests/PlayerTests/Player/SeekTests.swift b/Tests/PlayerTests/Player/SeekTests.swift
new file mode 100644
index 00000000..1e2d04e5
--- /dev/null
+++ b/Tests/PlayerTests/Player/SeekTests.swift
@@ -0,0 +1,120 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+private struct MockMetadata: AssetMetadata {
+ var playerMetadata: PlayerMetadata {
+ .init(timeRanges: [
+ .init(kind: .blocked, start: .init(value: 20, timescale: 1), end: .init(value: 60, timescale: 1))
+ ])
+ }
+}
+
+final class SeekTests: TestCase {
+ func testSeekWhenEmpty() {
+ let player = Player()
+ waitUntil { done in
+ player.seek(near(.zero)) { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSeekInTimeRange() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ waitUntil { done in
+ player.seek(near(CMTimeMultiplyByFloat64(Stream.onDemand.duration, multiplier: 0.5))) { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSeekInEmptyTimeRange() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ expect(player.streamType).toEventually(equal(.live))
+ waitUntil { done in
+ player.seek(near(.zero)) { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSeekToTimeRangeStart() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ waitUntil { done in
+ player.seek(near(player.seekableTimeRange.start)) { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSeekToTimeRangeEnd() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ waitUntil { done in
+ player.seek(near(player.seekableTimeRange.end)) { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSeekBeforeTimeRangeStart() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ waitUntil { done in
+ player.seek(near(CMTime(value: -10, timescale: 1))) { finished in
+ expect(finished).to(beTrue())
+ expect(player.time()).to(equal(.zero))
+ done()
+ }
+ }
+ }
+
+ func testSeekAfterTimeRangeEnd() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ waitUntil { done in
+ player.seek(near(player.seekableTimeRange.end + CMTime(value: 10, timescale: 1))) { finished in
+ expect(finished).to(beTrue())
+ expect(player.time()).to(equal(player.seekableTimeRange.end, by: beClose(within: 1)))
+ done()
+ }
+ }
+ }
+
+ func testTimesDuringSeekBeforeTimeRangeStart() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.play()
+ player.seek(near(CMTime(value: -10, timescale: 1)))
+ expect(player.time()).toAlways(beGreaterThanOrEqualTo(player.seekableTimeRange.start), until: .seconds(1))
+ }
+
+ func testOnDemandStartAtTime() {
+ let configuration = PlayerItemConfiguration(position: at(.init(value: 10, timescale: 1)))
+ let player = Player(item: .simple(url: Stream.onDemand.url, configuration: configuration))
+ expect(player.time().seconds).toEventually(equal(10))
+ }
+
+ func testDvrStartAtTime() {
+ let configuration = PlayerItemConfiguration(position: at(.init(value: 10, timescale: 1)))
+ let player = Player(item: .simple(url: Stream.dvr.url, configuration: configuration))
+ expect(player.time().seconds).toEventually(equal(10))
+ }
+}
diff --git a/Tests/PlayerTests/Player/SpeedTests.swift b/Tests/PlayerTests/Player/SpeedTests.swift
new file mode 100644
index 00000000..d70a1421
--- /dev/null
+++ b/Tests/PlayerTests/Player/SpeedTests.swift
@@ -0,0 +1,205 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class SpeedTests: TestCase {
+ func testEmpty() {
+ let player = Player()
+ expect(player.effectivePlaybackSpeed).toAlways(equal(1), until: .seconds(2))
+ expect(player.playbackSpeedRange).toAlways(equal(1...1), until: .seconds(2))
+ }
+
+ func testNoSpeedUpdateWhenEmpty() {
+ let player = Player()
+ player.setDesiredPlaybackSpeed(2)
+ expect(player.effectivePlaybackSpeed).toAlways(equal(1), until: .seconds(2))
+ expect(player.playbackSpeedRange).toAlways(equal(1...1), until: .seconds(2))
+ }
+
+ func testOnDemand() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ player.setDesiredPlaybackSpeed(2)
+ expect(player.effectivePlaybackSpeed).toEventually(equal(2))
+ expect(player.playbackSpeedRange).toEventually(equal(0.1...2))
+ }
+
+ func testDvr() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ player.setDesiredPlaybackSpeed(0.5)
+ expect(player.effectivePlaybackSpeed).toEventually(equal(0.5))
+ expect(player.playbackSpeedRange).toEventually(equal(0.1...1))
+ }
+
+ func testLive() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ player.setDesiredPlaybackSpeed(2)
+ expect(player.effectivePlaybackSpeed).toAlways(equal(1), until: .seconds(2))
+ expect(player.playbackSpeedRange).toAlways(equal(1...1), until: .seconds(2))
+ }
+
+ func testDvrInThePast() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ expect(player.seekableTimeRange).toEventuallyNot(equal(.invalid))
+ waitUntil { done in
+ player.seek(at(.init(value: 1, timescale: 1))) { _ in
+ done()
+ }
+ }
+
+ expect(player.playbackSpeedRange).toEventually(equal(0.1...2))
+ player.setDesiredPlaybackSpeed(2)
+ expect(player.effectivePlaybackSpeed).toEventually(equal(2))
+ }
+
+ func testPlaylistOnDemandToLive() {
+ let item1 = PlayerItem(asset: .simple(url: Stream.onDemand.url))
+ let item2 = PlayerItem(asset: .simple(url: Stream.live.url))
+ let player = Player(items: [item1, item2])
+
+ player.setDesiredPlaybackSpeed(2)
+ expect(player.effectivePlaybackSpeed).toEventually(equal(2))
+
+ player.advanceToNextItem()
+ expect(player.effectivePlaybackSpeed).toEventually(equal(1))
+ expect(player.playbackSpeedRange).toEventually(equal(1...1))
+ }
+
+ func testPlaylistOnDemandToOnDemand() {
+ let item1 = PlayerItem(asset: .simple(url: Stream.onDemand.url))
+ let item2 = PlayerItem(asset: .simple(url: Stream.onDemand.url))
+ let player = Player(items: [item1, item2])
+ player.setDesiredPlaybackSpeed(2)
+ expect(player.effectivePlaybackSpeed).toEventually(equal(2))
+
+ player.advanceToNextItem()
+ expect(player.effectivePlaybackSpeed).toEventually(equal(2))
+ expect(player.playbackSpeedRange).toEventually(equal(0.1...2))
+ }
+
+ func testSpeedUpdateWhenStartingPlayback() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ expectAtLeastEqualPublished(
+ values: [1, 0.5],
+ from: player.changePublisher(at: \.effectivePlaybackSpeed).removeDuplicates()
+ ) {
+ player.setDesiredPlaybackSpeed(0.5)
+ }
+ }
+
+ func testSpeedRangeUpdateWhenStartingPlayback() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ expectAtLeastEqualPublished(
+ values: [1...1, 0.1...1],
+ from: player.changePublisher(at: \.playbackSpeedRange).removeDuplicates()
+ ) {
+ player.setDesiredPlaybackSpeed(0.5)
+ }
+ }
+
+ func testSpeedUpdateWhenApproachingLiveEdge() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ player.play()
+ expect(player.seekableTimeRange).toEventuallyNot(equal(.invalid))
+ waitUntil { done in
+ player.seek(at(.init(value: 10, timescale: 1))) { _ in
+ done()
+ }
+ }
+
+ player.setDesiredPlaybackSpeed(2)
+ expect(player.effectivePlaybackSpeed).toEventually(equal(2))
+ expect(player.playbackSpeedRange).toEventually(equal(0.1...2))
+
+ expect(player.effectivePlaybackSpeed).toEventually(equal(1))
+ expect(player.playbackSpeedRange).toEventually(equal(0.1...1))
+ }
+
+ func testPlaylistEnd() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ player.setDesiredPlaybackSpeed(2)
+ player.play()
+ expect(player.currentItem).toEventually(beNil())
+
+ expect(player.effectivePlaybackSpeed).toEventually(equal(1))
+ expect(player.playbackSpeedRange).toEventually(equal(1...1))
+ }
+
+ func testItemAppendMustStartAtCurrentSpeed() {
+ let player = Player()
+ player.setDesiredPlaybackSpeed(2)
+ player.append(.simple(url: Stream.onDemand.url))
+ expect(player.effectivePlaybackSpeed).toEventually(equal(2))
+ }
+
+ func testInitialSpeedMustSetRate() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ player.setDesiredPlaybackSpeed(2)
+ player.play()
+ expect(player.queuePlayer.defaultRate).toEventually(equal(2))
+ expect(player.queuePlayer.rate).toEventually(equal(2))
+ }
+
+ func testSpeedUpdateMustUpdateRate() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ player.setDesiredPlaybackSpeed(2)
+ expect(player.queuePlayer.defaultRate).toEventually(equal(2))
+ expect(player.queuePlayer.rate).toEventually(equal(2))
+ }
+
+ func testSpeedUpdateWhilePausedMustUpdateRate() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.playbackState).toEventually(equal(.paused))
+
+ player.setDesiredPlaybackSpeed(2)
+ player.play()
+
+ expect(player.queuePlayer.defaultRate).toEventually(equal(2))
+ expect(player.queuePlayer.rate).toEventually(equal(2))
+ }
+
+ func testSpeedUpdateMustNotResumePlayback() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.playbackState).toEventually(equal(.paused))
+ player.setDesiredPlaybackSpeed(2)
+ expect(player.playbackState).toAlways(equal(.paused), until: .seconds(2))
+ }
+
+ func testPlayMustNotResetSpeed() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.setDesiredPlaybackSpeed(2)
+ player.play()
+ expect(player.effectivePlaybackSpeed).toEventually(equal(2))
+ }
+
+ func testRateChangeMustNotUpdatePlaybackSpeedOutsideAVPlayerViewController() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.queuePlayer.rate = 2
+ expect(player.effectivePlaybackSpeed).toAlways(equal(1), until: .seconds(2))
+ }
+
+ func testNoDesiredUpdateIsIgnored() {
+ let player = Player()
+ expectAtLeastEqualPublished(values: [
+ .value(1),
+ .value(2),
+ .value(2)
+ ], from: player.desiredPlaybackSpeedUpdatePublisher()) {
+ player.setDesiredPlaybackSpeed(1)
+ player.setDesiredPlaybackSpeed(2)
+ player.setDesiredPlaybackSpeed(2)
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Player/TextStyleRulesTests.swift b/Tests/PlayerTests/Player/TextStyleRulesTests.swift
new file mode 100644
index 00000000..8507ce40
--- /dev/null
+++ b/Tests/PlayerTests/Player/TextStyleRulesTests.swift
@@ -0,0 +1,48 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxStreams
+
+final class TextStyleRulesTests: TestCase {
+ private static let textStyleRules = [
+ AVTextStyleRule(textMarkupAttributes: [
+ kCMTextMarkupAttribute_ForegroundColorARGB: [1, 1, 0, 0],
+ kCMTextMarkupAttribute_ItalicStyle: true
+ ])
+ ]
+
+ func testDefaultWithEmptyPlayer() {
+ let player = Player()
+ expect(player.textStyleRules).to(beEmpty())
+ }
+
+ func testDefaultWithLoadedPlayer() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.textStyleRules).to(beEmpty())
+ expect(player.queuePlayer.currentItem?.textStyleRules).to(beEmpty())
+ }
+
+ func testStyleUpdate() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ player.textStyleRules = Self.textStyleRules
+ expect(player.textStyleRules).to(equal(Self.textStyleRules))
+ expect(player.queuePlayer.currentItem?.textStyleRules).to(equal(Self.textStyleRules))
+ }
+
+ func testStylePreservedBetweenItems() {
+ let player = Player(items: [
+ .simple(url: Stream.shortOnDemand.url),
+ .simple(url: Stream.onDemand.url)
+ ])
+ player.textStyleRules = Self.textStyleRules
+ player.advanceToNextItem()
+ expect(player.queuePlayer.currentItem?.textStyleRules).to(equal(Self.textStyleRules))
+ }
+}
diff --git a/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift b/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift
new file mode 100644
index 00000000..f56e3c92
--- /dev/null
+++ b/Tests/PlayerTests/PlayerItem/PlayerItemAssetPublisherTests.swift
@@ -0,0 +1,51 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class PlayerItemAssetPublisherTests: TestCase {
+ func testNoLoad() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ expectSimilarPublished(
+ values: [.loading],
+ from: item.$content.map(\.resource),
+ during: .milliseconds(500)
+ )
+ }
+
+ func testLoad() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ expectSimilarPublished(
+ values: [.loading, .simple(url: Stream.onDemand.url)],
+ from: item.$content.map(\.resource),
+ during: .milliseconds(500)
+ ) {
+ PlayerItem.load(for: item.id)
+ }
+ }
+
+ func testReload() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ expectSimilarPublished(
+ values: [.loading, .simple(url: Stream.onDemand.url)],
+ from: item.$content.map(\.resource),
+ during: .milliseconds(500)
+ ) {
+ PlayerItem.load(for: item.id)
+ }
+
+ expectSimilarPublishedNext(
+ values: [.simple(url: Stream.onDemand.url)],
+ from: item.$content.map(\.resource),
+ during: .milliseconds(500)
+ ) {
+ PlayerItem.reload(for: item.id)
+ }
+ }
+}
diff --git a/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift b/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift
new file mode 100644
index 00000000..14704a8f
--- /dev/null
+++ b/Tests/PlayerTests/PlayerItem/PlayerItemTests.swift
@@ -0,0 +1,129 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class PlayerItemTests: TestCase {
+ private static let limits = PlayerLimits(
+ preferredPeakBitRate: 100,
+ preferredPeakBitRateForExpensiveNetworks: 200,
+ preferredMaximumResolution: .init(width: 100, height: 200),
+ preferredMaximumResolutionForExpensiveNetworks: .init(width: 300, height: 400)
+ )
+
+ func testSimpleItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ PlayerItem.load(for: item.id)
+ expect(item.content.resource).toEventually(equal(.simple(url: Stream.onDemand.url)))
+ let playerItem = item.content.playerItem(configuration: .default, limits: .none)
+ expect(playerItem.preferredForwardBufferDuration).to(equal(0))
+ expect(playerItem.preferredPeakBitRate).to(equal(0))
+ expect(playerItem.preferredPeakBitRateForExpensiveNetworks).to(equal(0))
+ expect(playerItem.preferredMaximumResolution).to(equal(.zero))
+ expect(playerItem.preferredMaximumResolutionForExpensiveNetworks).to(equal(.zero))
+ }
+
+ func testSimpleItemWithConfiguration() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url, configuration: .init(preferredForwardBufferDuration: 4))
+ PlayerItem.load(for: item.id)
+ expect(item.content.resource).toEventually(equal(.simple(url: Stream.onDemand.url)))
+ expect(item.content.playerItem(configuration: .default, limits: .none).preferredForwardBufferDuration).to(equal(4))
+ }
+
+ func testSimpleItemWithLimits() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ PlayerItem.load(for: item.id)
+ expect(item.content.resource).toEventually(equal(.simple(url: Stream.onDemand.url)))
+ let playerItem = item.content.playerItem(configuration: .default, limits: Self.limits)
+ expect(playerItem.preferredPeakBitRate).to(equal(100))
+ expect(playerItem.preferredPeakBitRateForExpensiveNetworks).to(equal(200))
+ expect(playerItem.preferredMaximumResolution).to(equal(.init(width: 100, height: 200)))
+ expect(playerItem.preferredMaximumResolutionForExpensiveNetworks).to(equal(.init(width: 300, height: 400)))
+ }
+
+ func testCustomItem() {
+ let delegate = ResourceLoaderDelegateMock()
+ let item = PlayerItem.custom(url: Stream.onDemand.url, delegate: delegate)
+ PlayerItem.load(for: item.id)
+ expect(item.content.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate)))
+ let playerItem = item.content.playerItem(configuration: .default, limits: .none)
+ expect(playerItem.preferredForwardBufferDuration).to(equal(0))
+ expect(playerItem.preferredPeakBitRate).to(equal(0))
+ expect(playerItem.preferredPeakBitRateForExpensiveNetworks).to(equal(0))
+ expect(playerItem.preferredMaximumResolution).to(equal(.zero))
+ expect(playerItem.preferredMaximumResolutionForExpensiveNetworks).to(equal(.zero))
+ }
+
+ func testCustomItemWithConfiguration() {
+ let delegate = ResourceLoaderDelegateMock()
+ let item = PlayerItem.custom(
+ url: Stream.onDemand.url,
+ delegate: delegate,
+ configuration: .init(preferredForwardBufferDuration: 4)
+ )
+ PlayerItem.load(for: item.id)
+ expect(item.content.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate)))
+ expect(item.content.playerItem(
+ configuration: .default,
+ limits: .none
+ ).preferredForwardBufferDuration).to(equal(4))
+ }
+
+ func testCustomItemWithLimits() {
+ let delegate = ResourceLoaderDelegateMock()
+ let item = PlayerItem.custom(url: Stream.onDemand.url, delegate: delegate)
+ PlayerItem.load(for: item.id)
+ expect(item.content.resource).toEventually(equal(.custom(url: Stream.onDemand.url, delegate: delegate)))
+ let playerItem = item.content.playerItem(configuration: .default, limits: Self.limits)
+ expect(playerItem.preferredPeakBitRate).to(equal(100))
+ expect(playerItem.preferredPeakBitRateForExpensiveNetworks).to(equal(200))
+ expect(playerItem.preferredMaximumResolution).to(equal(.init(width: 100, height: 200)))
+ expect(playerItem.preferredMaximumResolutionForExpensiveNetworks).to(equal(.init(width: 300, height: 400)))
+ }
+
+ func testEncryptedItem() {
+ let delegate = ContentKeySessionDelegateMock()
+ let item = PlayerItem.encrypted(url: Stream.onDemand.url, delegate: delegate)
+ PlayerItem.load(for: item.id)
+ expect(item.content.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate)))
+ let playerItem = item.content.playerItem(configuration: .default, limits: .none)
+ expect(playerItem.preferredForwardBufferDuration).to(equal(0))
+ expect(playerItem.preferredPeakBitRate).to(equal(0))
+ expect(playerItem.preferredPeakBitRateForExpensiveNetworks).to(equal(0))
+ expect(playerItem.preferredMaximumResolution).to(equal(.zero))
+ expect(playerItem.preferredMaximumResolutionForExpensiveNetworks).to(equal(.zero))
+ }
+
+ func testEncryptedItemWithConfiguration() {
+ let delegate = ContentKeySessionDelegateMock()
+ let item = PlayerItem.encrypted(
+ url: Stream.onDemand.url,
+ delegate: delegate,
+ configuration: .init(preferredForwardBufferDuration: 4)
+ )
+ PlayerItem.load(for: item.id)
+ expect(item.content.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate)))
+ expect(item.content.playerItem(
+ configuration: .default,
+ limits: .none
+ ).preferredForwardBufferDuration).to(equal(4))
+ }
+
+ func testEncryptedItemWithNonStandardPlayerConfiguration() {
+ let delegate = ContentKeySessionDelegateMock()
+ let item = PlayerItem.encrypted(url: Stream.onDemand.url, delegate: delegate)
+ PlayerItem.load(for: item.id)
+ expect(item.content.resource).toEventually(equal(.encrypted(url: Stream.onDemand.url, delegate: delegate)))
+ let playerItem = item.content.playerItem(configuration: .default, limits: Self.limits)
+ expect(playerItem.preferredPeakBitRate).to(equal(100))
+ expect(playerItem.preferredPeakBitRateForExpensiveNetworks).to(equal(200))
+ expect(playerItem.preferredMaximumResolution).to(equal(.init(width: 100, height: 200)))
+ expect(playerItem.preferredMaximumResolutionForExpensiveNetworks).to(equal(.init(width: 300, height: 400)))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/CurrentItemTests.swift b/Tests/PlayerTests/Playlist/CurrentItemTests.swift
new file mode 100644
index 00000000..72079426
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/CurrentItemTests.swift
@@ -0,0 +1,191 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class CurrentItemTests: TestCase {
+ func testCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(items: [item1, item2])
+ expectAtLeastEqualPublished(
+ values: [item1, item2, nil],
+ from: player.changePublisher(at: \.currentItem).removeDuplicates()
+ ) {
+ player.play()
+ }
+ }
+
+ func testCurrentItemWithFirstFailedItem() {
+ let item1 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(items: [item1, item2])
+ expectEqualPublished(
+ values: [item1],
+ from: player.changePublisher(at: \.currentItem).removeDuplicates(),
+ during: .milliseconds(500)
+ ) {
+ player.play()
+ }
+ }
+
+ func testCurrentItemWithMiddleFailedItem() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item3 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ expectEqualPublished(
+ values: [item1, item2],
+ from: player.changePublisher(at: \.currentItem).removeDuplicates(),
+ during: .seconds(2)
+ ) {
+ player.play()
+ }
+ }
+
+ func testCurrentItemWithLastFailedItem() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(items: [item1, item2])
+ expectAtLeastEqualPublished(
+ values: [item1, item2],
+ from: player.changePublisher(at: \.currentItem).removeDuplicates()
+ ) {
+ player.play()
+ }
+ }
+
+ func testCurrentItemWithFirstItemRemoved() {
+ let item1 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.error).toEventuallyNot(beNil())
+ player.remove(item1)
+ expect(player.currentItem).toAlways(equal(item2), until: .seconds(1))
+ }
+
+ func testCurrentItemWithSecondItemRemoved() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ expect(player.currentItem).toEventually(equal(item2))
+ expect(player.error).toEventuallyNot(beNil())
+ player.remove(item2)
+ expect(player.currentItem).toAlways(equal(item3), until: .seconds(1))
+ }
+
+ func testCurrentItemWithFailedItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ expectEqualPublished(
+ values: [item],
+ from: player.changePublisher(at: \.currentItem).removeDuplicates(),
+ during: .milliseconds(500)
+ )
+ }
+
+ func testCurrentItemWithEmptyPlayer() {
+ let player = Player()
+ expect(player.currentItem).to(beNil())
+ }
+
+ func testSlowFirstCurrentItem() {
+ let item1 = PlayerItem.mock(url: Stream.shortOnDemand.url, loadedAfter: 1)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expectAtLeastEqualPublished(
+ values: [item1, item2],
+ from: player.changePublisher(at: \.currentItem).removeDuplicates()
+ ) {
+ player.play()
+ }
+ }
+
+ func testCurrentItemAfterPlayerEnded() {
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(items: [item])
+ expectAtLeastEqualPublished(
+ values: [item, nil],
+ from: player.changePublisher(at: \.currentItem).removeDuplicates()
+ ) {
+ player.play()
+ }
+ }
+
+ func testSetCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(items: [item1, item2])
+ expectEqualPublished(
+ values: [item1, item2],
+ from: player.changePublisher(at: \.currentItem).removeDuplicates(),
+ during: .milliseconds(500)
+ ) {
+ player.currentItem = item2
+ }
+ }
+
+ func testSetCurrentItemUpdatePlayerCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(items: [item1, item2])
+ player.currentItem = item2
+ expect(player.queuePlayer.currentItem?.url).toEventually(equal(Stream.shortOnDemand.url))
+ }
+
+ func testPlayerPreloadedItemCount() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item4 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item5 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3, item4, item5])
+ player.currentItem = item3
+
+ let items = player.queuePlayer.items()
+ expect(items).to(haveCount(player.configuration.preloadedItems))
+ }
+
+ func testSetCurrentItemWithUnknownItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.mediumOnDemand.url)
+ let player = Player(items: [item1, item2])
+ player.currentItem = item3
+ expect(player.currentItem).to(equal(item3))
+ expect(player.items).to(equalDiff([item3, item2]))
+ }
+
+ func testSetCurrentItemToNil() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expect(player.currentItem).to(equal(item))
+ player.currentItem = nil
+ expect(player.currentItem).to(beNil())
+ expect(player.items).to(equalDiff([item]))
+ expect(player.queuePlayer.items()).to(beEmpty())
+ }
+
+ func testSetCurrentItemToSameItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ player.play()
+ expect(player.time().seconds).toEventually(beGreaterThan(1))
+ player.pause()
+ player.currentItem = item
+ expect(player.playbackState).toAlways(equal(.paused), until: .seconds(1))
+ expect(player.currentItem).to(equal(item))
+ expect(player.items).to(equalDiff([item]))
+ expect(player.time().seconds).to(equal(0))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemInsertionAfterTests.swift b/Tests/PlayerTests/Playlist/ItemInsertionAfterTests.swift
new file mode 100644
index 00000000..7452d337
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemInsertionAfterTests.swift
@@ -0,0 +1,88 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ItemInsertionAfterTests: TestCase {
+ func testInsertItemAfterNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, after: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item2, insertedItem, item3]))
+ }
+
+ func testInsertItemAfterCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, after: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item2, insertedItem, item3]))
+ }
+
+ func testInsertItemAfterPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, after: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item2, insertedItem, item3]))
+ }
+
+ func testInsertItemAfterLastItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, after: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item2, insertedItem]))
+ }
+
+ func testInsertItemAfterIdenticalItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.insert(item1, after: item2)).to(beFalse())
+ expect(player.items).to(equalDiff([item1, item2]))
+ }
+
+ func testInsertItemAfterForeignItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ let foreignItem = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.insert(insertedItem, after: foreignItem)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+
+ func testInsertItemAfterNil() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, after: nil)).to(beTrue())
+ expect(player.items).to(equalDiff([item, insertedItem]))
+ }
+
+ func testAppendItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.append(insertedItem)).to(beTrue())
+ expect(player.items).to(equalDiff([item, insertedItem]))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemInsertionBeforeTests.swift b/Tests/PlayerTests/Playlist/ItemInsertionBeforeTests.swift
new file mode 100644
index 00000000..f36f303b
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemInsertionBeforeTests.swift
@@ -0,0 +1,88 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ItemInsertionBeforeTests: TestCase {
+ func testInsertItemBeforeNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, before: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, insertedItem, item2, item3]))
+ }
+
+ func testInsertItemBeforeCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, before: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, insertedItem, item2, item3]))
+ }
+
+ func testInsertItemBeforePreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, before: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, insertedItem, item2, item3]))
+ }
+
+ func testInsertItemBeforeFirstItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, before: item1)).to(beTrue())
+ expect(player.items).to(equalDiff([insertedItem, item1, item2]))
+ }
+
+ func testInsertItemBeforeIdenticalItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.insert(item1, before: item2)).to(beFalse())
+ expect(player.items).to(equalDiff([item1, item2]))
+ }
+
+ func testInsertItemBeforeForeignItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ let foreignItem = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.insert(insertedItem, before: foreignItem)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+
+ func testInsertItemBeforeNil() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.insert(insertedItem, before: nil)).to(beTrue())
+ expect(player.items).to(equalDiff([insertedItem, item]))
+ }
+
+ func testPrependItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ let insertedItem = PlayerItem.simple(url: Stream.onDemand.url)
+ expect(player.prepend(insertedItem)).to(beTrue())
+ expect(player.items).to(equalDiff([insertedItem, item]))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemMoveAfterTests.swift b/Tests/PlayerTests/Playlist/ItemMoveAfterTests.swift
new file mode 100644
index 00000000..df0b0945
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemMoveAfterTests.swift
@@ -0,0 +1,147 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ItemMoveAfterTests: TestCase {
+ func testMovePreviousItemAfterNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ expect(player.move(item1, after: item3)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item3, item1]))
+ }
+
+ func testMovePreviousItemAfterCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ expect(player.move(item1, after: item3)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item3, item1]))
+ }
+
+ func testMovePreviousItemAfterPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ expect(player.move(item1, after: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1, item3]))
+ }
+
+ func testMoveCurrentItemAfterNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ expect(player.move(item1, after: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1, item3]))
+ }
+
+ func testMoveCurrentItemAfterPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ expect(player.move(item3, after: item1)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item3, item2]))
+ }
+
+ func testMoveNextItemAfterPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ expect(player.move(item3, after: item1)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item3, item2]))
+ }
+
+ func testMoveNextItemAfterCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ expect(player.move(item3, after: item1)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item3, item2]))
+ }
+
+ func testMoveNextItemAfterNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ expect(player.move(item2, after: item3)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item3, item2]))
+ }
+
+ func testMoveItemAfterIdenticalItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.move(item, after: item)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+
+ func testMoveItemAfterItemAlreadyAtExpectedLocation() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.move(item2, after: item1)).to(beFalse())
+ expect(player.items).to(equalDiff([item1, item2]))
+ }
+
+ func testMoveForeignItemAfterItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let foreignItem = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.move(foreignItem, after: item)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+
+ func testMoveItemAfterForeignItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let foreignItem = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.move(item, after: foreignItem)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+
+ func testMoveItemAfterLastItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.move(item1, after: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1]))
+ }
+
+ func testMoveItemAfterNil() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.move(item1, after: nil)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1]))
+ }
+
+ func testMoveLastItemAfterNil() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.move(item, after: nil)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemMoveBeforeTests.swift b/Tests/PlayerTests/Playlist/ItemMoveBeforeTests.swift
new file mode 100644
index 00000000..c9cabc1d
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemMoveBeforeTests.swift
@@ -0,0 +1,147 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ItemMoveBeforeTests: TestCase {
+ func testMovePreviousItemBeforeNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ expect(player.move(item1, before: item3)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1, item3]))
+ }
+
+ func testMovePreviousItemBeforeCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ expect(player.move(item1, before: item3)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1, item3]))
+ }
+
+ func testMovePreviousItemBeforePreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ expect(player.move(item2, before: item1)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1, item3]))
+ }
+
+ func testMoveCurrentItemBeforeNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ expect(player.move(item1, before: item3)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1, item3]))
+ }
+
+ func testMoveCurrentItemBeforePreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ expect(player.move(item2, before: item1)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1, item3]))
+ }
+
+ func testMoveNextItemBeforePreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ expect(player.move(item3, before: item1)).to(beTrue())
+ expect(player.items).to(equalDiff([item3, item1, item2]))
+ }
+
+ func testMoveNextItemBeforeCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ expect(player.move(item3, before: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item3, item2]))
+ }
+
+ func testMoveNextItemBeforeNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ expect(player.move(item3, before: item2)).to(beTrue())
+ expect(player.items).to(equalDiff([item1, item3, item2]))
+ }
+
+ func testMoveItemBeforeIdenticalItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.move(item, before: item)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+
+ func testMoveItemBeforeItemAlreadyAtExpectedLocation() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.move(item1, before: item2)).to(beFalse())
+ expect(player.items).to(equalDiff([item1, item2]))
+ }
+
+ func testMoveForeignItemBeforeItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let foreignItem = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.move(foreignItem, before: item)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+
+ func testMoveBeforeForeignItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let foreignItem = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.move(item, before: foreignItem)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+
+ func testMoveItemBeforeFirstItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.move(item2, before: item1)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1]))
+ }
+
+ func testMoveBeforeNil() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.move(item2, before: nil)).to(beTrue())
+ expect(player.items).to(equalDiff([item2, item1]))
+ }
+
+ func testMoveFirstItemBeforeNil() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ expect(player.move(item, before: nil)).to(beFalse())
+ expect(player.items).to(equalDiff([item]))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemNavigationBackwardChecksTests.swift b/Tests/PlayerTests/Playlist/ItemNavigationBackwardChecksTests.swift
new file mode 100644
index 00000000..6ff6bc0b
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemNavigationBackwardChecksTests.swift
@@ -0,0 +1,38 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class ItemNavigationBackwardChecksTests: TestCase {
+ func testCanReturnToPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.canReturnToPreviousItem()).to(beTrue())
+ }
+
+ func testCannotReturnToPreviousItemAtFront() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.canReturnToPreviousItem()).to(beFalse())
+ }
+
+ func testCannotReturnToPreviousItemWhenEmpty() {
+ let player = Player()
+ expect(player.canReturnToPreviousItem()).to(beFalse())
+ }
+
+ func testWrapAtFrontWithRepeatAll() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ player.repeatMode = .all
+ expect(player.canReturnToPreviousItem()).to(beTrue())
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemNavigationBackwardTests.swift b/Tests/PlayerTests/Playlist/ItemNavigationBackwardTests.swift
new file mode 100644
index 00000000..174fc0fc
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemNavigationBackwardTests.swift
@@ -0,0 +1,48 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class ItemNavigationBackwardTests: TestCase {
+ func testReturnToPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnToPreviousItemAtFront() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnToPreviousItemOnFailedItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testWrapAtFrontWithRepeatAll() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.repeatMode = .all
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item2))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemNavigationForwardChecksTests.swift b/Tests/PlayerTests/Playlist/ItemNavigationForwardChecksTests.swift
new file mode 100644
index 00000000..98a6d21c
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemNavigationForwardChecksTests.swift
@@ -0,0 +1,38 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class ItemNavigationForwardChecksTests: TestCase {
+ func testCanAdvanceToNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.canAdvanceToNextItem()).to(beTrue())
+ }
+
+ func testCannotAdvanceToNextItemAtBack() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.canAdvanceToNextItem()).to(beFalse())
+ }
+
+ func testCannotAdvanceToNextItemWhenEmpty() {
+ let player = Player()
+ expect(player.canAdvanceToNextItem()).to(beFalse())
+ }
+
+ func testWrapAtBackWithRepeatAll() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ player.repeatMode = .all
+ expect(player.canAdvanceToNextItem()).to(beTrue())
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift b/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift
new file mode 100644
index 00000000..92377e48
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemNavigationForwardTests.swift
@@ -0,0 +1,63 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class ItemNavigationForwardTests: TestCase {
+ func testAdvanceToNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceToNextItemAtBack() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceToNextItemOnFailedItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ expect(player.currentItem).to(equal(item3))
+ }
+
+ func testPlayerPreloadedItemCount() {
+ let player = Player(items: [
+ PlayerItem.simple(url: Stream.onDemand.url),
+ PlayerItem.simple(url: Stream.squareOnDemand.url),
+ PlayerItem.simple(url: Stream.mediumOnDemand.url),
+ PlayerItem.simple(url: Stream.onDemand.url),
+ PlayerItem.simple(url: Stream.shortOnDemand.url)
+ ])
+ player.advanceToNextItem()
+
+ let items = player.queuePlayer.items()
+ expect(items).to(haveCount(player.configuration.preloadedItems))
+ }
+
+ func testWrapAtBackWithRepeatAll() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.repeatMode = .all
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemRemovalTests.swift b/Tests/PlayerTests/Playlist/ItemRemovalTests.swift
new file mode 100644
index 00000000..583300d3
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemRemovalTests.swift
@@ -0,0 +1,62 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ItemRemovalTests: TestCase {
+ func testRemovePreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.remove(item1)
+ expect(player.items).to(equalDiff([item2, item3]))
+ }
+
+ func testRemoveCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.remove(item2)
+ expect(player.currentItem).to(equal(item3))
+ expect(player.items).to(equalDiff([item1, item3]))
+ }
+
+ func testRemoveNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.remove(item3)
+ expect(player.items).to(equalDiff([item1, item2]))
+ }
+
+ func testRemoveForeignItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let foreignItem = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item])
+ player.remove(foreignItem)
+ expect(player.items).to(equalDiff([item]))
+ }
+
+ func testRemoveAllItems() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.removeAllItems()
+ expect(player.items).to(beEmpty())
+ expect(player.currentItem).to(beNil())
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemsTests.swift b/Tests/PlayerTests/Playlist/ItemsTests.swift
new file mode 100644
index 00000000..bbcb8f90
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemsTests.swift
@@ -0,0 +1,70 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ItemsTests: TestCase {
+ func testItemsOnFirstItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ expect(player.items).to(equalDiff([item1, item2, item3]))
+ expect(player.previousItems).to(beEmpty())
+ expect(player.nextItems).to(equalDiff([item2, item3]))
+ }
+
+ func testItemsOnMiddleItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ expect(player.items).to(equalDiff([item1, item2, item3]))
+ expect(player.previousItems).to(equalDiff([item1]))
+ expect(player.nextItems).to(equalDiff([item3]))
+ }
+
+ func testItemsOnLastItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.advanceToNextItem()
+ player.advanceToNextItem()
+ expect(player.items).to(equalDiff([item1, item2, item3]))
+ expect(player.previousItems).to(equalDiff([item1, item2]))
+ expect(player.nextItems).to(beEmpty())
+ }
+
+ func testEmpty() {
+ let player = Player()
+ expect(player.currentItem).to(beNil())
+ expect(player.items).to(beEmpty())
+ expect(player.nextItems).to(beEmpty())
+ expect(player.previousItems).to(beEmpty())
+ }
+
+ func testRemoveAll() {
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(item: item)
+ expect(player.currentItem).to(equal(item))
+ player.removeAllItems()
+ expect(player.currentItem).to(beNil())
+ }
+
+ func testAppendAfterRemoveAll() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ player.removeAllItems()
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ player.append(item)
+ expect(player.currentItem).to(equal(item))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/ItemsUpdateTests.swift b/Tests/PlayerTests/Playlist/ItemsUpdateTests.swift
new file mode 100644
index 00000000..c98459d4
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/ItemsUpdateTests.swift
@@ -0,0 +1,49 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ItemsUpdateTests: TestCase {
+ func testUpdateWithCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item4 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.items = [item4, item3, item1]
+ expect(player.items).to(equalDiff([item4, item3, item1]))
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testUpdateWithCurrentItemMustNotInterruptPlayback() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemandWithForcedAndUnforcedLegibleOptions.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemandWithSingleAudibleOption.url)
+ let item4 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ expect(player.queuePlayer.currentItem?.url).toEventually(equal(Stream.onDemand.url))
+ player.items = [item4, item3, item1]
+ expect(player.queuePlayer.currentItem?.url).toAlways(equal(Stream.onDemand.url), until: .seconds(2))
+ }
+
+ func testUpdateWithoutCurrentItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item3 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item4 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item5 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item6 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2, item3])
+ player.items = [item4, item5, item6]
+ expect(player.items).to(equalDiff([item4, item5, item6]))
+ expect(player.currentItem).to(equal(item4))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/NavigationBackwardChecksTests.swift b/Tests/PlayerTests/Playlist/NavigationBackwardChecksTests.swift
new file mode 100644
index 00000000..15a5e2c8
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/NavigationBackwardChecksTests.swift
@@ -0,0 +1,111 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class NavigationBackwardChecksTests: TestCase {
+ private static func configuration() -> PlayerConfiguration {
+ .init(navigationMode: .immediate)
+ }
+
+ func testCannotReturnForOnDemandAtBeginningWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canReturnToPrevious()).to(beFalse())
+ }
+
+ func testCanReturnForOnDemandNearBeginningWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).toEventually(equal(.onDemand))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 1, timescale: 1))) { _ in
+ done()
+ }
+ }
+
+ expect(player.canReturnToPrevious()).to(beFalse())
+ }
+
+ func testCanReturnForOnDemandAtBeginningWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCanReturnForOnDemandNotAtBeginning() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.onDemand))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 5, timescale: 1))) { _ in
+ done()
+ }
+ }
+
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCanReturnForLiveWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCannotReturnForLiveWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canReturnToPrevious()).to(beFalse())
+ }
+
+ func testCanReturnForDvrWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCannotReturnForDvrWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canReturnToPrevious()).to(beFalse())
+ }
+
+ func testCanReturnForUnknownWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).to(equal(.unknown))
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCannotReturnForUnknownWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).to(equal(.unknown))
+ expect(player.canReturnToPrevious()).to(beFalse())
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/NavigationBackwardTests.swift b/Tests/PlayerTests/Playlist/NavigationBackwardTests.swift
new file mode 100644
index 00000000..b7c086c1
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/NavigationBackwardTests.swift
@@ -0,0 +1,137 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class NavigationBackwardTests: TestCase {
+ private static func configuration() -> PlayerConfiguration {
+ .init(navigationMode: .immediate)
+ }
+
+ func testReturnForOnDemandAtBeginningWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.returnToPrevious()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testReturnForOnDemandNearBeginningWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).toEventually(equal(.onDemand))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 1, timescale: 1))) { _ in
+ done()
+ }
+ }
+
+ player.returnToPrevious()
+ expect(player.currentItem).to(equal(item))
+ expect(player.time()).toNever(equal(.zero), until: .seconds(3))
+ }
+
+ func testReturnForOnDemandAtBeginningWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.returnToPrevious()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnForOnDemandNotAtBeginning() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.onDemand))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 5, timescale: 1))) { _ in
+ done()
+ }
+ }
+ player.returnToPrevious()
+ expect(player.currentItem).to(equal(item1))
+ expect(player.time()).toEventually(equal(.zero))
+ }
+
+ func testReturnForLiveWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.live))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnForLiveWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).toEventually(equal(.live))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testReturnForDvrWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.dvr))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnForDvrWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).toEventually(equal(.dvr))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testReturnForUnknownWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(items: [item1, item2], configuration: Self.configuration())
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.unknown))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnForUnknownWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item, configuration: Self.configuration())
+ expect(player.streamType).toEventually(equal(.unknown))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testPlayerPreloadedItemCount() {
+ let player = Player(items: [
+ PlayerItem.simple(url: Stream.onDemand.url),
+ PlayerItem.simple(url: Stream.squareOnDemand.url),
+ PlayerItem.simple(url: Stream.mediumOnDemand.url),
+ PlayerItem.simple(url: Stream.onDemand.url),
+ PlayerItem.simple(url: Stream.shortOnDemand.url)
+ ])
+ player.advanceToNextItem()
+ player.returnToPrevious()
+
+ let items = player.queuePlayer.items()
+ expect(items).to(haveCount(player.configuration.preloadedItems))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/NavigationForwardChecksTests.swift b/Tests/PlayerTests/Playlist/NavigationForwardChecksTests.swift
new file mode 100644
index 00000000..2e075dc8
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/NavigationForwardChecksTests.swift
@@ -0,0 +1,72 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class NavigationForwardChecksTests: TestCase {
+ func testCanAdvanceForOnDemandWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canAdvanceToNext()).to(beTrue())
+ }
+
+ func testCannotAdvanceForOnDemandWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canAdvanceToNext()).to(beFalse())
+ }
+
+ func testCanAdvanceForLiveWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.live.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canAdvanceToNext()).to(beTrue())
+ }
+
+ func testCannotAdvanceForLiveWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canAdvanceToNext()).to(beFalse())
+ }
+
+ func testCanAdvanceForDvrWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.dvr.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canAdvanceToNext()).to(beTrue())
+ }
+
+ func testCannotAdvanceForDvrWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canAdvanceToNext()).to(beFalse())
+ }
+
+ func testCanAdvanceForUnknownWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).to(equal(.unknown))
+ expect(player.canAdvanceToNext()).to(beTrue())
+ }
+
+ func testCannotAdvanceForUnknownWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ expect(player.streamType).to(equal(.unknown))
+ expect(player.canAdvanceToNext()).to(beFalse())
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/NavigationForwardTests.swift b/Tests/PlayerTests/Playlist/NavigationForwardTests.swift
new file mode 100644
index 00000000..0e40acfd
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/NavigationForwardTests.swift
@@ -0,0 +1,80 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class NavigationForwardTests: TestCase {
+ func testAdvanceForOnDemandWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceForOnDemandWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testAdvanceForLiveWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.live.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.live))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceForLiveWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.live))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testAdvanceForDvrWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.dvr.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.dvr))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceForDvrWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.dvr))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testAdvanceForUnknownWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).to(equal(.unknown))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceForUnknownWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ expect(player.streamType).to(equal(.unknown))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/NavigationSmartBackwardChecksTests.swift b/Tests/PlayerTests/Playlist/NavigationSmartBackwardChecksTests.swift
new file mode 100644
index 00000000..5eb6d1fe
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/NavigationSmartBackwardChecksTests.swift
@@ -0,0 +1,107 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class NavigationSmartBackwardChecksTests: TestCase {
+ func testCanReturnForOnDemandAtBeginningWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCanReturnForOnDemandNearBeginningWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.onDemand))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 1, timescale: 1))) { _ in
+ done()
+ }
+ }
+
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCanReturnForOnDemandAtBeginningWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCanReturnForOnDemandNotAtBeginning() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.onDemand))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 5, timescale: 1))) { _ in
+ done()
+ }
+ }
+
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCanReturnForLiveWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCannotReturnForLiveWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canReturnToPrevious()).to(beFalse())
+ }
+
+ func testCanReturnForDvrWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCannotReturnForDvrWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canReturnToPrevious()).to(beFalse())
+ }
+
+ func testCanReturnForUnknownWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).to(equal(.unknown))
+ expect(player.canReturnToPrevious()).to(beTrue())
+ }
+
+ func testCannotReturnForUnknownWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ expect(player.streamType).to(equal(.unknown))
+ expect(player.canReturnToPrevious()).to(beFalse())
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/NavigationSmartBackwardTests.swift b/Tests/PlayerTests/Playlist/NavigationSmartBackwardTests.swift
new file mode 100644
index 00000000..222a445f
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/NavigationSmartBackwardTests.swift
@@ -0,0 +1,118 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class NavigationSmartBackwardTests: TestCase {
+ func testReturnForOnDemandAtBeginningWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.returnToPrevious()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testReturnForOnDemandNearBeginningWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.onDemand))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 1, timescale: 1))) { _ in
+ done()
+ }
+ }
+
+ player.returnToPrevious()
+ expect(player.currentItem).to(equal(item))
+ expect(player.time()).toEventually(equal(.zero))
+ }
+
+ func testReturnForOnDemandAtBeginningWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.returnToPrevious()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnForOnDemandNotAtBeginning() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.onDemand))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 5, timescale: 1))) { _ in
+ done()
+ }
+ }
+ player.returnToPrevious()
+ expect(player.currentItem).to(equal(item2))
+ expect(player.time()).toEventually(equal(.zero))
+ }
+
+ func testReturnForLiveWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.live))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnForLiveWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.live))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testReturnForDvrWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.dvr))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnForDvrWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.dvr))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testReturnForUnknownWithPreviousItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(items: [item1, item2])
+ player.advanceToNextItem()
+ expect(player.streamType).toEventually(equal(.unknown))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item1))
+ }
+
+ func testReturnForUnknownWithoutPreviousItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.unknown))
+ player.returnToPreviousItem()
+ expect(player.currentItem).to(equal(item))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/NavigationSmartForwardChecksTests.swift b/Tests/PlayerTests/Playlist/NavigationSmartForwardChecksTests.swift
new file mode 100644
index 00000000..e6c1e496
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/NavigationSmartForwardChecksTests.swift
@@ -0,0 +1,72 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class NavigationSmartForwardChecksTests: TestCase {
+ func testCanAdvanceForOnDemandWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canAdvanceToNext()).to(beTrue())
+ }
+
+ func testCannotAdvanceForOnDemandWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canAdvanceToNext()).to(beFalse())
+ }
+
+ func testCanAdvanceForLiveWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.live.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canAdvanceToNext()).to(beTrue())
+ }
+
+ func testCannotAdvanceForLiveWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canAdvanceToNext()).to(beFalse())
+ }
+
+ func testCanAdvanceForDvrWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.dvr.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canAdvanceToNext()).to(beTrue())
+ }
+
+ func testCannotAdvanceForDvrWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canAdvanceToNext()).to(beFalse())
+ }
+
+ func testCanAdvanceForUnknownWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).to(equal(.unknown))
+ expect(player.canAdvanceToNext()).to(beTrue())
+ }
+
+ func testCannotAdvanceForUnknownWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ expect(player.streamType).to(equal(.unknown))
+ expect(player.canAdvanceToNext()).to(beFalse())
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/NavigationSmartForwardTests.swift b/Tests/PlayerTests/Playlist/NavigationSmartForwardTests.swift
new file mode 100644
index 00000000..6f579725
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/NavigationSmartForwardTests.swift
@@ -0,0 +1,80 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class NavigationSmartForwardTests: TestCase {
+ func testAdvanceForOnDemandWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceForOnDemandWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testAdvanceForLiveWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.live.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.live))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceForLiveWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.live.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.live))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testAdvanceForDvrWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.dvr.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).toEventually(equal(.dvr))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceForDvrWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.dvr))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item))
+ }
+
+ func testAdvanceForUnknownWithNextItem() {
+ let item1 = PlayerItem.simple(url: Stream.unavailable.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ expect(player.streamType).to(equal(.unknown))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item2))
+ }
+
+ func testAdvanceForUnknownWithoutNextItem() {
+ let item = PlayerItem.simple(url: Stream.unavailable.url)
+ let player = Player(item: item)
+ expect(player.streamType).to(equal(.unknown))
+ player.advanceToNext()
+ expect(player.currentItem).to(equal(item))
+ }
+}
diff --git a/Tests/PlayerTests/Playlist/RepeatModeTests.swift b/Tests/PlayerTests/Playlist/RepeatModeTests.swift
new file mode 100644
index 00000000..298cc51b
--- /dev/null
+++ b/Tests/PlayerTests/Playlist/RepeatModeTests.swift
@@ -0,0 +1,53 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class RepeatModeTests: TestCase {
+ func testRepeatOne() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(items: [item1, item2])
+ player.repeatMode = .one
+ player.play()
+ expect(player.currentItem).toAlways(equal(item1), until: .seconds(2))
+ player.repeatMode = .off
+ expect(player.currentItem).toEventually(equal(item2))
+ }
+
+ func testRepeatAll() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(items: [item1, item2])
+ player.repeatMode = .all
+ player.play()
+ expect(player.currentItem).toEventually(equal(item1))
+ expect(player.currentItem).toEventually(equal(item2))
+ expect(player.currentItem).toEventually(equal(item1))
+ player.repeatMode = .off
+ expect(player.currentItem).toEventually(equal(item2))
+ expect(player.currentItem).toEventually(beNil())
+ }
+
+ func testRepeatModeUpdateDoesNotRestartPlayback() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ player.play()
+ expect(player.streamType).toEventually(equal(.onDemand))
+ player.repeatMode = .one
+ expect(player.streamType).toNever(equal(.unknown), until: .milliseconds(100))
+ }
+
+ func testRepeatModeUpdateDoesNotReplay() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ player.play()
+ expect(player.currentItem).toEventually(beNil())
+ player.repeatMode = .one
+ expect(player.currentItem).toAlways(beNil(), until: .milliseconds(100))
+ }
+}
diff --git a/Tests/PlayerTests/ProgressTracker/ProgressTrackerPlaybackStateTests.swift b/Tests/PlayerTests/ProgressTracker/ProgressTrackerPlaybackStateTests.swift
new file mode 100644
index 00000000..b8ee8729
--- /dev/null
+++ b/Tests/PlayerTests/ProgressTracker/ProgressTrackerPlaybackStateTests.swift
@@ -0,0 +1,65 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class ProgressTrackerPlaybackStateTests: TestCase {
+ func testInteractionPausesPlayback() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ progressTracker.isInteracting = true
+ expect(player.playbackState).toEventually(equal(.paused))
+
+ progressTracker.isInteracting = false
+ expect(player.playbackState).toEventually(equal(.playing))
+ }
+
+ func testInteractionDoesUpdateAlreadyPausedPlayback() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ expect(player.playbackState).toEventually(equal(.paused))
+
+ progressTracker.isInteracting = true
+ expect(player.playbackState).toAlways(equal(.paused), until: .seconds(1))
+
+ progressTracker.isInteracting = false
+ expect(player.playbackState).toAlways(equal(.paused), until: .seconds(1))
+ }
+
+ func testTransferInteractionBetweenPlayers() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+
+ let item1 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player1 = Player(item: item1)
+ progressTracker.player = player1
+ player1.play()
+ expect(player1.playbackState).toEventually(equal(.playing))
+
+ progressTracker.isInteracting = true
+ expect(player1.playbackState).toEventually(equal(.paused))
+
+ let item2 = PlayerItem.simple(url: Stream.onDemand.url)
+ let player2 = Player(item: item2)
+ progressTracker.player = player2
+ player2.play()
+ expect(player2.playbackState).toEventually(equal(.playing))
+
+ progressTracker.player = player2
+ expect(player1.playbackState).toEventually(equal(.playing))
+ expect(player2.playbackState).toEventually(equal(.paused))
+ }
+}
diff --git a/Tests/PlayerTests/ProgressTracker/ProgressTrackerProgressAvailabilityTests.swift b/Tests/PlayerTests/ProgressTracker/ProgressTrackerProgressAvailabilityTests.swift
new file mode 100644
index 00000000..5dc223fe
--- /dev/null
+++ b/Tests/PlayerTests/ProgressTracker/ProgressTrackerProgressAvailabilityTests.swift
@@ -0,0 +1,132 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ProgressTrackerProgressAvailabilityTests: TestCase {
+ func testUnbound() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [false],
+ from: progressTracker.changePublisher(at: \.isProgressAvailable)
+ .removeDuplicates()
+ )
+ }
+
+ func testEmptyPlayer() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [false],
+ from: progressTracker.changePublisher(at: \.isProgressAvailable)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = Player()
+ }
+ }
+
+ func testPausedPlayer() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [false, true],
+ from: progressTracker.changePublisher(at: \.isProgressAvailable)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ }
+ }
+
+ func testEntirePlayback() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [false, true, false],
+ from: progressTracker.changePublisher(at: \.isProgressAvailable)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ player.play()
+ }
+ }
+
+ func testPausedDvrStream() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [false, true],
+ from: progressTracker.changePublisher(at: \.isProgressAvailable)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ }
+ }
+
+ func testPlayerChange() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.isProgressAvailable).toEventually(beTrue())
+
+ expectAtLeastEqualPublished(
+ values: [true, false],
+ from: progressTracker.changePublisher(at: \.isProgressAvailable)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = Player()
+ }
+ }
+
+ func testPlayerSetToNil() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.isProgressAvailable).toEventually(beTrue())
+
+ expectAtLeastEqualPublished(
+ values: [true, false],
+ from: progressTracker.changePublisher(at: \.isProgressAvailable)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = nil
+ }
+ }
+
+ func testBoundToPlayerAtSomeTime() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+
+ expect(player.seekableTimeRange).toEventuallyNot(equal(.invalid))
+ let time = CMTime(value: 20, timescale: 1)
+
+ waitUntil { done in
+ player.seek(at(time)) { _ in
+ done()
+ }
+ }
+
+ expectAtLeastEqualPublished(
+ values: [false, true],
+ from: progressTracker.changePublisher(at: \.isProgressAvailable)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ }
+ }
+}
diff --git a/Tests/PlayerTests/ProgressTracker/ProgressTrackerProgressTests.swift b/Tests/PlayerTests/ProgressTracker/ProgressTrackerProgressTests.swift
new file mode 100644
index 00000000..559fb6f6
--- /dev/null
+++ b/Tests/PlayerTests/ProgressTracker/ProgressTrackerProgressTests.swift
@@ -0,0 +1,157 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ProgressTrackerProgressTests: TestCase {
+ func testUnbound() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [0],
+ from: progressTracker.changePublisher(at: \.progress)
+ .removeDuplicates()
+ )
+ }
+
+ func testEmptyPlayer() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [0],
+ from: progressTracker.changePublisher(at: \.progress)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = Player()
+ }
+ }
+
+ func testPausedPlayer() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [0],
+ from: progressTracker.changePublisher(at: \.progress)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ }
+ }
+
+ func testEntirePlayback() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(item: item)
+ expectPublished(
+ values: [0, 0.25, 0.5, 0.75, 1, 0],
+ from: progressTracker.changePublisher(at: \.progress)
+ .removeDuplicates(),
+ to: beClose(within: 0.1),
+ during: .seconds(2)
+ ) {
+ progressTracker.player = player
+ player.play()
+ }
+ }
+
+ func testPausedDvrStream() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expectAtLeastPublished(
+ values: [0, 1, 0.95, 0.9, 0.85, 0.8],
+ from: progressTracker.changePublisher(at: \.progress)
+ .removeDuplicates(),
+ to: beClose(within: 0.1)
+ ) {
+ progressTracker.player = player
+ }
+ }
+
+ func testPlayerChange() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.progress).toEventuallyNot(equal(0))
+
+ let progress = progressTracker.progress
+ expectAtLeastEqualPublished(
+ values: [progress, 0],
+ from: progressTracker.changePublisher(at: \.progress)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = Player()
+ }
+ }
+
+ func testPlayerSetToNil() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.progress).toEventuallyNot(equal(0))
+
+ let progress = progressTracker.progress
+ expectAtLeastEqualPublished(
+ values: [progress, 0],
+ from: progressTracker.changePublisher(at: \.progress)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = nil
+ }
+ }
+
+ func testBoundToPlayerAtSomeTime() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+
+ expect(player.seekableTimeRange).toEventuallyNot(equal(.invalid))
+ let time = CMTime(value: 20, timescale: 1)
+
+ waitUntil { done in
+ player.seek(at(time)) { _ in
+ done()
+ }
+ }
+
+ let progress = Float(20.0 / Stream.onDemand.duration.seconds)
+ expectAtLeastPublished(
+ values: [0, progress],
+ from: progressTracker.changePublisher(at: \.progress)
+ .removeDuplicates(),
+ to: beClose(within: 0.1)
+ ) {
+ progressTracker.player = player
+ }
+ }
+
+ func testProgressForTimeInTimeRange() {
+ let timeRange = CMTimeRange(start: .zero, end: .init(value: 10, timescale: 1))
+ expect(ProgressTracker.progress(for: .init(value: 5, timescale: 1), in: timeRange)).to(equal(0.5))
+ expect(ProgressTracker.progress(for: .init(value: 15, timescale: 1), in: timeRange)).to(equal(1.5))
+ }
+
+ func testValidProgressInRange() {
+ expect(ProgressTracker.validProgress(nil, in: 0...1)).to(equal(0))
+ expect(ProgressTracker.validProgress(0.5, in: 0...1)).to(equal(0.5))
+ expect(ProgressTracker.validProgress(1.5, in: 0...1)).to(equal(1))
+ }
+
+ func testTimeForProgressInTimeRange() {
+ let timeRange = CMTimeRange(start: .zero, end: .init(value: 10, timescale: 1))
+ expect(ProgressTracker.time(forProgress: 0.5, in: timeRange)).to(equal(CMTime(value: 5, timescale: 1)))
+ expect(ProgressTracker.time(forProgress: 1.5, in: timeRange)).to(equal(CMTime(value: 15, timescale: 1)))
+ }
+}
diff --git a/Tests/PlayerTests/ProgressTracker/ProgressTrackerRangeTests.swift b/Tests/PlayerTests/ProgressTracker/ProgressTrackerRangeTests.swift
new file mode 100644
index 00000000..1c80b1eb
--- /dev/null
+++ b/Tests/PlayerTests/ProgressTracker/ProgressTrackerRangeTests.swift
@@ -0,0 +1,132 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ProgressTrackerRangeTests: TestCase {
+ func testUnbound() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [0...0],
+ from: progressTracker.changePublisher(at: \.range)
+ .removeDuplicates()
+ )
+ }
+
+ func testEmptyPlayer() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [0...0],
+ from: progressTracker.changePublisher(at: \.range)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = Player()
+ }
+ }
+
+ func testPausedPlayer() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [0...0, 0...1],
+ from: progressTracker.changePublisher(at: \.range)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ }
+ }
+
+ func testEntirePlayback() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [0...0, 0...1, 0...0],
+ from: progressTracker.changePublisher(at: \.range)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ player.play()
+ }
+ }
+
+ func testPausedDvrStream() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [0...0, 0...1],
+ from: progressTracker.changePublisher(at: \.range)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ }
+ }
+
+ func testPlayerChange() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.range).toEventuallyNot(equal(0...0))
+
+ expectAtLeastEqualPublished(
+ values: [0...1, 0...0],
+ from: progressTracker.changePublisher(at: \.range)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = Player()
+ }
+ }
+
+ func testPlayerSetToNil() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.range).toEventuallyNot(equal(0...0))
+
+ expectAtLeastEqualPublished(
+ values: [0...1, 0...0],
+ from: progressTracker.changePublisher(at: \.range)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = nil
+ }
+ }
+
+ func testBoundToPlayerAtSomeTime() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+
+ expect(player.seekableTimeRange).toEventuallyNot(equal(.invalid))
+ let time = CMTime(value: 20, timescale: 1)
+
+ waitUntil { done in
+ player.seek(at(time)) { _ in
+ done()
+ }
+ }
+
+ expectAtLeastEqualPublished(
+ values: [0...0, 0...1],
+ from: progressTracker.changePublisher(at: \.range)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ }
+ }
+}
diff --git a/Tests/PlayerTests/ProgressTracker/ProgressTrackerSeekBehaviorTests.swift b/Tests/PlayerTests/ProgressTracker/ProgressTrackerSeekBehaviorTests.swift
new file mode 100644
index 00000000..f7088c0d
--- /dev/null
+++ b/Tests/PlayerTests/ProgressTracker/ProgressTrackerSeekBehaviorTests.swift
@@ -0,0 +1,68 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ProgressTrackerSeekBehaviorTests: TestCase {
+ private func isSeekingPublisher(for player: Player) -> AnyPublisher {
+ player.propertiesPublisher
+ .slice(at: \.isSeeking)
+ .eraseToAnyPublisher()
+ }
+
+ func testImmediateSeek() {
+ let progressTracker = ProgressTracker(
+ interval: CMTime(value: 1, timescale: 4),
+ seekBehavior: .immediate
+ )
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ expect(progressTracker.range).toEventually(equal(0...1))
+
+ expectAtLeastEqualPublished(
+ values: [false, true, false],
+ from: isSeekingPublisher(for: player)
+ ) {
+ progressTracker.isInteracting = true
+ progressTracker.progress = 0.5
+ }
+ expect(progressTracker.progress).to(equal(0.5))
+ }
+
+ func testDeferredSeek() {
+ let progressTracker = ProgressTracker(
+ interval: CMTime(value: 1, timescale: 4),
+ seekBehavior: .deferred
+ )
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ expect(progressTracker.range).toEventually(equal(0...1))
+
+ expectAtLeastEqualPublished(
+ values: [false],
+ from: isSeekingPublisher(for: player)
+ ) {
+ progressTracker.isInteracting = true
+ progressTracker.progress = 0.5
+ }
+
+ expectAtLeastEqualPublishedNext(
+ values: [true, false],
+ from: isSeekingPublisher(for: player)
+ ) {
+ progressTracker.isInteracting = false
+ }
+ expect(progressTracker.progress).toEventually(equal(0.5))
+ }
+}
diff --git a/Tests/PlayerTests/ProgressTracker/ProgressTrackerTimeTests.swift b/Tests/PlayerTests/ProgressTracker/ProgressTrackerTimeTests.swift
new file mode 100644
index 00000000..0752bafe
--- /dev/null
+++ b/Tests/PlayerTests/ProgressTracker/ProgressTrackerTimeTests.swift
@@ -0,0 +1,153 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class ProgressTrackerTimeTests: TestCase {
+ func testUnbound() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [.invalid],
+ from: progressTracker.changePublisher(at: \.time)
+ .removeDuplicates()
+ )
+ }
+
+ func testEmptyPlayer() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ expectAtLeastEqualPublished(
+ values: [.invalid],
+ from: progressTracker.changePublisher(at: \.time)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = Player()
+ }
+ }
+
+ func testPausedPlayer() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [.invalid, .zero],
+ from: progressTracker.changePublisher(at: \.time)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = player
+ }
+ }
+
+ func testEntirePlayback() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let player = Player(item: item)
+ expectPublished(
+ values: [
+ .invalid,
+ .zero,
+ CMTime(value: 1, timescale: 4),
+ CMTime(value: 1, timescale: 2),
+ CMTime(value: 3, timescale: 4),
+ CMTime(value: 1, timescale: 1),
+ .invalid
+ ],
+ from: progressTracker.changePublisher(at: \.time)
+ .removeDuplicates(),
+ to: beClose(within: 0.1),
+ during: .seconds(2)
+ ) {
+ progressTracker.player = player
+ player.play()
+ }
+ }
+
+ func testPausedDvrStream() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expectAtLeastPublished(
+ values: [
+ .invalid,
+ CMTime(value: 17, timescale: 1),
+ CMTime(value: 17, timescale: 1),
+ CMTime(value: 17, timescale: 1),
+ CMTime(value: 17, timescale: 1),
+ CMTime(value: 17, timescale: 1)
+ ],
+ from: progressTracker.changePublisher(at: \.time)
+ .removeDuplicates(),
+ to: beClose(within: 0.1)
+ ) {
+ progressTracker.player = player
+ }
+ }
+
+ func testPlayerChange() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.time).toEventuallyNot(equal(.invalid))
+
+ let time = progressTracker.time
+ expectAtLeastEqualPublished(
+ values: [time, .invalid],
+ from: progressTracker.changePublisher(at: \.time)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = Player()
+ }
+ }
+
+ func testPlayerSetToNil() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.time).toEventuallyNot(equal(.invalid))
+
+ let time = progressTracker.time
+ expectAtLeastEqualPublished(
+ values: [time, .invalid],
+ from: progressTracker.changePublisher(at: \.time)
+ .removeDuplicates()
+ ) {
+ progressTracker.player = nil
+ }
+ }
+
+ func testBoundToPlayerAtSomeTime() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+
+ expect(player.seekableTimeRange).toEventuallyNot(equal(.invalid))
+ let time = CMTime(value: 20, timescale: 1)
+
+ waitUntil { done in
+ player.seek(at(time)) { _ in
+ done()
+ }
+ }
+
+ expectAtLeastPublished(
+ values: [.invalid, time],
+ from: progressTracker.changePublisher(at: \.time)
+ .removeDuplicates(),
+ to: beClose(within: 0.1)
+ ) {
+ progressTracker.player = player
+ }
+ }
+}
diff --git a/Tests/PlayerTests/ProgressTracker/ProgressTrackerValueTests.swift b/Tests/PlayerTests/ProgressTracker/ProgressTrackerValueTests.swift
new file mode 100644
index 00000000..830d10a8
--- /dev/null
+++ b/Tests/PlayerTests/ProgressTracker/ProgressTrackerValueTests.swift
@@ -0,0 +1,56 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class ProgressTrackerValueTests: TestCase {
+ func testProgressValueInRange() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.range).toEventuallyNot(equal(0...0))
+ progressTracker.progress = 0.5
+ expect(progressTracker.progress).to(beCloseTo(0.5, within: 0.1))
+ }
+
+ func testProgressValueBelowZero() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.range).toEventuallyNot(equal(0...0))
+ progressTracker.progress = -10
+ expect(progressTracker.progress).to(beCloseTo(0, within: 0.1))
+ }
+
+ func testProgressValueAboveOne() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ progressTracker.player = player
+ player.play()
+ expect(progressTracker.range).toEventuallyNot(equal(0...0))
+ progressTracker.progress = 10
+ expect(progressTracker.progress).to(beCloseTo(1, within: 0.1))
+ }
+
+ func testCannotChangeProgressWhenUnavailable() {
+ let progressTracker = ProgressTracker(interval: CMTime(value: 1, timescale: 4))
+ let player = Player()
+ progressTracker.player = player
+ expect(progressTracker.isProgressAvailable).to(equal(false))
+ expect(progressTracker.progress).to(beCloseTo(0, within: 0.1))
+ progressTracker.progress = 0.5
+ expect(progressTracker.progress).to(beCloseTo(0, within: 0.1))
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/AVAssetMediaSelectionGroupsPublisherTests.swift b/Tests/PlayerTests/Publishers/AVAssetMediaSelectionGroupsPublisherTests.swift
new file mode 100644
index 00000000..9dd24d60
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/AVAssetMediaSelectionGroupsPublisherTests.swift
@@ -0,0 +1,37 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxStreams
+
+// swiftlint:disable:next type_name
+final class AVAssetMediaSelectionGroupsPublisherTests: TestCase {
+ func testFetch() throws {
+ let asset = AVURLAsset(url: Stream.onDemandWithOptions.url)
+ let groups = try waitForSingleOutput(from: asset.mediaSelectionGroupsPublisher())
+ expect(groups[.audible]).notTo(beNil())
+ expect(groups[.legible]).notTo(beNil())
+ }
+
+ func testFetchWithoutSelectionAvailable() throws {
+ let asset = AVURLAsset(url: Stream.onDemandWithoutOptions.url)
+ let groups = try waitForSingleOutput(from: asset.mediaSelectionGroupsPublisher())
+ expect(groups).to(beEmpty())
+ }
+
+ func testRepeatedFetch() throws {
+ let asset = AVURLAsset(url: Stream.onDemandWithOptions.url)
+
+ let groups1 = try waitForSingleOutput(from: asset.mediaSelectionGroupsPublisher())
+ expect(groups1).notTo(beEmpty())
+
+ let groups2 = try waitForSingleOutput(from: asset.mediaSelectionGroupsPublisher())
+ expect(groups2).to(equal(groups1))
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/AVAsynchronousKeyValueLoadingPublisherTests.swift b/Tests/PlayerTests/Publishers/AVAsynchronousKeyValueLoadingPublisherTests.swift
new file mode 100644
index 00000000..b2ad7d09
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/AVAsynchronousKeyValueLoadingPublisherTests.swift
@@ -0,0 +1,62 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+// swiftlint:disable:next type_name
+final class AVAsynchronousKeyValueLoadingPublisherTests: TestCase {
+ func testAssetFetch() throws {
+ let asset = AVURLAsset(url: Stream.onDemand.url)
+ let duration = try waitForSingleOutput(from: asset.propertyPublisher(.duration))
+ expect(duration).to(equal(Stream.onDemand.duration, by: beClose(within: 1)))
+ }
+
+ func testAssetRepeatedFetch() throws {
+ let asset = AVURLAsset(url: Stream.onDemand.url)
+
+ let duration1 = try waitForSingleOutput(from: asset.propertyPublisher(.duration))
+ expect(duration1).to(equal(Stream.onDemand.duration, by: beClose(within: 1)))
+
+ let duration2 = try waitForSingleOutput(from: asset.propertyPublisher(.duration))
+ expect(duration2).to(equal(Stream.onDemand.duration, by: beClose(within: 1)))
+ }
+
+ func testAssetFailedFetch() throws {
+ let asset = AVURLAsset(url: Stream.unavailable.url)
+ let error = try waitForFailure(from: asset.propertyPublisher(.duration))
+ expect(error).notTo(beNil())
+ }
+
+ func testAssetMultipleFetch() throws {
+ let asset = AVURLAsset(url: Stream.onDemand.url)
+ let (duration, preferredRate) = try waitForSingleOutput(from: asset.propertyPublisher(.duration, .preferredRate))
+ expect(duration).to(equal(Stream.onDemand.duration, by: beClose(within: 1)))
+ expect(preferredRate).to(equal(1))
+ }
+
+ func testAssetFailedMultipleFetch() throws {
+ let asset = AVURLAsset(url: Stream.unavailable.url)
+ let error = try waitForFailure(from: asset.propertyPublisher(.duration, .preferredRate))
+ expect(error).notTo(beNil())
+ }
+
+ func testMetadataItemFetch() throws {
+ let item = AVMetadataItem(identifier: .commonIdentifierTitle, value: "Title")!
+ let title = try waitForSingleOutput(from: item.propertyPublisher(.stringValue))
+ expect(title).to(equal("Title"))
+ }
+
+ func testMetadataItemFetchWithTypeMismatch() throws {
+ let item = AVMetadataItem(identifier: .commonIdentifierTitle, value: "Title")!
+ let title = try waitForSingleOutput(from: item.propertyPublisher(.dateValue))
+ expect(title).to(beNil())
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/AVPlayerBoundaryTimePublisherTests.swift b/Tests/PlayerTests/Publishers/AVPlayerBoundaryTimePublisherTests.swift
new file mode 100644
index 00000000..3a4c2e7f
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/AVPlayerBoundaryTimePublisherTests.swift
@@ -0,0 +1,74 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Combine
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class AVPlayerBoundaryTimePublisherTests: TestCase {
+ func testEmpty() {
+ let player = AVPlayer()
+ expectNothingPublished(
+ from: Publishers.BoundaryTimePublisher(
+ for: player,
+ times: [CMTimeMake(value: 1, timescale: 2)]
+ ),
+ during: .seconds(2)
+ )
+ }
+
+ func testNoPlayback() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = AVPlayer(playerItem: item)
+ expectNothingPublished(
+ from: Publishers.BoundaryTimePublisher(
+ for: player,
+ times: [CMTimeMake(value: 1, timescale: 2)]
+ ),
+ during: .seconds(2)
+ )
+ }
+
+ func testPlayback() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = AVPlayer(playerItem: item)
+ expectAtLeastEqualPublished(
+ values: [
+ "tick", "tick"
+ ],
+ from: Publishers.BoundaryTimePublisher(
+ for: player,
+ times: [
+ CMTimeMake(value: 1, timescale: 2),
+ CMTimeMake(value: 2, timescale: 2)
+ ]
+ )
+ .map { "tick" }
+ ) {
+ player.play()
+ }
+ }
+
+ func testDeallocation() {
+ var player: AVPlayer? = AVPlayer()
+ _ = Publishers.BoundaryTimePublisher(
+ for: player!,
+ times: [
+ CMTimeMake(value: 1, timescale: 2)
+ ]
+ )
+
+ weak var weakPlayer = player
+ autoreleasepool {
+ player = nil
+ }
+ expect(weakPlayer).to(beNil())
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/AVPlayerItemErrorPublisherTests.swift b/Tests/PlayerTests/Publishers/AVPlayerItemErrorPublisherTests.swift
new file mode 100644
index 00000000..0240d286
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/AVPlayerItemErrorPublisherTests.swift
@@ -0,0 +1,41 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Combine
+import PillarboxStreams
+
+final class AVPlayerItemErrorPublisherTests: TestCase {
+ private static func errorCodePublisher(for item: AVPlayerItem) -> AnyPublisher {
+ item.errorPublisher()
+ .map { .init(rawValue: ($0 as NSError).code) }
+ .eraseToAnyPublisher()
+ }
+
+ func testNoError() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ _ = AVPlayer(playerItem: item)
+ expectNothingPublished(from: item.errorPublisher(), during: .milliseconds(500))
+ }
+
+ func testM3u8Error() {
+ let item = AVPlayerItem(url: Stream.unavailable.url)
+ _ = AVPlayer(playerItem: item)
+ expectAtLeastEqualPublished(values: [
+ URLError.fileDoesNotExist
+ ], from: Self.errorCodePublisher(for: item))
+ }
+
+ func testMp3Error() {
+ let item = AVPlayerItem(url: Stream.unavailableMp3.url)
+ _ = AVPlayer(playerItem: item)
+ expectAtLeastEqualPublished(values: [
+ URLError.fileDoesNotExist
+ ], from: Self.errorCodePublisher(for: item))
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/AVPlayerItemMetricEventPublisherTests.swift b/Tests/PlayerTests/Publishers/AVPlayerItemMetricEventPublisherTests.swift
new file mode 100644
index 00000000..f4811e4f
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/AVPlayerItemMetricEventPublisherTests.swift
@@ -0,0 +1,54 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import PillarboxStreams
+
+final class AVPlayerItemMetricEventPublisherTests: TestCase {
+ func testPlayableItemAssetMetricEvent() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ _ = AVPlayer(playerItem: item)
+ expectOnlySimilarPublished(
+ values: [.anyAsset],
+ from: item.assetMetricEventPublisher()
+ )
+ }
+
+ func testFailingItemAssetMetricEvent() {
+ let item = AVPlayerItem(url: Stream.unavailable.url)
+ _ = AVPlayer(playerItem: item)
+ expectNothingPublished(from: item.assetMetricEventPublisher(), during: .milliseconds(500))
+ }
+
+ func testPlayableItemFailureMetricEvent() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ _ = AVPlayer(playerItem: item)
+ expectNothingPublished(from: item.failureMetricEventPublisher(), during: .milliseconds(500))
+ }
+
+ func testFailingItemFailureMetricEvent() {
+ let item = AVPlayerItem(url: Stream.unavailable.url)
+ _ = AVPlayer(playerItem: item)
+ expectOnlySimilarPublished(
+ values: [.anyFailure],
+ from: item.failureMetricEventPublisher()
+ )
+ }
+
+ func testPlayableItemWarningMetricEvent() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ _ = AVPlayer(playerItem: item)
+ expectNothingPublished(from: item.warningMetricEventPublisher(), during: .milliseconds(500))
+ }
+
+ func testPlayableItemStallMetricEvent() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ _ = AVPlayer(playerItem: item)
+ expectNothingPublished(from: item.stallEventPublisher(), during: .milliseconds(500))
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/AVPlayerPeriodicTimePublisherTests.swift b/Tests/PlayerTests/Publishers/AVPlayerPeriodicTimePublisherTests.swift
new file mode 100644
index 00000000..17f281de
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/AVPlayerPeriodicTimePublisherTests.swift
@@ -0,0 +1,60 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Combine
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class AVPlayerPeriodicTimePublisherTests: TestCase {
+ func testEmpty() {
+ let player = AVPlayer()
+ expectNothingPublished(
+ from: Publishers.PeriodicTimePublisher(
+ for: player,
+ interval: CMTimeMake(value: 1, timescale: 10)
+ ),
+ during: .milliseconds(500)
+ )
+ }
+
+ func testPlayback() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = AVPlayer(playerItem: item)
+ expectAtLeastPublished(
+ values: [
+ .zero,
+ CMTimeMake(value: 1, timescale: 10),
+ CMTimeMake(value: 2, timescale: 10),
+ CMTimeMake(value: 3, timescale: 10)
+ ],
+ from: Publishers.PeriodicTimePublisher(
+ for: player,
+ interval: CMTimeMake(value: 1, timescale: 10)
+ ),
+ to: beClose(within: 0.1)
+ ) {
+ player.play()
+ }
+ }
+
+ func testDeallocation() {
+ var player: AVPlayer? = AVPlayer()
+ _ = Publishers.PeriodicTimePublisher(
+ for: player!,
+ interval: CMTime(value: 1, timescale: 1)
+ )
+
+ weak var weakPlayer = player
+ autoreleasepool {
+ player = nil
+ }
+ expect(weakPlayer).to(beNil())
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/MetadataPublisherTests.swift b/Tests/PlayerTests/Publishers/MetadataPublisherTests.swift
new file mode 100644
index 00000000..ceee5204
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/MetadataPublisherTests.swift
@@ -0,0 +1,94 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class MetadataPublisherTests: TestCase {
+ private static func titlePublisherTest(for player: Player) -> AnyPublisher {
+ player.metadataPublisher.map(\.title).eraseToAnyPublisher()
+ }
+
+ func testEmpty() {
+ let player = Player()
+ expectEqualPublished(
+ values: [nil],
+ from: Self.titlePublisherTest(for: player),
+ during: .milliseconds(100)
+ )
+ }
+
+ func testImmediatelyAvailableWithoutMetadata() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expectEqualPublished(
+ values: [nil],
+ from: Self.titlePublisherTest(for: player),
+ during: .milliseconds(100)
+ )
+ }
+
+ func testAvailableAfterDelay() {
+ let player = Player(
+ item: .mock(url: Stream.onDemand.url, loadedAfter: 0.1, withMetadata: AssetMetadataMock(title: "title"))
+ )
+ expectEqualPublished(
+ values: [nil, "title"],
+ from: Self.titlePublisherTest(for: player),
+ during: .milliseconds(200)
+ )
+ }
+
+ func testImmediatelyAvailableWithMetadata() {
+ let player = Player(item: .mock(
+ url: Stream.onDemand.url,
+ loadedAfter: 0,
+ withMetadata: AssetMetadataMock(title: "title")
+ ))
+ expectEqualPublished(
+ values: [nil, "title"],
+ from: Self.titlePublisherTest(for: player),
+ during: .milliseconds(200)
+ )
+ }
+
+ func testUpdate() {
+ let player = Player(item: .mock(url: Stream.onDemand.url, withMetadataUpdateAfter: 0.1))
+ expectEqualPublished(
+ values: [nil, "title0", "title1"],
+ from: Self.titlePublisherTest(for: player),
+ during: .milliseconds(500)
+ )
+ }
+
+ func testNetworkItemReloading() {
+ let player = Player(item: .webServiceMock(media: .media1))
+ expectAtLeastEqualPublished(
+ values: [nil, "Title 1"],
+ from: Self.titlePublisherTest(for: player)
+ )
+ expectEqualPublishedNext(
+ values: [nil, "Title 2"],
+ from: Self.titlePublisherTest(for: player),
+ during: .milliseconds(500)
+ ) {
+ player.items = [.webServiceMock(media: .media2)]
+ }
+ }
+
+ func testEntirePlayback() {
+ let player = Player(item: .mock(url: Stream.shortOnDemand.url, loadedAfter: 0, withMetadata: AssetMetadataMock(title: "title")))
+ expectEqualPublished(
+ values: [nil, "title", nil],
+ from: Self.titlePublisherTest(for: player),
+ during: .seconds(2)
+ ) {
+ player.play()
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/NowPlayingInfoPublisherTests.swift b/Tests/PlayerTests/Publishers/NowPlayingInfoPublisherTests.swift
new file mode 100644
index 00000000..0b179ae5
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/NowPlayingInfoPublisherTests.swift
@@ -0,0 +1,47 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import MediaPlayer
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class NowPlayingInfoPublisherTests: TestCase {
+ private static func nowPlayingInfoPublisher(for player: Player) -> AnyPublisher {
+ player.nowPlayingPublisher()
+ .map(\.info)
+ .eraseToAnyPublisher()
+ }
+
+ func testInactive() {
+ let player = Player(item: .mock(url: Stream.onDemand.url, loadedAfter: 0, withMetadata: AssetMetadataMock(title: "title")))
+ expectSimilarPublished(
+ values: [[:]],
+ from: Self.nowPlayingInfoPublisher(for: player),
+ during: .milliseconds(100)
+ )
+ }
+
+ func testToggleActive() {
+ let player = Player(item: .mock(url: Stream.onDemand.url, loadedAfter: 0, withMetadata: AssetMetadataMock(title: "title")))
+ expectAtLeastSimilarPublished(
+ values: [[:], [MPNowPlayingInfoPropertyIsLiveStream: false]],
+ from: Self.nowPlayingInfoPublisher(for: player)
+ ) {
+ player.isActive = true
+ }
+
+ expectSimilarPublishedNext(
+ values: [[:]],
+ from: Self.nowPlayingInfoPublisher(for: player),
+ during: .milliseconds(100)
+ ) {
+ player.isActive = false
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/PeriodicMetricsPublisherTests.swift b/Tests/PlayerTests/Publishers/PeriodicMetricsPublisherTests.swift
new file mode 100644
index 00000000..9fe33272
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/PeriodicMetricsPublisherTests.swift
@@ -0,0 +1,72 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class PeriodicMetricsPublisherTests: TestCase {
+ func testEmpty() {
+ let player = Player()
+ expectEqualPublished(
+ values: [0],
+ from: player.periodicMetricsPublisher(forInterval: .init(value: 1, timescale: 4)).map(\.count),
+ during: .seconds(1)
+ )
+ }
+
+ func testPlayback() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [0, 1, 2],
+ from: player.periodicMetricsPublisher(forInterval: .init(value: 1, timescale: 4)).map(\.count)
+ ) {
+ player.play()
+ }
+ }
+
+ func testPlaylist() {
+ let item1 = PlayerItem.simple(url: Stream.shortOnDemand.url)
+ let item2 = PlayerItem.simple(url: Stream.mediumOnDemand.url)
+ let player = Player(items: [item1, item2])
+ let publisher = player.periodicMetricsPublisher(forInterval: .init(value: 1, timescale: 1)).map(\.count)
+ expectAtLeastEqualPublished(values: [0, 1, 0, 1], from: publisher) {
+ player.play()
+ }
+ }
+
+ func testNoMetricsForLiveMp3() {
+ let player = Player(item: .simple(url: Stream.liveMp3.url))
+ let publisher = player.periodicMetricsPublisher(forInterval: .init(value: 1, timescale: 4)).map(\.count)
+ expectEqualPublished(values: [0], from: publisher, during: .milliseconds(500)) {
+ player.play()
+ }
+ }
+
+ func testLimit() {
+ let item = PlayerItem.simple(url: Stream.onDemand.url)
+ let player = Player(item: item)
+ expectAtLeastEqualPublished(
+ values: [0, 1, 2, 2, 2, 2],
+ from: player.periodicMetricsPublisher(forInterval: .init(value: 1, timescale: 4), limit: 2).map(\.count)
+ ) {
+ player.play()
+ }
+ }
+
+ func testFailure() {
+ let item = PlayerItem.failing(loadedAfter: 0.1)
+ let player = Player(item: item)
+ expectEqualPublished(
+ values: [0],
+ from: player.periodicMetricsPublisher(forInterval: .init(value: 1, timescale: 4)).map(\.count),
+ during: .seconds(1)
+ )
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/PlayerItemMetricEventPublisherTests.swift b/Tests/PlayerTests/Publishers/PlayerItemMetricEventPublisherTests.swift
new file mode 100644
index 00000000..108c5624
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/PlayerItemMetricEventPublisherTests.swift
@@ -0,0 +1,28 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import PillarboxStreams
+
+final class PlayerItemMetricEventPublisherTests: TestCase {
+ func testPlayableItemMetricEvent() {
+ let item = PlayerItem.mock(url: Stream.onDemand.url, loadedAfter: 0.1)
+ expectAtLeastSimilarPublished(
+ values: [.anyMetadata],
+ from: item.metricEventPublisher()
+ ) {
+ PlayerItem.load(for: item.id)
+ }
+ }
+
+ func testFailingItemMetricEvent() {
+ let item = PlayerItem.failing(loadedAfter: 0.1)
+ expectNothingPublished(from: item.metricEventPublisher(), during: .milliseconds(500)) {
+ PlayerItem.load(for: item.id)
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Publishers/PlayerPublisherTests.swift b/Tests/PlayerTests/Publishers/PlayerPublisherTests.swift
new file mode 100644
index 00000000..814e75c3
--- /dev/null
+++ b/Tests/PlayerTests/Publishers/PlayerPublisherTests.swift
@@ -0,0 +1,159 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Combine
+import PillarboxCircumspect
+import PillarboxCore
+import PillarboxStreams
+
+final class PlayerPublisherTests: TestCase {
+ private static func bufferingPublisher(for player: Player) -> AnyPublisher {
+ player.propertiesPublisher
+ .slice(at: \.isBuffering)
+ .eraseToAnyPublisher()
+ }
+
+ private static func presentationSizePublisher(for player: Player) -> AnyPublisher {
+ player.propertiesPublisher
+ .slice(at: \.presentationSize)
+ .eraseToAnyPublisher()
+ }
+
+ private static func itemStatusPublisher(for player: Player) -> AnyPublisher {
+ player.propertiesPublisher
+ .slice(at: \.itemStatus)
+ .eraseToAnyPublisher()
+ }
+
+ private static func durationPublisher(for player: Player) -> AnyPublisher {
+ player.propertiesPublisher
+ .slice(at: \.duration)
+ .eraseToAnyPublisher()
+ }
+
+ private static func seekableTimeRangePublisher(for player: Player) -> AnyPublisher {
+ player.propertiesPublisher
+ .slice(at: \.seekableTimeRange)
+ .eraseToAnyPublisher()
+ }
+
+ func testBufferingEmpty() {
+ let player = Player()
+ expectEqualPublished(
+ values: [false],
+ from: Self.bufferingPublisher(for: player),
+ during: .milliseconds(500)
+ )
+ }
+
+ func testBuffering() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expectEqualPublished(
+ values: [true, false],
+ from: Self.bufferingPublisher(for: player),
+ during: .seconds(1)
+ )
+ }
+
+ func testPresentationSizeEmpty() {
+ let player = Player()
+ expectAtLeastEqualPublished(
+ values: [nil],
+ from: Self.presentationSizePublisher(for: player)
+ )
+ }
+
+ func testPresentationSize() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expectAtLeastEqualPublished(
+ values: [nil, CGSize(width: 640, height: 360), nil],
+ from: Self.presentationSizePublisher(for: player)
+ ) {
+ player.play()
+ }
+ }
+
+ func testItemStatusEmpty() {
+ let player = Player()
+ expectAtLeastEqualPublished(
+ values: [.unknown],
+ from: Self.itemStatusPublisher(for: player)
+ )
+ }
+
+ func testConsumedItemStatusLifeCycle() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expectAtLeastEqualPublished(
+ values: [.unknown, .readyToPlay, .ended, .unknown],
+ from: Self.itemStatusPublisher(for: player)
+ ) {
+ player.play()
+ }
+ }
+
+ func testPausedItemStatusLifeCycle() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expectAtLeastEqualPublished(
+ values: [.unknown, .readyToPlay, .ended],
+ from: Self.itemStatusPublisher(for: player)
+ ) {
+ player.actionAtItemEnd = .pause
+ player.play()
+ }
+ expectAtLeastEqualPublishedNext(
+ values: [.readyToPlay],
+ from: Self.itemStatusPublisher(for: player)
+ ) {
+ player.seek(to: .zero)
+ }
+ }
+
+ func testDurationEmpty() {
+ let player = Player()
+ expectAtLeastPublished(
+ values: [.invalid],
+ from: Self.durationPublisher(for: player),
+ to: beClose(within: 1)
+ )
+ }
+
+ func testDuration() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expectAtLeastPublished(
+ values: [.invalid, Stream.shortOnDemand.duration, .invalid],
+ from: Self.durationPublisher(for: player),
+ to: beClose(within: 1)
+ ) {
+ player.play()
+ }
+ }
+
+ func testSeekableTimeRangeEmpty() {
+ let player = Player()
+ expectAtLeastEqualPublished(
+ values: [.invalid],
+ from: Self.seekableTimeRangePublisher(for: player)
+ )
+ }
+
+ func testSeekableTimeRangeLifeCycle() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expectAtLeastPublished(
+ values: [
+ .invalid,
+ CMTimeRange(start: .zero, duration: Stream.shortOnDemand.duration),
+ .invalid
+ ],
+ from: Self.seekableTimeRangePublisher(for: player),
+ to: beClose(within: 1)
+ ) {
+ player.play()
+ }
+ }
+}
diff --git a/Tests/PlayerTests/QueuePlayer/QueuePlayerItemsTests.swift b/Tests/PlayerTests/QueuePlayer/QueuePlayerItemsTests.swift
new file mode 100644
index 00000000..cc078414
--- /dev/null
+++ b/Tests/PlayerTests/QueuePlayer/QueuePlayerItemsTests.swift
@@ -0,0 +1,88 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class QueuePlayerItemsTests: TestCase {
+ func testReplaceItemsWithEmptyList() {
+ let item1 = AVPlayerItem(url: Stream.onDemand.url)
+ let item2 = AVPlayerItem(url: Stream.onDemand.url)
+ let item3 = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(items: [item1, item2, item3])
+ player.replaceItems(with: [])
+ expect(player.items()).to(beEmpty())
+ }
+
+ func testReplaceItemsWhenEmpty() {
+ let player = QueuePlayer()
+ let item1 = AVPlayerItem(url: Stream.onDemand.url)
+ let item2 = AVPlayerItem(url: Stream.onDemand.url)
+ let item3 = AVPlayerItem(url: Stream.onDemand.url)
+ player.replaceItems(with: [item1, item2, item3])
+ expect(player.items()).to(equalDiff([item1, item2, item3]))
+ }
+
+ func testReplaceItemsWithOtherItems() {
+ let item1 = AVPlayerItem(url: Stream.onDemand.url)
+ let item2 = AVPlayerItem(url: Stream.onDemand.url)
+ let item3 = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(items: [item1, item2, item3])
+ let item4 = AVPlayerItem(url: Stream.onDemand.url)
+ let item5 = AVPlayerItem(url: Stream.onDemand.url)
+ player.replaceItems(with: [item4, item5])
+ expect(player.items()).to(equalDiff([item4, item5]))
+ }
+
+ func testReplaceItemsWithPreservedCurrentItem() {
+ let item1 = AVPlayerItem(url: Stream.onDemand.url)
+ let item2 = AVPlayerItem(url: Stream.onDemand.url)
+ let item3 = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(items: [item1, item2, item3])
+ let item4 = AVPlayerItem(url: Stream.onDemand.url)
+ player.replaceItems(with: [item1, item4])
+ expect(player.items()).to(equalDiff([item1, item4]))
+ }
+
+ func testReplaceItemsWithIdenticalItems() {
+ let item1 = AVPlayerItem(url: Stream.onDemand.url)
+ let item2 = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(items: [item1, item2])
+ player.replaceItems(with: [item1, item2])
+ expect(player.items()).to(equalDiff([item1, item2]))
+ }
+
+ func testReplaceItemsWithNextItems() {
+ let item1 = AVPlayerItem(url: Stream.onDemand.url)
+ let item2 = AVPlayerItem(url: Stream.onDemand.url)
+ let item3 = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(items: [item1, item2, item3])
+ player.replaceItems(with: [item2, item3])
+ expect(player.items()).to(equalDiff([item2, item3]))
+ }
+
+ func testReplaceItemsWithPreviousItems() {
+ let item2 = AVPlayerItem(url: Stream.onDemand.url)
+ let item3 = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(items: [item2, item3])
+ let item1 = AVPlayerItem(url: Stream.onDemand.url)
+ player.replaceItems(with: [item1, item2, item3])
+ expect(player.items()).to(equalDiff([item1, item2, item3]))
+ }
+
+ func testReplaceItemsLastReplacementWins() {
+ let player = QueuePlayer()
+ let item1 = AVPlayerItem(url: Stream.onDemand.url)
+ let item2 = AVPlayerItem(url: Stream.onDemand.url)
+ player.replaceItems(with: [item1, item2])
+ player.replaceItems(with: [item1])
+ expect(player.items()).to(equalDiff([item1]))
+ }
+}
diff --git a/Tests/PlayerTests/QueuePlayer/QueuePlayerSeekTests.swift b/Tests/PlayerTests/QueuePlayer/QueuePlayerSeekTests.swift
new file mode 100644
index 00000000..6e799634
--- /dev/null
+++ b/Tests/PlayerTests/QueuePlayer/QueuePlayerSeekTests.swift
@@ -0,0 +1,286 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import OrderedCollections
+import PillarboxCircumspect
+import PillarboxStreams
+
+private class QueuePlayerMock: QueuePlayer {
+ var seeks: Int = 0
+
+ override func enqueue(seek: Seek, completion: @escaping () -> Void) {
+ self.seeks += 1
+ super.enqueue(seek: seek, completion: completion)
+ }
+}
+
+final class QueuePlayerSeekTests: TestCase {
+ func testNotificationsForSeekWithInvalidTime() {
+ guard nimbleThrowAssertionsAvailable() else { return }
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect { player.seek(to: .invalid) }.to(throwAssertion())
+ }
+
+ func testNotificationsForSeekWithEmptyPlayer() {
+ let player = QueuePlayer()
+ expect {
+ player.seek(to: CMTime(value: 1, timescale: 1)) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.to(postNotifications(equalDiff([]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testNotificationsForSeek() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ let time = CMTime(value: 1, timescale: 1)
+ expect {
+ player.seek(to: time) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.to(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time]),
+ Notification(name: .didSeek, object: player)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testNotificationsForMultipleSeeks() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ expect {
+ player.seek(to: time1) { finished in
+ expect(finished).to(beTrue())
+ }
+ player.seek(to: time2) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.to(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time1]),
+ Notification(name: .didSeek, object: player),
+
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time2]),
+ Notification(name: .didSeek, object: player)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testNotificationsForMultipleSeeksWithinTimeRange() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ expect {
+ player.seek(to: time1) { finished in
+ expect(finished).to(beFalse())
+ }
+ player.seek(to: time2) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.toEventually(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time1]),
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time2]),
+ Notification(name: .didSeek, object: player)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testNotificationsForSeekAfterSmoothSeekWithinTimeRange() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ expect {
+ player.seek(to: time1, smooth: true) { finished in
+ expect(finished).to(beFalse())
+ }
+ player.seek(to: time2) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.toEventually(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time1]),
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time2]),
+ Notification(name: .didSeek, object: player)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testCompletionsForMultipleSeeks() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ let time3 = CMTime(value: 3, timescale: 1)
+
+ var results = OrderedDictionary()
+
+ func completion(index: Int) -> ((Bool) -> Void) {
+ { finished in
+ expect(results[index]).to(beNil())
+ results[index] = finished
+ }
+ }
+
+ player.seek(to: time1, completionHandler: completion(index: 1))
+ player.seek(to: time2, completionHandler: completion(index: 2))
+ player.seek(to: time3, completionHandler: completion(index: 3))
+
+ expect(results).toEventually(equalDiff([
+ 1: false,
+ 2: false,
+ 3: true
+ ]))
+ }
+
+ func testCompletionsForMultipleSmoothSeeksEndingWithSeek() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ let time3 = CMTime(value: 3, timescale: 1)
+
+ var results = OrderedDictionary()
+
+ func completion(index: Int) -> ((Bool) -> Void) {
+ { finished in
+ expect(results[index]).to(beNil())
+ results[index] = finished
+ }
+ }
+
+ player.seek(to: time1, smooth: true, completionHandler: completion(index: 1))
+ player.seek(to: time2, smooth: true, completionHandler: completion(index: 2))
+ player.seek(to: time3, smooth: false, completionHandler: completion(index: 3))
+
+ expect(results).toEventually(equalDiff([
+ 1: false,
+ 2: false,
+ 3: true
+ ]))
+ }
+
+ // Checks that time is not jumping back when seeking forward several times in a row (no tolerance before is allowed
+ // in this test as otherwise the player is allowed to pick a position before the desired position),
+ func testMultipleSeekMonotonicity() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ player.play()
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let values = collectOutput(from: player.smoothCurrentTimePublisher(interval: CMTime(value: 1, timescale: 10), queue: .main), during: .seconds(3)) {
+ player.seek(to: CMTime(value: 8, timescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) { _ in
+ player.seek(to: CMTime(value: 10, timescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) { _ in
+ player.seek(to: CMTime(value: 12, timescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) { _ in
+ player.seek(to: CMTime(value: 100, timescale: 1), toleranceBefore: .zero, toleranceAfter: .zero) { _ in
+ player.seek(to: CMTime(value: 100, timescale: 1), toleranceBefore: .zero, toleranceAfter: .zero)
+ }
+ }
+ }
+ }
+ }
+ expect(values.sorted()).to(equal(values))
+ }
+
+ func testNotificationCompletionOrder() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time = CMTime(value: 1, timescale: 1)
+ let notificationName = Notification.Name("SeekCompleted")
+ expect {
+ player.seek(to: time) { _ in
+ QueuePlayer.notificationCenter.post(name: notificationName, object: self)
+ }
+ }.toEventually(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time]),
+ Notification(name: .didSeek, object: player),
+ Notification(name: notificationName, object: self)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testNotificationCompletionOrderWithMultipleSeeks() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ let notificationName1 = Notification.Name("SeekCompleted1")
+ let notificationName2 = Notification.Name("SeekCompleted2")
+ expect {
+ player.seek(to: time1) { _ in
+ QueuePlayer.notificationCenter.post(name: notificationName1, object: self)
+ }
+ player.seek(to: time2) { _ in
+ QueuePlayer.notificationCenter.post(name: notificationName2, object: self)
+ }
+ }.toEventually(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time1]),
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time2]),
+ Notification(name: notificationName1, object: self),
+ Notification(name: .didSeek, object: player),
+ Notification(name: notificationName2, object: self)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testEnqueue() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayerMock(playerItem: item)
+ expect(player.timeRange).toEventuallyNot(equal(.invalid))
+ waitUntil { done in
+ player.seek(to: CMTime(value: 1, timescale: 1))
+ player.seek(to: CMTime(value: 2, timescale: 1))
+ player.seek(to: CMTime(value: 3, timescale: 1))
+ player.seek(to: CMTime(value: 4, timescale: 1))
+ player.seek(to: CMTime(value: 5, timescale: 1)) { _ in
+ done()
+ }
+ }
+ expect(player.seeks).to(equal(5))
+ }
+
+ func testEnqueueSmooth() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayerMock(playerItem: item)
+ expect(player.timeRange).toEventuallyNot(equal(.invalid))
+ waitUntil { done in
+ player.seek(to: CMTime(value: 1, timescale: 1), smooth: true) { _ in }
+ player.seek(to: CMTime(value: 2, timescale: 1), smooth: true) { _ in }
+ player.seek(to: CMTime(value: 3, timescale: 1), smooth: true) { _ in }
+ player.seek(to: CMTime(value: 4, timescale: 1), smooth: true) { _ in }
+ player.seek(to: CMTime(value: 5, timescale: 1), smooth: true) { _ in
+ done()
+ }
+ }
+ expect(player.seeks).to(equal(2))
+ }
+
+ func testTargetSeekTimeWithMultipleSeeks() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(player.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ player.seek(to: time1)
+ expect(player.targetSeekTime).to(equal(time1))
+
+ let time2 = CMTime(value: 2, timescale: 1)
+ player.seek(to: time2)
+ expect(player.targetSeekTime).to(equal(time2))
+ }
+}
diff --git a/Tests/PlayerTests/QueuePlayer/QueuePlayerSeekTimePublisherTests.swift b/Tests/PlayerTests/QueuePlayer/QueuePlayerSeekTimePublisherTests.swift
new file mode 100644
index 00000000..427cb1c5
--- /dev/null
+++ b/Tests/PlayerTests/QueuePlayer/QueuePlayerSeekTimePublisherTests.swift
@@ -0,0 +1,94 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class QueuePlayerSeekTimePublisherTests: TestCase {
+ func testEmpty() {
+ let player = QueuePlayer()
+ expectAtLeastEqualPublished(
+ values: [nil],
+ from: player.seekTimePublisher()
+ )
+ }
+
+ func testSeek() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ let time = CMTime(value: 1, timescale: 1)
+ expectAtLeastEqualPublished(
+ values: [nil, time, nil],
+ from: player.seekTimePublisher()
+ ) {
+ player.seek(to: time)
+ }
+ }
+
+ func testMultipleSeek() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ expectAtLeastEqualPublished(
+ values: [nil, time1, nil, time2, nil],
+ from: player.seekTimePublisher()
+ ) {
+ player.seek(to: time1)
+ player.seek(to: time2)
+ }
+ }
+
+ func testMultipleSeeksAtTheSameLocation() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ let time = CMTime(value: 1, timescale: 1)
+ expectAtLeastEqualPublished(
+ values: [nil, time, nil, time, nil],
+ from: player.seekTimePublisher()
+ ) {
+ player.seek(to: time)
+ player.seek(to: time)
+ }
+ }
+
+ func testMultipleSeeksWithinTimeRange() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ player.play()
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ expectAtLeastEqualPublished(
+ values: [nil, time1, time2, nil],
+ from: player.seekTimePublisher()
+ ) {
+ player.seek(to: time1)
+ player.seek(to: time2)
+ }
+ }
+
+ func testMultipleSeeksAtTheSameLocationWithinTimeRange() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ player.play()
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time = CMTime(value: 1, timescale: 1)
+ expectAtLeastEqualPublished(
+ values: [nil, time, nil],
+ from: player.seekTimePublisher()
+ ) {
+ player.seek(to: time)
+ player.seek(to: time)
+ }
+ }
+}
diff --git a/Tests/PlayerTests/QueuePlayer/QueuePlayerSmoothSeekTests.swift b/Tests/PlayerTests/QueuePlayer/QueuePlayerSmoothSeekTests.swift
new file mode 100644
index 00000000..c775b03e
--- /dev/null
+++ b/Tests/PlayerTests/QueuePlayer/QueuePlayerSmoothSeekTests.swift
@@ -0,0 +1,173 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Nimble
+import OrderedCollections
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class QueuePlayerSmoothSeekTests: TestCase {
+ func testNotificationsForSeekWithEmptyPlayer() {
+ let player = QueuePlayer()
+ expect {
+ player.seek(to: CMTime(value: 1, timescale: 1), smooth: true) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.to(postNotifications(equalDiff([]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testNotificationsForSeek() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ let time = CMTime(value: 1, timescale: 1)
+ expect {
+ player.seek(to: time, smooth: true) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.to(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time]),
+ Notification(name: .didSeek, object: player)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testNotificationsForMultipleSeeks() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ expect {
+ player.seek(to: time1, smooth: true) { finished in
+ expect(finished).to(beTrue())
+ }
+ player.seek(to: time2, smooth: true) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.to(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time1]),
+ Notification(name: .didSeek, object: player),
+
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time2]),
+ Notification(name: .didSeek, object: player)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testNotificationsForMultipleSeeksWithinTimeRange() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ expect {
+ player.seek(to: time1, smooth: true) { finished in
+ expect(finished).to(beTrue())
+ }
+ player.seek(to: time2, smooth: true) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.toEventually(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time1]),
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time2]),
+ Notification(name: .didSeek, object: player)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testNotificationsForSmoothSeekAfterSeekWithinTimeRange() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ expect {
+ player.seek(to: time1) { finished in
+ expect(finished).to(beTrue())
+ }
+ player.seek(to: time2, smooth: true) { finished in
+ expect(finished).to(beTrue())
+ }
+ }.toEventually(postNotifications(equalDiff([
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time1]),
+ Notification(name: .willSeek, object: player, userInfo: [SeekKey.time: time2]),
+ Notification(name: .didSeek, object: player)
+ ]), from: QueuePlayer.notificationCenter))
+ }
+
+ func testCompletionsForMultipleSeeks() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ let time3 = CMTime(value: 3, timescale: 1)
+
+ var results = OrderedDictionary()
+
+ func completion(index: Int) -> ((Bool) -> Void) {
+ { finished in
+ expect(results[index]).to(beNil())
+ results[index] = finished
+ }
+ }
+
+ player.seek(to: time1, smooth: true, completionHandler: completion(index: 1))
+ player.seek(to: time2, smooth: true, completionHandler: completion(index: 2))
+ player.seek(to: time3, smooth: true, completionHandler: completion(index: 3))
+
+ expect(results).toEventually(equalDiff([
+ 1: true,
+ 2: true,
+ 3: true
+ ]))
+ }
+
+ func testCompletionsForMultipleSeeksEndingWithSmoothSeek() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(item.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ let time2 = CMTime(value: 2, timescale: 1)
+ let time3 = CMTime(value: 3, timescale: 1)
+
+ var results = OrderedDictionary()
+
+ func completion(index: Int) -> ((Bool) -> Void) {
+ { finished in
+ expect(results[index]).to(beNil())
+ results[index] = finished
+ }
+ }
+
+ player.seek(to: time1, smooth: false, completionHandler: completion(index: 1))
+ player.seek(to: time2, smooth: false, completionHandler: completion(index: 2))
+ player.seek(to: time3, smooth: true, completionHandler: completion(index: 3))
+
+ expect(results).toEventually(equalDiff([
+ 1: false,
+ 2: true,
+ 3: true
+ ]))
+ }
+
+ func testTargetSeekTimeWithMultipleSeeks() {
+ let item = AVPlayerItem(url: Stream.onDemand.url)
+ let player = QueuePlayer(playerItem: item)
+ expect(player.timeRange).toEventuallyNot(equal(.invalid))
+
+ let time1 = CMTime(value: 1, timescale: 1)
+ player.seek(to: time1, smooth: true) { _ in }
+ expect(player.targetSeekTime).to(equal(time1))
+
+ let time2 = CMTime(value: 2, timescale: 1)
+ player.seek(to: time2, smooth: true) { _ in }
+ expect(player.targetSeekTime).to(equal(time2))
+ }
+}
diff --git a/Tests/PlayerTests/Resources/invalid.jpg b/Tests/PlayerTests/Resources/invalid.jpg
new file mode 100644
index 00000000..e69de29b
diff --git a/Tests/PlayerTests/Resources/pixel.jpg b/Tests/PlayerTests/Resources/pixel.jpg
new file mode 100644
index 00000000..d07bd67d
Binary files /dev/null and b/Tests/PlayerTests/Resources/pixel.jpg differ
diff --git a/Tests/PlayerTests/Skips/SkipBackwardChecksTests.swift b/Tests/PlayerTests/Skips/SkipBackwardChecksTests.swift
new file mode 100644
index 00000000..dede8231
--- /dev/null
+++ b/Tests/PlayerTests/Skips/SkipBackwardChecksTests.swift
@@ -0,0 +1,35 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class SkipBackwardChecksTests: TestCase {
+ func testCannotSkipWhenEmpty() {
+ let player = Player()
+ expect(player.canSkipBackward()).to(beFalse())
+ }
+
+ func testCanSkipForOnDemand() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canSkipBackward()).to(beTrue())
+ }
+
+ func testCannotSkipForLive() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canSkipBackward()).to(beFalse())
+ }
+
+ func testCanSkipForDvr() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canSkipBackward()).to(beTrue())
+ }
+}
diff --git a/Tests/PlayerTests/Skips/SkipBackwardTests.swift b/Tests/PlayerTests/Skips/SkipBackwardTests.swift
new file mode 100644
index 00000000..d46555db
--- /dev/null
+++ b/Tests/PlayerTests/Skips/SkipBackwardTests.swift
@@ -0,0 +1,79 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class SkipBackwardTests: TestCase {
+ func testSkipWhenEmpty() {
+ let player = Player()
+ waitUntil { done in
+ player.skipBackward { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSkipForOnDemand() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.time()).to(equal(.zero))
+
+ waitUntil { done in
+ player.skipBackward { _ in
+ expect(player.time()).to(equal(.zero))
+ done()
+ }
+ }
+ }
+
+ func testMultipleSkipsForOnDemand() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.time()).to(equal(.zero))
+
+ waitUntil { done in
+ player.skipBackward { finished in
+ expect(finished).to(beFalse())
+ }
+
+ player.skipBackward { finished in
+ expect(player.time()).to(equal(.zero))
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSkipForLive() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ expect(player.streamType).toEventually(equal(.live))
+ waitUntil { done in
+ player.skipBackward { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSkipForDvr() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ expect(player.streamType).toEventually(equal(.dvr))
+ let headTime = player.time()
+ waitUntil { done in
+ player.skipBackward { finished in
+ expect(player.time()).to(equal(headTime + player.backwardSkipTime, by: beClose(within: player.chunkDuration.seconds)))
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Skips/SkipForwardChecksTests.swift b/Tests/PlayerTests/Skips/SkipForwardChecksTests.swift
new file mode 100644
index 00000000..45a8bc82
--- /dev/null
+++ b/Tests/PlayerTests/Skips/SkipForwardChecksTests.swift
@@ -0,0 +1,35 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class SkipForwardChecksTests: TestCase {
+ func testCannotSkipWhenEmpty() {
+ let player = Player()
+ expect(player.canSkipForward()).to(beFalse())
+ }
+
+ func testCanSkipForOnDemand() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canSkipForward()).to(beTrue())
+ }
+
+ func testCannotSkipForLive() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canSkipForward()).to(beFalse())
+ }
+
+ func testCannotSkipForDvr() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canSkipForward()).to(beFalse())
+ }
+}
diff --git a/Tests/PlayerTests/Skips/SkipForwardTests.swift b/Tests/PlayerTests/Skips/SkipForwardTests.swift
new file mode 100644
index 00000000..94b48028
--- /dev/null
+++ b/Tests/PlayerTests/Skips/SkipForwardTests.swift
@@ -0,0 +1,126 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import CoreMedia
+import Nimble
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class SkipForwardTests: TestCase {
+ private func isSeekingPublisher(for player: Player) -> AnyPublisher {
+ player.propertiesPublisher
+ .slice(at: \.isSeeking)
+ .eraseToAnyPublisher()
+ }
+
+ func testSkipWhenEmpty() {
+ let player = Player()
+ waitUntil { done in
+ player.skipForward { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSkipForOnDemand() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.time()).to(equal(.zero))
+
+ waitUntil { done in
+ player.skipForward { _ in
+ expect(player.time()).to(equal(player.forwardSkipTime, by: beClose(within: player.chunkDuration.seconds)))
+ done()
+ }
+ }
+ }
+
+ func testMultipleSkipsForOnDemand() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.time()).to(equal(.zero))
+
+ waitUntil { done in
+ player.skipForward { finished in
+ expect(finished).to(beFalse())
+ }
+
+ player.skipForward { finished in
+ expect(player.time()).to(equal(CMTimeMultiply(player.forwardSkipTime, multiplier: 2), by: beClose(within: player.chunkDuration.seconds)))
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSkipForLive() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ expect(player.streamType).toEventually(equal(.live))
+ waitUntil { done in
+ player.skipForward { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSkipForDvr() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ expect(player.streamType).toEventually(equal(.dvr))
+ let headTime = player.time()
+ waitUntil { done in
+ player.skipForward { finished in
+ expect(player.time()).to(equal(headTime, by: beClose(within: player.chunkDuration.seconds)))
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSkipNearEndDoesNotSeekAnymore() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.time()).to(equal(.zero))
+ let seekTo = Stream.onDemand.duration - CMTime(value: 1, timescale: 1)
+
+ waitUntil { done in
+ player.seek(at(seekTo)) { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+
+ expectNothingPublishedNext(from: isSeekingPublisher(for: player), during: .seconds(2)) {
+ player.skipForward()
+ }
+ }
+
+ func testSkipNearEndCompletion() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.time()).to(equal(.zero))
+ let seekTo = Stream.onDemand.duration - CMTime(value: 1, timescale: 1)
+
+ waitUntil { done in
+ player.seek(at(seekTo)) { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+
+ waitUntil { done in
+ player.skipForward { finished in
+ expect(finished).to(beTrue())
+ expect(player.time()).to(equal(seekTo, by: beClose(within: 0.5)))
+ done()
+ }
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Skips/SkipToDefaultChecksTests.swift b/Tests/PlayerTests/Skips/SkipToDefaultChecksTests.swift
new file mode 100644
index 00000000..f2314685
--- /dev/null
+++ b/Tests/PlayerTests/Skips/SkipToDefaultChecksTests.swift
@@ -0,0 +1,55 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class SkipToDefaultChecksTests: TestCase {
+ func testCannotSkipWhenEmpty() {
+ let player = Player()
+ expect(player.canSkipToDefault()).to(beFalse())
+ }
+
+ func testCannotSkipForUnknown() {
+ let player = Player(item: .simple(url: Stream.unavailable.url))
+ expect(player.streamType).toEventually(equal(.unknown))
+ expect(player.canSkipToDefault()).to(beFalse())
+ }
+
+ func testCanSkipForOnDemand() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ expect(player.canSkipToDefault()).to(beTrue())
+ }
+
+ func testCannotSkipForDvrInLiveConditions() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ expect(player.streamType).toEventually(equal(.dvr))
+ expect(player.canSkipToDefault()).to(beFalse())
+ }
+
+ func testCanSkipForDvrInPastConditions() {
+ let player = Player(item: .simple(url: Stream.dvr.url))
+ expect(player.streamType).toEventually(equal(.dvr))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 1, timescale: 1))) { _ in
+ done()
+ }
+ }
+
+ expect(player.canSkipToDefault()).to(beTrue())
+ }
+
+ func testCanSkipForLive() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ expect(player.streamType).toEventually(equal(.live))
+ expect(player.canSkipToDefault()).to(beTrue())
+ }
+}
diff --git a/Tests/PlayerTests/Skips/SkipToDefaultTests.swift b/Tests/PlayerTests/Skips/SkipToDefaultTests.swift
new file mode 100644
index 00000000..225a9c2a
--- /dev/null
+++ b/Tests/PlayerTests/Skips/SkipToDefaultTests.swift
@@ -0,0 +1,91 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+import PillarboxStreams
+
+final class SkipToDefaultTests: TestCase {
+ func testSkipWhenEmpty() {
+ let player = Player()
+ waitUntil { done in
+ player.skipToDefault { finished in
+ expect(finished).to(beTrue())
+ expect(player.time()).to(equal(.invalid))
+ done()
+ }
+ }
+ }
+
+ func testSkipForUnknown() {
+ let player = Player(item: .simple(url: Stream.unavailable.url))
+ expect(player.streamType).toEventually(equal(.unknown))
+ waitUntil { done in
+ player.skipToDefault { finished in
+ expect(finished).to(beTrue())
+ expect(player.time()).to(equal(.zero))
+ done()
+ }
+ }
+ }
+
+ func testSkipForOnDemand() {
+ let player = Player(item: .simple(url: Stream.onDemand.url))
+ expect(player.streamType).toEventually(equal(.onDemand))
+ waitUntil { done in
+ player.skipToDefault { finished in
+ expect(finished).to(beTrue())
+ expect(player.time()).to(equal(.zero))
+ done()
+ }
+ }
+ }
+
+ func testSkipForLive() {
+ let player = Player(item: .simple(url: Stream.live.url))
+ expect(player.streamType).toEventually(equal(.live))
+ waitUntil { done in
+ player.skipToDefault { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSkipForDvrInLiveConditions() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.dvr))
+ waitUntil { done in
+ player.skipToDefault { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+
+ func testSkipForDvrInPastConditions() {
+ let item = PlayerItem.simple(url: Stream.dvr.url)
+ let player = Player(item: item)
+ expect(player.streamType).toEventually(equal(.dvr))
+
+ waitUntil { done in
+ player.seek(at(CMTime(value: 1, timescale: 1))) { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+
+ waitUntil { done in
+ player.skipToDefault { finished in
+ expect(finished).to(beTrue())
+ done()
+ }
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Tools/AVMediaSelectionOptionMock.swift b/Tests/PlayerTests/Tools/AVMediaSelectionOptionMock.swift
new file mode 100644
index 00000000..9fd618cb
--- /dev/null
+++ b/Tests/PlayerTests/Tools/AVMediaSelectionOptionMock.swift
@@ -0,0 +1,32 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import AVFoundation
+
+class AVMediaSelectionOptionMock: AVMediaSelectionOption {
+ override var displayName: String {
+ _displayName
+ }
+
+ override var locale: Locale? {
+ _locale
+ }
+
+ private let _displayName: String
+ private let _locale: Locale
+ private let _characteristics: [AVMediaCharacteristic]
+
+ init(displayName: String, languageCode: String = "", characteristics: [AVMediaCharacteristic] = []) {
+ _displayName = displayName
+ _locale = Locale(identifier: languageCode)
+ _characteristics = characteristics
+ super.init()
+ }
+
+ override func hasMediaCharacteristic(_ mediaCharacteristic: AVMediaCharacteristic) -> Bool {
+ _characteristics.contains(mediaCharacteristic)
+ }
+}
diff --git a/Tests/PlayerTests/Tools/ContentKeySessionDelegateMock.swift b/Tests/PlayerTests/Tools/ContentKeySessionDelegateMock.swift
new file mode 100644
index 00000000..9609cc0f
--- /dev/null
+++ b/Tests/PlayerTests/Tools/ContentKeySessionDelegateMock.swift
@@ -0,0 +1,11 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import AVFoundation
+
+final class ContentKeySessionDelegateMock: NSObject, AVContentKeySessionDelegate {
+ func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) {}
+}
diff --git a/Tests/PlayerTests/Tools/LanguageIdentifiable.swift b/Tests/PlayerTests/Tools/LanguageIdentifiable.swift
new file mode 100644
index 00000000..3e3210a6
--- /dev/null
+++ b/Tests/PlayerTests/Tools/LanguageIdentifiable.swift
@@ -0,0 +1,29 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import AVFoundation
+import PillarboxPlayer
+
+protocol LanguageIdentifiable {
+ var languageIdentifier: String? { get }
+}
+
+extension MediaSelectionOption: LanguageIdentifiable {
+ var languageIdentifier: String? {
+ switch self {
+ case let .on(option):
+ return option.languageIdentifier
+ default:
+ return nil
+ }
+ }
+}
+
+extension AVMediaSelectionOption: LanguageIdentifiable {
+ var languageIdentifier: String? {
+ locale?.identifier
+ }
+}
diff --git a/Tests/PlayerTests/Tools/Matchers.swift b/Tests/PlayerTests/Tools/Matchers.swift
new file mode 100644
index 00000000..af46caf0
--- /dev/null
+++ b/Tests/PlayerTests/Tools/Matchers.swift
@@ -0,0 +1,19 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import Nimble
+
+/// A Nimble matcher that checks language identifiers.
+func haveLanguageIdentifier(_ identifier: String) -> Matcher where T: LanguageIdentifiable {
+ let message = "have language identifier \(identifier)"
+ return .define { actualExpression in
+ let actualIdentifier = try actualExpression.evaluate()?.languageIdentifier
+ return MatcherResult(
+ bool: actualIdentifier == identifier,
+ message: .expectedCustomValueTo(message, actual: actualIdentifier ?? "nil")
+ )
+ }
+}
diff --git a/Tests/PlayerTests/Tools/MediaAccessibilityDisplayType.swift b/Tests/PlayerTests/Tools/MediaAccessibilityDisplayType.swift
new file mode 100644
index 00000000..e7ea68bf
--- /dev/null
+++ b/Tests/PlayerTests/Tools/MediaAccessibilityDisplayType.swift
@@ -0,0 +1,25 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import MediaAccessibility
+
+enum MediaAccessibilityDisplayType {
+ case automatic
+ case forcedOnly
+ case alwaysOn(languageCode: String)
+
+ func apply() {
+ switch self {
+ case .automatic:
+ MACaptionAppearanceSetDisplayType(.user, .automatic)
+ case .forcedOnly:
+ MACaptionAppearanceSetDisplayType(.user, .forcedOnly)
+ case let .alwaysOn(languageCode: languageCode):
+ MACaptionAppearanceSetDisplayType(.user, .alwaysOn)
+ MACaptionAppearanceAddSelectedLanguage(.user, languageCode as CFString)
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Tools/PlayerItem.swift b/Tests/PlayerTests/Tools/PlayerItem.swift
new file mode 100644
index 00000000..9d743b31
--- /dev/null
+++ b/Tests/PlayerTests/Tools/PlayerItem.swift
@@ -0,0 +1,79 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Combine
+import Foundation
+import PillarboxStreams
+
+enum MediaMock: String {
+ case media1
+ case media2
+}
+
+extension PlayerItem {
+ static func mock(
+ url: URL,
+ loadedAfter delay: TimeInterval,
+ trackerAdapters: [TrackerAdapter] = []
+ ) -> Self {
+ let publisher = Just(Asset.simple(url: url))
+ .delayIfNeeded(for: .seconds(delay), scheduler: DispatchQueue.main)
+ return .init(publisher: publisher, trackerAdapters: trackerAdapters)
+ }
+
+ static func mock(
+ url: URL,
+ loadedAfter delay: TimeInterval,
+ withMetadata: AssetMetadataMock,
+ trackerAdapters: [TrackerAdapter] = []
+ ) -> Self {
+ let publisher = Just(Asset.simple(url: url, metadata: withMetadata))
+ .delayIfNeeded(for: .seconds(delay), scheduler: DispatchQueue.main)
+ return .init(publisher: publisher, trackerAdapters: trackerAdapters)
+ }
+
+ static func mock(
+ url: URL,
+ withMetadataUpdateAfter delay: TimeInterval,
+ trackerAdapters: [TrackerAdapter] = []
+ ) -> Self {
+ let publisher = Just(Asset.simple(
+ url: url,
+ metadata: AssetMetadataMock(title: "title1", subtitle: "subtitle1")
+ ))
+ .delayIfNeeded(for: .seconds(delay), scheduler: DispatchQueue.main)
+ .prepend(Asset.simple(
+ url: url,
+ metadata: AssetMetadataMock(title: "title0", subtitle: "subtitle0")
+ ))
+ return .init(publisher: publisher, trackerAdapters: trackerAdapters)
+ }
+
+ static func webServiceMock(media: MediaMock, trackerAdapters: [TrackerAdapter] = []) -> Self {
+ let url = URL(string: "http://localhost:8123/json/\(media).json")!
+ return webServiceMock(url: url, trackerAdapters: trackerAdapters)
+ }
+
+ static func failing(
+ loadedAfter delay: TimeInterval,
+ trackerAdapters: [TrackerAdapter] = []
+ ) -> Self {
+ let url = URL(string: "http://localhost:8123/missing.json")!
+ return webServiceMock(url: url, trackerAdapters: trackerAdapters)
+ }
+
+ private static func webServiceMock(url: URL, trackerAdapters: [TrackerAdapter]) -> Self {
+ let publisher = URLSession(configuration: .default).dataTaskPublisher(for: url)
+ .map(\.data)
+ .decode(type: AssetMetadataMock.self, decoder: JSONDecoder())
+ .map { metadata in
+ Asset.simple(url: Stream.onDemand.url, metadata: metadata)
+ }
+ return .init(publisher: publisher, trackerAdapters: trackerAdapters)
+ }
+}
diff --git a/Tests/PlayerTests/Tools/PlayerItemTrackerMock.swift b/Tests/PlayerTests/Tools/PlayerItemTrackerMock.swift
new file mode 100644
index 00000000..ea5461ac
--- /dev/null
+++ b/Tests/PlayerTests/Tools/PlayerItemTrackerMock.swift
@@ -0,0 +1,69 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Combine
+
+final class PlayerItemTrackerMock: PlayerItemTracker {
+ typealias StatePublisher = PassthroughSubject
+
+ enum State: Equatable {
+ case initialized
+ case enabled
+ case metricEvents
+ case disabled
+ case deinitialized
+ }
+
+ struct Configuration {
+ let statePublisher: StatePublisher
+ let sessionIdentifier: String?
+
+ init(statePublisher: StatePublisher = .init(), sessionIdentifier: String? = nil) {
+ self.statePublisher = statePublisher
+ self.sessionIdentifier = sessionIdentifier
+ }
+ }
+
+ private let configuration: Configuration
+
+ var sessionIdentifier: String? {
+ configuration.sessionIdentifier
+ }
+
+ init(configuration: Configuration) {
+ self.configuration = configuration
+ configuration.statePublisher.send(.initialized)
+ }
+
+ func updateMetadata(to metadata: Void) {}
+
+ func updateProperties(to properties: PlayerProperties) {}
+
+ func updateMetricEvents(to events: [MetricEvent]) {
+ configuration.statePublisher.send(.metricEvents)
+ }
+
+ func enable(for player: AVPlayer) {
+ configuration.statePublisher.send(.enabled)
+ }
+
+ func disable(with properties: PlayerProperties) {
+ configuration.statePublisher.send(.disabled)
+ }
+
+ deinit {
+ configuration.statePublisher.send(.deinitialized)
+ }
+}
+
+extension PlayerItemTrackerMock {
+ static func adapter(statePublisher: StatePublisher, behavior: TrackingBehavior = .optional) -> TrackerAdapter {
+ adapter(configuration: Configuration(statePublisher: statePublisher), behavior: behavior)
+ }
+}
diff --git a/Tests/PlayerTests/Tools/ResourceLoaderDelegateMock.swift b/Tests/PlayerTests/Tools/ResourceLoaderDelegateMock.swift
new file mode 100644
index 00000000..6991f650
--- /dev/null
+++ b/Tests/PlayerTests/Tools/ResourceLoaderDelegateMock.swift
@@ -0,0 +1,24 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import AVFoundation
+
+final class ResourceLoaderDelegateMock: NSObject, AVAssetResourceLoaderDelegate {
+ func resourceLoader(
+ _ resourceLoader: AVAssetResourceLoader,
+ shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
+ ) -> Bool {
+ true
+ }
+
+ func resourceLoader(
+ _ resourceLoader: AVAssetResourceLoader,
+ shouldWaitForRenewalOfRequestedResource renewalRequest: AVAssetResourceRenewalRequest
+ ) -> Bool {
+ renewalRequest.finishLoading()
+ return true
+ }
+}
diff --git a/Tests/PlayerTests/Tools/Similarity.swift b/Tests/PlayerTests/Tools/Similarity.swift
new file mode 100644
index 00000000..8b96596e
--- /dev/null
+++ b/Tests/PlayerTests/Tools/Similarity.swift
@@ -0,0 +1,78 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import PillarboxCircumspect
+import UIKit
+
+extension ImageSource: Similar {
+ public static func ~~ (lhs: ImageSource, rhs: ImageSource) -> Bool {
+ switch (lhs.kind, rhs.kind) {
+ case (.none, .none):
+ return true
+ case let (
+ .url(
+ standardResolution: lhsStandardResolutionUrl,
+ lowResolution: lhsLowResolutionUrl
+ ),
+ .url(
+ standardResolution: rhsStandardResolutionUrl,
+ lowResolution: rhsLowResolutionUrl
+ )
+ ):
+ return lhsStandardResolutionUrl == rhsStandardResolutionUrl && lhsLowResolutionUrl == rhsLowResolutionUrl
+ case let (.image(lhsImage), .image(rhsImage)):
+ return lhsImage.pngData() == rhsImage.pngData()
+ default:
+ return false
+ }
+ }
+}
+
+extension Resource: Similar {
+ public static func ~~ (lhs: PillarboxPlayer.Resource, rhs: PillarboxPlayer.Resource) -> Bool {
+ switch (lhs, rhs) {
+ case let (.simple(url: lhsUrl), .simple(url: rhsUrl)),
+ let (.custom(url: lhsUrl, delegate: _), .custom(url: rhsUrl, delegate: _)),
+ let (.encrypted(url: lhsUrl, delegate: _), .encrypted(url: rhsUrl, delegate: _)):
+ return lhsUrl == rhsUrl
+ default:
+ return false
+ }
+ }
+}
+
+extension NowPlaying.Info: Similar {
+ public static func ~~ (lhs: Self, rhs: Self) -> Bool {
+ // swiftlint:disable:next legacy_objc_type
+ NSDictionary(dictionary: lhs).isEqual(to: rhs)
+ }
+}
+
+extension MetricEvent: Similar {
+ public static func ~~ (lhs: MetricEvent, rhs: MetricEvent) -> Bool {
+ switch (lhs.kind, rhs.kind) {
+ case (.metadata, .metadata), (.asset, .asset), (.failure, .failure), (.warning, .warning):
+ return true
+ default:
+ return false
+ }
+ }
+}
+
+func beClose(within tolerance: TimeInterval) -> ((CMTime, CMTime) -> Bool) {
+ CMTime.close(within: tolerance)
+}
+
+func beClose(within tolerance: TimeInterval) -> ((CMTime?, CMTime?) -> Bool) {
+ CMTime.close(within: tolerance)
+}
+
+func beClose(within tolerance: TimeInterval) -> ((CMTimeRange, CMTimeRange) -> Bool) {
+ CMTimeRange.close(within: tolerance)
+}
diff --git a/Tests/PlayerTests/Tools/TestCase.swift b/Tests/PlayerTests/Tools/TestCase.swift
new file mode 100644
index 00000000..91a0702f
--- /dev/null
+++ b/Tests/PlayerTests/Tools/TestCase.swift
@@ -0,0 +1,22 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import Nimble
+import XCTest
+
+/// A simple test suite with more tolerant Nimble settings. Beware that `toAlways` and `toNever` expectations appearing
+/// in tests will use the same value by default and should likely always provide an explicit `until` parameter.
+class TestCase: XCTestCase {
+ override class func setUp() {
+ PollingDefaults.timeout = .seconds(20)
+ PollingDefaults.pollInterval = .milliseconds(100)
+ }
+
+ override class func tearDown() {
+ PollingDefaults.timeout = .seconds(1)
+ PollingDefaults.pollInterval = .milliseconds(10)
+ }
+}
diff --git a/Tests/PlayerTests/Tools/Tools.swift b/Tests/PlayerTests/Tools/Tools.swift
new file mode 100644
index 00000000..338e6804
--- /dev/null
+++ b/Tests/PlayerTests/Tools/Tools.swift
@@ -0,0 +1,13 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+import Foundation
+
+struct StructError: LocalizedError {
+ var errorDescription: String? {
+ "Struct error description"
+ }
+}
diff --git a/Tests/PlayerTests/Tools/TrackerUpdateMock.swift b/Tests/PlayerTests/Tools/TrackerUpdateMock.swift
new file mode 100644
index 00000000..12ec1754
--- /dev/null
+++ b/Tests/PlayerTests/Tools/TrackerUpdateMock.swift
@@ -0,0 +1,55 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import Combine
+
+final class TrackerUpdateMock: PlayerItemTracker where Metadata: Equatable {
+ typealias StatePublisher = PassthroughSubject
+
+ enum State: Equatable {
+ case enabled
+ case disabled
+ case updatedMetadata(Metadata)
+ case updatedProperties
+ }
+
+ struct Configuration {
+ let statePublisher: StatePublisher
+ }
+
+ private let configuration: Configuration
+
+ init(configuration: Configuration) {
+ self.configuration = configuration
+ }
+
+ func enable(for player: AVPlayer) {
+ configuration.statePublisher.send(.enabled)
+ }
+
+ func updateMetadata(to metadata: Metadata) {
+ configuration.statePublisher.send(.updatedMetadata(metadata))
+ }
+
+ func updateProperties(to properties: PlayerProperties) {
+ configuration.statePublisher.send(.updatedProperties)
+ }
+
+ func updateMetricEvents(to events: [MetricEvent]) {}
+
+ func disable(with properties: PlayerProperties) {
+ configuration.statePublisher.send(.disabled)
+ }
+}
+
+extension TrackerUpdateMock {
+ static func adapter(statePublisher: StatePublisher, mapper: @escaping (M) -> Metadata) -> TrackerAdapter {
+ adapter(configuration: Configuration(statePublisher: statePublisher), mapper: mapper)
+ }
+}
diff --git a/Tests/PlayerTests/Tracking/PlayerItemTrackerLifeCycleTests.swift b/Tests/PlayerTests/Tracking/PlayerItemTrackerLifeCycleTests.swift
new file mode 100644
index 00000000..1a3939b0
--- /dev/null
+++ b/Tests/PlayerTests/Tracking/PlayerItemTrackerLifeCycleTests.swift
@@ -0,0 +1,98 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class PlayerItemTrackerLifeCycleTests: TestCase {
+ func testWithShortLivedPlayer() {
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+ expectAtLeastEqualPublished(values: [.initialized, .deinitialized], from: publisher) {
+ _ = PlayerItem.simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [PlayerItemTrackerMock.adapter(statePublisher: publisher)]
+ )
+ }
+ }
+
+ func testItemPlayback() {
+ let player = Player()
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+ expectEqualPublished(values: [.initialized, .enabled, .metricEvents, .metricEvents], from: publisher, during: .seconds(2)) {
+ player.append(.simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [PlayerItemTrackerMock.adapter(statePublisher: publisher)]
+ ))
+ player.play()
+ }
+ }
+
+ func testItemEntirePlayback() {
+ let player = Player()
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+ expectAtLeastEqualPublished(values: [.initialized, .enabled, .metricEvents, .metricEvents, .disabled], from: publisher) {
+ player.append(.simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [PlayerItemTrackerMock.adapter(statePublisher: publisher)]
+ ))
+ player.play()
+ }
+ }
+
+ func testDisableDuringDeinitPlayer() {
+ var player: Player? = Player()
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+ expectAtLeastEqualPublished(values: [.initialized, .enabled, .disabled], from: publisher) {
+ player?.append(.simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [PlayerItemTrackerMock.adapter(statePublisher: publisher)]
+ ))
+ player = nil
+ }
+ }
+
+ func testNetworkLoadedItemEntirePlayback() {
+ let player = Player()
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+ expectAtLeastEqualPublished(values: [.initialized, .enabled, .metricEvents, .metricEvents, .disabled], from: publisher) {
+ player.append(.mock(
+ url: Stream.shortOnDemand.url,
+ loadedAfter: 1,
+ trackerAdapters: [PlayerItemTrackerMock.adapter(statePublisher: publisher)]
+ ))
+ player.play()
+ }
+ }
+
+ func testFailedItem() {
+ let player = Player()
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+ expectEqualPublished(values: [.initialized, .enabled, .metricEvents, .metricEvents], from: publisher, during: .milliseconds(500)) {
+ player.append(.simple(
+ url: Stream.unavailable.url,
+ trackerAdapters: [PlayerItemTrackerMock.adapter(statePublisher: publisher)]
+ ))
+ player.play()
+ }
+ }
+
+ func testMoveCurrentItem() {
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+ let player = Player()
+ expectAtLeastEqualPublished(values: [.initialized, .enabled, .metricEvents, .metricEvents], from: publisher) {
+ player.append(.simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [PlayerItemTrackerMock.adapter(statePublisher: publisher)]
+ ))
+ player.play()
+ }
+ expectNothingPublished(from: publisher, during: .seconds(1)) {
+ player.prepend(.simple(url: Stream.onDemand.url))
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Tracking/PlayerItemTrackerMetricPublisherTests.swift b/Tests/PlayerTests/Tracking/PlayerItemTrackerMetricPublisherTests.swift
new file mode 100644
index 00000000..6d9895fa
--- /dev/null
+++ b/Tests/PlayerTests/Tracking/PlayerItemTrackerMetricPublisherTests.swift
@@ -0,0 +1,54 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class PlayerItemTrackerMetricPublisherTests: TestCase {
+ func testEmptyPlayer() {
+ let player = Player()
+ expectSimilarPublished(values: [[]], from: player.metricEventsPublisher, during: .milliseconds(500))
+ }
+
+ func testItemPlayback() {
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url))
+ expectAtLeastSimilarPublished(values: [
+ [],
+ [.anyMetadata],
+ [.anyMetadata, .anyAsset],
+ []
+ ], from: player.metricEventsPublisher) {
+ player.play()
+ }
+ }
+
+ func testError() {
+ let player = Player(item: .simple(url: Stream.unavailable.url))
+ expectAtLeastSimilarPublished(values: [
+ [],
+ [.anyMetadata],
+ [.anyMetadata, .anyFailure]
+ ], from: player.metricEventsPublisher)
+ }
+
+ func testPlaylist() {
+ let player = Player(items: [.simple(url: Stream.shortOnDemand.url), .simple(url: Stream.mediumOnDemand.url)])
+ expectSimilarPublished(
+ values: [
+ [],
+ [.anyMetadata],
+ [.anyMetadata, .anyAsset],
+ [.anyMetadata, .anyAsset]
+ ],
+ from: player.metricEventsPublisher,
+ during: .seconds(2)
+ ) {
+ player.play()
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Tracking/PlayerItemTrackerSessionTests.swift b/Tests/PlayerTests/Tracking/PlayerItemTrackerSessionTests.swift
new file mode 100644
index 00000000..1e02605f
--- /dev/null
+++ b/Tests/PlayerTests/Tracking/PlayerItemTrackerSessionTests.swift
@@ -0,0 +1,31 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class PlayerItemTrackerSessionTests: TestCase {
+ func testEmpty() {
+ let player = Player()
+ expect(player.currentSessionIdentifiers(trackedBy: PlayerItemTrackerMock.self)).to(beEmpty())
+ }
+
+ func testSessions() {
+ let player = Player(
+ item: .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ PlayerItemTrackerMock.adapter(configuration: .init(sessionIdentifier: "A")),
+ PlayerItemTrackerMock.adapter(configuration: .init()),
+ PlayerItemTrackerMock.adapter(configuration: .init(sessionIdentifier: "B"))
+ ]
+ )
+ )
+ expect(player.currentSessionIdentifiers(trackedBy: PlayerItemTrackerMock.self)).to(equal(["A", "B"]))
+ }
+}
diff --git a/Tests/PlayerTests/Tracking/PlayerItemTrackerUpdateTests.swift b/Tests/PlayerTests/Tracking/PlayerItemTrackerUpdateTests.swift
new file mode 100644
index 00000000..5bb52734
--- /dev/null
+++ b/Tests/PlayerTests/Tracking/PlayerItemTrackerUpdateTests.swift
@@ -0,0 +1,58 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class PlayerItemTrackerUpdateTests: TestCase {
+ func testMetadata() {
+ let player = Player()
+ let publisher = TrackerUpdateMock.StatePublisher()
+ let item = PlayerItem.simple(
+ url: Stream.shortOnDemand.url,
+ metadata: AssetMetadataMock(title: "title"),
+ trackerAdapters: [
+ TrackerUpdateMock.adapter(statePublisher: publisher) { $0.title }
+ ]
+ )
+ expectAtLeastEqualPublished(
+ values: [
+ .updatedMetadata("title"),
+ .enabled,
+ .updatedProperties,
+ .disabled
+ ],
+ from: publisher.removeDuplicates()
+ ) {
+ player.append(item)
+ player.play()
+ }
+ }
+
+ func testMetadataUpdate() {
+ let player = Player()
+ let publisher = TrackerUpdateMock.StatePublisher()
+ let item = PlayerItem.mock(url: Stream.shortOnDemand.url, withMetadataUpdateAfter: 1, trackerAdapters: [
+ TrackerUpdateMock.adapter(statePublisher: publisher) { $0.title }
+ ])
+ expectAtLeastEqualPublished(
+ values: [
+ .updatedMetadata("title0"),
+ .enabled,
+ .updatedProperties,
+ .updatedMetadata("title1"),
+ .updatedProperties,
+ .disabled
+ ],
+ from: publisher.removeDuplicates()
+ ) {
+ player.append(item)
+ player.play()
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Tracking/PlayerTrackingTests.swift b/Tests/PlayerTests/Tracking/PlayerTrackingTests.swift
new file mode 100644
index 00000000..cb036711
--- /dev/null
+++ b/Tests/PlayerTests/Tracking/PlayerTrackingTests.swift
@@ -0,0 +1,133 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import PillarboxCircumspect
+import PillarboxStreams
+
+final class PlayerTrackingTests: TestCase {
+ func testTrackingDisabled() {
+ let player = Player()
+ player.isTrackingEnabled = false
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+
+ expectEqualPublished(values: [.initialized], from: publisher, during: .milliseconds(500)) {
+ player.append(
+ .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ PlayerItemTrackerMock.adapter(statePublisher: publisher)
+ ]
+ )
+ )
+ player.play()
+ }
+ }
+
+ func testTrackingEnabledDuringPlayback() {
+ let player = Player()
+ player.isTrackingEnabled = false
+
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+
+ expectEqualPublished(values: [.initialized], from: publisher, during: .seconds(1)) {
+ player.append(
+ .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ PlayerItemTrackerMock.adapter(statePublisher: publisher)
+ ]
+ )
+ )
+ }
+
+ expectAtLeastEqualPublished(
+ values: [.enabled, .metricEvents, .disabled],
+ from: publisher
+ ) {
+ player.isTrackingEnabled = true
+ player.play()
+ }
+ }
+
+ func testTrackingDisabledDuringPlayback() {
+ let player = Player()
+ player.isTrackingEnabled = true
+
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+
+ expectEqualPublished(values: [.initialized, .enabled, .metricEvents, .metricEvents], from: publisher, during: .seconds(1)) {
+ player.append(
+ .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ PlayerItemTrackerMock.adapter(statePublisher: publisher)
+ ]
+ )
+ )
+ }
+
+ expectAtLeastEqualPublished(
+ values: [.disabled],
+ from: publisher
+ ) {
+ player.isTrackingEnabled = false
+ player.play()
+ }
+ }
+
+ func testTrackingEnabledTwice() {
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+
+ let player = Player(item: .simple(url: Stream.shortOnDemand.url, trackerAdapters: [PlayerItemTrackerMock.adapter(statePublisher: publisher)]))
+ player.isTrackingEnabled = true
+
+ expectEqualPublished(values: [.metricEvents, .metricEvents], from: publisher, during: .seconds(1)) {
+ player.isTrackingEnabled = true
+ }
+ }
+
+ func testMandatoryTracker() {
+ let player = Player()
+ player.isTrackingEnabled = false
+
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+
+ expectEqualPublished(values: [.initialized, .enabled, .metricEvents, .metricEvents], from: publisher, during: .seconds(1)) {
+ player.append(
+ .simple(
+ url: Stream.shortOnDemand.url,
+ trackerAdapters: [
+ PlayerItemTrackerMock.adapter(statePublisher: publisher, behavior: .mandatory)
+ ]
+ )
+ )
+ }
+ }
+
+ func testEnablingTrackingMustNotEmitMetricEventsAgainForMandatoryTracker() {
+ let player = Player()
+ player.isTrackingEnabled = false
+
+ let publisher = PlayerItemTrackerMock.StatePublisher()
+
+ expectEqualPublished(values: [.initialized, .enabled, .metricEvents, .metricEvents], from: publisher, during: .seconds(1)) {
+ player.append(
+ .simple(
+ url: Stream.onDemand.url,
+ trackerAdapters: [
+ PlayerItemTrackerMock.adapter(statePublisher: publisher, behavior: .mandatory)
+ ]
+ )
+ )
+ }
+
+ expectNothingPublished(from: publisher, during: .seconds(1)) {
+ player.isTrackingEnabled = true
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Types/CMTimeRangeTests.swift b/Tests/PlayerTests/Types/CMTimeRangeTests.swift
new file mode 100644
index 00000000..361b0f94
--- /dev/null
+++ b/Tests/PlayerTests/Types/CMTimeRangeTests.swift
@@ -0,0 +1,48 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+
+final class CMTimeRangeTests: TestCase {
+ func testEmpty() {
+ expect(CMTimeRange.flatten([])).to(beEmpty())
+ }
+
+ func testNoOverlap() {
+ let timeRanges: [CMTimeRange] = [
+ .init(start: .init(value: 1, timescale: 1), end: .init(value: 10, timescale: 1)),
+ .init(start: .init(value: 20, timescale: 1), end: .init(value: 30, timescale: 1))
+ ]
+ expect(CMTimeRange.flatten(timeRanges)).to(equal(timeRanges))
+ }
+
+ func testOverlap() {
+ let timeRanges: [CMTimeRange] = [
+ .init(start: .init(value: 1, timescale: 1), end: .init(value: 10, timescale: 1)),
+ .init(start: .init(value: 5, timescale: 1), end: .init(value: 30, timescale: 1))
+ ]
+ expect(CMTimeRange.flatten(timeRanges)).to(equal(
+ [
+ .init(start: .init(value: 1, timescale: 1), end: .init(value: 30, timescale: 1))
+ ]
+ ))
+ }
+
+ func testContained() {
+ let timeRanges: [CMTimeRange] = [
+ .init(start: .init(value: 1, timescale: 1), end: .init(value: 10, timescale: 1)),
+ .init(start: .init(value: 2, timescale: 1), end: .init(value: 8, timescale: 1))
+ ]
+ expect(CMTimeRange.flatten(timeRanges)).to(equal(
+ [
+ .init(start: .init(value: 1, timescale: 1), end: .init(value: 10, timescale: 1))
+ ]
+ ))
+ }
+}
diff --git a/Tests/PlayerTests/Types/CMTimeTests.swift b/Tests/PlayerTests/Types/CMTimeTests.swift
new file mode 100644
index 00000000..4f4e8956
--- /dev/null
+++ b/Tests/PlayerTests/Types/CMTimeTests.swift
@@ -0,0 +1,103 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+
+final class CMTimeTests: TestCase {
+ func testClampedWithNonEmptyRange() {
+ let range = CMTimeRange(start: CMTime(value: 1, timescale: 1), end: CMTime(value: 10, timescale: 1))
+ expect(CMTime.zero.clamped(to: range)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime.invalid.clamped(to: range)).to(equal(.invalid))
+ expect(CMTime(value: 1, timescale: 1).clamped(to: range)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime(value: 5, timescale: 1).clamped(to: range)).to(equal(CMTime(value: 5, timescale: 1)))
+ expect(CMTime(value: 10, timescale: 1).clamped(to: range)).to(equal(CMTime(value: 10, timescale: 1)))
+ expect(CMTime(value: 20, timescale: 1).clamped(to: range)).to(equal(CMTime(value: 10, timescale: 1)))
+ }
+
+ func testClampedWithNonEmptyRangeAndOffset() {
+ let range = CMTimeRange(start: CMTime(value: 1, timescale: 1), end: CMTime(value: 10, timescale: 1))
+ let offset = CMTime(value: 1, timescale: 10)
+ expect(CMTime.zero.clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime.invalid.clamped(to: range, offset: offset)).to(equal(.invalid))
+ expect(CMTime(value: 1, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime(value: 5, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 5, timescale: 1)))
+ expect(CMTime(value: 10, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 99, timescale: 10)))
+ expect(CMTime(value: 20, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 99, timescale: 10)))
+ }
+
+ func testClampedWithNonEmptyRangeAndLargeOffset() {
+ let range = CMTimeRange(start: CMTime(value: 1, timescale: 1), end: CMTime(value: 10, timescale: 1))
+ let offset = CMTime(value: 100, timescale: 1)
+ expect(CMTime.zero.clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime.invalid.clamped(to: range, offset: offset)).to(equal(.invalid))
+ expect(CMTime(value: 1, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime(value: 5, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime(value: 10, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime(value: 20, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ }
+
+ func testClampedWithEmptyRange() {
+ let range = CMTimeRange(start: CMTime(value: 1, timescale: 1), end: CMTime(value: 1, timescale: 1))
+ expect(CMTime.zero.clamped(to: range)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime.invalid.clamped(to: range)).to(equal(.invalid))
+ expect(CMTime(value: 1, timescale: 1).clamped(to: range)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime(value: 5, timescale: 1).clamped(to: range)).to(equal(CMTime(value: 1, timescale: 1)))
+ }
+
+ func testClampedWithEmptyRangeAndOffset() {
+ let range = CMTimeRange(start: CMTime(value: 1, timescale: 1), end: CMTime(value: 1, timescale: 1))
+ let offset = CMTime(value: 1, timescale: 10)
+ expect(CMTime.zero.clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime.invalid.clamped(to: range, offset: offset)).to(equal(.invalid))
+ expect(CMTime(value: 1, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(CMTime(value: 5, timescale: 1).clamped(to: range, offset: offset)).to(equal(CMTime(value: 1, timescale: 1)))
+ }
+
+ func testClampedWithInvalidRange() {
+ let range = CMTimeRange.invalid
+ expect(CMTime.zero.clamped(to: range)).to(equal(.invalid))
+ expect(CMTime.invalid.clamped(to: range)).to(equal(.invalid))
+ expect(CMTime(value: 1, timescale: 1).clamped(to: range)).to(equal(.invalid))
+ }
+
+ func testClampedWithInvalidRangeAndOffset() {
+ let range = CMTimeRange.invalid
+ let offset = CMTime(value: 1, timescale: 10)
+ expect(CMTime.zero.clamped(to: range, offset: offset)).to(equal(.invalid))
+ expect(CMTime.invalid.clamped(to: range, offset: offset)).to(equal(.invalid))
+ expect(CMTime(value: 1, timescale: 1).clamped(to: range, offset: offset)).to(equal(.invalid))
+ }
+
+ func testAfterWithoutTimeRange() {
+ let time = CMTime(value: 5, timescale: 1)
+ expect(time.after(timeRanges: [])).to(beNil())
+ }
+
+ func testAfterWithMatchingTimeRange() {
+ let time = CMTime(value: 5, timescale: 1)
+ expect(time.after(timeRanges: [
+ .init(start: CMTime(value: 2, timescale: 1), end: CMTime(value: 6, timescale: 1))
+ ])).to(equal(CMTime(value: 6, timescale: 1)))
+ }
+
+ func testAfterWithoutMatchingTimeRange() {
+ let time = CMTime(value: 5, timescale: 1)
+ expect(time.after(timeRanges: [
+ .init(start: CMTime(value: 12, timescale: 1), end: CMTime(value: 16, timescale: 1))
+ ])).to(beNil())
+ }
+
+ func testAfterWithMatchingTimeRanges() {
+ let time = CMTime(value: 5, timescale: 1)
+ expect(time.after(timeRanges: [
+ .init(start: CMTime(value: 2, timescale: 1), end: CMTime(value: 6, timescale: 1)),
+ .init(start: CMTime(value: 4, timescale: 1), end: CMTime(value: 10, timescale: 1))
+ ])).to(equal(CMTime(value: 10, timescale: 1)))
+ }
+}
diff --git a/Tests/PlayerTests/Types/ErrorsTests.swift b/Tests/PlayerTests/Types/ErrorsTests.swift
new file mode 100644
index 00000000..ebfad89e
--- /dev/null
+++ b/Tests/PlayerTests/Types/ErrorsTests.swift
@@ -0,0 +1,49 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import XCTest
+
+private enum EnumError: LocalizedError {
+ case someError
+
+ var errorDescription: String? {
+ "Enum error description"
+ }
+
+ var failureReason: String? {
+ "Enum failure reason"
+ }
+
+ var recoverySuggestion: String? {
+ "Enum recovery suggestion"
+ }
+
+ var helpAnchor: String? {
+ "Enum help anchor"
+ }
+}
+
+final class ErrorsTests: XCTestCase {
+ func testNSErrorFromNSError() {
+ let error = NSError(domain: "domain", code: 1012, userInfo: [
+ NSLocalizedDescriptionKey: "Error description",
+ NSLocalizedFailureReasonErrorKey: "Failure reason",
+ NSUnderlyingErrorKey: NSError(domain: "inner.domain", code: 2024)
+ ])
+ expect(NSError.error(from: error)).to(equal(error))
+ }
+
+ func testNSErrorFromSwiftError() {
+ let error = NSError.error(from: EnumError.someError)!
+ expect(error.localizedDescription).to(equal("Enum error description"))
+ expect(error.localizedFailureReason).to(equal("Enum failure reason"))
+ expect(error.localizedRecoverySuggestion).to(equal("Enum recovery suggestion"))
+ expect(error.helpAnchor).to(equal("Enum help anchor"))
+ }
+}
diff --git a/Tests/PlayerTests/Types/ImageSourceTests.swift b/Tests/PlayerTests/Types/ImageSourceTests.swift
new file mode 100644
index 00000000..df8b4c39
--- /dev/null
+++ b/Tests/PlayerTests/Types/ImageSourceTests.swift
@@ -0,0 +1,76 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import PillarboxCircumspect
+import UIKit
+
+final class ImageSourceTests: TestCase {
+ func testNone() {
+ expectEqualPublished(
+ values: [.none],
+ from: ImageSource.none.imageSourcePublisher(),
+ during: .milliseconds(100)
+ )
+ }
+
+ func testImage() {
+ let image = UIImage(systemName: "circle")!
+ expectEqualPublished(
+ values: [.image(image)],
+ from: ImageSource.image(image).imageSourcePublisher(),
+ during: .milliseconds(100)
+ )
+ }
+
+ func testNonLoadedImageForValidUrl() {
+ let url = Bundle.module.url(forResource: "pixel", withExtension: "jpg")!
+ let source = ImageSource.url(standardResolution: url)
+ expectSimilarPublished(
+ values: [.url(standardResolution: url)],
+ from: source.imageSourcePublisher(),
+ during: .milliseconds(100)
+ )
+ }
+
+ func testLoadedImageForValidUrl() {
+ let url = Bundle.module.url(forResource: "pixel", withExtension: "jpg")!
+ let image = UIImage(contentsOfFile: url.path())!
+ let source = ImageSource.url(standardResolution: url)
+ expectSimilarPublished(
+ values: [.url(standardResolution: url), .image(image)],
+ from: source.imageSourcePublisher(),
+ during: .milliseconds(100)
+ ) {
+ _ = source.image
+ }
+ }
+
+ func testInvalidImageFormat() {
+ let url = Bundle.module.url(forResource: "invalid", withExtension: "jpg")!
+ let source = ImageSource.url(standardResolution: url)
+ expectSimilarPublished(
+ values: [.url(standardResolution: url), .none],
+ from: source.imageSourcePublisher(),
+ during: .milliseconds(100)
+ ) {
+ _ = source.image
+ }
+ }
+
+ func testFailingUrl() {
+ let url = URL(string: "https://localhost:8123/missing.jpg")!
+ let source = ImageSource.url(standardResolution: url)
+ expectSimilarPublished(
+ values: [.url(standardResolution: url), .none],
+ from: source.imageSourcePublisher(),
+ during: .seconds(1)
+ ) {
+ _ = source.image
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Types/ItemErrorTests.swift b/Tests/PlayerTests/Types/ItemErrorTests.swift
new file mode 100644
index 00000000..865cbddf
--- /dev/null
+++ b/Tests/PlayerTests/Types/ItemErrorTests.swift
@@ -0,0 +1,43 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+
+final class ItemErrorTests: TestCase {
+ func testNoInnerComment() {
+ expect(ItemError.innerComment(from: "The internet connection appears to be offline"))
+ .to(equal("The internet connection appears to be offline"))
+ }
+
+ func testInnerComment() {
+ expect(ItemError.innerComment(
+ from: "The operation couldn’t be completed. (CoreBusiness.DataError error 1 - This content is not available anymore.)"
+ )).to(equal("This content is not available anymore."))
+
+ expect(ItemError.innerComment(
+ from: "The operation couldn’t be completed. (CoreBusiness.DataError error 1 - Not found)"
+ )).to(equal("Not found"))
+
+ expect(ItemError.innerComment(
+ from: "The operation couldn't be completed. (CoreMediaErrorDomain error -16839 - Unable to get playlist before long download timer.)"
+ )).to(equal("Unable to get playlist before long download timer."))
+
+ expect(ItemError.innerComment(
+ from: "L’opération n’a pas pu s’achever. (CoreBusiness.DataError erreur 1 - Ce contenu n'est plus disponible.)"
+ )).to(equal("Ce contenu n'est plus disponible."))
+ }
+
+ func testNestedInnerComments() {
+ expect(ItemError.innerComment(
+ from: """
+ The operation couldn’t be completed. (CoreMediaErrorDomain error -12660 - The operation couldn’t be completed. \
+ (CoreMediaErrorDomain error -12660 - HTTP 403: Forbidden))
+ """
+ )).to(equal("HTTP 403: Forbidden"))
+ }
+}
diff --git a/Tests/PlayerTests/Types/PlaybackSpeedTests.swift b/Tests/PlayerTests/Types/PlaybackSpeedTests.swift
new file mode 100644
index 00000000..38d94c40
--- /dev/null
+++ b/Tests/PlayerTests/Types/PlaybackSpeedTests.swift
@@ -0,0 +1,35 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+
+final class PlaybackSpeedTests: TestCase {
+ func testNoValueClampingToIndefiniteRange() {
+ let speed = PlaybackSpeed(value: 2, range: nil)
+ expect(speed.value).to(equal(2))
+ expect(speed.range).to(beNil())
+ }
+
+ func testValueClampingToDefiniteRange() {
+ let speed = PlaybackSpeed(value: 2, range: 1...1)
+ expect(speed.value).to(equal(1))
+ expect(speed.range).to(equal(1...1))
+ }
+
+ func testEffectivePropertiesWhenIndefinite() {
+ let speed = PlaybackSpeed.indefinite
+ expect(speed.effectiveValue).to(equal(1))
+ expect(speed.effectiveRange).to(equal(1...1))
+ }
+
+ func testEffectivePropertiesWhenDefinite() {
+ let speed = PlaybackSpeed(value: 2, range: 0...2)
+ expect(speed.effectiveValue).to(equal(2))
+ expect(speed.effectiveRange).to(equal(0...2))
+ }
+}
diff --git a/Tests/PlayerTests/Types/PlaybackStateTests.swift b/Tests/PlayerTests/Types/PlaybackStateTests.swift
new file mode 100644
index 00000000..0d3ece45
--- /dev/null
+++ b/Tests/PlayerTests/Types/PlaybackStateTests.swift
@@ -0,0 +1,20 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+
+final class PlaybackStateTests: TestCase {
+ func testAllCases() {
+ expect(PlaybackState(itemStatus: .unknown, rate: 0)).to(equal(.idle))
+ expect(PlaybackState(itemStatus: .unknown, rate: 1)).to(equal(.idle))
+ expect(PlaybackState(itemStatus: .readyToPlay, rate: 0)).to(equal(.paused))
+ expect(PlaybackState(itemStatus: .readyToPlay, rate: 1)).to(equal(.playing))
+ expect(PlaybackState(itemStatus: .ended, rate: 0)).to(equal(.ended))
+ expect(PlaybackState(itemStatus: .ended, rate: 1)).to(equal(.ended))
+ }
+}
diff --git a/Tests/PlayerTests/Types/PlayerConfigurationTests.swift b/Tests/PlayerTests/Types/PlayerConfigurationTests.swift
new file mode 100644
index 00000000..a6d39906
--- /dev/null
+++ b/Tests/PlayerTests/Types/PlayerConfigurationTests.swift
@@ -0,0 +1,42 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+
+final class PlayerConfigurationTests: TestCase {
+ func testDefaultValues() {
+ let configuration = PlayerConfiguration()
+ expect(configuration.allowsExternalPlayback).to(beTrue())
+ expect(configuration.usesExternalPlaybackWhileMirroring).to(beFalse())
+ expect(configuration.preventsDisplaySleepDuringVideoPlayback).to(beTrue())
+ expect(configuration.navigationMode).to(equal(.smart(interval: 3)))
+ expect(configuration.backwardSkipInterval).to(equal(10))
+ expect(configuration.forwardSkipInterval).to(equal(10))
+ expect(configuration.preloadedItems).to(equal(2))
+ expect(configuration.allowsConstrainedNetworkAccess).to(beTrue())
+ }
+
+ func testCustomValues() {
+ let configuration = PlayerConfiguration(
+ allowsExternalPlayback: false,
+ usesExternalPlaybackWhileMirroring: true,
+ preventsDisplaySleepDuringVideoPlayback: false,
+ navigationMode: .immediate,
+ backwardSkipInterval: 42,
+ forwardSkipInterval: 47,
+ allowsConstrainedNetworkAccess: false
+ )
+ expect(configuration.allowsExternalPlayback).to(beFalse())
+ expect(configuration.usesExternalPlaybackWhileMirroring).to(beTrue())
+ expect(configuration.preventsDisplaySleepDuringVideoPlayback).to(beFalse())
+ expect(configuration.navigationMode).to(equal(.immediate))
+ expect(configuration.backwardSkipInterval).to(equal(42))
+ expect(configuration.forwardSkipInterval).to(equal(47))
+ expect(configuration.allowsConstrainedNetworkAccess).to(beFalse())
+ }
+}
diff --git a/Tests/PlayerTests/Types/PlayerLimitsTests.swift b/Tests/PlayerTests/Types/PlayerLimitsTests.swift
new file mode 100644
index 00000000..d2daa438
--- /dev/null
+++ b/Tests/PlayerTests/Types/PlayerLimitsTests.swift
@@ -0,0 +1,79 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import PillarboxStreams
+
+final class PlayerLimitsTests: TestCase {
+ private static let limits = PlayerLimits(
+ preferredPeakBitRate: 100,
+ preferredPeakBitRateForExpensiveNetworks: 200,
+ preferredMaximumResolution: .init(width: 100, height: 200),
+ preferredMaximumResolutionForExpensiveNetworks: .init(width: 300, height: 400)
+ )
+
+ func testDefaultValues() {
+ let limits = PlayerLimits()
+ expect(limits.preferredPeakBitRate).to(equal(0))
+ expect(limits.preferredPeakBitRateForExpensiveNetworks).to(equal(0))
+ expect(limits.preferredMaximumResolution).to(equal(.zero))
+ expect(limits.preferredMaximumResolutionForExpensiveNetworks).to(equal(.zero))
+ }
+
+ func testCustomValues() {
+ let limits = PlayerLimits(
+ preferredPeakBitRate: 100,
+ preferredPeakBitRateForExpensiveNetworks: 200,
+ preferredMaximumResolution: .init(width: 100, height: 200),
+ preferredMaximumResolutionForExpensiveNetworks: .init(width: 300, height: 400)
+ )
+ expect(limits.preferredPeakBitRate).to(equal(100))
+ expect(limits.preferredPeakBitRateForExpensiveNetworks).to(equal(200))
+ expect(limits.preferredMaximumResolution).to(equal(.init(width: 100, height: 200)))
+ expect(limits.preferredMaximumResolutionForExpensiveNetworks).to(equal(.init(width: 300, height: 400)))
+ }
+
+ func testAppliedDefaultValues() {
+ let player = Player(items: [
+ .simple(url: Stream.onDemand.url),
+ .simple(url: Stream.mediumOnDemand.url)
+ ])
+ player.queuePlayer.items().forEach { item in
+ expect(item.preferredPeakBitRate).to(equal(0))
+ expect(item.preferredPeakBitRateForExpensiveNetworks).to(equal(0))
+ expect(item.preferredMaximumResolution).to(equal(.zero))
+ expect(item.preferredMaximumResolutionForExpensiveNetworks).to(equal(.zero))
+ }
+ }
+
+ func testAppliedInitialValues() {
+ let player = Player(items: [
+ .simple(url: Stream.onDemand.url),
+ .simple(url: Stream.mediumOnDemand.url)
+ ])
+ player.limits = Self.limits
+ player.queuePlayer.items().forEach { item in
+ expect(item.preferredPeakBitRate).to(equal(100))
+ expect(item.preferredPeakBitRateForExpensiveNetworks).to(equal(200))
+ expect(item.preferredMaximumResolution).to(equal(.init(width: 100, height: 200)))
+ expect(item.preferredMaximumResolutionForExpensiveNetworks).to(equal(.init(width: 300, height: 400)))
+ }
+ }
+
+ func testLoadedItem() {
+ let player = Player(item: .mock(url: Stream.onDemand.url, loadedAfter: 0.1))
+ player.limits = Self.limits
+ expect(player.playbackState).toEventually(equal(.paused))
+ player.queuePlayer.items().forEach { item in
+ expect(item.preferredPeakBitRate).to(equal(100))
+ expect(item.preferredPeakBitRateForExpensiveNetworks).to(equal(200))
+ expect(item.preferredMaximumResolution).to(equal(.init(width: 100, height: 200)))
+ expect(item.preferredMaximumResolutionForExpensiveNetworks).to(equal(.init(width: 300, height: 400)))
+ }
+ }
+}
diff --git a/Tests/PlayerTests/Types/PlayerMetadataTests.swift b/Tests/PlayerTests/Types/PlayerMetadataTests.swift
new file mode 100644
index 00000000..70402784
--- /dev/null
+++ b/Tests/PlayerTests/Types/PlayerMetadataTests.swift
@@ -0,0 +1,66 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import AVFoundation
+import MediaPlayer
+import Nimble
+
+final class PlayerMetadataTests: TestCase {
+ private static func value(for identifier: AVMetadataIdentifier, in items: [AVMetadataItem]) async throws -> Any? {
+ guard let item = AVMetadataItem.metadataItems(from: items, filteredByIdentifier: identifier).first else { return nil }
+ return try await item.load(.value)
+ }
+
+ func testNowPlayingInfo() {
+ let metadata = PlayerMetadata(
+ title: "title",
+ subtitle: "subtitle",
+ imageSource: .image(.init(systemName: "circle")!)
+ )
+ let nowPlayingInfo = metadata.nowPlayingInfo
+ expect(nowPlayingInfo[MPMediaItemPropertyTitle] as? String).to(equal("title"))
+ expect(nowPlayingInfo[MPMediaItemPropertyArtist] as? String).to(equal("subtitle"))
+ expect(nowPlayingInfo[MPMediaItemPropertyArtwork]).notTo(beNil())
+ }
+
+ func testExternalMetadata() async {
+ let metadata = PlayerMetadata(
+ identifier: "identifier",
+ title: "title",
+ subtitle: "subtitle",
+ description: "description",
+ imageSource: .image(.init(systemName: "circle")!),
+ episodeInformation: .long(season: 2, episode: 3)
+ )
+ let externalMetadata = metadata.externalMetadata
+ await expect {
+ try await Self.value(for: .commonIdentifierAssetIdentifier, in: externalMetadata) as? String
+ }.to(equal("identifier"))
+ await expect {
+ try await Self.value(for: .commonIdentifierTitle, in: externalMetadata) as? String
+ }.to(equal("title"))
+ await expect {
+ try await Self.value(for: .iTunesMetadataTrackSubTitle, in: externalMetadata) as? String
+ }.to(equal("subtitle"))
+ await expect {
+ try await Self.value(for: .commonIdentifierDescription, in: externalMetadata) as? String
+ }.to(equal("description"))
+ await expect {
+ try await Self.value(for: .quickTimeUserDataCreationDate, in: externalMetadata) as? String
+ }.to(equal("S2, E3"))
+
+ await expect {
+ try await Self.value(for: .commonIdentifierArtwork, in: externalMetadata)
+ }
+#if os(tvOS)
+ .notTo(beNil())
+#else
+ .to(beNil())
+#endif
+ }
+}
diff --git a/Tests/PlayerTests/Types/PositionTests.swift b/Tests/PlayerTests/Types/PositionTests.swift
new file mode 100644
index 00000000..f4216f2d
--- /dev/null
+++ b/Tests/PlayerTests/Types/PositionTests.swift
@@ -0,0 +1,47 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+
+final class PositionTests: TestCase {
+ func testPositionTo() {
+ let position = to(CMTime(value: 1, timescale: 1), toleranceBefore: CMTime(value: 2, timescale: 1), toleranceAfter: CMTime(value: 3, timescale: 1))
+ expect(position.time).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(position.toleranceBefore).to(equal(CMTime(value: 2, timescale: 1)))
+ expect(position.toleranceAfter).to(equal(CMTime(value: 3, timescale: 1)))
+ }
+
+ func testPositionAt() {
+ let position = at(CMTime(value: 1, timescale: 1))
+ expect(position.time).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(position.toleranceBefore).to(equal(.zero))
+ expect(position.toleranceAfter).to(equal(.zero))
+ }
+
+ func testPositionNear() {
+ let position = near(CMTime(value: 1, timescale: 1))
+ expect(position.time).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(position.toleranceBefore).to(equal(.positiveInfinity))
+ expect(position.toleranceAfter).to(equal(.positiveInfinity))
+ }
+
+ func testPositionBefore() {
+ let position = before(CMTime(value: 1, timescale: 1))
+ expect(position.time).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(position.toleranceBefore).to(equal(.positiveInfinity))
+ expect(position.toleranceAfter).to(equal(.zero))
+ }
+
+ func testPositionAfter() {
+ let position = after(CMTime(value: 1, timescale: 1))
+ expect(position.time).to(equal(CMTime(value: 1, timescale: 1)))
+ expect(position.toleranceBefore).to(equal(.zero))
+ expect(position.toleranceAfter).to(equal(.positiveInfinity))
+ }
+}
diff --git a/Tests/PlayerTests/Types/StreamTypeTests.swift b/Tests/PlayerTests/Types/StreamTypeTests.swift
new file mode 100644
index 00000000..4e7ef6ac
--- /dev/null
+++ b/Tests/PlayerTests/Types/StreamTypeTests.swift
@@ -0,0 +1,23 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+
+final class StreamTypeTests: TestCase {
+ func testAllCases() {
+ expect(StreamType(for: .zero, duration: .invalid)).to(equal(.unknown))
+ expect(StreamType(for: .zero, duration: .indefinite)).to(equal(.live))
+ expect(StreamType(for: .finite, duration: .indefinite)).to(equal(.dvr))
+ expect(StreamType(for: .zero, duration: .zero)).to(equal(.onDemand))
+ }
+}
+
+private extension CMTimeRange {
+ static let finite = Self(start: .zero, duration: .init(value: 1, timescale: 1))
+}
diff --git a/Tests/PlayerTests/Types/TimePropertiesTests.swift b/Tests/PlayerTests/Types/TimePropertiesTests.swift
new file mode 100644
index 00000000..d8f92d49
--- /dev/null
+++ b/Tests/PlayerTests/Types/TimePropertiesTests.swift
@@ -0,0 +1,69 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import CoreMedia
+import Nimble
+
+final class TimePropertiesTests: TestCase {
+ func testWithoutTimeRange() {
+ expect(TimeProperties.timeRange(from: [])).to(beNil())
+ }
+
+ func testTimeRange() {
+ expect(TimeProperties.timeRange(from: [NSValue(timeRange: .finite)])).to(equal(.finite))
+ }
+
+ func testTimeRanges() {
+ expect(TimeProperties.timeRange(from: [
+ NSValue(timeRange: .init(start: .init(value: 1, timescale: 1), duration: .init(value: 3, timescale: 1))),
+ NSValue(timeRange: .init(start: .init(value: 10, timescale: 1), duration: .init(value: 5, timescale: 1)))
+ ])).to(equal(
+ .init(start: .init(value: 1, timescale: 1), duration: .init(value: 14, timescale: 1))
+ ))
+ }
+
+ func testInvalidTimeRange() {
+ expect(TimeProperties.timeRange(from: [NSValue(timeRange: .invalid)])).to(equal(.invalid))
+ }
+
+ func testSeekableTimeRangeFallback() {
+ expect(
+ TimeProperties.timeRange(
+ loadedTimeRanges: [NSValue(timeRange: .finite)],
+ seekableTimeRanges: []
+ )
+ )
+ .to(equal(.zero))
+ }
+
+ func testBufferEmptyLoadedTimeRanges() {
+ expect(
+ TimeProperties(
+ loadedTimeRanges: [],
+ seekableTimeRanges: [NSValue(timeRange: .finite)],
+ isPlaybackLikelyToKeepUp: true
+ ).buffer
+ )
+ .to(equal(0))
+ }
+
+ func testBuffer() {
+ expect(
+ TimeProperties(
+ loadedTimeRanges: [NSValue(timeRange: .finite)],
+ seekableTimeRanges: [NSValue(timeRange: .finite)],
+ isPlaybackLikelyToKeepUp: true
+ ).buffer
+ )
+ .to(equal(1))
+ }
+}
+
+private extension CMTimeRange {
+ static let finite = Self(start: .zero, duration: .init(value: 1, timescale: 1))
+}
diff --git a/Tests/PlayerTests/UserInterface/VisibilityTrackerTests.swift b/Tests/PlayerTests/UserInterface/VisibilityTrackerTests.swift
new file mode 100644
index 00000000..a02d80ee
--- /dev/null
+++ b/Tests/PlayerTests/UserInterface/VisibilityTrackerTests.swift
@@ -0,0 +1,184 @@
+//
+// Copyright (c) SRG SSR. All rights reserved.
+//
+// License information is available from the LICENSE file.
+//
+
+@testable import PillarboxPlayer
+
+import Nimble
+import ObjectiveC
+import PillarboxCircumspect
+import PillarboxStreams
+
+#if os(iOS)
+final class VisibilityTrackerTests: TestCase {
+ func testInitiallyVisible() {
+ let visibilityTracker = VisibilityTracker()
+ expect(visibilityTracker.isUserInterfaceHidden).to(beFalse())
+ }
+
+ func testInitiallyHidden() {
+ let visibilityTracker = VisibilityTracker(isUserInterfaceHidden: true)
+ expect(visibilityTracker.isUserInterfaceHidden).to(beTrue())
+ }
+
+ func testNoToggleWithoutPlayer() {
+ let visibilityTracker = VisibilityTracker()
+ visibilityTracker.toggle()
+ expect(visibilityTracker.isUserInterfaceHidden).to(beFalse())
+ }
+
+ func testToggle() {
+ let visibilityTracker = VisibilityTracker()
+ visibilityTracker.player = Player()
+ visibilityTracker.toggle()
+ expect(visibilityTracker.isUserInterfaceHidden).to(beTrue())
+ }
+
+ func testInitiallyVisibleIfPaused() {
+ let visibilityTracker = VisibilityTracker(delay: 0.5, isUserInterfaceHidden: true)
+ visibilityTracker.player = Player(item: PlayerItem.simple(url: Stream.onDemand.url))
+ expect(visibilityTracker.isUserInterfaceHidden).toEventually(beFalse())
+ }
+
+ func testVisibleWhenPaused() {
+ let visibilityTracker = VisibilityTracker(delay: 0.5, isUserInterfaceHidden: true)
+ let player = Player(item: PlayerItem.simple(url: Stream.onDemand.url))
+ visibilityTracker.player = player
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+ expect(visibilityTracker.isUserInterfaceHidden).to(beTrue())
+ player.pause()
+ expect(visibilityTracker.isUserInterfaceHidden).toEventually(beFalse())
+ }
+
+ func testNoAutoHideWhileIdle() {
+ let visibilityTracker = VisibilityTracker(delay: 0.5)
+ visibilityTracker.player = Player()
+ expect(visibilityTracker.isUserInterfaceHidden).toNever(beTrue(), until: .seconds(1))
+ }
+
+ func testAutoHideWhilePlaying() {
+ let visibilityTracker = VisibilityTracker(delay: 0.5)
+ let player = Player(item: PlayerItem.simple(url: Stream.onDemand.url))
+ player.play()
+ visibilityTracker.player = player
+ expect(visibilityTracker.isUserInterfaceHidden).toEventually(beTrue())
+ }
+
+ func testNoAutoHideWhilePaused() {
+ let visibilityTracker = VisibilityTracker(delay: 0.5)
+ visibilityTracker.player = Player(item: PlayerItem.simple(url: Stream.onDemand.url))
+ expect(visibilityTracker.isUserInterfaceHidden).toNever(beTrue(), until: .seconds(1))
+ }
+
+ func testNoAutoHideWhileEnded() {
+ let visibilityTracker = VisibilityTracker(delay: Stream.shortOnDemand.duration.seconds + 0.5)
+ let player = Player(item: PlayerItem.simple(url: Stream.shortOnDemand.url))
+ player.play()
+ visibilityTracker.player = player
+ expect(visibilityTracker.isUserInterfaceHidden).toNever(beTrue(), until: .seconds(1))
+ }
+
+ func testNoAutoHideWhileFailed() {
+ let visibilityTracker = VisibilityTracker(delay: 0.5)
+ visibilityTracker.player = Player(item: PlayerItem.simple(url: Stream.unavailable.url))
+ expect(visibilityTracker.isUserInterfaceHidden).toNever(beTrue(), until: .seconds(1))
+ }
+
+ func testNoAutoHideWithEmptyPlayer() {
+ let visibilityTracker = VisibilityTracker(delay: 0.5)
+ visibilityTracker.player = Player()
+ expect(visibilityTracker.isUserInterfaceHidden).toNever(beTrue(), until: .seconds(1))
+ }
+
+ func testNoAutoHideWithoutPlayer() {
+ let visibilityTracker = VisibilityTracker(delay: 0.5)
+ expect(visibilityTracker.isUserInterfaceHidden).toNever(beTrue(), until: .seconds(1))
+ }
+
+ func testResetAutoHide() {
+ let visibilityTracker = VisibilityTracker(delay: 0.3)
+ let player = Player(item: PlayerItem.simple(url: Stream.onDemand.url))
+ visibilityTracker.player = player
+ player.play()
+ expect(player.playbackState).toEventually(equal(.playing))
+ expect(visibilityTracker.isUserInterfaceHidden).toAlways(beFalse(), until: .milliseconds(200))
+ visibilityTracker.reset()
+ expect(visibilityTracker.isUserInterfaceHidden).toAlways(beFalse(), until: .milliseconds(200))
+ }
+
+ func testResetDoesNotShowControls() {
+ let visibilityTracker = VisibilityTracker(isUserInterfaceHidden: true)
+ visibilityTracker.reset()
+ expect(visibilityTracker.isUserInterfaceHidden).to(beTrue())
+ }
+
+ func testAutoHideAfterUnhide() {
+ let visibilityTracker = VisibilityTracker(delay: 0.5, isUserInterfaceHidden: true)
+ let player = Player(item: PlayerItem.simple(url: Stream.onDemand.url))
+ player.play()
+ visibilityTracker.player = player
+ visibilityTracker.toggle()
+ expect(visibilityTracker.isUserInterfaceHidden).toEventually(beTrue())
+ }
+
+ func testInvalidDelay() {
+ guard nimbleThrowAssertionsAvailable() else { return }
+ expect(VisibilityTracker(delay: -5)).to(throwAssertion())
+ }
+
+ func testPlayerChangeDoesNotHideUserInterface() {
+ let visibilityTracker = VisibilityTracker()
+ visibilityTracker.player = Player()
+ expect(visibilityTracker.isUserInterfaceHidden).to(beFalse())
+ }
+
+ func testPlayerChangeDoesNotShowUserInterface() {
+ let visibilityTracker = VisibilityTracker(isUserInterfaceHidden: true)
+ visibilityTracker.player = Player()
+ expect(visibilityTracker.isUserInterfaceHidden).to(beTrue())
+ }
+
+ func testPlayerChangeResetsAutoHide() {
+ let player1 = Player(item: PlayerItem.simple(url: Stream.onDemand.url))
+ player1.play()
+ expect(player1.playbackState).toEventually(equal(.playing))
+
+ let player2 = Player(item: PlayerItem.simple(url: Stream.onDemand.url))
+ player2.play()
+ expect(player2.playbackState).toEventually(equal(.playing))
+
+ let visibilityTracker = VisibilityTracker(delay: 0.5)
+ visibilityTracker.player = player1
+ expect(visibilityTracker.isUserInterfaceHidden).toAlways(beFalse(), until: .milliseconds(400))
+
+ visibilityTracker.player = player2
+ expect(visibilityTracker.isUserInterfaceHidden).toAlways(beFalse(), until: .milliseconds(400))
+ }
+
+ func testDeallocation() {
+ var visibilityTracker: VisibilityTracker? = VisibilityTracker()
+ weak var weakVisibilityTracker = visibilityTracker
+ autoreleasepool {
+ visibilityTracker = nil
+ }
+ expect(weakVisibilityTracker).to(beNil())
+ }
+
+ func testDeallocationWhilePlaying() {
+ var visibilityTracker: VisibilityTracker? = VisibilityTracker()
+ let player = Player(item: PlayerItem.simple(url: Stream.onDemand.url))
+ player.play()
+ visibilityTracker?.player = player
+ expect(player.playbackState).toEventually(equal(.playing))
+
+ weak var weakVisibilityTracker = visibilityTracker
+ autoreleasepool {
+ visibilityTracker = nil
+ }
+ expect(weakVisibilityTracker).to(beNil())
+ }
+}
+#endif