diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj index a4495471..830b41de 100644 --- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj +++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj @@ -88,6 +88,15 @@ Entity\Experiment.cs + + Entity\ExperimentCoreExtensions.cs + + + Entity\Holdout.cs + + + Entity\IExperimentCore.cs + Entity\FeatureDecision.cs @@ -215,6 +224,9 @@ Bucketing\ExperimentUtils + + Utils\HoldoutConfig.cs + Bucketing\UserProfileUtil diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj index 05785575..a87e9732 100644 --- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj +++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj @@ -90,6 +90,15 @@ Entity\Experiment.cs + + Entity\ExperimentCoreExtensions.cs + + + Entity\Holdout.cs + + + Entity\IExperimentCore.cs + Entity\FeatureDecision.cs @@ -214,6 +223,9 @@ Bucketing\ExperimentUtils + + Utils\HoldoutConfig.cs + Bucketing\UserProfileUtil diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj index b17f79e7..de6c6010 100644 --- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj +++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj @@ -26,6 +26,9 @@ + + + @@ -64,6 +67,7 @@ + diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj index b7114653..68e10108 100644 --- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj +++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj @@ -178,6 +178,15 @@ Entity\Experiment.cs + + Entity\ExperimentCoreExtensions.cs + + + Entity\Holdout.cs + + + Entity\IExperimentCore.cs + Entity\FeatureDecision.cs @@ -331,6 +340,9 @@ Utils\ExperimentUtils.cs + + Utils\HoldoutConfig.cs + Utils\Schema.cs diff --git a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs new file mode 100644 index 00000000..a850971d --- /dev/null +++ b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs @@ -0,0 +1,196 @@ +/* + * Copyright 2025, Optimizely + * + * 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. + */ + +using System; +using System.IO; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using OptimizelySDK.Entity; + +namespace OptimizelySDK.Tests +{ + [TestFixture] + public class HoldoutTests + { + private JObject testData; + + [SetUp] + public void Setup() + { + // Load test data + var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory, + "TestData", "HoldoutTestData.json"); + var jsonContent = File.ReadAllText(testDataPath); + testData = JObject.Parse(jsonContent); + } + + [Test] + public void TestHoldoutDeserialization() + { + // Test global holdout deserialization + var globalHoldoutJson = testData["globalHoldout"].ToString(); + var globalHoldout = JsonConvert.DeserializeObject(globalHoldoutJson); + + Assert.IsNotNull(globalHoldout); + Assert.AreEqual("holdout_global_1", globalHoldout.Id); + Assert.AreEqual("global_holdout", globalHoldout.Key); + Assert.AreEqual("Running", globalHoldout.Status); + Assert.AreEqual("layer_1", globalHoldout.LayerId); + Assert.IsNotNull(globalHoldout.Variations); + Assert.AreEqual(1, globalHoldout.Variations.Length); + Assert.IsNotNull(globalHoldout.TrafficAllocation); + Assert.AreEqual(1, globalHoldout.TrafficAllocation.Length); + Assert.IsNotNull(globalHoldout.IncludedFlags); + Assert.AreEqual(0, globalHoldout.IncludedFlags.Length); + Assert.IsNotNull(globalHoldout.ExcludedFlags); + Assert.AreEqual(0, globalHoldout.ExcludedFlags.Length); + } + + [Test] + public void TestHoldoutWithIncludedFlags() + { + var includedHoldoutJson = testData["includedFlagsHoldout"].ToString(); + var includedHoldout = JsonConvert.DeserializeObject(includedHoldoutJson); + + Assert.IsNotNull(includedHoldout); + Assert.AreEqual("holdout_included_1", includedHoldout.Id); + Assert.AreEqual("included_holdout", includedHoldout.Key); + Assert.IsNotNull(includedHoldout.IncludedFlags); + Assert.AreEqual(2, includedHoldout.IncludedFlags.Length); + Assert.Contains("flag_1", includedHoldout.IncludedFlags); + Assert.Contains("flag_2", includedHoldout.IncludedFlags); + Assert.IsNotNull(includedHoldout.ExcludedFlags); + Assert.AreEqual(0, includedHoldout.ExcludedFlags.Length); + } + + [Test] + public void TestHoldoutWithExcludedFlags() + { + var excludedHoldoutJson = testData["excludedFlagsHoldout"].ToString(); + var excludedHoldout = JsonConvert.DeserializeObject(excludedHoldoutJson); + + Assert.IsNotNull(excludedHoldout); + Assert.AreEqual("holdout_excluded_1", excludedHoldout.Id); + Assert.AreEqual("excluded_holdout", excludedHoldout.Key); + Assert.IsNotNull(excludedHoldout.IncludedFlags); + Assert.AreEqual(0, excludedHoldout.IncludedFlags.Length); + Assert.IsNotNull(excludedHoldout.ExcludedFlags); + Assert.AreEqual(2, excludedHoldout.ExcludedFlags.Length); + Assert.Contains("flag_3", excludedHoldout.ExcludedFlags); + Assert.Contains("flag_4", excludedHoldout.ExcludedFlags); + } + + [Test] + public void TestHoldoutWithEmptyFlags() + { + var globalHoldoutJson = testData["globalHoldout"].ToString(); + var globalHoldout = JsonConvert.DeserializeObject(globalHoldoutJson); + + Assert.IsNotNull(globalHoldout); + Assert.IsNotNull(globalHoldout.IncludedFlags); + Assert.AreEqual(0, globalHoldout.IncludedFlags.Length); + Assert.IsNotNull(globalHoldout.ExcludedFlags); + Assert.AreEqual(0, globalHoldout.ExcludedFlags.Length); + } + + [Test] + public void TestHoldoutEquality() + { + var holdoutJson = testData["globalHoldout"].ToString(); + var holdout1 = JsonConvert.DeserializeObject(holdoutJson); + var holdout2 = JsonConvert.DeserializeObject(holdoutJson); + + Assert.IsNotNull(holdout1); + Assert.IsNotNull(holdout2); + // Note: This test depends on how Holdout implements equality + // If Holdout doesn't override Equals, this will test reference equality + // You may need to implement custom equality logic for Holdout + } + + [Test] + public void TestHoldoutStatusParsing() + { + var globalHoldoutJson = testData["globalHoldout"].ToString(); + var globalHoldout = JsonConvert.DeserializeObject(globalHoldoutJson); + + Assert.IsNotNull(globalHoldout); + Assert.AreEqual("Running", globalHoldout.Status); + + // Test that the holdout is considered activated when status is "Running" + // This assumes there's an IsActivated property or similar logic + // Adjust based on actual Holdout implementation + } + + [Test] + public void TestHoldoutVariationsDeserialization() + { + var holdoutJson = testData["includedFlagsHoldout"].ToString(); + var holdout = JsonConvert.DeserializeObject(holdoutJson); + + Assert.IsNotNull(holdout); + Assert.IsNotNull(holdout.Variations); + Assert.AreEqual(1, holdout.Variations.Length); + + var variation = holdout.Variations[0]; + Assert.AreEqual("var_2", variation.Id); + Assert.AreEqual("treatment", variation.Key); + Assert.AreEqual(true, variation.FeatureEnabled); + } + + [Test] + public void TestHoldoutTrafficAllocationDeserialization() + { + var holdoutJson = testData["excludedFlagsHoldout"].ToString(); + var holdout = JsonConvert.DeserializeObject(holdoutJson); + + Assert.IsNotNull(holdout); + Assert.IsNotNull(holdout.TrafficAllocation); + Assert.AreEqual(1, holdout.TrafficAllocation.Length); + + var trafficAllocation = holdout.TrafficAllocation[0]; + Assert.AreEqual("var_3", trafficAllocation.EntityId); + Assert.AreEqual(10000, trafficAllocation.EndOfRange); + } + + [Test] + public void TestHoldoutNullSafety() + { + // Test that holdout can handle null/missing includedFlags and excludedFlags + var minimalHoldoutJson = @"{ + ""id"": ""test_holdout"", + ""key"": ""test_key"", + ""status"": ""Running"", + ""layerId"": ""test_layer"", + ""variations"": [], + ""trafficAllocation"": [], + ""audienceIds"": [], + ""audienceConditions"": [] + }"; + + var holdout = JsonConvert.DeserializeObject(minimalHoldoutJson); + + Assert.IsNotNull(holdout); + Assert.AreEqual("test_holdout", holdout.Id); + Assert.AreEqual("test_key", holdout.Key); + + // Verify that missing includedFlags and excludedFlags are handled properly + // This depends on how the Holdout entity handles missing properties + Assert.IsNotNull(holdout.IncludedFlags); + Assert.IsNotNull(holdout.ExcludedFlags); + } + } +} diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj index 6792d934..1db35b8f 100644 --- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj +++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj @@ -119,6 +119,7 @@ + @@ -126,12 +127,16 @@ + + + PreserveNewest + diff --git a/OptimizelySDK.Tests/ProjectConfigTest.cs b/OptimizelySDK.Tests/ProjectConfigTest.cs index 55d6e63b..7b4d0d7c 100644 --- a/OptimizelySDK.Tests/ProjectConfigTest.cs +++ b/OptimizelySDK.Tests/ProjectConfigTest.cs @@ -16,6 +16,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using Moq; using Newtonsoft.Json; @@ -1175,9 +1176,7 @@ public void TestGetAttributeIdWithReservedPrefix() Assert.AreEqual(reservedAttrConfig.GetAttributeId(reservedPrefixAttrKey), reservedAttrConfig.GetAttribute(reservedPrefixAttrKey).Id); LoggerMock.Verify(l => l.Log(LogLevel.WARN, - $@"Attribute {reservedPrefixAttrKey} unexpectedly has reserved prefix { - DatafileProjectConfig.RESERVED_ATTRIBUTE_PREFIX - }; using attribute ID instead of reserved attribute name.")); + $@"Attribute {reservedPrefixAttrKey} unexpectedly has reserved prefix {DatafileProjectConfig.RESERVED_ATTRIBUTE_PREFIX}; using attribute ID instead of reserved attribute name.")); } [Test] @@ -1351,5 +1350,119 @@ public void TestProjectConfigWithOtherIntegrationsInCollection() Assert.IsNull(datafileProjectConfig.HostForOdp); Assert.IsNull(datafileProjectConfig.PublicKeyForOdp); } + + #region Holdout Integration Tests + + [Test] + public void TestHoldoutDeserialization_FromDatafile() + { + // Test that holdouts can be deserialized from a datafile with holdouts + var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory, + "TestData", "HoldoutTestData.json"); + var jsonContent = File.ReadAllText(testDataPath); + var testData = JObject.Parse(jsonContent); + + var datafileJson = testData["datafileWithHoldouts"].ToString(); + + var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson, + new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig; + + Assert.IsNotNull(datafileProjectConfig.Holdouts); + Assert.AreEqual(3, datafileProjectConfig.Holdouts.Length); + Assert.IsNotNull(datafileProjectConfig.HoldoutIdMap); + Assert.AreEqual(3, datafileProjectConfig.HoldoutIdMap.Count); + + // Verify specific holdouts are present + Assert.IsTrue(datafileProjectConfig.HoldoutIdMap.ContainsKey("holdout_global_1")); + Assert.IsTrue(datafileProjectConfig.HoldoutIdMap.ContainsKey("holdout_included_1")); + Assert.IsTrue(datafileProjectConfig.HoldoutIdMap.ContainsKey("holdout_excluded_1")); + } + + [Test] + public void TestGetHoldoutsForFlag_Integration() + { + var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory, + "TestData", "HoldoutTestData.json"); + var jsonContent = File.ReadAllText(testDataPath); + var testData = JObject.Parse(jsonContent); + + var datafileJson = testData["datafileWithHoldouts"].ToString(); + + var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson, + new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig; + + // Test GetHoldoutsForFlag method + var holdoutsForFlag1 = datafileProjectConfig.GetHoldoutsForFlag("flag_1"); + Assert.IsNotNull(holdoutsForFlag1); + Assert.AreEqual(3, holdoutsForFlag1.Length); // Global + excluded holdout (applies to all except flag_3/flag_4) + included holdout + + var holdoutsForFlag3 = datafileProjectConfig.GetHoldoutsForFlag("flag_3"); + Assert.IsNotNull(holdoutsForFlag3); + Assert.AreEqual(1, holdoutsForFlag3.Length); // Only true global (excluded holdout excludes flag_3) + + var holdoutsForUnknownFlag = datafileProjectConfig.GetHoldoutsForFlag("unknown_flag"); + Assert.IsNotNull(holdoutsForUnknownFlag); + Assert.AreEqual(2, holdoutsForUnknownFlag.Length); // Global + excluded holdout (unknown_flag not in excluded list) + } + + [Test] + public void TestGetHoldout_Integration() + { + var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory, + "TestData", "HoldoutTestData.json"); + var jsonContent = File.ReadAllText(testDataPath); + var testData = JObject.Parse(jsonContent); + + var datafileJson = testData["datafileWithHoldouts"].ToString(); + + var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson, + new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig; + + // Test GetHoldout method + var globalHoldout = datafileProjectConfig.GetHoldout("holdout_global_1"); + Assert.IsNotNull(globalHoldout); + Assert.AreEqual("holdout_global_1", globalHoldout.Id); + Assert.AreEqual("global_holdout", globalHoldout.Key); + + var invalidHoldout = datafileProjectConfig.GetHoldout("invalid_id"); + Assert.IsNull(invalidHoldout); + } + + [Test] + public void TestMissingHoldoutsField_BackwardCompatibility() + { + // Test that a datafile without holdouts field still works + var datafileWithoutHoldouts = @"{ + ""version"": ""4"", + ""rollouts"": [], + ""projectId"": ""test_project"", + ""experiments"": [], + ""groups"": [], + ""attributes"": [], + ""audiences"": [], + ""layers"": [], + ""events"": [], + ""revision"": ""1"", + ""featureFlags"": [] + }"; + + var datafileProjectConfig = DatafileProjectConfig.Create(datafileWithoutHoldouts, + new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig; + + Assert.IsNotNull(datafileProjectConfig.Holdouts); + Assert.AreEqual(0, datafileProjectConfig.Holdouts.Length); + Assert.IsNotNull(datafileProjectConfig.HoldoutIdMap); + Assert.AreEqual(0, datafileProjectConfig.HoldoutIdMap.Count); + + // Methods should still work with empty holdouts + var holdouts = datafileProjectConfig.GetHoldoutsForFlag("any_flag"); + Assert.IsNotNull(holdouts); + Assert.AreEqual(0, holdouts.Length); + + var holdout = datafileProjectConfig.GetHoldout("any_id"); + Assert.IsNull(holdout); + } + + #endregion } } diff --git a/OptimizelySDK.Tests/TestData/HoldoutTestData.json b/OptimizelySDK.Tests/TestData/HoldoutTestData.json new file mode 100644 index 00000000..b5c17b26 --- /dev/null +++ b/OptimizelySDK.Tests/TestData/HoldoutTestData.json @@ -0,0 +1,192 @@ +{ + "globalHoldout": { + "id": "holdout_global_1", + "key": "global_holdout", + "status": "Running", + "layerId": "layer_1", + "variations": [ + { + "id": "var_1", + "key": "control", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "var_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedFlags": [], + "excludedFlags": [] + }, + "includedFlagsHoldout": { + "id": "holdout_included_1", + "key": "included_holdout", + "status": "Running", + "layerId": "layer_2", + "variations": [ + { + "id": "var_2", + "key": "treatment", + "featureEnabled": true, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "var_2", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedFlags": ["flag_1", "flag_2"], + "excludedFlags": [] + }, + "excludedFlagsHoldout": { + "id": "holdout_excluded_1", + "key": "excluded_holdout", + "status": "Running", + "layerId": "layer_3", + "variations": [ + { + "id": "var_3", + "key": "excluded_var", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "var_3", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedFlags": [], + "excludedFlags": ["flag_3", "flag_4"] + }, + "datafileWithHoldouts": { + "version": "4", + "rollouts": [], + "projectId": "test_project", + "experiments": [], + "groups": [], + "attributes": [], + "audiences": [], + "layers": [], + "events": [], + "revision": "1", + "accountId": "12345", + "anonymizeIP": false, + "featureFlags": [ + { + "id": "flag_1", + "key": "test_flag_1", + "experimentIds": [], + "rolloutId": "", + "variables": [] + }, + { + "id": "flag_2", + "key": "test_flag_2", + "experimentIds": [], + "rolloutId": "", + "variables": [] + }, + { + "id": "flag_3", + "key": "test_flag_3", + "experimentIds": [], + "rolloutId": "", + "variables": [] + }, + { + "id": "flag_4", + "key": "test_flag_4", + "experimentIds": [], + "rolloutId": "", + "variables": [] + } + ], + "holdouts": [ + { + "id": "holdout_global_1", + "key": "global_holdout", + "status": "Running", + "layerId": "layer_1", + "variations": [ + { + "id": "var_1", + "key": "control", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "var_1", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedFlags": [], + "excludedFlags": [] + }, + { + "id": "holdout_included_1", + "key": "included_holdout", + "status": "Running", + "layerId": "layer_2", + "variations": [ + { + "id": "var_2", + "key": "treatment", + "featureEnabled": true, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "var_2", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedFlags": ["flag_1", "flag_2"], + "excludedFlags": [] + }, + { + "id": "holdout_excluded_1", + "key": "excluded_holdout", + "status": "Running", + "layerId": "layer_3", + "variations": [ + { + "id": "var_3", + "key": "excluded_var", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "var_3", + "endOfRange": 10000 + } + ], + "audienceIds": [], + "audienceConditions": [], + "includedFlags": [], + "excludedFlags": ["flag_3", "flag_4"] + } + ] + } +} diff --git a/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs new file mode 100644 index 00000000..23d3ecfd --- /dev/null +++ b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs @@ -0,0 +1,345 @@ +/* + * Copyright 2025, Optimizely + * + * 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. + */ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using OptimizelySDK.Entity; +using OptimizelySDK.Utils; + +namespace OptimizelySDK.Tests +{ + [TestFixture] + public class HoldoutConfigTests + { + private JObject testData; + private Holdout globalHoldout; + private Holdout includedHoldout; + private Holdout excludedHoldout; + + [SetUp] + public void Setup() + { + // Load test data + var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory, + "TestData", "HoldoutTestData.json"); + var jsonContent = File.ReadAllText(testDataPath); + testData = JObject.Parse(jsonContent); + + // Deserialize test holdouts + globalHoldout = JsonConvert.DeserializeObject(testData["globalHoldout"].ToString()); + includedHoldout = JsonConvert.DeserializeObject(testData["includedFlagsHoldout"].ToString()); + excludedHoldout = JsonConvert.DeserializeObject(testData["excludedFlagsHoldout"].ToString()); + } + + [Test] + public void TestEmptyHoldouts_ShouldHaveEmptyMaps() + { + var config = new HoldoutConfig(new Holdout[0]); + + Assert.IsNotNull(config.HoldoutIdMap); + Assert.AreEqual(0, config.HoldoutIdMap.Count); + Assert.IsNotNull(config.GetHoldoutsForFlag("any_flag")); + Assert.AreEqual(0, config.GetHoldoutsForFlag("any_flag").Count); + } + + [Test] + public void TestHoldoutIdMapping() + { + var allHoldouts = new[] { globalHoldout, includedHoldout, excludedHoldout }; + var config = new HoldoutConfig(allHoldouts); + + Assert.IsNotNull(config.HoldoutIdMap); + Assert.AreEqual(3, config.HoldoutIdMap.Count); + + Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_global_1")); + Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_included_1")); + Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_excluded_1")); + + Assert.AreEqual(globalHoldout.Id, config.HoldoutIdMap["holdout_global_1"].Id); + Assert.AreEqual(includedHoldout.Id, config.HoldoutIdMap["holdout_included_1"].Id); + Assert.AreEqual(excludedHoldout.Id, config.HoldoutIdMap["holdout_excluded_1"].Id); + } + + [Test] + public void TestGetHoldoutById() + { + var allHoldouts = new[] { globalHoldout, includedHoldout, excludedHoldout }; + var config = new HoldoutConfig(allHoldouts); + + var retrievedGlobal = config.GetHoldout("holdout_global_1"); + var retrievedIncluded = config.GetHoldout("holdout_included_1"); + var retrievedExcluded = config.GetHoldout("holdout_excluded_1"); + + Assert.IsNotNull(retrievedGlobal); + Assert.AreEqual("holdout_global_1", retrievedGlobal.Id); + Assert.AreEqual("global_holdout", retrievedGlobal.Key); + + Assert.IsNotNull(retrievedIncluded); + Assert.AreEqual("holdout_included_1", retrievedIncluded.Id); + Assert.AreEqual("included_holdout", retrievedIncluded.Key); + + Assert.IsNotNull(retrievedExcluded); + Assert.AreEqual("holdout_excluded_1", retrievedExcluded.Id); + Assert.AreEqual("excluded_holdout", retrievedExcluded.Key); + } + + [Test] + public void TestGetHoldoutById_InvalidId() + { + var allHoldouts = new[] { globalHoldout }; + var config = new HoldoutConfig(allHoldouts); + + var result = config.GetHoldout("invalid_id"); + Assert.IsNull(result); + } + + [Test] + public void TestGlobalHoldoutsForFlag() + { + var allHoldouts = new[] { globalHoldout }; + var config = new HoldoutConfig(allHoldouts); + + var holdoutsForFlag = config.GetHoldoutsForFlag("any_flag_id"); + + Assert.IsNotNull(holdoutsForFlag); + Assert.AreEqual(1, holdoutsForFlag.Count); + Assert.AreEqual("holdout_global_1", holdoutsForFlag[0].Id); + } + + [Test] + public void TestIncludedHoldoutsForFlag() + { + var allHoldouts = new[] { includedHoldout }; + var config = new HoldoutConfig(allHoldouts); + + // Test for included flags + var holdoutsForFlag1 = config.GetHoldoutsForFlag("flag_1"); + var holdoutsForFlag2 = config.GetHoldoutsForFlag("flag_2"); + var holdoutsForOtherFlag = config.GetHoldoutsForFlag("other_flag"); + + Assert.IsNotNull(holdoutsForFlag1); + Assert.AreEqual(1, holdoutsForFlag1.Count); + Assert.AreEqual("holdout_included_1", holdoutsForFlag1[0].Id); + + Assert.IsNotNull(holdoutsForFlag2); + Assert.AreEqual(1, holdoutsForFlag2.Count); + Assert.AreEqual("holdout_included_1", holdoutsForFlag2[0].Id); + + Assert.IsNotNull(holdoutsForOtherFlag); + Assert.AreEqual(0, holdoutsForOtherFlag.Count); + } + + [Test] + public void TestExcludedHoldoutsForFlag() + { + var allHoldouts = new[] { excludedHoldout }; + var config = new HoldoutConfig(allHoldouts); + + // Test for excluded flags - should NOT appear + var holdoutsForFlag3 = config.GetHoldoutsForFlag("flag_3"); + var holdoutsForFlag4 = config.GetHoldoutsForFlag("flag_4"); + var holdoutsForOtherFlag = config.GetHoldoutsForFlag("other_flag"); + + // Excluded flags should not get this holdout + Assert.IsNotNull(holdoutsForFlag3); + Assert.AreEqual(0, holdoutsForFlag3.Count); + + Assert.IsNotNull(holdoutsForFlag4); + Assert.AreEqual(0, holdoutsForFlag4.Count); + + // Other flags should get this global holdout (with exclusions) + Assert.IsNotNull(holdoutsForOtherFlag); + Assert.AreEqual(1, holdoutsForOtherFlag.Count); + Assert.AreEqual("holdout_excluded_1", holdoutsForOtherFlag[0].Id); + } + + [Test] + public void TestHoldoutOrdering_GlobalThenIncluded() + { + // Create additional test holdouts with specific IDs for ordering test + var global1 = CreateTestHoldout("global_1", "g1", new string[0], new string[0]); + var global2 = CreateTestHoldout("global_2", "g2", new string[0], new string[0]); + var included = CreateTestHoldout("included_1", "i1", new[] { "test_flag" }, new string[0]); + + var allHoldouts = new[] { included, global1, global2 }; + var config = new HoldoutConfig(allHoldouts); + + var holdoutsForFlag = config.GetHoldoutsForFlag("test_flag"); + + Assert.IsNotNull(holdoutsForFlag); + Assert.AreEqual(3, holdoutsForFlag.Count); + + // Should be: global1, global2, included (global first, then included) + var ids = holdoutsForFlag.Select(h => h.Id).ToArray(); + Assert.Contains("global_1", ids); + Assert.Contains("global_2", ids); + Assert.Contains("included_1", ids); + + // Included should be last (after globals) + Assert.AreEqual("included_1", holdoutsForFlag.Last().Id); + } + + [Test] + public void TestComplexFlagScenarios_MultipleRules() + { + var global1 = CreateTestHoldout("global_1", "g1", new string[0], new string[0]); + var global2 = CreateTestHoldout("global_2", "g2", new string[0], new string[0]); + var included = CreateTestHoldout("included_1", "i1", new[] { "flag_1" }, new string[0]); + var excluded = CreateTestHoldout("excluded_1", "e1", new string[0], new[] { "flag_2" }); + + var allHoldouts = new[] { included, excluded, global1, global2 }; + var config = new HoldoutConfig(allHoldouts); + + // Test flag_1: should get globals + excluded global + included + var holdoutsForFlag1 = config.GetHoldoutsForFlag("flag_1"); + Assert.AreEqual(4, holdoutsForFlag1.Count); + var flag1Ids = holdoutsForFlag1.Select(h => h.Id).ToArray(); + Assert.Contains("global_1", flag1Ids); + Assert.Contains("global_2", flag1Ids); + Assert.Contains("excluded_1", flag1Ids); // excluded global should appear for other flags + Assert.Contains("included_1", flag1Ids); + + // Test flag_2: should get only regular globals (excluded global should NOT appear) + var holdoutsForFlag2 = config.GetHoldoutsForFlag("flag_2"); + Assert.AreEqual(2, holdoutsForFlag2.Count); + var flag2Ids = holdoutsForFlag2.Select(h => h.Id).ToArray(); + Assert.Contains("global_1", flag2Ids); + Assert.Contains("global_2", flag2Ids); + Assert.IsFalse(flag2Ids.Contains("excluded_1")); // Should be excluded + Assert.IsFalse(flag2Ids.Contains("included_1")); // Not included for this flag + + // Test flag_3: should get globals + excluded global + var holdoutsForFlag3 = config.GetHoldoutsForFlag("flag_3"); + Assert.AreEqual(3, holdoutsForFlag3.Count); + var flag3Ids = holdoutsForFlag3.Select(h => h.Id).ToArray(); + Assert.Contains("global_1", flag3Ids); + Assert.Contains("global_2", flag3Ids); + Assert.Contains("excluded_1", flag3Ids); + } + + [Test] + public void TestExcludedHoldout_ShouldNotAppearInGlobal() + { + var global = CreateTestHoldout("global_1", "global", new string[0], new string[0]); + var excluded = CreateTestHoldout("excluded_1", "excluded", new string[0], new[] { "target_flag" }); + + var allHoldouts = new[] { global, excluded }; + var config = new HoldoutConfig(allHoldouts); + + var holdoutsForTargetFlag = config.GetHoldoutsForFlag("target_flag"); + + Assert.IsNotNull(holdoutsForTargetFlag); + Assert.AreEqual(1, holdoutsForTargetFlag.Count); + Assert.AreEqual("global_1", holdoutsForTargetFlag[0].Id); + // excluded should NOT appear for target_flag + } + + [Test] + public void TestCaching_SecondCallUsesCachedResult() + { + var allHoldouts = new[] { globalHoldout, includedHoldout }; + var config = new HoldoutConfig(allHoldouts); + + // First call + var firstResult = config.GetHoldoutsForFlag("flag_1"); + + // Second call - should use cache + var secondResult = config.GetHoldoutsForFlag("flag_1"); + + Assert.IsNotNull(firstResult); + Assert.IsNotNull(secondResult); + Assert.AreEqual(firstResult.Count, secondResult.Count); + + // Results should be the same (caching working) + for (int i = 0; i < firstResult.Count; i++) + { + Assert.AreEqual(firstResult[i].Id, secondResult[i].Id); + } + } + + [Test] + public void TestNullFlagId_ReturnsEmptyList() + { + var config = new HoldoutConfig(new[] { globalHoldout }); + + var result = config.GetHoldoutsForFlag(null); + + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void TestEmptyFlagId_ReturnsEmptyList() + { + var config = new HoldoutConfig(new[] { globalHoldout }); + + var result = config.GetHoldoutsForFlag(""); + + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void TestGetHoldoutsForFlag_WithNullHoldouts() + { + var config = new HoldoutConfig(null); + + var result = config.GetHoldoutsForFlag("any_flag"); + + Assert.IsNotNull(result); + Assert.AreEqual(0, result.Count); + } + + [Test] + public void TestUpdateHoldoutMapping() + { + var config = new HoldoutConfig(new[] { globalHoldout }); + + // Initial state + Assert.AreEqual(1, config.HoldoutIdMap.Count); + + // Update with new holdouts + config.UpdateHoldoutMapping(new[] { globalHoldout, includedHoldout }); + + Assert.AreEqual(2, config.HoldoutIdMap.Count); + Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_global_1")); + Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_included_1")); + } + + // Helper method to create test holdouts + private Holdout CreateTestHoldout(string id, string key, string[] includedFlags, string[] excludedFlags) + { + return new Holdout + { + Id = id, + Key = key, + Status = "Running", + LayerId = "test_layer", + Variations = new Variation[0], + TrafficAllocation = new TrafficAllocation[0], + AudienceIds = new string[0], + AudienceConditions = null, + IncludedFlags = includedFlags, + ExcludedFlags = excludedFlags + }; + } + } +} diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs index cb248f8c..419ff03c 100644 --- a/OptimizelySDK/Config/DatafileProjectConfig.cs +++ b/OptimizelySDK/Config/DatafileProjectConfig.cs @@ -216,6 +216,13 @@ private Dictionary> _VariationIdMap public Dictionary RolloutIdMap => _RolloutIdMap; + /// + /// Associative array of Holdout ID to Holdout(s) in the datafile + /// + private Dictionary _HoldoutIdMap; + + public Dictionary HoldoutIdMap => _HoldoutIdMap; + /// /// Associative array of experiment IDs that exist in any feature /// for checking that experiment is a feature experiment. @@ -232,6 +239,11 @@ private Dictionary> _VariationIdMap public Dictionary> FlagVariationMap => _FlagVariationMap; + /// + /// Holdout configuration manager for flag-to-holdout relationships. + /// + private HoldoutConfig _holdoutConfig; + //========================= Interfaces =========================== /// @@ -286,6 +298,11 @@ private Dictionary> _VariationIdMap /// public Rollout[] Rollouts { get; set; } + /// + /// Associative list of Holdouts. + /// + public Holdout[] Holdouts { get; set; } + /// /// Associative list of Integrations. /// @@ -309,6 +326,7 @@ private void Initialize() TypedAudiences = TypedAudiences ?? new Audience[0]; FeatureFlags = FeatureFlags ?? new FeatureFlag[0]; Rollouts = Rollouts ?? new Rollout[0]; + Holdouts = Holdouts ?? new Holdout[0]; Integrations = Integrations ?? new Integration[0]; _ExperimentKeyMap = new Dictionary(); @@ -327,6 +345,8 @@ private void Initialize() f => f.Key, true); _RolloutIdMap = ConfigParser.GenerateMap(Rollouts, r => r.Id.ToString(), true); + _HoldoutIdMap = ConfigParser.GenerateMap(Holdouts, + h => h.Id, true); // Overwrite similar items in audience id map with typed audience id map. var typedAudienceIdMap = ConfigParser.GenerateMap(TypedAudiences, @@ -450,6 +470,9 @@ private void Initialize() } _FlagVariationMap = flagToVariationsMap; + + // Initialize HoldoutConfig for managing flag-to-holdout relationships + _holdoutConfig = new HoldoutConfig(Holdouts ?? new Holdout[0]); } /// @@ -492,8 +515,7 @@ private static DatafileProjectConfig GetConfig(string configData) !(((int)supportedVersion).ToString() == config.Version))) { throw new ConfigParseException( - $@"This version of the C# SDK does not support the given datafile version: { - config.Version}"); + $@"This version of the C# SDK does not support the given datafile version: {config.Version}"); } return config; @@ -632,8 +654,7 @@ public Variation GetVariationFromKey(string experimentKey, string variationKey) return _VariationKeyMap[experimentKey][variationKey]; } - var message = $@"No variation key ""{variationKey - }"" defined in datafile for experiment ""{experimentKey}""."; + var message = $@"No variation key ""{variationKey}"" defined in datafile for experiment ""{experimentKey}""."; Logger.Log(LogLevel.ERROR, message); ErrorHandler.HandleError( new InvalidVariationException("Provided variation is not in datafile.")); @@ -655,8 +676,7 @@ public Variation GetVariationFromKeyByExperimentId(string experimentId, string v return _VariationKeyMapByExperimentId[experimentId][variationKey]; } - var message = $@"No variation key ""{variationKey - }"" defined in datafile for experiment ""{experimentId}""."; + var message = $@"No variation key ""{variationKey}"" defined in datafile for experiment ""{experimentId}""."; Logger.Log(LogLevel.ERROR, message); ErrorHandler.HandleError( new InvalidVariationException("Provided variation is not in datafile.")); @@ -678,8 +698,7 @@ public Variation GetVariationFromId(string experimentKey, string variationId) return _VariationIdMap[experimentKey][variationId]; } - var message = $@"No variation ID ""{variationId - }"" defined in datafile for experiment ""{experimentKey}""."; + var message = $@"No variation ID ""{variationId}"" defined in datafile for experiment ""{experimentKey}""."; Logger.Log(LogLevel.ERROR, message); ErrorHandler.HandleError( new InvalidVariationException("Provided variation is not in datafile.")); @@ -701,8 +720,7 @@ public Variation GetVariationFromIdByExperimentId(string experimentId, string va return _VariationIdMapByExperimentId[experimentId][variationId]; } - var message = $@"No variation ID ""{variationId - }"" defined in datafile for experiment ""{experimentId}""."; + var message = $@"No variation ID ""{variationId}"" defined in datafile for experiment ""{experimentId}""."; Logger.Log(LogLevel.ERROR, message); ErrorHandler.HandleError( new InvalidVariationException("Provided variation is not in datafile.")); @@ -773,6 +791,34 @@ public Rollout GetRolloutFromId(string rolloutId) return new Rollout(); } + /// + /// Get the holdout from the ID + /// + /// ID for holdout + /// Holdout Entity corresponding to the holdout ID or null if ID is invalid + public Holdout GetHoldout(string holdoutId) + { +#if NET35 || NET40 + if (string.IsNullOrEmpty(holdoutId) || string.IsNullOrEmpty(holdoutId.Trim())) +#else + if (string.IsNullOrWhiteSpace(holdoutId)) +#endif + { + return null; + } + + if (_HoldoutIdMap.ContainsKey(holdoutId)) + { + return _HoldoutIdMap[holdoutId]; + } + + var message = $@"Holdout ID ""{holdoutId}"" is not in datafile."; + Logger.Log(LogLevel.ERROR, message); + ErrorHandler.HandleError( + new InvalidExperimentException("Provided holdout is not in datafile.")); + return null; + } + /// /// Get attribute ID for the provided attribute key /// @@ -788,9 +834,7 @@ public string GetAttributeId(string attributeKey) if (hasReservedPrefix) { Logger.Log(LogLevel.WARN, - $@"Attribute {attributeKey} unexpectedly has reserved prefix { - RESERVED_ATTRIBUTE_PREFIX - }; using attribute ID instead of reserved attribute name."); + $@"Attribute {attributeKey} unexpectedly has reserved prefix {RESERVED_ATTRIBUTE_PREFIX}; using attribute ID instead of reserved attribute name."); } return attribute.Id; @@ -832,5 +876,16 @@ public string ToDatafile() { return _datafile; } + + /// + /// Get holdout instances associated with the given feature flag key. + /// + /// Feature flag key + /// Array of holdouts associated with the flag, empty array if none + public Holdout[] GetHoldoutsForFlag(string flagKey) + { + var holdouts = _holdoutConfig?.GetHoldoutsForFlag(flagKey); + return holdouts?.ToArray() ?? new Holdout[0]; + } } } diff --git a/OptimizelySDK/Entity/Experiment.cs b/OptimizelySDK/Entity/Experiment.cs index e1eee5f2..b0079365 100644 --- a/OptimizelySDK/Entity/Experiment.cs +++ b/OptimizelySDK/Entity/Experiment.cs @@ -22,7 +22,7 @@ namespace OptimizelySDK.Entity { - public class Experiment : IdKeyEntity + public class Experiment : IdKeyEntity, IExperimentCore { private const string STATUS_RUNNING = "Running"; @@ -281,5 +281,10 @@ public bool IsUserInForcedVariation(string userId) { return ForcedVariations != null && ForcedVariations.ContainsKey(userId); } + + /// + /// Determine if experiment is currently activated/running (IExperimentCore implementation) + /// + public bool IsActivated => IsExperimentRunning; } } diff --git a/OptimizelySDK/Entity/ExperimentCoreExtensions.cs b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs new file mode 100644 index 00000000..90f3996d --- /dev/null +++ b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs @@ -0,0 +1,127 @@ +/* + * Copyright 2025, Optimizely + * + * 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. + */ + +using System.Linq; + +namespace OptimizelySDK.Entity +{ + /// + /// Extension methods providing common functionality for IExperimentCore implementations + /// + public static class ExperimentCoreExtensions + { + /// + /// Get variation by ID + /// + /// The experiment or holdout instance + /// Variation ID to search for + /// Variation with the specified ID, or null if not found + public static Variation GetVariation(this IExperimentCore experimentCore, string id) + { + if (experimentCore?.Variations == null || string.IsNullOrEmpty(id)) + { + return null; + } + + return experimentCore.Variations.FirstOrDefault(v => v.Id == id); + } + + /// + /// Get variation by key + /// + /// The experiment or holdout instance + /// Variation key to search for + /// Variation with the specified key, or null if not found + public static Variation GetVariationByKey(this IExperimentCore experimentCore, string key) + { + if (experimentCore?.Variations == null || string.IsNullOrEmpty(key)) + { + return null; + } + + return experimentCore.Variations.FirstOrDefault(v => v.Key == key); + } + + /// + /// Replace audience IDs with audience names in a condition string + /// + /// The experiment or holdout instance + /// String containing audience conditions + /// Map of audience ID to audience name + /// String with audience IDs replaced by names + public static string ReplaceAudienceIdsWithNames(this IExperimentCore experimentCore, + string conditionString, System.Collections.Generic.Dictionary audiencesMap) + { + if (string.IsNullOrEmpty(conditionString) || audiencesMap == null) + { + return conditionString ?? string.Empty; + } + + const string beginWord = "AUDIENCE("; + const string endWord = ")"; + var keyIdx = 0; + var audienceId = string.Empty; + var collect = false; + var replaced = string.Empty; + + foreach (var ch in conditionString) + { + // Extract audience id in parenthesis (example: AUDIENCE("35") => "35") + if (collect) + { + if (ch.ToString() == endWord) + { + // Output the extracted audienceId + var audienceName = audiencesMap.ContainsKey(audienceId) ? audiencesMap[audienceId] : audienceId; + replaced += $"\"{audienceName}\""; + collect = false; + audienceId = string.Empty; + } + else + { + audienceId += ch; + } + continue; + } + + // Walk-through until finding a matching keyword "AUDIENCE(" + if (ch == beginWord[keyIdx]) + { + keyIdx++; + if (keyIdx == beginWord.Length) + { + keyIdx = 0; + collect = true; + } + continue; + } + else + { + if (keyIdx > 0) + { + replaced += beginWord.Substring(0, keyIdx); + } + keyIdx = 0; + } + + // Pass through other characters + replaced += ch; + } + + return replaced; + } + } +} diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs new file mode 100644 index 00000000..a8ed53fe --- /dev/null +++ b/OptimizelySDK/Entity/Holdout.cs @@ -0,0 +1,284 @@ +/* + * Copyright 2025, Optimizely + * + * 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. + */ + +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using OptimizelySDK.AudienceConditions; +using OptimizelySDK.Utils; + +namespace OptimizelySDK.Entity +{ + /// + /// Represents a holdout in an Optimizely project + /// + public class Holdout : IdKeyEntity, IExperimentCore + { + /// + /// Holdout status enumeration + /// + public enum HoldoutStatus + { + Draft, + Running, + Concluded, + Archived + } + + private const string STATUS_RUNNING = "Running"; + + /// + /// Holdout Status + /// + public string Status { get; set; } + + /// + /// Layer ID for the holdout + /// + public string LayerId { get; set; } + + /// + /// Variations for the holdout + /// + public Variation[] Variations { get; set; } + + /// + /// Traffic allocation of variations in the holdout + /// + public TrafficAllocation[] TrafficAllocation { get; set; } + + /// + /// ID(s) of audience(s) the holdout is targeted to + /// + public string[] AudienceIds { get; set; } + + /// + /// Audience Conditions + /// + public object AudienceConditions { get; set; } + + /// + /// Flags included in this holdout + /// + public string[] IncludedFlags { get; set; } = new string[0]; + + /// + /// Flags excluded from this holdout + /// + public string[] ExcludedFlags { get; set; } = new string[0]; + + #region Audience Processing Properties + + private ICondition _audienceIdsList = null; + + /// + /// De-serialized audience conditions from audience IDs + /// + public ICondition AudienceIdsList + { + get + { + if (AudienceIds == null || AudienceIds.Length == 0) + { + return null; + } + + if (_audienceIdsList == null) + { + var conditions = new List(); + foreach (var audienceId in AudienceIds) + { + conditions.Add(new AudienceIdCondition() { AudienceId = audienceId }); + } + + _audienceIdsList = new OrCondition() { Conditions = conditions.ToArray() }; + } + + return _audienceIdsList; + } + } + + private string _audienceIdsString = null; + + /// + /// Stringified audience IDs + /// + public string AudienceIdsString + { + get + { + if (AudienceIds == null) + { + return null; + } + + if (_audienceIdsString == null) + { + _audienceIdsString = JsonConvert.SerializeObject(AudienceIds, Formatting.None); + } + + return _audienceIdsString; + } + } + + private ICondition _audienceConditionsList = null; + + /// + /// De-serialized audience conditions + /// + public ICondition AudienceConditionsList + { + get + { + if (AudienceConditions == null) + { + return null; + } + + if (_audienceConditionsList == null) + { + if (AudienceConditions is string) + { + _audienceConditionsList = + ConditionParser.ParseAudienceConditions( + JToken.Parse((string)AudienceConditions)); + } + else + { + _audienceConditionsList = + ConditionParser.ParseAudienceConditions((JToken)AudienceConditions); + } + } + + return _audienceConditionsList; + } + } + + private string _audienceConditionsString = null; + + /// + /// Stringified audience conditions + /// + public string AudienceConditionsString + { + get + { + if (AudienceConditions == null) + { + return null; + } + + if (_audienceConditionsString == null) + { + if (AudienceConditions is JToken token) + { + _audienceConditionsString = token.ToString(Formatting.None); + } + else + { + _audienceConditionsString = AudienceConditions.ToString(); + } + } + + return _audienceConditionsString; + } + } + + #endregion + + #region Variation Mapping Properties + + private bool isGenerateKeyMapCalled = false; + + private Dictionary _VariationKeyToVariationMap; + + /// + /// Variation key to variation mapping + /// + public Dictionary VariationKeyToVariationMap + { + get + { + if (!isGenerateKeyMapCalled) + { + GenerateVariationKeyMap(); + } + + return _VariationKeyToVariationMap; + } + } + + private Dictionary _VariationIdToVariationMap; + + /// + /// Variation ID to variation mapping + /// + public Dictionary VariationIdToVariationMap + { + get + { + if (!isGenerateKeyMapCalled) + { + GenerateVariationKeyMap(); + } + + return _VariationIdToVariationMap; + } + } + + /// + /// Generate variation key maps for performance optimization + /// + public void GenerateVariationKeyMap() + { + if (Variations == null) + { + return; + } + + _VariationIdToVariationMap = + ConfigParser.GenerateMap(Variations, a => a.Id, true); + _VariationKeyToVariationMap = + ConfigParser.GenerateMap(Variations, a => a.Key, true); + isGenerateKeyMapCalled = true; + } + + #endregion + + /// + /// Determine if holdout is currently activated/running + /// + public bool IsActivated => + !string.IsNullOrEmpty(Status) && Status == STATUS_RUNNING; + + /// + /// Serializes audiences with provided audience map for display purposes + /// + /// Map of audience ID to audience name + /// Serialized audience string with names + public string SerializeAudiences(Dictionary audiencesMap) + { + if (AudienceConditions == null) + { + return string.Empty; + } + + var serialized = AudienceConditionsString; + return this.ReplaceAudienceIdsWithNames(serialized, audiencesMap); + } + } +} diff --git a/OptimizelySDK/Entity/IExperimentCore.cs b/OptimizelySDK/Entity/IExperimentCore.cs new file mode 100644 index 00000000..297442cc --- /dev/null +++ b/OptimizelySDK/Entity/IExperimentCore.cs @@ -0,0 +1,107 @@ +/* + * Copyright 2025, Optimizely + * + * 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. + */ + +using System.Collections.Generic; +using OptimizelySDK.AudienceConditions; + +namespace OptimizelySDK.Entity +{ + /// + /// Interface defining common properties and behaviors shared between Experiment and Holdout + /// + public interface IExperimentCore + { + /// + /// Entity ID + /// + string Id { get; set; } + + /// + /// Entity Key + /// + string Key { get; set; } + + /// + /// Status of the experiment/holdout + /// + string Status { get; set; } + + /// + /// Layer ID for the experiment/holdout + /// + string LayerId { get; set; } + + /// + /// Variations for the experiment/holdout + /// + Variation[] Variations { get; set; } + + /// + /// Traffic allocation of variations in the experiment/holdout + /// + TrafficAllocation[] TrafficAllocation { get; set; } + + /// + /// ID(s) of audience(s) the experiment/holdout is targeted to + /// + string[] AudienceIds { get; set; } + + /// + /// Audience Conditions + /// + object AudienceConditions { get; set; } + + /// + /// De-serialized audience conditions + /// + ICondition AudienceConditionsList { get; } + + /// + /// Stringified audience conditions + /// + string AudienceConditionsString { get; } + + /// + /// De-serialized audience conditions from audience IDs + /// + ICondition AudienceIdsList { get; } + + /// + /// Stringified audience IDs + /// + string AudienceIdsString { get; } + + /// + /// Variation key to variation mapping + /// + Dictionary VariationKeyToVariationMap { get; } + + /// + /// Variation ID to variation mapping + /// + Dictionary VariationIdToVariationMap { get; } + + /// + /// Determine if experiment/holdout is currently activated/running + /// + bool IsActivated { get; } + + /// + /// Generate variation key maps for performance optimization + /// + void GenerateVariationKeyMap(); + } +} diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index 5f041ac1..a8cafd73 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -84,6 +84,9 @@ + + + @@ -171,6 +174,7 @@ + diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs index 58272aa7..338dc577 100644 --- a/OptimizelySDK/ProjectConfig.cs +++ b/OptimizelySDK/ProjectConfig.cs @@ -128,6 +128,11 @@ public interface ProjectConfig /// Dictionary RolloutIdMap { get; } + /// + /// Associative array of Holdout ID to Holdout(s) in the datafile + /// + Dictionary HoldoutIdMap { get; } + /// /// Associative dictionary of Flag to Variation key and Variation in the datafile /// @@ -175,6 +180,11 @@ public interface ProjectConfig /// Rollout[] Rollouts { get; set; } + /// + /// Associative list of Holdouts. + /// + Holdout[] Holdouts { get; set; } + /// /// Associative list of Integrations. /// @@ -308,6 +318,20 @@ public interface ProjectConfig /// List| Feature flag ids list, null otherwise List GetExperimentFeatureList(string experimentId); + /// + /// Get the holdout from the ID + /// + /// ID for holdout + /// Holdout Entity corresponding to the holdout ID or null if ID is invalid + Holdout GetHoldout(string holdoutId); + + /// + /// Get holdout instances associated with the given feature flag key. + /// + /// Feature flag key + /// Array of holdouts associated with the flag, empty array if none + Holdout[] GetHoldoutsForFlag(string flagKey); + /// /// Returns the datafile corresponding to ProjectConfig /// diff --git a/OptimizelySDK/Utils/HoldoutConfig.cs b/OptimizelySDK/Utils/HoldoutConfig.cs new file mode 100644 index 00000000..a3afd38c --- /dev/null +++ b/OptimizelySDK/Utils/HoldoutConfig.cs @@ -0,0 +1,186 @@ +/* + * Copyright 2025, Optimizely + * + * 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 + * + * https://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. + */ + +using System.Collections.Generic; +using System.Linq; +using OptimizelySDK.Entity; + +namespace OptimizelySDK.Utils +{ + /// + /// Configuration manager for holdouts, providing flag-to-holdout relationship mapping and optimization logic. + /// + public class HoldoutConfig + { + private List _allHoldouts; + private readonly List _globalHoldouts; + private readonly Dictionary _holdoutIdMap; + private readonly Dictionary> _includedHoldouts; + private readonly Dictionary> _excludedHoldouts; + private readonly Dictionary> _flagHoldoutCache; + + /// + /// Initializes a new instance of the HoldoutConfig class. + /// + /// Array of all holdouts from the datafile + public HoldoutConfig(Holdout[] allHoldouts = null) + { + _allHoldouts = allHoldouts?.ToList() ?? new List(); + _globalHoldouts = new List(); + _holdoutIdMap = new Dictionary(); + _includedHoldouts = new Dictionary>(); + _excludedHoldouts = new Dictionary>(); + _flagHoldoutCache = new Dictionary>(); + + UpdateHoldoutMapping(); + } + + /// + /// Gets a read-only dictionary mapping holdout IDs to holdout instances. + /// + public IDictionary HoldoutIdMap => _holdoutIdMap; + + /// + /// Updates internal mappings of holdouts including the id map, global list, and per-flag inclusion/exclusion maps. + /// + private void UpdateHoldoutMapping() + { + // Clear existing mappings + _holdoutIdMap.Clear(); + _globalHoldouts.Clear(); + _includedHoldouts.Clear(); + _excludedHoldouts.Clear(); + _flagHoldoutCache.Clear(); + + foreach (var holdout in _allHoldouts) + { + // Build ID mapping + _holdoutIdMap[holdout.Id] = holdout; + + var hasIncludedFlags = holdout.IncludedFlags != null && holdout.IncludedFlags.Length > 0; + var hasExcludedFlags = holdout.ExcludedFlags != null && holdout.ExcludedFlags.Length > 0; + + if (hasIncludedFlags) + { + // Local/targeted holdout - only applies to specific included flags + foreach (var flagId in holdout.IncludedFlags) + { + if (!_includedHoldouts.ContainsKey(flagId)) + _includedHoldouts[flagId] = new List(); + + _includedHoldouts[flagId].Add(holdout); + } + } + else + { + // Global holdout (applies to all flags) + _globalHoldouts.Add(holdout); + + // If it has excluded flags, track which flags to exclude it from + if (hasExcludedFlags) + { + foreach (var flagId in holdout.ExcludedFlags) + { + if (!_excludedHoldouts.ContainsKey(flagId)) + _excludedHoldouts[flagId] = new List(); + + _excludedHoldouts[flagId].Add(holdout); + } + } + } + } + } + + /// + /// Returns the applicable holdouts for the given flag ID by combining global holdouts (excluding any specified) and included holdouts, in that order. + /// Caches the result for future calls. + /// + /// The flag identifier + /// A list of Holdout objects relevant to the given flag + public List GetHoldoutsForFlag(string flagId) + { + if (string.IsNullOrEmpty(flagId) || _allHoldouts.Count == 0) + return new List(); + + // Check cache first + if (_flagHoldoutCache.ContainsKey(flagId)) + return _flagHoldoutCache[flagId]; + + var activeHoldouts = new List(); + // Start with global holdouts, excluding any that are specifically excluded for this flag + var excludedForFlag = _excludedHoldouts.ContainsKey(flagId) ? _excludedHoldouts[flagId] : new List(); + + if (excludedForFlag.Count > 0) + { + // Only iterate if we have exclusions to check + foreach (var globalHoldout in _globalHoldouts) + { + if (!excludedForFlag.Contains(globalHoldout)) + { + activeHoldouts.Add(globalHoldout); + } + } + } + else + { + // No exclusions, add all global holdouts directly + activeHoldouts.AddRange(_globalHoldouts); + } + + // Add included holdouts for this flag + if (_includedHoldouts.ContainsKey(flagId)) + { + activeHoldouts.AddRange(_includedHoldouts[flagId]); + } + + // Cache the result + _flagHoldoutCache[flagId] = activeHoldouts; + + return activeHoldouts; + } + + /// + /// Get a Holdout object for an ID. + /// + /// The holdout identifier + /// The Holdout object if found, null otherwise + public Holdout GetHoldout(string holdoutId) + { + return _holdoutIdMap.ContainsKey(holdoutId) ? _holdoutIdMap[holdoutId] : null; + } + + /// + /// Gets the total number of holdouts. + /// + public int HoldoutCount => _allHoldouts.Count; + + /// + /// Gets the number of global holdouts. + /// + public int GlobalHoldoutCount => _globalHoldouts.Count; + + /// + /// Updates the holdout configuration with a new set of holdouts. + /// This method is useful for testing or when the holdout configuration needs to be updated at runtime. + /// + /// The new array of holdouts to use + public void UpdateHoldoutMapping(Holdout[] newHoldouts) + { + _allHoldouts = newHoldouts?.ToList() ?? new List(); + UpdateHoldoutMapping(); + } + } +}