Skip to main content

automapper_validation/expr/
parser.rs

1//! Recursive descent parser for condition expressions.
2//!
3//! Grammar (from lowest to highest precedence):
4//!
5//! ```text
6//! expression  = xor_expr
7//! xor_expr    = or_expr (XOR or_expr)*
8//! or_expr     = and_expr (OR and_expr)*
9//! and_expr    = not_expr ((AND | implicit) not_expr)*
10//! not_expr    = NOT not_expr | primary
11//! primary     = CONDITION_ID | '(' expression ')'
12//! ```
13//!
14//! Implicit AND: two adjacent condition references or a condition followed by
15//! `(` without an intervening operator are treated as AND.
16
17use std::collections::HashMap;
18
19use super::ast::ConditionExpr;
20use super::token::{strip_status_prefix, tokenize, SpannedToken, Token};
21use crate::error::ParseError;
22
23/// Parser for AHB condition expressions.
24pub struct ConditionParser;
25
26impl ConditionParser {
27    /// Parse an AHB status string into a condition expression.
28    ///
29    /// Returns `Ok(None)` if the input contains no condition references
30    /// (e.g., bare `"Muss"` or empty string).
31    ///
32    /// # Examples
33    ///
34    /// ```
35    /// use automapper_validation::expr::ConditionParser;
36    /// use automapper_validation::expr::ConditionExpr;
37    ///
38    /// let expr = ConditionParser::parse("Muss [494]").unwrap().unwrap();
39    /// assert_eq!(expr, ConditionExpr::Ref(494));
40    /// ```
41    pub fn parse(input: &str) -> Result<Option<ConditionExpr>, ParseError> {
42        Self::parse_with_ub(input, &HashMap::new())
43    }
44
45    /// Parse an AHB status string, expanding UB condition references inline.
46    ///
47    /// When `[UB1]` is encountered and `ub_definitions` contains "UB1", the
48    /// corresponding pre-parsed expression is substituted. Unknown UB references
49    /// fall back to the normal `parse_condition_id` behavior.
50    pub fn parse_with_ub(
51        input: &str,
52        ub_definitions: &HashMap<String, ConditionExpr>,
53    ) -> Result<Option<ConditionExpr>, ParseError> {
54        let input = input.trim();
55        if input.is_empty() {
56            return Ok(None);
57        }
58
59        let stripped = strip_status_prefix(input);
60        if stripped.is_empty() {
61            return Ok(None);
62        }
63
64        let tokens = tokenize(stripped)?;
65        if tokens.is_empty() {
66            return Ok(None);
67        }
68
69        let mut pos = 0;
70        let expr = parse_expression(&tokens, &mut pos, ub_definitions)?;
71
72        Ok(expr)
73    }
74
75    /// Parse an expression that is known to contain conditions (no prefix stripping).
76    ///
77    /// Returns `Err` if the input cannot be parsed. Returns `Ok(None)` if empty.
78    pub fn parse_raw(input: &str) -> Result<Option<ConditionExpr>, ParseError> {
79        let input = input.trim();
80        if input.is_empty() {
81            return Ok(None);
82        }
83
84        let tokens = tokenize(input)?;
85        if tokens.is_empty() {
86            return Ok(None);
87        }
88
89        let mut pos = 0;
90        let expr = parse_expression(&tokens, &mut pos, &HashMap::new())?;
91
92        Ok(expr)
93    }
94}
95
96/// Parse a full expression (entry point for precedence climbing).
97fn parse_expression(
98    tokens: &[SpannedToken],
99    pos: &mut usize,
100    ub_definitions: &HashMap<String, ConditionExpr>,
101) -> Result<Option<ConditionExpr>, ParseError> {
102    parse_xor(tokens, pos, ub_definitions)
103}
104
105/// XOR has the lowest precedence.
106fn parse_xor(
107    tokens: &[SpannedToken],
108    pos: &mut usize,
109    ub_definitions: &HashMap<String, ConditionExpr>,
110) -> Result<Option<ConditionExpr>, ParseError> {
111    let mut left = match parse_or(tokens, pos, ub_definitions)? {
112        Some(expr) => expr,
113        None => return Ok(None),
114    };
115
116    while *pos < tokens.len() && tokens[*pos].token == Token::Xor {
117        *pos += 1; // consume XOR
118        let right = match parse_or(tokens, pos, ub_definitions)? {
119            Some(expr) => expr,
120            None => return Ok(Some(left)),
121        };
122        left = ConditionExpr::Xor(Box::new(left), Box::new(right));
123    }
124
125    Ok(Some(left))
126}
127
128/// OR has middle-low precedence.
129fn parse_or(
130    tokens: &[SpannedToken],
131    pos: &mut usize,
132    ub_definitions: &HashMap<String, ConditionExpr>,
133) -> Result<Option<ConditionExpr>, ParseError> {
134    let mut left = match parse_and(tokens, pos, ub_definitions)? {
135        Some(expr) => expr,
136        None => return Ok(None),
137    };
138
139    while *pos < tokens.len() && tokens[*pos].token == Token::Or {
140        *pos += 1; // consume OR
141        let right = match parse_and(tokens, pos, ub_definitions)? {
142            Some(expr) => expr,
143            None => return Ok(Some(left)),
144        };
145        // Flatten nested ORs into a single Or(vec![...])
146        left = match left {
147            ConditionExpr::Or(mut exprs) => {
148                exprs.push(right);
149                ConditionExpr::Or(exprs)
150            }
151            _ => ConditionExpr::Or(vec![left, right]),
152        };
153    }
154
155    Ok(Some(left))
156}
157
158/// AND has middle-high precedence. Also handles implicit AND between adjacent
159/// conditions or parenthesized groups.
160fn parse_and(
161    tokens: &[SpannedToken],
162    pos: &mut usize,
163    ub_definitions: &HashMap<String, ConditionExpr>,
164) -> Result<Option<ConditionExpr>, ParseError> {
165    let mut left = match parse_not(tokens, pos, ub_definitions)? {
166        Some(expr) => expr,
167        None => return Ok(None),
168    };
169
170    while *pos < tokens.len() {
171        if tokens[*pos].token == Token::And {
172            *pos += 1; // consume explicit AND
173            let right = match parse_not(tokens, pos, ub_definitions)? {
174                Some(expr) => expr,
175                None => return Ok(Some(left)),
176            };
177            left = flatten_and(left, right);
178        } else if matches!(
179            tokens[*pos].token,
180            Token::ConditionId(_) | Token::LeftParen | Token::Not
181        ) {
182            // Implicit AND: adjacent condition, paren, or NOT without operator
183            let right = match parse_not(tokens, pos, ub_definitions)? {
184                Some(expr) => expr,
185                None => return Ok(Some(left)),
186            };
187            left = flatten_and(left, right);
188        } else {
189            break;
190        }
191    }
192
193    Ok(Some(left))
194}
195
196/// Flatten nested ANDs into a single And(vec![...]).
197fn flatten_and(left: ConditionExpr, right: ConditionExpr) -> ConditionExpr {
198    match left {
199        ConditionExpr::And(mut exprs) => {
200            exprs.push(right);
201            ConditionExpr::And(exprs)
202        }
203        _ => ConditionExpr::And(vec![left, right]),
204    }
205}
206
207/// NOT has the highest precedence (unary prefix).
208fn parse_not(
209    tokens: &[SpannedToken],
210    pos: &mut usize,
211    ub_definitions: &HashMap<String, ConditionExpr>,
212) -> Result<Option<ConditionExpr>, ParseError> {
213    if *pos < tokens.len() && tokens[*pos].token == Token::Not {
214        *pos += 1; // consume NOT
215        let inner = match parse_not(tokens, pos, ub_definitions)? {
216            Some(expr) => expr,
217            None => {
218                return Err(ParseError::UnexpectedToken {
219                    position: if *pos < tokens.len() {
220                        tokens[*pos].position
221                    } else {
222                        0
223                    },
224                    expected: "expression after NOT".to_string(),
225                    found: "end of input".to_string(),
226                });
227            }
228        };
229        return Ok(Some(ConditionExpr::Not(Box::new(inner))));
230    }
231    parse_primary(tokens, pos, ub_definitions)
232}
233
234/// Primary: a condition reference or a parenthesized expression.
235///
236/// When a `ConditionId` matches a key in `ub_definitions`, the pre-parsed
237/// UB expression is substituted inline instead of calling `parse_condition_id`.
238fn parse_primary(
239    tokens: &[SpannedToken],
240    pos: &mut usize,
241    ub_definitions: &HashMap<String, ConditionExpr>,
242) -> Result<Option<ConditionExpr>, ParseError> {
243    if *pos >= tokens.len() {
244        return Ok(None);
245    }
246
247    match &tokens[*pos].token {
248        Token::ConditionId(id) => {
249            *pos += 1;
250            // Check for UB expansion before falling back to parse_condition_id
251            if let Some(ub_expr) = ub_definitions.get(id.as_str()) {
252                return Ok(Some(ub_expr.clone()));
253            }
254            Ok(Some(parse_condition_id(id)))
255        }
256        Token::LeftParen => {
257            *pos += 1; // consume (
258            let expr = parse_expression(tokens, pos, ub_definitions)?;
259            // Consume closing paren if present (graceful handling of missing)
260            if *pos < tokens.len() && tokens[*pos].token == Token::RightParen {
261                *pos += 1;
262            }
263            Ok(expr)
264        }
265        _ => Ok(None),
266    }
267}
268
269/// Parse a condition ID string into a ConditionExpr.
270///
271/// Numeric IDs become `Ref(n)`. Non-numeric IDs (like `UB1`, `10P1..5`)
272/// are kept as-is by extracting numeric portions. For the Rust port,
273/// pure numeric IDs use `Ref(u32)`. Non-numeric IDs extract leading
274/// digits if present, otherwise use 0 as a sentinel.
275fn parse_condition_id(id: &str) -> ConditionExpr {
276    // Try pure numeric ID first
277    if let Ok(num) = id.parse::<u32>() {
278        return ConditionExpr::Ref(num);
279    }
280
281    // Check for package condition: NP or NPmin..max
282    if let Some(p_pos) = id.find('P') {
283        let num_part = &id[..p_pos];
284        let range_part = &id[p_pos + 1..];
285        if let Ok(pkg_id) = num_part.parse::<u32>() {
286            let (min, max) = parse_package_range(range_part);
287            return ConditionExpr::Package {
288                id: pkg_id,
289                min,
290                max,
291            };
292        }
293    }
294
295    // For non-numeric, non-package IDs (e.g., "UB1"), extract leading digits
296    let numeric_part: String = id.chars().take_while(|c| c.is_ascii_digit()).collect();
297    if let Ok(num) = numeric_part.parse::<u32>() {
298        ConditionExpr::Ref(num)
299    } else {
300        ConditionExpr::Ref(0)
301    }
302}
303
304/// Parse the range part of a package condition: `"0..1"` → `(0, 1)`, `""` → `(0, MAX)`.
305fn parse_package_range(range: &str) -> (u32, u32) {
306    if range.is_empty() {
307        return (0, u32::MAX);
308    }
309    if let Some((min_str, max_str)) = range.split_once("..") {
310        let min = min_str.parse::<u32>().unwrap_or(0);
311        let max = max_str.parse::<u32>().unwrap_or(u32::MAX);
312        (min, max)
313    } else {
314        let n = range.parse::<u32>().unwrap_or(0);
315        (n, n)
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use pretty_assertions::assert_eq;
323
324    // === Basic parsing ===
325
326    #[test]
327    fn test_parse_single_condition() {
328        let result = ConditionParser::parse("[931]").unwrap().unwrap();
329        assert_eq!(result, ConditionExpr::Ref(931));
330    }
331
332    #[test]
333    fn test_parse_with_muss_prefix() {
334        let result = ConditionParser::parse("Muss [494]").unwrap().unwrap();
335        assert_eq!(result, ConditionExpr::Ref(494));
336    }
337
338    #[test]
339    fn test_parse_with_soll_prefix() {
340        let result = ConditionParser::parse("Soll [494]").unwrap().unwrap();
341        assert_eq!(result, ConditionExpr::Ref(494));
342    }
343
344    #[test]
345    fn test_parse_with_kann_prefix() {
346        let result = ConditionParser::parse("Kann [182]").unwrap().unwrap();
347        assert_eq!(result, ConditionExpr::Ref(182));
348    }
349
350    #[test]
351    fn test_parse_with_x_prefix() {
352        let result = ConditionParser::parse("X [567]").unwrap().unwrap();
353        assert_eq!(result, ConditionExpr::Ref(567));
354    }
355
356    // === Binary operators ===
357
358    #[test]
359    fn test_parse_simple_and() {
360        let result = ConditionParser::parse("[182] ∧ [152]").unwrap().unwrap();
361        assert_eq!(
362            result,
363            ConditionExpr::And(vec![ConditionExpr::Ref(182), ConditionExpr::Ref(152)])
364        );
365    }
366
367    #[test]
368    fn test_parse_simple_or() {
369        let result = ConditionParser::parse("[1] ∨ [2]").unwrap().unwrap();
370        assert_eq!(
371            result,
372            ConditionExpr::Or(vec![ConditionExpr::Ref(1), ConditionExpr::Ref(2)])
373        );
374    }
375
376    #[test]
377    fn test_parse_simple_xor() {
378        let result = ConditionParser::parse("[1] ⊻ [2]").unwrap().unwrap();
379        assert_eq!(
380            result,
381            ConditionExpr::Xor(
382                Box::new(ConditionExpr::Ref(1)),
383                Box::new(ConditionExpr::Ref(2)),
384            )
385        );
386    }
387
388    // === Chained operators ===
389
390    #[test]
391    fn test_parse_three_way_and() {
392        let result = ConditionParser::parse("[1] ∧ [2] ∧ [3]").unwrap().unwrap();
393        assert_eq!(
394            result,
395            ConditionExpr::And(vec![
396                ConditionExpr::Ref(1),
397                ConditionExpr::Ref(2),
398                ConditionExpr::Ref(3),
399            ])
400        );
401    }
402
403    #[test]
404    fn test_parse_three_way_and_with_prefix() {
405        let result = ConditionParser::parse("Kann [182] ∧ [6] ∧ [570]")
406            .unwrap()
407            .unwrap();
408        assert_eq!(
409            result,
410            ConditionExpr::And(vec![
411                ConditionExpr::Ref(182),
412                ConditionExpr::Ref(6),
413                ConditionExpr::Ref(570),
414            ])
415        );
416        assert_eq!(result.condition_ids(), [6, 182, 570].into());
417    }
418
419    #[test]
420    fn test_parse_multiple_xor() {
421        let result = ConditionParser::parse("[1] ⊻ [2] ⊻ [3] ⊻ [4]")
422            .unwrap()
423            .unwrap();
424        assert_eq!(result.condition_ids(), [1, 2, 3, 4].into());
425    }
426
427    // === Parentheses ===
428
429    #[test]
430    fn test_parse_parenthesized_expression() {
431        let result = ConditionParser::parse("([1] ∨ [2]) ∧ [3]")
432            .unwrap()
433            .unwrap();
434        assert_eq!(
435            result,
436            ConditionExpr::And(vec![
437                ConditionExpr::Or(vec![ConditionExpr::Ref(1), ConditionExpr::Ref(2)]),
438                ConditionExpr::Ref(3),
439            ])
440        );
441    }
442
443    #[test]
444    fn test_parse_nested_parentheses() {
445        // (([1] ∧ [2]) ∨ ([3] ∧ [4])) ∧ [5]
446        let result = ConditionParser::parse("(([1] ∧ [2]) ∨ ([3] ∧ [4])) ∧ [5]")
447            .unwrap()
448            .unwrap();
449        assert_eq!(result.condition_ids(), [1, 2, 3, 4, 5].into());
450        // Outer is AND
451        match &result {
452            ConditionExpr::And(exprs) => {
453                assert_eq!(exprs.len(), 2);
454                assert!(matches!(&exprs[0], ConditionExpr::Or(_)));
455                assert_eq!(exprs[1], ConditionExpr::Ref(5));
456            }
457            other => panic!("Expected And, got {other:?}"),
458        }
459    }
460
461    // === Operator precedence ===
462
463    #[test]
464    fn test_and_has_higher_precedence_than_or() {
465        // [1] ∨ [2] ∧ [3] should parse as [1] ∨ ([2] ∧ [3])
466        let result = ConditionParser::parse("[1] ∨ [2] ∧ [3]").unwrap().unwrap();
467        assert_eq!(
468            result,
469            ConditionExpr::Or(vec![
470                ConditionExpr::Ref(1),
471                ConditionExpr::And(vec![ConditionExpr::Ref(2), ConditionExpr::Ref(3)]),
472            ])
473        );
474    }
475
476    #[test]
477    fn test_or_has_higher_precedence_than_xor() {
478        // [1] ⊻ [2] ∨ [3] should parse as [1] ⊻ ([2] ∨ [3])
479        let result = ConditionParser::parse("[1] ⊻ [2] ∨ [3]").unwrap().unwrap();
480        assert_eq!(
481            result,
482            ConditionExpr::Xor(
483                Box::new(ConditionExpr::Ref(1)),
484                Box::new(ConditionExpr::Or(vec![
485                    ConditionExpr::Ref(2),
486                    ConditionExpr::Ref(3),
487                ])),
488            )
489        );
490    }
491
492    // === Implicit AND ===
493
494    #[test]
495    fn test_adjacent_conditions_implicit_and() {
496        // "[1] [2]" is equivalent to "[1] ∧ [2]"
497        let result = ConditionParser::parse("[1] [2]").unwrap().unwrap();
498        assert_eq!(
499            result,
500            ConditionExpr::And(vec![ConditionExpr::Ref(1), ConditionExpr::Ref(2)])
501        );
502    }
503
504    #[test]
505    fn test_adjacent_conditions_no_space_implicit_and() {
506        // "[939][14]" from real AHB XML
507        let result = ConditionParser::parse("[939][14]").unwrap().unwrap();
508        assert_eq!(
509            result,
510            ConditionExpr::And(vec![ConditionExpr::Ref(939), ConditionExpr::Ref(14)])
511        );
512    }
513
514    // === NOT operator ===
515
516    #[test]
517    fn test_parse_not() {
518        let result = ConditionParser::parse("NOT [1]").unwrap().unwrap();
519        assert_eq!(result, ConditionExpr::Not(Box::new(ConditionExpr::Ref(1))));
520    }
521
522    #[test]
523    fn test_parse_not_with_and() {
524        // NOT [1] ∧ [2] should parse as (NOT [1]) ∧ [2] because NOT has highest precedence
525        let result = ConditionParser::parse("NOT [1] ∧ [2]").unwrap().unwrap();
526        assert_eq!(
527            result,
528            ConditionExpr::And(vec![
529                ConditionExpr::Not(Box::new(ConditionExpr::Ref(1))),
530                ConditionExpr::Ref(2),
531            ])
532        );
533    }
534
535    // === Real-world AHB expressions ===
536
537    #[test]
538    fn test_real_world_orders_expression() {
539        // From ORDERS AHB: "X (([939] [147]) ∨ ([940] [148])) ∧ [567]"
540        let result = ConditionParser::parse("X (([939] [147]) ∨ ([940] [148])) ∧ [567]")
541            .unwrap()
542            .unwrap();
543        assert_eq!(result.condition_ids(), [147, 148, 567, 939, 940].into());
544    }
545
546    #[test]
547    fn test_real_world_xor_expression() {
548        // "Muss ([102] ∧ [2006]) ⊻ ([103] ∧ [2005])"
549        let result = ConditionParser::parse("Muss ([102] ∧ [2006]) ⊻ ([103] ∧ [2005])")
550            .unwrap()
551            .unwrap();
552        assert!(matches!(result, ConditionExpr::Xor(_, _)));
553        assert_eq!(result.condition_ids(), [102, 103, 2005, 2006].into());
554    }
555
556    #[test]
557    fn test_real_world_complex_nested_with_implicit_and() {
558        // "([939][14]) ∨ ([940][15])"
559        let result = ConditionParser::parse("([939][14]) ∨ ([940][15])")
560            .unwrap()
561            .unwrap();
562        assert!(matches!(result, ConditionExpr::Or(_)));
563        assert_eq!(result.condition_ids(), [14, 15, 939, 940].into());
564    }
565
566    // === Edge cases ===
567
568    #[test]
569    fn test_parse_empty_string() {
570        assert!(ConditionParser::parse("").unwrap().is_none());
571    }
572
573    #[test]
574    fn test_parse_whitespace_only() {
575        assert!(ConditionParser::parse("   \t  ").unwrap().is_none());
576    }
577
578    #[test]
579    fn test_parse_bare_muss() {
580        assert!(ConditionParser::parse("Muss").unwrap().is_none());
581    }
582
583    #[test]
584    fn test_parse_bare_x() {
585        // "X" alone has no conditions after it
586        assert!(ConditionParser::parse("X").unwrap().is_none());
587    }
588
589    #[test]
590    fn test_parse_unmatched_open_paren_graceful() {
591        // ([1] ∧ [2] — missing closing paren
592        let result = ConditionParser::parse("([1] ∧ [2]").unwrap().unwrap();
593        assert_eq!(
594            result,
595            ConditionExpr::And(vec![ConditionExpr::Ref(1), ConditionExpr::Ref(2)])
596        );
597    }
598
599    #[test]
600    fn test_parse_text_and_operator() {
601        let result = ConditionParser::parse("[1] AND [2]").unwrap().unwrap();
602        assert_eq!(
603            result,
604            ConditionExpr::And(vec![ConditionExpr::Ref(1), ConditionExpr::Ref(2)])
605        );
606    }
607
608    #[test]
609    fn test_parse_text_or_operator() {
610        let result = ConditionParser::parse("[1] OR [2]").unwrap().unwrap();
611        assert_eq!(
612            result,
613            ConditionExpr::Or(vec![ConditionExpr::Ref(1), ConditionExpr::Ref(2)])
614        );
615    }
616
617    #[test]
618    fn test_parse_text_xor_operator() {
619        let result = ConditionParser::parse("[1] XOR [2]").unwrap().unwrap();
620        assert_eq!(
621            result,
622            ConditionExpr::Xor(
623                Box::new(ConditionExpr::Ref(1)),
624                Box::new(ConditionExpr::Ref(2)),
625            )
626        );
627    }
628
629    #[test]
630    fn test_parse_mixed_unicode_and_text_operators() {
631        let result = ConditionParser::parse("[1] ∧ [2] OR [3]").unwrap().unwrap();
632        assert_eq!(
633            result,
634            ConditionExpr::Or(vec![
635                ConditionExpr::And(vec![ConditionExpr::Ref(1), ConditionExpr::Ref(2)]),
636                ConditionExpr::Ref(3),
637            ])
638        );
639    }
640
641    #[test]
642    fn test_parse_deeply_nested() {
643        // ((([1])))
644        let result = ConditionParser::parse("((([1])))").unwrap().unwrap();
645        assert_eq!(result, ConditionExpr::Ref(1));
646    }
647
648    // === Package conditions ===
649
650    #[test]
651    fn test_parse_package_condition_0_1() {
652        let result = ConditionParser::parse("[4P0..1]").unwrap().unwrap();
653        assert_eq!(
654            result,
655            ConditionExpr::Package {
656                id: 4,
657                min: 0,
658                max: 1
659            }
660        );
661    }
662
663    #[test]
664    fn test_parse_package_condition_1_5() {
665        let result = ConditionParser::parse("[10P1..5]").unwrap().unwrap();
666        assert_eq!(
667            result,
668            ConditionExpr::Package {
669                id: 10,
670                min: 1,
671                max: 5
672            }
673        );
674    }
675
676    #[test]
677    fn test_parse_package_in_expression() {
678        let result = ConditionParser::parse("X [4P0..1] ⊻ [5P0..1]")
679            .unwrap()
680            .unwrap();
681        assert_eq!(
682            result,
683            ConditionExpr::Xor(
684                Box::new(ConditionExpr::Package {
685                    id: 4,
686                    min: 0,
687                    max: 1
688                }),
689                Box::new(ConditionExpr::Package {
690                    id: 5,
691                    min: 0,
692                    max: 1
693                }),
694            )
695        );
696    }
697
698    #[test]
699    fn test_parse_package_bare_p() {
700        let result = ConditionParser::parse("[1P]").unwrap().unwrap();
701        assert_eq!(
702            result,
703            ConditionExpr::Package {
704                id: 1,
705                min: 0,
706                max: u32::MAX
707            }
708        );
709    }
710
711    #[test]
712    fn test_condition_ids_extraction_full() {
713        let result = ConditionParser::parse("Muss ([102] ∧ [2006]) ⊻ ([103] ∧ [2005])")
714            .unwrap()
715            .unwrap();
716        let ids = result.condition_ids();
717        assert!(ids.contains(&102));
718        assert!(ids.contains(&103));
719        assert!(ids.contains(&2005));
720        assert!(ids.contains(&2006));
721        assert_eq!(ids.len(), 4);
722    }
723
724    // === UB inline expansion ===
725
726    #[test]
727    fn test_parse_ub_inline_expansion() {
728        let ub1_expr = ConditionParser::parse("[931] ∧ [932]").unwrap().unwrap();
729        let mut ub_map = HashMap::new();
730        ub_map.insert("UB1".to_string(), ub1_expr.clone());
731
732        let result = ConditionParser::parse_with_ub("X [UB1]", &ub_map)
733            .unwrap()
734            .unwrap();
735        assert_eq!(result, ub1_expr);
736    }
737
738    #[test]
739    fn test_parse_ub_unknown_falls_back() {
740        let ub_map = HashMap::new();
741        let result = ConditionParser::parse_with_ub("[UB99]", &ub_map)
742            .unwrap()
743            .unwrap();
744        assert_eq!(result, ConditionExpr::Ref(0));
745    }
746
747    #[test]
748    fn test_parse_with_ub_empty_map_same_as_parse() {
749        let ub_map = HashMap::new();
750        // Normal conditions should work unchanged
751        let result = ConditionParser::parse_with_ub("X [931] ∧ [932]", &ub_map)
752            .unwrap()
753            .unwrap();
754        let expected = ConditionParser::parse("X [931] ∧ [932]").unwrap().unwrap();
755        assert_eq!(result, expected);
756    }
757
758    #[test]
759    fn test_parse_ub_in_complex_expression() {
760        // UB expands inside a larger expression
761        let ub1_expr = ConditionParser::parse("[931] ∧ [932]").unwrap().unwrap();
762        let mut ub_map = HashMap::new();
763        ub_map.insert("UB1".to_string(), ub1_expr);
764
765        let result = ConditionParser::parse_with_ub("X [UB1] ∨ [100]", &ub_map)
766            .unwrap()
767            .unwrap();
768        // Should be: ([931] ∧ [932]) ∨ [100]
769        assert_eq!(
770            result,
771            ConditionExpr::Or(vec![
772                ConditionExpr::And(vec![ConditionExpr::Ref(931), ConditionExpr::Ref(932)]),
773                ConditionExpr::Ref(100),
774            ])
775        );
776    }
777
778    #[test]
779    fn test_parse_ub_multiple_references() {
780        // Two different UB references in one expression
781        let ub1_expr = ConditionParser::parse("[931]").unwrap().unwrap();
782        let ub2_expr = ConditionParser::parse("[932]").unwrap().unwrap();
783        let mut ub_map = HashMap::new();
784        ub_map.insert("UB1".to_string(), ub1_expr);
785        ub_map.insert("UB2".to_string(), ub2_expr);
786
787        let result = ConditionParser::parse_with_ub("X [UB1] ∧ [UB2]", &ub_map)
788            .unwrap()
789            .unwrap();
790        assert_eq!(
791            result,
792            ConditionExpr::And(vec![ConditionExpr::Ref(931), ConditionExpr::Ref(932)])
793        );
794    }
795}