• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kaidokert / xacro / 21240864284

22 Jan 2026 08:09AM UTC coverage: 91.159%. First build
21240864284

Pull #94

github

web-flow
Merge 4ca9c20ca into 5119823de
Pull Request #94: Reorganize yaml tests into separate module

117 of 120 new or added lines in 1 file covered. (97.5%)

5743 of 6300 relevant lines covered (91.16%)

272.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.64
/src/eval/interpreter/core.rs
1
use super::init::{format_value_python_style, init_interpreter, EvalError};
2
use super::math::preprocess_math_functions;
3
use super::parsing::{escape_python_string, eval_literal, remove_quotes};
4
use super::yaml_utils::preprocess_load_yaml;
5
use crate::eval::lexer::{Lexer, TokenType};
6
use log;
7
use pyisheval::{EvalError as PyEvalError, Interpreter, Value};
8
use std::collections::HashMap;
9

10
/// Build a pyisheval context HashMap from properties
11
///
12
/// Converts string properties to pyisheval Values, parsing numbers when possible.
13
/// For lambda expressions, evaluates them to callable lambda values using the
14
/// provided interpreter. This ensures lambdas capture the correct environment.
15
///
16
/// # Arguments
17
/// * `properties` - Property name-value pairs to convert to pyisheval Values
18
/// * `interp` - The interpreter to use for evaluating lambda expressions
19
///
20
/// # Errors
21
/// Returns `EvalError` if a lambda expression fails to evaluate.
22
pub(crate) fn build_pyisheval_context(
841✔
23
    properties: &HashMap<String, String>,
841✔
24
    interp: &mut Interpreter,
841✔
25
) -> Result<HashMap<String, Value>, EvalError> {
841✔
26
    // First pass: Load all constants and non-lambda properties into the interpreter
27
    // This ensures that lambda expressions can reference them during evaluation
28
    // Note: We skip inf/nan/NaN as they can't be created via arithmetic in pyisheval
29
    // (10**400 creates inf but 0.0/0.0 fails with DivisionByZero)
30

31
    // WORKAROUND: Add Python built-in constant None as numeric 0
32
    //
33
    // This is needed for load_yaml() which returns the string "None" for null YAML values.
34
    // We inject it into the interpreter environment so lambda expressions can reference it.
35
    //
36
    // LIMITATION: This is a bandaid that violates Python semantics:
37
    // - In Python: None + 5 raises TypeError
38
    // - In our implementation: None + 5 = 5 (because None=0)
39
    //
40
    // This workaround is necessary because pyisheval doesn't support None as a proper type.
41
    // Proper fix requires adding None type support to pyisheval itself, which would allow:
42
    // - Type checking: None + 5 -> TypeError
43
    // - Boolean context: None -> False
44
    // - Identity checks: x is None
45
    //
46
    // Until pyisheval adds None type support, we use this numeric approximation.
47
    // It handles the common cases (mostly boolean checks) but masks real type errors.
48
    interp.eval("None = 0").map_err(|e| EvalError::PyishEval {
841✔
49
        expr: "None = 0".to_string(),
×
50
        source: e,
×
51
    })?;
×
52

53
    for (name, value) in properties.iter() {
841✔
54
        let trimmed = value.trim();
685✔
55
        if !trimmed.starts_with("lambda ") {
685✔
56
            // Load dict/list/tuple literals into interpreter so lambdas can reference them
57
            // They'll be properly evaluated as Python expressions in the second pass
58
            if trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with('(') {
676✔
59
                // Try to load as Python literal into interpreter environment for lambda closure
60
                if let Err(e) = interp.eval(&format!("{} = {}", name, trimmed)) {
45✔
61
                    log::warn!(
1✔
62
                        "Could not load property '{}' with value '{}' into interpreter as Python literal: {}. \
×
63
                         Loading as string fallback to prevent 'Undefined variable' in lambdas.",
×
64
                        name, value, e
65
                    );
66
                    // Fallback: load as string literal so the variable is at least defined
67
                    // This prevents "Undefined variable" errors in lambdas that reference it
68
                    let escaped = escape_python_string(value);
1✔
69
                    let _ = interp.eval(&format!("{} = '{}'", name, escaped));
1✔
70
                }
44✔
71
                continue;
45✔
72
            }
631✔
73

74
            // Apply Python xacro's type coercion logic
75
            match eval_literal(value) {
631✔
76
                Value::Number(num) => {
488✔
77
                    // Special handling for inf/nan so lambdas can reference them
78
                    if num.is_infinite() {
488✔
79
                        // Use 10**400 to create infinity (pyisheval can't parse "inf" literal)
80
                        let sign = if num.is_sign_negative() { "-" } else { "" };
9✔
81
                        let expr = format!("{} = {}10 ** 400", name, sign);
9✔
82
                        interp
9✔
83
                            .eval(&expr)
9✔
84
                            .map_err(|e| EvalError::PyishEval { expr, source: e })?;
9✔
85
                        continue;
9✔
86
                    }
479✔
87
                    if num.is_nan() {
479✔
88
                        // LIMITATION: Cannot create NaN in pyisheval (0.0/0.0 triggers DivisionByZero)
89
                        // Lambdas that reference NaN properties will fail with "undefined variable"
90
                        log::warn!(
4✔
91
                            "Property '{}' has NaN value, which cannot be loaded into interpreter. \
×
92
                             Lambda expressions referencing this property will fail.",
×
93
                            name
94
                        );
95
                        continue;
4✔
96
                    }
475✔
97
                    // Numeric property (including boolean True=1.0, False=0.0): load as number
98
                    interp.eval(&format!("{} = {}", name, num)).map_err(|e| {
475✔
99
                        EvalError::PyishEval {
×
100
                            expr: format!("{} = {}", name, num),
×
101
                            source: e,
×
102
                        }
×
103
                    })?;
×
104
                }
105
                Value::StringLit(s) if !s.is_empty() => {
143✔
106
                    // String property: load as quoted string literal
107
                    // Skip empty strings as pyisheval can't parse ''
108
                    let escaped_value = escape_python_string(&s);
141✔
109
                    interp
141✔
110
                        .eval(&format!("{} = '{}'", name, escaped_value))
141✔
111
                        .map_err(|e| EvalError::PyishEval {
141✔
112
                            expr: format!("{} = '{}'", name, escaped_value),
×
113
                            source: e,
×
114
                        })?;
×
115
                }
116
                Value::StringLit(_) => {
2✔
117
                    // Empty string - skip in first pass (pyisheval can't handle '')
2✔
118
                    // Will be stored as Value::StringLit("") in the second pass
2✔
119
                }
2✔
120
                _ => {
121
                    // eval_literal only returns Value::Number or Value::StringLit
122
                    // Any other variant indicates a logic error
123
                    unreachable!(
×
124
                        "eval_literal returned unexpected value type for property '{}': {:?}",
125
                        name, value
126
                    );
127
                }
128
            }
129
        }
9✔
130
    }
131

132
    // Second pass: Build the actual context, evaluating lambdas
133
    let mut context: HashMap<String, Value> = properties
841✔
134
        .iter()
841✔
135
        .map(|(name, value)| -> Result<(String, Value), EvalError> {
841✔
136
            // Check if it's a lambda expression (check before eval_literal to avoid treating it as string)
137
            let trimmed = value.trim();
685✔
138
            if trimmed.starts_with("lambda ") {
685✔
139
                // Evaluate and assign the lambda expression to the variable name
140
                // The interpreter now has all constants and properties loaded from first pass
141
                let assignment = format!("{} = {}", name, trimmed);
9✔
142
                interp.eval(&assignment).map_err(|e| EvalError::PyishEval {
9✔
143
                    expr: assignment.clone(),
×
144
                    source: e,
×
145
                })?;
×
146

147
                // Retrieve the lambda value to store in context
148
                let lambda_value = interp.eval(name).map_err(|e| EvalError::PyishEval {
9✔
149
                    expr: name.clone(),
×
150
                    source: e,
×
151
                })?;
×
152

153
                return Ok((name.clone(), lambda_value));
9✔
154
            }
676✔
155

156
            // Try to evaluate as Python expression (lists, dicts, etc.)
157
            // This allows properties like "[1, 2, 3]" to be stored as actual lists,
158
            // so that indexing like arr[0] works correctly
159
            //
160
            // Optimization: Only attempt evaluation if value looks like a Python literal
161
            // to avoid unnecessary parse errors on common string values
162
            // (reuse trimmed from lambda check above)
163
            if trimmed.starts_with('[') || trimmed.starts_with('{') || trimmed.starts_with('(') {
676✔
164
                match interp.eval(trimmed) {
45✔
165
                    Ok(evaluated_value) => {
44✔
166
                        Ok((name.clone(), evaluated_value))
44✔
167
                    }
168
                    Err(e) => {
1✔
169
                        // Distinguish expected parse failures from unexpected runtime errors
170
                        match &e {
1✔
171
                            // Unexpected: runtime/type errors may indicate issues with property definitions
172
                            PyEvalError::TypeError
173
                            | PyEvalError::DivisionByZero
174
                            | PyEvalError::LambdaCallError
175
                            | PyEvalError::ArgError(_)
176
                            | PyEvalError::DictKeyError => {
177
                                log::warn!(
×
178
                                    "Property '{}' with value '{}' failed evaluation: {}. Treating as string literal.",
×
179
                                    name, value, e
180
                                );
181
                            }
182
                            // Expected: parse errors or undefined variables
183
                            PyEvalError::ParseError(_) | PyEvalError::UndefinedVar(_) => {}
1✔
184
                        }
185
                        // In all error cases, fall back to treating value as string literal
186
                        Ok((name.clone(), Value::StringLit(value.clone())))
1✔
187
                    }
188
                }
189
            } else {
190
                // Apply Python xacro's type coercion for literals (int/float/boolean)
191
                let literal_value = eval_literal(value);
631✔
192
                Ok((name.clone(), literal_value))
631✔
193
            }
194
        })
685✔
195
        .collect::<Result<HashMap<_, _>, _>>()?;
841✔
196

197
    // Manually inject inf, nan, and None constants (Strategy 3: bypass parsing)
198
    // Python xacro provides these via float('inf') and math.inf, but they're also
199
    // used as bare identifiers in expressions. Pyisheval cannot parse these as
200
    // literals, so we inject them directly into the context.
201
    //
202
    // WHY INJECT TWICE? (both into interpreter above AND into context map here):
203
    // 1. Interpreter injection (line ~928): Makes constants available to lambda expressions
204
    //    e.g., "lambda x: x if x != None else 0" needs None in interpreter environment
205
    // 2. Context map injection (here): Makes constants available to eval_with_context()
206
    //    e.g., "${None + 5}" needs None in the context passed to eval_with_context()
207
    //
208
    // Both injections are necessary for complete coverage of all expression types.
209
    //
210
    // WORKAROUND for None: Modeled as 0.0 (see comment above for limitations).
211
    // This violates Python semantics (None + 5 should be TypeError, not 5) but is
212
    // necessary until pyisheval adds proper None type support.
213
    context.insert("inf".to_string(), Value::Number(f64::INFINITY));
841✔
214
    context.insert("nan".to_string(), Value::Number(f64::NAN));
841✔
215
    context.insert("None".to_string(), Value::Number(0.0));
841✔
216

217
    Ok(context)
841✔
218
}
841✔
219

220
/// Evaluate a single expression string, handling Xacro-specific special cases
221
///
222
/// This centralizes handling of special functions like xacro.print_location()
223
/// that don't fit into the generic pyisheval evaluation model.
224
///
225
/// # Arguments
226
/// * `interp` - The pyisheval interpreter to use
227
/// * `expr` - The expression string to evaluate
228
/// * `context` - The variable context for evaluation
229
///
230
/// # Returns
231
/// * `Ok(Some(value))` - Normal expression evaluated successfully
232
/// * `Ok(None)` - Special case that produces no output (e.g., xacro.print_location())
233
/// * `Err(e)` - Evaluation error
234
///
235
/// # Special Cases
236
/// * `xacro.print_location()` - Debug function that prints stack trace to stderr in Python.
237
///   We stub it out by returning None (no output). This is handled as a special case because:
238
///   1. pyisheval doesn't support object.method syntax
239
///   2. This is a debug-only function with no production use
240
///   3. We're not implementing the full debug functionality
241
pub(crate) fn evaluate_expression_impl(
700✔
242
    interp: &mut Interpreter,
700✔
243
    expr: &str,
700✔
244
    context: &HashMap<String, Value>,
700✔
245
    #[cfg(feature = "yaml")] yaml_tag_handler_registry: Option<
700✔
246
        &crate::eval::yaml_tag_handler::YamlTagHandlerRegistry,
700✔
247
    >,
700✔
248
) -> Result<Option<Value>, pyisheval::EvalError> {
700✔
249
    let trimmed_expr = expr.trim();
700✔
250
    if trimmed_expr == "xacro.print_location()" {
700✔
251
        // Special case: stub debug function returns no output
252
        return Ok(None);
5✔
253
    }
695✔
254

255
    // Preprocess math functions (cos, sin, tan, etc.) before evaluation
256
    // This converts native math calls into computed values since pyisheval
257
    // doesn't support calling native Rust functions
258
    // Pass context to allow property references in function arguments
259
    let preprocessed = preprocess_math_functions(expr, interp, context).map_err(|e| match e {
695✔
260
        EvalError::PyishEval { source, .. } => source,
8✔
261
        _ => pyisheval::EvalError::ParseError(e.to_string()),
×
262
    })?;
8✔
263

264
    // Preprocess load_yaml() calls.
265
    // If the 'yaml' feature is enabled, this loads YAML files and replaces load_yaml()
266
    // with dict literals. Otherwise, it returns an error if load_yaml() is used.
267
    let preprocessed = preprocess_load_yaml(
687✔
268
        &preprocessed,
687✔
269
        interp,
687✔
270
        context,
687✔
271
        #[cfg(feature = "yaml")]
272
        yaml_tag_handler_registry,
687✔
273
    )
274
    .map_err(|e| match e {
687✔
275
        EvalError::PyishEval { source, .. } => source,
2✔
276
        _ => pyisheval::EvalError::ParseError(e.to_string()),
×
277
    })?;
2✔
278

279
    interp.eval_with_context(&preprocessed, context).map(Some)
685✔
280
}
700✔
281

282
/// Internal implementation of text evaluation with optional YAML tag handler registry
283
fn eval_text_with_interpreter_impl(
165✔
284
    text: &str,
165✔
285
    properties: &HashMap<String, String>,
165✔
286
    interp: &mut Interpreter,
165✔
287
    #[cfg(feature = "yaml")] yaml_tag_handler_registry: Option<
165✔
288
        &crate::eval::yaml_tag_handler::YamlTagHandlerRegistry,
165✔
289
    >,
165✔
290
) -> Result<String, EvalError> {
165✔
291
    // Build context for pyisheval (may fail if lambdas have errors)
292
    // This loads properties into the interpreter and evaluates lambda expressions
293
    let context = build_pyisheval_context(properties, interp)?;
165✔
294

295
    // Tokenize the input text
296
    let lexer = Lexer::new(text);
165✔
297
    let mut result = Vec::new();
165✔
298

299
    // Process each token
300
    for (token_type, token_value) in lexer {
341✔
301
        match token_type {
180✔
302
            TokenType::Text => {
64✔
303
                // Plain text, keep as-is
64✔
304
                result.push(token_value);
64✔
305
            }
64✔
306
            TokenType::Expr => {
307
                // Evaluate expression using centralized helper
308
                match evaluate_expression_impl(
113✔
309
                    interp,
113✔
310
                    &token_value,
113✔
311
                    &context,
113✔
312
                    #[cfg(feature = "yaml")]
313
                    yaml_tag_handler_registry,
113✔
314
                ) {
315
                    Ok(Some(value)) => {
106✔
316
                        #[cfg(feature = "compat")]
106✔
317
                        let value_str = format_value_python_style(&value, false);
106✔
318
                        #[cfg(not(feature = "compat"))]
106✔
319
                        let value_str = format_value_python_style(&value, true);
106✔
320
                        result.push(remove_quotes(&value_str).to_string());
106✔
321
                    }
106✔
322
                    Ok(None) => {
323
                        // Special case returned no output (e.g., xacro.print_location())
324
                        continue;
3✔
325
                    }
326
                    Err(e) => {
4✔
327
                        return Err(EvalError::PyishEval {
4✔
328
                            expr: token_value.clone(),
4✔
329
                            source: e,
4✔
330
                        });
4✔
331
                    }
332
                }
333
            }
334
            TokenType::Extension => {
×
335
                // $(extension) - handle later
×
336
                // For now, just keep the original text
×
337
                result.push(format!("$({})", token_value));
×
338
            }
×
339
            TokenType::DollarDollarBrace => {
3✔
340
                // $$ escape - output $ followed by the delimiter ({ or ()
3✔
341
                result.push(format!("${}", token_value));
3✔
342
            }
3✔
343
        }
344
    }
345

346
    Ok(result.join(""))
161✔
347
}
165✔
348

349
/// Evaluate text containing ${...} expressions using a provided interpreter
350
///
351
/// This version allows reusing an Interpreter instance for better performance
352
/// when processing multiple text blocks with the same properties context.
353
///
354
/// Takes a mutable reference to ensure lambdas are created in the same
355
/// interpreter context where they'll be evaluated.
356
pub(crate) fn eval_text_with_interpreter(
165✔
357
    text: &str,
165✔
358
    properties: &HashMap<String, String>,
165✔
359
    interp: &mut Interpreter,
165✔
360
) -> Result<String, EvalError> {
165✔
361
    eval_text_with_interpreter_impl(
165✔
362
        text,
165✔
363
        properties,
165✔
364
        interp,
165✔
365
        #[cfg(feature = "yaml")]
366
        None,
165✔
367
    )
368
}
165✔
369

370
/// Apply Python xacro's STRICT string truthiness rules
371
///
372
/// Accepts: "true", "True", "false", "False", or parseable integers
373
/// Rejects: Everything else (including "nonsense", empty string, floats as strings)
374
fn apply_string_truthiness(
51✔
375
    s: &str,
51✔
376
    original: &str,
51✔
377
) -> Result<bool, EvalError> {
51✔
378
    let trimmed = s.trim();
51✔
379

380
    // Exact string matches for boolean literals
381
    if trimmed == "true" || trimmed == "True" {
51✔
382
        return Ok(true);
17✔
383
    }
34✔
384
    if trimmed == "false" || trimmed == "False" {
34✔
385
        return Ok(false);
12✔
386
    }
22✔
387

388
    // Try integer conversion (Python's bool(int(value)))
389
    if let Ok(i) = trimmed.parse::<i64>() {
22✔
390
        return Ok(i != 0);
15✔
391
    }
7✔
392

393
    // Try float conversion (for values like "1.0")
394
    if let Ok(f) = trimmed.parse::<f64>() {
7✔
395
        return Ok(f != 0.0);
×
396
    }
7✔
397

398
    // Everything else is an error (STRICT mode)
399
    Err(EvalError::InvalidBoolean {
7✔
400
        condition: original.to_string(),
7✔
401
        evaluated: s.to_string(),
7✔
402
    })
7✔
403
}
51✔
404

405
/// Evaluate expression as boolean following Python xacro's STRICT rules
406
///
407
/// Python xacro's get_boolean_value() logic (ref/xacro/src/xacro/__init__.py:856):
408
/// - Accepts: "true", "True", "false", "False"
409
/// - Accepts: Any string convertible to int: "1", "0", "42", "-5"
410
/// - REJECTS: "nonsense", empty string, anything else → Error
411
///
412
/// Important: This preserves type information from pyisheval.
413
/// ${3*0.1} evaluates to float 0.3 (truthy), NOT string "0.3" (would error)
414
///
415
/// Examples:
416
///   eval_boolean("true", &props) → Ok(true)
417
///   eval_boolean("${3*0.1}", &props) → Ok(true)  // Float 0.3 != 0.0
418
///   eval_boolean("${0}", &props) → Ok(false)     // Integer 0
419
///   eval_boolean("nonsense", &props) → Err(InvalidBoolean)
420
pub(crate) fn eval_boolean(
164✔
421
    text: &str,
164✔
422
    properties: &HashMap<String, String>,
164✔
423
) -> Result<bool, EvalError> {
164✔
424
    let mut interp = init_interpreter();
164✔
425

426
    // Build context for pyisheval (may fail if lambdas have errors)
427
    let context = build_pyisheval_context(properties, &mut interp)?;
164✔
428

429
    // Tokenize input to detect structure
430
    let lexer = Lexer::new(text);
164✔
431
    let tokens: Vec<_> = lexer.collect();
164✔
432

433
    // CASE 1: Single ${expr} token → Preserve type, apply truthiness on Value
434
    // This is important for float truthiness: ${3*0.1} → float 0.3 → true
435
    if tokens.len() == 1 && tokens[0].0 == TokenType::Expr {
164✔
436
        let value = interp
113✔
437
            .eval_with_context(&tokens[0].1, &context)
113✔
438
            .map_err(|e| EvalError::PyishEval {
113✔
439
                expr: text.to_string(),
×
440
                source: e,
×
441
            })?;
×
442

443
        // Apply Python truthiness based on Value type
444
        return match value {
113✔
445
            Value::Number(n) => Ok(n != 0.0), // Float/int truthiness (includes bools: True=1.0, False=0.0)
113✔
446
            Value::StringLit(s) => {
×
447
                // String: must be "true"/"false" or parseable as int
448
                apply_string_truthiness(&s, text)
×
449
            }
450
            // Other types (Lambda, List, etc.) - error for now
451
            _ => Err(EvalError::InvalidBoolean {
×
452
                condition: text.to_string(),
×
453
                evaluated: format!("{:?}", value),
×
454
            }),
×
455
        };
456
    }
51✔
457

458
    // CASE 2: Multiple tokens or plain text → Evaluate to string, then parse
459
    // Example: "text ${expr} more" or just "true"
460
    let evaluated = eval_text_with_interpreter(text, properties, &mut interp)?;
51✔
461
    apply_string_truthiness(&evaluated, text)
51✔
462
}
164✔
463

464
#[cfg(test)]
465
mod tests {
466
    use super::*;
467
    use crate::eval::interpreter::parsing::{
468
        find_matching_paren, split_args_balanced, SUPPORTED_MATH_FUNCS,
469
    };
470

471
    /// Test helper: Evaluate text containing ${...} expressions
472
    ///
473
    /// Examples:
474
    ///   "hello ${name}" with {name: "world"} → "hello world"
475
    ///   "${2 + 3}" → "5"
476
    ///   "${width * 2}" with {width: "0.5"} → "1"
477
    fn eval_text(
96✔
478
        text: &str,
96✔
479
        properties: &HashMap<String, String>,
96✔
480
    ) -> Result<String, EvalError> {
96✔
481
        let mut interp = init_interpreter();
96✔
482
        eval_text_with_interpreter(text, properties, &mut interp)
96✔
483
    }
96✔
484

485
    /// Test helper: Evaluate a single expression using the given interpreter and context
486
    ///
487
    /// # Arguments
488
    /// * `interp` - The interpreter instance
489
    /// * `expr` - The expression to evaluate
490
    /// * `context` - The evaluation context (properties as pyisheval Values)
491
    ///
492
    /// # Returns
493
    /// * `Ok(Some(value))` - Normal expression evaluated successfully
494
    /// * `Ok(None)` - Special case that produces no output (e.g., xacro.print_location())
495
    /// * `Err(e)` - Evaluation error
496
    fn evaluate_expression(
6✔
497
        interp: &mut Interpreter,
6✔
498
        expr: &str,
6✔
499
        context: &HashMap<String, Value>,
6✔
500
    ) -> Result<Option<Value>, pyisheval::EvalError> {
6✔
501
        evaluate_expression_impl(
6✔
502
            interp,
6✔
503
            expr,
6✔
504
            context,
6✔
505
            #[cfg(feature = "yaml")]
506
            None,
6✔
507
        )
508
    }
6✔
509

510
    // TEST 1: Backward compatibility - simple property substitution
511
    #[test]
512
    fn test_simple_property_substitution() {
1✔
513
        let mut props = HashMap::new();
1✔
514
        props.insert("width".to_string(), "0.5".to_string());
1✔
515

516
        let result = eval_text("${width}", &props).unwrap();
1✔
517
        assert_eq!(result, "0.5");
1✔
518
    }
1✔
519

520
    // TEST 2: Property in text
521
    #[test]
522
    fn test_property_in_text() {
1✔
523
        let mut props = HashMap::new();
1✔
524
        props.insert("width".to_string(), "0.5".to_string());
1✔
525

526
        let result = eval_text("The width is ${width} meters", &props).unwrap();
1✔
527
        assert_eq!(result, "The width is 0.5 meters");
1✔
528
    }
1✔
529

530
    // TEST 3: Multiple properties
531
    #[test]
532
    fn test_multiple_properties() {
1✔
533
        let mut props = HashMap::new();
1✔
534
        props.insert("width".to_string(), "0.5".to_string());
1✔
535
        props.insert("height".to_string(), "1.0".to_string());
1✔
536

537
        let result = eval_text("${width} x ${height}", &props).unwrap();
1✔
538
        // Whole numbers format without .0 (Python int behavior)
539
        assert_eq!(result, "0.5 x 1");
1✔
540
    }
1✔
541

542
    // TEST 4: NEW - Simple arithmetic
543
    #[test]
544
    fn test_arithmetic_expression() {
1✔
545
        let mut props = HashMap::new();
1✔
546
        props.insert("width".to_string(), "0.5".to_string());
1✔
547

548
        let result = eval_text("${width * 2}", &props).unwrap();
1✔
549
        assert_eq!(result, "1");
1✔
550
    }
1✔
551

552
    // TEST 5: NEW - Arithmetic without properties
553
    #[test]
554
    fn test_pure_arithmetic() {
1✔
555
        let props = HashMap::new();
1✔
556

557
        let result = eval_text("${2 + 3}", &props).unwrap();
1✔
558
        assert_eq!(result, "5");
1✔
559
    }
1✔
560

561
    // TEST 6: NEW - Complex expression
562
    #[test]
563
    fn test_complex_expression() {
1✔
564
        let mut props = HashMap::new();
1✔
565
        props.insert("width".to_string(), "0.5".to_string());
1✔
566
        props.insert("height".to_string(), "2.0".to_string());
1✔
567

568
        let result = eval_text("${width * height + 1}", &props).unwrap();
1✔
569
        assert_eq!(result, "2");
1✔
570
    }
1✔
571

572
    // TEST 7: NEW - String concatenation with literals
573
    // Note: pyisheval doesn't currently support string concatenation with +
574
    // This is documented as a known limitation. Use property substitution instead.
575
    #[test]
576
    #[ignore]
577
    fn test_string_concatenation() {
×
578
        let props = HashMap::new();
×
579

580
        // String concatenation with string literals (quoted in expression)
581
        let result = eval_text("${'link' + '_' + 'base'}", &props).unwrap();
×
582
        assert_eq!(result, "link_base");
×
583
    }
×
584

585
    // TEST 8: NEW - Built-in functions
586
    #[test]
587
    fn test_builtin_functions() {
1✔
588
        let props = HashMap::new();
1✔
589

590
        let result = eval_text("${abs(-5)}", &props).unwrap();
1✔
591
        assert_eq!(result, "5");
1✔
592

593
        let result = eval_text("${max(2, 5, 3)}", &props).unwrap();
1✔
594
        assert_eq!(result, "5");
1✔
595
    }
1✔
596

597
    // TEST 9: NEW - Conditional expressions
598
    #[test]
599
    fn test_conditional_expression() {
1✔
600
        let mut props = HashMap::new();
1✔
601
        props.insert("width".to_string(), "0.5".to_string());
1✔
602

603
        let result = eval_text("${width if width > 0.3 else 0.3}", &props).unwrap();
1✔
604
        assert_eq!(result, "0.5");
1✔
605
    }
1✔
606

607
    // TEST 10: Text without expressions (pass through)
608
    #[test]
609
    fn test_no_expressions() {
1✔
610
        let props = HashMap::new();
1✔
611
        let result = eval_text("hello world", &props).unwrap();
1✔
612
        assert_eq!(result, "hello world");
1✔
613
    }
1✔
614

615
    // TEST 11: Empty string
616
    #[test]
617
    fn test_empty_string() {
1✔
618
        let props = HashMap::new();
1✔
619
        let result = eval_text("", &props).unwrap();
1✔
620
        assert_eq!(result, "");
1✔
621
    }
1✔
622

623
    // TEST 12: Error case - undefined property
624
    #[test]
625
    fn test_undefined_property() {
1✔
626
        let props = HashMap::new();
1✔
627
        let result = eval_text("${undefined}", &props);
1✔
628
        assert!(result.is_err());
1✔
629
    }
1✔
630

631
    // TEST 13: String property substitution (non-numeric values)
632
    #[test]
633
    fn test_string_property() {
1✔
634
        let mut props = HashMap::new();
1✔
635
        props.insert("link_name".to_string(), "base_link".to_string());
1✔
636
        props.insert("joint_type".to_string(), "revolute".to_string());
1✔
637

638
        // Test single property
639
        let result = eval_text("${link_name}", &props).unwrap();
1✔
640
        assert_eq!(result, "base_link");
1✔
641

642
        // Test property in text
643
        let result = eval_text("name_${link_name}_suffix", &props).unwrap();
1✔
644
        assert_eq!(result, "name_base_link_suffix");
1✔
645

646
        // Test multiple string properties
647
        let result = eval_text("${link_name} ${joint_type}", &props).unwrap();
1✔
648
        assert_eq!(result, "base_link revolute");
1✔
649
    }
1✔
650

651
    #[test]
652
    fn test_double_dollar_escape() {
1✔
653
        let props = HashMap::new();
1✔
654

655
        // Test $$ escape with brace - should produce literal ${
656
        let result = eval_text("$${expr}", &props).unwrap();
1✔
657
        assert_eq!(result, "${expr}");
1✔
658

659
        // Test $$ escape with paren - should produce literal $(
660
        let result = eval_text("$$(command)", &props).unwrap();
1✔
661
        assert_eq!(result, "$(command)");
1✔
662

663
        // Test $$ escape in context
664
        let result = eval_text("prefix_$${literal}_suffix", &props).unwrap();
1✔
665
        assert_eq!(result, "prefix_${literal}_suffix");
1✔
666
    }
1✔
667

668
    // ===== NEW TESTS FOR eval_boolean =====
669

670
    // Test from Python xacro: test_boolean_if_statement (line 715)
671
    #[test]
672
    fn test_eval_boolean_literals() {
1✔
673
        let props = HashMap::new();
1✔
674

675
        // Boolean string literals
676
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
677
        assert_eq!(eval_boolean("false", &props).unwrap(), false);
1✔
678
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
679
        assert_eq!(eval_boolean("False", &props).unwrap(), false);
1✔
680
    }
1✔
681

682
    // Test from Python xacro: test_integer_if_statement (line 735)
683
    #[test]
684
    fn test_eval_boolean_integer_truthiness() {
1✔
685
        let props = HashMap::new();
1✔
686

687
        // Integer literals as strings
688
        assert_eq!(eval_boolean("0", &props).unwrap(), false);
1✔
689
        assert_eq!(eval_boolean("1", &props).unwrap(), true);
1✔
690
        assert_eq!(eval_boolean("42", &props).unwrap(), true);
1✔
691
        assert_eq!(eval_boolean("-5", &props).unwrap(), true);
1✔
692

693
        // Integer expressions
694
        assert_eq!(eval_boolean("${0*42}", &props).unwrap(), false); // 0
1✔
695
        assert_eq!(eval_boolean("${0}", &props).unwrap(), false);
1✔
696
        assert_eq!(eval_boolean("${1*2+3}", &props).unwrap(), true); // 5
1✔
697
    }
1✔
698

699
    // Test from Python xacro: test_float_if_statement (line 755)
700
    #[test]
701
    fn test_eval_boolean_float_truthiness() {
1✔
702
        let props = HashMap::new();
1✔
703

704
        // Float expressions must preserve type
705
        assert_eq!(eval_boolean("${3*0.0}", &props).unwrap(), false); // 0.0
1✔
706
        assert_eq!(eval_boolean("${3*0.1}", &props).unwrap(), true); // 0.3 (non-zero float)
1✔
707
        assert_eq!(eval_boolean("${0.5}", &props).unwrap(), true);
1✔
708
        assert_eq!(eval_boolean("${-0.1}", &props).unwrap(), true);
1✔
709
    }
1✔
710

711
    // Test from Python xacro: test_property_if_statement (line 769)
712
    #[test]
713
    fn test_eval_boolean_with_properties() {
1✔
714
        let mut props = HashMap::new();
1✔
715
        props.insert("condT".to_string(), "1".to_string()); // True as number
1✔
716
        props.insert("condF".to_string(), "0".to_string()); // False as number
1✔
717
        props.insert("num".to_string(), "5".to_string());
1✔
718

719
        assert_eq!(eval_boolean("${condT}", &props).unwrap(), true);
1✔
720
        assert_eq!(eval_boolean("${condF}", &props).unwrap(), false);
1✔
721
        assert_eq!(eval_boolean("${num}", &props).unwrap(), true); // 5 != 0
1✔
722

723
        // Note: pyisheval doesn't have True/False as built-in constants
724
        // They would need to be defined as properties with value 1/0
725
    }
1✔
726

727
    // Test from Python xacro: test_equality_expression_in_if_statement (line 788)
728
    #[test]
729
    fn test_eval_boolean_expressions() {
1✔
730
        let mut props = HashMap::new();
1✔
731
        props.insert("var".to_string(), "useit".to_string());
1✔
732

733
        // Equality
734
        assert_eq!(eval_boolean("${var == 'useit'}", &props).unwrap(), true);
1✔
735
        assert_eq!(eval_boolean("${var == 'other'}", &props).unwrap(), false);
1✔
736

737
        // Comparison
738
        props.insert("x".to_string(), "5".to_string());
1✔
739
        assert_eq!(eval_boolean("${x > 3}", &props).unwrap(), true);
1✔
740
        assert_eq!(eval_boolean("${x < 3}", &props).unwrap(), false);
1✔
741

742
        // Note: pyisheval doesn't support 'in' operator for strings yet
743
        // That would require extending pyisheval or using a different evaluator
744
    }
1✔
745

746
    /// Test that pyisheval returns Value::Number for boolean expressions
747
    ///
748
    /// Note: pyisheval v0.9.0 does NOT have Value::Bool.
749
    /// Boolean comparison expressions like ${1 == 1} return Value::Number(1.0), not Value::Bool(true).
750
    /// This is similar to Python where bool is a subclass of int (True == 1, False == 0).
751
    ///
752
    /// This test exists to:
753
    /// 1. Verify our Number-based truthiness handling works for comparisons
754
    /// 2. Document pyisheval's current behavior
755
    /// 3. Catch if pyisheval adds Value::Bool in future (this would fail, prompting us to update)
756
    #[test]
757
    fn test_eval_boolean_comparison_expressions() {
1✔
758
        let mut props = HashMap::new();
1✔
759
        props.insert("x".to_string(), "5".to_string());
1✔
760
        props.insert("y".to_string(), "10".to_string());
1✔
761

762
        // Equality comparisons
763
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
764
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
765
        assert_eq!(eval_boolean("${x == 5}", &props).unwrap(), true);
1✔
766
        assert_eq!(eval_boolean("${x == y}", &props).unwrap(), false);
1✔
767

768
        // Inequality comparisons
769
        assert_eq!(eval_boolean("${1 != 2}", &props).unwrap(), true);
1✔
770
        assert_eq!(eval_boolean("${1 != 1}", &props).unwrap(), false);
1✔
771

772
        // Less than / greater than
773
        assert_eq!(eval_boolean("${x < y}", &props).unwrap(), true);
1✔
774
        assert_eq!(eval_boolean("${x > y}", &props).unwrap(), false);
1✔
775
        assert_eq!(eval_boolean("${x <= 5}", &props).unwrap(), true);
1✔
776
        assert_eq!(eval_boolean("${y >= 10}", &props).unwrap(), true);
1✔
777

778
        // NOTE: `and`/`or` operators are now supported in pyisheval v0.13+
779
        // See tests/test_logical_operators.rs for integration tests
780

781
        // Note: All these comparison expressions are evaluated by pyisheval as Value::Number(1.0) or Value::Number(0.0)
782
        // Our eval_boolean correctly applies != 0.0 truthiness to convert to bool
783
    }
1✔
784

785
    // Test from Python xacro: test_invalid_if_statement (line 729)
786
    #[test]
787
    fn test_eval_boolean_invalid_values() {
1✔
788
        let props = HashMap::new();
1✔
789

790
        // STRICT mode: "nonsense" should error
791
        let result = eval_boolean("nonsense", &props);
1✔
792
        assert!(result.is_err());
1✔
793
        assert!(result
1✔
794
            .unwrap_err()
1✔
795
            .to_string()
1✔
796
            .contains("not a boolean expression"));
1✔
797

798
        // Empty string should error
799
        let result = eval_boolean("", &props);
1✔
800
        assert!(result.is_err());
1✔
801

802
        // Random text should error
803
        let result = eval_boolean("random text", &props);
1✔
804
        assert!(result.is_err());
1✔
805
    }
1✔
806

807
    // Test edge case: whitespace handling
808
    #[test]
809
    fn test_eval_boolean_whitespace() {
1✔
810
        let props = HashMap::new();
1✔
811

812
        // Should trim whitespace
813
        assert_eq!(eval_boolean(" true ", &props).unwrap(), true);
1✔
814
        assert_eq!(eval_boolean("\tfalse\n", &props).unwrap(), false);
1✔
815
        assert_eq!(eval_boolean("  0  ", &props).unwrap(), false);
1✔
816
        assert_eq!(eval_boolean("  1  ", &props).unwrap(), true);
1✔
817
    }
1✔
818

819
    // Test case sensitivity
820
    #[test]
821
    fn test_eval_boolean_case_sensitivity() {
1✔
822
        let props = HashMap::new();
1✔
823

824
        // "true" and "True" are accepted
825
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
826
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
827

828
        // But not other cases (should error)
829
        assert!(eval_boolean("TRUE", &props).is_err());
1✔
830
        assert!(eval_boolean("tRuE", &props).is_err());
1✔
831
    }
1✔
832

833
    // Test evaluate_expression special case handling directly
834
    #[test]
835
    fn test_evaluate_expression_special_cases() {
1✔
836
        let mut interp = init_interpreter();
1✔
837
        let context = HashMap::new();
1✔
838

839
        // Test xacro.print_location() special case
840
        let result = evaluate_expression(&mut interp, "xacro.print_location()", &context).unwrap();
1✔
841
        assert!(
1✔
842
            result.is_none(),
1✔
843
            "xacro.print_location() should return None"
×
844
        );
845

846
        // Test with surrounding whitespace
847
        let result =
1✔
848
            evaluate_expression(&mut interp, "  xacro.print_location()  ", &context).unwrap();
1✔
849
        assert!(
1✔
850
            result.is_none(),
1✔
851
            "xacro.print_location() with whitespace should return None"
×
852
        );
853

854
        // Test a normal expression to ensure it's not affected
855
        let result = evaluate_expression(&mut interp, "1 + 1", &context).unwrap();
1✔
856
        assert!(
1✔
857
            matches!(result, Some(Value::Number(n)) if n == 2.0),
1✔
858
            "Normal expression should evaluate correctly"
×
859
        );
860
    }
1✔
861

862
    // Test xacro.print_location() stub function via integration
863
    #[test]
864
    fn test_xacro_print_location_stub() {
1✔
865
        let props = HashMap::new();
1✔
866

867
        // xacro.print_location() should return empty string
868
        let result = eval_text("${xacro.print_location()}", &props).unwrap();
1✔
869
        assert_eq!(result, "");
1✔
870

871
        // Should work in text context too
872
        let result = eval_text("before${xacro.print_location()}after", &props).unwrap();
1✔
873
        assert_eq!(result, "beforeafter");
1✔
874

875
        // With whitespace in expression
876
        let result = eval_text("${ xacro.print_location() }", &props).unwrap();
1✔
877
        assert_eq!(result, "");
1✔
878
    }
1✔
879

880
    // Test that inf and nan are available via direct context injection
881
    #[test]
882
    fn test_inf_nan_direct_injection() {
1✔
883
        let props = HashMap::new();
1✔
884
        let mut interp = init_interpreter();
1✔
885

886
        // Build context with direct inf/nan injection
887
        let context = build_pyisheval_context(&props, &mut interp).unwrap();
1✔
888

889
        // Verify inf and nan are in the context
890
        assert!(
1✔
891
            context.contains_key("inf"),
1✔
892
            "Context should contain 'inf' key"
×
893
        );
894
        assert!(
1✔
895
            context.contains_key("nan"),
1✔
896
            "Context should contain 'nan' key"
×
897
        );
898

899
        // Test 1: inf should be positive infinity
900
        if let Some(Value::Number(n)) = context.get("inf") {
1✔
901
            assert!(
1✔
902
                n.is_infinite() && n.is_sign_positive(),
1✔
903
                "inf should be positive infinity, got: {}",
×
904
                n
905
            );
906
        } else {
907
            panic!("inf should be a Number value");
×
908
        }
909

910
        // Test 2: nan should be NaN
911
        if let Some(Value::Number(n)) = context.get("nan") {
1✔
912
            assert!(n.is_nan(), "nan should be NaN, got: {}", n);
1✔
913
        } else {
914
            panic!("nan should be a Number value");
×
915
        }
916

917
        // Test 3: inf should be usable in expressions
918
        let result = interp.eval_with_context("inf * 2", &context);
1✔
919
        assert!(
1✔
920
            matches!(result, Ok(Value::Number(n)) if n.is_infinite() && n.is_sign_positive()),
1✔
921
            "inf * 2 should return positive infinity, got: {:?}",
×
922
            result
923
        );
924

925
        // Test 4: nan should be usable in expressions
926
        let result = interp.eval_with_context("nan + 1", &context);
1✔
927
        assert!(
1✔
928
            matches!(result, Ok(Value::Number(n)) if n.is_nan()),
1✔
929
            "nan + 1 should return NaN, got: {:?}",
×
930
            result
931
        );
932
    }
1✔
933

934
    // Test type preservation: the key feature!
935
    #[test]
936
    fn test_eval_boolean_type_preservation() {
1✔
937
        let props = HashMap::new();
1✔
938

939
        // Single expression: type preserved
940
        // ${3*0.1} → Value::Number(0.3) → != 0.0 → true
941
        assert_eq!(eval_boolean("${3*0.1}", &props).unwrap(), true);
1✔
942

943
        // Multiple tokens: becomes string
944
        // "result: ${3*0.1}" → "result: 0.3" → can't parse as int → error
945
        let result = eval_boolean("result: ${3*0.1}", &props);
1✔
946
        assert!(result.is_err());
1✔
947
    }
1✔
948

949
    // Test Boolean value type from pyisheval
950
    #[test]
951
    fn test_eval_boolean_bool_values() {
1✔
952
        let props = HashMap::new();
1✔
953

954
        // pyisheval returns Value::Bool directly
955
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
956
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
957
        assert_eq!(eval_boolean("${5 > 3}", &props).unwrap(), true);
1✔
958
    }
1✔
959

960
    // Lambda expression tests
961
    #[test]
962
    fn test_basic_lambda_works() {
1✔
963
        let mut props = HashMap::new();
1✔
964
        props.insert("f".to_string(), "lambda x: x * 2".to_string());
1✔
965
        assert_eq!(eval_text("${f(5)}", &props).unwrap(), "10");
1✔
966
    }
1✔
967

968
    // NOTE: pyisheval doesn't support multi-parameter lambdas (parser limitation)
969
    // lambda x, y: x + y fails with "Unexpected trailing input"
970
    // This is a known pyisheval limitation - single parameter lambdas only
971

972
    // Python-style number formatting tests
973
    #[test]
974
    fn test_format_value_python_style_whole_numbers() {
1✔
975
        // Whole numbers format without .0 (Python int behavior)
976
        assert_eq!(format_value_python_style(&Value::Number(0.0), false), "0");
1✔
977
        assert_eq!(format_value_python_style(&Value::Number(1.0), false), "1");
1✔
978
        assert_eq!(format_value_python_style(&Value::Number(2.0), false), "2");
1✔
979
        assert_eq!(format_value_python_style(&Value::Number(-1.0), false), "-1");
1✔
980
        assert_eq!(
1✔
981
            format_value_python_style(&Value::Number(100.0), false),
1✔
982
            "100"
983
        );
984
    }
1✔
985

986
    #[test]
987
    fn test_format_value_python_style_fractional() {
1✔
988
        //Fractional numbers use default formatting (no trailing zeros)
989
        assert_eq!(format_value_python_style(&Value::Number(1.5), false), "1.5");
1✔
990
        assert_eq!(format_value_python_style(&Value::Number(0.5), false), "0.5");
1✔
991
        assert_eq!(
1✔
992
            format_value_python_style(&Value::Number(0.4235294117647059), false),
1✔
993
            "0.4235294117647059"
994
        );
995
    }
1✔
996

997
    #[test]
998
    fn test_format_value_python_style_special() {
1✔
999
        //Special values
1000
        assert_eq!(
1✔
1001
            format_value_python_style(&Value::Number(f64::INFINITY), false),
1✔
1002
            "inf"
1003
        );
1004
        assert_eq!(
1✔
1005
            format_value_python_style(&Value::Number(f64::NEG_INFINITY), false),
1✔
1006
            "-inf"
1007
        );
1008
        assert_eq!(
1✔
1009
            format_value_python_style(&Value::Number(f64::NAN), false),
1✔
1010
            "NaN"
1011
        );
1012
    }
1✔
1013

1014
    #[test]
1015
    fn test_eval_with_python_number_formatting() {
1✔
1016
        let mut props = HashMap::new();
1✔
1017
        props.insert("height".to_string(), "1.0".to_string());
1✔
1018

1019
        // Whole numbers format without .0 (mimics Python int behavior)
1020
        assert_eq!(eval_text("${height}", &props).unwrap(), "1");
1✔
1021
        assert_eq!(eval_text("${1.0 + 0.0}", &props).unwrap(), "1");
1✔
1022
        assert_eq!(eval_text("${2.0 * 1.0}", &props).unwrap(), "2");
1✔
1023
    }
1✔
1024

1025
    #[test]
1026
    fn test_lambda_referencing_property() {
1✔
1027
        let mut props = HashMap::new();
1✔
1028
        props.insert("offset".to_string(), "10".to_string());
1✔
1029
        props.insert("add_offset".to_string(), "lambda x: x + offset".to_string());
1✔
1030
        assert_eq!(eval_text("${add_offset(5)}", &props).unwrap(), "15");
1✔
1031
    }
1✔
1032

1033
    #[test]
1034
    fn test_lambda_referencing_multiple_properties() {
1✔
1035
        let mut props = HashMap::new();
1✔
1036
        props.insert("a".to_string(), "2".to_string());
1✔
1037
        props.insert("b".to_string(), "3".to_string());
1✔
1038
        props.insert("scale".to_string(), "lambda x: x * a + b".to_string());
1✔
1039
        assert_eq!(eval_text("${scale(5)}", &props).unwrap(), "13");
1✔
1040
    }
1✔
1041

1042
    #[test]
1043
    fn test_lambda_with_conditional() {
1✔
1044
        let mut props = HashMap::new();
1✔
1045
        props.insert(
1✔
1046
            "sign".to_string(),
1✔
1047
            "lambda x: 1 if x > 0 else -1".to_string(),
1✔
1048
        );
1049
        assert_eq!(eval_text("${sign(5)}", &props).unwrap(), "1");
1✔
1050
        assert_eq!(eval_text("${sign(-3)}", &props).unwrap(), "-1");
1✔
1051
    }
1✔
1052

1053
    #[test]
1054
    fn test_multiple_lambdas() {
1✔
1055
        let mut props = HashMap::new();
1✔
1056
        props.insert("double".to_string(), "lambda x: x * 2".to_string());
1✔
1057
        props.insert("triple".to_string(), "lambda x: x * 3".to_string());
1✔
1058
        assert_eq!(
1✔
1059
            eval_text("${double(5)} ${triple(5)}", &props).unwrap(),
1✔
1060
            "10 15"
1061
        );
1062
    }
1✔
1063

1064
    #[test]
1065
    fn test_lambda_referencing_inf_property() {
1✔
1066
        let mut props = HashMap::new();
1✔
1067
        props.insert("my_inf".to_string(), "inf".to_string());
1✔
1068
        props.insert("is_inf".to_string(), "lambda x: x == my_inf".to_string());
1✔
1069
        // inf == inf should be true (1)
1070
        assert_eq!(eval_text("${is_inf(inf)}", &props).unwrap(), "1");
1✔
1071
    }
1✔
1072

1073
    // ===== Math Function Tests =====
1074

1075
    #[test]
1076
    fn test_math_functions_cos_sin() {
1✔
1077
        let mut props = HashMap::new();
1✔
1078
        props.insert("pi".to_string(), "3.141592653589793".to_string());
1✔
1079

1080
        let result = eval_text("${cos(0)}", &props).unwrap();
1✔
1081
        assert_eq!(result, "1");
1✔
1082

1083
        let result = eval_text("${sin(0)}", &props).unwrap();
1✔
1084
        assert_eq!(result, "0");
1✔
1085

1086
        let result = eval_text("${cos(pi)}", &props).unwrap();
1✔
1087
        assert_eq!(result, "-1");
1✔
1088
    }
1✔
1089

1090
    #[test]
1091
    fn test_math_functions_nested() {
1✔
1092
        let mut props = HashMap::new();
1✔
1093
        props.insert("radius".to_string(), "0.5".to_string());
1✔
1094

1095
        // radians() is a lambda defined in init_interpreter
1096
        let result = eval_text("${radius*cos(radians(0))}", &props).unwrap();
1✔
1097
        assert_eq!(result, "0.5");
1✔
1098

1099
        let result = eval_text("${radius*cos(radians(60))}", &props).unwrap();
1✔
1100
        // cos(60°) = 0.5, so 0.5 * 0.5 = 0.25 (with floating point rounding)
1101
        let value: f64 = result.parse().unwrap();
1✔
1102
        assert!(
1✔
1103
            (value - 0.25).abs() < 1e-10,
1✔
1104
            "Expected ~0.25, got {}",
×
1105
            value
1106
        );
1107
    }
1✔
1108

1109
    #[test]
1110
    fn test_math_functions_sqrt_abs() {
1✔
1111
        let props = HashMap::new();
1✔
1112

1113
        let result = eval_text("${sqrt(16)}", &props).unwrap();
1✔
1114
        assert_eq!(result, "4");
1✔
1115

1116
        let result = eval_text("${abs(-5)}", &props).unwrap();
1✔
1117
        assert_eq!(result, "5");
1✔
1118

1119
        let result = eval_text("${abs(5)}", &props).unwrap();
1✔
1120
        assert_eq!(result, "5");
1✔
1121
    }
1✔
1122

1123
    #[test]
1124
    fn test_math_functions_floor_ceil() {
1✔
1125
        let props = HashMap::new();
1✔
1126

1127
        let result = eval_text("${floor(3.7)}", &props).unwrap();
1✔
1128
        assert_eq!(result, "3");
1✔
1129

1130
        let result = eval_text("${ceil(3.2)}", &props).unwrap();
1✔
1131
        assert_eq!(result, "4");
1✔
1132

1133
        let result = eval_text("${floor(-2.3)}", &props).unwrap();
1✔
1134
        assert_eq!(result, "-3");
1✔
1135

1136
        let result = eval_text("${ceil(-2.3)}", &props).unwrap();
1✔
1137
        assert_eq!(result, "-2");
1✔
1138
    }
1✔
1139

1140
    #[test]
1141
    fn test_math_functions_trig() {
1✔
1142
        let props = HashMap::new();
1✔
1143

1144
        // tan(0) = 0
1145
        let result = eval_text("${tan(0)}", &props).unwrap();
1✔
1146
        assert_eq!(result, "0");
1✔
1147

1148
        // asin(0) = 0
1149
        let result = eval_text("${asin(0)}", &props).unwrap();
1✔
1150
        assert_eq!(result, "0");
1✔
1151

1152
        // acos(1) = 0
1153
        let result = eval_text("${acos(1)}", &props).unwrap();
1✔
1154
        assert_eq!(result, "0");
1✔
1155

1156
        // atan(0) = 0
1157
        let result = eval_text("${atan(0)}", &props).unwrap();
1✔
1158
        assert_eq!(result, "0");
1✔
1159
    }
1✔
1160

1161
    #[test]
1162
    fn test_math_functions_multiple_in_expression() {
1✔
1163
        let mut props = HashMap::new();
1✔
1164
        props.insert("x".to_string(), "3".to_string());
1✔
1165
        props.insert("y".to_string(), "4".to_string());
1✔
1166

1167
        // sqrt(x^2 + y^2) = sqrt(9 + 16) = sqrt(25) = 5
1168
        let result = eval_text("${sqrt(x**2 + y**2)}", &props).unwrap();
1✔
1169
        assert_eq!(result, "5");
1✔
1170
    }
1✔
1171

1172
    /// Test to prevent divergence between regex pattern and match statement
1173
    ///
1174
    /// This ensures all functions in SUPPORTED_MATH_FUNCS have corresponding implementations,
1175
    /// catching bugs at test time rather than runtime.
1176
    #[test]
1177
    fn test_math_functions_regex_match_consistency() {
1✔
1178
        let props = HashMap::new();
1✔
1179

1180
        // Test each function in SUPPORTED_MATH_FUNCS to ensure it's implemented
1181
        for func in SUPPORTED_MATH_FUNCS {
14✔
1182
            // atan2 and pow require two arguments, others require one
1183
            let expr = if *func == "atan2" || *func == "pow" {
13✔
1184
                format!("${{{}(0, 1)}}", func)
2✔
1185
            } else {
1186
                format!("${{{}(0)}}", func)
11✔
1187
            };
1188
            let result = eval_text(&expr, &props);
13✔
1189

1190
            // Ensure evaluation succeeds - unreachable!() would panic if function is missing
1191
            result.expect("Evaluation should succeed for all supported math functions");
13✔
1192
        }
1193
    }
1✔
1194

1195
    #[test]
1196
    fn test_find_matching_paren_with_brackets() {
1✔
1197
        // Regression test: find_matching_paren should handle brackets inside parens
1198
        let text = "pow([1,2][0], 3)";
1✔
1199
        let result = find_matching_paren(text, 3); // Start at '(' after 'pow'
1✔
1200
        assert_eq!(result, Some(15)); // Position of final ')'
1✔
1201
    }
1✔
1202

1203
    #[test]
1204
    fn test_find_matching_paren_with_braces() {
1✔
1205
        // Regression test: find_matching_paren should handle braces (dict literals)
1206
        let text = "func({a:1,b:2}, 3)";
1✔
1207
        let result = find_matching_paren(text, 4); // Start at '(' after 'func'
1✔
1208
        assert_eq!(result, Some(17)); // Position of final ')'
1✔
1209
    }
1✔
1210

1211
    #[test]
1212
    fn test_split_args_with_array_literal() {
1✔
1213
        // Regression test: split_args_balanced should not split on commas inside arrays
1214
        let args = "[1,2][0], 3";
1✔
1215
        let result = split_args_balanced(args);
1✔
1216
        assert_eq!(result.len(), 2);
1✔
1217
        assert_eq!(result[0], "[1,2][0]");
1✔
1218
        assert_eq!(result[1], " 3");
1✔
1219
    }
1✔
1220

1221
    #[test]
1222
    fn test_split_args_with_dict_literal() {
1✔
1223
        // Regression test: split_args_balanced should not split on commas inside dicts
1224
        let args = "{a:1,b:2}, 3";
1✔
1225
        let result = split_args_balanced(args);
1✔
1226
        assert_eq!(result.len(), 2);
1✔
1227
        assert_eq!(result[0], "{a:1,b:2}");
1✔
1228
        assert_eq!(result[1], " 3");
1✔
1229
    }
1✔
1230

1231
    #[test]
1232
    fn test_split_args_with_nested_structures() {
1✔
1233
        // Complex case: nested arrays, dicts, and function calls
1234
        let args = "[[1,2],[3,4]], {x:[5,6]}, max(7,8)";
1✔
1235
        let result = split_args_balanced(args);
1✔
1236
        assert_eq!(result.len(), 3);
1✔
1237
        assert_eq!(result[0], "[[1,2],[3,4]]");
1✔
1238
        assert_eq!(result[1], " {x:[5,6]}");
1✔
1239
        assert_eq!(result[2], " max(7,8)");
1✔
1240
    }
1✔
1241

1242
    #[test]
1243
    fn test_math_pi_not_substring_match() {
1✔
1244
        // Regression test: math.pi should not match identifiers like math_pi_value
1245
        let mut props = HashMap::new();
1✔
1246
        props.insert("math_pi_value".to_string(), "42".to_string());
1✔
1247

1248
        // math_pi_value should remain as a property lookup, not be replaced
1249
        let result = eval_text("${math_pi_value}", &props).unwrap();
1✔
1250
        assert_eq!(result, "42");
1✔
1251

1252
        // But math.pi should be replaced with pi (pyisheval built-in)
1253
        let result = eval_text("${math.pi * 2}", &props).unwrap();
1✔
1254
        let value: f64 = result.parse().unwrap();
1✔
1255
        assert!((value - (std::f64::consts::PI * 2.0)).abs() < 1e-9);
1✔
1256
    }
1✔
1257

1258
    #[test]
1259
    fn test_math_functions_not_in_string_literals() {
1✔
1260
        // CRITICAL: Math functions inside string literals should NOT be evaluated
1261
        let props = HashMap::new();
1✔
1262

1263
        // Single quoted string literal containing cos(0)
1264
        let result = eval_text("${'Print cos(0)'}", &props).unwrap();
1✔
1265
        assert_eq!(
1✔
1266
            result, "Print cos(0)",
1267
            "cos(0) in single-quoted string should not be evaluated"
×
1268
        );
1269

1270
        // Another example with sin
1271
        let result = eval_text("${'The function sin(x) is useful'}", &props).unwrap();
1✔
1272
        assert_eq!(
1✔
1273
            result, "The function sin(x) is useful",
1274
            "sin(x) in string should not be evaluated"
×
1275
        );
1276

1277
        // Actual cos(0) usage (not in string) should be evaluated
1278
        let result = eval_text("${cos(0)}", &props).unwrap();
1✔
1279
        let value: f64 = result.parse().unwrap();
1✔
1280
        assert!(
1✔
1281
            (value - 1.0).abs() < 1e-9,
1✔
1282
            "cos(0) outside strings should be evaluated to 1"
×
1283
        );
1284
    }
1✔
1285

1286
    #[test]
1287
    fn test_math_pi_not_in_string_literals() {
1✔
1288
        // CRITICAL: math.pi inside string literals should NOT be replaced
1289
        let props = HashMap::new();
1✔
1290

1291
        // Single quoted string literal containing math.pi
1292
        let result = eval_text("${'Use math.pi for calculations'}", &props).unwrap();
1✔
1293
        assert_eq!(
1✔
1294
            result, "Use math.pi for calculations",
1295
            "math.pi in single-quoted string should not be replaced"
×
1296
        );
1297

1298
        // Another single quoted example
1299
        let result = eval_text("${'The constant math.pi is useful'}", &props).unwrap();
1✔
1300
        assert_eq!(
1✔
1301
            result, "The constant math.pi is useful",
1302
            "math.pi in string should not be replaced"
×
1303
        );
1304

1305
        // Actual math.pi usage (not in string) should be replaced
1306
        let result = eval_text("${math.pi}", &props).unwrap();
1✔
1307
        let value: f64 = result.parse().unwrap();
1✔
1308
        assert!(
1✔
1309
            (value - std::f64::consts::PI).abs() < 1e-9,
1✔
1310
            "math.pi outside strings should be replaced with pi constant"
×
1311
        );
1312

1313
        // Mixed: comparison with string containing math.pi vs actual math.pi
1314
        let result = eval_text("${'math.pi' == 'math.pi'}", &props).unwrap();
1✔
1315
        assert_eq!(result, "1", "String comparison should work");
1✔
1316

1317
        let result = eval_text("${math.pi > 3}", &props).unwrap();
1✔
1318
        assert_eq!(
1✔
1319
            result, "1",
1320
            "Numeric math.pi should be replaced and evaluated"
×
1321
        );
1322
    }
1✔
1323

1324
    #[test]
1325
    fn test_pow_with_nested_function_args() {
1✔
1326
        // Regression test: pow(max(1, 2), 3) should handle nested commas correctly
1327
        let props = HashMap::new();
1✔
1328

1329
        // This previously failed because split_once(',') would split at the first comma.
1330
        // Now split_args_balanced handles nested function calls correctly.
1331
        // `max` is a pyisheval builtin, so `max(1, 2)` will be evaluated to 2
1332
        // before `pow` is pre-processed.
1333
        let result = eval_text("${pow(max(1, 2), 3)}", &props).unwrap();
1✔
1334
        assert_eq!(result, "8");
1✔
1335

1336
        // Also test with nested arithmetic expressions
1337
        let result_arith = eval_text("${pow((1 + 1), 3)}", &props).unwrap();
1✔
1338
        assert_eq!(result_arith, "8");
1✔
1339
    }
1✔
1340

1341
    #[test]
1342
    fn test_pow_with_array_indexing() {
1✔
1343
        // Regression test: pow([1,2][0], 3) should handle commas inside array literals
1344
        let mut props = HashMap::new();
1✔
1345
        props.insert("values".to_string(), "[2,3,4]".to_string());
1✔
1346

1347
        // Array indexing with commas inside the array literal
1348
        // This tests that split_args_balanced correctly handles brackets
1349
        let result = eval_text("${pow([1,2][1], 3)}", &props).unwrap();
1✔
1350
        assert_eq!(result, "8"); // 2^3 = 8
1✔
1351

1352
        // Property containing array literal
1353
        let result = eval_text("${pow(values[0], 3)}", &props).unwrap();
1✔
1354
        assert_eq!(result, "8"); // 2^3 = 8
1✔
1355
    }
1✔
1356

1357
    #[test]
1358
    fn test_custom_namespace_not_hijacked() {
1✔
1359
        // Regression test: custom.sin() should not be treated as math function
1360
        let mut props = HashMap::new();
1✔
1361
        props.insert("custom".to_string(), "unused".to_string());
1✔
1362

1363
        // custom.sin(0) should fail (custom namespace not supported)
1364
        // It should NOT be preprocessed as a math function
1365
        let result = eval_text("${custom.sin(0)}", &props);
1✔
1366

1367
        // Should error because custom.sin is not defined, not because we tried to preprocess it
1368
        assert!(result.is_err());
1✔
1369
        // The error should be from pyisheval, not from our preprocessing
1370
        let err_msg = format!("{:?}", result.unwrap_err());
1✔
1371
        assert!(
1✔
1372
            err_msg.contains("UndefinedVar") || err_msg.contains("AttributeError"),
1✔
1373
            "Expected undefined variable error, got: {}",
×
1374
            err_msg
1375
        );
1376
    }
1✔
1377

1378
    #[test]
1379
    fn test_context_can_shadow_len_builtin() {
1✔
1380
        let mut interp = Interpreter::new();
1✔
1381
        let mut properties = HashMap::new();
1✔
1382
        properties.insert("len".to_string(), "0.2".to_string());
1✔
1383

1384
        // Build context
1385
        let context = build_pyisheval_context(&properties, &mut interp).unwrap();
1✔
1386

1387
        // Check that context has "len" with correct value
1388
        assert_eq!(
1✔
1389
            context.get("len"),
1✔
1390
            Some(&Value::Number(0.2)),
1391
            "len should be 0.2"
×
1392
        );
1393

1394
        // Try to evaluate an expression with it
1395
        let result = evaluate_expression(&mut interp, "len", &context).unwrap();
1✔
1396
        assert_eq!(
1✔
1397
            result,
1398
            Some(Value::Number(0.2)),
1399
            "Should return 0.2, not builtin"
×
1400
        );
1401

1402
        // Try in a real expression
1403
        let result2 = evaluate_expression(&mut interp, "len * 2", &context).unwrap();
1✔
1404
        assert_eq!(
1✔
1405
            result2,
1406
            Some(Value::Number(0.4)),
1407
            "Should be able to use len in expressions"
×
1408
        );
1409
    }
1✔
1410

1411
    #[test]
1412
    fn test_context_can_shadow_other_builtins() {
1✔
1413
        let mut interp = Interpreter::new();
1✔
1414
        let mut properties = HashMap::new();
1✔
1415
        properties.insert("min".to_string(), "42".to_string());
1✔
1416
        properties.insert("max".to_string(), "100".to_string());
1✔
1417

1418
        // Build context
1419
        let context = build_pyisheval_context(&properties, &mut interp).unwrap();
1✔
1420

1421
        // Check that context has the shadowable built-ins with correct values
1422
        assert_eq!(
1✔
1423
            context.get("min"),
1✔
1424
            Some(&Value::Number(42.0)),
1425
            "min should be 42.0"
×
1426
        );
1427
        assert_eq!(
1✔
1428
            context.get("max"),
1✔
1429
            Some(&Value::Number(100.0)),
1430
            "max should be 100.0"
×
1431
        );
1432

1433
        // Try to evaluate expressions with them
1434
        let result = evaluate_expression(&mut interp, "min + max", &context).unwrap();
1✔
1435
        assert_eq!(
1✔
1436
            result,
1437
            Some(Value::Number(142.0)),
1438
            "Should be able to use min and max in expressions"
×
1439
        );
1440
    }
1✔
1441

1442
    // TEST: Type coercion for True/False constants
1443
    #[test]
1444
    fn test_eval_literal_boolean_true() {
1✔
1445
        let result = eval_literal("True");
1✔
1446
        assert!(
1✔
1447
            matches!(result, Value::Number(n) if (n - 1.0).abs() < 1e-10),
1✔
1448
            "True should convert to 1.0, got: {:?}",
×
1449
            result
1450
        );
1451
    }
1✔
1452

1453
    #[test]
1454
    fn test_eval_literal_boolean_false() {
1✔
1455
        let result = eval_literal("False");
1✔
1456
        assert!(
1✔
1457
            matches!(result, Value::Number(n) if n.abs() < 1e-10),
1✔
1458
            "False should convert to 0.0, got: {:?}",
×
1459
            result
1460
        );
1461
    }
1✔
1462

1463
    #[test]
1464
    fn test_eval_literal_boolean_lowercase_true() {
1✔
1465
        let result = eval_literal("true");
1✔
1466
        assert!(
1✔
1467
            matches!(result, Value::Number(n) if (n - 1.0).abs() < 1e-10),
1✔
1468
            "true should convert to 1.0, got: {:?}",
×
1469
            result
1470
        );
1471
    }
1✔
1472

1473
    #[test]
1474
    fn test_eval_literal_boolean_lowercase_false() {
1✔
1475
        let result = eval_literal("false");
1✔
1476
        assert!(
1✔
1477
            matches!(result, Value::Number(n) if n.abs() < 1e-10),
1✔
1478
            "false should convert to 0.0, got: {:?}",
×
1479
            result
1480
        );
1481
    }
1✔
1482

1483
    #[test]
1484
    fn test_eval_literal_int() {
1✔
1485
        let result = eval_literal("123");
1✔
1486
        assert!(
1✔
1487
            matches!(result, Value::Number(n) if (n - 123.0).abs() < 1e-10),
1✔
1488
            "Integer string should convert to float, got: {:?}",
×
1489
            result
1490
        );
1491
    }
1✔
1492

1493
    #[test]
1494
    fn test_eval_literal_float() {
1✔
1495
        let result = eval_literal("3.14");
1✔
1496
        assert!(
1✔
1497
            matches!(result, Value::Number(n) if (n - 3.14).abs() < 1e-10),
1✔
1498
            "Float string should convert to float, got: {:?}",
×
1499
            result
1500
        );
1501
    }
1✔
1502

1503
    #[test]
1504
    fn test_eval_literal_quoted_string() {
1✔
1505
        let result = eval_literal("'hello'");
1✔
1506
        assert_eq!(
1✔
1507
            result,
1508
            Value::StringLit("hello".to_string()),
1✔
1509
            "Quoted string should strip quotes"
×
1510
        );
1511
    }
1✔
1512

1513
    #[test]
1514
    fn test_eval_literal_underscore_string() {
1✔
1515
        let result = eval_literal("foo_bar");
1✔
1516
        assert_eq!(
1✔
1517
            result,
1518
            Value::StringLit("foo_bar".to_string()),
1✔
1519
            "String with underscore should remain string (likely variable name)"
×
1520
        );
1521
    }
1✔
1522

1523
    #[test]
1524
    fn test_eval_literal_numeric_looking_underscore_string() {
1✔
1525
        // Regression test: "36_11" should NOT be parsed as number (which would become 3611)
1526
        // Python xacro explicitly skips numeric parsing for ANY value with underscores
1527
        // to preserve identifiers like tag36_11_00333 in filenames
1528
        let result = eval_literal("36_11");
1✔
1529
        assert_eq!(
1✔
1530
            result,
1531
            Value::StringLit("36_11".to_string()),
1✔
1532
            "Numeric-looking string with underscore should remain string, not be parsed as number"
×
1533
        );
1534
    }
1✔
1535

1536
    #[test]
1537
    fn test_eval_literal_unparseable_string() {
1✔
1538
        let result = eval_literal("hello");
1✔
1539
        assert_eq!(
1✔
1540
            result,
1541
            Value::StringLit("hello".to_string()),
1✔
1542
            "Unparseable string should remain string"
×
1543
        );
1544
    }
1✔
1545

1546
    #[test]
1547
    fn test_eval_literal_empty_string() {
1✔
1548
        let result = eval_literal("");
1✔
1549
        assert_eq!(
1✔
1550
            result,
1551
            Value::StringLit("".to_string()),
1✔
1552
            "Empty string should remain empty string"
×
1553
        );
1554
    }
1✔
1555

1556
    // Integration test: True/False in properties
1557
    #[test]
1558
    fn test_true_false_in_properties() {
1✔
1559
        let mut props = HashMap::new();
1✔
1560
        props.insert("flag".to_string(), "True".to_string());
1✔
1561
        props.insert("disabled".to_string(), "False".to_string());
1✔
1562

1563
        // Test that properties are converted to numbers
1564
        let result = eval_text("${flag}", &props).unwrap();
1✔
1565
        assert_eq!(result, "1", "True converts to 1");
1✔
1566

1567
        let result = eval_text("${disabled}", &props).unwrap();
1✔
1568
        assert_eq!(result, "0", "False converts to 0");
1✔
1569

1570
        // Test numeric comparisons (True=1.0, False=0.0)
1571
        let result = eval_text("${flag == 1}", &props).unwrap();
1✔
1572
        assert_eq!(result, "1", "True should equal 1 (returns 1 for true)");
1✔
1573

1574
        let result = eval_text("${disabled == 0}", &props).unwrap();
1✔
1575
        assert_eq!(result, "1", "False should equal 0 (returns 1 for true)");
1✔
1576

1577
        // Test boolean in conditional
1578
        let result = eval_text("${1 if flag else 0}", &props).unwrap();
1✔
1579
        assert_eq!(result, "1", "True (1.0) should evaluate as truthy");
1✔
1580

1581
        let result = eval_text("${1 if disabled else 0}", &props).unwrap();
1✔
1582
        assert_eq!(result, "0", "False (0.0) should evaluate as falsy");
1✔
1583
    }
1✔
1584

1585
    // Integration test: Comparing boolean properties
1586
    #[test]
1587
    fn test_true_false_property_comparison() {
1✔
1588
        let mut props = HashMap::new();
1✔
1589
        props.insert("enabled".to_string(), "True".to_string());
1✔
1590
        props.insert("also_enabled".to_string(), "True".to_string());
1✔
1591
        props.insert("disabled".to_string(), "False".to_string());
1✔
1592

1593
        // Compare two properties with same value (returns 1 for true comparison)
1594
        let result = eval_text("${enabled == also_enabled}", &props).unwrap();
1✔
1595
        assert_eq!(result, "1", "1.0 should equal 1.0");
1✔
1596

1597
        // Compare properties with different values (returns 0 for false comparison)
1598
        let result = eval_text("${enabled == disabled}", &props).unwrap();
1✔
1599
        assert_eq!(result, "0", "1.0 should not equal 0.0");
1✔
1600
    }
1✔
1601

1602
    // Tests for pow() function
1603
    #[test]
1604
    fn test_pow_function() {
1✔
1605
        let props = HashMap::new();
1✔
1606
        let result = eval_text("${pow(2, 3)}", &props).expect("pow should work");
1✔
1607
        assert_eq!(result, "8");
1✔
1608

1609
        let result = eval_text("${pow(10, 0.5)}", &props).expect("pow with fractional exp");
1✔
1610
        let value: f64 = result.parse().expect("parse float");
1✔
1611
        assert!((value - 10.0_f64.sqrt()).abs() < 1e-10, "sqrt(10) mismatch");
1✔
1612
    }
1✔
1613

1614
    // Tests for log() function
1615
    #[test]
1616
    fn test_log_function() {
1✔
1617
        let props = HashMap::new();
1✔
1618
        let result = eval_text("${log(1)}", &props).expect("log should work");
1✔
1619
        assert_eq!(result, "0", "ln(1) = 0");
1✔
1620

1621
        // ln(e) should be 1, using the built-in 'e' constant
1622
        let result = eval_text("${log(e)}", &props).expect("log(e)");
1✔
1623
        let value: f64 = result.parse().expect("parse float");
1✔
1624
        assert!((value - 1.0).abs() < 1e-10, "ln(e) = 1");
1✔
1625

1626
        // Test log with base (log(100, 10) = 2)
1627
        let result = eval_text("${log(100, 10)}", &props).expect("log(100, 10)");
1✔
1628
        let value: f64 = result.parse().expect("parse float");
1✔
1629
        assert!((value - 2.0).abs() < 1e-10, "log_10(100) = 2");
1✔
1630

1631
        // Test log with base 2 (log(8, 2) = 3)
1632
        let result = eval_text("${log(8, 2)}", &props).expect("log(8, 2)");
1✔
1633
        let value: f64 = result.parse().expect("parse float");
1✔
1634
        assert!((value - 3.0).abs() < 1e-10, "log_2(8) = 3");
1✔
1635
    }
1✔
1636

1637
    // Tests for math. prefix functions
1638
    #[test]
1639
    fn test_math_prefix_functions() {
1✔
1640
        let props = HashMap::new();
1✔
1641

1642
        // Test math.pow
1643
        let result = eval_text("${math.pow(2, 3)}", &props).expect("math.pow");
1✔
1644
        assert_eq!(result, "8");
1✔
1645

1646
        // Test math.log
1647
        let result = eval_text("${math.log(1)}", &props).expect("math.log");
1✔
1648
        assert_eq!(result, "0");
1✔
1649

1650
        // Test math.atan2
1651
        let result = eval_text("${math.atan2(1, 0)}", &props).expect("math.atan2");
1✔
1652
        let value: f64 = result.parse().expect("parse float");
1✔
1653
        assert!(
1✔
1654
            (value - std::f64::consts::FRAC_PI_2).abs() < 1e-10,
1✔
1655
            "atan2(1,0) = π/2"
×
1656
        );
1657

1658
        // Test math.sqrt
1659
        let result = eval_text("${math.sqrt(4)}", &props).expect("math.sqrt");
1✔
1660
        assert_eq!(result, "2");
1✔
1661
    }
1✔
1662

1663
    // Tests for math.pi constant
1664
    #[test]
1665
    fn test_math_pi_constant_access() {
1✔
1666
        let props = HashMap::new();
1✔
1667

1668
        // Test math.pi
1669
        let result = eval_text("${math.pi}", &props).expect("math.pi");
1✔
1670
        let value: f64 = result.parse().expect("parse float");
1✔
1671
        assert!((value - std::f64::consts::PI).abs() < 1e-10, "math.pi = π");
1✔
1672

1673
        // Test math.pi in expression
1674
        let result = eval_text("${-math.pi / 2}", &props).expect("-math.pi / 2");
1✔
1675
        let value: f64 = result.parse().expect("parse float");
1✔
1676
        assert!((value + std::f64::consts::FRAC_PI_2).abs() < 1e-10, "-Ï€/2");
1✔
1677
    }
1✔
1678
}
1679

1680
// ========================================================================
1681
// YAML-specific tests
1682
// ========================================================================
1683

1684
#[cfg(all(test, feature = "yaml"))]
1685
mod yaml_tests {
1686
    use super::*;
1687
    use std::collections::HashMap;
1688

1689
    /// Test helper: Evaluate text containing ${...} expressions
1690
    fn eval_text(
16✔
1691
        text: &str,
16✔
1692
        properties: &HashMap<String, String>,
16✔
1693
    ) -> Result<String, EvalError> {
16✔
1694
        let mut interp = init_interpreter();
16✔
1695
        eval_text_with_interpreter(text, properties, &mut interp)
16✔
1696
    }
16✔
1697

1698
    #[test]
1699
    fn test_load_yaml_nested_dict() {
1✔
1700
        let props = HashMap::new();
1✔
1701

1702
        // Access nested dict values
1703
        let value = eval_text(
1✔
1704
            "${load_yaml('tests/data/test_config.yaml')['robot']['chassis']['length']}",
1✔
1705
            &props,
1✔
1706
        )
1707
        .expect("load_yaml nested access should succeed");
1✔
1708

1709
        assert_eq!(value, "0.5", "chassis length should be 0.5");
1✔
1710
    }
1✔
1711

1712
    #[test]
1713
    fn test_load_yaml_with_xacro_prefix() {
1✔
1714
        let props = HashMap::new();
1✔
1715

1716
        // Test xacro.load_yaml() syntax
1717
        let value = eval_text(
1✔
1718
            "${xacro.load_yaml('tests/data/test_config.yaml')['count']}",
1✔
1719
            &props,
1✔
1720
        )
1721
        .expect("xacro.load_yaml should succeed");
1✔
1722

1723
        assert_eq!(value, "5", "count should be 5");
1✔
1724
    }
1✔
1725

1726
    #[test]
1727
    fn test_load_yaml_array_access() {
1✔
1728
        let props = HashMap::new();
1✔
1729

1730
        // Access array elements
1731
        let value = eval_text(
1✔
1732
            "${load_yaml('tests/data/test_config.yaml')['joints'][0]}",
1✔
1733
            &props,
1✔
1734
        )
1735
        .expect("load_yaml array access should succeed");
1✔
1736

1737
        assert_eq!(value, "joint1", "first joint should be joint1");
1✔
1738
    }
1✔
1739

1740
    #[test]
1741
    fn test_load_yaml_deep_nesting() {
1✔
1742
        let props = HashMap::new();
1✔
1743

1744
        // Access deeply nested value
1745
        let value = eval_text(
1✔
1746
            "${load_yaml('tests/data/test_config.yaml')['nested']['level1']['level2']['value']}",
1✔
1747
            &props,
1✔
1748
        )
1749
        .expect("load_yaml deep nesting should succeed");
1✔
1750

1751
        assert_eq!(value, "deep_value", "deep nested value should match");
1✔
1752
    }
1✔
1753

1754
    #[test]
1755
    fn test_load_yaml_in_arithmetic() {
1✔
1756
        let props = HashMap::new();
1✔
1757

1758
        // Use loaded value in arithmetic expression
1759
        let value = eval_text(
1✔
1760
            "${load_yaml('tests/data/test_config.yaml')['robot']['wheel']['radius'] * 2}",
1✔
1761
            &props,
1✔
1762
        )
1763
        .expect("load_yaml in arithmetic should succeed");
1✔
1764

1765
        assert_eq!(value, "0.2", "radius * 2 should be 0.2");
1✔
1766
    }
1✔
1767

1768
    #[test]
1769
    fn test_load_yaml_multiple_calls() {
1✔
1770
        let props = HashMap::new();
1✔
1771

1772
        // Multiple load_yaml calls in same expression
1773
        let value = eval_text(
1✔
1774
            "${load_yaml('tests/data/test_config.yaml')['robot']['chassis']['length'] + \
1✔
1775
             load_yaml('tests/data/test_config.yaml')['robot']['chassis']['width']}",
1✔
1776
            &props,
1✔
1777
        )
1778
        .expect("multiple load_yaml calls should succeed");
1✔
1779

1780
        assert_eq!(value, "0.8", "0.5 + 0.3 should be 0.8");
1✔
1781
    }
1✔
1782

1783
    #[test]
1784
    fn test_load_yaml_extract_and_store() {
1✔
1785
        let mut props = HashMap::new();
1✔
1786
        let mut interp = init_interpreter();
1✔
1787

1788
        // Extract a specific value from YAML and store it
1789
        let wheel_base = eval_text_with_interpreter(
1✔
1790
            "${load_yaml('tests/data/test_config.yaml')['robot']['wheel']['base']}",
1✔
1791
            &props,
1✔
1792
            &mut interp,
1✔
1793
        )
1794
        .expect("load_yaml should succeed");
1✔
1795

1796
        // Store the extracted value
1797
        props.insert("wheel_base".to_string(), wheel_base);
1✔
1798

1799
        // Now use the stored value in calculations
1800
        let value = eval_text_with_interpreter("${wheel_base * 2}", &props, &mut interp)
1✔
1801
            .expect("stored value calculation should succeed");
1✔
1802

1803
        assert_eq!(value, "0.8", "wheel_base * 2 should be 0.8");
1✔
1804
    }
1✔
1805

1806
    #[test]
1807
    fn test_load_yaml_file_not_found() {
1✔
1808
        let props = HashMap::new();
1✔
1809

1810
        // Try to load non-existent file
1811
        let result = eval_text("${load_yaml('tests/data/nonexistent.yaml')}", &props);
1✔
1812

1813
        assert!(result.is_err(), "should error on missing file");
1✔
1814
        let err_msg = result.unwrap_err().to_string();
1✔
1815
        assert!(
1✔
1816
            err_msg.contains("Failed to load YAML") || err_msg.contains("No such file"),
1✔
NEW
1817
            "error should mention file loading failure, got: {}",
×
1818
            err_msg
1819
        );
1820
    }
1✔
1821

1822
    #[test]
1823
    fn test_load_yaml_invalid_yaml() {
1✔
1824
        use std::io::Write;
1825
        use tempfile::NamedTempFile;
1826

1827
        // Create temporary file with invalid YAML
1828
        let mut temp_file = NamedTempFile::new().expect("create temp file");
1✔
1829
        write!(temp_file, "invalid: yaml:\n  - bad\n  syntax").expect("write temp file");
1✔
1830
        let temp_path = temp_file.path().to_string_lossy().replace('\\', "/");
1✔
1831

1832
        let props = HashMap::new();
1✔
1833
        let result = eval_text(&format!("${{load_yaml('{}')}}", temp_path), &props);
1✔
1834

1835
        assert!(result.is_err(), "should error on invalid YAML");
1✔
1836
        let err_msg = result.unwrap_err().to_string();
1✔
1837
        assert!(
1✔
1838
            err_msg.contains("Failed to parse YAML") || err_msg.contains("parse"),
1✔
NEW
1839
            "error should mention YAML parsing failure, got: {}",
×
1840
            err_msg
1841
        );
1842
    }
1✔
1843

1844
    #[test]
1845
    fn test_load_yaml_with_property_filename() {
1✔
1846
        let mut props = HashMap::new();
1✔
1847
        props.insert(
1✔
1848
            "config_file".to_string(),
1✔
1849
            "tests/data/test_config.yaml".to_string(),
1✔
1850
        );
1851

1852
        // Variable filenames are now supported
1853
        let value = eval_text("${load_yaml(config_file)['count']}", &props)
1✔
1854
            .expect("variable filename should work");
1✔
1855

1856
        assert_eq!(value, "5", "count should be 5");
1✔
1857
    }
1✔
1858

1859
    #[test]
1860
    fn test_load_yaml_argument_with_parentheses_in_string() {
1✔
1861
        use std::io::Write;
1862
        use tempfile::Builder;
1863

1864
        let props = HashMap::new();
1✔
1865

1866
        // Create a temp YAML file whose path literal includes parentheses
1867
        // This truly tests that find_matching_paren handles parens in the argument
1868
        let mut temp = Builder::new()
1✔
1869
            .prefix("config(")
1✔
1870
            .suffix(").yaml")
1✔
1871
            .tempfile()
1✔
1872
            .expect("create temp yaml");
1✔
1873
        write!(temp, "robot:\n  chassis:\n    width: 0.3\n").expect("write temp yaml");
1✔
1874
        let path = temp.path().to_string_lossy().replace('\\', "/");
1✔
1875

1876
        // The fix ensures we use find_matching_paren instead of regex capture [^()]+?
1877
        // This allows proper handling when the argument contains parentheses
1878
        let expr = format!("${{load_yaml('{}')['robot']['chassis']['width']}}", path);
1✔
1879
        let value = eval_text(&expr, &props).expect("load_yaml argument parsing should succeed");
1✔
1880

1881
        assert_eq!(
1✔
1882
            value, "0.3",
NEW
1883
            "should correctly parse load_yaml argument even with potential paren complexity"
×
1884
        );
1885
    }
1✔
1886

1887
    #[test]
1888
    fn test_load_yaml_null_value() {
1✔
1889
        use std::io::Write;
1890
        use tempfile::NamedTempFile;
1891

1892
        let mut temp_file = NamedTempFile::new().expect("create temp file");
1✔
1893
        write!(temp_file, "~").expect("write temp file");
1✔
1894
        let temp_path = temp_file.path().to_string_lossy().replace('\\', "/");
1✔
1895

1896
        let props = HashMap::new();
1✔
1897
        let value = eval_text(&format!("${{load_yaml('{}') + 5}}", &temp_path), &props)
1✔
1898
            .expect("load_yaml with null should succeed");
1✔
1899
        assert_eq!(value, "5", "null (None) + 5 should be 5");
1✔
1900
    }
1✔
1901

1902
    #[test]
1903
    fn test_load_yaml_null_in_dict() {
1✔
1904
        use std::io::Write;
1905
        use tempfile::NamedTempFile;
1906

1907
        let mut temp_file = NamedTempFile::new().expect("create temp file");
1✔
1908
        write!(temp_file, "value: null\nother: 10").expect("write temp file");
1✔
1909
        let temp_path = temp_file.path().to_string_lossy().replace('\\', "/");
1✔
1910

1911
        let props = HashMap::new();
1✔
1912
        let value = eval_text(
1✔
1913
            &format!("${{load_yaml('{}')['value']}}", &temp_path),
1✔
1914
            &props,
1✔
1915
        )
1916
        .expect("load_yaml null value access should succeed");
1✔
1917
        assert_eq!(value, "0", "null value should evaluate to 0 (None)");
1✔
1918
    }
1✔
1919

1920
    #[test]
1921
    fn test_load_yaml_inf_nan_values() {
1✔
1922
        let props = HashMap::new();
1✔
1923

1924
        // Test positive infinity - evaluates to Python inf
1925
        let result = eval_text(
1✔
1926
            "${load_yaml('tests/data/test_inf_nan.yaml')['positive_inf']}",
1✔
1927
            &props,
1✔
1928
        );
1929
        // Python's float('inf') evaluates to inf, which is a valid value
1930
        assert!(
1✔
1931
            result.is_ok(),
1✔
1932
            "positive_inf should evaluate successfully, got: {:?}",
×
1933
            result
1934
        );
1935

1936
        // Test negative infinity
1937
        let result = eval_text(
1✔
1938
            "${load_yaml('tests/data/test_inf_nan.yaml')['negative_inf']}",
1✔
1939
            &props,
1✔
1940
        );
1941
        assert!(result.is_ok(), "negative_inf should evaluate successfully");
1✔
1942

1943
        // Test NaN
1944
        let result = eval_text(
1✔
1945
            "${load_yaml('tests/data/test_inf_nan.yaml')['not_a_number']}",
1✔
1946
            &props,
1✔
1947
        );
1948
        assert!(result.is_ok(), "not_a_number should evaluate successfully");
1✔
1949

1950
        // Test normal float still works
1951
        let value = eval_text(
1✔
1952
            "${load_yaml('tests/data/test_inf_nan.yaml')['normal_float']}",
1✔
1953
            &props,
1✔
1954
        )
1955
        .expect("normal_float should succeed");
1✔
1956
        assert_eq!(value, "3.14", "normal float should be '3.14'");
1✔
1957
    }
1✔
1958
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc