import Foundation @objc public protocol ABTestsPersisting: AnyObject { func libraryValue(for key: String) -> NSCoding? func setLibraryValue(_ value: NSCoding?, for key: String) } @objc(WMFABTestsController) public class ABTestsController: NSObject { enum ABTestsError: Error { case invalidPercentage } struct ExperimentConfig { let experiment: Experiment let percentageKey: PercentageKey let bucketKey: BucketKey let bucketValueControl: BucketValue let bucketValueTest: BucketValue } public enum Experiment { case articleAsLivingDoc var config: ExperimentConfig { switch self { case .articleAsLivingDoc: return ABTestsController.articleAsLivingDocConfig } } } public enum PercentageKey: String { case articleAsLivingDocPercentKey } enum BucketKey: String { case articleAsLivingDocBucketKey } public enum BucketValue: String { case articleAsLivingDocTest = "LivingDoc_Test" case articleAsLivingDocControl = "LivingDoc_Control" } private static let articleAsLivingDocConfig = ExperimentConfig(experiment: .articleAsLivingDoc, percentageKey: .articleAsLivingDocPercentKey, bucketKey: .articleAsLivingDocBucketKey, bucketValueControl: .articleAsLivingDocControl, bucketValueTest: .articleAsLivingDocTest) private let persistanceService: ABTestsPersisting @objc public init(persistanceService: ABTestsPersisting) { self.persistanceService = persistanceService super.init() } // this will only generate a new bucket as needed (i.e. if the percentage is different than the last time bucket was generated) @discardableResult func determineBucketForExperiment(_ experiment: Experiment, withPercentage percentage: NSNumber) throws -> BucketValue { guard percentage.intValue >= 0 && percentage.intValue <= 100 else { throw ABTestsError.invalidPercentage } // if we have previously generated a bucket with the same percentage, return that value let maybeOldPercentage = percentageForExperiment(experiment) let maybeOldBucket = bucketForExperiment(experiment) if let oldPercentage = maybeOldPercentage, let oldBucket = maybeOldBucket, oldPercentage == percentage { return oldBucket } // otherwise generate new bucket let randomInt = Int.random(in: 1...100) let isInTest = randomInt <= percentage.intValue let bucket: BucketValue switch experiment { case .articleAsLivingDoc: bucket = isInTest ? .articleAsLivingDocTest : .articleAsLivingDocControl } setBucket(bucket, forExperiment: experiment) try setPercentage(percentage, forExperiment: experiment) return bucket } // MARK: Persistence setters/getters func percentageForExperiment(_ experiment: Experiment) -> NSNumber? { let key = experiment.config.percentageKey.rawValue return persistanceService.libraryValue(for: key) as? NSNumber } func setPercentage(_ percentage: NSNumber, forExperiment experiment: Experiment) throws { guard percentage.intValue >= 0 && percentage.intValue <= 100 else { throw ABTestsError.invalidPercentage } let key = experiment.config.percentageKey.rawValue persistanceService.setLibraryValue(percentage, for: key) } public func bucketForExperiment(_ experiment: Experiment) -> BucketValue? { let key = experiment.config.bucketKey.rawValue guard let rawValue = persistanceService.libraryValue(for: key) as? String else { return nil } return BucketValue(rawValue: rawValue) } func setBucket(_ bucket: BucketValue, forExperiment experiment: Experiment) { let key = experiment.config.bucketKey.rawValue persistanceService.setLibraryValue((bucket.rawValue as NSString), for: key) } }