Skip to main content

eui_core/
macros.rs

1//! The macro expander: applies `catalog/macros.json` (plus extension macros)
2//! to a normalized tree until every node is native. Mirrors `src/macros.ts`;
3//! `fixtures/catalog-macros.json` locks the output.
4//!
5//! Round 1 (docs/round-1-contract.md §1–2): a `$if`/`$any` that cannot be
6//! decided at expansion time (a bound input) emits the part WITH `visible` =
7//! the bound condition; `"$slot:name"` children splice an author slot in
8//! place and a template `slots` entry may be a template node; a RESPONSIVE
9//! prop (`{base, sm?, md?…}`) is expanded per breakpoint into `@media
10//! (min-width: $breakpoint.<bp>)` blocks on every part whose style it
11//! changes; a `$set` on a part whose macro prop is BOUND adds the `set`
12//! write-back to that part's action (two-way binding without a host round
13//! trip); a part's `$a11y` becomes the node's `accessibility` (role, states,
14//! name: §6).
15
16use std::collections::HashSet;
17use std::sync::LazyLock;
18
19use indexmap::IndexMap;
20use serde_json::{Map, Value};
21
22use crate::catalog::{CatalogView, TOKEN_GROUPS};
23use crate::expr::{eval_condition_value, eval_value, is_binding, Decided, ExprContext};
24use crate::json;
25use crate::types::{MacroDef, MacroTemplate, Props, Recipe, ReduceIssue, TemplateChild, UiNode};
26
27const MAX_DEPTH: usize = 16;
28
29/// The breakpoint names in ascending order (`tokens.json` `breakpoint`).
30pub static BREAKPOINTS: LazyLock<Vec<String>> = LazyLock::new(|| TOKEN_GROUPS.get("breakpoint").cloned().unwrap_or_default());
31
32struct Variant {
33    breakpoint: String,
34    props: Props,
35}
36
37struct Expansion<'a> {
38    view: &'a CatalogView,
39    macro_: &'a str,
40    def: &'a MacroDef,
41    source: &'a UiNode,
42    /// The author's props with every responsive value at its `base`.
43    base_props: Props,
44    /// One per breakpoint a responsive prop names, ascending.
45    variants: Vec<Variant>,
46    claimed: HashSet<String>,
47    /// Expansion problems (an author function that replaces a `$set` write).
48    issues: Vec<ReduceIssue>,
49    /// eApps U3: the item nodes `$template` parts built (expanded later).
50    built: Vec<UiNode>,
51}
52
53/// `{base, sm?, md?, lg?, xl?}`: an object with `base` and only breakpoint
54/// keys besides.
55pub fn is_responsive_value(value: &Value) -> bool {
56    let Some(obj) = value.as_object() else { return false };
57    obj.contains_key("base") && obj.keys().all(|k| k == "base" || BREAKPOINTS.iter().any(|b| b == k))
58}
59
60/// The value a responsive prop takes at a breakpoint: the nearest defined
61/// key at or below it, `base` under the first one. `None` = the base.
62pub fn responsive_at<'v>(value: &'v Value, breakpoint: Option<&str>) -> Option<&'v Value> {
63    let obj = value.as_object()?;
64    let mut out = obj.get("base");
65    let Some(breakpoint) = breakpoint else { return out };
66    for bp in BREAKPOINTS.iter() {
67        if let Some(v) = obj.get(bp) {
68            out = Some(v);
69        }
70        if bp == breakpoint {
71            break;
72        }
73    }
74    out
75}
76
77/// The author's props with every responsive object replaced by its value at
78/// `breakpoint` (`None` = base).
79pub fn props_at(props: &Props, breakpoint: Option<&str>) -> Props {
80    let mut out = Props::new();
81    for (k, v) in props {
82        if is_responsive_value(v) {
83            if let Some(at) = responsive_at(v, breakpoint) {
84                out.insert(k.clone(), at.clone());
85            }
86        } else {
87            out.insert(k.clone(), v.clone());
88        }
89    }
90    out
91}
92
93fn responsive_variants(props: &Props) -> Vec<Variant> {
94    let mut used: HashSet<&str> = HashSet::new();
95    for v in props.values() {
96        if is_responsive_value(v) {
97            for k in v.as_object().into_iter().flat_map(|o| o.keys()) {
98                if k != "base" {
99                    used.insert(k.as_str());
100                }
101            }
102        }
103    }
104    BREAKPOINTS
105        .iter()
106        .filter(|bp| used.contains(bp.as_str()))
107        .map(|bp| Variant { breakpoint: bp.clone(), props: props_at(props, Some(bp)) })
108        .collect()
109}
110
111/// JS `{ ...value }` for the value an object spread meets: objects copy,
112/// strings spread their characters by index, everything else is `{}`.
113fn spread(value: Option<&Value>) -> Map<String, Value> {
114    match value {
115        Some(Value::Object(map)) => map.clone(),
116        Some(Value::String(s)) => s.chars().enumerate().map(|(i, c)| (i.to_string(), Value::String(c.to_string()))).collect(),
117        _ => Map::new(),
118    }
119}
120
121/// Merge `over` into `base`; condition objects (`@media …`, `:pressed`) merge
122/// key by key.
123fn merge_style(base: Option<&Props>, over: Option<&Props>) -> Option<Props> {
124    if base.is_none() && over.is_none() {
125        return None;
126    }
127    let mut out = base.cloned().unwrap_or_default();
128    for (k, v) in over.into_iter().flatten() {
129        if let Value::Object(nested) = v {
130            let mut merged = spread(out.get(k));
131            for (nk, nv) in nested {
132                merged.insert(nk.clone(), nv.clone());
133            }
134            out.insert(k.clone(), Value::Object(merged));
135        } else {
136            out.insert(k.clone(), v.clone());
137        }
138    }
139    Some(out)
140}
141
142/// A deep copy of `action` with `context` merged into `event.context`.
143fn routed_action(action: &Value, context: Option<&Value>) -> Value {
144    let mut out = action.clone();
145    if let (Some(context), Some(Value::Object(event))) = (context.filter(|c| js_truthy(c)), out.get_mut("event")) {
146        let mut merged = spread(event.get("context").filter(|c| !c.is_null()));
147        merged.extend(spread(Some(context)));
148        event.insert("context".into(), Value::Object(merged));
149    }
150    out
151}
152
153/// The macro's recipe discriminators: the author's value, else the prop's
154/// catalog default, then the evaluated `$recipe` keys.
155fn recipe_props(exp: &Expansion, ctx: &ExprContext, extra: Option<&Props>) -> Props {
156    let mut props = Props::new();
157    let schema = exp.view.components.get(exp.macro_).map(|d| &d.props);
158    for name in &exp.def.recipe_props {
159        let value = exp
160            .base_props
161            .get(name)
162            .filter(|v| !v.is_null())
163            .cloned()
164            .or_else(|| schema.and_then(|s| s.get(name)).and_then(|p| p.default.clone()));
165        if let Some(value) = value {
166            props.insert(name.clone(), value);
167        }
168    }
169    if let Some(extra) = extra {
170        if let Some(Value::Object(evaluated)) = eval_value(&Value::Object(extra.clone()), ctx) {
171            for (k, v) in evaluated {
172                props.insert(k, v);
173            }
174        }
175    }
176    props
177}
178
179/// `and{values}` / `or{values}` over several conditions; one alone is itself.
180fn combine(mut conditions: Vec<Value>, op: &str) -> Value {
181    if conditions.len() == 1 {
182        return conditions.pop().expect("one");
183    }
184    crate::expr::call(op, vec![("values", Some(Value::Array(conditions)))])
185}
186
187/// The part's `$if`/`$any`: `Bool(false)` = leave it out, `Bool(true)` =
188/// emit it, a dynamic value = emit it with that as `visible`.
189fn decide_conditions(tpl: &MacroTemplate, ctx: &ExprContext) -> Decided {
190    let mut bound: Vec<Value> = Vec::new();
191    if let Some(cond) = &tpl.if_ {
192        let conds: Vec<&Value> = match cond {
193            Value::Array(list) => list.iter().collect(),
194            single => vec![single],
195        };
196        for c in conds {
197            match eval_condition_value(Some(c), ctx) {
198                Decided::Bool(false) => return Decided::Bool(false),
199                Decided::Bool(true) => {}
200                Decided::Dynamic(v) => bound.push(v),
201            }
202        }
203    }
204    if let Some(any) = &tpl.any {
205        let mut decided = false;
206        let mut dynamic: Vec<Value> = Vec::new();
207        for c in any {
208            match eval_condition_value(Some(c), ctx) {
209                Decided::Bool(true) => {
210                    decided = true;
211                    break;
212                }
213                Decided::Bool(false) => {}
214                Decided::Dynamic(v) => dynamic.push(v),
215            }
216        }
217        if !decided {
218            if dynamic.is_empty() {
219                return Decided::Bool(false);
220            }
221            bound.push(combine(dynamic, "or"));
222        }
223    }
224    if bound.is_empty() {
225        Decided::Bool(true)
226    } else {
227        Decided::Dynamic(combine(bound, "and"))
228    }
229}
230
231fn expand_template(tpl: &MacroTemplate, exp: &mut Expansion, ctx: &ExprContext, parent_id: &str) -> Vec<UiNode> {
232    let visible = match decide_conditions(tpl, ctx) {
233        Decided::Bool(false) => return Vec::new(),
234        Decided::Bool(true) => None,
235        Decided::Dynamic(v) => Some(v),
236    };
237    if let Some(each) = &tpl.each {
238        let Some(Value::Array(items)) = eval_value(&Value::String(format!("{{{each}}}")), ctx) else {
239            return Vec::new();
240        };
241        let as_ = tpl.as_.as_deref().unwrap_or("item");
242        return items
243            .into_iter()
244            .enumerate()
245            .map(|(index, item)| {
246                let mut vars = ctx.vars.clone();
247                vars.insert(as_.to_string(), item);
248                vars.insert("index".to_string(), json::number(index as f64));
249                let item_ctx = ExprContext { id: ctx.id, props: ctx.props, vars };
250                build_node(tpl, exp, &item_ctx, format!("{parent_id}.{}.{index}", tpl.part), visible.clone())
251            })
252            .collect();
253    }
254    let id = if tpl.part == "root" { exp.source.id.clone() } else { format!("{parent_id}.{}", tpl.part) };
255    vec![build_node(tpl, exp, ctx, id, visible)]
256}
257
258/// The part's style: evaluated with the base props, then once per
259/// breakpoint variant; every key a variant changes lands in its media block.
260fn part_style(tpl: &MacroTemplate, exp: &Expansion, ctx: &ExprContext) -> Option<Props> {
261    let template = tpl.style.as_ref()?;
262    let Some(Value::Object(mut style)) = eval_value(&Value::Object(template.clone()), ctx) else { return None };
263    let mut previous = style.clone();
264    for variant in &exp.variants {
265        let variant_ctx = ExprContext { id: ctx.id, props: &variant.props, vars: ctx.vars.clone() };
266        let Some(Value::Object(at)) = eval_value(&Value::Object(template.clone()), &variant_ctx) else { continue };
267        let mut diff = Map::new();
268        for (k, v) in &at {
269            if k.starts_with('@') || k.starts_with(':') {
270                continue;
271            }
272            if !previous.get(k).is_some_and(|p| json::equal(p, v)) {
273                diff.insert(k.clone(), v.clone());
274            }
275        }
276        if !diff.is_empty() {
277            let key = format!("@media (min-width: $breakpoint.{})", variant.breakpoint);
278            let mut block = spread(style.get(&key));
279            block.extend(diff);
280            style.insert(key, Value::Object(block));
281        }
282        previous = at;
283    }
284    (!style.is_empty()).then_some(style)
285}
286
287fn build_node(tpl: &MacroTemplate, exp: &mut Expansion, ctx: &ExprContext, id: String, visible: Option<Value>) -> UiNode {
288    let props = match eval_value(&Value::Object(tpl.props.clone().unwrap_or_default()), ctx) {
289        Some(Value::Object(p)) => p,
290        _ => Props::new(),
291    };
292    let mut node = UiNode::new(id.clone(), tpl.component.clone());
293    node.props = props;
294    node.style = part_style(tpl, exp, ctx);
295    node.visible = visible;
296    node.recipe = Some(Recipe {
297        macro_: exp.macro_.to_string(),
298        part: tpl.part.clone(),
299        props: recipe_props(exp, ctx, tpl.recipe.as_ref()),
300        authored: Vec::new(),
301    });
302    if let Some(routes) = &tpl.on {
303        let mut on = IndexMap::new();
304        for (part_event, macro_event) in routes {
305            let Some(action) = exp.source.on.as_ref().and_then(|o| o.get(macro_event)).filter(|a| js_truthy(a)) else {
306                continue;
307            };
308            exp.claimed.insert(macro_event.clone());
309            let context = tpl.context.as_ref().and_then(|c| eval_value(&Value::Object(c.clone()), ctx));
310            on.insert(part_event.clone(), routed_action(action, context.as_ref()));
311        }
312        if !on.is_empty() {
313            node.on = Some(on);
314        }
315    }
316    if let Some(sets) = &tpl.set {
317        for (part_event, spec) in sets {
318            let Some(bound) = exp.base_props.get(&spec.prop).filter(|b| is_binding(b)) else { continue };
319            let has_function = node.on.as_ref().and_then(|on| on.get(part_event)).and_then(|a| a.get("functionCall")).is_some_and(js_truthy);
320            if has_function {
321                // An Action carries ONE `functionCall`: the author's routed
322                // function call wins and the bound prop is not written back (reported,
323                // never lost silently). Route an `event` to keep the write.
324                let macro_event = tpl.on.as_ref().and_then(|o| o.get(part_event)).unwrap_or(part_event);
325                let message = format!(
326                    "on.{macro_event}: a function action replaces the two-way set of props.{}; write it in that function's handler or use an event",
327                    spec.prop
328                );
329                if !exp.issues.iter().any(|i| i.id == exp.source.id && i.message == message) {
330                    exp.issues.push(ReduceIssue { id: exp.source.id.clone(), message });
331                }
332                continue;
333            }
334            let path = bound["path"].clone();
335            let value = eval_value(&spec.value, ctx);
336            let mut args = Map::new();
337            args.insert("path".into(), path);
338            if let Some(value) = value {
339                args.insert("value".into(), value);
340            }
341            let mut function = Map::new();
342            function.insert("call".into(), Value::String("set".into()));
343            function.insert("args".into(), Value::Object(args));
344            let on = node.on.get_or_insert_with(IndexMap::new);
345            let mut action = match on.get(part_event) {
346                Some(Value::Object(existing)) => existing.clone(),
347                _ => Map::new(),
348            };
349            action.insert("functionCall".into(), Value::Object(function));
350            on.insert(part_event.clone(), Value::Object(action));
351        }
352    }
353    if let Some(a11y) = &tpl.a11y {
354        if let Some(Value::Object(evaluated)) = eval_value(&Value::Object(a11y.clone()), ctx) {
355            if !evaluated.is_empty() {
356                node.accessibility = Some(Value::Object(evaluated));
357            }
358        }
359    }
360    if let Some(slots) = &tpl.slots {
361        for (slot, reference) in slots {
362            match reference {
363                TemplateChild::Splice(reference) => {
364                    let Some(name) = reference.strip_prefix("$slot:").filter(|n| !n.is_empty()) else { continue };
365                    if let Some(value) = exp.source.slots.as_ref().and_then(|s| s.get(name)) {
366                        node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), value.clone());
367                    }
368                }
369                TemplateChild::Node(part) => {
370                    if let Some(built) = expand_template(part, exp, ctx, &id).into_iter().next() {
371                        node.slots.get_or_insert_with(IndexMap::new).insert(slot.clone(), built);
372                    }
373                }
374            }
375        }
376    }
377    if let Some(data) = &tpl.template {
378        // eApps U3: a data template: the item is built now, lifted into the
379        // reducer's `templates` (expand_macro expands it like any tree).
380        let path = eval_value(&data.path, ctx);
381        let key = data.key.as_ref().and_then(|k| eval_value(k, ctx));
382        if let Some(Value::String(path)) = path.filter(|p| p.as_str().is_some_and(|s| !s.is_empty())) {
383            if let Some(item) = expand_template(&data.item, exp, ctx, &id).into_iter().next() {
384                let key = match key {
385                    Some(Value::String(k)) if !k.is_empty() => Some(k),
386                    _ => None,
387                };
388                node.template = Some(crate::types::Template { component: item.id.clone(), path, key });
389                exp.built.push(item);
390            }
391        }
392    }
393    for child in tpl.children.iter().flatten() {
394        match child {
395            TemplateChild::Splice(s) if s == "$children" => {
396                node.children.extend(exp.source.children.iter().cloned());
397                if let Some(template) = &exp.source.template {
398                    node.template = Some(template.clone());
399                }
400            }
401            TemplateChild::Splice(s) => {
402                if let Some(name) = s.strip_prefix("$slot:") {
403                    if let Some(value) = exp.source.slots.as_ref().and_then(|slots| slots.get(name)) {
404                        node.children.push(value.clone());
405                    }
406                }
407            }
408            TemplateChild::Node(child) => {
409                let built = expand_template(child, exp, ctx, &id);
410                node.children.extend(built);
411            }
412        }
413    }
414    node
415}
416
417/// Expand one macro node (children already native) into its template.
418/// Never inlined: its large frame stays off [`expand_tree`]'s recursion
419/// (VAPP-103: nested macros otherwise cost ~12 KB of stack per level).
420#[inline(never)]
421fn expand_macro(node: UiNode, macro_: &str, def: &MacroDef, view: &CatalogView, depth: usize, issues: &mut Vec<ReduceIssue>, templates: &mut Vec<UiNode>) -> Result<UiNode, String> {
422    if depth > MAX_DEPTH {
423        return Err(format!("macro {macro_}: expansion deeper than {MAX_DEPTH}"));
424    }
425    let mut base_props = props_at(&node.props, None);
426    // Round 3: an `on.press` implies `pressable` on a macro that offers it (Row, Chip).
427    // #EXP-1674: so does an `href` (Row).
428    if (node.on.as_ref().is_some_and(|o| o.contains_key("press")) || base_props.get("href").is_some_and(|h| !h.is_null() && h != "")) && !base_props.contains_key("pressable") && view.components.get(macro_).is_some_and(|d| d.props.contains_key("pressable")) {
429        base_props.insert("pressable".into(), Value::Bool(true));
430    }
431    let variants = responsive_variants(&node.props);
432    let mut exp = Expansion { view, macro_, def, source: &node, base_props: base_props.clone(), variants, claimed: HashSet::new(), issues: Vec::new(), built: Vec::new() };
433    let ctx = ExprContext { id: &node.id, props: &base_props, vars: IndexMap::new() };
434    let mut root = expand_template(&def.root, &mut exp, &ctx, &node.id)
435        .into_iter()
436        .next()
437        .ok_or_else(|| format!("macro {macro_}: the root template produced no node"))?;
438    let claimed = exp.claimed;
439    let built = exp.built;
440    for issue in exp.issues {
441        if !issues.contains(&issue) {
442            issues.push(issue);
443        }
444    }
445    root.style = merge_style(root.style.as_ref(), node.style.as_ref()).filter(|s| !s.is_empty());
446    // VAPP-163: the style written ON the macro node wins over the macro's
447    // part recipes: its top-level keys (condition blocks included), SORTED,
448    // ride on the root's recipe as `authored`. Only the AUTHOR's keys count:
449    // a node an outer template drew (it carries a recipe) passes on just the
450    // keys its own author wrote, never template style.
451    let authored: Vec<String> = match &node.recipe {
452        Some(outer) => outer.authored.clone(),
453        None => {
454            let mut keys: Vec<String> = node.style.as_ref().map(|s| s.keys().cloned().collect()).unwrap_or_default();
455            keys.sort();
456            keys
457        }
458    };
459    if let Some(recipe) = root.recipe.as_mut() {
460        recipe.authored = authored;
461    }
462    if let Some(visible) = &node.visible {
463        root.visible = Some(visible.clone());
464    }
465    // The author's label/description (or an outer macro part's `$a11y`) win
466    // over the root part's own.
467    if let Some(Value::Object(accessibility)) = &node.accessibility {
468        let mut merged = spread(root.accessibility.as_ref());
469        for (k, v) in accessibility {
470            merged.insert(k.clone(), v.clone());
471        }
472        root.accessibility = Some(Value::Object(merged));
473    }
474    let mut remaining = root.on.take().unwrap_or_default();
475    for (event, action) in node.on.iter().flatten() {
476        if !claimed.contains(event) {
477            remaining.insert(event.clone(), action.clone());
478        }
479    }
480    root.on = (!remaining.is_empty()).then_some(remaining);
481    expand_tree(&mut root, view, depth + 1, issues, templates)?;
482    // eApps U3: the data-template items, expanded like the root, in build order.
483    for mut item in built {
484        expand_tree(&mut item, view, depth + 1, issues, templates)?;
485        templates.push(item);
486    }
487    Ok(root)
488}
489
490/// JavaScript truthiness (`if (value)`).
491fn js_truthy(value: &Value) -> bool {
492    match value {
493        Value::Null => false,
494        Value::Bool(b) => *b,
495        Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
496        Value::String(s) => !s.is_empty(),
497        _ => true,
498    }
499}
500
501/// Expand IN PLACE, children and slots first (VAPP-103: no clone per
502/// level, a small frame per level: nested macros recurse through here).
503fn expand_tree(node: &mut UiNode, view: &CatalogView, depth: usize, issues: &mut Vec<ReduceIssue>, templates: &mut Vec<UiNode>) -> Result<(), String> {
504    crate::deep(|| expand_node(node, view, depth, issues, templates))
505}
506
507fn expand_node(node: &mut UiNode, view: &CatalogView, depth: usize, issues: &mut Vec<ReduceIssue>, templates: &mut Vec<UiNode>) -> Result<(), String> {
508    for child in &mut node.children {
509        expand_tree(child, view, depth, issues, templates)?;
510    }
511    if let Some(slots) = &mut node.slots {
512        for child in slots.values_mut() {
513            expand_tree(child, view, depth, issues, templates)?;
514        }
515    }
516    if let Some(def) = view.macros.get(&node.component) {
517        if view.components.get(&node.component).is_some_and(|d| d.is_macro()) {
518            let component = node.component.clone();
519            let source = std::mem::replace(node, UiNode::new("", ""));
520            *node = expand_macro(source, &component, def, view, depth, issues, templates)?;
521        }
522    }
523    Ok(())
524}
525
526/// The normalized tree with every macro (core + extensions) expanded into
527/// natives, recipes attached. Pure: the input is not mutated. `Err` when an
528/// expansion nests deeper than 16 macros. Expansion problems are dropped; see
529/// [`expand_macros_with_issues`].
530pub fn expand_macros(root: &UiNode, view: &CatalogView) -> Result<UiNode, String> {
531    expand_macros_with_issues(root, view).map(|(node, _)| node)
532}
533
534/// [`expand_macros`] plus the expansion problems (an author function that
535/// replaces a `$set` write-back), deduplicated per node: the TS
536/// `expandMacros(root, {issues})`.
537pub fn expand_macros_with_issues(root: &UiNode, view: &CatalogView) -> Result<(UiNode, Vec<ReduceIssue>), String> {
538    expand_macros_with_templates(root, view).map(|(node, issues, _)| (node, issues))
539}
540
541/// [`expand_macros_with_issues`] plus the items a macro's `$template` parts
542/// built (eApps U3: native, guides filled), for the reducer's `templates`.
543pub fn expand_macros_with_templates(root: &UiNode, view: &CatalogView) -> Result<(UiNode, Vec<ReduceIssue>, Vec<UiNode>), String> {
544    crate::roomy(|| expand_all(root, view))
545}
546
547fn expand_all(root: &UiNode, view: &CatalogView) -> Result<(UiNode, Vec<ReduceIssue>, Vec<UiNode>), String> {
548    let mut issues = Vec::new();
549    let mut templates = Vec::new();
550    let mut node = root.clone();
551    expand_tree(&mut node, view, 0, &mut issues, &mut templates)?;
552    // Round 3: the tree guides of nested Rows come from their siblings, so
553    // they are filled AFTER the whole tree is native (`tree_guides`).
554    crate::tree_guides::apply_tree_guides(&mut node);
555    for t in &mut templates {
556        crate::tree_guides::apply_tree_guides(t);
557    }
558    Ok((node, issues, templates))
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use serde_json::json;
565
566    #[test]
567    fn responsive_values_pick_the_nearest_breakpoint_at_or_below() {
568        let v = json!({"base": 1, "md": 2, "xl": 4});
569        assert!(is_responsive_value(&v));
570        assert!(!is_responsive_value(&json!({"base": 1, "huge": 2})));
571        assert!(!is_responsive_value(&json!({"md": 2})));
572        assert_eq!(responsive_at(&v, None), Some(&json!(1)));
573        assert_eq!(responsive_at(&v, Some("sm")), Some(&json!(1)));
574        assert_eq!(responsive_at(&v, Some("md")), Some(&json!(2)));
575        assert_eq!(responsive_at(&v, Some("lg")), Some(&json!(2)));
576        assert_eq!(responsive_at(&v, Some("xl")), Some(&json!(4)));
577        assert_eq!(BREAKPOINTS.as_slice(), ["sm", "md", "lg", "xl"]);
578    }
579}