diff --git a/OptimizelySwiftSDK.xcodeproj/project.pbxproj b/OptimizelySwiftSDK.xcodeproj/project.pbxproj index e9c921b8..db67fedf 100644 --- a/OptimizelySwiftSDK.xcodeproj/project.pbxproj +++ b/OptimizelySwiftSDK.xcodeproj/project.pbxproj @@ -2034,6 +2034,18 @@ 984FE51E2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; 984FE51F2CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; 984FE5202CC8AA88004F6F41 /* UserProfileTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */; }; + 989428B32DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B42DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B52DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B62DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B72DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B82DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428B92DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BA2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BB2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BC2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BD2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; + 989428BE2DBFA431008BA1C8 /* MockBucketer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 989428B22DBFA431008BA1C8 /* MockBucketer.swift */; }; 98AC97E22DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; 98AC97E32DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; 98AC97E42DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; @@ -2052,6 +2064,14 @@ 98AC97F12DAE4579001405DD /* HoldoutConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */; }; 98AC97F32DAE9685001405DD /* HoldoutConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */; }; 98AC97F42DAE9685001405DD /* HoldoutConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */; }; + 98AC98462DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */; }; + 98AC98472DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */; }; + 98AC98492DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */; }; + 98AC984B2DB8FFE0001405DD /* DecisionServiceTests_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */; }; + 98AC985E2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; }; + 98AC985F2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; }; + 98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; }; + 98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; }; BD1C3E8524E4399C0084B4DA /* SemanticVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B97DD93249D327F003DE606 /* SemanticVersion.swift */; }; BD64853C2491474500F30986 /* Optimizely.h in Headers */ = {isa = PBXBuildFile; fileRef = 6E75167A22C520D400B2B157 /* Optimizely.h */; settings = {ATTRIBUTES = (Public, ); }; }; BD64853E2491474500F30986 /* Audience.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E75169822C520D400B2B157 /* Audience.swift */; }; @@ -2495,8 +2515,13 @@ 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = ""; }; 984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = ""; }; 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 989428B22DBFA431008BA1C8 /* MockBucketer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBucketer.swift; sourceTree = ""; }; 98AC97E12DAE4579001405DD /* HoldoutConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfig.swift; sourceTree = ""; }; 98AC97F22DAE9685001405DD /* HoldoutConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutConfigTests.swift; sourceTree = ""; }; + 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_HoldoutToVariation.swift; sourceTree = ""; }; + 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_Holdouts.swift; sourceTree = ""; }; + 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift; sourceTree = ""; }; + 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = ""; }; BD6485812491474500F30986 /* Optimizely.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Optimizely.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C78CAF572445AD8D009FE876 /* OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyJSON.swift; sourceTree = ""; }; C78CAF652446DB91009FE876 /* OptimizelyClientTests_OptimizelyJSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_OptimizelyJSON.swift; sourceTree = ""; }; @@ -3003,6 +3028,7 @@ 6E75198F22C5211100B2B157 /* BucketTests_BucketVariation.swift */, 6E75198C22C5211100B2B157 /* BucketTests_ExpToVariation.swift */, 6E75198322C5211100B2B157 /* BucketTests_GroupToExp.swift */, + 98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */, 6E75198422C5211100B2B157 /* BucketTests_Others.swift */, 6E75199622C5211100B2B157 /* DatafileHandlerTests.swift */, 6E75199822C5211100B2B157 /* DataStoreTests.swift */, @@ -3011,6 +3037,7 @@ 6E27ECBD266FD78600B4A6D4 /* DecisionReasonsTests.swift */, 6E75198022C5211100B2B157 /* DecisionServiceTests_Experiments.swift */, 6E75199122C5211100B2B157 /* DecisionServiceTests_Features.swift */, + 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */, 6E75199422C5211100B2B157 /* DecisionServiceTests_Others.swift */, 6E75198622C5211100B2B157 /* DecisionServiceTests_UserProfiles.swift */, 6E75198822C5211100B2B157 /* DefaultLoggerTests.swift */, @@ -3031,7 +3058,9 @@ 6E75198122C5211100B2B157 /* OptimizelyErrorTests.swift */, 6EC6DD6824AE94820017D296 /* OptimizelyUserContextTests.swift */, 6E2D34B8250AD14000A0CDFE /* OptimizelyUserContextTests_Decide.swift */, + 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */, 6E7E9B362523F8BF009E4426 /* OptimizelyUserContextTests_Decide_Reasons.swift */, + 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */, 6EB97BCC24C89DFB00068883 /* OptimizelyUserContextTests_Decide_Legacy.swift */, 6E0A72D326C5B9AE00FF92B5 /* OptimizelyUserContextTests_ForcedDecisions.swift */, 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */, @@ -3101,6 +3130,7 @@ 6E7519B722C5211100B2B157 /* MockUrlSession.swift */, 6E5D121E2638DDF4000ABFC3 /* MockEventDispatcher.swift */, 6E20050726B4D28400278087 /* MockLogger.swift */, + 989428B22DBFA431008BA1C8 /* MockBucketer.swift */, ); path = TestUtils; sourceTree = ""; @@ -4198,6 +4228,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 989428BB2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E14CDAB2423F9EB00010234 /* MockUrlSession.swift in Sources */, 6E14CDAA2423F9C300010234 /* SDKVersion.swift in Sources */, 845945C3287758A100D13E11 /* OdpConfig.swift in Sources */, @@ -4334,6 +4365,7 @@ 6E424D0026324B620081004A /* EventForDispatch.swift in Sources */, 6E424D0126324B620081004A /* SemanticVersion.swift in Sources */, 6E424D0226324B620081004A /* Audience.swift in Sources */, + 989428B62DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E424D0326324B620081004A /* AttributeValue.swift in Sources */, 84E2E9482852A378001114AB /* VuidManager.swift in Sources */, 6E424D0426324B620081004A /* ConditionLeaf.swift in Sources */, @@ -4514,6 +4546,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 989428B32DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E75170222C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 6E7516BA22C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, 845945C7287758A300D13E11 /* OdpConfig.swift in Sources */, @@ -4710,6 +4743,7 @@ 6E9B11DA22C548A200C22D81 /* OptimizelyClientTests_ObjcAPIs.m in Sources */, 84518B21287737070023F104 /* OdpConfig.swift in Sources */, 6E75179A22C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */, + 989428B52DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E75182022C520D400B2B157 /* BatchEventBuilder.swift in Sources */, 6E5AB69323F6130D007A82B1 /* OptimizelyClientTests_Init_Sync.swift in Sources */, 6E4544B2270E67C800F2CEBC /* NetworkReachability.swift in Sources */, @@ -4836,6 +4870,7 @@ 6E7517A922C520D400B2B157 /* Array+Extension.swift in Sources */, 6E75186B22C520D400B2B157 /* Rollout.swift in Sources */, 6E75183B22C520D400B2B157 /* EventForDispatch.swift in Sources */, + 989428B42DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E75194322C520D500B2B157 /* OPTDecisionService.swift in Sources */, 84E2E97D2855875E001114AB /* OdpEventManager.swift in Sources */, 84861806286CF33700B7F41B /* OdpEvent.swift in Sources */, @@ -4882,6 +4917,7 @@ 6E9B116322C5487100C22D81 /* BucketTests_GroupToExp.swift in Sources */, 6E7516AF22C520D400B2B157 /* DefaultLogger.swift in Sources */, 6EF8DE2524BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, + 98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, 6E75194522C520D500B2B157 /* OPTDecisionService.swift in Sources */, 6E75185522C520D400B2B157 /* ProjectConfig.swift in Sources */, 84F6BAB427FCC5CF004BE62A /* OptimizelyUserContextTests_ODP.swift in Sources */, @@ -4901,6 +4937,7 @@ 6E75192D22C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516D322C520D400B2B157 /* OPTLogger.swift in Sources */, 6E75180122C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, + 98AC98472DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E75175722C520D400B2B157 /* LogMessage.swift in Sources */, 6E7516EB22C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, 6E75188522C520D400B2B157 /* TrafficAllocation.swift in Sources */, @@ -4954,6 +4991,7 @@ 6E27EC9C266EF11000B4A6D4 /* OptimizelyDecisionTests.swift in Sources */, 6E7518D922C520D400B2B157 /* AttributeValue.swift in Sources */, 6E9B116822C5487100C22D81 /* DefaultLoggerTests.swift in Sources */, + 98AC985F2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */, C78CAF622445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 6E75179322C520D400B2B157 /* OptimizelyClient+Extension.swift in Sources */, 6E9B117122C5487100C22D81 /* DecisionServiceTests_Features.swift in Sources */, @@ -4983,6 +5021,8 @@ 6E7516F722C520D400B2B157 /* OptimizelyError.swift in Sources */, 84861812286D0B8900B7F41B /* OdpSegmentManagerTests.swift in Sources */, 6E75189122C520D400B2B157 /* Project.swift in Sources */, + 98AC98492DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift in Sources */, + 989428B92DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E7517F522C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E0207A9272A11CF008C3711 /* NetworkReachabilityTests.swift in Sources */, 6E75183D22C520D400B2B157 /* EventForDispatch.swift in Sources */, @@ -5009,6 +5049,7 @@ 6E9B119C22C5488300C22D81 /* ProjectConfigTests.swift in Sources */, 980CC8FF2D833F0D00E07D24 /* Holdout.swift in Sources */, 6E7518FE22C520D500B2B157 /* UserAttribute.swift in Sources */, + 989428B82DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E7517F622C520D400B2B157 /* DataStoreMemory.swift in Sources */, 6E9B119322C5488300C22D81 /* AttributeTests.swift in Sources */, 845945C9287758A600D13E11 /* OdpConfig.swift in Sources */, @@ -5158,6 +5199,7 @@ 6E9B114922C5486E00C22D81 /* BucketTests_GroupToExp.swift in Sources */, 6E75182B22C520D400B2B157 /* BatchEvent.swift in Sources */, 6EF8DE1E24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, + 98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */, 6E75190322C520D500B2B157 /* Attribute.swift in Sources */, 6E75192722C520D500B2B157 /* DataStoreQueueStack.swift in Sources */, 6E7516F122C520D400B2B157 /* OptimizelyError.swift in Sources */, @@ -5166,6 +5208,7 @@ 6E75175D22C520D400B2B157 /* AtomicProperty.swift in Sources */, 6E7516D922C520D400B2B157 /* OPTUserProfileService.swift in Sources */, 6E7516E522C520D400B2B157 /* OPTEventDispatcher.swift in Sources */, + 98AC98462DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */, 6E7E9B552523F8C6009E4426 /* OptimizelyUserContextTests_Decide_Legacy.swift in Sources */, 6E652305278E688B00954EA1 /* LruCache.swift in Sources */, 6EC6DD3524ABF6990017D296 /* OptimizelyClient+Decide.swift in Sources */, @@ -5230,6 +5273,7 @@ 6E7518EB22C520D400B2B157 /* ConditionHolder.swift in Sources */, 6E27EC9B266EF11000B4A6D4 /* OptimizelyDecisionTests.swift in Sources */, 6E75176922C520D400B2B157 /* Utils.swift in Sources */, + 98AC985E2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */, 6E9B114E22C5486E00C22D81 /* DefaultLoggerTests.swift in Sources */, C78CAF5B2445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 6E7518C722C520D400B2B157 /* Audience.swift in Sources */, @@ -5260,6 +5304,7 @@ 6E75193F22C520D500B2B157 /* OPTDecisionService.swift in Sources */, 84E7ABC027D2A1F100447CAE /* ThreadSafeLogger.swift in Sources */, 6E7516CD22C520D400B2B157 /* OPTLogger.swift in Sources */, + 989428BD2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 8428D3D02807337400D0FB0C /* LruCacheTests.swift in Sources */, 84E2E9772855875E001114AB /* OdpEventManager.swift in Sources */, 6E7517FB22C520D400B2B157 /* DataStoreUserDefaults.swift in Sources */, @@ -5334,6 +5379,7 @@ 6EF8DE2124BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */, 8464087928130D3200CCF97D /* Integration.swift in Sources */, 6E9B118122C5488100C22D81 /* ConditionLeafTests.swift in Sources */, + 98AC984B2DB8FFE0001405DD /* DecisionServiceTests_Holdouts.swift in Sources */, 6E75184522C520D400B2B157 /* Event.swift in Sources */, 6E75191122C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */, 848617D12863DC2700B7F41B /* OdpSegmentManager.swift in Sources */, @@ -5356,6 +5402,7 @@ 6E75175F22C520D400B2B157 /* AtomicProperty.swift in Sources */, C78CAF5E2445AD8D009FE876 /* OptimizelyJSON.swift in Sources */, 6E7516B722C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, + 989428BC2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E623F09253F9045000617D0 /* DecisionInfo.swift in Sources */, 6E4544B3270E67C800F2CEBC /* NetworkReachability.swift in Sources */, 84E2E96A28540B5E001114AB /* OptimizelySdkSettings.swift in Sources */, @@ -5468,6 +5515,7 @@ 6E7518BE22C520D400B2B157 /* Variable.swift in Sources */, 6E7518CA22C520D400B2B157 /* Audience.swift in Sources */, 98AC97E62DAE4579001405DD /* HoldoutConfig.swift in Sources */, + 989428BA2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 848617E42863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E75187622C520D400B2B157 /* Variation.swift in Sources */, 6E7517F222C520D400B2B157 /* DataStoreMemory.swift in Sources */, @@ -5573,6 +5621,7 @@ 6E7518C322C520D400B2B157 /* Variable.swift in Sources */, 6E7518CF22C520D400B2B157 /* Audience.swift in Sources */, 98AC97E42DAE4579001405DD /* HoldoutConfig.swift in Sources */, + 989428B72DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 848617E92863E21200B7F41B /* OdpSegmentApiManager.swift in Sources */, 6E75187B22C520D400B2B157 /* Variation.swift in Sources */, 6E7517F722C520D400B2B157 /* DataStoreMemory.swift in Sources */, @@ -5708,6 +5757,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 989428BE2DBFA431008BA1C8 /* MockBucketer.swift in Sources */, 6E7516FC22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */, 6E7516B422C520D400B2B157 /* DefaultUserProfileService.swift in Sources */, 845945C02877589F00D13E11 /* OdpConfig.swift in Sources */, diff --git a/Sources/Data Model/FeatureFlag.swift b/Sources/Data Model/FeatureFlag.swift index e402825a..f0650561 100644 --- a/Sources/Data Model/FeatureFlag.swift +++ b/Sources/Data Model/FeatureFlag.swift @@ -35,8 +35,6 @@ struct FeatureFlag: Codable, Equatable, OptimizelyFeature { case variables } -// var holdoutIds: [String] = [] - // MARK: - OptimizelyConfig var experimentsMap: [String: OptimizelyExperiment] = [:] diff --git a/Sources/Data Model/HoldoutConfig.swift b/Sources/Data Model/HoldoutConfig.swift index dae915a4..24726e2f 100644 --- a/Sources/Data Model/HoldoutConfig.swift +++ b/Sources/Data Model/HoldoutConfig.swift @@ -115,4 +115,3 @@ struct HoldoutConfig { return holdoutIdMap[id] } } - diff --git a/Sources/Implementation/DecisionInfo.swift b/Sources/Implementation/DecisionInfo.swift index 72c38c30..fcb30eb2 100644 --- a/Sources/Implementation/DecisionInfo.swift +++ b/Sources/Implementation/DecisionInfo.swift @@ -22,7 +22,7 @@ struct DecisionInfo { let decisionType: Constants.DecisionType /// The experiment that the decision variation belongs to. - var experiment: Experiment? + var experiment: ExperimentCore? /// The variation selected by the decision. var variation: Variation? @@ -58,7 +58,7 @@ struct DecisionInfo { var decisionEventDispatched: Bool init(decisionType: Constants.DecisionType, - experiment: Experiment? = nil, + experiment: ExperimentCore? = nil, variation: Variation? = nil, source: String? = nil, feature: FeatureFlag? = nil, diff --git a/Sources/Implementation/DefaultBucketer.swift b/Sources/Implementation/DefaultBucketer.swift index 44f896b0..7f616eeb 100644 --- a/Sources/Implementation/DefaultBucketer.swift +++ b/Sources/Implementation/DefaultBucketer.swift @@ -120,7 +120,7 @@ class DefaultBucketer: OPTBucketer { return DecisionResponse(result: nil, reasons: reasons) } - func bucketToVariation(experiment: Experiment, + func bucketToVariation(experiment: ExperimentCore, bucketingId: String) -> DecisionResponse { let reasons = DecisionReasons() diff --git a/Sources/Implementation/DefaultDecisionService.swift b/Sources/Implementation/DefaultDecisionService.swift index 9267a4f6..10f1c01e 100644 --- a/Sources/Implementation/DefaultDecisionService.swift +++ b/Sources/Implementation/DefaultDecisionService.swift @@ -17,7 +17,7 @@ import Foundation struct FeatureDecision { - var experiment: Experiment? + var experiment: ExperimentCore? let variation: Variation let source: String } @@ -42,7 +42,20 @@ class DefaultDecisionService: OPTDecisionService { self.userProfileService = userProfileService } - /// Public Method + init(userProfileService: OPTUserProfileService, bucketer: OPTBucketer) { + self.bucketer = bucketer + self.userProfileService = userProfileService + } + + // MARK: - Experiment Decision + + /// Determines the variation for a user in a given experiment. + /// - Parameters: + /// - config: The project configuration containing experiment and feature details. + /// - experiment: The experiment to evaluate. + /// - user: The user context containing user ID and attributes. + /// - options: Optional decision options (e.g., ignore user profile service). + /// - Returns: A `DecisionResponse` containing the assigned variation (if any) and decision reasons. func getVariation(config: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, @@ -64,6 +77,14 @@ class DefaultDecisionService: OPTDecisionService { return response } + /// Determines the variation for a user in an experiment, considering user profile and decision rules. + /// - Parameters: + /// - config: The project configuration. + /// - experiment: The experiment to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - userProfileTracker: Optional tracker for user profile data. + /// - Returns: A `DecisionResponse` with the variation (if any) and decision reasons. func getVariation(config: ProjectConfig, experiment: Experiment, user: OptimizelyUserContext, @@ -157,62 +178,15 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: bucketedVariation, reasons: reasons) } - func doesMeetAudienceConditions(config: ProjectConfig, - experiment: Experiment, - user: OptimizelyUserContext, - logType: Constants.EvaluationLogType = .experiment, - loggingKey: String? = nil) -> DecisionResponse { - let reasons = DecisionReasons() - - var result = true // success as default (no condition, etc) - let evType = logType.rawValue - let finalLoggingKey = loggingKey ?? experiment.key - - do { - if let conditions = experiment.audienceConditions { - logger.d { () -> String in - return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: conditions)).description - } - switch conditions { - case .array(let arrConditions): - if arrConditions.count > 0 { - result = try conditions.evaluate(project: config.project, user: user) - } else { - // empty conditions (backward compatibility with "audienceIds" is ignored if exists even though empty - result = true - } - case .leaf: - result = try conditions.evaluate(project: config.project, user: user) - default: - result = true - } - } - // backward compatibility with audienceIds list - else if experiment.audienceIds.count > 0 { - var holder = [ConditionHolder]() - holder.append(.logicalOp(.or)) - for id in experiment.audienceIds { - holder.append(.leaf(.audienceId(id))) - } - logger.d { () -> String in - return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: holder)).description - } - result = try holder.evaluate(project: config.project, user: user) - } - } catch { - if let error = error as? OptimizelyError { - logger.i(error) - reasons.addInfo(error) - } - result = false - } - - logger.i(.audienceEvaluationResultCombined(evType, finalLoggingKey, result.description)) - - return DecisionResponse(result: result, reasons: reasons) - } + // MARK: - Feature Flag Decision - /// Public Method + /// Determines the feature decision for a user for a specific feature flag. + /// - Parameters: + /// - config: The project configuration. + /// - featureFlag: The feature flag to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. func getVariationForFeature(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, @@ -228,6 +202,13 @@ class DefaultDecisionService: OPTDecisionService { return response! } + /// Determines feature decisions for a list of feature flags. + /// - Parameters: + /// - config: The project configuration. + /// - featureFlags: The list of feature flags to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: An array of `DecisionResponse` objects, each containing a feature decision and reasons. func getVariationForFeatureList(config: ProjectConfig, featureFlags: [FeatureFlag], user: OptimizelyUserContext, @@ -245,7 +226,7 @@ class DefaultDecisionService: OPTDecisionService { var decisions = [DecisionResponse]() for featureFlag in featureFlags { - var decisionResponse = getVariationForFeatureExperiment(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker) + var decisionResponse = getVariationForFeature(config: config, featureFlag: featureFlag, user: user, userProfileTracker: profileTracker) reasons.merge(decisionResponse.reasons) @@ -272,15 +253,35 @@ class DefaultDecisionService: OPTDecisionService { return decisions } - - func getVariationForFeatureExperiment(config: ProjectConfig, - featureFlag: FeatureFlag, - user: OptimizelyUserContext, - userProfileTracker: UserProfileTracker? = nil, - options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + /// Determines the feature decision for a feature flag, considering experiments and holdouts. + /// - Parameters: + /// - config: The project configuration. + /// - featureFlag: The feature flag to evaluate. + /// - user: The user context. + /// - userProfileTracker: Optional tracker for user profile data. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. + func getVariationForFeature(config: ProjectConfig, + featureFlag: FeatureFlag, + user: OptimizelyUserContext, + userProfileTracker: UserProfileTracker? = nil, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { let reasons = DecisionReasons(options: options) + let holdouts = config.getHoldoutForFlag(id: featureFlag.id) + for holdout in holdouts { + let dicisionResponse = getVariationForHoldout(config: config, + flagKey: featureFlag.key, + holdout: holdout, + user: user) + reasons.merge(dicisionResponse.reasons) + if let variation = dicisionResponse.result { + let featureDicision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue) + return DecisionResponse(result: featureDicision, reasons: reasons) + } + } + let experimentIds = featureFlag.experimentIds if experimentIds.isEmpty { let info = LogMessage.featureHasNoExperiments(featureFlag.key) @@ -309,6 +310,13 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: nil, reasons: reasons) } + /// Determines the feature decision for a feature flag's rollout rules. + /// - Parameters: + /// - config: The project configuration. + /// - featureFlag: The feature flag to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the feature decision (if any) and reasons. func getVariationForFeatureRollout(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, @@ -363,6 +371,85 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: nil, reasons: reasons) } + + // MARK: - Holdout and Rule Decisions + + /// Determines the variation for a holdout group. + /// - Parameters: + /// - config: The project configuration. + /// - flagKey: The feature flag key. + /// - holdout: The holdout group to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the variation (if any) and reasons. + func getVariationForHoldout(config: ProjectConfig, + flagKey: String, + holdout: Holdout, + user: OptimizelyUserContext, + options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + let reasons = DecisionReasons(options: options) + + guard holdout.isActivated else { + let info = LogMessage.holdoutNotRunning(holdout.key) + reasons.addInfo(info) + logger.i(info) + return DecisionResponse(result: nil, reasons: reasons) + } + + // ---- check if the user passes audience targeting before bucketing ---- + let audienceResponse = doesMeetAudienceConditions(config: config, + experiment: holdout, + user: user) + + reasons.merge(audienceResponse.reasons) + + let userId = user.userId + let attributes = user.attributes + + // Acquire bucketingId . + let bucketingId = getBucketingId(userId: userId, attributes: attributes) + var bucketedVariation: Variation? + + if audienceResponse.result ?? false { + let info = LogMessage.userMeetsConditionsForHoldout(userId, holdout.key) + reasons.addInfo(info) + logger.i(info) + + // bucket user into holdout variation + let decisionResponse = bucketer.bucketToVariation(experiment: holdout, bucketingId: bucketingId) + + reasons.merge(decisionResponse.reasons) + + bucketedVariation = decisionResponse.result + + if let variation = bucketedVariation { + let info = LogMessage.userBucketedIntoVariationInHoldout(userId, holdout.key, variation.key) + reasons.addInfo(info) + logger.i(info) + } else { + let info = LogMessage.userNotBucketedIntoHoldoutVariation(userId) + reasons.addInfo(info) + logger.i(info) + } + + } else { + let info = LogMessage.userDoesntMeetConditionsForHoldout(userId, holdout.key) + reasons.addInfo(info) + logger.i(info) + } + + return DecisionResponse(result: bucketedVariation, reasons: reasons) + } + + /// Determines the variation for an experiment rule within a feature flag. + /// - Parameters: + /// - config: The project configuration. + /// - flagKey: The feature flag key. + /// - rule: The experiment rule to evaluate. + /// - user: The user context. + /// - userProfileTracker: Optional tracker for user profile data. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the variation (if any) and reasons. func getVariationFromExperimentRule(config: ProjectConfig, flagKey: String, rule: Experiment, @@ -389,7 +476,15 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: variation, reasons: reasons) } - + /// Determines the variation for a delivery rule in a rollout. + /// - Parameters: + /// - config: The project configuration. + /// - flagKey: The feature flag key. + /// - rules: The list of rollout rules. + /// - ruleIndex: The index of the rule to evaluate. + /// - user: The user context. + /// - options: Optional decision options. + /// - Returns: A `DecisionResponse` with the variation (if any), a flag indicating whether to skip to the "Everyone Else" rule, and reasons. func getVariationFromDeliveryRule(config: ProjectConfig, flagKey: String, rules: [Experiment], @@ -461,8 +556,79 @@ class DefaultDecisionService: OPTDecisionService { return DecisionResponse(result: (bucketedVariation, skipToEveryoneElse), reasons: reasons) } - func getBucketingId(userId: String, attributes: OptimizelyAttributes) -> String { + // MARK: - Audience Evaluation + + /// Evaluates whether a user meets the audience conditions for an experiment or rule. + /// - Parameters: + /// - config: The project configuration. + /// - experiment: The experiment or rule to evaluate. + /// - user: The user context. + /// - logType: The type of evaluation for logging (e.g., experiment or rollout rule). + /// - loggingKey: Optional key for logging. + /// - Returns: A `DecisionResponse` with a boolean indicating whether conditions are met and reasons. + func doesMeetAudienceConditions(config: ProjectConfig, + experiment: ExperimentCore, + user: OptimizelyUserContext, + logType: Constants.EvaluationLogType = .experiment, + loggingKey: String? = nil) -> DecisionResponse { + let reasons = DecisionReasons() + var result = true // success as default (no condition, etc) + let evType = logType.rawValue + let finalLoggingKey = loggingKey ?? experiment.key + + do { + if let conditions = experiment.audienceConditions { + logger.d { () -> String in + return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: conditions)).description + } + switch conditions { + case .array(let arrConditions): + if arrConditions.count > 0 { + result = try conditions.evaluate(project: config.project, user: user) + } else { + // empty conditions (backward compatibility with "audienceIds" is ignored if exists even though empty + result = true + } + case .leaf: + result = try conditions.evaluate(project: config.project, user: user) + default: + result = true + } + } + // backward compatibility with audienceIds list + else if experiment.audienceIds.count > 0 { + var holder = [ConditionHolder]() + holder.append(.logicalOp(.or)) + for id in experiment.audienceIds { + holder.append(.leaf(.audienceId(id))) + } + logger.d { () -> String in + return LogMessage.evaluatingAudiencesCombined(evType, finalLoggingKey, Utils.getConditionString(conditions: holder)).description + } + result = try holder.evaluate(project: config.project, user: user) + } + } catch { + if let error = error as? OptimizelyError { + logger.i(error) + reasons.addInfo(error) + } + result = false + } + + logger.i(.audienceEvaluationResultCombined(evType, finalLoggingKey, result.description)) + + return DecisionResponse(result: result, reasons: reasons) + } + + // MARK: - Utilities + + /// Retrieves the bucketing ID for a user, defaulting to user ID unless overridden in attributes. + /// - Parameters: + /// - userId: The user's ID. + /// - attributes: The user's attributes. + /// - Returns: The bucketing ID to use for variation assignment. + func getBucketingId(userId: String, attributes: OptimizelyAttributes) -> String { // By default, the bucketing ID should be the user ID . var bucketingId = userId // If the bucketing ID key is defined in attributes, then use that @@ -474,7 +640,12 @@ class DefaultDecisionService: OPTDecisionService { return bucketingId } - /// Public Method + /// Finds and validates a forced decision for a given context. + /// - Parameters: + /// - config: The project configuration. + /// - user: The user context. + /// - context: The decision context (flag and rule keys). + /// - Returns: A `DecisionResponse` with the forced variation (if valid) and reasons. func findValidatedForcedDecision(config: ProjectConfig, user: OptimizelyUserContext, context: OptimizelyDecisionContext) -> DecisionResponse { diff --git a/Sources/Implementation/Events/BatchEventBuilder.swift b/Sources/Implementation/Events/BatchEventBuilder.swift index 78fb329c..4dbd0961 100644 --- a/Sources/Implementation/Events/BatchEventBuilder.swift +++ b/Sources/Implementation/Events/BatchEventBuilder.swift @@ -22,7 +22,7 @@ class BatchEventBuilder { // MARK: - Impression Event static func createImpressionEvent(config: ProjectConfig, - experiment: Experiment?, + experiment: ExperimentCore?, variation: Variation?, userId: String, attributes: OptimizelyAttributes?, diff --git a/Sources/Optimizely/OptimizelyClient.swift b/Sources/Optimizely/OptimizelyClient.swift index b99c9393..15905a5c 100644 --- a/Sources/Optimizely/OptimizelyClient.swift +++ b/Sources/Optimizely/OptimizelyClient.swift @@ -804,7 +804,7 @@ extension OptimizelyClient { return (source == Constants.DecisionSource.featureTest.rawValue && decision?.variation != nil) || config.sendFlagDecisions } - func sendImpressionEvent(experiment: Experiment?, + func sendImpressionEvent(experiment: ExperimentCore?, variation: Variation?, userId: String, attributes: OptimizelyAttributes? = nil, @@ -892,7 +892,7 @@ extension OptimizelyClient { extension OptimizelyClient { - func sendActivateNotification(experiment: Experiment, + func sendActivateNotification(experiment: ExperimentCore, variation: Variation, userId: String, attributes: OptimizelyAttributes?, diff --git a/Sources/Protocols/OPTBucketer.swift b/Sources/Protocols/OPTBucketer.swift index 0f9440ec..76f98e78 100644 --- a/Sources/Protocols/OPTBucketer.swift +++ b/Sources/Protocols/OPTBucketer.swift @@ -36,6 +36,15 @@ protocol OPTBucketer { func bucketExperiment(config: ProjectConfig, experiment: Experiment, bucketingId: String) -> DecisionResponse + + /** + Bucket a bucketingId into an experiment. + - Parameter experiment: The rule in which to bucket the bucketingId. + - Parameter bucketingId: The ID to bucket. This must be a non-null, non-empty string. + - Returns: The variation the bucketingId was bucketed into. + */ + func bucketToVariation(experiment: ExperimentCore, + bucketingId: String) -> DecisionResponse /** Hash the bucketing ID and map it to the range [0, 10000). diff --git a/Sources/Utils/Constants.swift b/Sources/Utils/Constants.swift index f60f6fdc..2623f49b 100644 --- a/Sources/Utils/Constants.swift +++ b/Sources/Utils/Constants.swift @@ -57,6 +57,7 @@ struct Constants { case experiment = "experiment" case featureTest = "feature-test" case rollout = "rollout" + case holdout = "holdout" } struct DecisionInfoKeys { diff --git a/Sources/Utils/LogMessage.swift b/Sources/Utils/LogMessage.swift index 2be76f5e..4ce7c08a 100644 --- a/Sources/Utils/LogMessage.swift +++ b/Sources/Utils/LogMessage.swift @@ -18,6 +18,7 @@ import Foundation enum LogMessage { case experimentNotRunning(_ key: String) + case holdoutNotRunning(_ key: String) case featureEnabledForUser(_ key: String, _ userId: String) case featureNotEnabledForUser(_ key: String, _ userId: String) case featureHasNoExperiments(_ key: String) @@ -34,7 +35,9 @@ enum LogMessage { case userAssignedToBucketValue(_ bucket: Int, _ userId: String) case userMappedToForcedVariation(_ userId: String, _ expId: String, _ varId: String) case userMeetsConditionsForTargetingRule(_ userId: String, _ rule: String) + case userMeetsConditionsForHoldout(_ userId: String, _ holdoutKey: String) case userDoesntMeetConditionsForTargetingRule(_ userId: String, _ rule: String) + case userDoesntMeetConditionsForHoldout(_ userId: String, _ holdoutKey: String) case userBucketedIntoTargetingRule(_ userId: String, _ rule: String) case userNotBucketedIntoTargetingRule(_ userId: String, _ rule: String) case userHasForcedDecision(_ userId: String, _ flagKey: String, _ ruleKey: String?, _ varKey: String) @@ -44,8 +47,10 @@ enum LogMessage { case userHasNoForcedVariation(_ userId: String) case userHasNoForcedVariationForExperiment(_ userId: String, _ expKey: String) case userBucketedIntoVariationInExperiment(_ userId: String, _ expKey: String, _ varKey: String) + case userBucketedIntoVariationInHoldout(_ userId: String, _ expKey: String, _ varKey: String) case userNotBucketedIntoVariation(_ userId: String) case userBucketedIntoInvalidVariation(_ id: String) + case userNotBucketedIntoHoldoutVariation(_ userId: String) case userBucketedIntoExperimentInGroup(_ userId: String, _ expKey: String, _ group: String) case userNotBucketedIntoExperimentInGroup(_ userId: String, _ expKey: String, _ group: String) case userNotBucketedIntoAnyExperimentInGroup(_ userId: String, _ group: String) @@ -76,6 +81,7 @@ extension LogMessage: CustomStringConvertible { switch self { case .experimentNotRunning(let key): message = "Experiment (\(key)) is not running." + case .holdoutNotRunning(let key): message = "Holdout (\(key)) is not running." case .featureEnabledForUser(let key, let userId): message = "Feature (\(key)) is enabled for user (\(userId))." case .featureNotEnabledForUser(let key, let userId): message = "Feature (\(key)) is not enabled for user (\(userId))." case .featureHasNoExperiments(let key): message = "Feature (\(key)) is not attached to any experiments." @@ -91,10 +97,12 @@ extension LogMessage: CustomStringConvertible { case .savedVariationInUserProfile(let varId, let expId, let userId): message = "Saved variation (\(varId)) of experiment (\(expId)) for user (\(userId))." case .userAssignedToBucketValue(let bucket, let userId): message = "Assigned bucket (\(bucket)) to user with bucketing ID (\(userId))." case .userMappedToForcedVariation(let userId, let expId, let varId): message = "Set variation (\(varId)) for experiment (\(expId)) and user (\(userId)) in the forced variation map." - case .userMeetsConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) meets conditions for targeting rule (\(rule))." - case .userDoesntMeetConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) does not meet conditions for targeting rule (\(rule))." - case .userBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is in the traffic group of targeting rule (\(rule))." - case .userNotBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is not in the traffic group for targeting rule (\(rule)). Checking (Everyone Else) rule now." + case .userMeetsConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) meets conditions for targeting rule (\(rule))." + case .userMeetsConditionsForHoldout(let userId, let holdoutKey): message = "User (\(userId)) meets conditions for holdout(\(holdoutKey))." + case .userDoesntMeetConditionsForTargetingRule(let userId, let rule): message = "User (\(userId)) does not meet conditions for targeting rule (\(rule))." + case .userDoesntMeetConditionsForHoldout(let userId, let holdoutKey): message = "User (\(userId)) does not meet conditions for holdout (\(holdoutKey))." + case .userBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is in the traffic group of targeting rule (\(rule))." + case .userNotBucketedIntoTargetingRule(let userId, let rule): message = "User (\(userId)) is not in the traffic group for targeting rule (\(rule)). Checking (Everyone Else) rule now." case .userHasForcedDecision(let userId, let flagKey, let ruleKey, let varKey): let target = (ruleKey != nil) ? "flag (\(flagKey)), rule (\(ruleKey!))" : "flag (\(flagKey))" message = "Variation (\(varKey)) is mapped to \(target) and user (\(userId)) in the forced decision map." @@ -106,7 +114,9 @@ extension LogMessage: CustomStringConvertible { case .userHasNoForcedVariation(let userId): message = "User (\(userId)) is not in the forced variation map." case .userHasNoForcedVariationForExperiment(let userId, let expKey): message = "No experiment (\(expKey)) mapped to user (\(userId)) in the forced variation map." case .userBucketedIntoVariationInExperiment(let userId, let expKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of experiment (\(expKey))" + case .userBucketedIntoVariationInHoldout(let userId, let holdoutKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of holdout (\(holdoutKey))" case .userNotBucketedIntoVariation(let userId): message = "User (\(userId)) is in no variation." + case .userNotBucketedIntoHoldoutVariation(let userId): message = "User (\(userId)) is in no holdout variation." case .userBucketedIntoInvalidVariation(let id): message = "Bucketed into an invalid variation id (\(id))" case .userBucketedIntoExperimentInGroup(let userId, let expId, let group): message = "User (\(userId)) is in experiment (\(expId)) of group (\(group))." case .userNotBucketedIntoExperimentInGroup(let userId, let expKey, let group): message = "User (\(userId)) is not in experiment (\(expKey)) of group (\(group))." diff --git a/Tests/OptimizelyTests-Common/BucketTests_HoldoutToVariation.swift b/Tests/OptimizelyTests-Common/BucketTests_HoldoutToVariation.swift new file mode 100644 index 00000000..02b59464 --- /dev/null +++ b/Tests/OptimizelyTests-Common/BucketTests_HoldoutToVariation.swift @@ -0,0 +1,143 @@ +// +// Copyright 2019, 2021, Optimizely, Inc. and contributors +// +// 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 + +class BucketTests_HoldoutToVariation: XCTestCase { + var optimizely: OptimizelyClient! + var config: ProjectConfig! + var bucketer: DefaultBucketer! + + var kUserId = "123456" + var kHoldoutId = "4444444" + var kHoldoutKey = "holdout_key" + + var kVariationKeyA = "a" + var kVariationIdA = "a11" + + var kAudienceIdCountry = "10" + var kAudienceIdAge = "20" + var kAudienceIdInvalid = "9999999" + + var kAttributesCountryMatch: [String: Any] = ["country": "us"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + var kAttributesAgeMatch: [String: Any] = ["age": 30] + var kAttributesAgeNotMatch: [String: Any] = ["age": 10] + var kAttributesEmpty: [String: Any] = [:] + + var holdout: Holdout! + + // MARK: - Sample datafile data + + var sampleHoldoutData: [String: Any] { + return [ + "status": "Running", + "id": kHoldoutId, + "key": kHoldoutKey, + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": kVariationIdA, "endOfRange": 1000] // 10% traffic allocation (0-1000 out of 10000) + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + ["variables": [], "id": kVariationIdA, "key": kVariationKeyA] + ], + ] + } + + // MARK: - Setup + + override func setUp() { + super.setUp() + + self.optimizely = OTUtils.createOptimizely(datafileName: "empty_datafile", + clearUserProfileService: true) + self.config = self.optimizely.config! + self.bucketer = ((optimizely.decisionService as! DefaultDecisionService).bucketer as! DefaultBucketer) + + // Initialize holdout + holdout = try! OTUtils.model(from: sampleHoldoutData) + } + + // MARK: - Tests for bucketToVariation + + func testBucketToVariation_ValidBucketingWithinAllocation() { + // Test users that should bucket into the single variation (within 0-1000 range) + let testCases = [ + ["userId": "user1", "expectedVariation": kVariationKeyA], // Buckets to variation A + ["userId": "testuser", "expectedVariation": kVariationKeyA] // Buckets to variation A + ] + + for (index, test) in testCases.enumerated() { + // Mock bucket value to ensure it falls within 0-1000 + let mockBucketValue = 500 // Within 10% allocation + let mockBucketer = MockBucketer(mockBucketValue: mockBucketValue) + let response = mockBucketer.bucketToVariation(experiment: holdout, bucketingId: test["userId"]!) + XCTAssertNotNil(response.result, "Variation should not be nil for test case \(index)") + XCTAssertEqual(response.result?.key, test["expectedVariation"], "Wrong variation for test case \(index)") + } + } + + func testBucketToVariation_BucketValueOutsideAllocation() { + // Test users that fall outside the 10% allocation (bucket value > 1000) + let testCases = [ + ["userId": "user2"], + ["userId": "anotheruser"] + ] + + for (index, test) in testCases.enumerated() { + // Mock bucket value to ensure it falls outside 0-1000 + let mockBucketValue = 1500 // Outside 10% allocation + let mockBucketer = MockBucketer(mockBucketValue: mockBucketValue) + let response = mockBucketer.bucketToVariation(experiment: holdout, bucketingId: test["userId"]!) + XCTAssertNil(response.result, "Variation should be nil for test case \(index) when outside allocation") + } + } + + func testBucketToVariation_NoTrafficAllocation() { + // Create a holdout with empty traffic allocation + var modifiedHoldoutData = sampleHoldoutData + modifiedHoldoutData["trafficAllocation"] = [] + let modifiedHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + + let response = bucketer.bucketToVariation(experiment: modifiedHoldout, bucketingId: kUserId) + + XCTAssertNil(response.result, "Variation should be nil when no traffic allocation") + } + + func testBucketToVariation_InvalidVariationId() { + // Create a holdout with invalid variation ID in traffic allocation + var modifiedHoldoutData = sampleHoldoutData + modifiedHoldoutData["trafficAllocation"] = [ + ["entityId": "invalid_variation_id", "endOfRange": 1000] + ] + let modifiedHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + + let response = bucketer.bucketToVariation(experiment: modifiedHoldout, bucketingId: kUserId) + + XCTAssertNil(response.result, "Variation should be nil for invalid variation ID") + } + + func testBucketToVariation_EmptyBucketingId() { + // Test with empty bucketing ID, still within allocation + let mockBucketValue = 500 + let mockBucketer = MockBucketer(mockBucketValue: mockBucketValue) + let response = mockBucketer.bucketToVariation(experiment: holdout, bucketingId: "") + + XCTAssertNotNil(response.result, "Should still bucket with empty bucketing ID") + XCTAssertEqual(response.result?.key, kVariationKeyA, "Should bucket to variation A") + } +} diff --git a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift index 3837ff15..f746362f 100644 --- a/Tests/OptimizelyTests-Common/DecisionListenerTests.swift +++ b/Tests/OptimizelyTests-Common/DecisionListenerTests.swift @@ -1263,7 +1263,7 @@ class FakeDecisionService: DefaultDecisionService { return DecisionResponse.responseNoReasons(result: featureDecision) } - override func getVariationForFeatureExperiment(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { + override func getVariationForFeature(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse { guard let experiment = self.experiment, let tmpVariation = self.variation else { return DecisionResponse.nilNoReasons() } diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift index 4101578d..ecd459e0 100644 --- a/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Features.swift @@ -258,7 +258,7 @@ class DecisionServiceTests_Features: XCTestCase { extension DecisionServiceTests_Features { func testGetVariationForFeatureExperimentWhenMatched() { - let pair = self.decisionService.getVariationForFeatureExperiment(config: config, + let pair = self.decisionService.getVariationForFeature(config: config, featureFlag: featureFlag, user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)).result @@ -268,7 +268,7 @@ extension DecisionServiceTests_Features { } func testGetVariationForFeatureExperimentWhenNotMatched() { - let pair = self.decisionService.getVariationForFeatureExperiment(config: config, + let pair = self.decisionService.getVariationForFeature(config: config, featureFlag: featureFlag, user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch)).result @@ -280,7 +280,7 @@ extension DecisionServiceTests_Features { featureFlag.experimentIds = ["99999"] // not-existing experiment self.config.project.featureFlags = [featureFlag] - let pair = self.decisionService.getVariationForFeatureExperiment(config: config, + let pair = self.decisionService.getVariationForFeature(config: config, featureFlag: featureFlag, user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)).result diff --git a/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift new file mode 100644 index 00000000..c18096c9 --- /dev/null +++ b/Tests/OptimizelyTests-Common/DecisionServiceTests_Holdouts.swift @@ -0,0 +1,675 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// 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 + +class DecisionServiceTests_Holdouts: XCTestCase { + + var optimizely: OptimizelyClient! + var config: ProjectConfig! + var mockDecisionService: DefaultDecisionService! + + var kUserId = "12345" + var kExperimentKey = "countryExperiment" + var kExperimentId = "country11" + + var kVariationKeyA = "a" + var kVariationKeyB = "b" + var kVariationKeyC = "c" + var kVariationKeyD = "d" + + var kAudienceIdCountry = "10" + var kAudienceIdAge = "20" + var kAudienceIdInvalid = "9999999" + + var kAttributesCountryMatch: [String: Any] = ["country": "us"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + var kAttributesAgeMatch: [String: Any] = ["age": 30] + var kAttributesAgeNotMatch: [String: Any] = ["age": 10] + var kAttributesEmpty: [String: Any] = [:] + + var holdout: Holdout! + var variation: Variation! + var featureFlag: FeatureFlag! + + // MARK: - Sample datafile data + + var sampleExperimentData: [String: Any] { return + [ + "status": "Running", + "id": kExperimentId, + "key": kExperimentKey, + "layerId": "10420273888", + "trafficAllocation": [ + [ + "entityId": "16456523121", + "endOfRange": 10000 + ] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "10389729780", + "key": kVariationKeyA + ], + [ + "variables": [], + "id": "10416523121", + "key": kVariationKeyB + ], + [ + "variables": [], + "id": "13456523121", + "key": kVariationKeyC + ], + [ + "variables": [], + "id": "16456523121", + "key": kVariationKeyD + ] + ], + "forcedVariations": [:] + ] + } + + var sampleTypedAudiencesData: [[String: Any]] { return + [ + [ + "id": kAudienceIdCountry, + "conditions": [ "type": "custom_attribute", "name": "country", "match": "exact", "value": "us" ], + "name": "country" + ] + ] + } + + var sampleFeatureFlagData: [String: Any] { return + [ + "id": "flag_id_1234", + "key": "flag_key", + "experimentIds": [kExperimentId], + "rolloutId": "", + "variables": [] + ] + } + + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "holdout_4444444", + "key": "holdout_key", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "holdout_variation_a11", "endOfRange": 1000] // 10% traffic allocation + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "holdout_variation_a11", + "key": "holdout_a" + ] + ], + "includedFlags": ["flag_id_1234"], + "excludedFlags": [] + ] + } + + var sampleHoldoutGlobal: [String: Any] { + return [ + "status": "Running", + "id": "holdout_global", + "key": "holdout_global", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "holdout_global_variation", "endOfRange": 500] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "holdout_global_variation", + "key": "global_variation" + ] + ], + "includedFlags": [], + "excludedFlags": [] + ] + } + + var sampleHoldoutIncluded: [String: Any] { + return [ + "status": "Running", + "id": "holdout_included", + "key": "holdout_included", + "layerId": "10420273889", + "trafficAllocation": [ + ["entityId": "holdout_included_variation", "endOfRange": 1000] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "holdout_included_variation", + "key": "included_variation" + ] + ], + "includedFlags": ["flag_id_1234"], + "excludedFlags": [] + ] + } + + var sampleHoldoutExcluded: [String: Any] { + return [ + "status": "Running", + "id": "holdout_excluded", + "key": "holdout_excluded", + "layerId": "10420273890", + "trafficAllocation": [ + ["entityId": "holdout_excluded_variation", "endOfRange": 1000] + ], + "audienceIds": [kAudienceIdCountry], + "variations": [ + [ + "variables": [], + "id": "holdout_excluded_variation", + "key": "excluded_variation" + ] + ], + "includedFlags": [], + "excludedFlags": ["flag_id_1234"] + ] + } + + // MARK: - Setup + + override func setUp() { + super.setUp() + + self.optimizely = OTUtils.createOptimizely(datafileName: "empty_datafile", + clearUserProfileService: true) + self.config = self.optimizely.config! + + // Mock bucketer to ensure user would bucket in holdout + let mockBucketer = MockBucketer(mockBucketValue: 500) // Within holdout range + self.mockDecisionService = MockDecisionService(bucketer: mockBucketer) + self.optimizely.decisionService = mockDecisionService + + // Project config + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + holdout = try! OTUtils.model(from: sampleHoldout) + var experiment: Experiment = try! OTUtils.model(from: sampleExperimentData) + experiment.audienceIds = [kAudienceIdCountry] + self.config.project.experiments = [experiment] + + featureFlag = try! OTUtils.model(from: sampleFeatureFlagData) + self.config.project.featureFlags = [featureFlag] + self.config.project.holdouts = [holdout] + } + +} + +// MARK: - Test doesMeetAudienceConditions() + +extension DecisionServiceTests_Holdouts { + + func testDoesMeetAudienceConditionsWithAudienceConditions() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + + // (1) matching true + + holdout = try! OTUtils.model(from: sampleHoldout) + holdout.audienceConditions = try! OTUtils.model(from: ["or", kAudienceIdCountry]) + holdout.audienceIds = [kAudienceIdAge] + self.config.project.holdouts = [holdout] + + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result, "attribute should be matched to audienceConditions") + + // (2) matching false + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryNotMatch)).result + XCTAssertFalse(result, "attribute should be matched to audienceConditions") + + // (3) other attribute + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesAgeMatch)).result + XCTAssertFalse(result, "no matching attribute provided") + } + + func testDoesMeetAudienceConditionsWithAudienceIds() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + + // (1) matching true + + holdout = try! OTUtils.model(from: sampleHoldout) + holdout.audienceConditions = nil + holdout.audienceIds = [kAudienceIdCountry] + self.config.project.holdouts = [holdout] + + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result, "attribute should be matched to audienceConditions") + + // (2) matching false + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryNotMatch)).result + XCTAssertFalse(result, "attribute should be matched to audienceConditions") + + // (3) other attribute + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesAgeMatch)).result + XCTAssertFalse(result, "no matching attribute provided") + } + + func testDoesMeetAudienceConditionsWithAudienceConditionsEmptyArray() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + holdout = try! OTUtils.model(from: sampleHoldout) + holdout.audienceConditions = try! OTUtils.model(from: []) + holdout.audienceIds = [kAudienceIdAge] + self.config.project.holdouts = [holdout] + + let result: Bool! = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result, "empty conditions is true always") + } + + func testDoesMeetAudienceConditionsWithAudienceIdsEmpty() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + holdout = try! OTUtils.model(from: sampleHoldout) + holdout.audienceConditions = nil + holdout.audienceIds = [] + self.config.project.holdouts = [holdout] + + let result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result, "empty conditions is true always") + } + + func testDoesMeetAudienceConditionsWithCornerCases() { + self.config.project.typedAudiences = try! OTUtils.model(from: sampleTypedAudiencesData) + holdout = try! OTUtils.model(from: sampleHoldout) + + // (1) leaf (not array) in "audienceConditions" still works ok + + // JSON does not support raw string, so wrap in array for decode + var array: [ConditionHolder] = try! OTUtils.model(from: [kAudienceIdCountry]) + holdout.audienceConditions = array[0] + holdout.audienceIds = [kAudienceIdAge] + self.config.project.holdouts = [holdout] + + var result: Bool! = mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result) + + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesEmpty)).result + XCTAssertFalse(result) + + // (2) invalid string in "audienceConditions" + array = try! OTUtils.model(from: ["and"]) + holdout.audienceConditions = array[0] + self.config.project.holdouts = [holdout] + + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result) + + // (2) invalid string in "audienceConditions" + holdout.audienceConditions = nil + holdout.audienceIds = [] + self.config.project.holdouts = [holdout] + + result = self.mockDecisionService.doesMeetAudienceConditions(config: config, + experiment: holdout, + user: OTUtils.user(userId: kUserId, attributes: kAttributesCountryMatch)).result + XCTAssert(result) + } +} + + +// MARK: - Test getVariationForFeatureExperiment + +extension DecisionServiceTests_Holdouts { + func testGetVariationForFeatureExperiment_HoldoutMatch() { + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") + XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") + } + + func testGetVariationForFeatureExperiment_HoldoutAudienceMismatch() { + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + ).result + + // Should fall back to experiment, but experiment also requires country match + XCTAssertNil(decision, "Decision should be nil due to audience mismatch for both holdout and experiment") + } + + func testGetVariationForFeatureExperiment_HoldoutNotBucketed() { + // Mock bucketer to ensure user is not bucketed into holdout variation + let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside holdout range (0-1000) + mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should fall back to experiment and bucket into variation D + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") + XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") + } + + + func testGetVariationForFeatureExperiment_HoldoutInactive() { + // Set holdout to inactive + var modifiedHoldoutData = sampleHoldout + modifiedHoldoutData["status"] = "Draft" + let inactiveHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + self.config.project.holdouts = [inactiveHoldout] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should skip holdout and bucket into experiment + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") + XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") + } + + + func testGetVariationForFeatureExperiment_NoHoldouts() { + // Remove holdouts + self.config.project.holdouts = [] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should bucket into experiment + XCTAssertNotNil(decision, "Decision should not personally identifiable informationnil") + XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") + XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should be featureTest") + } + + func testGetVariationForFeatureExperiment_NoExperiments() { + // Set feature flag with no experiment IDs + var modifiedFeatureFlagData = sampleFeatureFlagData + modifiedFeatureFlagData["experimentIds"] = [] + featureFlag = try! OTUtils.model(from: modifiedFeatureFlagData) + self.config.project.featureFlags = [featureFlag] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should return holdout decision + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") + XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") + } + + func testGetVariationForFeatureExperiment_InvalidExperimentIds() { + // Set feature flag with invalid experiment IDs + var modifiedFeatureFlagData = sampleFeatureFlagData + modifiedFeatureFlagData["experimentIds"] = ["invalid_experiment_id"] + featureFlag = try! OTUtils.model(from: modifiedFeatureFlagData) + self.config.project.featureFlags = [featureFlag] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should return holdout decision + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, holdout.id, "Should return holdout experiment") + XCTAssertEqual(decision?.variation.key, "holdout_a", "Should return holdout variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue, "Source should be holdout") + } + + func testGetVariationForFeatureExperiment_HoldoutExcludedFlag() { + // Modify holdout to exclude the feature flag + var modifiedHoldoutData = sampleHoldout + modifiedHoldoutData["includedFlags"] = [] + modifiedHoldoutData["excludedFlags"] = ["flag_id_1234"] + let excludedHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + self.config.project.holdouts = [excludedHoldout] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should skip holdout and bucket into experiment + XCTAssertNotNil(decision, "Decision should not be nil") + XCTAssertEqual(decision?.experiment?.id, kExperimentId, "Should return experiment") + XCTAssertEqual(decision?.variation.key, kVariationKeyD, "Should return experiment variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue, "Source should Westhill") + } + + func testGetVariationForFeatureExperiment_MultipleHoldoutsWithOrdering() { + // Setup multiple holdouts: global, included, excluded + let tfAllocationRange = 1500 + var globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout + globalHoldout.trafficAllocation[0].endOfRange = tfAllocationRange + + var includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout + includedHoldout.trafficAllocation[0].endOfRange = tfAllocationRange + var excludedHoldout = try! OTUtils.model(from: sampleHoldoutExcluded) as Holdout + excludedHoldout.trafficAllocation[0].endOfRange = tfAllocationRange + + self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] + + // Mock bucketer to bucket into the first valid holdout (global) + let mockBucketer = MockBucketer(mockBucketValue: 1000) // Within all holdout ranges + let mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should select global holdout first (ordering: global > included) + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, globalHoldout.id, "Should select global holdout first") + XCTAssertEqual(decision?.variation.key, "global_variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue) + } + + + func testGetVariationForFeatureExperiment_GlobalHoldoutFailsThenIncluded() { + // Setup multiple holdouts + let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout + let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout + self.config.project.holdouts = [globalHoldout, includedHoldout] + + // Mock bucketer to fail global holdout bucketing, succeed for included + let mockBucketer = MockBucketer(mockBucketValue: 700) // Outside global range, within included range + mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Global holdout fails bucketing, should select included holdout + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, includedHoldout.id, "Should select included holdout") + XCTAssertEqual(decision?.variation.key, "included_variation") + XCTAssertEqual(decision?.source, Constants.DecisionSource.holdout.rawValue) + } + + func testGetVariationForFeatureExperiment_AllHoldoutsFailThenExperiment() { + // Setup multiple holdouts + let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout + let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout + let excludedHoldout = try! OTUtils.model(from: sampleHoldoutExcluded) as Holdout + self.config.project.holdouts = [globalHoldout, includedHoldout, excludedHoldout] + + // Mock bucketer to fail all holdout bucketing + let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside all holdout ranges + mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // All holdouts fail, should fall back to experiment + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, kExperimentId) + XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) + } + + func testGetVariationForFeatureExperiment_HoldoutWithNoTrafficAllocation() { + // Setup holdout with no traffic allocation + var modifiedHoldoutData = sampleHoldoutGlobal + modifiedHoldoutData["trafficAllocation"] = [] + let noTrafficHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + self.config.project.holdouts = [noTrafficHoldout] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Holdout has no traffic allocation, should fall back to experiment + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, kExperimentId) + XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) + } + + func testGetVariationForFeatureExperiment_MixedAudienceAndBucketingFailures() { + // Setup multiple holdouts with different audience conditions + var globalHoldoutData = sampleHoldoutGlobal + globalHoldoutData["audienceIds"] = [kAudienceIdAge] // Requires age > 17 + let globalHoldout = try! OTUtils.model(from: globalHoldoutData) as Holdout + + let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout // Requires country: "us" + + self.config.project.holdouts = [globalHoldout, includedHoldout] + + // Mock bucketer to fail included holdout bucketing + let mockBucketer = MockBucketer(mockBucketValue: 1500) // Outside included holdout range + mockDecisionService = MockDecisionService(bucketer: mockBucketer) + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + ).result + + // Global holdout passes audience (age not specified, defaults to true), but fails bucketing + // Included holdout fails audience (country: "ca") + // Falls back to experiment, but experiment also fails audience + XCTAssertNil(decision) + } + + func testGetVariationForFeatureExperiment_EmptyVariationsInHoldout() { + // Setup holdout with no variations + var modifiedHoldoutData = sampleHoldoutGlobal + modifiedHoldoutData["variations"] = [] + let noVariationsHoldout = try! OTUtils.model(from: modifiedHoldoutData) as Holdout + self.config.project.holdouts = [noVariationsHoldout] + + let decision = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Holdout has no variations, should fall back to experiment + XCTAssertNotNil(decision) + XCTAssertEqual(decision?.experiment?.id, kExperimentId) + XCTAssertEqual(decision?.variation.key, kVariationKeyD) + XCTAssertEqual(decision?.source, Constants.DecisionSource.featureTest.rawValue) + } + + func testGetVariationForFeatureExperiment_CacheConsistency() { + // Setup multiple holdouts + let globalHoldout = try! OTUtils.model(from: sampleHoldoutGlobal) as Holdout + let includedHoldout = try! OTUtils.model(from: sampleHoldoutIncluded) as Holdout + self.config.project.holdouts = [globalHoldout, includedHoldout] + + // First call + let decision1 = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Second call with same inputs + let decision2 = mockDecisionService.getVariationForFeature( + config: config, + featureFlag: featureFlag, + user: optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + ).result + + // Should consistently return global holdout + XCTAssertNotNil(decision1) + XCTAssertNotNil(decision2) + XCTAssertEqual(decision1?.experiment?.id, includedHoldout.id) + XCTAssertEqual(decision2?.experiment?.id, includedHoldout.id) + XCTAssertEqual(decision1?.variation.key, "included_variation") + XCTAssertEqual(decision2?.variation.key, "included_variation") + XCTAssertEqual(decision1?.source, Constants.DecisionSource.holdout.rawValue) + XCTAssertEqual(decision2?.source, Constants.DecisionSource.holdout.rawValue) + } +} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift new file mode 100644 index 00000000..9820f6cd --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Holdouts.swift @@ -0,0 +1,564 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// 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 + +class OptimizelyUserContextTests_Decide_Holdouts: XCTestCase { + let kUserId = "tester" + var optimizely: OptimizelyClient! + var eventDispatcher = MockEventDispatcher() + + var kAttributesCountryMatch: [String: Any] = ["country": "US"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "id_holdout", + "key": "key_holdout", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "id_holdout_variation", "endOfRange": 500] + ], + "audienceIds": [], + "variations": [ + [ + "variables": [], + "id": "id_holdout_variation", + "key": "key_holdout_variation" + ] + ], + "includedFlags": [], + "excludedFlags": [] + ] + } + + override func setUp() { + super.setUp() + + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + eventDispatcher: eventDispatcher, + userProfileService: OTUtils.createClearUserProfileService()) + + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) + } + + func test_decide_with_global_holdout_audience_matched() { + let featureKey = "feature_1" + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + // Audience "13389130056" requires "country" = "US" + holdout.audienceIds = ["13389130056"] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + // Call decide with reasons + let decision = user.decide(key: featureKey) + + XCTAssert(decision == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected, + ruleKey: "key_holdout", + flagKey: featureKey, + userContext: user, + reasons: [])) + + + } + + func test_decide_with_gloabl_holdout_audience_mis_matched() { + let featureKey = "feature_2" + let featureKeys = [featureKey] + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + // Audience "13389130056" requires "country" = "US" + holdout.audienceIds = ["13389130056"] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + let decisions = user.decide(keys: featureKeys) + + XCTAssert(decisions.count == 1) + let decision = decisions[featureKey]! + + let expDecision = OptimizelyDecision(variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected, + ruleKey: "exp_no_audience", + flagKey: featureKey, + userContext: user, + reasons: []) + XCTAssertEqual(decision, expDecision) + } + + func testDecide_ForNullVariation() { + let featureKey = "feature_2" + let featureKeys = [featureKey] + var null_Variation_json = sampleHoldout + null_Variation_json["variations"] = [] + + let holdout = try! OTUtils.model(from: null_Variation_json) as Holdout + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + let decisions = user.decide(keys: featureKeys) + + XCTAssert(decisions.count == 1) + let decision = decisions[featureKey]! + + let expDecision = OptimizelyDecision(variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected, + ruleKey: "exp_no_audience", + flagKey: featureKey, + userContext: user, + reasons: []) + XCTAssertEqual(decision, expDecision) + } + + + func testDecide_with_holdout_options_excludeVariables() { + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + + let featureKey = "feature_1" + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey,options: [.excludeVariables]) + XCTAssertEqual(decision.variationKey, "key_holdout_variation") + XCTAssertFalse(decision.enabled) + XCTAssertTrue(decision.variables.isEmpty) + } + + func testDecide_defaultDecideOption() { + let featureKey = "feature_2" + let feature_id = "4482920078" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [feature_id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected = try! optimizely.getAllFeatureVariables(featureKey: featureKey, userId: kUserId) + + var user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + var decision = user.decide(key: featureKey) + + XCTAssert(decision == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected, + ruleKey: "key_holdout", + flagKey: featureKey, + userContext: user, + reasons: [])) + + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + defaultDecideOptions: [.excludeVariables]) + + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) + optimizely.config!.project.holdouts = [holdout] + + user = optimizely.createUserContext(userId: kUserId) + decision = user.decide(key: featureKey) + + XCTAssertTrue(decision.variables.isEmpty) + + } + + func test_decide_with_holdout_included_flags() { + let featureKey1 = "feature_1" + let feature1_Id = "4482920077" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [feature1_Id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId) + + let decision1 = user.decide(key: featureKey1) + + XCTAssert(decision1 == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected1, + ruleKey: "key_holdout", + flagKey: featureKey1, + userContext: user, + reasons: [])) + } + + func test_decide_for_keys_with_holdout_included_flags() { + let featureKey1 = "feature_1" + let feature1_Id = "4482920077" + let featureKey2 = "feature_2" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [feature1_Id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + let user = optimizely.createUserContext(userId: kUserId) + + let decisions = user.decide(keys: [featureKey1, featureKey2]) + + XCTAssert(decisions.count == 2) + + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected1, + ruleKey: "key_holdout", + flagKey: featureKey1, + userContext: user, + reasons: [])) + + XCTAssert(decisions[featureKey2]! == OptimizelyDecision(variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [])) + } +} + +// MARK:- Decide All + +extension OptimizelyUserContextTests_Decide_Holdouts { + func testDecideAll_with_global_holdout() { + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let featureKey3 = "feature_3" + + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + let variablesExpected3 = OptimizelyJSON.createEmpty() + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll() + + XCTAssert(decisions.count == 3) + + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected1, + ruleKey: "key_holdout", + flagKey: featureKey1, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey2]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected2, + ruleKey: "key_holdout", + flagKey: featureKey2, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey3]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected3, + ruleKey: "key_holdout", + flagKey: featureKey3, + userContext: user, + reasons: [])) + } + + func testDecideAll_with_holdout_included_flags() { + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let feature2_id = "4482920078" + let featureKey3 = "feature_3" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [feature2_id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + let variablesExpected3 = OptimizelyJSON.createEmpty() + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll() + + XCTAssert(decisions.count == 3) + + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: featureKey1, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey2]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected2, + ruleKey: "key_holdout", + flagKey: featureKey2, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey3]! == OptimizelyDecision(variationKey: nil, + enabled: false, + variables: variablesExpected3, + ruleKey: nil, + flagKey: featureKey3, + userContext: user, + reasons: [])) + } + + func testDecideAll_with_holdout_excluded_flags() { + let featureKey1 = "feature_1" + let featureKey2 = "feature_2" + let feature2_id = "4482920078" + let featureKey3 = "feature_3" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.excludedFlags = [feature2_id] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: featureKey1, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: featureKey2, userId: kUserId) + let variablesExpected3 = OptimizelyJSON.createEmpty() + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll() + + XCTAssert(decisions.count == 3) + + XCTAssert(decisions[featureKey1]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected1, + ruleKey: "key_holdout", + flagKey: featureKey1, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey2]! == OptimizelyDecision(variationKey: "variation_with_traffic", + enabled: true, + variables: variablesExpected2, + ruleKey: "exp_no_audience", + flagKey: featureKey2, + userContext: user, + reasons: [])) + XCTAssert(decisions[featureKey3]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected3, + ruleKey: "key_holdout", + flagKey: featureKey3, + userContext: user, + reasons: [])) + } + + func testDecideAll_with_multiple_holdouts() { + let feature1 = (key: "feature_1", id: "4482920077") + let feature2 = (key: "feature_2", id: "4482920078") + let feature3 = (key: "feature_3", id: "44829230000") + + /// Applicable to feature (1, 2, 3) + let gHoldout = try! OTUtils.model(from: sampleHoldout) as Holdout + + var includedHoldout = gHoldout + includedHoldout.id = "holdout_id_included" + includedHoldout.key = "holdout_key_included" + includedHoldout.trafficAllocation[0].endOfRange = 2000 + /// Applicable to feature 2 + includedHoldout.includedFlags = [feature2.id] + + var excludedHoldout = gHoldout + excludedHoldout.id = "holdout_id_excluded" + excludedHoldout.key = "holdout_key_excluded" + /// Applicable to feature 3 + excludedHoldout.excludedFlags = [feature1.id, feature2.id] + excludedHoldout.trafficAllocation[0].endOfRange = 2000 + + optimizely.config!.project.holdouts = [gHoldout, includedHoldout, excludedHoldout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 1000)) + optimizely.decisionService = mockDecisionService + + let variablesExpected1 = try! optimizely.getAllFeatureVariables(featureKey: feature1.key, userId: kUserId) + let variablesExpected2 = try! optimizely.getAllFeatureVariables(featureKey: feature2.key, userId: kUserId) + let variablesExpected3 = OptimizelyJSON.createEmpty() + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll() + + XCTAssert(decisions.count == 3) + + XCTAssert(decisions[feature1.key]! == OptimizelyDecision(variationKey: "a", + enabled: true, + variables: variablesExpected1, + ruleKey: "exp_with_audience", + flagKey: feature1.key, + userContext: user, + reasons: [])) + XCTAssert(decisions[feature2.key]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected2, + ruleKey: "holdout_key_included", + flagKey: feature2.key, + userContext: user, + reasons: [])) + XCTAssert(decisions[feature3.key]! == OptimizelyDecision(variationKey: "key_holdout_variation", + enabled: false, + variables: variablesExpected3, + ruleKey: "holdout_key_excluded", + flagKey: feature3.key, + userContext: user, + reasons: [])) + } + + func testDecideAll_with_holdouts_options_enabledFlagsOnly() { + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId, attributes: ["gender": "f"]) + let decisions = user.decideAll(options: [.enabledFlagsOnly]) + + XCTAssert(decisions.count == 0) + } +} + +// MARK: - impression events + +extension OptimizelyUserContextTests_Decide_Holdouts { + func testDecide_sendImpression() { + let featureKey = "feature_2" + + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey) + + optimizely.eventLock.sync{} + + XCTAssertEqual(decision.variationKey, "key_holdout_variation") + XCTAssertFalse(decision.enabled) + XCTAssertFalse(eventDispatcher.events.isEmpty) + + let eventSent = eventDispatcher.events.first! + let event = try! JSONDecoder().decode(BatchEvent.self, from: eventSent.body) + let eventDecision: Decision = event.visitors[0].snapshots[0].decisions![0] + let metadata = eventDecision.metaData + + let desc = eventSent.description + XCTAssert(desc.contains("campaign_activated")) + + XCTAssertEqual(eventDecision.experimentID, "id_holdout") + XCTAssertEqual(eventDecision.variationID, "id_holdout_variation") + + XCTAssertEqual(metadata.flagKey, "feature_2") + XCTAssertEqual(metadata.ruleKey, "key_holdout") + XCTAssertEqual(metadata.ruleType, "holdout") + XCTAssertEqual(metadata.variationKey, "key_holdout_variation") + XCTAssertEqual(metadata.enabled, false) + } + + func testDecideError_doNotSendImpression() { + let featureKey = "invalid" // invalid flag + + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey) + + optimizely.eventLock.sync{} + + XCTAssertNil(decision.variationKey) + XCTAssertFalse(decision.enabled) + XCTAssert(eventDispatcher.events.isEmpty) + } + + func testDecide_sendImpression_with_disable_tracking() { + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let featureKey = "feature_2" + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey, options: [.disableDecisionEvent]) + XCTAssertEqual(decision.variationKey, "key_holdout_variation") + XCTAssertFalse(decision.enabled) + optimizely.eventLock.sync{} + XCTAssert(eventDispatcher.events.isEmpty) + } + + func testDecide_sendImpression_withSendFlagDecisionsOff() { + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + optimizely.config?.project.sendFlagDecisions = false + + let featureKey = "feature_2" + + let user = optimizely.createUserContext(userId: kUserId) + let decision = user.decide(key: featureKey) + XCTAssertEqual(decision.variationKey, "key_holdout_variation") + XCTAssertFalse(decision.enabled) + optimizely.eventLock.sync{} + XCTAssert(eventDispatcher.events.isEmpty) + } +} diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift index a08ba0e5..f3461b64 100644 --- a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_Reasons.swift @@ -17,7 +17,7 @@ import XCTest class OptimizelyUserContextTests_Decide_Reasons: XCTestCase { - + /// Need to add testcases for holdout let kUserId = "tester" var optimizely: OptimizelyClient! diff --git a/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift new file mode 100644 index 00000000..ff93122f --- /dev/null +++ b/Tests/OptimizelyTests-Common/OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift @@ -0,0 +1,199 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// 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 + +class OptimizelyUserContextTests_Decide_With_Holdouts_Reasons: XCTestCase { + let kUserId = "tester" + var optimizely: OptimizelyClient! + + var kAttributesCountryMatch: [String: Any] = ["country": "US"] + var kAttributesCountryNotMatch: [String: Any] = ["country": "ca"] + + var sampleHoldout: [String: Any] { + return [ + "status": "Running", + "id": "id_holdout", + "key": "key_holdout", + "layerId": "10420273888", + "trafficAllocation": [ + ["entityId": "id_holdout_variation", "endOfRange": 500] + ], + "audienceIds": [], + "variations": [ + [ + "variables": [], + "id": "id_holdout_variation", + "key": "key_holdout_variation" + ] + ], + "includedFlags": [], + "excludedFlags": [] + ] + } + + override func setUp() { + super.setUp() + + optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, + userProfileService: OTUtils.createClearUserProfileService()) + + try! optimizely.start(datafile: OTUtils.loadJSONDatafile("decide_datafile")!) + } + + /// Test when user is bucketed into the global holdout + func testDecideReasons_userBucketedIntoGlobalHoldout() { + let featureKey = "feature_1" + + let holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + // Assertions + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertEqual(decision.variationKey, "key_holdout_variation", "Expected variationKey to be 'key_holdout_variation'") + XCTAssertFalse(decision.enabled, "Feature should be disabled in holdout") + XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) + } + + /// Test when user is bucketed into the included flags holdout for feature_1 + func testDecideReasons_userBucketedIntoIncludedHoldout() { + let featureKey = "feature_1" + let featureId = "4482920077" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.includedFlags = [featureId] + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + // Assertions + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertEqual(decision.variationKey, "key_holdout_variation", "Expected variationKey to be 'key_holdout_variation'") + XCTAssertFalse(decision.enabled, "Feature should be disabled in holdout") + XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) + } + + /// Test when user is not bucketed into any holdout for feature_2 (excluded) + func testDecideReasons_userNotBucketedIntoExcludedHoldout() { + // Global holdout with 5% traffice + let holdout1 = try! OTUtils.model(from: sampleHoldout) as Holdout + + let featureKey_2 = "feature_2" + let featureId_2 = "4482920078" + + var holdout2 = holdout1 + holdout2.id = "id_holdout_2" + holdout2.key = "key_holdout_2" + + // Global holdout with 10% traffice (featureId_2 excluded) + holdout2.trafficAllocation[0].endOfRange = 1000 + holdout2.excludedFlags = [featureId_2] + + // Bucket valud outside global holdout range but inside second holdout range + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 600)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout1, holdout2] + + let user = optimizely.createUserContext(userId: kUserId) + // Call decide with reasons + let decision = user.decide(key: featureKey_2, options: [.includeReasons]) + + // Assertions + XCTAssertEqual(decision.flagKey, "feature_2", "Expected flagKey to be 'feature_2'") + XCTAssert(decision.reasons.contains(LogMessage.userNotBucketedIntoHoldoutVariation(kUserId).reason)) + } + + /// Test when holdout is not running + func testDecideReasons_holdoutNotRunning() { + let featureKey = "feature_1" + + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + holdout.status = .draft + optimizely.config!.project.holdouts = [holdout] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + + let user = optimizely.createUserContext(userId: kUserId) + + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + + /// Doesn't get holdout decision, because holdout isn't running + /// Get decision for feature flag 1 + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertEqual(decision.ruleKey, "18322080788") + XCTAssertEqual(decision.variationKey, "18257766532") + XCTAssertTrue(decision.enabled) + XCTAssert(decision.reasons.contains(LogMessage.holdoutNotRunning("key_holdout").reason)) + } + + + /// Test when user meets audience conditions for holdout + func testDecideReasons_userDoesMeetConditionsForHoldout() { + let featureKey = "feature_1" + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + // Audience "13389130056" requires "country" = "US" + holdout.audienceIds = ["13389130056"] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch) + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + + // Assertions + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertEqual(decision.variationKey, "key_holdout_variation", "Expected variationKey to be 'key_holdout_variation'") + XCTAssertFalse(decision.enabled, "Feature should be disabled in holdout") + XCTAssert(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) + XCTAssert(decision.reasons.contains(LogMessage.userMeetsConditionsForHoldout(kUserId, "key_holdout").reason)) + } + + /// Test when user does not meet audience conditions for holdout + func testDecideReasons_userDoesntMeetConditionsForHoldout() { + let featureKey = "feature_1" + var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout + // Audience "13389130056" requires "country" = "US" + holdout.audienceIds = ["13389130056"] + + let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400)) + optimizely.decisionService = mockDecisionService + optimizely.config!.project.holdouts = [holdout] + + let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryNotMatch) + // Call decide with reasons + let decision = user.decide(key: featureKey, options: [.includeReasons]) + + XCTAssertEqual(decision.flagKey, "feature_1", "Expected flagKey to be 'feature_1'") + XCTAssertNotEqual(decision.variationKey, "key_holdout_variation", "Expected variationKey not to be 'key_holdout_variation'") + XCTAssertFalse(decision.reasons.contains(LogMessage.userBucketedIntoVariationInHoldout(kUserId, "key_holdout", "key_holdout_variation").reason)) + XCTAssert(decision.reasons.contains(LogMessage.userDoesntMeetConditionsForHoldout(kUserId, "key_holdout").reason)) + } +} diff --git a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift index 8e5f6e3a..aba7150d 100644 --- a/Tests/OptimizelyTests-DataModel/HoldoutTests.swift +++ b/Tests/OptimizelyTests-DataModel/HoldoutTests.swift @@ -21,9 +21,8 @@ import XCTest class HoldoutTests: XCTestCase { static var variationData: [String: Any] = ["id": "553339214", - "key": "house", - "featureEnabled": true, - "variables": [["id": "553339214", "value": "100"]]] + "key": "house", + "featureEnabled": true] static var trafficAllocationData: [String: Any] = ["entityId": "553339214", "endOfRange": 5000] diff --git a/Tests/TestUtils/MockBucketer.swift b/Tests/TestUtils/MockBucketer.swift new file mode 100644 index 00000000..e8724407 --- /dev/null +++ b/Tests/TestUtils/MockBucketer.swift @@ -0,0 +1,40 @@ +// +// Copyright 2022, Optimizely, Inc. and contributors +// +// 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. +// + + +// MARK: - Helper for mocking bucketer + +class MockBucketer: DefaultBucketer { + var mockBucketValue: Int + + init(mockBucketValue: Int) { + self.mockBucketValue = mockBucketValue + super.init() + } + + override func generateBucketValue(bucketingId: String) -> Int { + return mockBucketValue + } +} + +// MARK: - Mock Decision Service + +class MockDecisionService: DefaultDecisionService { + init(bucketer: OPTBucketer, userProfileService: OPTUserProfileService = DefaultUserProfileService()) { + super.init(userProfileService: userProfileService, bucketer: bucketer) + } +} +