From 91cbcef606b938f11738d459c9d7e26dff1d85b8 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Tue, 1 Sep 2026 15:49:16 -0700 Subject: [PATCH] =?UTF-8?q?Retire=20the=20Math=20node=20and=20migrate=20it?= =?UTF-8?q?s=20documents=20to=20Math=20f(x)=20and=20Math=20f(=E2=80=A6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../node_graph/document_node_definitions.rs | 1 - .../document/node_graph/node_properties.rs | 57 ---- .../messages/portfolio/document_migration.rs | 261 +++++++++++++++++- libraries/math-parser/src/lexer.rs | 27 ++ libraries/math-parser/src/lib.rs | 17 ++ node-graph/nodes/math/src/lib.rs | 95 ++----- 6 files changed, 319 insertions(+), 139 deletions(-) diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index ad9d4bc201..70b8672837 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -927,7 +927,6 @@ fn static_node_properties() -> NodeProperties { map.insert("offset_path_properties".to_string(), Box::new(node_properties::offset_path_properties)); map.insert("selective_color_properties".to_string(), Box::new(node_properties::selective_color_properties)); map.insert("exposure_properties".to_string(), Box::new(node_properties::exposure_properties)); - map.insert("math_properties".to_string(), Box::new(node_properties::math_properties)); map.insert("format_number_properties".to_string(), Box::new(node_properties::format_number_properties)); map.insert("string_capitalization_properties".to_string(), Box::new(node_properties::string_capitalization_properties)); map.insert("rectangle_properties".to_string(), Box::new(node_properties::rectangle_properties)); diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 2d298132f9..8a0db9b810 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -3404,63 +3404,6 @@ pub fn offset_path_properties(node_id: NodeId, context: &mut NodePropertiesConte vec![LayoutGroup::row(distance), join, LayoutGroup::row(miter_limit)] } -pub fn math_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { - use graphene_std::math_nodes::math::*; - - let expression = (|| { - let mut widgets = start_widgets(&ParameterWidgetsInfo::new(node_id, ExpressionInput, true, context)); - - let document_node = match get_document_node(node_id, context) { - Ok(document_node) => document_node, - Err(err) => { - log::error!("Could not get document node in offset_path_properties: {err}"); - return Vec::new(); - } - }; - let Some(input) = document_node.input(ExpressionInput) else { - log::warn!("A widget failed to be built because its node's input index is invalid."); - return vec![]; - }; - if let Some(TaggedValue::String(x)) = &input.as_non_exposed_value() { - widgets.extend_from_slice(&[ - Separator::new(SeparatorStyle::Unrelated).widget_instance(), - TextInput::new(x.clone()) - .centered(true) - .on_update(update_value( - |x: &TextInput| { - TaggedValue::String({ - let mut expression = x.value.trim().to_string(); - - if ["+", "-", "*", "/", "^"].iter().any(|&infix| infix == expression) { - expression = format!("A {expression} B"); - } else if expression == "^" { - expression = String::from("A^B"); - } else if expression == "%" || expression == "mod" { - expression = String::from("mod(a, b)"); - } - - expression - }) - }, - node_id, - ExpressionInput, - )) - .on_commit(commit_value) - .widget_instance(), - ]) - } - widgets - })(); - let operand_b = number_widget(ParameterWidgetsInfo::new(node_id, OperandBInput, true, context), NumberInput::default()); - let operand_a_hint = vec![TextLabel::new("(Operand A is the primary input)").widget_instance()]; - - vec![ - LayoutGroup::row(expression).with_tooltip_description(r#"A math expression that may incorporate "A" and/or "B", such as "sqrt(A + B) - B^2"."#), - LayoutGroup::row(operand_b).with_tooltip_description(r#"The value of "B" when calculating the expression."#), - LayoutGroup::row(operand_a_hint).with_tooltip_description(r#""A" is fed by the value from the previous node in the primary data flow, or it is 0 if disconnected."#), - ] -} - pub struct ParameterWidgetsInfo<'a> { document_id: DocumentId, network_interface: &'a NodeNetworkInterface, diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 8980c17a76..435b8d7e28 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -339,10 +339,6 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ "graphene_math_nodes::LogicalOrNode", ], }, - NodeReplacement { - node: graphene_std::math_nodes::math::IDENTIFIER, - aliases: &["graphene_math_nodes::MathNode", "graphene_core::ops::MathNode"], - }, NodeReplacement { node: graphene_std::math_nodes::max::IDENTIFIER, aliases: &["graphene_math_nodes::MaxNode", "graphene_core::ops::MaxNode"], @@ -1264,6 +1260,134 @@ pub fn document_migration_upgrades(document: &mut DocumentMessageHandler, reset_ } } + // The old "Math" node evaluated an expression over "A" and "B". A static expression not reading a wired `B` becomes "Math f(x)" + // with `A` as `x` and a constant `B` inlined. Anything else becomes "Extend" feeding "Math f(…)", which reads the pair as `a` and `b`. + let math_nodes: Vec<(NodeId, Vec, Vec)> = document + .network_interface + .document_network() + .recursive_nodes() + .filter_map(|(node_id, node, path)| { + let DocumentNodeImplementation::ProtoNode(protonode_id) = &node.implementation else { return None }; + let name = protonode_id.as_str().split('<').next().unwrap_or_default(); + let is_old_math = matches!(name, "math_nodes::MathNode" | "graphene_math_nodes::MathNode" | "graphene_core::ops::MathNode"); + (is_old_math && node.inputs.len() >= 3).then(|| (*node_id, path, node.inputs.clone())) + }) + .collect(); + for (node_id, network_path, old_inputs) in &math_nodes { + // Pre-load `outward_wires` so the chain-break check inside `set_input` resolves wires from cache, as in the Transform pass above + let _ = document.network_interface.outward_wires(network_path); + let (operand_a, expression, operand_b) = (&old_inputs[0], &old_inputs[1], &old_inputs[2]); + + // Lex a static expression once, learning both whether it lexes at all and whether it references `B` + let static_expression = match expression.as_value() { + Some(TaggedValue::String(expression)) => Some(expression.clone()), + _ => None, + }; + let mut references_b = false; + let lexable = static_expression + .as_deref() + .and_then(|source| { + math_parser::lexer::rename_identifiers(source, |name| { + references_b |= name.eq_ignore_ascii_case("b"); + None + }) + }) + .is_some(); + + // The rewritten "Math f(x)" expression, or `None` when only the Extend + "Math f(…)" form can preserve the node's meaning + let inline_b_constant = match operand_b.as_value() { + Some(TaggedValue::F64(constant)) => Some(*constant), + Some(TaggedValue::F32(constant)) => Some(*constant as f64), + _ => None, + }; + let fx_expression = match &static_expression { + Some(source) if lexable && !references_b => math_parser::lexer::rename_identifiers(source, |name| name.eq_ignore_ascii_case("a").then(|| "x".to_string())), + Some(source) if lexable && let Some(constant) = inline_b_constant => math_parser::lexer::rename_identifiers(source, |name| { + if name.eq_ignore_ascii_case("a") { + Some("x".to_string()) + } else if name.eq_ignore_ascii_case("b") { + Some(format!("({constant:?})")) + } else { + None + } + }), + _ => None, + }; + + if let Some(fx_expression) = fx_expression { + // "Math f(x)": forward the old `A` input and the rewritten expression; the old `B` input is dropped + let Some(definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::math_fx::IDENTIFIER)) else { + continue; + }; + let mut node_template = definition.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + if document.network_interface.replace_inputs(node_id, network_path, &mut node_template).is_none() { + continue; + } + + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 0), operand_a.clone(), network_path); + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 1), NodeInput::value(TaggedValue::String(fx_expression), false), network_path); + } else { + // "Extend" joins the two operands into the list "Math f(…)" reads; an unwired constant operand becomes a one-item list value + let as_list_input = |input: &NodeInput| match input.as_value() { + Some(TaggedValue::F64(value)) => NodeInput::value(TaggedValue::F64Array(vec![*value]), input.is_exposed()), + Some(TaggedValue::F32(value)) => NodeInput::value(TaggedValue::F64Array(vec![*value as f64]), input.is_exposed()), + _ => input.clone(), + }; + let Some(extend_definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::graphic::extend::IDENTIFIER)) else { + continue; + }; + let extend_template = extend_definition.default_node_template(); + + let Some(definition) = resolve_document_node_type(&DefinitionIdentifier::ProtoNode(graphene_std::math_nodes::math_f::IDENTIFIER)) else { + continue; + }; + let mut node_template = definition.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + if document.network_interface.replace_inputs(node_id, network_path, &mut node_template).is_none() { + continue; + } + + // Wire inputs cannot ride along on an inserted template, so the Extend node's operands are set after insertion + let extend_node_id = NodeId::new(); + let math_position = document.network_interface.position_from_downstream_node(node_id, network_path); + document.network_interface.insert_node(extend_node_id, extend_template, network_path); + if let Some(math_position) = math_position { + document + .network_interface + .shift_absolute_node_position(&extend_node_id, math_position + IVec2::new(-7, 1), network_path); + } + document + .network_interface + .set_input(&InputConnector::node_at_index(extend_node_id, 0), as_list_input(operand_a), network_path); + document + .network_interface + .set_input(&InputConnector::node_at_index(extend_node_id, 1), as_list_input(operand_b), network_path); + + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 0), NodeInput::node(extend_node_id, 0), network_path); + + // A static expression is rewritten to the exact-lowercase positional spellings, escaping the old node's + // constants `e` and `i` behind the `\` prefix that always reaches the builtin; a wired or unlexable one forwards untouched + let positional_expression = match (&static_expression, lexable) { + (Some(source), true) => math_parser::lexer::rename_identifiers(source, |name| { + if name.eq_ignore_ascii_case("a") || name.eq_ignore_ascii_case("b") { + return Some(name.to_ascii_lowercase()); + } + matches!(name, "e" | "i").then(|| format!("\\{name}")) + }) + .map(|rewritten| NodeInput::value(TaggedValue::String(rewritten), false)), + _ => None, + }; + document + .network_interface + .set_input(&InputConnector::node_at_index(*node_id, 1), positional_expression.unwrap_or_else(|| expression.clone()), network_path); + } + } + // Record which old text nodes are chain-positioned now, before `migrate_node`'s staged input-count migrations run, since those set // the upstream chain to absolute; the split below re-chains exactly the nodes that were originally part of a layer chain. let text_nodes_in_chain: std::collections::HashSet = document @@ -3140,6 +3264,135 @@ mod tests { } } + // The old Math node's expression decides its replacement: a static string that never reads `B` becomes "Math f(x)", + // while a wired `B` or an uninspectable expression becomes "Extend" feeding "Math f(…)" + #[test] + fn old_math_nodes_split_into_the_expression_node_pair() { + use crate::messages::portfolio::document::utility_types::network_interface::NodeTemplate; + + let math_id = NodeId(1); + let (source_a_id, source_b_id, source_expression_id) = (NodeId(2), NodeId(3), NodeId(4)); + + // Builds a document holding one old Math node with the given inputs, plus three source nodes to wire from + let build_document = |identifier: &'static str, inputs: Vec| { + let mut document = DocumentMessageHandler::default(); + for source_id in [source_a_id, source_b_id, source_expression_id] { + document.network_interface.insert_node( + source_id, + NodeTemplate { + inputs: vec![NodeInput::value(TaggedValue::None, false)], + ..Default::default() + }, + &[], + ); + } + document.network_interface.insert_node( + math_id, + NodeTemplate { + implementation: NodeTemplateImplementation::ProtoNode(ProtoNodeIdentifier::new(identifier)), + inputs: vec![NodeInput::value(TaggedValue::None, false); 3], + ..Default::default() + }, + &[], + ); + for (index, input) in inputs.into_iter().enumerate() { + document.network_interface.set_input(&InputConnector::node_at_index(math_id, index), input, &[]); + } + document + }; + + let implementation_of = |document: &DocumentMessageHandler, node_id: NodeId| match &document.network_interface.document_network().nodes[&node_id].implementation { + DocumentNodeImplementation::ProtoNode(identifier) => identifier.clone(), + other => panic!("expected a proto node implementation, got {other:?}"), + }; + let find_extend = |document: &DocumentMessageHandler| { + document + .network_interface + .document_network() + .nodes + .iter() + .find(|(_, node)| matches!(&node.implementation, DocumentNodeImplementation::ProtoNode(identifier) if *identifier == graphene_std::graphic::extend::IDENTIFIER)) + .map(|(extend_id, _)| *extend_id) + .expect("an Extend node should be spliced in") + }; + + // A static expression that never reads `B` becomes "Math f(x)" with `A` renamed to `x` and the `B` input dropped + { + let inputs = vec![ + NodeInput::node(source_a_id, 0), + NodeInput::value(TaggedValue::String("2 - 0.2A".into()), false), + NodeInput::value(TaggedValue::F64(0.), false), + ]; + let mut document = build_document("math_nodes::MathNode", inputs); + document_migration_upgrades(&mut document, false); + + assert_eq!(implementation_of(&document, math_id), graphene_std::math_nodes::math_fx::IDENTIFIER); + let node = &document.network_interface.document_network().nodes[&math_id]; + assert_eq!(node.inputs.len(), 2, "the old `B` input should be dropped"); + assert_eq!(node.inputs.first(), Some(&NodeInput::node(source_a_id, 0))); + assert_eq!(node.inputs.get(1).and_then(|input| input.as_value()).cloned(), Some(TaggedValue::String("2 - 0.2x".into()))); + } + + // A constant `B` inlines into the rewritten string as a parenthesized literal; an alias spelling of the old identifier also matches + { + let inputs = vec![ + NodeInput::node(source_a_id, 0), + NodeInput::value(TaggedValue::String("sqrt(A + B) - B^2".into()), false), + NodeInput::value(TaggedValue::F64(3.), false), + ]; + let mut document = build_document("graphene_core::ops::MathNode", inputs); + document_migration_upgrades(&mut document, false); + + assert_eq!(implementation_of(&document, math_id), graphene_std::math_nodes::math_fx::IDENTIFIER); + let node = &document.network_interface.document_network().nodes[&math_id]; + assert_eq!( + node.inputs.get(1).and_then(|input| input.as_value()).cloned(), + Some(TaggedValue::String("sqrt(x + (3.0)) - (3.0)^2".into())) + ); + } + + // A wired `B` keeps both wires by joining them through a spliced Extend node, with the expression rewritten to the + // lowercase positional spellings and the old constants escaped behind the `\` prefix that still reaches them + { + let inputs = vec![ + NodeInput::node(source_a_id, 0), + NodeInput::value(TaggedValue::String("A * B + e".into()), false), + NodeInput::node(source_b_id, 0), + ]; + let mut document = build_document("math_nodes::MathNode", inputs); + document_migration_upgrades(&mut document, false); + + assert_eq!(implementation_of(&document, math_id), graphene_std::math_nodes::math_f::IDENTIFIER); + let extend_id = find_extend(&document); + let network = document.network_interface.document_network(); + assert_eq!(network.nodes[&math_id].inputs.first(), Some(&NodeInput::node(extend_id, 0))); + assert_eq!( + network.nodes[&math_id].inputs.get(1).and_then(|input| input.as_value()).cloned(), + Some(TaggedValue::String("a * b + \\e".into())) + ); + assert_eq!(network.nodes[&extend_id].inputs.first(), Some(&NodeInput::node(source_a_id, 0))); + assert_eq!(network.nodes[&extend_id].inputs.get(1), Some(&NodeInput::node(source_b_id, 0))); + } + + // A wired expression cannot be inspected, so it splices too, wrapping unwired constant operands as one-item list values + { + let inputs = vec![ + NodeInput::value(TaggedValue::F64(2.), true), + NodeInput::node(source_expression_id, 0), + NodeInput::value(TaggedValue::F64(5.), false), + ]; + let mut document = build_document("math_nodes::MathNode", inputs); + document_migration_upgrades(&mut document, false); + + assert_eq!(implementation_of(&document, math_id), graphene_std::math_nodes::math_f::IDENTIFIER); + let extend_id = find_extend(&document); + let network = document.network_interface.document_network(); + assert_eq!(network.nodes[&math_id].inputs.get(1), Some(&NodeInput::node(source_expression_id, 0))); + assert_eq!(network.nodes[&extend_id].inputs.first(), Some(&NodeInput::value(TaggedValue::F64Array(vec![2.]), true))); + assert_eq!(network.nodes[&extend_id].inputs.get(1), Some(&NodeInput::value(TaggedValue::F64Array(vec![5.]), false))); + } + } + #[test] fn every_legacy_image_shape_stores_its_resource_as_secondary_input() { use graphene_std::raster::Image; diff --git a/libraries/math-parser/src/lexer.rs b/libraries/math-parser/src/lexer.rs index b3736f2f25..f4aead8e32 100644 --- a/libraries/math-parser/src/lexer.rs +++ b/libraries/math-parser/src/lexer.rs @@ -483,6 +483,33 @@ impl<'a> Iterator for Lexer<'a> { } } +/// Replaces each whole identifier for which `rename` returns a new spelling, so `b` never matches inside `logb`, and leaves all other source text untouched. +/// Returns `None` if the source fails to lex. +pub fn rename_identifiers(source: &str, mut rename: impl FnMut(&str) -> Option) -> Option { + let mut lexer = Lexer::new(source); + let mut result = String::with_capacity(source.len()); + let mut copied_up_to = 0; + + while let Some(token) = lexer.next_token() { + match token { + Token::Error => return None, + Token::Ident(name) => { + if let Some(new_name) = rename(name) { + // An `Ident` always borrows directly from the source, so its span is recoverable by pointer offset + let start = name.as_ptr() as usize - source.as_ptr() as usize; + result.push_str(&source[copied_up_to..start]); + result.push_str(&new_name); + copied_up_to = start + name.len(); + } + } + _ => {} + } + } + + result.push_str(&source[copied_up_to..]); + Some(result) +} + impl<'src> Input<'src> for Lexer<'src> { type Token = Token<'src>; type Span = Span; diff --git a/libraries/math-parser/src/lib.rs b/libraries/math-parser/src/lib.rs index c555da8792..ec118c7769 100644 --- a/libraries/math-parser/src/lib.rs +++ b/libraries/math-parser/src/lib.rs @@ -346,6 +346,23 @@ mod tests { assert_eq!(eval("\\sin(pi / 2)"), Some(1.)); } + #[test] + fn rename_identifiers_is_token_exact() { + let a_to_x = |name: &str| name.eq_ignore_ascii_case("a").then(|| "x".to_string()); + + // Only whole identifiers rename, so function names and other tokens that merely contain the letter stay untouched + assert_eq!(crate::lexer::rename_identifiers("2 - 0.2A", a_to_x).as_deref(), Some("2 - 0.2x")); + assert_eq!(crate::lexer::rename_identifiers("atan(a) + tau", a_to_x).as_deref(), Some("atan(x) + tau")); + assert_eq!( + crate::lexer::rename_identifiers("sqrt(A + B) - B^2", |name| name.eq_ignore_ascii_case("b").then(|| "(3)".to_string())).as_deref(), + Some("sqrt(A + (3)) - (3)^2") + ); + assert_eq!(crate::lexer::rename_identifiers("logb + b", |name| (name == "b").then(|| "c".to_string())).as_deref(), Some("logb + c")); + + // A string that fails to lex reports `None` rather than renaming unreliably + assert_eq!(crate::lexer::rename_identifiers("a + \u{200b}b", a_to_x), None); + } + #[test] fn dot_led_function_suffixes_fail_to_parse() { // A `.`-led base suffix is an error (the supported spelling is `log0.5`) diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index 34a1b770ba..3c94dde84f 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -12,80 +12,11 @@ use math_parser::ast; use math_parser::context::{EvalContext, NothingMap, ValueProvider}; use math_parser::lexer::Constant; use math_parser::reducer::classify_reducer; -use math_parser::value::{Number, Value}; +use math_parser::value::Value; use rand::{Rng, SeedableRng}; use std::ops::{Add, Mul, Rem, Sub}; use vector_types::Gradient; -/// The struct that stores the context for the maths parser. -/// This is currently just limited to supplying `a` and `b` until we add better node graph support and UI for variadic inputs. -struct MathNodeContext { - a: f64, - b: f64, -} - -impl ValueProvider for MathNodeContext { - fn get_value(&self, name: &str) -> Option { - if name.eq_ignore_ascii_case("a") { - Some(Value::from_f64(self.a)) - } else if name.eq_ignore_ascii_case("b") { - Some(Value::from_f64(self.b)) - } else { - None - } - } -} - -/// Calculates a mathematical expression with input values "A" and "B". -#[node_macro::node(category("Math: Arithmetic"), properties("math_properties"))] -fn math( - _: impl Ctx, - /// The value of "A" when calculating the expression. - #[implementations(f64, f32)] - operand_a: Item, - /// A math expression that may incorporate "A" and/or "B", such as `sqrt(A + B) - B^2`. - #[default("A + B")] - expression: Item, - /// The value of "B" when calculating the expression. - #[implementations(f64, f32)] - #[default(1.)] - operand_b: Item, -) -> Item { - let (operand_a, attributes) = operand_a.into_parts(); - let (expression, operand_b) = (expression.element(), *operand_b.element()); - - let node = match ast::Node::try_parse_from_str(expression) { - Ok(expr) => expr, - Err(e) => { - warn!("Invalid expression: `{expression}`\n{e}"); - return Item::from_parts(T::from(0.).unwrap(), attributes); - } - }; - let context = EvalContext::new( - MathNodeContext { - a: operand_a.to_f64().unwrap(), - b: operand_b.to_f64().unwrap(), - }, - NothingMap, - ); - - let value = match node.eval(&context) { - Ok(value) => value, - Err(e) => { - warn!("Expression evaluation error: {e:?}"); - return Item::from_parts(T::from(0.).unwrap(), attributes); - } - }; - - let Value::Number(num) = value; - let result = match num { - Number::Real(val) => T::from(val).unwrap(), - Number::Complex(c) => T::from(c.re).unwrap(), - }; - - Item::from_parts(result, attributes) -} - /// Parses and evaluates a math expression with the given variable bindings, logging and returning `None` on failure. fn evaluate_expression(expression: &str, provider: impl ValueProvider) -> Option { let node = match ast::Node::try_parse_from_str(expression) { @@ -1988,19 +1919,25 @@ mod test { #[test] fn test_basic_expression() { - let result = math((), Item::new_from_element(0.), Item::new_from_element("2 + 2".to_string()), Item::new_from_element(0.)); + let result = math_fx((), Item::new_from_element(0.), Item::new_from_element("2 + 2".to_string())); assert_eq!(result.into_element(), 4.); } #[test] fn test_complex_expression() { - let result = math((), Item::new_from_element(0.), Item::new_from_element("(5 * 3) + (10 / 2)".to_string()), Item::new_from_element(0.)); + let result = math_fx((), Item::new_from_element(0.), Item::new_from_element("(5 * 3) + (10 / 2)".to_string())); assert_eq!(result.into_element(), 20.); } #[test] - fn test_default_expression() { - let result = math((), Item::new_from_element(0.), Item::new_from_element("0".to_string()), Item::new_from_element(0.)); + fn test_variable_binding() { + let result = math_fx((), Item::new_from_element(7.), Item::new_from_element("x * 2".to_string())); + assert_eq!(result.into_element(), 14.); + } + + #[test] + fn test_invalid_expression() { + let result = math_fx((), Item::new_from_element(0.), Item::new_from_element("invalid".to_string())); assert_eq!(result.into_element(), 0.); } @@ -2033,9 +1970,13 @@ mod test { } #[test] - fn test_invalid_expression() { - let result = math((), Item::new_from_element(0.), Item::new_from_element("invalid".to_string()), Item::new_from_element(0.)); - assert_eq!(result.into_element(), 0.); + fn test_positional_and_reducer_expressions() { + let values = || [4., 1., 7.].into_iter().map(Item::new_from_element).collect::>(); + + // A full expression reads the items positionally as `a`, `b`, `c`, while a lone token applies across all of them + assert_eq!(math_f((), values(), Item::new_from_element("a - b + c".to_string())).into_element(), 10.); + assert_eq!(math_f((), values(), Item::new_from_element("min".to_string())).into_element(), 1.); + assert_eq!(math_f((), values(), Item::new_from_element("+".to_string())).into_element(), 12.); } #[test]