Skip to main content

hypen_engine/reactive/
expression.rs

1//! Expression evaluation using exprimo
2//!
3//! Supports JavaScript-like expressions in bindings:
4//! - `@{state.selected ? '#FFA7E1' : '#374151'}`
5//! - `@{item.count > 10 ? 'many' : 'few'}`
6//! - `@{state.user.name + ' (' + state.user.role + ')'}`
7
8use exprimo::{CustomFuncError, CustomFunction, Evaluator};
9use serde_json::Value;
10use std::collections::{HashMap, HashSet};
11use std::sync::Arc;
12
13use super::{Binding, BindingSource};
14use crate::error::EngineError;
15
16/// `length(x)` — JS-like size across the common container types.
17///
18/// Exprimo's built-in `.length` property only works on arrays and objects
19/// (0.6.1 rejects strings with "Cannot read property 'length' of
20/// non-array/non-object value"), which silently hid conditions like
21/// `@{state.searchQuery.length == 0}` when `searchQuery` was a string.
22/// Registering this as a custom function sidesteps the upstream gap
23/// without forking exprimo: the DSL syntax becomes `length(state.x)`
24/// instead of `.length`, and it works uniformly for strings, arrays,
25/// and objects.
26#[derive(Debug)]
27struct LengthFn;
28
29impl CustomFunction for LengthFn {
30    fn call(&self, args: &[Value]) -> Result<Value, CustomFuncError> {
31        if args.len() != 1 {
32            return Err(CustomFuncError::ArityError {
33                expected: 1,
34                got: args.len(),
35            });
36        }
37        let n = match &args[0] {
38            // `chars().count()` so grapheme-ish counting is unicode-aware.
39            // Cheap enough for the short strings that actually flow through
40            // template expressions; the full grapheme-cluster story is a
41            // separate feature if it ever matters.
42            Value::String(s) => s.chars().count(),
43            Value::Array(a) => a.len(),
44            Value::Object(o) => o.len(),
45            Value::Null => 0,
46            other => {
47                return Err(CustomFuncError::ArgumentError(format!(
48                    "length() expects string, array, object, or null; got {}",
49                    other
50                )));
51            }
52        };
53        // `len()` is `usize` and `serde_json::Number` accepts `u64` losslessly,
54        // so this can't fail on any 64-bit target.
55        Ok(Value::Number(serde_json::Number::from(n as u64)))
56    }
57}
58
59/// Build the custom-function registry shared by every evaluator the engine
60/// constructs. Centralised here so future additions (e.g. `keys()`,
61/// `values()`) land in one place rather than every `Evaluator::new` call
62/// site.
63fn builtin_functions() -> HashMap<String, Arc<dyn CustomFunction>> {
64    let mut funcs: HashMap<String, Arc<dyn CustomFunction>> = HashMap::new();
65    funcs.insert("length".to_string(), Arc::new(LengthFn));
66    funcs
67}
68
69/// Evaluate an expression string with the given context
70///
71/// The context should contain flattened variables like:
72/// - `state` -> the full state object
73/// - `item` -> the current item (in list iteration)
74///
75/// # Example
76/// ```
77/// use serde_json::json;
78/// use std::collections::HashMap;
79/// use hypen_engine::reactive::evaluate_expression;
80///
81/// let mut context = HashMap::new();
82/// context.insert("selected".to_string(), json!(true));
83///
84/// let result = evaluate_expression("selected ? 'yes' : 'no'", &context);
85/// assert_eq!(result, Ok(json!("yes")));
86/// ```
87pub fn evaluate_expression(
88    expr: &str,
89    context: &HashMap<String, Value>,
90) -> Result<Value, EngineError> {
91    let evaluator = Evaluator::new(context.clone(), builtin_functions());
92    evaluator
93        .evaluate(expr)
94        .map_err(|e| EngineError::ExpressionError(e.to_string()))
95}
96
97/// Build a context for expression evaluation from state, optional item, and data sources.
98///
99/// Flattens `state`, `item`, and data source objects so that expressions can access:
100/// - `state.user.name` via path traversal
101/// - `item.selected` via path traversal
102/// - `spacetime.messages` via data source path traversal
103pub fn build_expression_context(
104    state: &Value,
105    item: Option<&Value>,
106    data_sources: Option<&indexmap::IndexMap<String, Value>>,
107) -> HashMap<String, Value> {
108    let mut context = HashMap::new();
109
110    // Add the full state object
111    context.insert("state".to_string(), state.clone());
112
113    // Add the full item object if present
114    if let Some(item_value) = item {
115        context.insert("item".to_string(), item_value.clone());
116    }
117
118    // Add data source states as top-level context entries
119    if let Some(ds_map) = data_sources {
120        for (provider, ds_state) in ds_map {
121            context.insert(provider.clone(), ds_state.clone());
122        }
123    }
124
125    context
126}
127
128/// Extract all state, item, and data source bindings from an expression string
129///
130/// This scans the expression for `state.xxx`, `item.xxx`, and `provider.xxx`
131/// patterns and returns Binding objects for dependency tracking. Any
132/// `identifier.path` that isn't `state.` or `item.` is treated as a potential
133/// data source binding (validated at the dependency graph level).
134///
135/// # Example
136/// ```
137/// use hypen_engine::reactive::extract_bindings_from_expression;
138///
139/// let bindings = extract_bindings_from_expression("state.selected ? '#FFA7E1' : '#374151'");
140/// assert_eq!(bindings.len(), 1);
141/// assert_eq!(bindings[0].full_path(), "selected");
142/// ```
143pub fn extract_bindings_from_expression(expr: &str) -> Vec<Binding> {
144    let mut bindings = Vec::new();
145    let mut seen_paths: HashSet<String> = HashSet::new();
146
147    // Find all state.xxx and item.xxx patterns
148    for prefix in &["state.", "item."] {
149        let source = if *prefix == "state." {
150            BindingSource::State
151        } else {
152            BindingSource::Item
153        };
154
155        let mut search_pos = 0;
156        while let Some(start) = expr[search_pos..].find(prefix) {
157            let abs_start = search_pos + start;
158
159            // Check that this isn't in the middle of another identifier
160            // (e.g., "mystate.x" should not match)
161            if abs_start > 0 {
162                let prev_char = expr.chars().nth(abs_start - 1).unwrap_or(' ');
163                if prev_char.is_ascii_alphanumeric() || prev_char == '_' {
164                    search_pos = abs_start + prefix.len();
165                    continue;
166                }
167            }
168
169            // Extract the path after the prefix
170            let path_start = abs_start + prefix.len();
171            let mut path_end = path_start;
172
173            // Consume valid path characters (alphanumeric, underscore, and dots)
174            let chars: Vec<char> = expr.chars().collect();
175            while path_end < chars.len() {
176                let c = chars[path_end];
177                if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
178                    path_end += 1;
179                } else {
180                    break;
181                }
182            }
183
184            if path_end > path_start {
185                let path_str: String = chars[path_start..path_end].iter().collect();
186                // Remove trailing dots
187                let path_str = path_str.trim_end_matches('.');
188
189                if !path_str.is_empty() {
190                    let full_path = format!("{}{}", prefix, path_str);
191                    if !seen_paths.contains(&full_path) {
192                        seen_paths.insert(full_path);
193                        let path: Vec<String> =
194                            path_str.split('.').map(|s| s.to_string()).collect();
195                        bindings.push(Binding::new(source.clone(), path));
196                    }
197                }
198            }
199
200            search_pos = path_end.max(abs_start + prefix.len());
201        }
202    }
203
204    // Also find potential data source references: identifier.path patterns that
205    // aren't state.* or item.*. E.g., `spacetime.status == 'connected'`
206    extract_data_source_bindings_from_expression(expr, &mut bindings, &mut seen_paths);
207
208    bindings
209}
210
211/// Extract data source bindings (identifier.path patterns not starting with state/item)
212fn extract_data_source_bindings_from_expression(
213    expr: &str,
214    bindings: &mut Vec<Binding>,
215    seen_paths: &mut HashSet<String>,
216) {
217    let chars: Vec<char> = expr.chars().collect();
218    let len = chars.len();
219    let mut pos = 0;
220
221    // Reserved identifiers that are NOT data source providers
222    let reserved = ["state", "item", "true", "false", "null"];
223
224    while pos < len {
225        // Find the start of an identifier
226        if !chars[pos].is_ascii_alphabetic() && chars[pos] != '_' {
227            pos += 1;
228            continue;
229        }
230
231        // Check not preceded by alphanumeric (part of another identifier)
232        if pos > 0 && (chars[pos - 1].is_ascii_alphanumeric() || chars[pos - 1] == '_') {
233            pos += 1;
234            continue;
235        }
236
237        // Consume the identifier
238        let ident_start = pos;
239        while pos < len && (chars[pos].is_ascii_alphanumeric() || chars[pos] == '_') {
240            pos += 1;
241        }
242        let ident: String = chars[ident_start..pos].iter().collect();
243
244        // Must be followed by a dot and more path segments
245        if pos >= len || chars[pos] != '.' {
246            continue;
247        }
248
249        // Skip reserved words
250        if reserved.contains(&ident.as_str()) {
251            continue;
252        }
253
254        // Consume the dot and path segments
255        let path_start = pos + 1; // skip the dot
256        let mut path_end = path_start;
257        while path_end < len
258            && (chars[path_end].is_ascii_alphanumeric()
259                || chars[path_end] == '_'
260                || chars[path_end] == '.')
261        {
262            path_end += 1;
263        }
264
265        if path_end > path_start {
266            let path_str: String = chars[path_start..path_end].iter().collect();
267            let path_str = path_str.trim_end_matches('.');
268            if !path_str.is_empty() {
269                let full_path = format!("{}.{}", ident, path_str);
270                if !seen_paths.contains(&full_path) {
271                    seen_paths.insert(full_path);
272                    let path: Vec<String> =
273                        path_str.split('.').map(|s| s.to_string()).collect();
274                    bindings.push(Binding::data_source(&ident, path));
275                }
276            }
277        }
278
279        pos = path_end;
280    }
281}
282
283/// Build a reusable [`Evaluator`] from state, optional item, and data sources.
284///
285/// Use this when you need to evaluate multiple expressions against the same
286/// state — `Evaluator::new` clones its context internally, so building once
287/// and reusing avoids paying the clone for every expression.
288pub fn build_evaluator(
289    state: &Value,
290    item: Option<&Value>,
291    data_sources: Option<&indexmap::IndexMap<String, Value>>,
292) -> Evaluator {
293    let context = build_expression_context(state, item, data_sources);
294    Evaluator::new(context, builtin_functions())
295}
296
297/// Evaluate a template string against a pre-built [`Evaluator`].
298///
299/// Substitutes every `@{...}` segment with the evaluator's result. Callers
300/// that need a one-shot evaluation against fresh state should pair this
301/// with [`build_evaluator`].
302pub fn evaluate_template_string(
303    template: &str,
304    evaluator: &Evaluator,
305) -> Result<String, EngineError> {
306    let mut result = template.to_string();
307    let mut pos = 0;
308
309    while let Some(rel_start) = result[pos..].find("@{") {
310        let abs_start = pos + rel_start;
311        let body_start = abs_start + 2;
312
313        // Walk the remainder of the string with char-boundary awareness to
314        // locate the matching closing brace. Byte offsets are used throughout
315        // so the arithmetic stays valid when the template contains multi-byte
316        // UTF-8 characters (·, —, emoji, accented letters, CJK, …) before or
317        // inside the expression.
318        let mut depth: i32 = 1;
319        let mut close_byte: Option<usize> = None;
320        for (off, ch) in result[body_start..].char_indices() {
321            match ch {
322                '{' => depth += 1,
323                '}' => {
324                    depth -= 1;
325                    if depth == 0 {
326                        close_byte = Some(body_start + off);
327                        break;
328                    }
329                }
330                _ => {}
331            }
332        }
333
334        let close_byte = match close_byte {
335            Some(b) => b,
336            None => {
337                return Err(EngineError::ExpressionError(
338                    "Unclosed expression in template".to_string(),
339                ));
340            }
341        };
342
343        // Copy the expression out of `result` so we can mutate `result` below.
344        let expr_content = result[body_start..close_byte].to_string();
345
346        // Evaluate the expression directly via the evaluator (no context clone)
347        let value = evaluator
348            .evaluate(&expr_content)
349            .map_err(|e| EngineError::ExpressionError(e.to_string()))?;
350
351        // Convert to string
352        let replacement = match &value {
353            Value::String(s) => s.clone(),
354            Value::Number(n) => n.to_string(),
355            Value::Bool(b) => b.to_string(),
356            Value::Null => "null".to_string(),
357            _ => serde_json::to_string(&value).unwrap_or_default(),
358        };
359
360        // `}` is ASCII so close_byte + 1 is a valid char boundary past it.
361        let end_byte = close_byte + 1;
362        result.replace_range(abs_start..end_byte, &replacement);
363
364        // Advance past the replacement so we don't re-scan substituted content.
365        pos = abs_start + replacement.len();
366    }
367
368    Ok(result)
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use serde_json::json;
375
376    #[test]
377    fn test_simple_expression() {
378        let mut context = HashMap::new();
379        context.insert("x".to_string(), json!(5));
380        context.insert("y".to_string(), json!(3));
381
382        let result = evaluate_expression("x + y", &context).unwrap();
383        // exprimo returns floats for arithmetic, so compare as f64
384        assert_eq!(result.as_f64().unwrap(), 8.0);
385    }
386
387    #[test]
388    fn test_ternary_expression() {
389        let mut context = HashMap::new();
390        context.insert("selected".to_string(), json!(true));
391
392        let result = evaluate_expression("selected ? 'yes' : 'no'", &context).unwrap();
393        assert_eq!(result, json!("yes"));
394    }
395
396    #[test]
397    fn test_ternary_with_colors() {
398        let mut context = HashMap::new();
399        context.insert("selected".to_string(), json!(true));
400
401        let result = evaluate_expression("selected ? '#FFA7E1' : '#374151'", &context).unwrap();
402        assert_eq!(result, json!("#FFA7E1"));
403    }
404
405    #[test]
406    fn test_comparison_expression() {
407        let mut context = HashMap::new();
408        context.insert("count".to_string(), json!(15));
409
410        let result = evaluate_expression("count > 10", &context).unwrap();
411        assert_eq!(result, json!(true));
412    }
413
414    #[test]
415    fn test_state_object_access() {
416        let context =
417            build_expression_context(&json!({"user": {"name": "Alice", "age": 30}}), None, None);
418
419        let result = evaluate_expression("state.user.name", &context).unwrap();
420        assert_eq!(result, json!("Alice"));
421    }
422
423    #[test]
424    fn test_item_object_access() {
425        let context = build_expression_context(
426            &json!({}),
427            Some(&json!({"name": "Item 1", "selected": true})),
428            None,
429        );
430
431        let result = evaluate_expression("item.name", &context).unwrap();
432        assert_eq!(result, json!("Item 1"));
433    }
434
435    #[test]
436    fn test_item_ternary() {
437        let context =
438            build_expression_context(&json!({}), Some(&json!({"selected": true})), None);
439
440        let result =
441            evaluate_expression("item.selected ? '#FFA7E1' : '#374151'", &context).unwrap();
442        assert_eq!(result, json!("#FFA7E1"));
443    }
444
445    #[test]
446    fn test_template_string_simple() {
447        let state = json!({"user": {"name": "Alice"}});
448        let evaluator = build_evaluator(&state, None, None);
449        let result = evaluate_template_string("Hello @{state.user.name}!", &evaluator).unwrap();
450        assert_eq!(result, "Hello Alice!");
451    }
452
453    #[test]
454    fn test_template_string_with_expression() {
455        let state = json!({"selected": true});
456        let evaluator = build_evaluator(&state, None, None);
457        let result = evaluate_template_string(
458            "Color: @{state.selected ? '#FFA7E1' : '#374151'}",
459            &evaluator,
460        )
461        .unwrap();
462        assert_eq!(result, "Color: #FFA7E1");
463    }
464
465    #[test]
466    fn test_template_string_multiple_expressions() {
467        let state = json!({"name": "Alice", "count": 5});
468        let evaluator = build_evaluator(&state, None, None);
469        let result =
470            evaluate_template_string("@{state.name} has @{state.count} items", &evaluator)
471                .unwrap();
472        assert_eq!(result, "Alice has 5 items");
473    }
474
475    #[test]
476    fn test_template_with_item() {
477        let state = json!({});
478        let item = json!({"name": "Product", "price": 99});
479        let evaluator = build_evaluator(&state, Some(&item), None);
480        let result = evaluate_template_string("@{item.name}: $@{item.price}", &evaluator).unwrap();
481        assert_eq!(result, "Product: $99");
482    }
483
484    #[test]
485    fn test_string_concatenation() {
486        let mut context = HashMap::new();
487        context.insert("first".to_string(), json!("Hello"));
488        context.insert("second".to_string(), json!("World"));
489
490        let result = evaluate_expression("first + ' ' + second", &context).unwrap();
491        assert_eq!(result, json!("Hello World"));
492    }
493
494    #[test]
495    fn test_logical_and() {
496        let mut context = HashMap::new();
497        context.insert("a".to_string(), json!(true));
498        context.insert("b".to_string(), json!(false));
499
500        let result = evaluate_expression("a && b", &context).unwrap();
501        assert_eq!(result, json!(false));
502    }
503
504    #[test]
505    fn test_logical_or() {
506        let mut context = HashMap::new();
507        context.insert("a".to_string(), json!(false));
508        context.insert("b".to_string(), json!(true));
509
510        let result = evaluate_expression("a || b", &context).unwrap();
511        assert_eq!(result, json!(true));
512    }
513
514    #[test]
515    fn test_template_string_multibyte_before_expression() {
516        // Regression: `find("@{")` returns a byte offset but the old code
517        // indexed into `Vec<char>` with it. Any multi-byte char before `@{`
518        // (·, —, emoji, accented letters, CJK …) desynchronised the two and
519        // the expression-extractor ate the leading byte of the identifier.
520        let state = json!({"a": "ALPHA", "b": "BETA"});
521        let evaluator = build_evaluator(&state, None, None);
522
523        // Middle-dot (U+00B7, 2 bytes in UTF-8)
524        let result =
525            evaluate_template_string("@{state.a} · @{state.b}", &evaluator).unwrap();
526        assert_eq!(result, "ALPHA · BETA");
527
528        let result =
529            evaluate_template_string("prefix · @{state.a}", &evaluator).unwrap();
530        assert_eq!(result, "prefix · ALPHA");
531
532        // Em dash (U+2014, 3 bytes)
533        let result = evaluate_template_string("@{state.a} — @{state.b}", &evaluator).unwrap();
534        assert_eq!(result, "ALPHA — BETA");
535
536        // Emoji (4 bytes)
537        let result = evaluate_template_string("🍕 @{state.a}", &evaluator).unwrap();
538        assert_eq!(result, "🍕 ALPHA");
539
540        // CJK (3 bytes each)
541        let result = evaluate_template_string("你好 @{state.a}", &evaluator).unwrap();
542        assert_eq!(result, "你好 ALPHA");
543
544        // Accented letter (2 bytes)
545        let result =
546            evaluate_template_string("café @{state.a}", &evaluator).unwrap();
547        assert_eq!(result, "café ALPHA");
548    }
549
550    #[test]
551    fn test_template_string_multibyte_inside_replacement() {
552        // The replacement itself may contain multi-byte bytes, and the next
553        // `@{` may appear after them. Byte-offset advancement must still work.
554        let state = json!({"a": "café", "b": "naïve"});
555        let evaluator = build_evaluator(&state, None, None);
556
557        let result =
558            evaluate_template_string("@{state.a} · @{state.b}", &evaluator).unwrap();
559        assert_eq!(result, "café · naïve");
560    }
561
562    #[test]
563    fn test_template_string_realistic_restaurant_example() {
564        // Mirrors the user-visible report: a Text containing two template
565        // expressions separated by a middle dot. Before the fix this returned
566        // Err and the caller silently surfaced the raw DSL template.
567        let state = json!({
568            "restaurant": {
569                "cuisine": "Italian",
570                "description": "Wood-fired pizza"
571            }
572        });
573        let evaluator = build_evaluator(&state, None, None);
574        let result = evaluate_template_string(
575            "@{state.restaurant.cuisine} · @{state.restaurant.description}",
576            &evaluator,
577        )
578        .unwrap();
579        assert_eq!(result, "Italian · Wood-fired pizza");
580    }
581
582    #[test]
583    fn test_length_function_strings_arrays_objects() {
584        // Regression: exprimo 0.6.1 rejects `.length` on strings with
585        // "Cannot read property 'length' of non-array/non-object value".
586        // We register `length(x)` as a custom function so the DSL can use
587        // a single syntax for strings / arrays / objects uniformly.
588        let state = json!({
589            "empty": "",
590            "hello": "hello",
591            "unicode": "café",        // 4 chars, 5 bytes
592            "items_empty": [],
593            "items": [1, 2, 3],
594            "obj": { "a": 1, "b": 2 },
595        });
596        let ev = build_evaluator(&state, None, None);
597
598        // strings
599        assert_eq!(
600            evaluate_template_string("@{length(state.empty)}", &ev).unwrap(),
601            "0"
602        );
603        assert_eq!(
604            evaluate_template_string("@{length(state.hello)}", &ev).unwrap(),
605            "5"
606        );
607        // unicode char count, not byte count
608        assert_eq!(
609            evaluate_template_string("@{length(state.unicode)}", &ev).unwrap(),
610            "4"
611        );
612
613        // arrays
614        assert_eq!(
615            evaluate_template_string("@{length(state.items_empty)}", &ev).unwrap(),
616            "0"
617        );
618        assert_eq!(
619            evaluate_template_string("@{length(state.items)}", &ev).unwrap(),
620            "3"
621        );
622
623        // objects (key count)
624        assert_eq!(
625            evaluate_template_string("@{length(state.obj)}", &ev).unwrap(),
626            "2"
627        );
628    }
629
630    #[test]
631    fn test_length_function_empty_search_query_condition() {
632        // The exact condition from the bug report: with `searchQuery = ""`
633        // the "no results / suggestions" branch was silently never rendering
634        // because exprimo errored on `.length`. `length(state.searchQuery) == 0`
635        // now evaluates cleanly.
636        let state = json!({ "searchQuery": "" });
637        let ev = build_evaluator(&state, None, None);
638
639        let mut ctx = HashMap::new();
640        ctx.insert("state".to_string(), state.clone());
641
642        let result = evaluate_expression("length(state.searchQuery) == 0", &ctx).unwrap();
643        assert_eq!(result, json!(true));
644
645        let populated = json!({ "searchQuery": "pizza" });
646        let mut ctx = HashMap::new();
647        ctx.insert("state".to_string(), populated);
648        let result = evaluate_expression("length(state.searchQuery) == 0", &ctx).unwrap();
649        assert_eq!(result, json!(false));
650
651        // `length()` inside a pure-expression template must preserve the Bool
652        // result (not stringify it), so `If(condition: "@{length(…) == 0}")`
653        // matches the Static(true) pattern the parser emits for If.
654        let _ = ev; // keep build_evaluator referenced so the evaluator path is also exercised
655    }
656
657    #[test]
658    fn test_length_function_null_and_errors() {
659        let state = json!({ "missing": null });
660        let ev = build_evaluator(&state, None, None);
661
662        // Null treated as 0 so `length(state.missing) == 0` doesn't throw
663        // for uninitialised state fields.
664        assert_eq!(
665            evaluate_template_string("@{length(state.missing)}", &ev).unwrap(),
666            "0"
667        );
668
669        // Wrong arity is a real error — surfaced, not silenced.
670        let err = evaluate_template_string("@{length()}", &ev).unwrap_err();
671        match err {
672            EngineError::ExpressionError(msg) => {
673                assert!(
674                    msg.contains("expected 1") || msg.contains("arg"),
675                    "expected arity error, got: {}",
676                    msg
677                );
678            }
679            _ => panic!("expected ExpressionError"),
680        }
681    }
682
683    #[test]
684    fn test_template_string_unclosed_expression() {
685        let state = json!({});
686        let evaluator = build_evaluator(&state, None, None);
687        let err = evaluate_template_string("prefix @{state.a", &evaluator).unwrap_err();
688        match err {
689            EngineError::ExpressionError(msg) => {
690                assert!(msg.contains("Unclosed"));
691            }
692            _ => panic!("expected ExpressionError"),
693        }
694    }
695
696    #[test]
697    fn test_complex_expression() {
698        let context = build_expression_context(
699            &json!({
700                "user": {
701                    "premium": true,
702                    "age": 25
703                }
704            }),
705            None,
706            None,
707        );
708
709        let result = evaluate_expression(
710            "state.user.premium && state.user.age >= 18 ? 'VIP Adult' : 'Standard'",
711            &context,
712        )
713        .unwrap();
714        assert_eq!(result, json!("VIP Adult"));
715    }
716}