//! Prototype expressions and nested (conditional) actions. use std::collections::BTreeMap; use crate::color::Color; use crate::proto::*; use crate::{Collection, Mode, NodeId, Variable, VariableType, VariableValue, Variables}; pub(crate) fn vars() -> Variables { let values = |a: VariableValue, b: VariableValue| BTreeMap::from([("light".to_string(), a), ("dark".to_string(), b)]); let var = |id: &str, kind, values| Variable { id: id.into(), name: format!("{id}-name"), collection: "c".into(), kind, values, description: String::new(), scopes: None, hidden_from_publishing: false, extends: None, overrides: Default::default(), code_syntax: Default::default(), }; Variables { collections: vec![Collection { id: "c".into(), name: "Theme".into(), modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], extends: None, }], variables: vec![ var("n", VariableType::Number, values(VariableValue::Number(3.0), VariableValue::Number(7.0))), var("s", VariableType::String, values(VariableValue::String("hi".into()), VariableValue::String("yo".into()))), var("b", VariableType::Boolean, values(VariableValue::Boolean(true), VariableValue::Boolean(false))), var("alias", VariableType::Number, values(VariableValue::Alias("n".into()), VariableValue::Alias("n".into()))), ], } } fn n(x: f64) -> Expr { Expr::Value(VariableValue::Number(x)) } fn s(x: &str) -> Expr { Expr::Value(VariableValue::String(x.into())) } fn b(x: bool) -> Expr { Expr::Value(VariableValue::Boolean(x)) } fn eval(e: &Expr) -> Option { e.eval(&vars(), &BTreeMap::new()) } #[test] fn arithmetic_text_and_comparisons() { use ExprOp::*; let num = |x: f64| Some(VariableValue::Number(x)); let yes = Some(VariableValue::Boolean(true)); let no = Some(VariableValue::Boolean(false)); assert_eq!(eval(&Expr::op(Add, vec![Expr::var("n"), n(1.0)])), num(4.0)); assert_eq!(eval(&Expr::op(Subtract, vec![n(10.0), Expr::var("alias"), n(2.0)])), num(5.0), "left to right, through aliases"); assert_eq!(eval(&Expr::op(Multiply, vec![Expr::var("n"), n(2.5)])), num(7.5)); assert_eq!(eval(&Expr::op(Divide, vec![n(9.0), Expr::var("n")])), num(3.0)); assert_eq!(eval(&Expr::op(Divide, vec![n(1.0), n(0.0)])), None, "division by zero"); assert_eq!(eval(&Expr::op(Multiply, vec![n(1e300), n(1e300)])), None, "overflow"); assert_eq!(eval(&Expr::op(Negate, vec![Expr::var("n")])), num(-3.0)); // Text joins. assert_eq!(eval(&Expr::op(Add, vec![s("Count: "), Expr::var("n"), s(" / "), b(true)])), Some(VariableValue::String("Count: 3 / true".into()))); assert_eq!(eval(&Expr::op(Add, vec![s("x"), Expr::Value(VariableValue::Color(Color::BLACK))])), None); // Comparisons. assert_eq!(eval(&Expr::op(Equals, vec![Expr::var("n"), n(3.0)])), yes); assert_eq!(eval(&Expr::op(Equals, vec![n(0.1 + 0.2), n(0.3)])), yes, "float noise"); assert_eq!(eval(&Expr::op(NotEqual, vec![Expr::var("s"), s("hi")])), no); assert_eq!(eval(&Expr::op(Equals, vec![Expr::var("s"), n(3.0)])), no, "different types differ"); assert_eq!(eval(&Expr::op(Less, vec![Expr::var("n"), n(4.0)])), yes); assert_eq!(eval(&Expr::op(LessOrEqual, vec![n(4.0), n(4.0)])), yes); assert_eq!(eval(&Expr::op(Greater, vec![s("b"), s("a")])), yes, "strings compare"); assert_eq!(eval(&Expr::op(GreaterOrEqual, vec![n(1.0), n(2.0)])), no); assert_eq!(eval(&Expr::op(Greater, vec![n(1.0), s("a")])), None); assert_eq!(eval(&Expr::op(Less, vec![n(1.0)])), None, "two operands"); // Logic. assert_eq!(eval(&Expr::op(And, vec![Expr::var("b"), b(true)])), yes); assert_eq!(eval(&Expr::op(Or, vec![b(false), b(false)])), no); assert_eq!(eval(&Expr::op(Not, vec![Expr::var("b")])), no); assert_eq!(eval(&Expr::op(And, vec![b(true), n(1.0)])), None); assert_eq!(eval(&Expr::op(And, vec![])), None); } #[test] fn variables_resolve_in_the_modes_given() { let dark = BTreeMap::from([("c".to_string(), "dark".to_string())]); let e = Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]); assert_eq!(e.eval(&vars(), &dark), Some(VariableValue::Number(8.0))); assert!(!Expr::var("b").holds(&vars(), &dark)); assert!(Expr::var("b").holds(&vars(), &BTreeMap::new())); // Missing variables and non-boolean conditions don't hold. assert!(!Expr::var("missing").holds(&vars(), &dark)); assert!(!n(1.0).holds(&vars(), &dark)); assert_eq!(e.variables(), ["n"]); } #[test] fn display_reads_like_a_formula() { let v = vars(); let e = Expr::op(ExprOp::Equals, vec![Expr::op(ExprOp::Add, vec![Expr::var("n"), n(1.0)]), n(4.0)]); assert_eq!(e.display(&v), "(n-name + 1) == 4"); assert_eq!(Expr::op(ExprOp::Not, vec![Expr::var("b")]).display(&v), "not b-name"); assert_eq!(Expr::op(ExprOp::Add, vec![s("a"), Expr::var("gone")]).display(&v), "\"a\" + (missing variable)"); } #[test] fn deep_and_hostile_expressions_are_bounded() { let mut e = n(1.0); for _ in 0..(MAX_EXPR_DEPTH + 5) { e = Expr::op(ExprOp::Add, vec![e, n(1.0)]); } assert_eq!(eval(&e), None, "too deep"); assert!(e.display(&vars()).contains('…')); assert!(e.variables().is_empty()); assert_eq!(eval(&n(f64::NAN)), None); let long = "x".repeat(8000); let joined = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]); let Some(VariableValue::String(t)) = eval(&joined) else { panic!() }; assert_eq!(t.chars().count(), 10_000, "capped"); } fn nav(id: u64) -> Action { Action::Navigate { dest: NodeId(id), preserve_scroll: false } } fn cond(blocks: Vec<(Option, Vec)>) -> Action { Action::Conditional { blocks: blocks.into_iter().map(|(condition, actions)| ConditionalBlock { condition, actions }).collect() } } #[test] fn walking_nested_actions() { let mut actions = vec![ nav(1), cond(vec![ (Some(Expr::var("b")), vec![nav(2), cond(vec![(None, vec![nav(3)])])]), (None, vec![Action::SetVariable { variable: "n".into(), value: serde_json::Value::Null, expr: Some(Expr::var("alias")) }]), ]), Action::Back, ]; let dests: Vec = walk(&actions).filter_map(Action::dest).collect(); assert_eq!(dests, [NodeId(1), NodeId(2), NodeId(3)], "depth-first, in order"); assert_eq!(walk(&actions).count(), 7); let mut vars_used: Vec<&str> = actions.iter().flat_map(Action::variables).collect(); vars_used.sort_unstable(); assert_eq!(vars_used, ["alias", "b", "n"]); walk_mut(&mut actions, &mut |a| { if let Action::Navigate { dest, .. } = a { dest.0 += 10; } }); assert_eq!(walk(&actions).filter_map(Action::dest).collect::>(), [NodeId(11), NodeId(12), NodeId(13)]); // Dropping navigations empties the inner conditional (gone) but not the outer one. retain_deep(&mut actions, &mut |a| !matches!(a, Action::Navigate { .. })); assert_eq!(walk(&actions).count(), 3, "{actions:?}"); retain_deep(&mut actions, &mut |a| !matches!(a, Action::SetVariable { .. })); assert_eq!(actions, [Action::Back]); } #[test] fn conditional_choice_and_deep_nesting() { let v = vars(); let blocks = [ ConditionalBlock { condition: Some(Expr::var("missing")), actions: vec![nav(1)] }, ConditionalBlock { condition: Some(Expr::var("b")), actions: vec![nav(2)] }, ConditionalBlock { condition: None, actions: vec![nav(3)] }, ]; assert_eq!(ConditionalBlock::choose(&blocks, &v, &BTreeMap::new()), Some(&[nav(2)][..])); let dark = BTreeMap::from([("c".to_string(), "dark".to_string())]); assert_eq!(ConditionalBlock::choose(&blocks, &v, &dark), Some(&[nav(3)][..])); assert_eq!(ConditionalBlock::choose(&blocks[..1], &v, &dark), None); // Nesting deeper than the limit stops walking (no stack overflow). let mut a = nav(9); for _ in 0..(MAX_ACTION_DEPTH * 4) { a = cond(vec![(None, vec![a])]); } let list = vec![a]; assert_eq!(walk(&list).count(), MAX_ACTION_DEPTH + 1); } #[test] fn serde_round_trip_and_old_files() { let a = vec![ cond(vec![(Some(Expr::op(ExprOp::GreaterOrEqual, vec![Expr::var("n"), n(2.0)])), vec![nav(4)]), (None, vec![])]), Action::SetVariableMode { collection: "c".into(), mode: "dark".into() }, Action::SetVariable { variable: "n".into(), value: serde_json::json!(1), expr: None }, ]; let j = serde_json::to_value(&a).unwrap_or_default(); assert_eq!(j[2].get("expr"), None, "no expression: the field is left out"); let back: Vec = serde_json::from_value(j).unwrap_or_default(); assert_eq!(back, a); // Files written before expressions existed still read. let old: Action = serde_json::from_str(r#"{"type":"setVariable","variable":"n","value":2}"#).unwrap_or(Action::Back); assert_eq!(old, Action::SetVariable { variable: "n".into(), value: serde_json::json!(2), expr: None }); }