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
11pub 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, }
26 }
27}
28
29#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Default)]
32#[serde(rename_all = "camelCase")]
33pub enum RolloutKind {
34 #[default]
37 Rollout,
38
39 Experiment,
42}
43
44#[skip_serializing_none]
51#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
52#[serde(rename_all = "camelCase", from = "IntermediateRollout")]
53pub struct Rollout {
54 kind: Option<RolloutKind>,
57 context_kind: Option<Kind>,
59 bucket_by: Option<Reference>,
62 variations: Vec<WeightedVariation>,
64 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 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 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#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
193#[serde(untagged)]
194pub enum VariationOrRollout {
195 Variation {
197 variation: VariationIndex,
199 },
200 Rollout {
202 rollout: Rollout,
204 },
205 Malformed(serde_json::Value),
208}
209
210pub(crate) type VariationWeight = f32;
211
212#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
214pub struct WeightedVariation {
215 pub variation: VariationIndex,
218
219 pub weight: VariationWeight,
221
222 #[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#[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)] #[test_case(None, "userKeyB", 2)] #[test_case(None, "userKeyC", 1)] #[test_case(Some(61), "userKeyA", 0)] #[test_case(Some(61), "userKeyB", 1)] #[test_case(Some(61), "userKeyC", 2)] 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 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 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 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}