From 8bc022b4753f4ae0e6f82f380a499505477dae5a Mon Sep 17 00:00:00 2001 From: Fahad Heylaal Date: Sun, 30 Aug 2026 22:30:07 +0200 Subject: [PATCH] feat: variables testing in Rust --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/child.rs | 3 +- src/cli/test.rs | 140 ++++++++++++++++++++++++++++++++---------------- src/instance.rs | 60 +++++++++++++++++---- tests/child.rs | 58 +++++++++++++++++++- 6 files changed, 206 insertions(+), 59 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ca138a7..c610c0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,7 +136,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "featurevisor" -version = "1.0.0" +version = "1.1.0" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 1c87e8b..d7522b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "featurevisor" -version = "1.0.0" +version = "1.1.0" edition = "2021" rust-version = "1.74.0" description = "Featurevisor SDK for Rust: feature flags, experiments, and remote configuration" diff --git a/src/child.rs b/src/child.rs index 995fca1..ac1d7fb 100644 --- a/src/child.rs +++ b/src/child.rs @@ -418,7 +418,7 @@ impl FeaturevisorChild { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> Evaluation { - let (stored, _, sticky_variables) = self.options(); + let (stored, sticky_features, sticky_variables) = self.options(); let mut merged = stored; if let Some(context) = context { merged.extend(context.clone()); @@ -427,6 +427,7 @@ impl FeaturevisorChild { variable_key, Some(&merged), options, + sticky_features, sticky_variables, ) } diff --git a/src/cli/test.rs b/src/cli/test.rs index e34eaf0..43a5086 100644 --- a/src/cli/test.rs +++ b/src/cli/test.rs @@ -92,6 +92,44 @@ fn compare_evaluation( } } +fn compare_global_variable_evaluation( + errors: &mut Vec, + variable_key: &str, + assertion: &JsonValue, + evaluation: &crate::Evaluation, + child_index: Option, +) { + let prefix = child_index + .map(|index| format!("children[{index}].")) + .unwrap_or_default(); + if let Some(expected) = assertion.get("expectedValue") { + let actual = evaluation + .variable_value + .as_ref() + .map(crate::VariableValue::to_json) + .unwrap_or(JsonValue::Null); + if actual != *expected { + errors.push(format!( + "{variable_key}: {prefix}expectedValue expected {expected}, got {actual}" + )); + } + } + if let Some(expected) = assertion + .get("expectedEvaluation") + .and_then(JsonValue::as_object) + { + let actual = serde_json::to_value(evaluation).unwrap_or(JsonValue::Null); + for (key, expected_value) in expected { + let actual_value = actual.get(key).cloned().unwrap_or(JsonValue::Null); + if actual_value != *expected_value { + errors.push(format!( + "{variable_key}: {prefix}expectedEvaluation.{key} expected {expected_value}, got {actual_value}" + )); + } + } + } +} + fn compare_evaluations( errors: &mut Vec, feature_key: &str, @@ -360,15 +398,12 @@ fn run_assertion( } if let Some(segment_key) = segment_key { - let datafile = base_datafile(datafiles, environment); - let Some(datafile) = datafile else { - return Err(format!( - "No datafile available for segment assertion {segment_key}" - )); - }; let context = context_from_json(assertion.get("context")); - let mut segment_datafile = datafile.clone(); - segment_datafile.segments = segments.clone(); + let segment_datafile = crate::DatafileContent { + revision: "tester".to_string(), + segments: segments.clone(), + ..Default::default() + }; let f = crate::create_featurevisor(FeaturevisorOptions { datafile: Some(input(segment_datafile)), context: Some(context.clone()), @@ -383,32 +418,44 @@ fn run_assertion( .get("expectedToMatch") .and_then(JsonValue::as_bool) .unwrap_or(false); - return if actual == expected { + let result = if actual == expected { Ok(Vec::new()) } else { Ok(vec![format!( "{segment_key}: expected segment match {expected}, got {actual}" )]) }; + f.close(); + return result; } if let Some(variable_key) = variable_key { let selected_key = datafile_key(environment, target); - let datafile = datafiles - .get(&selected_key) - .or_else(|| base_datafile(datafiles, environment)); + let datafile = datafiles.get(&selected_key); let Some(datafile) = datafile else { return Err(format!( "No datafile available for variable assertion {variable_key}" )); }; + if options.common.show_datafile { + println!( + "{}", + serde_json::to_string_pretty(datafile).unwrap_or_default() + ); + } let f = crate::create_featurevisor(FeaturevisorOptions { datafile: Some(input(datafile.clone())), context: Some(context_from_json(assertion.get("context"))), sticky_variables: assertion .get("stickyVariables") .and_then(|value| serde_json::from_value(value.clone()).ok()), + sticky_features: assertion + .get("stickyFeatures") + .and_then(|value| serde_json::from_value(value.clone()).ok()), log_level: Some(log_level(options)), + modules: vec![Arc::new(AtModule { + at: assertion.get("at").and_then(JsonValue::as_f64), + })], ..Default::default() }); let evaluation_options = OverrideOptions { @@ -420,26 +467,42 @@ fn run_assertion( }; let evaluation = f.evaluate_global_variable(variable_key, None, Some(&evaluation_options)); let mut errors = Vec::new(); - if let Some(expected) = assertion.get("expectedValue") { - if evaluation - .variable_value - .as_ref() - .map(crate::VariableValue::to_json) - != Some(expected.clone()) - { - errors.push(format!( - "{variable_key}: expected value {expected}, got {:?}", - evaluation.variable_value - )); + compare_global_variable_evaluation(&mut errors, variable_key, assertion, &evaluation, None); + if let Some(children) = assertion.get("children").and_then(JsonValue::as_array) { + for (child_index, child_assertion) in children.iter().enumerate() { + let child = f.spawn( + context_from_json(child_assertion.get("context")), + SpawnOptions { + sticky_features: child_assertion + .get("stickyFeatures") + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .or_else(|| Some(Default::default())), + sticky_variables: child_assertion + .get("stickyVariables") + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .or_else(|| Some(Default::default())), + }, + ); + let child_options = OverrideOptions { + default_variable_value: child_assertion + .get("defaultVariableValue") + .cloned() + .map(crate::VariableValue::from_json), + ..Default::default() + }; + let child_evaluation = + child.evaluate_global_variable(variable_key, None, Some(&child_options)); + compare_global_variable_evaluation( + &mut errors, + variable_key, + child_assertion, + &child_evaluation, + Some(child_index), + ); + child.close(); } } - if let Some(expected) = assertion - .get("expectedEvaluation") - .and_then(JsonValue::as_object) - { - let actual = serde_json::to_value(&evaluation).unwrap_or(JsonValue::Null); - compare_evaluation(&mut errors, variable_key, "variable", expected, &actual); - } + f.close(); return Ok(errors); } @@ -447,9 +510,7 @@ fn run_assertion( return Ok(vec!["test has no feature, segment, or variable".to_string()]); }; let selected_key = datafile_key(environment, target); - let datafile = datafiles - .get(&selected_key) - .or_else(|| base_datafile(datafiles, environment)); + let datafile = datafiles.get(&selected_key); let Some(datafile) = datafile else { return Err(format!( "No datafile available for feature assertion {feature_key}" @@ -516,6 +577,7 @@ fn run_assertion( compare_variables(&mut errors, feature_key, assertion, &f); compare_evaluations(&mut errors, feature_key, assertion, &f); compare_children(&mut errors, feature_key, assertion, &f); + f.close(); Ok(errors) } @@ -575,18 +637,6 @@ fn project_segments(project: &Path) -> Result, String> Ok(segments) } -fn base_datafile<'a>( - datafiles: &'a HashMap, - environment: Option<&str>, -) -> Option<&'a crate::DatafileContent> { - datafiles.get(&datafile_key(environment, None)).or_else(|| { - datafiles - .iter() - .find(|(key, _)| !key.contains("-target-")) - .map(|(_, value)| value) - }) -} - pub fn run(options: TestOptions) -> Result<(), String> { let project = project_path(&options.common.project_directory_path); let key_pattern = compile_pattern("--keyPattern", options.key_pattern.as_deref())?; diff --git a/src/instance.rs b/src/instance.rs index 4b2d994..eeba5c2 100644 --- a/src/instance.rs +++ b/src/instance.rs @@ -856,12 +856,23 @@ impl Featurevisor { context: Option<&Context>, options: Option<&OverrideOptions>, ) -> Evaluation { - let sticky = self + let (sticky_features, sticky_variables) = self .inner .lock() - .map(|inner| inner.sticky_variables.clone()) + .map(|inner| { + ( + inner.sticky_features.clone(), + inner.sticky_variables.clone(), + ) + }) .unwrap_or_default(); - self.evaluate_global_variable_with_sticky(variable_key, context, options, sticky) + self.evaluate_global_variable_with_sticky( + variable_key, + context, + options, + sticky_features, + sticky_variables, + ) } pub(crate) fn evaluate_global_variable_with_sticky( @@ -869,10 +880,17 @@ impl Featurevisor { variable_key: &str, context: Option<&Context>, options: Option<&OverrideOptions>, - sticky: StickyVariables, + sticky_features: StickyFeatures, + sticky_variables: StickyVariables, ) -> Evaluation { match catch_unwind(AssertUnwindSafe(|| { - self.evaluate_global_variable_inner(variable_key, context, options, sticky) + self.evaluate_global_variable_inner( + variable_key, + context, + options, + sticky_features, + sticky_variables, + ) })) { Ok(evaluation) => evaluation, Err(error) => { @@ -927,7 +945,8 @@ impl Featurevisor { variable_key: &str, context: Option<&Context>, options: Option<&OverrideOptions>, - sticky: StickyVariables, + sticky_features: StickyFeatures, + sticky_variables: StickyVariables, ) -> Evaluation { let (datafile, stored_context, _, _, _, modules, regex_cache) = self.snapshot(); let mut resolved_context = stored_context; @@ -963,7 +982,7 @@ impl Featurevisor { .clone() .unwrap_or_else(|| variable_key.to_string()); let mut evaluation = self.empty_global_variable_evaluation(&resolved_key); - if let Some(value) = sticky.get(&resolved_key) { + if let Some(value) = sticky_variables.get(&resolved_key) { evaluation.reason = EvaluationReason::Sticky; evaluation.variable_value = Some(value.clone()); } else if let Some(variable) = datafile.variables.get(&resolved_key) { @@ -972,6 +991,7 @@ impl Featurevisor { variable.required_features.as_deref(), &evaluation_options.context, options, + &sticky_features, ); if !requirements_match { evaluation.reason = EvaluationReason::RequiredFeaturesUnmet; @@ -986,6 +1006,7 @@ impl Featurevisor { item.required_features.as_deref(), &evaluation_options.context, options, + &sticky_features, ) { continue; } @@ -1054,6 +1075,7 @@ impl Featurevisor { requirements: Option<&[crate::types::Required]>, context: &Context, options: Option<&OverrideOptions>, + sticky_features: &StickyFeatures, ) -> bool { requirements.unwrap_or_default().iter().all(|required| { let (key, enabled, variation) = match required { @@ -1071,11 +1093,31 @@ impl Featurevisor { (key.as_str(), true, Some(variation.as_str())) } }; - if self.is_enabled(key, Some(context)) != enabled { + let enabled_evaluation = self.evaluate_child( + EvaluationType::Flag, + key, + None, + Some(context), + options, + sticky_features.clone(), + ); + if (enabled_evaluation.enabled == Some(true)) != enabled { return false; } variation.map_or(true, |expected| { - self.get_variation(key, Some(context), options).as_deref() == Some(expected) + let evaluation = self.evaluate_child( + EvaluationType::Variation, + key, + None, + Some(context), + options, + sticky_features.clone(), + ); + evaluation + .variation_value + .or_else(|| evaluation.variation.map(|value| value.value)) + .as_deref() + == Some(expected) }) }) } diff --git a/tests/child.rs b/tests/child.rs index a1b91d1..2b05a17 100644 --- a/tests/child.rs +++ b/tests/child.rs @@ -1,6 +1,6 @@ use featurevisor::{ - create_featurevisor, AttributeValue, DatafileInput, FeaturevisorOptions, SpawnOptions, - StickyVariables, + create_featurevisor, AttributeValue, DatafileInput, EvaluatedFeature, FeaturevisorOptions, + SpawnOptions, StickyFeatures, StickyVariables, }; use serde_json::json; @@ -41,6 +41,60 @@ fn child_keeps_a_context_snapshot_and_inherits_new_parent_keys() { child.close(); } +#[test] +fn child_global_variable_required_features_use_child_sticky_state() { + let datafile = serde_json::from_value(json!({ + "schemaVersion": "2", + "revision": "child-required-feature", + "segments": {}, + "features": { + "dependency": { + "bucketBy": ["userId"], + "traffic": [{ "key": "all", "segments": "*", "percentage": 100000 }] + } + }, + "variables": { + "message": { + "type": "string", + "defaultValue": "available", + "disabledValue": "unavailable", + "requiredFeatures": ["dependency"] + } + } + })) + .unwrap(); + let f = create_featurevisor(FeaturevisorOptions { + datafile: Some(DatafileInput::Content(datafile)), + context: Some( + [(String::from("userId"), AttributeValue::from("one"))] + .into_iter() + .collect(), + ), + sticky_features: Some(StickyFeatures::from([( + "dependency".to_string(), + EvaluatedFeature { + enabled: false, + variation: None, + variables: None, + }, + )])), + ..Default::default() + }); + let child = f.spawn(Default::default(), Default::default()); + + assert_eq!( + f.get_global_variable_string("message", None, None) + .as_deref(), + Some("unavailable") + ); + assert_eq!( + child + .get_global_variable_string("message", None, None) + .as_deref(), + Some("available") + ); +} + #[test] fn child_global_variable_sticky_state_is_isolated() { let datafile = serde_json::from_value(json!({