From becebe95961e2aff8d7560e6f681454972a52a41 Mon Sep 17 00:00:00 2001 From: Daniel Bernal Date: Mon, 11 Nov 2024 11:41:14 +0100 Subject: [PATCH] [DuckPlayer] Base Overlay Pixel Implementation (#3545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task/Issue URL: https://app.asana.com/0/1204099484721401/1208686031091507/f Tech Design URL: CC: --- Core/PixelEvent.swift | 18 ++ DuckDuckGo.xcodeproj/project.pbxproj | 8 + .../DuckPlayerNavigationHandler.swift | 10 +- .../DuckPlayerNavigationHandling.swift | 3 + .../DuckPlayerOverlayUsagePixels.swift | 123 ++++++++++ .../DuckPlayerOverlayUsagePixelsTests.swift | 227 ++++++++++++++++++ 6 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 DuckDuckGo/DuckPlayer/DuckPlayerOverlayUsagePixels.swift create mode 100644 DuckDuckGoTests/DuckPlayerOverlayUsagePixelsTests.swift diff --git a/Core/PixelEvent.swift b/Core/PixelEvent.swift index 35429325fc..2589576efc 100644 --- a/Core/PixelEvent.swift +++ b/Core/PixelEvent.swift @@ -850,6 +850,14 @@ extension Pixel { case protectedDataUnavailableWhenBecomeActive case statisticsLoaderATBStateMismatch case adAttributionReportStateMismatch + + // MARK: - DuckPlayer Overlay Navigation + case duckPlayerYouTubeOverlayNavigationBack + case duckPlayerYouTubeOverlayNavigationRefresh + case duckPlayerYouTubeNavigationWithinYouTube + case duckPlayerYouTubeOverlayNavigationOutsideYoutube + case duckPlayerYouTubeOverlayNavigationClosed + case duckPlayerYouTubeNavigationIdle30 } } @@ -1690,6 +1698,16 @@ extension Pixel.Event { case .protectedDataUnavailableWhenBecomeActive: return "m_protected_data_unavailable_when_become_active" case .statisticsLoaderATBStateMismatch: return "m_statistics_loader_atb_state_mismatch" case .adAttributionReportStateMismatch: return "m_ad_attribution_report_state_mismatch" + + // MARK: - DuckPlayer Overlay Navigation + case .duckPlayerYouTubeOverlayNavigationBack: return "duckplayer.youtube.overlay.navigation.back" + case .duckPlayerYouTubeOverlayNavigationRefresh: return "duckplayer.youtube.overlay.navigation.refresh" + case .duckPlayerYouTubeNavigationWithinYouTube: return "duckplayer.youtube.overlay.navigation.within-youtube" + case .duckPlayerYouTubeOverlayNavigationOutsideYoutube: return "duckplayer.youtube.overlay.navigation.outside-youtube" + case .duckPlayerYouTubeOverlayNavigationClosed: return "duckplayer.youtube.overlay.navigation.closed" + case .duckPlayerYouTubeNavigationIdle30: return "duckplayer.youtube.overlay.idle-30" + + } } } diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index ea3eb46242..58e5dfe98e 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -1014,6 +1014,8 @@ D69FBF762B28BE3600B505F1 /* SettingsSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69FBF752B28BE3600B505F1 /* SettingsSubscriptionView.swift */; }; D6ACEA322BBD55BF008FADDF /* TabURLInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6ACEA312BBD55BF008FADDF /* TabURLInterceptor.swift */; }; D6B67A122C332B6E002122EB /* DuckPlayerMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B67A112C332B6E002122EB /* DuckPlayerMocks.swift */; }; + D6B9E8D22CDA4420002B640C /* DuckPlayerOverlayUsagePixels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9E8D12CDA4418002B640C /* DuckPlayerOverlayUsagePixels.swift */; }; + D6B9E8D42CDA8375002B640C /* DuckPlayerOverlayUsagePixelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6B9E8D32CDA8369002B640C /* DuckPlayerOverlayUsagePixelsTests.swift */; }; D6BC8ACB2C5AA3860025375B /* DuckPlayer in Frameworks */ = {isa = PBXBuildFile; productRef = D6BC8ACA2C5AA3860025375B /* DuckPlayer */; }; D6BFCB5F2B7524AA0051FF81 /* SubscriptionPIRView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */; }; D6BFCB612B7525160051FF81 /* SubscriptionPIRViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BFCB602B7525160051FF81 /* SubscriptionPIRViewModel.swift */; }; @@ -2823,6 +2825,8 @@ D69FBF752B28BE3600B505F1 /* SettingsSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSubscriptionView.swift; sourceTree = ""; }; D6ACEA312BBD55BF008FADDF /* TabURLInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabURLInterceptor.swift; sourceTree = ""; }; D6B67A112C332B6E002122EB /* DuckPlayerMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerMocks.swift; sourceTree = ""; }; + D6B9E8D12CDA4418002B640C /* DuckPlayerOverlayUsagePixels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerOverlayUsagePixels.swift; sourceTree = ""; }; + D6B9E8D32CDA8369002B640C /* DuckPlayerOverlayUsagePixelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DuckPlayerOverlayUsagePixelsTests.swift; sourceTree = ""; }; D6BFCB5E2B7524AA0051FF81 /* SubscriptionPIRView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPIRView.swift; sourceTree = ""; }; D6BFCB602B7525160051FF81 /* SubscriptionPIRViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionPIRViewModel.swift; sourceTree = ""; }; D6D95CE22B6D9F8800960317 /* AsyncHeadlessWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncHeadlessWebViewModel.swift; sourceTree = ""; }; @@ -5324,6 +5328,7 @@ D62EC3B72C24695800FC9D04 /* DuckPlayer */ = { isa = PBXGroup; children = ( + D6B9E8D32CDA8369002B640C /* DuckPlayerOverlayUsagePixelsTests.swift */, D6B67A112C332B6E002122EB /* DuckPlayerMocks.swift */, D62EC3BB2C2470E000FC9D04 /* DuckPlayerTests.swift */, D62EC3B82C246A5600FC9D04 /* YoutublePlayerNavigationHandlerTests.swift */, @@ -5342,6 +5347,7 @@ D63FF8892C1B21C2006DE24D /* DuckPlayerNavigationHandler.swift */, D63FF8942C1B67E8006DE24D /* YoutubeOverlayUserScript.swift */, D63FF8932C1B67E8006DE24D /* YoutubePlayerUserScript.swift */, + D6B9E8D12CDA4418002B640C /* DuckPlayerOverlayUsagePixels.swift */, 31860A5A2C57ED2D005561F5 /* DuckPlayerStorage.swift */, ); path = DuckPlayer; @@ -7471,6 +7477,7 @@ D670E5BB2BB6A75300941A42 /* SubscriptionNavigationCoordinator.swift in Sources */, 6F9FFE262C579BCD00A238BE /* NewTabPageShortcutsSettingsStorage.swift in Sources */, C1D21E2D293A5965006E5A05 /* AutofillLoginSession.swift in Sources */, + D6B9E8D22CDA4420002B640C /* DuckPlayerOverlayUsagePixels.swift in Sources */, 4B53648A26718D0E001AA041 /* EmailWaitlist.swift in Sources */, D63677F52BBDB1C300605BA5 /* DaxLogoNavbarTitle.swift in Sources */, 8524CC98246D66E100E59D45 /* String+Markdown.swift in Sources */, @@ -8001,6 +8008,7 @@ 5694372B2BE3F2D900C0881B /* SyncErrorHandlerTests.swift in Sources */, 4B27FBB52C927435007E21A7 /* PersistentPixelTests.swift in Sources */, 987130C7294AAB9F00AB05E0 /* MenuBookmarksViewModelTests.swift in Sources */, + D6B9E8D42CDA8375002B640C /* DuckPlayerOverlayUsagePixelsTests.swift in Sources */, 858650D32469BFAD00C36F8A /* DaxDialogTests.swift in Sources */, 9F1623092C9D14F10093C4FC /* DefaultVariantManagerOnboardingTests.swift in Sources */, 31C138B227A4097800FFD4B2 /* DownloadTestsHelper.swift in Sources */, diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift index adb9d58ab0..f64a61f558 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandler.swift @@ -32,6 +32,9 @@ final class DuckPlayerNavigationHandler: NSObject { /// The DuckPlayer instance used for handling video playback. var duckPlayer: DuckPlayerControlling + /// The DuckPlayerOverlayPixelFiring instance used for handling overlay pixel firing. + var duckPlayerOverlayUsagePixels: DuckPlayerOverlayPixelFiring? + /// Indicates where the DuckPlayer was referred from (e.g., YouTube, SERP). var referrer: DuckPlayerReferrer = .other @@ -113,13 +116,15 @@ final class DuckPlayerNavigationHandler: NSObject { appSettings: AppSettings, pixelFiring: PixelFiring.Type = Pixel.self, dailyPixelFiring: DailyPixelFiring.Type = DailyPixel.self, - tabNavigationHandler: DuckPlayerTabNavigationHandling? = nil) { + tabNavigationHandler: DuckPlayerTabNavigationHandling? = nil, + duckPlayerOverlayUsagePixels: DuckPlayerOverlayPixelFiring? = nil) { self.duckPlayer = duckPlayer self.featureFlagger = featureFlagger self.appSettings = appSettings self.pixelFiring = pixelFiring self.dailyPixelFiring = dailyPixelFiring self.tabNavigationHandler = tabNavigationHandler + self.duckPlayerOverlayUsagePixels = duckPlayerOverlayUsagePixels } /// Returns the file path for the Duck Player HTML template. @@ -658,6 +663,9 @@ extension DuckPlayerNavigationHandler: DuckPlayerNavigationHandling { @MainActor func handleURLChange(webView: WKWebView) -> DuckPlayerNavigationHandlerURLChangeResult { + // Track overlayUsagePixels + duckPlayerOverlayUsagePixels?.registerNavigation(url: webView.url) + // We want to prevent multiple simultaneous redirects // This can be caused by Duplicate Nav events, and quick URL changes if let lastTimestamp = lastURLChangeHandling, diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift index 1755a54cf2..168512c112 100644 --- a/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift +++ b/DuckDuckGo/DuckPlayer/DuckPlayerNavigationHandling.swift @@ -74,6 +74,9 @@ protocol DuckPlayerNavigationHandling: AnyObject { /// The DuckPlayer instance used for handling video playback. var duckPlayer: DuckPlayerControlling { get } + /// DuckPlayerOverlayUsagePixels instance used for handling pixel firing. + var duckPlayerOverlayUsagePixels: DuckPlayerOverlayPixelFiring? { get } + /// Handles URL changes in the web view. /// /// - Parameter webView: The web view where the URL change occurred. diff --git a/DuckDuckGo/DuckPlayer/DuckPlayerOverlayUsagePixels.swift b/DuckDuckGo/DuckPlayer/DuckPlayerOverlayUsagePixels.swift new file mode 100644 index 0000000000..459b762f40 --- /dev/null +++ b/DuckDuckGo/DuckPlayer/DuckPlayerOverlayUsagePixels.swift @@ -0,0 +1,123 @@ +// +// DuckPlayerOverlayUsagePixels.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Core + +protocol DuckPlayerOverlayPixelFiring { + + var pixelFiring: PixelFiring.Type { get set } + var navigationHistory: [URL] { get set } + + func registerNavigation(url: URL?) + func navigationBack(duckPlayerMode: DuckPlayerMode) + func navigationReload(duckPlayerMode: DuckPlayerMode) + func navigationWithinYoutube(duckPlayerMode: DuckPlayerMode) + func navigationOutsideYoutube(duckPlayerMode: DuckPlayerMode) + func navigationClosed(duckPlayerMode: DuckPlayerMode) + func overlayIdle(duckPlayerMode: DuckPlayerMode) + +} + +final class DuckPlayerOverlayUsagePixels: DuckPlayerOverlayPixelFiring { + + var pixelFiring: PixelFiring.Type + var navigationHistory: [URL] = [] + + private var idleTimer: Timer? + private var idleTimeInterval: TimeInterval + + init(pixelFiring: PixelFiring.Type = Pixel.self, + navigationHistory: [URL] = [], + timeoutInterval: TimeInterval = 30.0) { + self.pixelFiring = pixelFiring + self.idleTimeInterval = timeoutInterval + } + + // Method to reset the idle timer + private func resetIdleTimer() { + idleTimer?.invalidate() + idleTimer = nil + } + + func registerNavigation(url: URL?) { + guard let url = url else { return } + navigationHistory.append(url) + + // Cancel and reset the idle timer whenever a new navigation occurs + resetIdleTimer() + } + + func navigationBack(duckPlayerMode: DuckPlayerMode) { + guard duckPlayerMode == .alwaysAsk, + let lastURL = navigationHistory.last, + lastURL.isYoutubeWatch else { return } + + pixelFiring.fire(.duckPlayerYouTubeOverlayNavigationBack, withAdditionalParameters: [:]) + } + + func navigationReload(duckPlayerMode: DuckPlayerMode) { + guard duckPlayerMode == .alwaysAsk, + let lastURL = navigationHistory.last, + lastURL.isYoutubeWatch else { return } + + pixelFiring.fire(.duckPlayerYouTubeOverlayNavigationRefresh, withAdditionalParameters: [:]) + } + + func navigationWithinYoutube(duckPlayerMode: DuckPlayerMode) { + guard duckPlayerMode == .alwaysAsk, + navigationHistory.count > 1, + let currentURL = navigationHistory.last, + let previousURL = navigationHistory.dropLast().last, + previousURL.isYoutubeWatch, + currentURL.isYoutube else { return } + + pixelFiring.fire(.duckPlayerYouTubeNavigationWithinYouTube, withAdditionalParameters: [:]) + } + + func navigationOutsideYoutube(duckPlayerMode: DuckPlayerMode) { + guard duckPlayerMode == .alwaysAsk, + navigationHistory.count > 1, + let currentURL = navigationHistory.last, + let previousURL = navigationHistory.dropLast().last, + previousURL.isYoutubeWatch, + !currentURL.isYoutube else { return } + + pixelFiring.fire(.duckPlayerYouTubeOverlayNavigationOutsideYoutube, withAdditionalParameters: [:]) + } + + func navigationClosed(duckPlayerMode: DuckPlayerMode) { + + guard duckPlayerMode == .alwaysAsk, + let lastURL = navigationHistory.last, + lastURL.isYoutubeWatch else { return } + + pixelFiring.fire(.duckPlayerYouTubeOverlayNavigationClosed, withAdditionalParameters: [:]) + + } + + func overlayIdle(duckPlayerMode: DuckPlayerMode) { + guard duckPlayerMode == .alwaysAsk, + let lastURL = navigationHistory.last, + lastURL.isYoutubeWatch else { return } + + idleTimer = Timer.scheduledTimer(withTimeInterval: idleTimeInterval, repeats: false) { [weak self] _ in + self?.pixelFiring.fire(.duckPlayerYouTubeNavigationIdle30, withAdditionalParameters: [:]) + } + } +} diff --git a/DuckDuckGoTests/DuckPlayerOverlayUsagePixelsTests.swift b/DuckDuckGoTests/DuckPlayerOverlayUsagePixelsTests.swift new file mode 100644 index 0000000000..dc5be8e048 --- /dev/null +++ b/DuckDuckGoTests/DuckPlayerOverlayUsagePixelsTests.swift @@ -0,0 +1,227 @@ +// +// DuckPlayerOverlayUsagePixelsTests.swift +// DuckDuckGo +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Core +@testable import DuckDuckGo + +class DuckPlayerOverlayUsagePixelsTests: XCTestCase { + + var duckPlayerOverlayPixels: DuckPlayerOverlayUsagePixels! + + override func setUp() { + super.setUp() + // Initialize DuckPlayerOverlayUsagePixels with a shorter timeoutInterval for testing + PixelFiringMock.tearDown() + duckPlayerOverlayPixels = DuckPlayerOverlayUsagePixels(pixelFiring: PixelFiringMock.self, timeoutInterval: 3.0) + } + + override func tearDown() { + // Clean up after each test + PixelFiringMock.tearDown() + duckPlayerOverlayPixels = nil + super.tearDown() + } + + func testRegisterNavigationAddsURLToHistory() { + // Arrange + let testURL = URL(string: "https://www.example.com")! + + // Act + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Assert + XCTAssertEqual(duckPlayerOverlayPixels.navigationHistory.count, 1) + XCTAssertEqual(duckPlayerOverlayPixels.navigationHistory.first, testURL) + } + + func testRegisterNavigationWithNilURLDoesNotAddToHistory() { + // Act + duckPlayerOverlayPixels.registerNavigation(url: nil) + + // Assert + XCTAssertTrue(duckPlayerOverlayPixels.navigationHistory.isEmpty, "Navigation history should remain empty when registering a nil URL.") + } + + func testNavigationBackFiresPixelWhenConditionsMet() { + // Arrange + let testURL = URL(string: "https://www.youtube.com/watch?v=example")! + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Act + duckPlayerOverlayPixels.navigationBack(duckPlayerMode: .alwaysAsk) + + // Assert + XCTAssertEqual(PixelFiringMock.lastPixelName, Pixel.Event.duckPlayerYouTubeOverlayNavigationBack.name) + XCTAssertNotNil(PixelFiringMock.lastPixelInfo, "Pixel should be fired when conditions are met for navigationBack.") + } + + func testNavigationBackDoesNotFirePixelWhenConditionsNotMet() { + // Act + duckPlayerOverlayPixels.navigationBack(duckPlayerMode: .enabled) + + // Assert + XCTAssertNil(PixelFiringMock.lastPixelName, "Pixel should not be fired when conditions are not met for navigationBack.") + } + + func testNavigationReloadFiresPixelWhenConditionsMet() { + // Arrange + let testURL = URL(string: "https://www.youtube.com/watch?v=example")! + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Act + duckPlayerOverlayPixels.navigationReload(duckPlayerMode: .alwaysAsk) + + // Assert + XCTAssertEqual(PixelFiringMock.lastPixelName, Pixel.Event.duckPlayerYouTubeOverlayNavigationRefresh.name) + XCTAssertNotNil(PixelFiringMock.lastPixelInfo, "Pixel should be fired when conditions are met for navigationReload.") + } + + func testNavigationReloadDoesNotFirePixelWhenConditionsNotMet() { + // Act + duckPlayerOverlayPixels.navigationReload(duckPlayerMode: .enabled) + + // Assert + XCTAssertNil(PixelFiringMock.lastPixelName, "Pixel should not be fired when conditions are not met for navigationReload.") + } + + func testNavigationWithinYoutubeFiresPixelWhenConditionsMet() { + // Arrange + let previousURL = URL(string: "https://www.youtube.com/watch?v=example1")! + let currentURL = URL(string: "https://www.youtube.com/watch?v=example2")! + duckPlayerOverlayPixels.registerNavigation(url: previousURL) + duckPlayerOverlayPixels.registerNavigation(url: currentURL) + + // Act + duckPlayerOverlayPixels.navigationWithinYoutube(duckPlayerMode: .alwaysAsk) + + // Assert + XCTAssertEqual(PixelFiringMock.lastPixelName, Pixel.Event.duckPlayerYouTubeNavigationWithinYouTube.name) + XCTAssertNotNil(PixelFiringMock.lastPixelInfo, "Pixel should be fired when conditions are met for navigationWithinYoutube.") + } + + func testNavigationWithinYoutubeDoesNotFirePixelWhenConditionsNotMet() { + // Arrange + let testURL = URL(string: "https://www.example.com")! + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Act + duckPlayerOverlayPixels.navigationWithinYoutube(duckPlayerMode: .alwaysAsk) + + // Assert + XCTAssertNil(PixelFiringMock.lastPixelName, "Pixel should not be fired when conditions are not met for navigationWithinYoutube.") + } + + func testNavigationOutsideYoutubeFiresPixelWhenConditionsMet() { + // Arrange + let previousURL = URL(string: "https://www.youtube.com/watch?v=example1")! + let currentURL = URL(string: "https://www.example.com")! + duckPlayerOverlayPixels.registerNavigation(url: previousURL) + duckPlayerOverlayPixels.registerNavigation(url: currentURL) + + // Act + duckPlayerOverlayPixels.navigationOutsideYoutube(duckPlayerMode: .alwaysAsk) + + // Assert + XCTAssertEqual(PixelFiringMock.lastPixelName, Pixel.Event.duckPlayerYouTubeOverlayNavigationOutsideYoutube.name) + XCTAssertNotNil(PixelFiringMock.lastPixelInfo, "Pixel should be fired when conditions are met for navigationOutsideYoutube.") + } + + func testNavigationOutsideYoutubeDoesNotFirePixelWhenConditionsNotMet() { + // Arrange + let testURL = URL(string: "https://www.youtube.com/watch?v=example")! + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Act + duckPlayerOverlayPixels.navigationOutsideYoutube(duckPlayerMode: .alwaysAsk) + + // Assert + XCTAssertNil(PixelFiringMock.lastPixelName, "Pixel should not be fired when conditions are not met for navigationOutsideYoutube.") + } + + func testOverlayIdleStartsTimerAndFiresPixelAfter3Seconds() { + // Arrange + let testURL = URL(string: "https://www.youtube.com/watch?v=example")! + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Act + duckPlayerOverlayPixels.overlayIdle(duckPlayerMode: .alwaysAsk) + + // Simulate waiting for 3 seconds + let expectation = XCTestExpectation(description: "Wait for the pixel to be fired after 3 seconds.") + DispatchQueue.main.asyncAfter(deadline: .now() + 3.1) { + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5) + + // Assert + XCTAssertEqual(PixelFiringMock.lastPixelName, Pixel.Event.duckPlayerYouTubeNavigationIdle30.name) + XCTAssertNotNil(PixelFiringMock.lastPixelInfo, "Pixel should be fired after 3 seconds of inactivity.") + } + + func testOverlayIdleDoesNotFirePixelWhenNavigationHistoryIsNotYouTubeWatch() { + // Arrange + let testURL = URL(string: "https://www.example.com")! + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Act + duckPlayerOverlayPixels.overlayIdle(duckPlayerMode: .alwaysAsk) + + // Assert + XCTAssertNil(PixelFiringMock.lastPixelName, "Pixel should not be fired if the last URL is not a YouTube watch URL.") + } + + func testOverlayIdleDoesNotStartTimerIfModeIsNotAlwaysAsk() { + // Arrange + let testURL = URL(string: "https://www.youtube.com/watch?v=example")! + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Act + duckPlayerOverlayPixels.overlayIdle(duckPlayerMode: .enabled) + + // Assert + XCTAssertNil(PixelFiringMock.lastPixelName, "Pixel should not be fired if the mode is not .alwaysAsk.") + } + + func testNavigationClosedFiresPixelWhenConditionsMet() { + // Arrange + let testURL = URL(string: "https://www.youtube.com/watch?v=example")! + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Act + duckPlayerOverlayPixels.navigationClosed(duckPlayerMode: .alwaysAsk) + + // Assert + XCTAssertEqual(PixelFiringMock.lastPixelName, Pixel.Event.duckPlayerYouTubeOverlayNavigationClosed.name) + XCTAssertNotNil(PixelFiringMock.lastPixelInfo, "Pixel should be fired when conditions are met for navigationReload.") + } + + func testNavigationClosedDoesNotFirePixelWhenConditionsNotMet() { + // Arrange + let testURL = URL(string: "https://www.youtube.com")! + duckPlayerOverlayPixels.registerNavigation(url: testURL) + + // Act + duckPlayerOverlayPixels.navigationClosed(duckPlayerMode: .enabled) + + // Assert + XCTAssertNil(PixelFiringMock.lastPixelName, "Pixel should not be fired when conditions are not met for navigationClosed.") + } +}