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

kaidokert / xacro / 20785770890

07 Jan 2026 03:01PM UTC coverage: 87.401%. First build
20785770890

Pull #27

github

web-flow
Merge 366b0665e into 8a27de1ef
Pull Request #27: Format whole numbers with .0 suffix to match Python xacro

52 of 53 new or added lines in 2 files covered. (98.11%)

1658 of 1897 relevant lines covered (87.4%)

169.0 hits per line

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

88.75
/src/utils/eval.rs
1
use crate::features::properties::BUILTIN_CONSTANTS;
2
use crate::utils::lexer::{Lexer, TokenType};
3
use pyisheval::{Interpreter, Value};
4
use std::collections::HashMap;
5

6
/// Initialize an Interpreter with builtin constants and math functions
7
///
8
/// This ensures all interpreters have access to:
9
/// - Math constants: pi, e, tau, M_PI
10
/// - Math functions: radians(), degrees()
11
///
12
/// Note: inf and nan are NOT initialized here - they are injected directly into
13
/// the context HashMap in build_pyisheval_context() to bypass parsing issues.
14
///
15
/// # Returns
16
/// A fully initialized Interpreter ready for expression evaluation
17
pub fn init_interpreter() -> Interpreter {
311✔
18
    let mut interp = Interpreter::new();
311✔
19

20
    // Initialize math constants in the interpreter
21
    // These are loaded directly into the interpreter's environment for use in expressions
22
    // Note: inf and nan are NOT in BUILTIN_CONSTANTS (pyisheval can't parse them as literals)
23
    // They are injected directly into the context map in build_pyisheval_context()
24
    for (name, value) in BUILTIN_CONSTANTS {
1,555✔
25
        if let Err(e) = interp.eval(&format!("{} = {}", name, value)) {
1,244✔
26
            log::warn!(
×
27
                "Could not initialize built-in constant '{}': {}. \
×
28
                 This constant will not be available in expressions.",
×
29
                name,
30
                e
31
            );
32
        }
1,244✔
33
    }
34

35
    // Add math conversion functions as lambda expressions directly in the interpreter
36
    // This makes them available as callable functions in all expressions
37
    if let Err(e) = interp.eval("radians = lambda x: x * pi / 180") {
311✔
38
        log::warn!(
×
39
            "Could not define built-in function 'radians': {}. \
×
40
             This function will not be available in expressions. \
×
41
             (May be due to missing 'pi' constant)",
×
42
            e
43
        );
44
    }
311✔
45
    if let Err(e) = interp.eval("degrees = lambda x: x * 180 / pi") {
311✔
46
        log::warn!(
×
47
            "Could not define built-in function 'degrees': {}. \
×
48
             This function will not be available in expressions. \
×
49
             (May be due to missing 'pi' constant)",
×
50
            e
51
        );
52
    }
311✔
53

54
    interp
311✔
55
}
311✔
56

57
#[derive(Debug, thiserror::Error)]
58
pub enum EvalError {
59
    #[error("Failed to evaluate expression '{expr}': {source}")]
60
    PyishEval {
61
        expr: String,
62
        #[source]
63
        source: pyisheval::EvalError,
64
    },
65

66
    #[error("Xacro conditional \"{condition}\" evaluated to \"{evaluated}\", which is not a boolean expression.")]
67
    InvalidBoolean {
68
        condition: String,
69
        evaluated: String,
70
    },
71
}
72

73
/// Format a pyisheval Value to match Python xacro's output format
74
///
75
/// Python always shows at least one decimal place for floats (e.g., "1.0" not "1"),
76
/// which is required for exact output matching.
77
fn format_value_python_style(value: &Value) -> String {
263✔
78
    match value {
189✔
79
        Value::Number(n) if n.is_finite() => {
189✔
80
            // Python's `str()` for floats switches to scientific notation at 1e16.
81
            // To align with this, we format whole numbers with `.0` below this threshold.
82
            const PYTHON_SCIENTIFIC_THRESHOLD: f64 = 1e16;
83

84
            if n.fract() == 0.0 && n.abs() < PYTHON_SCIENTIFIC_THRESHOLD {
164✔
85
                format!("{:.1}", n)
113✔
86
            } else {
87
                // Has fractional part or is a large number: use default formatting.
88
                n.to_string()
51✔
89
            }
90
        }
91
        _ => value.to_string(),
99✔
92
    }
93
}
263✔
94

95
/// Remove quotes from string values (handles both single and double quotes)
96
fn remove_quotes(s: &str) -> &str {
252✔
97
    // pyisheval's StringLit to_string() returns strings with single quotes
98
    if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
252✔
99
        if s.len() >= 2 {
×
100
            &s[1..s.len() - 1]
×
101
        } else {
102
            s
×
103
        }
104
    } else {
105
        s
252✔
106
    }
107
}
252✔
108

109
/// Evaluate text containing ${...} expressions
110
///
111
/// Examples:
112
///   "hello ${name}" with {name: "world"} → "hello world"
113
///   "${2 + 3}" → "5"
114
///   "${width * 2}" with {width: "0.5"} → "1"
115
pub fn eval_text(
28✔
116
    text: &str,
28✔
117
    properties: &HashMap<String, String>,
28✔
118
) -> Result<String, EvalError> {
28✔
119
    let mut interp = init_interpreter();
28✔
120
    eval_text_with_interpreter(text, properties, &mut interp)
28✔
121
}
28✔
122

123
/// Build a pyisheval context HashMap from properties
124
///
125
/// Converts string properties to pyisheval Values, parsing numbers when possible.
126
/// For lambda expressions, evaluates them to callable lambda values using the
127
/// provided interpreter. This ensures lambdas capture the correct environment.
128
///
129
/// # Arguments
130
/// * `properties` - Property name-value pairs to convert to pyisheval Values
131
/// * `interp` - The interpreter to use for evaluating lambda expressions
132
///
133
/// # Errors
134
/// Returns `EvalError` if a lambda expression fails to evaluate.
135
fn build_pyisheval_context(
413✔
136
    properties: &HashMap<String, String>,
413✔
137
    interp: &mut Interpreter,
413✔
138
) -> Result<HashMap<String, Value>, EvalError> {
413✔
139
    // First pass: Load all constants and non-lambda properties into the interpreter
140
    // This ensures that lambda expressions can reference them during evaluation
141
    // Note: We skip inf/nan/NaN as they can't be created via arithmetic in pyisheval
142
    // (10**400 creates inf but 0.0/0.0 fails with DivisionByZero)
143
    for (name, value) in properties.iter() {
413✔
144
        let trimmed = value.trim();
308✔
145
        if !trimmed.starts_with("lambda ") {
308✔
146
            // Load both numeric and string properties into interpreter
147
            if let Ok(num) = value.parse::<f64>() {
299✔
148
                // Special handling for inf/nan so lambdas can reference them
149
                if num.is_infinite() {
210✔
150
                    // Use 10**400 to create infinity (pyisheval can't parse "inf" literal)
151
                    let sign = if num.is_sign_negative() { "-" } else { "" };
9✔
152
                    let expr = format!("{} = {}10 ** 400", name, sign);
9✔
153
                    interp
9✔
154
                        .eval(&expr)
9✔
155
                        .map_err(|e| EvalError::PyishEval { expr, source: e })?;
9✔
156
                    continue;
9✔
157
                }
201✔
158
                if num.is_nan() {
201✔
159
                    // LIMITATION: Cannot create NaN in pyisheval (0.0/0.0 triggers DivisionByZero)
160
                    // Lambdas that reference NaN properties will fail with "undefined variable"
161
                    log::warn!(
4✔
162
                        "Property '{}' has NaN value, which cannot be loaded into interpreter. \
×
163
                         Lambda expressions referencing this property will fail.",
×
164
                        name
165
                    );
166
                    continue;
4✔
167
                }
197✔
168
                // Numeric property: load as number
169
                interp
197✔
170
                    .eval(&format!("{} = {}", name, num))
197✔
171
                    .map_err(|e| EvalError::PyishEval {
197✔
172
                        expr: format!("{} = {}", name, num),
×
173
                        source: e,
×
174
                    })?;
×
175
            } else if !value.is_empty() {
89✔
176
                // String property: load as quoted string literal
177
                // Skip empty strings as pyisheval can't parse ''
178
                // Escape backslashes first, then single quotes (order matters!)
179
                // This handles Windows paths (C:\Users), regex patterns, etc.
180
                let escaped_value = value.replace('\\', "\\\\").replace('\'', "\\'");
88✔
181
                interp
88✔
182
                    .eval(&format!("{} = '{}'", name, escaped_value))
88✔
183
                    .map_err(|e| EvalError::PyishEval {
88✔
184
                        expr: format!("{} = '{}'", name, escaped_value),
×
185
                        source: e,
×
186
                    })?;
×
187
            }
1✔
188
            // Empty strings are skipped in first pass (pyisheval can't handle '')
189
            // They'll be stored as Value::StringLit("") in the second pass
190
        }
9✔
191
    }
192

193
    // Second pass: Build the actual context, evaluating lambdas
194
    let mut context: HashMap<String, Value> = properties
413✔
195
        .iter()
413✔
196
        .map(|(name, value)| -> Result<(String, Value), EvalError> {
413✔
197
            // Try to parse as number first
198
            if let Ok(num) = value.parse::<f64>() {
308✔
199
                return Ok((name.clone(), Value::Number(num)));
210✔
200
            }
98✔
201

202
            // Check if it's a lambda expression
203
            let trimmed = value.trim();
98✔
204
            if trimmed.starts_with("lambda ") {
98✔
205
                // Evaluate and assign the lambda expression to the variable name
206
                // The interpreter now has all constants and properties loaded from first pass
207
                let assignment = format!("{} = {}", name, trimmed);
9✔
208
                interp.eval(&assignment).map_err(|e| EvalError::PyishEval {
9✔
209
                    expr: assignment.clone(),
×
210
                    source: e,
×
211
                })?;
×
212

213
                // Retrieve the lambda value to store in context
214
                let lambda_value = interp.eval(name).map_err(|e| EvalError::PyishEval {
9✔
215
                    expr: name.clone(),
×
216
                    source: e,
×
217
                })?;
×
218

219
                return Ok((name.clone(), lambda_value));
9✔
220
            }
89✔
221

222
            // Default: store as string literal
223
            Ok((name.clone(), Value::StringLit(value.clone())))
89✔
224
        })
308✔
225
        .collect::<Result<HashMap<_, _>, _>>()?;
413✔
226

227
    // Manually inject inf and nan constants (Strategy 3: bypass parsing)
228
    // Python xacro provides these via float('inf') and math.inf, but they're also
229
    // used as bare identifiers in expressions. Pyisheval cannot parse these as
230
    // literals, so we inject them directly into the context.
231
    context.insert("inf".to_string(), Value::Number(f64::INFINITY));
413✔
232
    context.insert("nan".to_string(), Value::Number(f64::NAN));
413✔
233

234
    Ok(context)
413✔
235
}
413✔
236

237
/// Evaluate text containing ${...} expressions using a provided interpreter
238
///
239
/// This version allows reusing an Interpreter instance for better performance
240
/// when processing multiple text blocks with the same properties context.
241
///
242
/// Takes a mutable reference to ensure lambdas are created in the same
243
/// interpreter context where they'll be evaluated.
244
pub fn eval_text_with_interpreter(
305✔
245
    text: &str,
305✔
246
    properties: &HashMap<String, String>,
305✔
247
    interp: &mut Interpreter,
305✔
248
) -> Result<String, EvalError> {
305✔
249
    // Build context for pyisheval (may fail if lambdas have errors)
250
    // This loads properties into the interpreter and evaluates lambda expressions
251
    let context = build_pyisheval_context(properties, interp)?;
305✔
252

253
    // Tokenize the input text
254
    let lexer = Lexer::new(text);
305✔
255
    let mut result = Vec::new();
305✔
256

257
    // Process each token
258
    for (token_type, token_value) in lexer {
629✔
259
        match token_type {
332✔
260
            TokenType::Text => {
69✔
261
                // Plain text, keep as-is
69✔
262
                result.push(token_value);
69✔
263
            }
69✔
264
            TokenType::Expr => {
265
                // ${expr} - evaluate using pyisheval
266
                match interp.eval_with_context(&token_value, &context) {
260✔
267
                    Ok(value) => {
252✔
268
                        let value_str = format_value_python_style(&value);
252✔
269
                        result.push(remove_quotes(&value_str).to_string());
252✔
270
                    }
252✔
271
                    Err(e) => {
8✔
272
                        return Err(EvalError::PyishEval {
8✔
273
                            expr: token_value.clone(),
8✔
274
                            source: e,
8✔
275
                        });
8✔
276
                    }
277
                }
278
            }
279
            TokenType::Extension => {
×
280
                // $(extension) - handle later (Phase 6)
×
281
                // For now, just keep the original text
×
282
                result.push(format!("$({})", token_value));
×
283
            }
×
284
            TokenType::DollarDollarBrace => {
3✔
285
                // $$ escape - output $ followed by the delimiter ({ or ()
3✔
286
                result.push(format!("${}", token_value));
3✔
287
            }
3✔
288
        }
289
    }
290

291
    Ok(result.join(""))
297✔
292
}
305✔
293

294
/// Apply Python xacro's STRICT string truthiness rules
295
///
296
/// Accepts: "true", "True", "false", "False", or parseable integers
297
/// Rejects: Everything else (including "nonsense", empty string, floats as strings)
298
fn apply_string_truthiness(
50✔
299
    s: &str,
50✔
300
    original: &str,
50✔
301
) -> Result<bool, EvalError> {
50✔
302
    let trimmed = s.trim();
50✔
303

304
    // Exact string matches for boolean literals
305
    if trimmed == "true" || trimmed == "True" {
50✔
306
        return Ok(true);
18✔
307
    }
32✔
308
    if trimmed == "false" || trimmed == "False" {
32✔
309
        return Ok(false);
11✔
310
    }
21✔
311

312
    // Try integer conversion (Python's bool(int(value)))
313
    if let Ok(i) = trimmed.parse::<i64>() {
21✔
314
        return Ok(i != 0);
14✔
315
    }
7✔
316

317
    // Try float conversion (for values like "1.0")
318
    if let Ok(f) = trimmed.parse::<f64>() {
7✔
NEW
319
        return Ok(f != 0.0);
×
320
    }
7✔
321

322
    // Everything else is an error (STRICT mode)
323
    Err(EvalError::InvalidBoolean {
7✔
324
        condition: original.to_string(),
7✔
325
        evaluated: s.to_string(),
7✔
326
    })
7✔
327
}
50✔
328

329
/// Evaluate expression as boolean following Python xacro's STRICT rules
330
///
331
/// Python xacro's get_boolean_value() logic (ref/xacro/src/xacro/__init__.py:856):
332
/// - Accepts: "true", "True", "false", "False"
333
/// - Accepts: Any string convertible to int: "1", "0", "42", "-5"
334
/// - REJECTS: "nonsense", empty string, anything else → Error
335
///
336
/// CRITICAL: This preserves type information from pyisheval!
337
/// ${3*0.1} evaluates to float 0.3 (truthy), NOT string "0.3" (would error)
338
///
339
/// Examples:
340
///   eval_boolean("true", &props) → Ok(true)
341
///   eval_boolean("${3*0.1}", &props) → Ok(true)  // Float 0.3 != 0.0
342
///   eval_boolean("${0}", &props) → Ok(false)     // Integer 0
343
///   eval_boolean("nonsense", &props) → Err(InvalidBoolean)
344
pub fn eval_boolean(
107✔
345
    text: &str,
107✔
346
    properties: &HashMap<String, String>,
107✔
347
) -> Result<bool, EvalError> {
107✔
348
    let mut interp = init_interpreter();
107✔
349

350
    // Build context for pyisheval (may fail if lambdas have errors)
351
    let context = build_pyisheval_context(properties, &mut interp)?;
107✔
352

353
    // Tokenize input to detect structure
354
    let lexer = Lexer::new(text);
107✔
355
    let tokens: Vec<_> = lexer.collect();
107✔
356

357
    // CASE 1: Single ${expr} token → Preserve type, apply truthiness on Value
358
    // This is CRITICAL for float truthiness: ${3*0.1} → float 0.3 → true
359
    if tokens.len() == 1 && tokens[0].0 == TokenType::Expr {
107✔
360
        let value = interp
61✔
361
            .eval_with_context(&tokens[0].1, &context)
61✔
362
            .map_err(|e| EvalError::PyishEval {
61✔
363
                expr: text.to_string(),
×
364
                source: e,
×
365
            })?;
×
366

367
        // Apply Python truthiness based on Value type
368
        return match value {
61✔
369
            Value::Number(n) => Ok(n != 0.0), // Float/int truthiness (includes bools: True=1.0, False=0.0)
57✔
370
            Value::StringLit(s) => {
4✔
371
                // String: must be "true"/"false" or parseable as int
372
                apply_string_truthiness(&s, text)
4✔
373
            }
374
            // Other types (Lambda, List, etc.) - error for now
375
            _ => Err(EvalError::InvalidBoolean {
×
376
                condition: text.to_string(),
×
377
                evaluated: format!("{:?}", value),
×
378
            }),
×
379
        };
380
    }
46✔
381

382
    // CASE 2: Multiple tokens or plain text → Evaluate to string, then parse
383
    // Example: "text ${expr} more" or just "true"
384
    let evaluated = eval_text_with_interpreter(text, properties, &mut interp)?;
46✔
385
    apply_string_truthiness(&evaluated, text)
46✔
386
}
107✔
387

388
#[cfg(test)]
389
mod tests {
390
    use super::*;
391

392
    // TEST 1: Backward compatibility - simple property substitution
393
    #[test]
394
    fn test_simple_property_substitution() {
1✔
395
        let mut props = HashMap::new();
1✔
396
        props.insert("width".to_string(), "0.5".to_string());
1✔
397

398
        let result = eval_text("${width}", &props).unwrap();
1✔
399
        assert_eq!(result, "0.5");
1✔
400
    }
1✔
401

402
    // TEST 2: Property in text
403
    #[test]
404
    fn test_property_in_text() {
1✔
405
        let mut props = HashMap::new();
1✔
406
        props.insert("width".to_string(), "0.5".to_string());
1✔
407

408
        let result = eval_text("The width is ${width} meters", &props).unwrap();
1✔
409
        assert_eq!(result, "The width is 0.5 meters");
1✔
410
    }
1✔
411

412
    // TEST 3: Multiple properties
413
    #[test]
414
    fn test_multiple_properties() {
1✔
415
        let mut props = HashMap::new();
1✔
416
        props.insert("width".to_string(), "0.5".to_string());
1✔
417
        props.insert("height".to_string(), "1.0".to_string());
1✔
418

419
        let result = eval_text("${width} x ${height}", &props).unwrap();
1✔
420
        // Note: pyisheval formats 1.0 as "1" since it's an integer value
421
        assert_eq!(result, "0.5 x 1.0");
1✔
422
    }
1✔
423

424
    // TEST 4: NEW - Simple arithmetic
425
    #[test]
426
    fn test_arithmetic_expression() {
1✔
427
        let mut props = HashMap::new();
1✔
428
        props.insert("width".to_string(), "0.5".to_string());
1✔
429

430
        let result = eval_text("${width * 2}", &props).unwrap();
1✔
431
        assert_eq!(result, "1.0");
1✔
432
    }
1✔
433

434
    // TEST 5: NEW - Arithmetic without properties
435
    #[test]
436
    fn test_pure_arithmetic() {
1✔
437
        let props = HashMap::new();
1✔
438

439
        let result = eval_text("${2 + 3}", &props).unwrap();
1✔
440
        assert_eq!(result, "5.0");
1✔
441
    }
1✔
442

443
    // TEST 6: NEW - Complex expression
444
    #[test]
445
    fn test_complex_expression() {
1✔
446
        let mut props = HashMap::new();
1✔
447
        props.insert("width".to_string(), "0.5".to_string());
1✔
448
        props.insert("height".to_string(), "2.0".to_string());
1✔
449

450
        let result = eval_text("${width * height + 1}", &props).unwrap();
1✔
451
        assert_eq!(result, "2.0");
1✔
452
    }
1✔
453

454
    // TEST 7: NEW - String concatenation with literals
455
    // Note: pyisheval doesn't currently support string concatenation with +
456
    // This is documented as a known limitation. Use property substitution instead.
457
    #[test]
458
    #[ignore]
459
    fn test_string_concatenation() {
×
460
        let props = HashMap::new();
×
461

462
        // String concatenation with string literals (quoted in expression)
463
        let result = eval_text("${'link' + '_' + 'base'}", &props).unwrap();
×
464
        assert_eq!(result, "link_base");
×
465
    }
×
466

467
    // TEST 8: NEW - Built-in functions
468
    #[test]
469
    fn test_builtin_functions() {
1✔
470
        let props = HashMap::new();
1✔
471

472
        let result = eval_text("${abs(-5)}", &props).unwrap();
1✔
473
        assert_eq!(result, "5.0");
1✔
474

475
        let result = eval_text("${max(2, 5, 3)}", &props).unwrap();
1✔
476
        assert_eq!(result, "5.0");
1✔
477
    }
1✔
478

479
    // TEST 9: NEW - Conditional expressions
480
    #[test]
481
    fn test_conditional_expression() {
1✔
482
        let mut props = HashMap::new();
1✔
483
        props.insert("width".to_string(), "0.5".to_string());
1✔
484

485
        let result = eval_text("${width if width > 0.3 else 0.3}", &props).unwrap();
1✔
486
        assert_eq!(result, "0.5");
1✔
487
    }
1✔
488

489
    // TEST 10: Text without expressions (pass through)
490
    #[test]
491
    fn test_no_expressions() {
1✔
492
        let props = HashMap::new();
1✔
493
        let result = eval_text("hello world", &props).unwrap();
1✔
494
        assert_eq!(result, "hello world");
1✔
495
    }
1✔
496

497
    // TEST 11: Empty string
498
    #[test]
499
    fn test_empty_string() {
1✔
500
        let props = HashMap::new();
1✔
501
        let result = eval_text("", &props).unwrap();
1✔
502
        assert_eq!(result, "");
1✔
503
    }
1✔
504

505
    // TEST 12: Error case - undefined property
506
    #[test]
507
    fn test_undefined_property() {
1✔
508
        let props = HashMap::new();
1✔
509
        let result = eval_text("${undefined}", &props);
1✔
510
        assert!(result.is_err());
1✔
511
    }
1✔
512

513
    // TEST 13: String property substitution (non-numeric values)
514
    #[test]
515
    fn test_string_property() {
1✔
516
        let mut props = HashMap::new();
1✔
517
        props.insert("link_name".to_string(), "base_link".to_string());
1✔
518
        props.insert("joint_type".to_string(), "revolute".to_string());
1✔
519

520
        // Test single property
521
        let result = eval_text("${link_name}", &props).unwrap();
1✔
522
        assert_eq!(result, "base_link");
1✔
523

524
        // Test property in text
525
        let result = eval_text("name_${link_name}_suffix", &props).unwrap();
1✔
526
        assert_eq!(result, "name_base_link_suffix");
1✔
527

528
        // Test multiple string properties
529
        let result = eval_text("${link_name} ${joint_type}", &props).unwrap();
1✔
530
        assert_eq!(result, "base_link revolute");
1✔
531
    }
1✔
532

533
    #[test]
534
    fn test_double_dollar_escape() {
1✔
535
        let props = HashMap::new();
1✔
536

537
        // Test $$ escape with brace - should produce literal ${
538
        let result = eval_text("$${expr}", &props).unwrap();
1✔
539
        assert_eq!(result, "${expr}");
1✔
540

541
        // Test $$ escape with paren - should produce literal $(
542
        let result = eval_text("$$(command)", &props).unwrap();
1✔
543
        assert_eq!(result, "$(command)");
1✔
544

545
        // Test $$ escape in context
546
        let result = eval_text("prefix_$${literal}_suffix", &props).unwrap();
1✔
547
        assert_eq!(result, "prefix_${literal}_suffix");
1✔
548
    }
1✔
549

550
    // ===== NEW TESTS FOR eval_boolean =====
551

552
    // Test from Python xacro: test_boolean_if_statement (line 715)
553
    #[test]
554
    fn test_eval_boolean_literals() {
1✔
555
        let props = HashMap::new();
1✔
556

557
        // Boolean string literals
558
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
559
        assert_eq!(eval_boolean("false", &props).unwrap(), false);
1✔
560
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
561
        assert_eq!(eval_boolean("False", &props).unwrap(), false);
1✔
562
    }
1✔
563

564
    // Test from Python xacro: test_integer_if_statement (line 735)
565
    #[test]
566
    fn test_eval_boolean_integer_truthiness() {
1✔
567
        let props = HashMap::new();
1✔
568

569
        // Integer literals as strings
570
        assert_eq!(eval_boolean("0", &props).unwrap(), false);
1✔
571
        assert_eq!(eval_boolean("1", &props).unwrap(), true);
1✔
572
        assert_eq!(eval_boolean("42", &props).unwrap(), true);
1✔
573
        assert_eq!(eval_boolean("-5", &props).unwrap(), true);
1✔
574

575
        // Integer expressions
576
        assert_eq!(eval_boolean("${0*42}", &props).unwrap(), false); // 0
1✔
577
        assert_eq!(eval_boolean("${0}", &props).unwrap(), false);
1✔
578
        assert_eq!(eval_boolean("${1*2+3}", &props).unwrap(), true); // 5
1✔
579
    }
1✔
580

581
    // Test from Python xacro: test_float_if_statement (line 755)
582
    #[test]
583
    fn test_eval_boolean_float_truthiness() {
1✔
584
        let props = HashMap::new();
1✔
585

586
        // CRITICAL: Float expressions must preserve type
587
        assert_eq!(eval_boolean("${3*0.0}", &props).unwrap(), false); // 0.0
1✔
588
        assert_eq!(eval_boolean("${3*0.1}", &props).unwrap(), true); // 0.3 (non-zero float)
1✔
589
        assert_eq!(eval_boolean("${0.5}", &props).unwrap(), true);
1✔
590
        assert_eq!(eval_boolean("${-0.1}", &props).unwrap(), true);
1✔
591
    }
1✔
592

593
    // Test from Python xacro: test_property_if_statement (line 769)
594
    #[test]
595
    fn test_eval_boolean_with_properties() {
1✔
596
        let mut props = HashMap::new();
1✔
597
        props.insert("condT".to_string(), "1".to_string()); // True as number
1✔
598
        props.insert("condF".to_string(), "0".to_string()); // False as number
1✔
599
        props.insert("num".to_string(), "5".to_string());
1✔
600

601
        assert_eq!(eval_boolean("${condT}", &props).unwrap(), true);
1✔
602
        assert_eq!(eval_boolean("${condF}", &props).unwrap(), false);
1✔
603
        assert_eq!(eval_boolean("${num}", &props).unwrap(), true); // 5 != 0
1✔
604

605
        // Note: pyisheval doesn't have True/False as built-in constants
606
        // They would need to be defined as properties with value 1/0
607
    }
1✔
608

609
    // Test from Python xacro: test_equality_expression_in_if_statement (line 788)
610
    #[test]
611
    fn test_eval_boolean_expressions() {
1✔
612
        let mut props = HashMap::new();
1✔
613
        props.insert("var".to_string(), "useit".to_string());
1✔
614

615
        // Equality
616
        assert_eq!(eval_boolean("${var == 'useit'}", &props).unwrap(), true);
1✔
617
        assert_eq!(eval_boolean("${var == 'other'}", &props).unwrap(), false);
1✔
618

619
        // Comparison
620
        props.insert("x".to_string(), "5".to_string());
1✔
621
        assert_eq!(eval_boolean("${x > 3}", &props).unwrap(), true);
1✔
622
        assert_eq!(eval_boolean("${x < 3}", &props).unwrap(), false);
1✔
623

624
        // Note: pyisheval doesn't support 'in' operator for strings yet
625
        // That would require extending pyisheval or using a different evaluator
626
    }
1✔
627

628
    /// Test that pyisheval returns Value::Number for boolean expressions
629
    ///
630
    /// CRITICAL: This test documents that pyisheval v0.9.0 does NOT have Value::Bool.
631
    /// Boolean comparison expressions like ${1 == 1} return Value::Number(1.0), not Value::Bool(true).
632
    /// This is similar to Python where bool is a subclass of int (True == 1, False == 0).
633
    ///
634
    /// This test exists to:
635
    /// 1. Verify our Number-based truthiness handling works for comparisons
636
    /// 2. Document pyisheval's current behavior
637
    /// 3. Catch if pyisheval adds Value::Bool in future (this would fail, prompting us to update)
638
    #[test]
639
    fn test_eval_boolean_comparison_expressions() {
1✔
640
        let mut props = HashMap::new();
1✔
641
        props.insert("x".to_string(), "5".to_string());
1✔
642
        props.insert("y".to_string(), "10".to_string());
1✔
643

644
        // Equality comparisons
645
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
646
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
647
        assert_eq!(eval_boolean("${x == 5}", &props).unwrap(), true);
1✔
648
        assert_eq!(eval_boolean("${x == y}", &props).unwrap(), false);
1✔
649

650
        // Inequality comparisons
651
        assert_eq!(eval_boolean("${1 != 2}", &props).unwrap(), true);
1✔
652
        assert_eq!(eval_boolean("${1 != 1}", &props).unwrap(), false);
1✔
653

654
        // Less than / greater than
655
        assert_eq!(eval_boolean("${x < y}", &props).unwrap(), true);
1✔
656
        assert_eq!(eval_boolean("${x > y}", &props).unwrap(), false);
1✔
657
        assert_eq!(eval_boolean("${x <= 5}", &props).unwrap(), true);
1✔
658
        assert_eq!(eval_boolean("${y >= 10}", &props).unwrap(), true);
1✔
659

660
        // NOTE: pyisheval v0.9.0 does NOT support `and`/`or` operators
661
        // These would fail: ${1 and 1}, ${x > 3 and y < 15}
662
        // Fortunately, real xacro files don't use these - they use simpler expressions
663

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

668
    // Test from Python xacro: test_invalid_if_statement (line 729)
669
    #[test]
670
    fn test_eval_boolean_invalid_values() {
1✔
671
        let props = HashMap::new();
1✔
672

673
        // STRICT mode: "nonsense" should error
674
        let result = eval_boolean("nonsense", &props);
1✔
675
        assert!(result.is_err());
1✔
676
        assert!(result
1✔
677
            .unwrap_err()
1✔
678
            .to_string()
1✔
679
            .contains("not a boolean expression"));
1✔
680

681
        // Empty string should error
682
        let result = eval_boolean("", &props);
1✔
683
        assert!(result.is_err());
1✔
684

685
        // Random text should error
686
        let result = eval_boolean("random text", &props);
1✔
687
        assert!(result.is_err());
1✔
688
    }
1✔
689

690
    // Test edge case: whitespace handling
691
    #[test]
692
    fn test_eval_boolean_whitespace() {
1✔
693
        let props = HashMap::new();
1✔
694

695
        // Should trim whitespace
696
        assert_eq!(eval_boolean(" true ", &props).unwrap(), true);
1✔
697
        assert_eq!(eval_boolean("\tfalse\n", &props).unwrap(), false);
1✔
698
        assert_eq!(eval_boolean("  0  ", &props).unwrap(), false);
1✔
699
        assert_eq!(eval_boolean("  1  ", &props).unwrap(), true);
1✔
700
    }
1✔
701

702
    // Test case sensitivity
703
    #[test]
704
    fn test_eval_boolean_case_sensitivity() {
1✔
705
        let props = HashMap::new();
1✔
706

707
        // "true" and "True" are accepted
708
        assert_eq!(eval_boolean("true", &props).unwrap(), true);
1✔
709
        assert_eq!(eval_boolean("True", &props).unwrap(), true);
1✔
710

711
        // But not other cases (should error)
712
        assert!(eval_boolean("TRUE", &props).is_err());
1✔
713
        assert!(eval_boolean("tRuE", &props).is_err());
1✔
714
    }
1✔
715

716
    // Test that inf and nan are available via direct context injection
717
    #[test]
718
    fn test_inf_nan_direct_injection() {
1✔
719
        let props = HashMap::new();
1✔
720
        let mut interp = init_interpreter();
1✔
721

722
        // Build context with direct inf/nan injection
723
        let context = build_pyisheval_context(&props, &mut interp).unwrap();
1✔
724

725
        // Verify inf and nan are in the context
726
        assert!(
1✔
727
            context.contains_key("inf"),
1✔
728
            "Context should contain 'inf' key"
×
729
        );
730
        assert!(
1✔
731
            context.contains_key("nan"),
1✔
732
            "Context should contain 'nan' key"
×
733
        );
734

735
        // Test 1: inf should be positive infinity
736
        if let Some(Value::Number(n)) = context.get("inf") {
1✔
737
            assert!(
1✔
738
                n.is_infinite() && n.is_sign_positive(),
1✔
739
                "inf should be positive infinity, got: {}",
×
740
                n
741
            );
742
        } else {
743
            panic!("inf should be a Number value");
×
744
        }
745

746
        // Test 2: nan should be NaN
747
        if let Some(Value::Number(n)) = context.get("nan") {
1✔
748
            assert!(n.is_nan(), "nan should be NaN, got: {}", n);
1✔
749
        } else {
750
            panic!("nan should be a Number value");
×
751
        }
752

753
        // Test 3: inf should be usable in expressions
754
        let result = interp.eval_with_context("inf * 2", &context);
1✔
755
        assert!(
1✔
756
            matches!(result, Ok(Value::Number(n)) if n.is_infinite() && n.is_sign_positive()),
1✔
757
            "inf * 2 should return positive infinity, got: {:?}",
×
758
            result
759
        );
760

761
        // Test 4: nan should be usable in expressions
762
        let result = interp.eval_with_context("nan + 1", &context);
1✔
763
        assert!(
1✔
764
            matches!(result, Ok(Value::Number(n)) if n.is_nan()),
1✔
765
            "nan + 1 should return NaN, got: {:?}",
×
766
            result
767
        );
768
    }
1✔
769

770
    // Test type preservation: the key feature!
771
    #[test]
772
    fn test_eval_boolean_type_preservation() {
1✔
773
        let props = HashMap::new();
1✔
774

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

779
        // Multiple tokens: becomes string
780
        // "result: ${3*0.1}" → "result: 0.3" → can't parse as int → error
781
        let result = eval_boolean("result: ${3*0.1}", &props);
1✔
782
        assert!(result.is_err());
1✔
783
    }
1✔
784

785
    // Test Boolean value type from pyisheval
786
    #[test]
787
    fn test_eval_boolean_bool_values() {
1✔
788
        let props = HashMap::new();
1✔
789

790
        // pyisheval returns Value::Bool directly
791
        assert_eq!(eval_boolean("${1 == 1}", &props).unwrap(), true);
1✔
792
        assert_eq!(eval_boolean("${1 == 2}", &props).unwrap(), false);
1✔
793
        assert_eq!(eval_boolean("${5 > 3}", &props).unwrap(), true);
1✔
794
    }
1✔
795

796
    // Lambda expression tests
797
    #[test]
798
    fn test_basic_lambda_works() {
1✔
799
        let mut props = HashMap::new();
1✔
800
        props.insert("f".to_string(), "lambda x: x * 2".to_string());
1✔
801
        assert_eq!(eval_text("${f(5)}", &props).unwrap(), "10.0");
1✔
802
    }
1✔
803

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

808
    // Python-style number formatting tests
809
    #[test]
810
    fn test_format_value_python_style_whole_numbers() {
1✔
811
        use pyisheval::Value;
812

813
        // Whole numbers should always have .0
814
        assert_eq!(format_value_python_style(&Value::Number(0.0)), "0.0");
1✔
815
        assert_eq!(format_value_python_style(&Value::Number(1.0)), "1.0");
1✔
816
        assert_eq!(format_value_python_style(&Value::Number(2.0)), "2.0");
1✔
817
        assert_eq!(format_value_python_style(&Value::Number(-1.0)), "-1.0");
1✔
818
        assert_eq!(format_value_python_style(&Value::Number(100.0)), "100.0");
1✔
819
    }
1✔
820

821
    #[test]
822
    fn test_format_value_python_style_fractional() {
1✔
823
        use pyisheval::Value;
824

825
        // Fractional numbers use default formatting (no trailing zeros)
826
        assert_eq!(format_value_python_style(&Value::Number(1.5)), "1.5");
1✔
827
        assert_eq!(format_value_python_style(&Value::Number(0.5)), "0.5");
1✔
828
        assert_eq!(
1✔
829
            format_value_python_style(&Value::Number(0.4235294117647059)),
1✔
830
            "0.4235294117647059"
831
        );
832
    }
1✔
833

834
    #[test]
835
    fn test_format_value_python_style_special() {
1✔
836
        use pyisheval::Value;
837

838
        // Special values
839
        assert_eq!(
1✔
840
            format_value_python_style(&Value::Number(f64::INFINITY)),
1✔
841
            "inf"
842
        );
843
        assert_eq!(
1✔
844
            format_value_python_style(&Value::Number(f64::NEG_INFINITY)),
1✔
845
            "-inf"
846
        );
847
        assert_eq!(format_value_python_style(&Value::Number(f64::NAN)), "NaN");
1✔
848
    }
1✔
849

850
    #[test]
851
    fn test_eval_with_python_number_formatting() {
1✔
852
        let mut props = HashMap::new();
1✔
853
        props.insert("height".to_string(), "1.0".to_string());
1✔
854

855
        // Should output "1.0" not "1"
856
        assert_eq!(eval_text("${height}", &props).unwrap(), "1.0");
1✔
857
        assert_eq!(eval_text("${1.0 + 0.0}", &props).unwrap(), "1.0");
1✔
858
        assert_eq!(eval_text("${2.0 * 1.0}", &props).unwrap(), "2.0");
1✔
859
    }
1✔
860

861
    #[test]
862
    fn test_lambda_referencing_property() {
1✔
863
        let mut props = HashMap::new();
1✔
864
        props.insert("offset".to_string(), "10".to_string());
1✔
865
        props.insert("add_offset".to_string(), "lambda x: x + offset".to_string());
1✔
866
        assert_eq!(eval_text("${add_offset(5)}", &props).unwrap(), "15.0");
1✔
867
    }
1✔
868

869
    #[test]
870
    fn test_lambda_referencing_multiple_properties() {
1✔
871
        let mut props = HashMap::new();
1✔
872
        props.insert("a".to_string(), "2".to_string());
1✔
873
        props.insert("b".to_string(), "3".to_string());
1✔
874
        props.insert("scale".to_string(), "lambda x: x * a + b".to_string());
1✔
875
        assert_eq!(eval_text("${scale(5)}", &props).unwrap(), "13.0");
1✔
876
    }
1✔
877

878
    #[test]
879
    fn test_lambda_with_conditional() {
1✔
880
        let mut props = HashMap::new();
1✔
881
        props.insert(
1✔
882
            "sign".to_string(),
1✔
883
            "lambda x: 1 if x > 0 else -1".to_string(),
1✔
884
        );
885
        assert_eq!(eval_text("${sign(5)}", &props).unwrap(), "1.0");
1✔
886
        assert_eq!(eval_text("${sign(-3)}", &props).unwrap(), "-1.0");
1✔
887
    }
1✔
888

889
    #[test]
890
    fn test_multiple_lambdas() {
1✔
891
        let mut props = HashMap::new();
1✔
892
        props.insert("double".to_string(), "lambda x: x * 2".to_string());
1✔
893
        props.insert("triple".to_string(), "lambda x: x * 3".to_string());
1✔
894
        assert_eq!(
1✔
895
            eval_text("${double(5)} ${triple(5)}", &props).unwrap(),
1✔
896
            "10.0 15.0"
897
        );
898
    }
1✔
899

900
    #[test]
901
    fn test_lambda_referencing_inf_property() {
1✔
902
        let mut props = HashMap::new();
1✔
903
        props.insert("my_inf".to_string(), "inf".to_string());
1✔
904
        props.insert("is_inf".to_string(), "lambda x: x == my_inf".to_string());
1✔
905
        // inf == inf should be true (1)
906
        assert_eq!(eval_text("${is_inf(inf)}", &props).unwrap(), "1.0");
1✔
907
    }
1✔
908
}
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