From e9e0df537750093c415d25f08c9dba15ac2178dd Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Tue, 12 Aug 2025 22:03:51 +0600
Subject: [PATCH 1/9] [FSSDK-11544] experiment core and holdout introduction
---
.../OptimizelySDK.Net35.csproj | 9 +
.../OptimizelySDK.Net40.csproj | 9 +
.../OptimizelySDK.NetStandard16.csproj | 3 +
.../OptimizelySDK.NetStandard20.csproj | 9 +
.../Entity/ExperimentCoreExtensions.cs | 127 ++++++++
OptimizelySDK/Entity/Holdout.cs | 284 ++++++++++++++++++
OptimizelySDK/Entity/IExperimentCore.cs | 107 +++++++
OptimizelySDK/OptimizelySDK.csproj | 3 +
8 files changed, 551 insertions(+)
create mode 100644 OptimizelySDK/Entity/ExperimentCoreExtensions.cs
create mode 100644 OptimizelySDK/Entity/Holdout.cs
create mode 100644 OptimizelySDK/Entity/IExperimentCore.cs
diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index a4495471..c8d89e8b 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
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index 05785575..a779cdee 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
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index b17f79e7..f7b9b9a6 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -26,6 +26,9 @@
+
+
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index b7114653..54846cc2 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
diff --git a/OptimizelySDK/Entity/ExperimentCoreExtensions.cs b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
new file mode 100644
index 00000000..0ea279eb
--- /dev/null
+++ b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
@@ -0,0 +1,127 @@
+/*
+ * Copyright 2017-2019, 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..72b04a8e
--- /dev/null
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -0,0 +1,284 @@
+/*
+ * Copyright 2017-2019, 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; }
+
+ ///
+ /// Flags excluded from this holdout
+ ///
+ public string[] ExcludedFlags { get; set; }
+
+ #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..395b418a
--- /dev/null
+++ b/OptimizelySDK/Entity/IExperimentCore.cs
@@ -0,0 +1,107 @@
+/*
+ * Copyright 2017-2019, 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..aa37daa0 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -84,6 +84,9 @@
+
+
+
From 3f3f4c0c092bd59c2fabf37c53615bdef6ed7e36 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Tue, 12 Aug 2025 22:08:32 +0600
Subject: [PATCH 2/9] [FSSDK-11544] copyright fix
---
OptimizelySDK/Entity/ExperimentCoreExtensions.cs | 2 +-
OptimizelySDK/Entity/Holdout.cs | 2 +-
OptimizelySDK/Entity/IExperimentCore.cs | 2 +-
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/OptimizelySDK/Entity/ExperimentCoreExtensions.cs b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
index 0ea279eb..085f01de 100644
--- a/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
+++ b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2019, Optimizely
+ * 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.
diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs
index 72b04a8e..634a4530 100644
--- a/OptimizelySDK/Entity/Holdout.cs
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2019, Optimizely
+ * 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.
diff --git a/OptimizelySDK/Entity/IExperimentCore.cs b/OptimizelySDK/Entity/IExperimentCore.cs
index 395b418a..29d2af28 100644
--- a/OptimizelySDK/Entity/IExperimentCore.cs
+++ b/OptimizelySDK/Entity/IExperimentCore.cs
@@ -1,5 +1,5 @@
/*
- * Copyright 2017-2019, Optimizely
+ * 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.
From 73d261a6742dc85773f42ce044abe7a0f5b671cb Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Tue, 12 Aug 2025 22:19:55 +0600
Subject: [PATCH 3/9] [FSSDK-11544] Experiment adjustment
---
OptimizelySDK/Entity/Experiment.cs | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
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;
}
}
From 6f7d2c848eb3ec707329a1c7d1987747f4e059ef Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Wed, 13 Aug 2025 00:11:02 +0600
Subject: [PATCH 4/9] [FSSDK-11544] holdout parsing
---
.../OptimizelySDK.Net35.csproj | 3 +
.../OptimizelySDK.Net40.csproj | 3 +
.../OptimizelySDK.NetStandard16.csproj | 1 +
.../OptimizelySDK.NetStandard20.csproj | 3 +
OptimizelySDK/Config/DatafileProjectConfig.cs | 62 +++++++
OptimizelySDK/OptimizelySDK.csproj | 1 +
OptimizelySDK/ProjectConfig.cs | 24 +++
OptimizelySDK/Utils/HoldoutConfig.cs | 163 ++++++++++++++++++
8 files changed, 260 insertions(+)
create mode 100644 OptimizelySDK/Utils/HoldoutConfig.cs
diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index c8d89e8b..830b41de 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -224,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 a779cdee..a87e9732 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -223,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 f7b9b9a6..de6c6010 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -67,6 +67,7 @@
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index 54846cc2..68e10108 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -340,6 +340,9 @@
Utils\ExperimentUtils.cs
+
+ Utils\HoldoutConfig.cs
+
Utils\Schema.cs
diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs
index cb248f8c..91e810a7 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]);
}
///
@@ -773,6 +796,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 a dummy entity 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 new Holdout();
+ }
+
+ 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 new Holdout();
+ }
+
///
/// Get attribute ID for the provided attribute key
///
@@ -832,5 +883,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/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index aa37daa0..a8cafd73 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -174,6 +174,7 @@
+
diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs
index 58272aa7..de3cbacb 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 a dummy entity 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..308389c6
--- /dev/null
+++ b/OptimizelySDK/Utils/HoldoutConfig.cs
@@ -0,0 +1,163 @@
+/*
+ * 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 readonly 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();
+ }
+
+ ///
+ /// 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 && !hasExcludedFlags)
+ {
+ // Global holdout (no included or excluded flags)
+ _globalHoldouts.Add(holdout);
+ }
+ else if (hasIncludedFlags)
+ {
+ // Holdout with specific included flags
+ foreach (var flagId in holdout.IncludedFlags)
+ {
+ if (!_includedHoldouts.ContainsKey(flagId))
+ _includedHoldouts[flagId] = new List();
+
+ _includedHoldouts[flagId].Add(holdout);
+ }
+ }
+ else if (hasExcludedFlags)
+ {
+ // Global holdout with excluded flags
+ _globalHoldouts.Add(holdout);
+
+ 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 (_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();
+
+ foreach (var globalHoldout in _globalHoldouts)
+ {
+ if (!excludedForFlag.Contains(globalHoldout))
+ {
+ activeHoldouts.Add(globalHoldout);
+ }
+ }
+
+ // 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;
+ }
+}
From b3b3a6eea99e620cf6f3aeab570463afc1a97817 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Wed, 13 Aug 2025 23:59:46 +0600
Subject: [PATCH 5/9] [FSSDK-11544] test coverage
---
.../EntityTests/HoldoutTests.cs | 196 ++++++++++
.../OptimizelySDK.Tests.csproj | 5 +
OptimizelySDK.Tests/ProjectConfigTest.cs | 117 ++++++
.../TestData/HoldoutTestData.json | 192 ++++++++++
.../UtilsTests/HoldoutConfigTests.cs | 345 ++++++++++++++++++
OptimizelySDK/Entity/Holdout.cs | 13 +-
OptimizelySDK/Utils/HoldoutConfig.cs | 47 ++-
7 files changed, 897 insertions(+), 18 deletions(-)
create mode 100644 OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
create mode 100644 OptimizelySDK.Tests/TestData/HoldoutTestData.json
create mode 100644 OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
diff --git a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
new file mode 100644
index 00000000..3ea45067
--- /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..ac131760 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;
@@ -1351,5 +1352,121 @@ 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.IsNotNull(invalidHoldout);
+ Assert.AreEqual("", invalidHoldout.Id); // Dummy holdout has empty ID
+ }
+
+ [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.IsNotNull(holdout);
+ Assert.AreEqual("", holdout.Id); // Dummy holdout has empty ID
+ }
+
+ #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..550f10dc
--- /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/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs
index 634a4530..bc959271 100644
--- a/OptimizelySDK/Entity/Holdout.cs
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -28,6 +28,15 @@ namespace OptimizelySDK.Entity
///
public class Holdout : IdKeyEntity, IExperimentCore
{
+ ///
+ /// Constructor that initializes properties to avoid null values
+ ///
+ public Holdout()
+ {
+ Id = "";
+ Key = "";
+ }
+
///
/// Holdout status enumeration
///
@@ -74,12 +83,12 @@ public enum HoldoutStatus
///
/// Flags included in this holdout
///
- public string[] IncludedFlags { get; set; }
+ public string[] IncludedFlags { get; set; } = new string[0];
///
/// Flags excluded from this holdout
///
- public string[] ExcludedFlags { get; set; }
+ public string[] ExcludedFlags { get; set; } = new string[0];
#region Audience Processing Properties
diff --git a/OptimizelySDK/Utils/HoldoutConfig.cs b/OptimizelySDK/Utils/HoldoutConfig.cs
index 308389c6..78f723a5 100644
--- a/OptimizelySDK/Utils/HoldoutConfig.cs
+++ b/OptimizelySDK/Utils/HoldoutConfig.cs
@@ -25,7 +25,7 @@ namespace OptimizelySDK.Utils
///
public class HoldoutConfig
{
- private readonly List _allHoldouts;
+ private List _allHoldouts;
private readonly List _globalHoldouts;
private readonly Dictionary _holdoutIdMap;
private readonly Dictionary> _includedHoldouts;
@@ -48,6 +48,11 @@ public HoldoutConfig(Holdout[] allHoldouts = null)
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.
///
@@ -68,14 +73,9 @@ private void UpdateHoldoutMapping()
var hasIncludedFlags = holdout.IncludedFlags != null && holdout.IncludedFlags.Length > 0;
var hasExcludedFlags = holdout.ExcludedFlags != null && holdout.ExcludedFlags.Length > 0;
- if (!hasIncludedFlags && !hasExcludedFlags)
+ if (hasIncludedFlags)
{
- // Global holdout (no included or excluded flags)
- _globalHoldouts.Add(holdout);
- }
- else if (hasIncludedFlags)
- {
- // Holdout with specific included flags
+ // Local/targeted holdout - only applies to specific included flags
foreach (var flagId in holdout.IncludedFlags)
{
if (!_includedHoldouts.ContainsKey(flagId))
@@ -84,17 +84,21 @@ private void UpdateHoldoutMapping()
_includedHoldouts[flagId].Add(holdout);
}
}
- else if (hasExcludedFlags)
+ else
{
- // Global holdout with excluded flags
+ // Global holdout (applies to all flags)
_globalHoldouts.Add(holdout);
- foreach (var flagId in holdout.ExcludedFlags)
+ // If it has excluded flags, track which flags to exclude it from
+ if (hasExcludedFlags)
{
- if (!_excludedHoldouts.ContainsKey(flagId))
- _excludedHoldouts[flagId] = new List();
-
- _excludedHoldouts[flagId].Add(holdout);
+ foreach (var flagId in holdout.ExcludedFlags)
+ {
+ if (!_excludedHoldouts.ContainsKey(flagId))
+ _excludedHoldouts[flagId] = new List();
+
+ _excludedHoldouts[flagId].Add(holdout);
+ }
}
}
}
@@ -108,7 +112,7 @@ private void UpdateHoldoutMapping()
/// A list of Holdout objects relevant to the given flag
public List GetHoldoutsForFlag(string flagId)
{
- if (_allHoldouts.Count == 0)
+ if (string.IsNullOrEmpty(flagId) || _allHoldouts.Count == 0)
return new List();
// Check cache first
@@ -159,5 +163,16 @@ public Holdout GetHoldout(string holdoutId)
/// 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();
+ }
}
}
From 4be2173d5d7cce46186169c78abf88f96230e154 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Thu, 14 Aug 2025 19:38:53 +0600
Subject: [PATCH 6/9] [FSSDK-11544] lint fix
---
.../EntityTests/HoldoutTests.cs | 322 ++++-----
OptimizelySDK.Tests/ProjectConfigTest.cs | 30 +-
.../UtilsTests/HoldoutConfigTests.cs | 634 +++++++++---------
OptimizelySDK/Config/DatafileProjectConfig.cs | 19 +-
.../Entity/ExperimentCoreExtensions.cs | 4 +-
OptimizelySDK/Entity/Holdout.cs | 2 +-
OptimizelySDK/Entity/IExperimentCore.cs | 2 +-
OptimizelySDK/Utils/HoldoutConfig.cs | 10 +-
8 files changed, 507 insertions(+), 516 deletions(-)
diff --git a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
index 3ea45067..7189fc50 100644
--- a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
+++ b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright 2025, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -23,154 +23,154 @@
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 = @"{
+ [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"",
@@ -181,16 +181,16 @@ public void TestHoldoutNullSafety()
""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);
- }
- }
+ 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/ProjectConfigTest.cs b/OptimizelySDK.Tests/ProjectConfigTest.cs
index ac131760..b9b4bd5b 100644
--- a/OptimizelySDK.Tests/ProjectConfigTest.cs
+++ b/OptimizelySDK.Tests/ProjectConfigTest.cs
@@ -1176,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]
@@ -1359,14 +1357,14 @@ public void TestProjectConfigWithOtherIntegrationsInCollection()
public void TestHoldoutDeserialization_FromDatafile()
{
// Test that holdouts can be deserialized from a datafile with holdouts
- var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ 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,
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson,
new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
Assert.IsNotNull(datafileProjectConfig.Holdouts);
@@ -1383,14 +1381,14 @@ public void TestHoldoutDeserialization_FromDatafile()
[Test]
public void TestGetHoldoutsForFlag_Integration()
{
- var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ 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,
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson,
new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
// Test GetHoldoutsForFlag method
@@ -1410,14 +1408,14 @@ public void TestGetHoldoutsForFlag_Integration()
[Test]
public void TestGetHoldout_Integration()
{
- var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ 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,
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson,
new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
// Test GetHoldout method
@@ -1449,7 +1447,7 @@ public void TestMissingHoldoutsField_BackwardCompatibility()
""featureFlags"": []
}";
- var datafileProjectConfig = DatafileProjectConfig.Create(datafileWithoutHoldouts,
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileWithoutHoldouts,
new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
Assert.IsNotNull(datafileProjectConfig.Holdouts);
diff --git a/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
index 550f10dc..cc6ff04c 100644
--- a/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
+++ b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright 2025, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -26,320 +26,320 @@
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
- };
- }
- }
+ [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 91e810a7..bb4ac6ef 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -515,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;
@@ -655,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."));
@@ -678,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."));
@@ -701,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."));
@@ -724,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."));
@@ -839,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;
diff --git a/OptimizelySDK/Entity/ExperimentCoreExtensions.cs b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
index 085f01de..90f3996d 100644
--- a/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
+++ b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright 2025, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -62,7 +62,7 @@ public static Variation GetVariationByKey(this IExperimentCore experimentCore, s
/// 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,
+ public static string ReplaceAudienceIdsWithNames(this IExperimentCore experimentCore,
string conditionString, System.Collections.Generic.Dictionary audiencesMap)
{
if (string.IsNullOrEmpty(conditionString) || audiencesMap == null)
diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs
index bc959271..604b1f24 100644
--- a/OptimizelySDK/Entity/Holdout.cs
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright 2025, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/OptimizelySDK/Entity/IExperimentCore.cs b/OptimizelySDK/Entity/IExperimentCore.cs
index 29d2af28..297442cc 100644
--- a/OptimizelySDK/Entity/IExperimentCore.cs
+++ b/OptimizelySDK/Entity/IExperimentCore.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright 2025, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
diff --git a/OptimizelySDK/Utils/HoldoutConfig.cs b/OptimizelySDK/Utils/HoldoutConfig.cs
index 78f723a5..211b87f8 100644
--- a/OptimizelySDK/Utils/HoldoutConfig.cs
+++ b/OptimizelySDK/Utils/HoldoutConfig.cs
@@ -1,4 +1,4 @@
-/*
+/*
* Copyright 2025, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -80,7 +80,7 @@ private void UpdateHoldoutMapping()
{
if (!_includedHoldouts.ContainsKey(flagId))
_includedHoldouts[flagId] = new List();
-
+
_includedHoldouts[flagId].Add(holdout);
}
}
@@ -88,7 +88,7 @@ private void UpdateHoldoutMapping()
{
// Global holdout (applies to all flags)
_globalHoldouts.Add(holdout);
-
+
// If it has excluded flags, track which flags to exclude it from
if (hasExcludedFlags)
{
@@ -96,7 +96,7 @@ private void UpdateHoldoutMapping()
{
if (!_excludedHoldouts.ContainsKey(flagId))
_excludedHoldouts[flagId] = new List();
-
+
_excludedHoldouts[flagId].Add(holdout);
}
}
@@ -123,7 +123,7 @@ public List GetHoldoutsForFlag(string flagId)
// Start with global holdouts, excluding any that are specifically excluded for this flag
var excludedForFlag = _excludedHoldouts.ContainsKey(flagId) ? _excludedHoldouts[flagId] : new List();
-
+
foreach (var globalHoldout in _globalHoldouts)
{
if (!excludedForFlag.Contains(globalHoldout))
From 702db9db9b1277b6b1f1fa8d4e584c41af6fafd6 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Thu, 14 Aug 2025 19:46:56 +0600
Subject: [PATCH 7/9] [FSSDK-11544] lint fix test for one file
---
.../EntityTests/HoldoutTests.cs | 316 +++++++++---------
1 file changed, 158 insertions(+), 158 deletions(-)
diff --git a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
index 7189fc50..a850971d 100644
--- a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
+++ b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
@@ -23,154 +23,154 @@
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 = @"{
+ [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"",
@@ -181,16 +181,16 @@ public void TestHoldoutNullSafety()
""audienceConditions"": []
}";
- var holdout = JsonConvert.DeserializeObject(minimalHoldoutJson);
+ var holdout = JsonConvert.DeserializeObject(minimalHoldoutJson);
- Assert.IsNotNull(holdout);
- Assert.AreEqual("test_holdout", holdout.Id);
- Assert.AreEqual("test_key", holdout.Key);
+ 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);
- }
- }
+ // 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);
+ }
+ }
}
From f77205d6c5dcbe1cee1ca13c36cd819c828f32b9 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Thu, 14 Aug 2025 19:53:52 +0600
Subject: [PATCH 8/9] [FSSDK-11544] lint fix
---
.../UtilsTests/HoldoutConfigTests.cs | 632 +++++++++---------
1 file changed, 316 insertions(+), 316 deletions(-)
diff --git a/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
index cc6ff04c..23d3ecfd 100644
--- a/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
+++ b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
@@ -26,320 +26,320 @@
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
- };
- }
- }
+ [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
+ };
+ }
+ }
}
From 12d8d1b68797b57bdd9b3ff6b6d20c57bab570e3 Mon Sep 17 00:00:00 2001
From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com>
Date: Thu, 14 Aug 2025 23:57:57 +0600
Subject: [PATCH 9/9] [FSSDK-11544] review update
---
OptimizelySDK.Tests/ProjectConfigTest.cs | 6 ++----
OptimizelySDK/Config/DatafileProjectConfig.cs | 6 +++---
OptimizelySDK/Entity/Holdout.cs | 9 ---------
OptimizelySDK/ProjectConfig.cs | 2 +-
OptimizelySDK/Utils/HoldoutConfig.cs | 16 ++++++++++++----
5 files changed, 18 insertions(+), 21 deletions(-)
diff --git a/OptimizelySDK.Tests/ProjectConfigTest.cs b/OptimizelySDK.Tests/ProjectConfigTest.cs
index b9b4bd5b..7b4d0d7c 100644
--- a/OptimizelySDK.Tests/ProjectConfigTest.cs
+++ b/OptimizelySDK.Tests/ProjectConfigTest.cs
@@ -1425,8 +1425,7 @@ public void TestGetHoldout_Integration()
Assert.AreEqual("global_holdout", globalHoldout.Key);
var invalidHoldout = datafileProjectConfig.GetHoldout("invalid_id");
- Assert.IsNotNull(invalidHoldout);
- Assert.AreEqual("", invalidHoldout.Id); // Dummy holdout has empty ID
+ Assert.IsNull(invalidHoldout);
}
[Test]
@@ -1461,8 +1460,7 @@ public void TestMissingHoldoutsField_BackwardCompatibility()
Assert.AreEqual(0, holdouts.Length);
var holdout = datafileProjectConfig.GetHoldout("any_id");
- Assert.IsNotNull(holdout);
- Assert.AreEqual("", holdout.Id); // Dummy holdout has empty ID
+ Assert.IsNull(holdout);
}
#endregion
diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs
index bb4ac6ef..419ff03c 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -795,7 +795,7 @@ public Rollout GetRolloutFromId(string rolloutId)
/// Get the holdout from the ID
///
/// ID for holdout
- /// Holdout Entity corresponding to the holdout ID or a dummy entity if ID is invalid
+ /// Holdout Entity corresponding to the holdout ID or null if ID is invalid
public Holdout GetHoldout(string holdoutId)
{
#if NET35 || NET40
@@ -804,7 +804,7 @@ public Holdout GetHoldout(string holdoutId)
if (string.IsNullOrWhiteSpace(holdoutId))
#endif
{
- return new Holdout();
+ return null;
}
if (_HoldoutIdMap.ContainsKey(holdoutId))
@@ -816,7 +816,7 @@ public Holdout GetHoldout(string holdoutId)
Logger.Log(LogLevel.ERROR, message);
ErrorHandler.HandleError(
new InvalidExperimentException("Provided holdout is not in datafile."));
- return new Holdout();
+ return null;
}
///
diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs
index 604b1f24..a8ed53fe 100644
--- a/OptimizelySDK/Entity/Holdout.cs
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -28,15 +28,6 @@ namespace OptimizelySDK.Entity
///
public class Holdout : IdKeyEntity, IExperimentCore
{
- ///
- /// Constructor that initializes properties to avoid null values
- ///
- public Holdout()
- {
- Id = "";
- Key = "";
- }
-
///
/// Holdout status enumeration
///
diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs
index de3cbacb..338dc577 100644
--- a/OptimizelySDK/ProjectConfig.cs
+++ b/OptimizelySDK/ProjectConfig.cs
@@ -322,7 +322,7 @@ public interface ProjectConfig
/// Get the holdout from the ID
///
/// ID for holdout
- /// Holdout Entity corresponding to the holdout ID or a dummy entity if ID is invalid
+ /// Holdout Entity corresponding to the holdout ID or null if ID is invalid
Holdout GetHoldout(string holdoutId);
///
diff --git a/OptimizelySDK/Utils/HoldoutConfig.cs b/OptimizelySDK/Utils/HoldoutConfig.cs
index 211b87f8..a3afd38c 100644
--- a/OptimizelySDK/Utils/HoldoutConfig.cs
+++ b/OptimizelySDK/Utils/HoldoutConfig.cs
@@ -120,17 +120,25 @@ public List GetHoldoutsForFlag(string 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();
- foreach (var globalHoldout in _globalHoldouts)
+ if (excludedForFlag.Count > 0)
{
- if (!excludedForFlag.Contains(globalHoldout))
+ // Only iterate if we have exclusions to check
+ foreach (var globalHoldout in _globalHoldouts)
{
- activeHoldouts.Add(globalHoldout);
+ 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))