launchdarkly_server_sdk_evaluation/
variation.rs

1use serde::{Deserialize, Serialize};
2
3use crate::contexts::attribute_reference::AttributeName;
4use crate::util::is_false;
5use crate::{
6    contexts::context::{BucketPrefix, Kind},
7    Context, Reference,
8};
9use serde_with::skip_serializing_none;
10
11/// A type representing the index into the [crate::Flag]'s variations.
12pub type VariationIndex = isize;
13
14#[derive(Debug, PartialEq)]
15pub(crate) struct BucketResult {
16    pub variation_index: VariationIndex,
17    pub in_experiment: bool,
18}
19
20impl From<&VariationIndex> for BucketResult {
21    fn from(variation_index: &VariationIndex) -> Self {
22        BucketResult {
23            variation_index: *variation_index,
24            in_experiment: false, // single variations are never in an experiment
25        }
26    }
27}
28
29/// RolloutKind describes whether a rollout is a simple percentage rollout or represents an
30/// experiment. Experiments have different behaviour for tracking and variation bucketing.
31#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
32#[serde(rename_all = "camelCase")]
33pub enum RolloutKind {
34    /// Represents a simple percentage rollout. This is the default rollout kind, and will be assumed if
35    /// not otherwise specified.
36    #[default]
37    Rollout,
38
39    /// Represents an experiment. Experiments have different behaviour for tracking and variation
40    /// bucketing.
41    Experiment,
42}
43
44/// Rollout describes how contexts will be bucketed into variations during a percentage rollout.
45// Rollout is deserialized via a helper, IntermediateRollout, because of semantic ambiguity
46// of the bucketBy Reference field.
47//
48// Rollout implements Serialize directly without a helper because References can serialize
49// themselves without any ambiguity.
50#[skip_serializing_none]
51#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
52#[serde(rename_all = "camelCase", from = "IntermediateRollout")]
53pub struct Rollout {
54    // Specifies if this rollout is a simple rollout or an experiment. Should default to rollout
55    // if absent.
56    kind: Option<RolloutKind>,
57    // Context kind associated with this rollout.
58    context_kind: Option<Kind>,
59    // Specifies which attribute should be used to distinguish between Contexts in a rollout.
60    // Can be omitted; evaluation should treat absence as 'key'.
61    bucket_by: Option<Reference>,
62    // Which variations should be included in the rollout, and associated weight.
63    variations: Vec<WeightedVariation>,
64    // Specifies the seed to be used by the hashing algorithm.
65    seed: Option<i64>,
66}
67
68#[derive(Debug, Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct RolloutWithContextKind {
71    kind: Option<RolloutKind>,
72    context_kind: Kind,
73    // bucketBy deserializes directly into a reference.
74    bucket_by: Option<Reference>,
75    variations: Vec<WeightedVariation>,
76    seed: Option<i64>,
77}
78
79#[derive(Debug, Deserialize)]
80#[serde(rename_all = "camelCase")]
81struct RolloutWithoutContextKind {
82    kind: Option<RolloutKind>,
83    bucket_by: Option<AttributeName>,
84    variations: Vec<WeightedVariation>,
85    seed: Option<i64>,
86}
87
88#[derive(Debug, Deserialize)]
89#[serde(untagged)]
90enum IntermediateRollout {
91    // RolloutWithContextKind must be listed first in the enum because otherwise
92    // RolloutWithoutContextKind could match the input (by ignoring/discarding
93    // the context_kind field).
94    ContextAware(RolloutWithContextKind),
95    ContextOblivious(RolloutWithoutContextKind),
96}
97
98impl From<IntermediateRollout> for Rollout {
99    fn from(rollout: IntermediateRollout) -> Self {
100        match rollout {
101            IntermediateRollout::ContextAware(fields) => Self {
102                kind: fields.kind,
103                context_kind: Some(fields.context_kind),
104                bucket_by: fields.bucket_by,
105                variations: fields.variations,
106                seed: fields.seed,
107            },
108            IntermediateRollout::ContextOblivious(fields) => Self {
109                kind: fields.kind,
110                context_kind: None,
111                bucket_by: fields.bucket_by.map(Reference::from),
112                variations: fields.variations,
113                seed: fields.seed,
114            },
115        }
116    }
117}
118
119#[cfg(test)]
120pub(crate) mod proptest_generators {
121    use super::*;
122    use crate::proptest_generators::{any_kind, any_ref};
123    use proptest::{collection::vec, option::of, prelude::*};
124
125    prop_compose! {
126        fn any_weighted_variation() (
127            variation in any::<isize>(),
128            weight in any::<f32>(),
129            untracked in any::<bool>()
130        ) -> WeightedVariation {
131            WeightedVariation {
132                variation,
133                weight,
134                untracked
135            }
136        }
137    }
138
139    fn any_rollout_kind() -> BoxedStrategy<RolloutKind> {
140        prop_oneof![Just(RolloutKind::Rollout), Just(RolloutKind::Experiment)].boxed()
141    }
142
143    prop_compose! {
144        pub(crate) fn any_rollout() (
145            kind in of(any_rollout_kind()),
146            context_kind in any_kind(),
147            bucket_by in of(any_ref()),
148            seed in of(any::<i64>()),
149            variations in vec(any_weighted_variation(), 0..2)
150        ) -> Rollout {
151            Rollout {
152                kind,
153                context_kind: Some(context_kind),
154                bucket_by,
155                seed,
156                variations,
157            }
158        }
159    }
160}
161
162impl Rollout {
163    #[cfg(test)]
164    fn with_variations<V: Into<Vec<WeightedVariation>>>(variations: V) -> Self {
165        Rollout {
166            kind: None,
167            context_kind: None,
168            bucket_by: None,
169            seed: None,
170            variations: variations.into(),
171        }
172    }
173
174    #[cfg(test)]
175    fn bucket_by(self, bucket_by: &str) -> Self {
176        Rollout {
177            bucket_by: Some(Reference::new(bucket_by)),
178            ..self
179        }
180    }
181}
182
183/// VariationOrRollout describes either a fixed variation or a percentage rollout.
184///
185/// There is a VariationOrRollout for every [crate::FlagRule], and also one in [crate::eval::Reason::Fallthrough] which is
186/// used if no rules match.
187///
188/// Invariant: one of the variation or rollout must be non-nil.
189// This enum is a bit oddly-shaped because data errors may cause the server to emit rules with neither or both of a
190// variation or rollout, and we need to treat invalid states with grace (i.e. don't throw a 500 on deserialization, and
191// prefer variation if both are present)
192#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
193#[serde(untagged)]
194pub enum VariationOrRollout {
195    /// Represents a fixed variation.
196    Variation {
197        /// The index of the variation to return. It is undefined if no specific variation is defined.
198        variation: VariationIndex,
199    },
200    /// Represents a percentage rollout.
201    Rollout {
202        /// Specifies the rollout definition. See [Rollout].
203        rollout: Rollout,
204    },
205    /// Represents a malformed VariationOrRollout payload. This is done to deal with potential
206    /// data errors that may occur server-side. Generally speaking this should not occur.
207    Malformed(serde_json::Value),
208}
209
210pub(crate) type VariationWeight = f32;
211
212/// WeightedVariation describes a fraction of contexts which will receive a specific variation.
213#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
214pub struct WeightedVariation {
215    /// The index of the variation to be returned if the context is in this bucket. This is always a
216    /// real variation index; it cannot be undefined.
217    pub variation: VariationIndex,
218
219    /// The proportion of contexts which should go into this bucket, as an integer from 0 to 100000.
220    pub weight: VariationWeight,
221
222    /// Untracked means that contexts allocated to this variation should not have tracking events sent.
223    #[serde(default, skip_serializing_if = "is_false")]
224    pub untracked: bool,
225}
226
227impl WeightedVariation {
228    #[cfg(test)]
229    fn new(variation: VariationIndex, weight: VariationWeight) -> Self {
230        WeightedVariation {
231            variation,
232            weight,
233            untracked: false,
234        }
235    }
236
237    fn as_bucket_result(&self, is_experiment: bool) -> BucketResult {
238        BucketResult {
239            variation_index: self.variation,
240            in_experiment: is_experiment && !self.untracked,
241        }
242    }
243}
244
245impl VariationOrRollout {
246    pub(crate) fn variation(
247        &self,
248        flag_key: &str,
249        context: &Context,
250        salt: &str,
251    ) -> Result<Option<BucketResult>, String> {
252        match self {
253            VariationOrRollout::Variation { variation: var } => Ok(Some(var.into())),
254            VariationOrRollout::Rollout {
255                rollout:
256                    Rollout {
257                        kind,
258                        bucket_by,
259                        variations,
260                        seed,
261                        context_kind,
262                    },
263            } => {
264                let is_experiment =
265                    kind.as_ref().unwrap_or(&RolloutKind::default()) == &RolloutKind::Experiment;
266
267                let prefix = match seed {
268                    Some(seed) => BucketPrefix::Seed(*seed),
269                    None => BucketPrefix::KeyAndSalt(flag_key, salt),
270                };
271
272                let (bucket, was_missing_context) = context.bucket(
273                    bucket_by,
274                    prefix,
275                    is_experiment,
276                    context_kind.as_ref().unwrap_or(&Kind::default()),
277                )?;
278
279                let mut sum = 0.0;
280                for variation in variations {
281                    sum += variation.weight / 100_000.0;
282                    if bucket < sum {
283                        return Ok(Some(
284                            variation.as_bucket_result(is_experiment && !was_missing_context),
285                        ));
286                    }
287                }
288                Ok(variations
289                    .last()
290                    .map(|var| var.as_bucket_result(is_experiment && !was_missing_context)))
291            }
292            VariationOrRollout::Malformed(_) => Ok(None),
293        }
294    }
295}
296
297// Note: These tests are meant to be exact duplicates of tests
298// in other SDKs. Do not change any of the values unless they
299// are also changed in other SDKs. These are not traditional behavioral
300// tests so much as consistency tests to guarantee that the implementation
301// is identical across SDKs.
302
303#[cfg(test)]
304mod consistency_tests {
305    use super::*;
306    use crate::ContextBuilder;
307    use serde_json::json;
308    use spectral::prelude::*;
309    use test_case::test_case;
310
311    const BUCKET_TOLERANCE: f32 = 0.0000001;
312
313    #[test]
314    fn variation_index_for_context() {
315        const HASH_KEY: &str = "hashKey";
316        const SALT: &str = "saltyA";
317
318        let wv0 = WeightedVariation::new(0, 60_000.0);
319        let wv1 = WeightedVariation::new(1, 40_000.0);
320        let rollout = VariationOrRollout::Rollout {
321            rollout: Rollout::with_variations(vec![wv0, wv1]),
322        };
323
324        asserting!("userKeyA (bucket 0.42157587) should get variation 0")
325            .that(
326                &rollout
327                    .variation(
328                        HASH_KEY,
329                        &ContextBuilder::new("userKeyA").build().unwrap(),
330                        SALT,
331                    )
332                    .unwrap(),
333            )
334            .contains_value(BucketResult {
335                variation_index: 0,
336                in_experiment: false,
337            });
338        asserting!("userKeyB (bucket 0.6708485) should get variation 1")
339            .that(
340                &rollout
341                    .variation(
342                        HASH_KEY,
343                        &ContextBuilder::new("userKeyB").build().unwrap(),
344                        SALT,
345                    )
346                    .unwrap(),
347            )
348            .contains_value(BucketResult {
349                variation_index: 1,
350                in_experiment: false,
351            });
352        asserting!("userKeyC (bucket 0.10343106) should get variation 0")
353            .that(
354                &rollout
355                    .variation(
356                        HASH_KEY,
357                        &ContextBuilder::new("userKeyC").build().unwrap(),
358                        SALT,
359                    )
360                    .unwrap(),
361            )
362            .contains_value(BucketResult {
363                variation_index: 0,
364                in_experiment: false,
365            });
366    }
367
368    #[test_case(None, "userKeyA", 2)] // 0.42157587,
369    #[test_case(None, "userKeyB", 2)] // 0.6708485,
370    #[test_case(None, "userKeyC", 1)] // 0.10343106,
371    #[test_case(Some(61), "userKeyA", 0)] // 0.09801207,
372    #[test_case(Some(61), "userKeyB", 1)] // 0.14483777,
373    #[test_case(Some(61), "userKeyC", 2)] // 0.9242641,
374    fn testing_experiment_bucketing(
375        seed: Option<i64>,
376        key: &str,
377        expected_variation_index: VariationIndex,
378    ) {
379        const HASH_KEY: &str = "hashKey";
380        const SALT: &str = "saltyA";
381
382        let wv0 = WeightedVariation::new(0, 10_000.0);
383        let wv1 = WeightedVariation::new(1, 20_000.0);
384        let wv2 = WeightedVariation::new(2, 70_000.0);
385
386        let mut rollout = Rollout::with_variations(vec![wv0, wv1, wv2]).bucket_by("intAttr");
387        rollout.kind = Some(RolloutKind::Experiment);
388        rollout.seed = seed;
389        let rollout = VariationOrRollout::Rollout { rollout };
390
391        let result = rollout
392            .variation(
393                HASH_KEY,
394                &ContextBuilder::new(key)
395                    .set_value("intAttr", 0.6708485.into())
396                    .build()
397                    .unwrap(),
398                SALT,
399            )
400            .unwrap()
401            .unwrap();
402
403        assert_eq!(result.variation_index, expected_variation_index);
404    }
405
406    #[test]
407    fn variation_index_for_context_with_custom_attribute() {
408        const HASH_KEY: &str = "hashKey";
409        const SALT: &str = "saltyA";
410
411        let wv0 = WeightedVariation::new(0, 60_000.0);
412        let wv1 = WeightedVariation::new(1, 40_000.0);
413        let rollout = VariationOrRollout::Rollout {
414            rollout: Rollout::with_variations(vec![wv0, wv1]).bucket_by("intAttr"),
415        };
416
417        asserting!("userKeyD (bucket 0.54771423) should get variation 0")
418            .that(
419                &rollout
420                    .variation(
421                        HASH_KEY,
422                        &ContextBuilder::new("userKeyA")
423                            .set_value("intAttr", 33_333.into())
424                            .build()
425                            .unwrap(),
426                        SALT,
427                    )
428                    .unwrap(),
429            )
430            .contains_value(BucketResult {
431                variation_index: 0,
432                in_experiment: false,
433            });
434
435        asserting!("userKeyD (bucket 0.7309658) should get variation 0")
436            .that(
437                &rollout
438                    .variation(
439                        HASH_KEY,
440                        &ContextBuilder::new("userKeyA")
441                            .set_value("intAttr", 99_999.into())
442                            .build()
443                            .unwrap(),
444                        SALT,
445                    )
446                    .unwrap(),
447            )
448            .contains_value(BucketResult {
449                variation_index: 1,
450                in_experiment: false,
451            });
452    }
453
454    #[test]
455    fn variation_index_for_context_in_experiment() {
456        const HASH_KEY: &str = "hashKey";
457        const SALT: &str = "saltyA";
458
459        let wv0 = WeightedVariation {
460            variation: 0,
461            weight: 10_000.0,
462            untracked: false,
463        };
464        let wv1 = WeightedVariation {
465            variation: 1,
466            weight: 20_000.0,
467            untracked: false,
468        };
469        let wv0_untracked = WeightedVariation {
470            variation: 0,
471            weight: 70_000.0,
472            untracked: true,
473        };
474        let rollout = VariationOrRollout::Rollout {
475            rollout: Rollout {
476                seed: Some(61),
477                kind: Some(RolloutKind::Experiment),
478                ..Rollout::with_variations(vec![wv0, wv1, wv0_untracked])
479            },
480        };
481
482        asserting!("userKeyA (bucket 0.09801207) should get variation 0 and be in the experiment")
483            .that(
484                &rollout
485                    .variation(
486                        HASH_KEY,
487                        &ContextBuilder::new("userKeyA").build().unwrap(),
488                        SALT,
489                    )
490                    .unwrap(),
491            )
492            .contains_value(BucketResult {
493                variation_index: 0,
494                in_experiment: true,
495            });
496        asserting!("userKeyB (bucket 0.14483777) should get variation 1 and be in the experiment")
497            .that(
498                &rollout
499                    .variation(
500                        HASH_KEY,
501                        &ContextBuilder::new("userKeyB").build().unwrap(),
502                        SALT,
503                    )
504                    .unwrap(),
505            )
506            .contains_value(BucketResult {
507                variation_index: 1,
508                in_experiment: true,
509            });
510        asserting!(
511            "userKeyC (bucket 0.9242641) should get variation 0 and not be in the experiment"
512        )
513        .that(
514            &rollout
515                .variation(
516                    HASH_KEY,
517                    &ContextBuilder::new("userKeyC").build().unwrap(),
518                    SALT,
519                )
520                .unwrap(),
521        )
522        .contains_value(BucketResult {
523            variation_index: 0,
524            in_experiment: false,
525        });
526    }
527
528    #[test]
529    fn bucket_context_by_key() {
530        const PREFIX: BucketPrefix = BucketPrefix::KeyAndSalt("hashKey", "saltyA");
531
532        let context = ContextBuilder::new("userKeyA").build().unwrap();
533        let (bucket, _) = context.bucket(&None, PREFIX, false, &Kind::user()).unwrap();
534        assert_that!(bucket).is_close_to(0.42157587, BUCKET_TOLERANCE);
535
536        let context = ContextBuilder::new("userKeyB").build().unwrap();
537        let (bucket, _) = context.bucket(&None, PREFIX, false, &Kind::user()).unwrap();
538        assert_that!(bucket).is_close_to(0.6708485, BUCKET_TOLERANCE);
539
540        let context = ContextBuilder::new("userKeyC").build().unwrap();
541        let (bucket, _) = context.bucket(&None, PREFIX, false, &Kind::user()).unwrap();
542        assert_that!(bucket).is_close_to(0.10343106, BUCKET_TOLERANCE);
543
544        let result = context.bucket(&Some(Reference::new("")), PREFIX, false, &Kind::user());
545        assert!(result.is_err());
546    }
547
548    #[test]
549    fn bucket_context_by_key_with_seed() {
550        const PREFIX: BucketPrefix = BucketPrefix::Seed(61);
551
552        let context_a = ContextBuilder::new("userKeyA").build().unwrap();
553        let (bucket, _) = context_a
554            .bucket(&None, PREFIX, false, &Kind::user())
555            .unwrap();
556        assert_that!(bucket).is_close_to(0.09801207, BUCKET_TOLERANCE);
557
558        let context_b = ContextBuilder::new("userKeyB").build().unwrap();
559        let (bucket, _) = context_b
560            .bucket(&None, PREFIX, false, &Kind::user())
561            .unwrap();
562        assert_that!(bucket).is_close_to(0.14483777, BUCKET_TOLERANCE);
563
564        let context_c = ContextBuilder::new("userKeyC").build().unwrap();
565        let (bucket, _) = context_c
566            .bucket(&None, PREFIX, false, &Kind::user())
567            .unwrap();
568        assert_that!(bucket).is_close_to(0.9242641, BUCKET_TOLERANCE);
569
570        // changing seed produces different bucket value
571        let (bucket, _) = context_a
572            .bucket(&None, BucketPrefix::Seed(60), false, &Kind::user())
573            .unwrap();
574        assert_that!(bucket).is_close_to(0.7008816, BUCKET_TOLERANCE)
575    }
576
577    #[test]
578    #[cfg_attr(not(feature = "secondary_key_bucketing"), ignore)]
579    fn bucket_context_with_secondary_key_only_when_feature_enabled() {
580        const PREFIX: BucketPrefix = BucketPrefix::KeyAndSalt("hashKey", "salt");
581
582        let context1 = ContextBuilder::new("userKey").build().unwrap();
583
584        // can only construct a context w/ secondary by deserializing from implicit user format.
585        let context2: Context = serde_json::from_value(json!({
586            "key" : "userKey",
587            "secondary" : "mySecondaryKey"
588        }))
589        .unwrap();
590
591        let result1 = context1.bucket(&None, PREFIX, false, &Kind::user());
592        let result2 = context2.bucket(&None, PREFIX, false, &Kind::user());
593        assert_that!(result1).is_not_equal_to(result2);
594    }
595
596    #[test]
597    #[cfg_attr(feature = "secondary_key_bucketing", ignore)]
598    fn bucket_context_with_secondary_key_does_not_change_result() {
599        const PREFIX: BucketPrefix = BucketPrefix::KeyAndSalt("hashKey", "salt");
600
601        let context1: Context = ContextBuilder::new("userKey").build().unwrap();
602
603        // can only construct a context w/ secondary by deserializing from implicit user format.
604        let context2: Context = serde_json::from_value(json!({
605            "key" : "userKey",
606            "secondary" : "mySecondaryKey"
607        }))
608        .unwrap();
609
610        let result1 = context1.bucket(&None, PREFIX, false, &Kind::user());
611        let result2 = context2.bucket(&None, PREFIX, false, &Kind::user());
612        assert_that!(result1).is_equal_to(result2);
613    }
614
615    #[test]
616    fn bucket_context_by_int_attr() {
617        const USER_KEY: &str = "userKeyD";
618        const PREFIX: BucketPrefix = BucketPrefix::KeyAndSalt("hashKey", "saltyA");
619
620        let context = ContextBuilder::new(USER_KEY)
621            .set_value("intAttr", 33_333.into())
622            .build()
623            .unwrap();
624        let (bucket, _) = context
625            .bucket(
626                &Some(Reference::new("intAttr")),
627                PREFIX,
628                false,
629                &Kind::user(),
630            )
631            .unwrap();
632        assert_that!(bucket).is_close_to(0.54771423, BUCKET_TOLERANCE);
633
634        let context = ContextBuilder::new(USER_KEY)
635            .set_value("stringAttr", "33333".into())
636            .build()
637            .unwrap();
638        let (bucket2, _) = context
639            .bucket(
640                &Some(Reference::new("stringAttr")),
641                PREFIX,
642                false,
643                &Kind::user(),
644            )
645            .unwrap();
646        assert_that!(bucket).is_close_to(bucket2, BUCKET_TOLERANCE);
647    }
648
649    #[test]
650    fn bucket_context_by_float_attr_not_allowed() {
651        const USER_KEY: &str = "userKeyE";
652        const PREFIX: BucketPrefix = BucketPrefix::KeyAndSalt("hashKey", "saltyA");
653
654        let context = ContextBuilder::new(USER_KEY)
655            .set_value("floatAttr", 999.999.into())
656            .build()
657            .unwrap();
658        let (bucket, _) = context
659            .bucket(
660                &Some(Reference::new("floatAttr")),
661                PREFIX,
662                false,
663                &Kind::user(),
664            )
665            .unwrap();
666        assert_that!(bucket).is_close_to(0.0, BUCKET_TOLERANCE);
667    }
668
669    #[test]
670    fn bucket_context_by_float_attr_that_is_really_an_int_is_allowed() {
671        const PREFIX: BucketPrefix = BucketPrefix::KeyAndSalt("hashKey", "saltyA");
672
673        let context = ContextBuilder::new("userKeyE")
674            .set_value("floatAttr", f64::from(33_333).into())
675            .build()
676            .unwrap();
677        let (bucket, _) = context
678            .bucket(
679                &Some(Reference::new("floatAttr")),
680                PREFIX,
681                false,
682                &Kind::user(),
683            )
684            .unwrap();
685        assert_that!(bucket).is_close_to(0.54771423, BUCKET_TOLERANCE);
686    }
687
688    #[test]
689    fn test_bucket_value_beyond_last_bucket_is_pinned_to_last_bucket() {
690        const HASH_KEY: &str = "hashKey";
691        const SALT: &str = "saltyA";
692
693        let wv0 = WeightedVariation::new(0, 5_000.0);
694        let wv1 = WeightedVariation::new(1, 5_000.0);
695        let rollout = VariationOrRollout::Rollout {
696            rollout: Rollout {
697                seed: Some(61),
698                kind: Some(RolloutKind::Rollout),
699                ..Rollout::with_variations(vec![wv0, wv1])
700            },
701        };
702        let context = ContextBuilder::new("userKeyD")
703            .set_value("intAttr", 99_999.into())
704            .build()
705            .unwrap();
706        asserting!("userKeyD should get variation 1 and not be in the experiment")
707            .that(&rollout.variation(HASH_KEY, &context, SALT).unwrap())
708            .contains_value(BucketResult {
709                variation_index: 1,
710                in_experiment: false,
711            });
712    }
713
714    #[test]
715    fn test_bucket_value_beyond_last_bucket_is_pinned_to_last_bucket_for_experiment() {
716        const HASH_KEY: &str = "hashKey";
717        const SALT: &str = "saltyA";
718
719        let wv0 = WeightedVariation::new(0, 5_000.0);
720        let wv1 = WeightedVariation::new(1, 5_000.0);
721        let rollout = VariationOrRollout::Rollout {
722            rollout: Rollout {
723                seed: Some(61),
724                kind: Some(RolloutKind::Experiment),
725                ..Rollout::with_variations(vec![wv0, wv1])
726            },
727        };
728        let context = ContextBuilder::new("userKeyD")
729            .set_value("intAttr", 99_999.into())
730            .build()
731            .unwrap();
732        asserting!("userKeyD should get variation 1 and be in the experiment")
733            .that(&rollout.variation(HASH_KEY, &context, SALT).unwrap())
734            .contains_value(BucketResult {
735                variation_index: 1,
736                in_experiment: true,
737            });
738    }
739}
740
741#[cfg(test)]
742mod tests {
743    use crate::ContextBuilder;
744    use assert_json_diff::assert_json_eq;
745
746    use super::*;
747    use crate::proptest_generators::*;
748    use proptest::prelude::*;
749    use serde_json::json;
750    use spectral::prelude::*;
751
752    proptest! {
753        #[test]
754         fn arbitrary_rollout_serialization_roundtrip(rollout in any_rollout()) {
755            let json = serde_json::to_value(rollout).expect("a rollout should serialize");
756            let parsed: Rollout = serde_json::from_value(json.clone()).expect("a rollout should parse");
757            assert_json_eq!(json, parsed);
758        }
759    }
760
761    #[test]
762    fn rollout_serialize_omits_optional_fields() {
763        let json = json!({"variations" : []});
764        let parsed: Rollout = serde_json::from_value(json.clone()).expect("should parse");
765        assert_json_eq!(json, parsed);
766    }
767
768    #[test]
769    fn test_parse_variation_or_rollout() {
770        let variation: VariationOrRollout =
771            serde_json::from_str(r#"{"variation":4}"#).expect("should parse");
772
773        assert_that!(variation).is_equal_to(&VariationOrRollout::Variation { variation: 4 });
774
775        let rollout: VariationOrRollout =
776            serde_json::from_str(r#"{"rollout":{"variations":[{"variation":1,"weight":100000}]}}"#)
777                .expect("should parse");
778        assert_that!(rollout).is_equal_to(&VariationOrRollout::Rollout {
779            rollout: Rollout::with_variations(vec![WeightedVariation::new(1, 100_000.0)]),
780        });
781
782        let rollout_bucket_by: VariationOrRollout = serde_json::from_str(
783            r#"{"rollout":{"bucketBy":"bucket","variations":[{"variation":1,"weight":100000}]}}"#,
784        )
785        .expect("should parse");
786        assert_that!(rollout_bucket_by).is_equal_to(&VariationOrRollout::Rollout {
787            rollout: Rollout {
788                bucket_by: Some(Reference::new("bucket")),
789                ..Rollout::with_variations(vec![WeightedVariation::new(1, 100_000.0)])
790            },
791        });
792
793        let rollout_seed: VariationOrRollout = serde_json::from_str(
794            r#"{"rollout":{"variations":[{"variation":1,"weight":100000}],"seed":42}}"#,
795        )
796        .expect("should parse");
797        assert_that!(rollout_seed).is_equal_to(&VariationOrRollout::Rollout {
798            rollout: Rollout {
799                seed: Some(42),
800                ..Rollout::with_variations(vec![WeightedVariation::new(1, 100_000.0)])
801            },
802        });
803
804        let rollout_experiment: VariationOrRollout = serde_json::from_str(
805            r#"{
806                 "rollout":
807                   {
808                     "kind": "experiment",
809                     "variations": [
810                       {"variation":1, "weight":20000},
811                       {"variation":0, "weight":20000},
812                       {"variation":0, "weight":60000, "untracked": true}
813                     ],
814                     "seed":42
815                   }
816            }"#,
817        )
818        .expect("should parse");
819        assert_that!(rollout_experiment).is_equal_to(&VariationOrRollout::Rollout {
820            rollout: Rollout {
821                kind: Some(RolloutKind::Experiment),
822                seed: Some(42),
823                ..Rollout::with_variations(vec![
824                    WeightedVariation::new(1, 20_000.0),
825                    WeightedVariation::new(0, 20_000.0),
826                    WeightedVariation {
827                        untracked: true,
828                        ..WeightedVariation::new(0, 60_000.0)
829                    },
830                ])
831            },
832        });
833
834        let malformed: VariationOrRollout = serde_json::from_str(r#"{}"#).expect("should parse");
835        assert_that!(malformed).is_equal_to(VariationOrRollout::Malformed(json!({})));
836
837        let overspecified: VariationOrRollout = serde_json::from_str(
838            r#"{
839                "variation": 1,
840                "rollout": {"variations": [{"variation": 1, "weight": 100000}], "seed": 42}
841            }"#,
842        )
843        .expect("should parse");
844        assert_that!(overspecified).is_equal_to(VariationOrRollout::Variation { variation: 1 });
845    }
846
847    #[test]
848    fn incomplete_weighting_defaults_to_last_variation() {
849        const HASH_KEY: &str = "hashKey";
850        const SALT: &str = "saltyA";
851
852        let wv0 = WeightedVariation::new(0, 1.0);
853        let wv1 = WeightedVariation::new(1, 2.0);
854        let wv2 = WeightedVariation::new(2, 3.0);
855        let rollout = VariationOrRollout::Rollout {
856            rollout: Rollout::with_variations(vec![wv0, wv1, wv2]),
857        };
858
859        asserting!("userKeyD (bucket 0.7816281) should get variation 2")
860            .that(
861                &rollout
862                    .variation(
863                        HASH_KEY,
864                        &ContextBuilder::new("userKeyD").build().unwrap(),
865                        SALT,
866                    )
867                    .unwrap(),
868            )
869            .contains_value(BucketResult {
870                variation_index: 2,
871                in_experiment: false,
872            });
873    }
874}