diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme index 7aeef5267..bcddddeb8 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme @@ -553,6 +553,20 @@ ReferencedContainer = "container:"> + + + + + + + + ) -> Double private let queue = DispatchQueue(label: "com.ExperimentCohortsManager.queue") + private let fireCohortAssigned: (_ subfeatureID: SubfeatureID, _ experiment: ExperimentData) -> Void public var experiments: Experiments? { get { @@ -81,9 +82,11 @@ public class ExperimentCohortsManager: ExperimentCohortsManaging { } } - public init(store: ExperimentsDataStoring = ExperimentsDataStore(), randomizer: @escaping (Range) -> Double = Double.random(in:)) { + public init(store: ExperimentsDataStoring = ExperimentsDataStore(), randomizer: @escaping (Range) -> Double = Double.random(in:), + fireCohortAssigned: @escaping (_ subfeatureID: SubfeatureID, _ experiment: ExperimentData) -> Void) { self.store = store self.randomizer = randomizer + self.fireCohortAssigned = fireCohortAssigned } public func resolveCohort(for experiment: ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID? { @@ -113,6 +116,7 @@ extension ExperimentCohortsManager { cumulativeWeight += Double(cohort.weight) if randomValue < cumulativeWeight { saveCohort(cohort.name, in: subfeature.subfeatureID, parentID: subfeature.parentID) + fireCohortAssigned(subfeature.subfeatureID, ExperimentData(parentID: subfeature.parentID, cohortID: cohort.name, enrollmentDate: Date())) return cohort.name } } diff --git a/Sources/PixelExperimentKit/ExperimentEventTracker.swift b/Sources/PixelExperimentKit/ExperimentEventTracker.swift new file mode 100644 index 000000000..e38f39702 --- /dev/null +++ b/Sources/PixelExperimentKit/ExperimentEventTracker.swift @@ -0,0 +1,80 @@ +// +// ExperimentEventTracker.swift +// +// 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 Foundation + +public typealias ThresholdCheckResult = Bool +public typealias ExprimentPixelNameAndParameters = String +public typealias NumberOfActions = Int + +public protocol ExperimentActionPixelStore { + func removeObject(forKey defaultName: String) + func integer(forKey defaultName: String) -> Int + func set(_ value: Int, forKey defaultName: String) + } + +public protocol ExperimentEventTracking { + /// Increments the count for a given event key and checks if the threshold has been exceeded. + /// + /// This method performs the following actions: + /// 1. If the `isInWindow` parameter is `false`, it removes the stored count for the key and returns `false`. + /// 2. If `isInWindow` is `true`, it increments the count for the key. + /// 3. If the updated count meets or exceeds the specified `threshold`, the stored count is removed, and the method returns `true`. + /// 4. If the updated count does not meet the threshold, it updates the count and returns `false`. + /// + /// - Parameters: + /// - key: The key used to store and retrieve the count. + /// - threshold: The count threshold that triggers a return of `true`. + /// - isInWindow: A flag indicating if the count should be considered (e.g., within a time window). + /// - Returns: `true` if the threshold is exceeded and the count is reset, otherwise `false`. + func incrementAndCheckThreshold(forKey key: ExprimentPixelNameAndParameters, threshold: NumberOfActions, isInWindow: Bool) -> ThresholdCheckResult +} + +public struct ExperimentEventTracker: ExperimentEventTracking { + private let store: ExperimentActionPixelStore + private let syncQueue = DispatchQueue(label: "com.pixelkit.experimentActionSyncQueue") + + public init(store: ExperimentActionPixelStore = UserDefaults.standard) { + self.store = store + } + + public func incrementAndCheckThreshold(forKey key: ExprimentPixelNameAndParameters, threshold: NumberOfActions, isInWindow: Bool) -> ThresholdCheckResult { + syncQueue.sync { + // Remove the key if is not in window + guard isInWindow else { + store.removeObject(forKey: key) + return false + } + + // Increment the current count + let currentCount = store.integer(forKey: key) + let newCount = currentCount + 1 + store.set(newCount, forKey: key) + + // Check if the threshold is exceeded + if newCount >= threshold { + store.removeObject(forKey: key) + return true + } + return false + } + } + +} + +extension UserDefaults: ExperimentActionPixelStore {} diff --git a/Sources/PixelExperimentKit/PixelExperimentKit.swift b/Sources/PixelExperimentKit/PixelExperimentKit.swift new file mode 100644 index 000000000..d0962f791 --- /dev/null +++ b/Sources/PixelExperimentKit/PixelExperimentKit.swift @@ -0,0 +1,249 @@ +// +// PixelExperimentKit.swift +// +// 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 PixelKit +import BrowserServicesKit +import Foundation + +public typealias ConversionWindow = ClosedRange + +struct ExperimentEvent: PixelKitEvent { + var name: String + var parameters: [String: String]? +} + +extension PixelKit { + + struct Constants { + static let enrollmentEventPrefix = "experiment_enroll" + static let metricsEventPrefix = "experiment_metrics" + static let metricKey = "metric" + static let conversionWindowDaysKey = "conversionWindowDays" + static let valueKey = "value" + static let enrollmentDateKey = "enrollmentDate" + static let searchMetricValue = "search" + static let appUseMetricValue = "app_use" + } + + // Static property to hold shared dependencies + struct ExperimentConfig { + static var featureFlagger: FeatureFlagger? + static var eventTracker: ExperimentEventTracking = ExperimentEventTracker() + static var fireFunction: (PixelKitEvent, PixelKit.Frequency, Bool) -> Void = { event, frequency, includeAppVersion in + fire(event, frequency: frequency, includeAppVersionParameter: includeAppVersion) + } + } + + // Setup method to initialize dependencies + public static func configureExperimentKit( + featureFlagger: FeatureFlagger, + eventTracker: ExperimentEventTracking = ExperimentEventTracker(), + fire: @escaping (PixelKitEvent, PixelKit.Frequency, Bool) -> Void = { event, frequency, includeAppVersion in + fire(event, frequency: frequency, includeAppVersionParameter: includeAppVersion) + } + ) { + ExperimentConfig.featureFlagger = featureFlagger + ExperimentConfig.eventTracker = eventTracker + ExperimentConfig.fireFunction = fire + } + + /// Fires a pixel indicating the user's enrollment in an experiment. + /// - Parameters: + /// - subfeatureID: Identifier for the subfeature associated with the experiment. + /// - experiment: Data about the experiment like cohort and enrollment date + public static func fireExperimentEnrollmentPixel(subfeatureID: SubfeatureID, experiment: ExperimentData) { + let eventName = "\(Constants.enrollmentEventPrefix)_\(subfeatureID)_\(experiment.cohortID)" + let event = ExperimentEvent(name: eventName, parameters: [Constants.enrollmentDateKey: experiment.enrollmentDate.toYYYYMMDDInET()]) + ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false) + } + + /// Fires a pixel for a specific action in an experiment, based on conversion window and value thresholds (if value is a number). + /// - Parameters: + /// - subfeatureID: Identifier for the subfeature associated with the experiment. + /// - metric: The name of the metric being tracked (e.g., "searches"). + /// - conversionWindowDays: The range of days after enrollment during which the action is valid. + /// - value: A specific value associated to the action. It could be the target number of actions required to fire the pixel. + /// + /// This function: + /// 1. Validates if the experiment is active. + /// 2. Ensures the user is within the specified conversion window. + /// 3. Tracks actions performed and sends the pixel once the target value is reached (if applicable). + public static func fireExperimentPixel(for subfeatureID: SubfeatureID, + metric: String, + conversionWindowDays: ConversionWindow, + value: String) { + // Check is active experiment for user + guard let featureFlagger = ExperimentConfig.featureFlagger else { + assertionFailure("PixelKit is not configured for experiments") + return + } + guard let experimentData = featureFlagger.getAllActiveExperiments()[subfeatureID] else { return } + + fireExperimentPixelForActiveExperiment(subfeatureID, + experimentData: experimentData, + metric: metric, + conversionWindowDays: conversionWindowDays, + value: value) + } + + /// Fires search-related experiment pixels for all active experiments. + /// + /// This function iterates through all active experiments and triggers + /// pixel firing based on predefined search-related value and conversion window mappings. + /// - The value and conversion windows define when and how many search actions + /// must occur before the pixel is fired. + public static func fireSearchExperimentPixels() { + let valueConversionDictionary: [NumberOfActions: [ConversionWindow]] = [ + 1: [0...0, 1...1, 2...2, 3...3, 4...4, 5...5, 6...6, 7...7, 5...7], + 4: [5...7, 8...15], + 6: [5...7, 8...15], + 11: [5...7, 8...15], + 21: [5...7, 8...15], + 30: [5...7, 8...15] + ] + guard let featureFlagger = ExperimentConfig.featureFlagger else { + assertionFailure("PixelKit is not configured for experiments") + return + } + featureFlagger.getAllActiveExperiments().forEach { experiment in + fireExperimentPixels(for: + experiment.key, + experimentData: experiment.value, + metric: Constants.searchMetricValue, + valueConversionDictionary: valueConversionDictionary + ) + } + } + + /// Fires app retention-related experiment pixels for all active experiments. + /// + /// This function iterates through all active experiments and triggers + /// pixel firing based on predefined app retention value and conversion window mappings. + /// - The value and conversion windows define when and how many app usage actions + /// must occur before the pixel is fired. + public static func fireAppRetentionExperimentPixels() { + let valueConversionDictionary: [NumberOfActions: [ConversionWindow]] = [ + 1: [1...1, 2...2, 3...3, 4...4, 5...5, 6...6, 7...7, 5...7], + 4: [5...7, 8...15], + 6: [5...7, 8...15], + 11: [5...7, 8...15], + 21: [5...7, 8...15], + 30: [5...7, 8...15] + ] + guard let featureFlagger = ExperimentConfig.featureFlagger else { + assertionFailure("PixelKit is not configured for experiments") + return + } + featureFlagger.getAllActiveExperiments().forEach { experiment in + fireExperimentPixels( + for: experiment.key, + experimentData: experiment.value, + metric: Constants.appUseMetricValue, + valueConversionDictionary: valueConversionDictionary + ) + } + } + + private static func fireExperimentPixels( + for experiment: SubfeatureID, + experimentData: ExperimentData, + metric: String, + valueConversionDictionary: [NumberOfActions: [ConversionWindow]] + ) { + valueConversionDictionary.forEach { value, ranges in + ranges.forEach { range in + fireExperimentPixelForActiveExperiment( + experiment, + experimentData: experimentData, + metric: metric, + conversionWindowDays: range, + value: "\(value)" + ) + } + } + } + + private static func fireExperimentPixelForActiveExperiment(_ subfeatureID: SubfeatureID, + experimentData: ExperimentData, + metric: String, + conversionWindowDays: ConversionWindow, + value: String) { + // Set parameters, event name, store key + let eventName = "\(Constants.metricsEventPrefix)_\(subfeatureID)_\(experimentData.cohortID)" + let conversionWindowValue = (conversionWindowDays.lowerBound != conversionWindowDays.upperBound) ? + "\(conversionWindowDays.lowerBound)-\(conversionWindowDays.upperBound)" : + "\(conversionWindowDays.lowerBound)" + let parameters: [String: String] = [ + Constants.metricKey: metric, + Constants.conversionWindowDaysKey: conversionWindowValue, + Constants.valueKey: value, + Constants.enrollmentDateKey: experimentData.enrollmentDate.toYYYYMMDDInET() + ] + let event = ExperimentEvent(name: eventName, parameters: parameters) + let eventStoreKey = "\(eventName)_\(parameters.toString())" + + // Determine if the user is within the conversion window + let isInWindow = isUserInConversionWindow(conversionWindowDays, enrollmentDate: experimentData.enrollmentDate) + + // Check if value is a number + if let numberOfAction = NumberOfActions(value), numberOfAction > 1 { + // Increment or remove based on conversion window status + let shouldSendPixel = ExperimentConfig.eventTracker.incrementAndCheckThreshold( + forKey: eventStoreKey, + threshold: numberOfAction, + isInWindow: isInWindow + ) + + // Send the pixel only if conditions are met + if shouldSendPixel { + ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false) + } + } else if isInWindow { + // If value is not a number, send the pixel only if within the window + ExperimentConfig.fireFunction(event, .uniqueByNameAndParameters, false) + } + } + + private static func isUserInConversionWindow( + _ conversionWindowRange: ConversionWindow, + enrollmentDate: Date + ) -> Bool { + let calendar = Calendar.current + guard let startOfWindow = enrollmentDate.addDays(conversionWindowRange.lowerBound), + let endOfWindow = enrollmentDate.addDays(conversionWindowRange.upperBound) else { + return false + } + + let currentDate = calendar.startOfDay(for: Date()) + return currentDate >= calendar.startOfDay(for: startOfWindow) && + currentDate <= calendar.startOfDay(for: endOfWindow) + } +} + +extension Date { + public func toYYYYMMDDInET() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.timeZone = TimeZone(identifier: "America/New_York") + return formatter.string(from: self) + } + + func addDays(_ days: Int) -> Date? { + Calendar.current.date(byAdding: .day, value: days, to: self) + } +} diff --git a/Sources/PixelKit/Extensions/DictionaryExtension.swift b/Sources/PixelKit/Extensions/DictionaryExtension.swift new file mode 100644 index 000000000..a905a9854 --- /dev/null +++ b/Sources/PixelKit/Extensions/DictionaryExtension.swift @@ -0,0 +1,25 @@ +// +// DictionaryExtension.swift +// +// 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. +// + +public extension Dictionary where Key: Comparable { + func toString(pairSeparator: String = ":", entrySeparator: String = ",") -> String { + sorted(by: { $0.key < $1.key }) + .map { "\($0.key)\(pairSeparator)\($0.value)" } + .joined(separator: entrySeparator) + } +} diff --git a/Sources/PixelKit/PixelKit.swift b/Sources/PixelKit/PixelKit.swift index 4f01a5647..2e719dd26 100644 --- a/Sources/PixelKit/PixelKit.swift +++ b/Sources/PixelKit/PixelKit.swift @@ -31,9 +31,12 @@ public final class PixelKit { /// [Legacy] Used in Pixel.fire(...) as .unique but without the `_u` requirement in the name case legacyInitial - /// Sent only once ever. The timestamp for this pixel is stored. + /// Sent only once ever (based on pixel name only.) The timestamp for this pixel is stored. /// Note: This is the only pixel that MUST end with `_u`, Name for pixels of this type must end with if it doesn't an assertion is fired. - case unique + case uniqueByName + + /// Sent only once ever (based on pixel name AND parameters). The timestamp for this pixel is stored. + case uniqueByNameAndParameters /// [Legacy] Used in Pixel.fire(...) as .daily but without the `_d` automatically added to the name case legacyDaily @@ -57,7 +60,7 @@ public final class PixelKit { "Standard" case .legacyInitial: "Legacy Initial" - case .unique: + case .uniqueByName: "Unique" case .legacyDaily: "Legacy Daily" @@ -67,6 +70,8 @@ public final class PixelKit { "Legacy Daily and Count" case .dailyAndCount: "Daily and Count" + case .uniqueByNameAndParameters: + "Unique By Name And Parameters" } } } @@ -190,81 +195,165 @@ public final class PixelKit { var headers = headers ?? defaultHeaders headers[Header.moreInfo] = "See " + Self.duckDuckGoMorePrivacyInfo.absoluteString - headers[Header.client] = "macOS" + // Needs to be updated/generalised when fully adopted by iOS + if let source { + switch source { + case Source.iOS.rawValue: + headers[Header.client] = "iOS" + case Source.iPadOS.rawValue: + headers[Header.client] = "iPadOS" + case Source.macDMG.rawValue, Source.macStore.rawValue: + headers[Header.client] = "macOS" + default: + headers[Header.client] = "macOS" + } + } // The event name can't contain `.` reportErrorIf(pixel: pixelName, contains: ".") switch frequency { case .standard: - reportErrorIf(pixel: pixelName, endsWith: "_u") - reportErrorIf(pixel: pixelName, endsWith: "_d") - fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete) + handleStandardFrequency(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete) case .legacyInitial: - reportErrorIf(pixel: pixelName, endsWith: "_u") - reportErrorIf(pixel: pixelName, endsWith: "_d") - if !pixelHasBeenFiredEver(pixelName) { - fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete) - updatePixelLastFireDate(pixelName: pixelName) - } else { - printDebugInfo(pixelName: pixelName, frequency: frequency, parameters: newParams, skipped: true) - } - case .unique: - reportErrorIf(pixel: pixelName, endsWith: "_d") - guard pixelName.hasSuffix("_u") else { - assertionFailure("Unique pixel: must end with _u") - onComplete(false, nil) - return - } - if !pixelHasBeenFiredEver(pixelName) { - fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete) - updatePixelLastFireDate(pixelName: pixelName) - } else { - printDebugInfo(pixelName: pixelName, frequency: frequency, parameters: newParams, skipped: true) - } + handleLegacyInitial(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete) + case .uniqueByName: + handleUnique(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete) + case .uniqueByNameAndParameters: + handleUniqueByNameAndParameters(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete) case .legacyDaily: - reportErrorIf(pixel: pixelName, endsWith: "_u") - reportErrorIf(pixel: pixelName, endsWith: "_d") - if !pixelHasBeenFiredToday(pixelName) { - fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete) - updatePixelLastFireDate(pixelName: pixelName) - } else { - printDebugInfo(pixelName: pixelName, frequency: frequency, parameters: newParams, skipped: true) - } + handleLegacyDaily(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete) case .daily: - reportErrorIf(pixel: pixelName, endsWith: "_u") - reportErrorIf(pixel: pixelName, endsWith: "_d") // Because is added automatically - if !pixelHasBeenFiredToday(pixelName) { - fireRequestWrapper(pixelName + "_d", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete) - updatePixelLastFireDate(pixelName: pixelName) - } else { - printDebugInfo(pixelName: pixelName + "_d", frequency: frequency, parameters: newParams, skipped: true) - } + handleDaily(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete) case .legacyDailyAndCount: - reportErrorIf(pixel: pixelName, endsWith: "_u") - reportErrorIf(pixel: pixelName, endsWith: "_d") // Because is added automatically - reportErrorIf(pixel: pixelName, endsWith: "_c") // Because is added automatically - if !pixelHasBeenFiredToday(pixelName) { - fireRequestWrapper(pixelName + "_d", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete) - updatePixelLastFireDate(pixelName: pixelName) - } else { - printDebugInfo(pixelName: pixelName + "_d", frequency: frequency, parameters: newParams, skipped: true) - } - - fireRequestWrapper(pixelName + "_c", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete) + handleLegacyDailyAndCount(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete) case .dailyAndCount: - reportErrorIf(pixel: pixelName, endsWith: "_u") - reportErrorIf(pixel: pixelName, endsWith: "_daily") // Because is added automatically - reportErrorIf(pixel: pixelName, endsWith: "_count") // Because is added automatically - if !pixelHasBeenFiredToday(pixelName) { - fireRequestWrapper(pixelName + "_daily", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete) - updatePixelLastFireDate(pixelName: pixelName) - } else { - printDebugInfo(pixelName: pixelName + "_daily", frequency: frequency, parameters: newParams, skipped: true) - } + handleDailyAndCount(pixelName, headers, newParams, allowedQueryReservedCharacters, onComplete) + } + } + + private func handleStandardFrequency(_ pixelName: String, + _ headers: [String: String], + _ params: [String: String], + _ allowedQueryReservedCharacters: CharacterSet?, + _ onComplete: @escaping CompletionBlock) { + reportErrorIf(pixel: pixelName, endsWith: "_u") + reportErrorIf(pixel: pixelName, endsWith: "_d") + fireRequestWrapper(pixelName, headers, params, allowedQueryReservedCharacters, true, .standard, onComplete) + } + + private func handleLegacyInitial(_ pixelName: String, + _ headers: [String: String], + _ newParams: [String: String], + _ allowedQueryReservedCharacters: CharacterSet?, + _ onComplete: @escaping CompletionBlock) { + reportErrorIf(pixel: pixelName, endsWith: "_u") + reportErrorIf(pixel: pixelName, endsWith: "_d") + if !pixelHasBeenFiredEver(pixelName) { + fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, .legacyInitial, onComplete) + updatePixelLastFireDate(pixelName: pixelName) + } else { + printDebugInfo(pixelName: pixelName, frequency: .legacyInitial, parameters: newParams, skipped: true) + } + } + + private func handleUnique(_ pixelName: String, + _ headers: [String: String], + _ newParams: [String: String], + _ allowedQueryReservedCharacters: CharacterSet?, + _ onComplete: @escaping CompletionBlock) { + reportErrorIf(pixel: pixelName, endsWith: "_d") + guard pixelName.hasSuffix("_u") else { + assertionFailure("Unique pixel: must end with _u") + onComplete(false, nil) + return + } + if !pixelHasBeenFiredEver(pixelName) { + fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, .uniqueByName, onComplete) + updatePixelLastFireDate(pixelName: pixelName) + } else { + printDebugInfo(pixelName: pixelName, frequency: .uniqueByName, parameters: newParams, skipped: true) + } + } + + private func handleUniqueByNameAndParameters(_ pixelName: String, + _ headers: [String: String], + _ newParams: [String: String], + _ allowedQueryReservedCharacters: CharacterSet?, + _ onComplete: @escaping CompletionBlock) { + let pixelNameAndParams = pixelName + newParams.toString() + if !pixelHasBeenFiredEver(pixelNameAndParams) { + fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, .uniqueByNameAndParameters, onComplete) + updatePixelLastFireDate(pixelName: pixelNameAndParams) + } else { + printDebugInfo(pixelName: pixelName, frequency: .uniqueByNameAndParameters, parameters: newParams, skipped: true) + } + } + + private func handleLegacyDaily(_ pixelName: String, + _ headers: [String: String], + _ newParams: [String: String], + _ allowedQueryReservedCharacters: CharacterSet?, + _ onComplete: @escaping CompletionBlock) { + reportErrorIf(pixel: pixelName, endsWith: "_u") + reportErrorIf(pixel: pixelName, endsWith: "_d") + if !pixelHasBeenFiredToday(pixelName) { + fireRequestWrapper(pixelName, headers, newParams, allowedQueryReservedCharacters, true, .legacyDaily, onComplete) + updatePixelLastFireDate(pixelName: pixelName) + } else { + printDebugInfo(pixelName: pixelName, frequency: .legacyDaily, parameters: newParams, skipped: true) + } + } + + private func handleDaily(_ pixelName: String, + _ headers: [String: String], + _ newParams: [String: String], + _ allowedQueryReservedCharacters: CharacterSet?, + _ onComplete: @escaping CompletionBlock) { + reportErrorIf(pixel: pixelName, endsWith: "_u") + reportErrorIf(pixel: pixelName, endsWith: "_d") // Because is added automatically + if !pixelHasBeenFiredToday(pixelName) { + fireRequestWrapper(pixelName + "_d", headers, newParams, allowedQueryReservedCharacters, true, .daily, onComplete) + updatePixelLastFireDate(pixelName: pixelName) + } else { + printDebugInfo(pixelName: pixelName + "_d", frequency: .daily, parameters: newParams, skipped: true) + } + } + + private func handleLegacyDailyAndCount(_ pixelName: String, + _ headers: [String: String], + _ newParams: [String: String], + _ allowedQueryReservedCharacters: CharacterSet?, + _ onComplete: @escaping CompletionBlock) { + reportErrorIf(pixel: pixelName, endsWith: "_u") + reportErrorIf(pixel: pixelName, endsWith: "_d") // Because is added automatically + reportErrorIf(pixel: pixelName, endsWith: "_c") // Because is added automatically + if !pixelHasBeenFiredToday(pixelName) { + fireRequestWrapper(pixelName + "_d", headers, newParams, allowedQueryReservedCharacters, true, .legacyDailyAndCount, onComplete) + updatePixelLastFireDate(pixelName: pixelName) + } else { + printDebugInfo(pixelName: pixelName + "_d", frequency: .legacyDailyAndCount, parameters: newParams, skipped: true) + } - fireRequestWrapper(pixelName + "_count", headers, newParams, allowedQueryReservedCharacters, true, frequency, onComplete) + fireRequestWrapper(pixelName + "_c", headers, newParams, allowedQueryReservedCharacters, true, .legacyDailyAndCount, onComplete) + } + + private func handleDailyAndCount(_ pixelName: String, + _ headers: [String: String], + _ newParams: [String: String], + _ allowedQueryReservedCharacters: CharacterSet?, + _ onComplete: @escaping CompletionBlock) { + reportErrorIf(pixel: pixelName, endsWith: "_u") + reportErrorIf(pixel: pixelName, endsWith: "_daily") // Because is added automatically + reportErrorIf(pixel: pixelName, endsWith: "_count") // Because is added automatically + if !pixelHasBeenFiredToday(pixelName) { + fireRequestWrapper(pixelName + "_daily", headers, newParams, allowedQueryReservedCharacters, true, .dailyAndCount, onComplete) + updatePixelLastFireDate(pixelName: pixelName) + } else { + printDebugInfo(pixelName: pixelName + "_daily", frequency: .dailyAndCount, parameters: newParams, skipped: true) } + + fireRequestWrapper(pixelName + "_count", headers, newParams, allowedQueryReservedCharacters, true, .dailyAndCount, onComplete) } /// If the pixel name ends with the forbiddenString then an error is logged or an assertion failure is fired in debug @@ -307,7 +396,11 @@ public final class PixelKit { fireRequest(pixelName, headers, parameters, allowedQueryReservedCharacters, callBackOnMainThread, onComplete) } - private func prefixedName(for event: Event) -> String { + // Only set up for macOS and for Experiments + private func prefixedAndSuffixedName(for event: Event) -> String { + if event.name.hasPrefix("experiment") { + return addPlatformSuffix(to: event.name) + } if event.name.hasPrefix("m_mac_") { // Can be a debug event or not, if already prefixed the name remains unchanged return event.name @@ -322,6 +415,22 @@ public final class PixelKit { } } + private func addPlatformSuffix(to name: String) -> String { + if let source { + switch source { + case Source.iOS.rawValue: + return "\(name)_ios_phone" + case Source.iPadOS.rawValue: + return "\(name)_ios_tablet" + case Source.macStore.rawValue, Source.macDMG.rawValue: + return "\(name)_mac" + default: + return name + } + } + return name + } + public func fire(_ event: Event, frequency: Frequency = .standard, withHeaders headers: [String: String]? = nil, @@ -331,13 +440,13 @@ public final class PixelKit { includeAppVersionParameter: Bool = true, onComplete: @escaping CompletionBlock = { _, _ in }) { - let pixelName = prefixedName(for: event) + let pixelName = prefixedAndSuffixedName(for: event) if !dryRun { if frequency == .daily, pixelHasBeenFiredToday(pixelName) { onComplete(false, nil) return - } else if frequency == .unique, pixelHasBeenFiredEver(pixelName) { + } else if frequency == .uniqueByName, pixelHasBeenFiredEver(pixelName) { onComplete(false, nil) return } @@ -355,6 +464,14 @@ public final class PixelKit { newParams = nil } + if !dryRun, let newParams { + let pixelNameAndParams = pixelName + newParams.toString() + if frequency == .uniqueByNameAndParameters, pixelHasBeenFiredEver(pixelNameAndParams) { + onComplete(false, nil) + return + } + } + let newError: Error? if let event = event as? PixelKitEventV2, @@ -430,7 +547,7 @@ public final class PixelKit { } public func pixelLastFireDate(event: Event) -> Date? { - pixelLastFireDate(pixelName: prefixedName(for: event)) + pixelLastFireDate(pixelName: prefixedAndSuffixedName(for: event)) } private func updatePixelLastFireDate(pixelName: String) { diff --git a/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift b/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift index 371bd3c42..8ae6efb40 100644 --- a/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift +++ b/Sources/PixelKitTestingUtilities/XCTestCase+PixelKit.swift @@ -149,7 +149,7 @@ public extension XCTestCase { expectedPixelNames.append(originalName) case .legacyInitial: expectedPixelNames.append(originalName) - case .unique: + case .uniqueByName: expectedPixelNames.append(originalName) case .legacyDaily: expectedPixelNames.append(originalName) @@ -161,6 +161,8 @@ public extension XCTestCase { case .dailyAndCount: expectedPixelNames.append(originalName.appending("_daily")) expectedPixelNames.append(originalName.appending("_count")) + case .uniqueByNameAndParameters: + expectedPixelNames.append(originalName) } return expectedPixelNames } diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillTestHelper.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillTestHelper.swift index 67e825e89..f0d7dd597 100644 --- a/Tests/BrowserServicesKitTests/Autofill/AutofillTestHelper.swift +++ b/Tests/BrowserServicesKitTests/Autofill/AutofillTestHelper.swift @@ -31,7 +31,7 @@ struct AutofillTestHelper { fetchedData: nil, embeddedDataProvider: mockEmbeddedData, localProtection: MockDomainsProtectionStore(), - internalUserDecider: DefaultInternalUserDecider()) + internalUserDecider: DefaultInternalUserDecider()) return manager } } diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift index 6f5fffbd4..5687c5267 100644 --- a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift @@ -309,7 +309,7 @@ final class DefaultFeatureFlaggerTests: XCTestCase { fetchedData: nil, embeddedDataProvider: mockEmbeddedData, localProtection: MockDomainsProtectionStore(), - internalUserDecider: DefaultInternalUserDecider()) + internalUserDecider: DefaultInternalUserDecider()) let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore) return DefaultFeatureFlagger(internalUserDecider: internalUserDecider, privacyConfigManager: manager, experimentManager: experimentManager) } @@ -320,7 +320,7 @@ final class DefaultFeatureFlaggerTests: XCTestCase { fetchedData: nil, embeddedDataProvider: mockEmbeddedData, localProtection: MockDomainsProtectionStore(), - internalUserDecider: DefaultInternalUserDecider()) + internalUserDecider: DefaultInternalUserDecider()) let internalUserDecider = DefaultInternalUserDecider(store: internalUserDeciderStore) overrides = CapturingFeatureFlagOverriding() diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift index 48fc85355..7a2be7abc 100644 --- a/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/ExperimentCohortsManagerTests.swift @@ -41,6 +41,9 @@ final class ExperimentCohortsManagerTests: XCTestCase { let subfeatureName4 = "TestSubfeature4" var experimentData4: ExperimentData! + var firedSubfeatureID: SubfeatureID? + var firedExperimentData: ExperimentData? + let encoder: JSONEncoder = { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .secondsSince1970 @@ -50,8 +53,12 @@ final class ExperimentCohortsManagerTests: XCTestCase { override func setUp() { super.setUp() mockStore = MockExperimentDataStore() + experimentCohortsManager = ExperimentCohortsManager( - store: mockStore + store: mockStore, fireCohortAssigned: {subfeatureID, experimentData in + self.firedSubfeatureID = subfeatureID + self.firedExperimentData = experimentData + } ) let expectedDate1 = Date() @@ -87,6 +94,8 @@ final class ExperimentCohortsManagerTests: XCTestCase { XCTAssertEqual(experiments?[subfeatureName1], experimentData1) XCTAssertEqual(experiments?[subfeatureName2], experimentData2) XCTAssertNil(experiments?[subfeatureName3]) + XCTAssertNil(firedSubfeatureID) + XCTAssertNil(firedExperimentData) } func testCohortReturnsCohortIDIfExistsForMultipleSubfeatures() { @@ -100,6 +109,8 @@ final class ExperimentCohortsManagerTests: XCTestCase { // THEN XCTAssertEqual(result1, experimentData1.cohortID) XCTAssertEqual(result2, experimentData2.cohortID) + XCTAssertNil(firedSubfeatureID) + XCTAssertNil(firedExperimentData) } func testCohortAssignIfEnabledWhenNoCohortExists() { @@ -114,6 +125,10 @@ final class ExperimentCohortsManagerTests: XCTestCase { // THEN XCTAssertNotNil(result) XCTAssertEqual(result, experimentData1.cohortID) + XCTAssertEqual(firedSubfeatureID, subfeatureName1) + XCTAssertEqual(firedExperimentData?.cohortID, experimentData1.cohortID) + XCTAssertEqual(firedExperimentData?.parentID, experimentData1.parentID) + XCTAssertEqual(firedExperimentData?.enrollmentDate.daySinceReferenceDate, experimentData1.enrollmentDate.daySinceReferenceDate) } func testCohortDoesNotAssignIfAssignIfEnabledIsFalse() { @@ -127,6 +142,8 @@ final class ExperimentCohortsManagerTests: XCTestCase { // THEN XCTAssertNil(result) + XCTAssertNil(firedSubfeatureID) + XCTAssertNil(firedExperimentData) } func testCohortDoesNotAssignIfAssignIfEnabledIsTrueButNoCohortsAvailable() { @@ -139,6 +156,8 @@ final class ExperimentCohortsManagerTests: XCTestCase { // THEN XCTAssertNil(result) + XCTAssertNil(firedSubfeatureID) + XCTAssertNil(firedExperimentData) } func testCohortReassignsCohortIfAssignedCohortDoesNotExistAndAssignIfEnabledIsTrue() { @@ -150,6 +169,10 @@ final class ExperimentCohortsManagerTests: XCTestCase { // THEN XCTAssertEqual(result1, experimentData3.cohortID) + XCTAssertEqual(firedSubfeatureID, subfeatureName1) + XCTAssertEqual(firedExperimentData?.cohortID, experimentData3.cohortID) + XCTAssertEqual(firedExperimentData?.parentID, experimentData3.parentID) + XCTAssertEqual(firedExperimentData?.enrollmentDate.daySinceReferenceDate, experimentData3.enrollmentDate.daySinceReferenceDate) } func testCohortDoesNotReassignsCohortIfAssignedCohortDoesNotExistAndAssignIfEnabledIsTrue() { @@ -161,6 +184,8 @@ final class ExperimentCohortsManagerTests: XCTestCase { // THEN XCTAssertNil(result1) + XCTAssertNil(firedSubfeatureID) + XCTAssertNil(firedExperimentData) } func testCohortAssignsBasedOnWeight() { @@ -173,7 +198,7 @@ final class ExperimentCohortsManagerTests: XCTestCase { experimentCohortsManager = ExperimentCohortsManager( store: mockStore, - randomizer: randomizer + randomizer: randomizer, fireCohortAssigned: { _, _ in } ) // WHEN diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift index b6931ef22..933eb11b3 100644 --- a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlaggerExperimentsTests.swift @@ -81,7 +81,7 @@ final class FeatureFlaggerExperimentsTests: XCTestCase { mockEmbeddedData = MockEmbeddedDataProvider(data: featureJson, etag: "test") let mockInternalUserStore = MockInternalUserStoring() mockStore = MockExperimentDataStore() - experimentManager = ExperimentCohortsManager(store: mockStore) + experimentManager = ExperimentCohortsManager(store: mockStore, fireCohortAssigned: { _, _ in }) manager = PrivacyConfigurationManager(fetchedETag: nil, fetchedData: nil, embeddedDataProvider: mockEmbeddedData, diff --git a/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift b/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift new file mode 100644 index 000000000..30334f6e6 --- /dev/null +++ b/Tests/PixelExperimentKitTests/PixelExperimentKitTests.swift @@ -0,0 +1,556 @@ +// +// PixelExperimentKitTests.swift +// +// 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 +@testable import PixelExperimentKit +@testable import BrowserServicesKit +import PixelKit +import Combine + +final class PixelExperimentKitTests: XCTestCase { + var featureJson: Data = "{}".data(using: .utf8)! + var mockPixelStore: MockExperimentActionPixelStore! + var mockFeatureFlagger: MockFeatureFlagger! + var firedEventSet = Set() + var firedEvent = [PixelKitEvent]() + var firedFrequency = [PixelKit.Frequency]() + var firedIncludeAppVersion = [Bool]() + + override func setUp() { + super.setUp() + mockPixelStore = MockExperimentActionPixelStore() + mockFeatureFlagger = MockFeatureFlagger() + PixelKit.configureExperimentKit(featureFlagger: mockFeatureFlagger, eventTracker: ExperimentEventTracker(store: mockPixelStore), fire: { event, frequency, includeAppVersion in + self.firedEventSet.insert(event.name + "_" + (event.parameters?.toString() ?? "")) + self.firedEvent.append(event) + self.firedFrequency.append(frequency) + self.firedIncludeAppVersion.append(includeAppVersion) + }) + } + + override func tearDown() { + mockPixelStore = nil + mockFeatureFlagger = nil + firedEvent = [] + firedFrequency = [] + firedIncludeAppVersion = [] + } + + func testFireExperimentEnrollmentPixelSendsExpectedData() { + // GIVEN + let subfeatureID = "testSubfeature" + let cohort = "A" + let enrollmentDate = Date(timeIntervalSince1970: 0) + let experimentData = ExperimentData(parentID: "parent", cohortID: cohort, enrollmentDate: enrollmentDate) + let expectedEventName = "experiment_enroll_\(subfeatureID)_\(cohort)" + let expectedParameters = ["enrollmentDate": enrollmentDate.toYYYYMMDDInET()] + + // WHEN + PixelKit.fireExperimentEnrollmentPixel(subfeatureID: subfeatureID, experiment: experimentData) + + // THEN + XCTAssertEqual(firedEvent[0].name, expectedEventName) + XCTAssertEqual(firedEvent[0].parameters, expectedParameters) + XCTAssertEqual(firedFrequency[0], .uniqueByNameAndParameters) + XCTAssertFalse(firedIncludeAppVersion[0]) + } + + func testFireExperimentPixel_WithValidExperimentAndConversionWindowAndValueNotNumber() { + // GIVEN + + let subfeatureID = "credentialsSaving" + let cohort = "control" + let enrollmentDate = Date().addingTimeInterval(-3 * 24 * 60 * 60) // 5 days ago + let conversionWindow = 3...3 + let value = "true" + let expectedEventName = "experiment_metrics_\(subfeatureID)_\(cohort)" + let expectedParameters = [ + "metric": "someMetric", + "conversionWindowDays": "3", + "value": value, + "enrollmentDate": enrollmentDate.toYYYYMMDDInET() + ] + let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate) + mockFeatureFlagger.experiments = [subfeatureID: experimentData] + + // WHEN + PixelKit.fireExperimentPixel(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, value: value) + + // THEN + XCTAssertEqual(firedEvent[0].name, expectedEventName) + XCTAssertEqual(firedEvent[0].parameters, expectedParameters) + XCTAssertEqual(firedFrequency[0], .uniqueByNameAndParameters) + XCTAssertFalse(firedIncludeAppVersion[0]) + XCTAssertEqual(mockPixelStore.store.count, 0) + } + + func testFireExperimentPixel_WithValidExperimentAndConversionWindowAndValue1() { + // GIVEN + let subfeatureID = "credentialsSaving" + let cohort = "control" + let enrollmentDate = Date().addingTimeInterval(-3 * 24 * 60 * 60) // 5 days ago + let conversionWindow = 3...7 + let value = "1" + let expectedEventName = "experiment_metrics_\(subfeatureID)_\(cohort)" + let expectedParameters = [ + "metric": "someMetric", + "conversionWindowDays": "3-7", + "value": value, + "enrollmentDate": enrollmentDate.toYYYYMMDDInET() + ] + let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate) + mockFeatureFlagger.experiments = [subfeatureID: experimentData] + + // WHEN + PixelKit.fireExperimentPixel(for: subfeatureID, metric: "someMetric", conversionWindowDays: conversionWindow, value: value) + + // THEN + XCTAssertEqual(firedEvent[0].name, expectedEventName) + XCTAssertEqual(firedEvent[0].parameters, expectedParameters) + XCTAssertEqual(firedFrequency[0], .uniqueByNameAndParameters) + XCTAssertFalse(firedIncludeAppVersion[0]) + XCTAssertEqual(mockPixelStore.store.count, 0) + } + + func testFireExperimentPixel_WithValidExperimentAndConversionWindowAndValueN() { + // GIVEN + let subfeatureID = "credentialsSaving" + let cohort = "control" + let enrollmentDate = Date().addingTimeInterval(-7 * 24 * 60 * 60) // 5 days ago + let conversionWindow = 3...7 + let randomNumber = Int.random(in: 1...100) + let value = "\(randomNumber)" + let expectedEventName = "experiment_metrics_\(subfeatureID)_\(cohort)" + let expectedParameters = [ + "metric": "someMetric", + "conversionWindowDays": "3-7", + "value": value, + "enrollmentDate": enrollmentDate.toYYYYMMDDInET() + ] + let experimentData = ExperimentData(parentID: "autofill", cohortID: cohort, enrollmentDate: enrollmentDate) + mockFeatureFlagger.experiments = [subfeatureID: experimentData] + + // WHEN calling fire before expected number of calls + for n in 1.. Int { + return store[defaultName] ?? 0 + } + + func set(_ value: Int, forKey defaultName: String) { + store[defaultName] = value + } +} + +class MockFeatureFlagger: FeatureFlagger { + var experiments: Experiments = [:] + + var internalUserDecider: any InternalUserDecider = MockInternalUserDecider() + + var localOverrides: (any BrowserServicesKit.FeatureFlagLocalOverriding)? + + func getCohortIfEnabled(for featureFlag: Flag) -> (any FlagCohort)? where Flag: FeatureFlagExperimentDescribing { + return nil + } + + func getAllActiveExperiments() -> Experiments { + return experiments + } + + func isFeatureOn(for featureFlag: Flag, allowOverride: Bool) -> Bool where Flag: FeatureFlagDescribing { + return false + } +} + +final class MockInternalUserDecider: InternalUserDecider { + var isInternalUser: Bool = false + + var isInternalUserPublisher: AnyPublisher { + Just(false).eraseToAnyPublisher() + } + + func markUserAsInternalIfNeeded(forUrl url: URL?, response: HTTPURLResponse?) -> Bool { + return false + } +} diff --git a/Tests/PixelKitTests/PixelKitTests.swift b/Tests/PixelKitTests/PixelKitTests.swift index 2a6b98acc..5225fbdf4 100644 --- a/Tests/PixelKitTests/PixelKitTests.swift +++ b/Tests/PixelKitTests/PixelKitTests.swift @@ -82,7 +82,7 @@ final class PixelKitTests: XCTestCase { case .testEvent, .testEventWithoutParameters, .nameWithDot: return .standard case .uniqueEvent: - return .unique + return .uniqueByName case .dailyEvent, .dailyEventWithoutParameters: return .daily case .dailyAndContinuousEvent, .dailyAndContinuousEventWithoutParameters: @@ -209,6 +209,7 @@ final class PixelKitTests: XCTestCase { // Prepare mock to validate expectations let pixelKit = PixelKit(dryRun: false, appVersion: appVersion, + source: PixelKit.Source.macDMG.rawValue, defaultHeaders: headers, dailyPixelCalendar: nil, defaults: userDefaults) { firedPixelName, firedHeaders, parameters, _, _, _ in @@ -254,6 +255,7 @@ final class PixelKitTests: XCTestCase { // Prepare mock to validate expectations let pixelKit = PixelKit(dryRun: false, appVersion: appVersion, + source: PixelKit.Source.macDMG.rawValue, defaultHeaders: headers, dailyPixelCalendar: nil, defaults: userDefaults) { firedPixelName, firedHeaders, parameters, _, _, _ in @@ -300,6 +302,7 @@ final class PixelKitTests: XCTestCase { // Prepare mock to validate expectations let pixelKit = PixelKit(dryRun: false, appVersion: appVersion, + source: PixelKit.Source.macDMG.rawValue, defaultHeaders: headers, dailyPixelCalendar: nil, defaults: userDefaults) { firedPixelName, firedHeaders, parameters, _, _, _ in @@ -397,19 +400,69 @@ final class PixelKitTests: XCTestCase { } // Run test - pixelKit.fire(event, frequency: .unique) // Fired + pixelKit.fire(event, frequency: .uniqueByName) // Fired timeMachine.travel(by: .hour, value: 2) - pixelKit.fire(event, frequency: .unique) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByName) // Skipped (already fired) timeMachine.travel(by: .day, value: 1) timeMachine.travel(by: .hour, value: 2) - pixelKit.fire(event, frequency: .unique) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByName) // Skipped (already fired) timeMachine.travel(by: .hour, value: 10) - pixelKit.fire(event, frequency: .unique) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByName) // Skipped (already fired) timeMachine.travel(by: .day, value: 1) - pixelKit.fire(event, frequency: .unique) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByName) // Skipped (already fired) + + // Wait for expectations to be fulfilled + wait(for: [fireCallbackCalled], timeout: 0.5) + } + + func testUniqueNyNameAndParameterPixel() { + // Prepare test parameters + let appVersion = "1.0.5" + let headers = ["a": "2", "b": "3", "c": "2000"] + let event = TestEventV2.uniqueEvent + let userDefaults = userDefaults() + + let timeMachine = TimeMachine() + + // Set expectations + let fireCallbackCalled = expectation(description: "Expect the pixel firing callback to be called") + fireCallbackCalled.expectedFulfillmentCount = 3 + fireCallbackCalled.assertForOverFulfill = true + + let pixelKit = PixelKit(dryRun: false, + appVersion: appVersion, + defaultHeaders: headers, + dailyPixelCalendar: nil, + dateGenerator: timeMachine.now, + defaults: userDefaults) { _, _, _, _, _, _ in + fireCallbackCalled.fulfill() + } + + // Run test + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Fired + timeMachine.travel(by: .hour, value: 2) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["b": "200"]) // Fired + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Skipped (already fired) + + timeMachine.travel(by: .day, value: 1) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100", "c": "300"]) // Fired + timeMachine.travel(by: .hour, value: 2) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["b": "200"]) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["c": "300", "a": "100"]) // Skipped (already fired) + + timeMachine.travel(by: .hour, value: 10) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["b": "200"]) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100", "c": "300"]) // Skipped (already fired) + + timeMachine.travel(by: .day, value: 1) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100"]) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["b": "200"]) // Skipped (already fired) + pixelKit.fire(event, frequency: .uniqueByNameAndParameters, withAdditionalParameters: ["a": "100", "c": "300"]) // Skipped (already fired) // Wait for expectations to be fulfilled wait(for: [fireCallbackCalled], timeout: 0.5)