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))