Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Updating SDK to 0.7.0 spec #45

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,705 changes: 3,911 additions & 1,794 deletions GrowthBook.Tests/Json/standard-cases.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using FluentAssertions;
using GrowthBook.Services;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;

namespace GrowthBook.Tests.StandardTests.GrowthBookTests;

public class StickyBucketTests : UnitTest
{
[StandardCaseTestCategory("stickyBucket")]
public class StickyBucketTestCase
{
[TestPropertyIndex(0)]
public string TestName { get; set; }
[TestPropertyIndex(1)]
public Context Context { get; set; }
[TestPropertyIndex(2)]
public StickyAssignmentsDocument[] PreExistingAssignmentDocs { get; set; } = [];
[TestPropertyIndex(3)]
public string FeatureName { get; set; }
[TestPropertyIndex(4)]
public JToken ExpectedResult { get; set; }
[TestPropertyIndex(5)]
public Dictionary<string, StickyAssignmentsDocument> ExpectedAssignmentDocs { get; set; } = [];
}

[Theory]
[MemberData(nameof(GetMappedTestsInCategory), typeof(StickyBucketTestCase))]
public void Run(StickyBucketTestCase testCase)
{
var service = new InMemoryStickyBucketService();

testCase.Context.StickyBucketService = service;
testCase.Context.StickyBucketAssignmentDocs = testCase.PreExistingAssignmentDocs.ToDictionary(x => x.FormattedAttribute);

// NOTE: Existing sticky bucket JSON tests in the JS SDK load this into the service up front
// but I wonder if that's correct because without that any assignment doc that exists
// other than those will not be stored and some of these test cases will fail.

foreach (var document in testCase.PreExistingAssignmentDocs)
{
service.SaveAssignments(document);
}

var gb = new GrowthBook(testCase.Context);

var result = gb.EvalFeature(testCase.FeatureName);

var actualResult = JToken.Parse(JsonConvert.SerializeObject(result.ExperimentResult));

if (testCase.ExpectedResult is JObject obj)
{
foreach (var property in obj.Properties())
{
actualResult[property.Name].ToString().Should().Be(property.Value.ToString());
}
}
else
{
actualResult.ToString().Should().Be(testCase.ExpectedResult.ToString());
}

var storedDocuments = service.GetAllAssignments(testCase.ExpectedAssignmentDocs.Keys);

storedDocuments.Should().BeEquivalentTo(testCase.ExpectedAssignmentDocs, "because those should have been stored correctly");
}
}
30 changes: 30 additions & 0 deletions GrowthBook.Tests/StandardTests/GrowthBookTests/UrlRedirectTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
using Xunit;

namespace GrowthBook.Tests.StandardTests.GrowthBookTests;

public class UrlRedirectTests : UnitTest
{
[StandardCaseTestCategory("urlRedirect")]
public class UrlRedirectTestCase
{
[TestPropertyIndex(0)]
public string TestName { get; set; }
[TestPropertyIndex(1)]
public Context Context { get; set; }
[TestPropertyIndex(2)]
public JToken[] ExpectedResults { get; set; }
}

[Theory]
[MemberData(nameof(GetMappedTestsInCategory), typeof(UrlRedirectTestCase))]
public void Run(UrlRedirectTestCase testCase)
{
var gb = new GrowthBook(testCase.Context);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ public class EvalConditionTestCase
public JObject Attributes { get; set; }
[TestPropertyIndex(3)]
public bool ExpectedValue { get; set; }
[TestPropertyIndex(4, isOptional: true)]
public Dictionary<string, object[]> Groups { get; set; } = [];
}

[Theory]
[MemberData(nameof(GetMappedTestsInCategory), typeof(EvalConditionTestCase))]
public void EvalCondition(EvalConditionTestCase testCase)
{
var logger = new NullLogger<ConditionEvaluationProvider>();
var actualResult = new ConditionEvaluationProvider(logger).EvalCondition(testCase.Attributes, testCase.Condition);
var actualResult = new ConditionEvaluationProvider(logger).EvalCondition(testCase.Attributes, testCase.Condition, JObject.FromObject(testCase.Groups));

actualResult.Should().Be(testCase.ExpectedValue, "because the condition should evaluate correctly");
}
Expand Down

This file was deleted.

19 changes: 18 additions & 1 deletion GrowthBook.Tests/UnitTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,13 @@
/// </summary>
public int Index { get; }

/// <summary>
/// Gets whether this value might be omitted in the test JSON.
/// </summary>
public bool IsOptional { get; }

public TestPropertyIndexAttribute(int index) => Index = index;
public TestPropertyIndexAttribute(int index, bool isOptional) : this(index) => IsOptional = isOptional;
}

/// <summary>
Expand Down Expand Up @@ -159,7 +165,7 @@
return (T)DeserializeFromJsonArray(array, typeof(T));
}

private static object? DeserializeFromJsonArray(JArray array, Type instanceType)

Check warning on line 168 in GrowthBook.Tests/UnitTest.cs

View workflow job for this annotation

GitHub Actions / build (3.1.x)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 168 in GrowthBook.Tests/UnitTest.cs

View workflow job for this annotation

GitHub Actions / build (6.0.x)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

Check warning on line 168 in GrowthBook.Tests/UnitTest.cs

View workflow job for this annotation

GitHub Actions / build (7.0.x)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
{
if (array is null)
{
Expand Down Expand Up @@ -196,7 +202,10 @@

foreach(var property in instanceType.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
var testIndex = property.GetCustomAttribute<TestPropertyIndexAttribute>()?.Index;
var indexAttribute = property.GetCustomAttribute<TestPropertyIndexAttribute>();

var testIndex = indexAttribute?.Index;
var isOptional = indexAttribute?.IsOptional;

if (testIndex is null)
{
Expand All @@ -208,6 +217,14 @@
throw new InvalidOperationException($"Unable to deserialize type '{instanceType}', property '{property.Name}' has an index of '{testIndex}' that is out of range");
}

if (testIndex == array.Count && isOptional == true)
{
// Some of the JSON tests may omit the last property, in which case
// we should just fail gracefully and keep going here.

continue;
}

var jsonInstance = array[testIndex];

if (jsonInstance.Type == JTokenType.Array)
Expand Down
16 changes: 16 additions & 0 deletions GrowthBook/Context.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using GrowthBook.Services;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
Expand Down Expand Up @@ -48,6 +49,16 @@ public class Context
/// </summary>
public IDictionary<string, Feature> Features { get; set; } = new Dictionary<string, Feature>();

/// <summary>
/// Service for using sticky buckets.
/// </summary>
public IStickyBucketService StickyBucketService { get; set; }

/// <summary>
/// The assignment docs for sticky bucket usage. Optional.
/// </summary>
public IDictionary<string, StickyAssignmentsDocument> StickyBucketAssignmentDocs { get; set; } = new Dictionary<string, StickyAssignmentsDocument>();

/// <summary>
/// Feature definitions that have been encrypted. Requires that the <see cref="DecryptionKey"/> property
/// be set in order for the <see cref="GrowthBook"/> class to decrypt them for use.
Expand All @@ -59,6 +70,11 @@ public class Context
/// </summary>
public IDictionary<string, int> ForcedVariations { get; set; } = new Dictionary<string, int>();

/// <summary>
/// Gets groups that have been saved, if any.
/// </summary>
public JObject SavedGroups { get; set; }

/// <summary>
/// If true, random assignment is disabled and only explicitly forced variations are used.
/// </summary>
Expand Down
35 changes: 35 additions & 0 deletions GrowthBook/Experiment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ public class Experiment
/// </summary>
public JObject Condition { get; set; }

/// <summary>
/// Each item defines a prerequisite where a condition must evaluate against a parent feature's value (identified by id). If gate is true, then this is a blocking feature-level prerequisite; otherwise it applies to the current rule only.
/// </summary>
public IList<ParentCondition> ParentConditions { get; set; }

/// <summary>
/// Adds the experiment to a namespace.
/// </summary>
Expand All @@ -63,6 +68,11 @@ public class Experiment
/// </summary>
public string HashAttribute { get; set; } = "id";

/// <summary>
/// When using sticky bucketing, can be used as a fallback to assign variations.
/// </summary>
public string FallbackAttribute { get; set; }

/// <summary>
/// The hash version to use (defaults to 1).
/// </summary>
Expand Down Expand Up @@ -93,6 +103,31 @@ public class Experiment
/// </summary>
public string Phase { get; set; }

/// <summary>
/// If true, sticky bucketing will be disabled for this experiment. (Note: sticky bucketing is only available if a StickyBucketingService is provided in the Context).
/// </summary>
public bool DisableStickyBucketing { get; set; }

/// <summary>
/// A sticky bucket version number that can be used to force a re-bucketing of users (default to 0).
/// </summary>
public int BucketVersion { get; set; } = 0;

/// <summary>
/// Any users with a sticky bucket version less than this will be excluded from the experiment.
/// </summary>
public int MinBucketVersion { get; set; } = 0;

/// <summary>
/// Any URL patterns associated with this experiment.
/// </summary>
public IList<UrlPattern> UrlPatterns { get; set; }

/// <summary>
/// Determines whether to persist the query string.
/// </summary>
public bool PersistQueryString { get; set; }

/// <summary>
/// Returns the experiment variations cast to the specified type.
/// </summary>
Expand Down
5 changes: 5 additions & 0 deletions GrowthBook/ExperimentResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ public class ExperimentResult
/// </summary>
public bool Passthrough { get; set; }

/// <summary>
/// If sticky bucketing was used to assign a variation.
/// </summary>
public bool StickyBucketUsed { get; set; }

/// <summary>
/// Returns the value of the assigned variation cast to the specified type.
/// </summary>
Expand Down
14 changes: 10 additions & 4 deletions GrowthBook/Extensions/JsonExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,24 @@ internal static class JsonExtensions
/// <param name="json">The JSON object to look up the key from.</param>
/// <param name="attributeKey">The key of the attribute value in the JSON object. Defaults to "id" when not provided.</param>
/// <returns>The value associated with the requested attribute, or null if the value is null or <see cref="JTokenType.Null"/>.</returns>
public static string GetHashAttributeValue(this JObject json, string attributeKey = null)
public static (string Attribute, string Value) GetHashAttributeAndValue(this JObject json, string attributeKey = null, string fallbackAttributeKey = null)
{
var attribute = attributeKey ?? "id";

var attributeValue = json[attribute];

if (attributeValue.IsNull())
if (attributeValue.IsNull() && fallbackAttributeKey != null)
{
return null;
return (fallbackAttributeKey, json[fallbackAttributeKey]?.ToString());
}

return attributeValue.ToString();
return (attribute, attributeValue?.ToString());
}

public static JArray AsArray(this JToken token) => (JArray)token;

public static JArray AsArray(this JProperty property) => (JArray)property.Value;

public static JObject AsObject(this JProperty property) => (JObject)property.Value;
}
}
29 changes: 29 additions & 0 deletions GrowthBook/Extensions/StickyAssignmentExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Text;

namespace GrowthBook.Extensions
{
public static class StickyAssignmentExtensions
{
public static IDictionary<TKey, TValue> MergeWith<TKey, TValue>(this IDictionary<TKey, TValue> mergedData, IDictionary<TKey, TValue> additionalData)
{
foreach (var pair in additionalData)
{
mergedData[pair.Key] = pair.Value;
}

return mergedData;
}

public static IDictionary<TKey, TValue> MergeWith<TKey, TValue>(this IDictionary<TKey, TValue> mergedData, IEnumerable<IDictionary<TKey, TValue>> additionalData)
{
foreach(var dictionary in additionalData)
{
mergedData = mergedData.MergeWith(dictionary);
}

return mergedData;
}
}
}
Loading
Loading