From e5f5d7ed5b1ea9ba61b1be8cc23a39477623e621 Mon Sep 17 00:00:00 2001 From: LaunchDarklyCI Date: Thu, 17 Jun 2021 18:22:36 -0700 Subject: [PATCH] prepare 5.5.0 release (#237) --- build.gradle | 70 ++++++---- packaging-test/Makefile | 5 +- packaging-test/test-app/build.gradle | 7 + .../launchdarkly/sdk/server/DataModel.java | 42 +++++- .../launchdarkly/sdk/server/Evaluator.java | 50 +++++++- .../sdk/server/EvaluatorBucketing.java | 38 ++---- .../launchdarkly/sdk/server/EventFactory.java | 6 + .../server/DataModelSerializationTest.java | 70 +++++++++- .../sdk/server/DataModelTest.java | 3 +- .../sdk/server/EvaluatorBucketingTest.java | 120 +++++++++++++++--- .../sdk/server/EvaluatorTest.java | 91 +++++++++++++ .../sdk/server/EvaluatorTestUtil.java | 3 - .../sdk/server/EventFactoryTest.java | 82 ++++++++++++ .../sdk/server/ModelBuilders.java | 16 ++- .../RolloutRandomizationConsistencyTest.java | 116 +++++++++++++++++ 15 files changed, 627 insertions(+), 92 deletions(-) create mode 100644 src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java create mode 100644 src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java diff --git a/build.gradle b/build.gradle index eff787942..37039b6da 100644 --- a/build.gradle +++ b/build.gradle @@ -72,7 +72,7 @@ ext.versions = [ "gson": "2.7", "guava": "30.1-jre", "jackson": "2.11.2", - "launchdarklyJavaSdkCommon": "1.1.1", + "launchdarklyJavaSdkCommon": "1.2.0", "okhttp": "4.8.1", // specify this for the SDK build instead of relying on the transitive dependency from okhttp-eventsource "okhttpEventsource": "2.3.1", "slf4j": "1.7.21", @@ -83,6 +83,16 @@ ext.versions = [ // Add dependencies to "libraries.internal" that are not exposed in our public API. These // will be completely omitted from the "thin" jar, and will be embedded with shaded names // in the other two SDK jars. +// +// Note that Gson is included here but Jackson is not, even though there is some Jackson +// helper code in java-sdk-common. The reason is that the SDK always needs to use Gson for +// its own usual business, so (except in the "thin" jar) we will be embedding a shaded +// copy of Gson; but we do not use Jackson normally, we just provide those helpers for use +// by applications that are already using Jackson. So we do not want to embed it and we do +// not want it to show up as a dependency at all in our pom (and it's been excluded from +// the launchdarkly-java-sdk-common pom for the same reason). However, we do include +// Jackson in "libraries.optional" because we need to generate OSGi optional import +// headers for it. libraries.internal = [ "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}", "commons-codec:commons-codec:${versions.commonsCodec}", @@ -96,7 +106,15 @@ libraries.internal = [ // Add dependencies to "libraries.external" that are exposed in our public API, or that have // global state that must be shared between the SDK and the caller. libraries.external = [ - "org.slf4j:slf4j-api:${versions.slf4j}", + "org.slf4j:slf4j-api:${versions.slf4j}" +] + +// Add dependencies to "libraries.optional" that are not exposed in our public API and are +// *not* embedded in the SDK jar. These are for optional things that will only work if +// they are already in the application classpath; we do not want show them as a dependency +// because that would cause them to be pulled in automatically in all builds. The reason +// we need to even mention them here at all is for the sake of OSGi optional import headers. +libraries.optional = [ "com.fasterxml.jackson.core:jackson-core:${versions.jackson}", "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" ] @@ -114,24 +132,29 @@ libraries.test = [ "com.fasterxml.jackson.core:jackson-databind:${versions.jackson}" ] +configurations { + // We need to define "internal" as a custom configuration that contains the same things as + // "implementation", because "implementation" has special behavior in Gradle that prevents us + // from referencing it the way we do in shadeDependencies(). + internal.extendsFrom implementation + optional + imports +} + dependencies { implementation libraries.internal api libraries.external testImplementation libraries.test, libraries.internal, libraries.external + optional libraries.optional commonClasses "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}" commonDoc "com.launchdarkly:launchdarkly-java-sdk-common:${versions.launchdarklyJavaSdkCommon}:sources" // Unlike what the name might suggest, the "shadow" configuration specifies dependencies that // should *not* be shaded by the Shadow plugin when we build our shaded jars. - shadow libraries.external -} + shadow libraries.external, libraries.optional -configurations { - // We need to define "internal" as a custom configuration that contains the same things as - // "implementation", because "implementation" has special behavior in Gradle that prevents us - // from referencing it the way we do in shadeDependencies(). - internal.extendsFrom implementation + imports libraries.external } checkstyle { @@ -171,7 +194,6 @@ shadowJar { dependencies { exclude(dependency('org.slf4j:.*:.*')) - exclude(dependency('com.fasterxml.jackson.core:.*:.*')) } // Kotlin metadata for shaded classes should not be included - it confuses IDEs @@ -185,7 +207,7 @@ shadowJar { shadeDependencies(project.tasks.shadowJar) // Note that "configurations.shadow" is the same as "libraries.external", except it contains // objects with detailed information about the resolved dependencies. - addOsgiManifest(project.tasks.shadowJar, [ project.configurations.shadow ], []) + addOsgiManifest(project.tasks.shadowJar, [ project.configurations.imports ], []) } doLast { @@ -208,17 +230,17 @@ task shadowJarAll(type: com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJ exclude '**/*.kotlin_builtins' dependencies { - exclude(dependency('com.fasterxml.jackson.core:.*:.*')) + // Currently we don't need to exclude anything - SLF4J will be embedded, unshaded } // doFirst causes the following steps to be run during Gradle's execution phase rather than the // configuration phase; this is necessary because they access the build products doFirst { shadeDependencies(project.tasks.shadowJarAll) - // The "all" jar exposes its bundled Gson and SLF4j dependencies as exports - but, like the - // default jar, it *also* imports them ("self-wiring"), which allows the bundle to use a + // The "all" jar exposes its bundled SLF4j dependency as an export - but, like the + // default jar, it *also* imports it ("self-wiring"), which allows the bundle to use a // higher version if one is provided by another bundle. - addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.shadow ], [ project.configurations.shadow ]) + addOsgiManifest(project.tasks.shadowJarAll, [ project.configurations.imports ], [ project.configurations.imports ]) } doLast { @@ -370,14 +392,18 @@ def addOsgiManifest(jarTask, List importConfigs, List bundleImport(p, a.moduleVersion.id.version, nextMajorVersion(a.moduleVersion.id.version)) }) + systemPackageImports - imports += "com.google.gson;resolution:=optional" - imports += "com.google.gson.reflect;resolution:=optional" - imports += "com.google.gson.stream;resolution:=optional" + + // We also always add *optional* imports for Gson and Jackson, so that GsonTypeAdapters and + // JacksonTypeAdapters will work *if* Gson or Jackson is present externally. Currently we + // are hard-coding the Gson packages but there is probably a better way. + def optImports = [ "com.google.gson", "com.google.gson.reflect", "com.google.gson.stream" ] + forEachArtifactAndVisiblePackage([ configurations.optional ]) { a, p -> optImports += p } + imports += (optImports.join(";") + ";resolution:=optional" ) + attributes("Import-Package": imports.join(",")) // Similarly, we're adding package exports for every package in whatever libraries we're @@ -391,9 +417,7 @@ def addOsgiManifest(jarTask, List importConfigs, List variations; private UserAttribute bucketBy; + private RolloutKind kind; + private Integer seed; Rollout() {} - Rollout(List variations, UserAttribute bucketBy) { + Rollout(List variations, UserAttribute bucketBy, RolloutKind kind) { this.variations = variations; this.bucketBy = bucketBy; + this.kind = kind; + this.seed = null; + } + + Rollout(List variations, UserAttribute bucketBy, RolloutKind kind, Integer seed) { + this.variations = variations; + this.bucketBy = bucketBy; + this.kind = kind; + this.seed = seed; } // Guaranteed non-null @@ -360,6 +371,18 @@ List getVariations() { UserAttribute getBucketBy() { return bucketBy; } + + RolloutKind getKind() { + return this.kind; + } + + Integer getSeed() { + return this.seed; + } + + boolean isExperiment() { + return kind == RolloutKind.experiment; + } } /** @@ -389,12 +412,14 @@ Rollout getRollout() { static final class WeightedVariation { private int variation; private int weight; + private boolean untracked; WeightedVariation() {} - WeightedVariation(int variation, int weight) { + WeightedVariation(int variation, int weight, boolean untracked) { this.variation = variation; this.weight = weight; + this.untracked = untracked; } int getVariation() { @@ -404,6 +429,10 @@ int getVariation() { int getWeight() { return weight; } + + boolean isUntracked() { + return untracked; + } } @JsonAdapter(JsonHelpers.PostProcessingDeserializableTypeAdapterFactory.class) @@ -511,4 +540,13 @@ static enum Operator { semVerGreaterThan, segmentMatch } + + /** + * This enum is all lowercase so that when it is automatically deserialized from JSON, + * the lowercase properties properly map to these enumerations. + */ + static enum RolloutKind { + rollout, + experiment + } } diff --git a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java index e102c70c2..8128f8c88 100644 --- a/src/main/java/com/launchdarkly/sdk/server/Evaluator.java +++ b/src/main/java/com/launchdarkly/sdk/server/Evaluator.java @@ -6,6 +6,8 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.LDValueType; +import com.launchdarkly.sdk.EvaluationReason.Kind; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import com.launchdarkly.sdk.server.interfaces.Event; import org.slf4j.Logger; @@ -15,6 +17,7 @@ import java.util.Set; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; +import static com.launchdarkly.sdk.server.EvaluatorBucketing.bucketUser; /** * Encapsulates the feature flag evaluation logic. The Evaluator has no knowledge of the rest of the SDK environment; @@ -221,13 +224,52 @@ private EvalResult getOffValue(DataModel.FeatureFlag flag, EvaluationReason reas } private EvalResult getValueForVariationOrRollout(DataModel.FeatureFlag flag, DataModel.VariationOrRollout vr, LDUser user, EvaluationReason reason) { - Integer index = EvaluatorBucketing.variationIndexForUser(vr, user, flag.getKey(), flag.getSalt()); - if (index == null) { + int variation = -1; + boolean inExperiment = false; + Integer maybeVariation = vr.getVariation(); + if (maybeVariation != null) { + variation = maybeVariation.intValue(); + } else { + DataModel.Rollout rollout = vr.getRollout(); + if (rollout != null && !rollout.getVariations().isEmpty()) { + float bucket = bucketUser(rollout.getSeed(), user, flag.getKey(), rollout.getBucketBy(), flag.getSalt()); + float sum = 0F; + for (DataModel.WeightedVariation wv : rollout.getVariations()) { + sum += (float) wv.getWeight() / 100000F; + if (bucket < sum) { + variation = wv.getVariation(); + inExperiment = vr.getRollout().isExperiment() && !wv.isUntracked(); + break; + } + } + if (variation < 0) { + // The user's bucket value was greater than or equal to the end of the last bucket. This could happen due + // to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag + // data could contain buckets that don't actually add up to 100000. Rather than returning an error in + // this case (or changing the scaling, which would potentially change the results for *all* users), we + // will simply put the user in the last bucket. + WeightedVariation lastVariation = rollout.getVariations().get(rollout.getVariations().size() - 1); + variation = lastVariation.getVariation(); + inExperiment = vr.getRollout().isExperiment() && !lastVariation.isUntracked(); + } + } + } + + if (variation < 0) { logger.error("Data inconsistency in feature flag \"{}\": variation/rollout object with no variation or rollout", flag.getKey()); return EvalResult.error(EvaluationReason.ErrorKind.MALFORMED_FLAG); } else { - return getVariation(flag, index, reason); + return getVariation(flag, variation, inExperiment ? experimentize(reason) : reason); + } + } + + private EvaluationReason experimentize(EvaluationReason reason) { + if (reason.getKind() == Kind.FALLTHROUGH) { + return EvaluationReason.fallthrough(true); + } else if (reason.getKind() == Kind.RULE_MATCH) { + return EvaluationReason.ruleMatch(reason.getRuleIndex(), reason.getRuleId(), true); } + return reason; } private boolean ruleMatchesUser(DataModel.FeatureFlag flag, DataModel.Rule rule, LDUser user) { @@ -344,7 +386,7 @@ private boolean segmentRuleMatchesUser(DataModel.SegmentRule segmentRule, LDUser } // All of the clauses are met. See if the user buckets in - double bucket = EvaluatorBucketing.bucketUser(user, segmentKey, segmentRule.getBucketBy(), salt); + double bucket = EvaluatorBucketing.bucketUser(null, user, segmentKey, segmentRule.getBucketBy(), salt); double weight = (double)segmentRule.getWeight() / 100000.0; return bucket < weight; } diff --git a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java index 6f6891dff..b770020cb 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java +++ b/src/main/java/com/launchdarkly/sdk/server/EvaluatorBucketing.java @@ -14,42 +14,20 @@ private EvaluatorBucketing() {} private static final float LONG_SCALE = (float) 0xFFFFFFFFFFFFFFFL; - // Attempt to determine the variation index for a given user. Returns null if no index can be computed - // due to internal inconsistency of the data (i.e. a malformed flag). - static Integer variationIndexForUser(DataModel.VariationOrRollout vr, LDUser user, String key, String salt) { - Integer variation = vr.getVariation(); - if (variation != null) { - return variation; - } else { - DataModel.Rollout rollout = vr.getRollout(); - if (rollout != null && !rollout.getVariations().isEmpty()) { - float bucket = bucketUser(user, key, rollout.getBucketBy(), salt); - float sum = 0F; - for (DataModel.WeightedVariation wv : rollout.getVariations()) { - sum += (float) wv.getWeight() / 100000F; - if (bucket < sum) { - return wv.getVariation(); - } - } - // The user's bucket value was greater than or equal to the end of the last bucket. This could happen due - // to a rounding error, or due to the fact that we are scaling to 100000 rather than 99999, or the flag - // data could contain buckets that don't actually add up to 100000. Rather than returning an error in - // this case (or changing the scaling, which would potentially change the results for *all* users), we - // will simply put the user in the last bucket. - return rollout.getVariations().get(rollout.getVariations().size() - 1).getVariation(); - } - } - return null; - } - - static float bucketUser(LDUser user, String key, UserAttribute attr, String salt) { + static float bucketUser(Integer seed, LDUser user, String key, UserAttribute attr, String salt) { LDValue userValue = user.getAttribute(attr == null ? UserAttribute.KEY : attr); String idHash = getBucketableStringValue(userValue); if (idHash != null) { + String prefix; + if (seed != null) { + prefix = seed.toString(); + } else { + prefix = key + "." + salt; + } if (user.getSecondary() != null) { idHash = idHash + "." + user.getSecondary(); } - String hash = DigestUtils.sha1Hex(key + "." + salt + "." + idHash).substring(0, 15); + String hash = DigestUtils.sha1Hex(prefix + "." + idHash).substring(0, 15); long longVal = Long.parseLong(hash, 16); return (float) longVal / LONG_SCALE; } diff --git a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java index d7daf9cea..32ceb9c5d 100644 --- a/src/main/java/com/launchdarkly/sdk/server/EventFactory.java +++ b/src/main/java/com/launchdarkly/sdk/server/EventFactory.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; import com.launchdarkly.sdk.server.interfaces.Event; import com.launchdarkly.sdk.server.interfaces.Event.Custom; import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; @@ -210,6 +211,11 @@ private static boolean isExperiment(DataModel.FeatureFlag flag, EvaluationReason // doesn't happen in real life, but possible in testing return false; } + + // If the reason says we're in an experiment, we are. Otherwise, apply + // the legacy rule exclusion logic. + if (reason.isInExperiment()) return true; + switch (reason.getKind()) { case FALLTHROUGH: return flag.isTrackEventsFallthrough(); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java index 3489228ff..b03b6e11b 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelSerializationTest.java @@ -9,6 +9,7 @@ import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Operator; import com.launchdarkly.sdk.server.DataModel.Prerequisite; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.Rule; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.SegmentRule; @@ -67,6 +68,54 @@ public void flagIsDeserializedWithMinimalProperties() { }); } + @Test + public void flagIsDeserializedWithOptionalExperimentProperties() { + String json = LDValue.buildObject() + .put("key", "flag-key") + .put("version", 157) + .put("rules", LDValue.buildArray() + .add(LDValue.buildObject() + .put("id", "id1") + .put("rollout", LDValue.buildObject() + .put("variations", LDValue.buildArray() + .add(LDValue.buildObject() + .put("variation", 2) + .put("weight", 100000) + .build()) + .build()) + .put("bucketBy", "email") + .build()) + .build()) + .build()) + .put("fallthrough", LDValue.buildObject() + .put("variation", 1) + .build()) + .put("offVariation", 2) + .put("variations", LDValue.buildArray().add("a").add("b").add("c").build()) + .build().toJsonString(); + FeatureFlag flag = (FeatureFlag)FEATURES.deserialize(json).getItem(); + assertEquals("flag-key", flag.getKey()); + assertEquals(157, flag.getVersion()); + assertFalse(flag.isOn()); + assertNull(flag.getSalt()); + assertNotNull(flag.getTargets()); + assertEquals(0, flag.getTargets().size()); + assertNotNull(flag.getRules()); + assertEquals(1, flag.getRules().size()); + assertNull(flag.getRules().get(0).getRollout().getKind()); + assertFalse(flag.getRules().get(0).getRollout().isExperiment()); + assertNull(flag.getRules().get(0).getRollout().getSeed()); + assertEquals(2, flag.getRules().get(0).getRollout().getVariations().get(0).getVariation()); + assertEquals(100000, flag.getRules().get(0).getRollout().getVariations().get(0).getWeight()); + assertFalse(flag.getRules().get(0).getRollout().getVariations().get(0).isUntracked()); + assertNotNull(flag.getVariations()); + assertEquals(3, flag.getVariations().size()); + assertFalse(flag.isClientSide()); + assertFalse(flag.isTrackEvents()); + assertFalse(flag.isTrackEventsFallthrough()); + assertNull(flag.getDebugEventsUntilDate()); + } + @Test public void deletedFlagIsConvertedToAndFromJsonPlaceholder() { String json0 = LDValue.buildObject().put("version", 99) @@ -297,6 +346,8 @@ private LDValue flagWithAllPropertiesJson() { .build()) .build()) .put("bucketBy", "email") + .put("kind", "experiment") + .put("seed", 123) .build()) .build()) .build()) @@ -317,7 +368,7 @@ private void assertFlagHasAllProperties(FeatureFlag flag) { assertEquals(99, flag.getVersion()); assertTrue(flag.isOn()); assertEquals("123", flag.getSalt()); - + assertNotNull(flag.getTargets()); assertEquals(1, flag.getTargets().size()); Target t0 = flag.getTargets().get(0); @@ -329,7 +380,7 @@ private void assertFlagHasAllProperties(FeatureFlag flag) { Rule r0 = flag.getRules().get(0); assertEquals("id0", r0.getId()); assertTrue(r0.isTrackEvents()); - assertEquals(new Integer(2), r0.getVariation()); + assertEquals(Integer.valueOf(2), r0.getVariation()); assertNull(r0.getRollout()); assertNotNull(r0.getClauses()); @@ -354,16 +405,19 @@ private void assertFlagHasAllProperties(FeatureFlag flag) { assertEquals(2, r1.getRollout().getVariations().get(0).getVariation()); assertEquals(100000, r1.getRollout().getVariations().get(0).getWeight()); assertEquals(UserAttribute.EMAIL, r1.getRollout().getBucketBy()); + assertEquals(RolloutKind.experiment, r1.getRollout().getKind()); + assert(r1.getRollout().isExperiment()); + assertEquals(Integer.valueOf(123), r1.getRollout().getSeed()); assertNotNull(flag.getFallthrough()); - assertEquals(new Integer(1), flag.getFallthrough().getVariation()); + assertEquals(Integer.valueOf(1), flag.getFallthrough().getVariation()); assertNull(flag.getFallthrough().getRollout()); - assertEquals(new Integer(2), flag.getOffVariation()); + assertEquals(Integer.valueOf(2), flag.getOffVariation()); assertEquals(ImmutableList.of(LDValue.of("a"), LDValue.of("b"), LDValue.of("c")), flag.getVariations()); assertTrue(flag.isClientSide()); assertTrue(flag.isTrackEvents()); assertTrue(flag.isTrackEventsFallthrough()); - assertEquals(new Long(1000), flag.getDebugEventsUntilDate()); + assertEquals(Long.valueOf(1000), flag.getDebugEventsUntilDate()); } private LDValue segmentWithAllPropertiesJson() { @@ -389,6 +443,10 @@ private LDValue segmentWithAllPropertiesJson() { .add(LDValue.buildObject() .build()) .build()) + .put("fallthrough", LDValue.buildObject() + .put("variation", 1) + .build()) + .put("variations", LDValue.buildArray().add("a").add("b").add("c").build()) .build(); } @@ -402,7 +460,7 @@ private void assertSegmentHasAllProperties(Segment segment) { assertNotNull(segment.getRules()); assertEquals(2, segment.getRules().size()); SegmentRule r0 = segment.getRules().get(0); - assertEquals(new Integer(50000), r0.getWeight()); + assertEquals(Integer.valueOf(50000), r0.getWeight()); assertNotNull(r0.getClauses()); assertEquals(1, r0.getClauses().size()); diff --git a/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java index 3b3c951e3..489701db2 100644 --- a/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/DataModelTest.java @@ -5,6 +5,7 @@ import com.launchdarkly.sdk.server.DataModel.Clause; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.Rule; import com.launchdarkly.sdk.server.DataModel.Segment; import com.launchdarkly.sdk.server.DataModel.SegmentRule; @@ -84,7 +85,7 @@ public void segmentRuleClausesListCanNeverBeNull() { @Test public void rolloutVariationsListCanNeverBeNull() { - Rollout r = new Rollout(null, null); + Rollout r = new Rollout(null, null, RolloutKind.rollout); assertEquals(ImmutableList.of(), r.getVariations()); } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java index 2e245874d..f2ca34451 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorBucketingTest.java @@ -1,24 +1,33 @@ package com.launchdarkly.sdk.server; +import com.launchdarkly.sdk.EvaluationReason; import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Operator; import com.launchdarkly.sdk.server.DataModel.Rollout; -import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.Evaluator.EvalResult; -import org.hamcrest.Matchers; import org.junit.Test; import java.util.Arrays; import java.util.List; +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.lessThan; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotEquals; @SuppressWarnings("javadoc") public class EvaluatorBucketingTest { + private Integer noSeed = null; + @Test public void variationIndexIsReturnedForBucket() { LDUser user = new LDUser.Builder("userkey").build(); @@ -27,19 +36,57 @@ public void variationIndexIsReturnedForBucket() { // First verify that with our test inputs, the bucket value will be greater than zero and less than 100000, // so we can construct a rollout whose second bucket just barely contains that value - int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(noSeed, user, flagKey, UserAttribute.KEY, salt) * 100000); assertThat(bucketValue, greaterThanOrEqualTo(1)); - assertThat(bucketValue, Matchers.lessThan(100000)); + assertThat(bucketValue, lessThan(100000)); int badVariationA = 0, matchedVariation = 1, badVariationB = 2; List variations = Arrays.asList( - new WeightedVariation(badVariationA, bucketValue), // end of bucket range is not inclusive, so it will *not* match the target value - new WeightedVariation(matchedVariation, 1), // size of this bucket is 1, so it only matches that specific value - new WeightedVariation(badVariationB, 100000 - (bucketValue + 1))); - VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null)); + new WeightedVariation(badVariationA, bucketValue, true), // end of bucket range is not inclusive, so it will *not* match the target value + new WeightedVariation(matchedVariation, 1, true), // size of this bucket is 1, so it only matches that specific value + new WeightedVariation(badVariationB, 100000 - (bucketValue + 1), true)); + Rollout rollout = new Rollout(variations, null, RolloutKind.rollout); - Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt); - assertEquals(Integer.valueOf(matchedVariation), resultVariation); + assertVariationIndexFromRollout(matchedVariation, rollout, user, flagKey, salt); + } + + @Test + public void usingSeedIsDifferentThanSalt() { + LDUser user = new LDUser.Builder("userkey").build(); + String flagKey = "flagkey"; + String salt = "salt"; + Integer seed = 123; + + float bucketValue1 = EvaluatorBucketing.bucketUser(noSeed, user, flagKey, UserAttribute.KEY, salt); + float bucketValue2 = EvaluatorBucketing.bucketUser(seed, user, flagKey, UserAttribute.KEY, salt); + assert(bucketValue1 != bucketValue2); + } + + @Test + public void differentSeedsProduceDifferentAssignment() { + LDUser user = new LDUser.Builder("userkey").build(); + String flagKey = "flagkey"; + String salt = "salt"; + Integer seed1 = 123; + Integer seed2 = 456; + + float bucketValue1 = EvaluatorBucketing.bucketUser(seed1, user, flagKey, UserAttribute.KEY, salt); + float bucketValue2 = EvaluatorBucketing.bucketUser(seed2, user, flagKey, UserAttribute.KEY, salt); + assert(bucketValue1 != bucketValue2); + } + + @Test + public void flagKeyAndSaltDoNotMatterWhenSeedIsUsed() { + LDUser user = new LDUser.Builder("userkey").build(); + String flagKey1 = "flagkey"; + String flagKey2 = "flagkey2"; + String salt1 = "salt"; + String salt2 = "salt2"; + Integer seed = 123; + + float bucketValue1 = EvaluatorBucketing.bucketUser(seed, user, flagKey1, UserAttribute.KEY, salt1); + float bucketValue2 = EvaluatorBucketing.bucketUser(seed, user, flagKey2, UserAttribute.KEY, salt2); + assert(bucketValue1 == bucketValue2); } @Test @@ -49,13 +96,12 @@ public void lastBucketIsUsedIfBucketValueEqualsTotalWeight() { String salt = "salt"; // We'll construct a list of variations that stops right at the target bucket value - int bucketValue = (int)(EvaluatorBucketing.bucketUser(user, flagKey, UserAttribute.KEY, salt) * 100000); + int bucketValue = (int)(EvaluatorBucketing.bucketUser(noSeed, user, flagKey, UserAttribute.KEY, salt) * 100000); - List variations = Arrays.asList(new WeightedVariation(0, bucketValue)); - VariationOrRollout vr = new VariationOrRollout(null, new Rollout(variations, null)); + List variations = Arrays.asList(new WeightedVariation(0, bucketValue, true)); + Rollout rollout = new Rollout(variations, null, RolloutKind.rollout); - Integer resultVariation = EvaluatorBucketing.variationIndexForUser(vr, user, flagKey, salt); - assertEquals(Integer.valueOf(0), resultVariation); + assertVariationIndexFromRollout(0, rollout, user, flagKey, salt); } @Test @@ -64,8 +110,8 @@ public void canBucketByIntAttributeSameAsString() { .custom("stringattr", "33333") .custom("intattr", 33333) .build(); - float resultForString = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("stringattr"), "salt"); - float resultForInt = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("intattr"), "salt"); + float resultForString = EvaluatorBucketing.bucketUser(noSeed, user, "key", UserAttribute.forName("stringattr"), "salt"); + float resultForInt = EvaluatorBucketing.bucketUser(noSeed, user, "key", UserAttribute.forName("intattr"), "salt"); assertEquals(resultForString, resultForInt, Float.MIN_VALUE); } @@ -74,7 +120,7 @@ public void cannotBucketByFloatAttribute() { LDUser user = new LDUser.Builder("key") .custom("floatattr", 33.5f) .build(); - float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("floatattr"), "salt"); + float result = EvaluatorBucketing.bucketUser(noSeed, user, "key", UserAttribute.forName("floatattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } @@ -83,7 +129,7 @@ public void cannotBucketByBooleanAttribute() { LDUser user = new LDUser.Builder("key") .custom("boolattr", true) .build(); - float result = EvaluatorBucketing.bucketUser(user, "key", UserAttribute.forName("boolattr"), "salt"); + float result = EvaluatorBucketing.bucketUser(noSeed, user, "key", UserAttribute.forName("boolattr"), "salt"); assertEquals(0f, result, Float.MIN_VALUE); } @@ -91,8 +137,40 @@ public void cannotBucketByBooleanAttribute() { public void userSecondaryKeyAffectsBucketValue() { LDUser user1 = new LDUser.Builder("key").build(); LDUser user2 = new LDUser.Builder("key").secondary("other").build(); - float result1 = EvaluatorBucketing.bucketUser(user1, "flagkey", UserAttribute.KEY, "salt"); - float result2 = EvaluatorBucketing.bucketUser(user2, "flagkey", UserAttribute.KEY, "salt"); + float result1 = EvaluatorBucketing.bucketUser(noSeed, user1, "flagkey", UserAttribute.KEY, "salt"); + float result2 = EvaluatorBucketing.bucketUser(noSeed, user2, "flagkey", UserAttribute.KEY, "salt"); assertNotEquals(result1, result2); } + + private static void assertVariationIndexFromRollout( + int expectedVariation, + Rollout rollout, + LDUser user, + String flagKey, + String salt + ) { + FeatureFlag flag1 = ModelBuilders.flagBuilder(flagKey) + .on(true) + .generatedVariations(3) + .fallthrough(rollout) + .salt(salt) + .build(); + EvalResult result1 = BASE_EVALUATOR.evaluate(flag1, user, EventFactory.DEFAULT); + assertThat(result1.getReason(), equalTo(EvaluationReason.fallthrough())); + assertThat(result1.getVariationIndex(), equalTo(expectedVariation)); + + // Make sure we consistently apply the rollout regardless of whether it's in a rule or a fallthrough + FeatureFlag flag2 = ModelBuilders.flagBuilder(flagKey) + .on(true) + .generatedVariations(3) + .rules(ModelBuilders.ruleBuilder() + .rollout(rollout) + .clauses(ModelBuilders.clause(UserAttribute.KEY, Operator.in, LDValue.of(user.getKey()))) + .build()) + .salt(salt) + .build(); + EvalResult result2 = BASE_EVALUATOR.evaluate(flag2, user, EventFactory.DEFAULT); + assertThat(result2.getReason().getKind(), equalTo(EvaluationReason.Kind.RULE_MATCH)); + assertThat(result2.getVariationIndex(), equalTo(expectedVariation)); + } } diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java index 49a112a0d..6864c9bf3 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTest.java @@ -6,9 +6,15 @@ import com.launchdarkly.sdk.LDUser; import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; import com.launchdarkly.sdk.server.ModelBuilders.FlagBuilder; import com.launchdarkly.sdk.server.interfaces.Event; +import java.util.ArrayList; +import java.util.List; import org.junit.Test; import static com.launchdarkly.sdk.EvaluationDetail.NO_VARIATION; @@ -62,6 +68,17 @@ private static FlagBuilder buildRedGreenFlag(String flagKey) { .variations(RED_GREEN_VARIATIONS) .version(versionFromKey(flagKey)); } + + private static Rollout buildRollout(boolean isExperiment, boolean untrackedVariations) { + List variations = new ArrayList<>(); + variations.add(new WeightedVariation(1, 50000, untrackedVariations)); + variations.add(new WeightedVariation(2, 50000, untrackedVariations)); + UserAttribute bucketBy = UserAttribute.KEY; + RolloutKind kind = isExperiment ? RolloutKind.experiment : RolloutKind.rollout; + Integer seed = 123; + Rollout rollout = new Rollout(variations, bucketBy, kind, seed); + return rollout; + } private static int versionFromKey(String flagKey) { return Math.abs(flagKey.hashCode()); @@ -132,6 +149,80 @@ public void flagReturnsErrorIfFlagIsOffAndOffVariationIsNegative() throws Except assertThat(result.getPrerequisiteEvents(), emptyIterable()); } + @Test + public void flagReturnsInExperimentForFallthroughWhenInExperimentVariation() throws Exception { + Rollout rollout = buildRollout(true, false); + VariationOrRollout vr = new VariationOrRollout(null, rollout); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .fallthrough(vr) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(result.getReason().isInExperiment()); + } + + @Test + public void flagReturnsNotInExperimentForFallthroughWhenNotInExperimentVariation() throws Exception { + Rollout rollout = buildRollout(true, true); + VariationOrRollout vr = new VariationOrRollout(null, rollout); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .fallthrough(vr) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(!result.getReason().isInExperiment()); + } + + @Test + public void flagReturnsNotInExperimentForFallthrougWhenInExperimentVariationButNonExperimentRollout() throws Exception { + Rollout rollout = buildRollout(false, false); + VariationOrRollout vr = new VariationOrRollout(null, rollout); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .fallthrough(vr) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(!result.getReason().isInExperiment()); + } + + @Test + public void flagReturnsInExperimentForRuleMatchWhenInExperimentVariation() throws Exception { + Rollout rollout = buildRollout(true, false); + + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of(BASE_USER.getKey())); + DataModel.Rule rule = ruleBuilder().id("ruleid0").clauses(clause).rollout(rollout).build(); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .rules(rule) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(result.getReason().isInExperiment()); + } + + @Test + public void flagReturnsNotInExperimentForRuleMatchWhenNotInExperimentVariation() throws Exception { + Rollout rollout = buildRollout(true, true); + + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of("userkey")); + DataModel.Rule rule = ruleBuilder().id("ruleid0").clauses(clause).rollout(rollout).build(); + + DataModel.FeatureFlag f = buildThreeWayFlag("feature") + .on(true) + .rules(rule) + .build(); + Evaluator.EvalResult result = BASE_EVALUATOR.evaluate(f, BASE_USER, EventFactory.DEFAULT); + + assert(!result.getReason().isInExperiment()); + } + @Test public void flagReturnsFallthroughIfFlagIsOnAndThereAreNoRules() throws Exception { DataModel.FeatureFlag f = buildThreeWayFlag("feature") diff --git a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java index dcb9372f4..6a0cc5e22 100644 --- a/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java +++ b/src/test/java/com/launchdarkly/sdk/server/EvaluatorTestUtil.java @@ -1,8 +1,5 @@ package com.launchdarkly.sdk.server; -import com.launchdarkly.sdk.server.DataModel; -import com.launchdarkly.sdk.server.Evaluator; - @SuppressWarnings("javadoc") public abstract class EvaluatorTestUtil { public static Evaluator BASE_EVALUATOR = evaluatorBuilder().build(); diff --git a/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java b/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java new file mode 100644 index 000000000..ca50c76ca --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/EventFactoryTest.java @@ -0,0 +1,82 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; +import com.launchdarkly.sdk.server.DataModel.VariationOrRollout; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.interfaces.Event.FeatureRequest; + +import org.junit.Test; + +import static com.launchdarkly.sdk.server.ModelBuilders.*; + +import java.util.ArrayList; +import java.util.List; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.LDValue; +import com.launchdarkly.sdk.UserAttribute; + +public class EventFactoryTest { + private static final LDUser BASE_USER = new LDUser.Builder("x").build(); + private static Rollout buildRollout(boolean isExperiment, boolean untrackedVariations) { + List variations = new ArrayList<>(); + variations.add(new WeightedVariation(1, 50000, untrackedVariations)); + variations.add(new WeightedVariation(2, 50000, untrackedVariations)); + UserAttribute bucketBy = UserAttribute.KEY; + RolloutKind kind = isExperiment ? RolloutKind.experiment : RolloutKind.rollout; + Integer seed = 123; + Rollout rollout = new Rollout(variations, bucketBy, kind, seed); + return rollout; + } + + @Test + public void trackEventFalseTest() { + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false).build(); + LDUser user = new LDUser("moniker"); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, null); + + assert(!fr.isTrackEvents()); + } + + @Test + public void trackEventTrueTest() { + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(true).build(); + LDUser user = new LDUser("moniker"); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, null); + + assert(fr.isTrackEvents()); + } + + @Test + public void trackEventTrueWhenTrackEventsFalseButExperimentFallthroughReasonTest() { + Rollout rollout = buildRollout(true, false); + VariationOrRollout vr = new VariationOrRollout(null, rollout); + + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false) + .fallthrough(vr).build(); + LDUser user = new LDUser("moniker"); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, 0, + EvaluationReason.fallthrough(true), null, null); + + assert(fr.isTrackEvents()); + } + + @Test + public void trackEventTrueWhenTrackEventsFalseButExperimentRuleMatchReasonTest() { + Rollout rollout = buildRollout(true, false); + + DataModel.Clause clause = clause(UserAttribute.KEY, DataModel.Operator.in, LDValue.of(BASE_USER.getKey())); + DataModel.Rule rule = ruleBuilder().id("ruleid0").clauses(clause).rollout(rollout).build(); + + DataModel.FeatureFlag flag = flagBuilder("flagkey").version(11).trackEvents(false) + .rules(rule).build(); + LDUser user = new LDUser("moniker"); + FeatureRequest fr = EventFactory.DEFAULT.newFeatureRequestEvent(flag, user, null, 0, + EvaluationReason.ruleMatch(0, "something", true), null, null); + + assert(fr.isTrackEvents()); + } + +} diff --git a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java index ea5af29f5..6d03d9777 100644 --- a/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java +++ b/src/test/java/com/launchdarkly/sdk/server/ModelBuilders.java @@ -6,6 +6,7 @@ import com.launchdarkly.sdk.LDValue; import com.launchdarkly.sdk.UserAttribute; import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; import com.launchdarkly.sdk.server.DataModel.Segment; import java.util.ArrayList; @@ -76,7 +77,7 @@ public static DataModel.Prerequisite prerequisite(String key, int variation) { } public static DataModel.Rollout emptyRollout() { - return new DataModel.Rollout(ImmutableList.of(), null); + return new DataModel.Rollout(ImmutableList.of(), null, RolloutKind.rollout); } public static SegmentBuilder segmentBuilder(String key) { @@ -164,6 +165,11 @@ FlagBuilder fallthroughVariation(int fallthroughVariation) { return this; } + FlagBuilder fallthrough(DataModel.Rollout rollout) { + this.fallthrough = new DataModel.VariationOrRollout(null, rollout); + return this; + } + FlagBuilder fallthrough(DataModel.VariationOrRollout fallthrough) { this.fallthrough = fallthrough; return this; @@ -188,6 +194,14 @@ FlagBuilder variations(boolean... variations) { return this; } + FlagBuilder generatedVariations(int numVariations) { + variations.clear(); + for (int i = 0; i < numVariations; i++) { + variations.add(LDValue.of(i)); + } + return this; + } + FlagBuilder clientSide(boolean clientSide) { this.clientSide = clientSide; return this; diff --git a/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java b/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java new file mode 100644 index 000000000..6614c5108 --- /dev/null +++ b/src/test/java/com/launchdarkly/sdk/server/RolloutRandomizationConsistencyTest.java @@ -0,0 +1,116 @@ +package com.launchdarkly.sdk.server; + +import com.launchdarkly.sdk.EvaluationReason; +import com.launchdarkly.sdk.LDUser; +import com.launchdarkly.sdk.UserAttribute; +import com.launchdarkly.sdk.server.DataModel.FeatureFlag; +import com.launchdarkly.sdk.server.DataModel.Rollout; +import com.launchdarkly.sdk.server.DataModel.RolloutKind; +import com.launchdarkly.sdk.server.DataModel.WeightedVariation; +import com.launchdarkly.sdk.server.Evaluator.EvalResult; + +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static com.launchdarkly.sdk.server.EvaluatorTestUtil.BASE_EVALUATOR; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.Assert.assertEquals; + +/* + * Note: These tests are meant to be exact duplicates of tests + * in other SDKs. Do not change any of the values unless they + * are also changed in other SDKs. These are not traditional behavioral + * tests so much as consistency tests to guarantee that the implementation + * is identical across SDKs. + */ +public class RolloutRandomizationConsistencyTest { + private Integer noSeed = null; + + private static Rollout buildRollout(boolean isExperiment, boolean untrackedVariations) { + List variations = new ArrayList<>(); + variations.add(new WeightedVariation(0, 10000, untrackedVariations)); + variations.add(new WeightedVariation(1, 20000, untrackedVariations)); + variations.add(new WeightedVariation(0, 70000, true)); + UserAttribute bucketBy = UserAttribute.KEY; + RolloutKind kind = isExperiment ? RolloutKind.experiment : RolloutKind.rollout; + Integer seed = 61; + Rollout rollout = new Rollout(variations, bucketBy, kind, seed); + return rollout; + } + + @Test + public void variationIndexForUserInExperimentTest() { + // seed here carefully chosen so users fall into different buckets + Rollout rollout = buildRollout(true, false); + String key = "hashKey"; + String salt = "saltyA"; + + LDUser user1 = new LDUser("userKeyA"); + // bucketVal = 0.09801207 + assertVariationIndexAndExperimentStateForRollout(0, true, rollout, user1, key, salt); + + LDUser user2 = new LDUser("userKeyB"); + // bucketVal = 0.14483777 + assertVariationIndexAndExperimentStateForRollout(1, true, rollout, user2, key, salt); + + LDUser user3 = new LDUser("userKeyC"); + // bucketVal = 0.9242641 + assertVariationIndexAndExperimentStateForRollout(0, false, rollout, user3, key, salt); + } + + private static void assertVariationIndexAndExperimentStateForRollout( + int expectedVariation, + boolean expectedInExperiment, + Rollout rollout, + LDUser user, + String flagKey, + String salt + ) { + FeatureFlag flag = ModelBuilders.flagBuilder(flagKey) + .on(true) + .generatedVariations(3) + .fallthrough(rollout) + .salt(salt) + .build(); + EvalResult result = BASE_EVALUATOR.evaluate(flag, user, EventFactory.DEFAULT); + assertThat(result.getVariationIndex(), equalTo(expectedVariation)); + assertThat(result.getReason().getKind(), equalTo(EvaluationReason.Kind.FALLTHROUGH)); + assertThat(result.getReason().isInExperiment(), equalTo(expectedInExperiment)); + } + + @Test + public void bucketUserByKeyTest() { + LDUser user1 = new LDUser("userKeyA"); + Float point1 = EvaluatorBucketing.bucketUser(noSeed, user1, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.42157587, point1, 0.0000001); + + LDUser user2 = new LDUser("userKeyB"); + Float point2 = EvaluatorBucketing.bucketUser(noSeed, user2, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.6708485, point2, 0.0000001); + + LDUser user3 = new LDUser("userKeyC"); + Float point3 = EvaluatorBucketing.bucketUser(noSeed, user3, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.10343106, point3, 0.0000001); + } + + @Test + public void bucketUserWithSeedTest() { + Integer seed = 61; + + LDUser user1 = new LDUser("userKeyA"); + Float point1 = EvaluatorBucketing.bucketUser(seed, user1, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.09801207, point1, 0.0000001); + + LDUser user2 = new LDUser("userKeyB"); + Float point2 = EvaluatorBucketing.bucketUser(seed, user2, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.14483777, point2, 0.0000001); + + LDUser user3 = new LDUser("userKeyC"); + Float point3 = EvaluatorBucketing.bucketUser(seed, user3, "hashKey", UserAttribute.KEY, "saltyA"); + assertEquals(0.9242641, point3, 0.0000001); + } + +}