Skip to main content

rustmotion_core/
expand.rs

1use std::collections::HashMap;
2
3use serde::Deserialize;
4use serde_json::Value;
5
6use crate::error::{Result, RustmotionError};
7use crate::schema::VariableType;
8use crate::variables::substitute;
9
10const MAX_EXPANSION_DEPTH: u32 = 64;
11
12const MAX_EXPANSION_NODES: u64 = 2_000_000;
13
14const MAX_FOR_EACH_ITEMS: usize = 100_000;
15
16#[derive(Debug, Clone, Deserialize)]
17#[serde(deny_unknown_fields)]
18struct ComponentDefinition {
19    #[serde(default)]
20    params: HashMap<String, ComponentParam>,
21    template: Value,
22}
23
24#[derive(Debug, Clone, Deserialize)]
25#[serde(deny_unknown_fields)]
26struct ComponentParam {
27    #[serde(rename = "type")]
28    #[allow(dead_code)]
29    param_type: VariableType,
30    #[serde(default)]
31    default: Option<Value>,
32    #[serde(default)]
33    #[allow(dead_code)]
34    description: Option<String>,
35}
36
37#[derive(Debug, Deserialize)]
38#[serde(deny_unknown_fields)]
39struct UseDirective {
40    #[serde(rename = "use")]
41    use_name: String,
42    #[serde(default)]
43    props: HashMap<String, Value>,
44}
45
46#[derive(Debug, Deserialize)]
47#[serde(deny_unknown_fields)]
48struct ForEachDirective {
49    #[serde(rename = "for-each")]
50    for_each: Value,
51    template: Value,
52}
53
54fn is_for_each(v: &Value) -> bool {
55    matches!(v, Value::Object(m) if m.contains_key("for-each"))
56}
57
58fn is_use(v: &Value) -> bool {
59    matches!(v, Value::Object(m) if m.contains_key("use"))
60}
61
62pub fn expand_directives(value: &mut Value, file_label: &str) -> Result<()> {
63    let defs = extract_component_definitions(value, file_label)?;
64    let mut budget = MAX_EXPANSION_NODES;
65
66    let Value::Object(root) = value else {
67        return Ok(());
68    };
69    root.remove("components");
70
71    if let Some(Value::Array(scenes)) = root.remove("scenes") {
72        let mut out = Vec::with_capacity(scenes.len());
73        for (i, mut scene) in scenes.into_iter().enumerate() {
74            let scene_path = format!("scenes[{i}]");
75            let mut stack = Vec::new();
76            walk_children(
77                &mut scene,
78                &defs,
79                file_label,
80                &scene_path,
81                &mut stack,
82                0,
83                &mut budget,
84            )?;
85            out.push(scene);
86        }
87        root.insert("scenes".to_string(), Value::Array(out));
88    }
89
90    if let Some(Value::Array(views)) = root.remove("composition") {
91        let mut out_views = Vec::with_capacity(views.len());
92        for (vi, mut view) in views.into_iter().enumerate() {
93            if let Value::Object(vmap) = &mut view {
94                if let Some(Value::Array(scenes)) = vmap.remove("scenes") {
95                    let mut out = Vec::with_capacity(scenes.len());
96                    for (si, mut scene) in scenes.into_iter().enumerate() {
97                        let scene_path = format!("composition[{vi}].scenes[{si}]");
98                        let mut stack = Vec::new();
99                        walk_children(
100                            &mut scene,
101                            &defs,
102                            file_label,
103                            &scene_path,
104                            &mut stack,
105                            0,
106                            &mut budget,
107                        )?;
108                        out.push(scene);
109                    }
110                    vmap.insert("scenes".to_string(), Value::Array(out));
111                }
112            }
113            out_views.push(view);
114        }
115        root.insert("composition".to_string(), Value::Array(out_views));
116    }
117
118    warn_unresolved_after_expansion(value, file_label);
119    Ok(())
120}
121
122fn consume_node_budget(budget: &mut u64, n: u64, file_label: &str, location: &str) -> Result<()> {
123    match budget.checked_sub(n) {
124        Some(remaining) => {
125            *budget = remaining;
126            Ok(())
127        }
128        None => Err(RustmotionError::Generic(format!(
129            "expansion node budget ({MAX_EXPANSION_NODES}) exceeded at '{file_label}: {location}' \
130             — for-each/use nesting multiplies past the limit"
131        ))),
132    }
133}
134
135fn warn_unresolved_after_expansion(value: &Value, file_label: &str) {
136    for name in crate::variables::find_unresolved(value) {
137        eprintln!(
138            "Warning: {}",
139            crate::error::RustmotionError::UnresolvedVariable {
140                name,
141                path: file_label.to_string(),
142            }
143        );
144    }
145}
146
147fn extract_component_definitions(
148    value: &Value,
149    file_label: &str,
150) -> Result<HashMap<String, ComponentDefinition>> {
151    let Value::Object(root) = value else {
152        return Ok(HashMap::new());
153    };
154    match root.get("components") {
155        None => Ok(HashMap::new()),
156        Some(Value::Object(defs_map)) => {
157            let mut out = HashMap::with_capacity(defs_map.len());
158            for (name, def_val) in defs_map {
159                let def: ComponentDefinition =
160                    serde_json::from_value(def_val.clone()).map_err(|e| {
161                        RustmotionError::ComponentDefinitionInvalid {
162                            name: name.clone(),
163                            path: file_label.to_string(),
164                            reason: e.to_string(),
165                        }
166                    })?;
167                out.insert(name.clone(), def);
168            }
169            Ok(out)
170        }
171        Some(_) => Err(RustmotionError::ComponentsBlockNotObject {
172            path: file_label.to_string(),
173        }),
174    }
175}
176
177fn walk_children(
178    value: &mut Value,
179    defs: &HashMap<String, ComponentDefinition>,
180    file_label: &str,
181    location: &str,
182    stack: &mut Vec<String>,
183    depth: u32,
184    budget: &mut u64,
185) -> Result<()> {
186    match value {
187        Value::Object(map) => {
188            if matches!(map.get("children"), Some(Value::Array(_))) {
189                if let Some(Value::Array(arr)) = map.remove("children") {
190                    let mut expanded = Vec::with_capacity(arr.len());
191                    for (i, entry) in arr.into_iter().enumerate() {
192                        let entry_loc = format!("{location}.children[{i}]");
193                        expanded.extend(resolve_entry(
194                            entry, defs, file_label, &entry_loc, stack, depth, budget,
195                        )?);
196                    }
197                    map.insert("children".to_string(), Value::Array(expanded));
198                }
199            }
200            for (k, v) in map.iter_mut() {
201                if k == "children" {
202                    continue;
203                }
204                walk_children(v, defs, file_label, location, stack, depth, budget)?;
205            }
206        }
207        Value::Array(arr) => {
208            for v in arr.iter_mut() {
209                walk_children(v, defs, file_label, location, stack, depth, budget)?;
210            }
211        }
212        _ => {}
213    }
214    Ok(())
215}
216
217fn resolve_entry(
218    entry: Value,
219    defs: &HashMap<String, ComponentDefinition>,
220    file_label: &str,
221    location: &str,
222    stack: &mut Vec<String>,
223    depth: u32,
224    budget: &mut u64,
225) -> Result<Vec<Value>> {
226    if depth > MAX_EXPANSION_DEPTH {
227        return Err(RustmotionError::ExpansionDepthExceeded {
228            limit: MAX_EXPANSION_DEPTH,
229            path: format!("{file_label}: {location}"),
230        });
231    }
232
233    if let Value::Array(fragment) = entry {
234        let mut out = Vec::with_capacity(fragment.len());
235        for (i, n) in fragment.into_iter().enumerate() {
236            let frag_loc = format!("{location}[{i}]");
237            out.extend(resolve_entry(
238                n,
239                defs,
240                file_label,
241                &frag_loc,
242                stack,
243                depth + 1,
244                budget,
245            )?);
246        }
247        return Ok(out);
248    }
249
250    if is_for_each(&entry) {
251        let produced = expand_for_each_directive(entry, file_label, location, budget)?;
252        let mut out = Vec::with_capacity(produced.len());
253        for (i, node) in produced.into_iter().enumerate() {
254            let iter_loc = format!("{location}[{i}]");
255            out.extend(resolve_entry(
256                node,
257                defs,
258                file_label,
259                &iter_loc,
260                stack,
261                depth + 1,
262                budget,
263            )?);
264        }
265        return Ok(out);
266    }
267
268    if is_use(&entry) {
269        let (name, node) = expand_use_directive(entry, defs, file_label, location)?;
270        if stack.contains(&name) {
271            let mut chain = stack.clone();
272            chain.push(name);
273            return Err(RustmotionError::ComponentCycle {
274                chain: chain.join(" -> "),
275                path: format!("{file_label}: {location}"),
276            });
277        }
278        stack.push(name);
279        let result = resolve_entry(node, defs, file_label, location, stack, depth + 1, budget);
280        stack.pop();
281        return result;
282    }
283
284    let mut node = entry;
285    walk_children(&mut node, defs, file_label, location, stack, depth, budget)?;
286    Ok(vec![node])
287}
288
289fn expand_for_each_directive(
290    entry: Value,
291    file_label: &str,
292    location: &str,
293    budget: &mut u64,
294) -> Result<Vec<Value>> {
295    let directive: ForEachDirective =
296        serde_json::from_value(entry).map_err(|e| RustmotionError::ForEachDirectiveInvalid {
297            path: format!("{file_label}: {location}"),
298            reason: e.to_string(),
299        })?;
300
301    let items = match &directive.for_each {
302        Value::Array(items) => items.clone(),
303        other => {
304            return Err(RustmotionError::ForEachNotArray {
305                path: format!("{file_label}: {location}"),
306                found: describe_value(other),
307            })
308        }
309    };
310
311    if items.len() > MAX_FOR_EACH_ITEMS {
312        return Err(RustmotionError::Generic(format!(
313            "for-each at '{file_label}: {location}' has {} items, exceeding the per-directive \
314             cap of {MAX_FOR_EACH_ITEMS}",
315            items.len()
316        )));
317    }
318    consume_node_budget(budget, items.len() as u64, file_label, location)?;
319
320    let count = items.len();
321    let mut out = Vec::with_capacity(count);
322    for (idx, element) in items.into_iter().enumerate() {
323        let mut bindings: HashMap<String, Value> = HashMap::new();
324        if let Value::Object(obj) = &element {
325            for (k, v) in obj {
326                bindings.insert(k.clone(), v.clone());
327            }
328        }
329        bindings
330            .entry("index".to_string())
331            .or_insert_with(|| Value::from(idx));
332        bindings
333            .entry("item".to_string())
334            .or_insert_with(|| element.clone());
335        bindings
336            .entry("i".to_string())
337            .or_insert_with(|| Value::from(idx));
338        bindings
339            .entry("count".to_string())
340            .or_insert_with(|| Value::from(count));
341
342        let mut node = directive.template.clone();
343        substitute(&mut node, &bindings, file_label)?;
344        out.push(node);
345    }
346    Ok(out)
347}
348
349fn expand_use_directive(
350    entry: Value,
351    defs: &HashMap<String, ComponentDefinition>,
352    file_label: &str,
353    location: &str,
354) -> Result<(String, Value)> {
355    let directive: UseDirective =
356        serde_json::from_value(entry).map_err(|e| RustmotionError::UseDirectiveInvalid {
357            path: format!("{file_label}: {location}"),
358            reason: e.to_string(),
359        })?;
360
361    let def = defs
362        .get(&directive.use_name)
363        .ok_or_else(|| RustmotionError::UnknownComponent {
364            name: directive.use_name.clone(),
365            path: format!("{file_label}: {location}"),
366        })?;
367
368    for key in directive.props.keys() {
369        if !def.params.contains_key(key) {
370            return Err(RustmotionError::UnknownComponentParam {
371                component: directive.use_name.clone(),
372                param: key.clone(),
373                path: format!("{file_label}: {location}"),
374            });
375        }
376    }
377
378    let mut bindings: HashMap<String, Value> = HashMap::with_capacity(def.params.len());
379    for (pname, pdef) in &def.params {
380        match directive.props.get(pname) {
381            Some(v) => {
382                bindings.insert(pname.clone(), v.clone());
383            }
384            None => match &pdef.default {
385                Some(d) => {
386                    bindings.insert(pname.clone(), d.clone());
387                }
388                None => {
389                    return Err(RustmotionError::ComponentParamMissing {
390                        component: directive.use_name.clone(),
391                        param: pname.clone(),
392                        path: format!("{file_label}: {location}"),
393                    })
394                }
395            },
396        }
397    }
398
399    let mut node = def.template.clone();
400    substitute(&mut node, &bindings, file_label)?;
401    Ok((directive.use_name.clone(), node))
402}
403
404fn describe_value(v: &Value) -> String {
405    match v {
406        Value::Null => "null".to_string(),
407        Value::Bool(b) => format!("boolean ({b})"),
408        Value::Number(n) => format!("number ({n})"),
409        Value::String(s) => {
410            let preview: String = s.chars().take(40).collect();
411            let ellipsis = if s.chars().count() > 40 { "…" } else { "" };
412            format!(
413                "string (\"{preview}{ellipsis}\"){}",
414                if s.starts_with('$') {
415                    " — looks like an unresolved/undeclared variable reference"
416                } else {
417                    ""
418                }
419            )
420        }
421        Value::Object(_) => "object".to_string(),
422        Value::Array(_) => "array".to_string(),
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use serde_json::json;
430
431    fn expand(mut value: Value) -> Result<Value> {
432        expand_directives(&mut value, "test.json")?;
433        Ok(value)
434    }
435
436    #[test]
437    fn template_bindings_are_not_reported_as_unresolved_before_expansion() {
438        let doc = json!({
439            "components": {
440                "card": {
441                    "params": { "label": { "type": "string" } },
442                    "template": { "type": "text", "content": "$label" }
443                }
444            },
445            "scenes": [{ "duration": 1.0, "children": [{
446                "for-each": [{ "label": "one" }],
447                "template": { "use": "card", "props": { "label": "$label" } }
448            }]}]
449        });
450        assert!(
451            crate::variables::find_unresolved(&doc).is_empty(),
452            "bindings inside components/template/props belong to expansion, \
453             not to the pre-expansion scan: {:?}",
454            crate::variables::find_unresolved(&doc)
455        );
456    }
457
458    #[test]
459    fn a_typo_inside_a_template_is_still_found_after_expansion() {
460        let expanded = expand(json!({
461            "video": { "width": 100, "height": 100 },
462            "scenes": [{ "duration": 1.0, "children": [{
463                "for-each": [{ "label": "one" }],
464                "template": { "type": "text", "content": "$labl" }
465            }]}]
466        }))
467        .expect("a typo is a warning, not a hard error");
468        assert_eq!(
469            crate::variables::find_unresolved(&expanded),
470            vec!["labl".to_string()],
471            "the leftover must be visible once template/props are gone"
472        );
473    }
474
475    #[test]
476    fn a_correct_binding_leaves_nothing_unresolved_after_expansion() {
477        let expanded = expand(json!({
478            "video": { "width": 100, "height": 100 },
479            "scenes": [{ "duration": 1.0, "children": [{
480                "for-each": [{ "label": "one" }],
481                "template": { "type": "text", "content": "$label" }
482            }]}]
483        }))
484        .expect("expands");
485        assert!(crate::variables::find_unresolved(&expanded).is_empty());
486    }
487
488    #[test]
489    fn for_each_repeats_template_once_per_element_binding_its_fields() {
490        let doc = json!({
491            "video": { "width": 100, "height": 100 },
492            "scenes": [{
493                "duration": 1.0,
494                "children": [{
495                    "for-each": [
496                        { "label": "Revenue", "value": 120 },
497                        { "label": "Users", "value": 340 }
498                    ],
499                    "template": { "type": "text", "content": "$label: $value" }
500                }]
501            }]
502        });
503        let out = expand(doc).unwrap();
504        let children = out["scenes"][0]["children"].as_array().unwrap();
505        assert_eq!(children.len(), 2);
506        assert_eq!(children[0]["content"], json!("Revenue: 120"));
507        assert_eq!(children[1]["content"], json!("Users: 340"));
508    }
509
510    #[test]
511    fn for_each_binds_index_and_whole_item() {
512        let doc = json!({
513            "video": { "width": 100, "height": 100 },
514            "scenes": [{
515                "duration": 1.0,
516                "children": [{
517                    "for-each": ["a", "b", "c"],
518                    "template": { "type": "text", "content": "$index:$item" }
519                }]
520            }]
521        });
522        let out = expand(doc).unwrap();
523        let children = out["scenes"][0]["children"].as_array().unwrap();
524        assert_eq!(children.len(), 3);
525        assert_eq!(children[0]["content"], json!("0:a"));
526        assert_eq!(children[1]["content"], json!("1:b"));
527        assert_eq!(children[2]["content"], json!("2:c"));
528    }
529
530    #[test]
531    fn for_each_binds_i_and_count_aliases_for_expr_grammar() {
532        let doc = json!({
533            "video": { "width": 100, "height": 100 },
534            "scenes": [{
535                "duration": 1.0,
536                "children": [{
537                    "for-each": ["a", "b", "c"],
538                    "template": { "type": "text", "content": "$i/$count" }
539                }]
540            }]
541        });
542        let out = expand(doc).unwrap();
543        let children = out["scenes"][0]["children"].as_array().unwrap();
544        assert_eq!(children.len(), 3);
545        assert_eq!(children[0]["content"], json!("0/3"));
546        assert_eq!(children[1]["content"], json!("1/3"));
547        assert_eq!(children[2]["content"], json!("2/3"));
548    }
549
550    #[test]
551    fn for_each_lets_explicit_item_fields_win_over_built_in_index() {
552        let doc = json!({
553            "video": { "width": 100, "height": 100 },
554            "scenes": [{
555                "duration": 1.0,
556                "children": [{
557                    "for-each": [{ "index": "custom", "label": "x" }],
558                    "template": { "type": "text", "content": "$index" }
559                }]
560            }]
561        });
562        let out = expand(doc).unwrap();
563        assert_eq!(out["scenes"][0]["children"][0]["content"], json!("custom"));
564    }
565
566    #[test]
567    fn for_each_over_empty_array_produces_nothing_and_is_not_an_error() {
568        let doc = json!({
569            "video": { "width": 100, "height": 100 },
570            "scenes": [{
571                "duration": 1.0,
572                "children": [{
573                    "for-each": [],
574                    "template": { "type": "text", "content": "unused" }
575                }]
576            }]
577        });
578        let out = expand(doc).unwrap();
579        assert_eq!(out["scenes"][0]["children"], json!([]));
580    }
581
582    #[test]
583    fn for_each_source_that_is_not_an_array_is_a_named_error_not_a_silent_empty_result() {
584        let doc = json!({
585            "video": { "width": 100, "height": 100 },
586            "scenes": [{
587                "duration": 1.0,
588                "children": [{
589                    "for-each": "$itms",
590                    "template": { "type": "text", "content": "$label" }
591                }]
592            }]
593        });
594        let err = expand(doc).expect_err("non-array for-each source must fail loudly");
595        assert!(
596            matches!(err, RustmotionError::ForEachNotArray { .. }),
597            "{err}"
598        );
599        let msg = err.to_string();
600        assert!(msg.contains("scenes[0].children[0]"), "{msg}");
601        assert!(msg.contains("unresolved"), "{msg}");
602    }
603
604    #[test]
605    fn for_each_missing_template_is_a_named_error() {
606        let doc = json!({
607            "video": { "width": 100, "height": 100 },
608            "scenes": [{
609                "duration": 1.0,
610                "children": [{ "for-each": [1, 2, 3] }]
611            }]
612        });
613        let err = expand(doc).expect_err("missing template must fail");
614        assert!(
615            matches!(err, RustmotionError::ForEachDirectiveInvalid { .. }),
616            "{err}"
617        );
618    }
619
620    #[test]
621    fn for_each_with_a_fragment_template_splices_every_sibling_in_place_not_a_nested_array() {
622        let doc = json!({
623            "video": { "width": 100, "height": 100 },
624            "scenes": [{
625                "duration": 1.0,
626                "children": [{
627                    "for-each": [ { "label": "A" }, { "label": "B" } ],
628                    "template": [
629                        { "type": "icon", "icon": "lucide:dot" },
630                        { "type": "text", "content": "$label" }
631                    ]
632                }]
633            }]
634        });
635        let out = expand(doc).unwrap();
636        let children = out["scenes"][0]["children"].as_array().unwrap();
637        assert_eq!(
638            children.len(),
639            4,
640            "2 iterations x 2 fragment nodes = 4 flat siblings, got: {children:#?}"
641        );
642        assert!(children.iter().all(|c| c.is_object()), "{children:#?}");
643        assert_eq!(children[0]["type"], json!("icon"));
644        assert_eq!(children[1]["content"], json!("A"));
645        assert_eq!(children[2]["type"], json!("icon"));
646        assert_eq!(children[3]["content"], json!("B"));
647    }
648
649    fn doc_with_stat_card(props: Value) -> Value {
650        json!({
651            "video": { "width": 100, "height": 100 },
652            "components": {
653                "stat_card": {
654                    "params": {
655                        "label": { "type": "string" },
656                        "value": { "type": "number", "default": 0 },
657                        "color": { "type": "string", "default": "#6366F1" }
658                    },
659                    "template": {
660                        "type": "card",
661                        "style": { "background": "$color" },
662                        "children": [
663                            { "type": "text", "content": "$label" },
664                            { "type": "counter", "value": "$value" }
665                        ]
666                    }
667                }
668            },
669            "scenes": [{
670                "duration": 1.0,
671                "children": [{ "use": "stat_card", "props": props }]
672            }]
673        })
674    }
675
676    #[test]
677    fn use_instantiates_a_component_with_props_overriding_defaults() {
678        let out = expand(doc_with_stat_card(
679            json!({ "label": "Revenue", "value": 42 }),
680        ))
681        .unwrap();
682        let card = &out["scenes"][0]["children"][0];
683        assert_eq!(card["type"], json!("card"));
684        assert_eq!(card["style"]["background"], json!("#6366F1"));
685        assert_eq!(card["children"][0]["content"], json!("Revenue"));
686        assert_eq!(card["children"][1]["value"], json!(42));
687    }
688
689    #[test]
690    fn use_falls_back_to_param_default_when_not_overridden() {
691        let out = expand(doc_with_stat_card(json!({ "label": "Users" }))).unwrap();
692        assert_eq!(
693            out["scenes"][0]["children"][0]["children"][1]["value"],
694            json!(0)
695        );
696    }
697
698    #[test]
699    fn components_block_does_not_survive_expansion() {
700        let out = expand(doc_with_stat_card(json!({ "label": "x" }))).unwrap();
701        assert!(out.get("components").is_none());
702    }
703
704    #[test]
705    fn use_of_unknown_component_is_a_named_error() {
706        let doc = json!({
707            "video": { "width": 100, "height": 100 },
708            "scenes": [{
709                "duration": 1.0,
710                "children": [{ "use": "does_not_exist", "props": {} }]
711            }]
712        });
713        let err = expand(doc).expect_err("unknown component must fail");
714        match &err {
715            RustmotionError::UnknownComponent { name, path } => {
716                assert_eq!(name, "does_not_exist");
717                assert!(path.contains("scenes[0].children[0]"), "{path}");
718            }
719            other => panic!("expected UnknownComponent, got {other}"),
720        }
721    }
722
723    #[test]
724    fn use_missing_a_required_parameter_is_a_named_error() {
725        let out = expand(doc_with_stat_card(json!({})));
726        let err = out.expect_err("missing required param must fail");
727        match &err {
728            RustmotionError::ComponentParamMissing {
729                component, param, ..
730            } => {
731                assert_eq!(component, "stat_card");
732                assert_eq!(param, "label");
733            }
734            other => panic!("expected ComponentParamMissing, got {other}"),
735        }
736    }
737
738    #[test]
739    fn use_with_an_undeclared_prop_key_is_a_named_error() {
740        let out = expand(doc_with_stat_card(
741            json!({ "label": "x", "labell": "typo" }),
742        ));
743        let err = out.expect_err("typo'd prop key must fail");
744        match &err {
745            RustmotionError::UnknownComponentParam { param, .. } => assert_eq!(param, "labell"),
746            other => panic!("expected UnknownComponentParam, got {other}"),
747        }
748    }
749
750    #[test]
751    fn use_of_a_component_that_uses_itself_is_a_named_cycle_not_a_stack_overflow() {
752        let doc = json!({
753            "video": { "width": 100, "height": 100 },
754            "components": {
755                "recursive": {
756                    "params": {},
757                    "template": { "type": "card", "children": [ { "use": "recursive", "props": {} } ] }
758                }
759            },
760            "scenes": [{
761                "duration": 1.0,
762                "children": [{ "use": "recursive", "props": {} }]
763            }]
764        });
765        let err = expand(doc).expect_err("self-referencing component must fail");
766        match &err {
767            RustmotionError::ComponentCycle { chain, .. } => {
768                assert!(chain.contains("recursive"), "{chain}");
769            }
770            other => panic!("expected ComponentCycle, got {other}"),
771        }
772    }
773
774    #[test]
775    fn indirect_two_hop_cycle_is_also_a_named_cycle() {
776        let doc = json!({
777            "video": { "width": 100, "height": 100 },
778            "components": {
779                "a": { "params": {}, "template": { "type": "card", "children": [ { "use": "b", "props": {} } ] } },
780                "b": { "params": {}, "template": { "type": "card", "children": [ { "use": "a", "props": {} } ] } }
781            },
782            "scenes": [{
783                "duration": 1.0,
784                "children": [{ "use": "a", "props": {} }]
785            }]
786        });
787        let err = expand(doc).expect_err("indirect cycle must fail");
788        match &err {
789            RustmotionError::ComponentCycle { chain, .. } => {
790                assert!(chain.contains('a') && chain.contains('b'), "{chain}");
791            }
792            other => panic!("expected ComponentCycle, got {other}"),
793        }
794    }
795
796    #[test]
797    fn use_with_a_fragment_template_splices_every_sibling_in_place() {
798        let doc = json!({
799            "video": { "width": 100, "height": 100 },
800            "components": {
801                "icon_label": {
802                    "params": { "label": { "type": "string" } },
803                    "template": [
804                        { "type": "icon", "icon": "lucide:dot" },
805                        { "type": "text", "content": "$label" }
806                    ]
807                }
808            },
809            "scenes": [{
810                "duration": 1.0,
811                "children": [{ "use": "icon_label", "props": { "label": "hi" } }]
812            }]
813        });
814        let out = expand(doc).unwrap();
815        let children = out["scenes"][0]["children"].as_array().unwrap();
816        assert_eq!(children.len(), 2, "{children:#?}");
817        assert_eq!(children[0]["type"], json!("icon"));
818        assert_eq!(children[1]["content"], json!("hi"));
819    }
820
821    #[test]
822    fn for_each_template_can_be_a_use_directive() {
823        let doc = json!({
824            "video": { "width": 100, "height": 100 },
825            "components": {
826                "row": {
827                    "params": { "label": { "type": "string" } },
828                    "template": { "type": "text", "content": "$label" }
829                }
830            },
831            "scenes": [{
832                "duration": 1.0,
833                "children": [{
834                    "for-each": [ { "label": "A" }, { "label": "B" } ],
835                    "template": { "use": "row", "props": { "label": "$label" } }
836                }]
837            }]
838        });
839        let out = expand(doc).unwrap();
840        let children = out["scenes"][0]["children"].as_array().unwrap();
841        assert_eq!(children.len(), 2);
842        assert_eq!(children[0]["content"], json!("A"));
843        assert_eq!(children[1]["content"], json!("B"));
844    }
845
846    #[test]
847    fn use_template_can_contain_a_nested_for_each() {
848        let doc = json!({
849            "video": { "width": 100, "height": 100 },
850            "components": {
851                "list_card": {
852                    "params": { "items": { "type": "array" } },
853                    "template": {
854                        "type": "card",
855                        "children": [{
856                            "for-each": "$items",
857                            "template": { "type": "text", "content": "$item" }
858                        }]
859                    }
860                }
861            },
862            "scenes": [{
863                "duration": 1.0,
864                "children": [{ "use": "list_card", "props": { "items": ["x", "y", "z"] } }]
865            }]
866        });
867        let out = expand(doc).unwrap();
868        let inner = out["scenes"][0]["children"][0]["children"]
869            .as_array()
870            .unwrap();
871        assert_eq!(inner.len(), 3);
872        assert_eq!(inner[2]["content"], json!("z"));
873    }
874
875    #[test]
876    fn nested_children_containers_are_expanded_recursively() {
877        let doc = json!({
878            "video": { "width": 100, "height": 100 },
879            "scenes": [{
880                "duration": 1.0,
881                "children": [{
882                    "type": "card",
883                    "children": [{
884                        "for-each": [{ "v": 1 }, { "v": 2 }],
885                        "template": { "type": "text", "content": "$v" }
886                    }]
887                }]
888            }]
889        });
890        let out = expand(doc).unwrap();
891        let inner = out["scenes"][0]["children"][0]["children"]
892            .as_array()
893            .unwrap();
894        assert_eq!(inner.len(), 2);
895        assert_eq!(inner[0]["content"], json!(1));
896        assert_eq!(inner[1]["content"], json!(2));
897    }
898
899    #[test]
900    fn for_each_authored_tree_is_identical_to_the_hand_written_equivalent() {
901        let generated = json!({
902            "video": { "width": 100, "height": 100 },
903            "scenes": [{
904                "duration": 1.0,
905                "children": [{
906                    "for-each": [
907                        { "label": "Revenue", "value": 120 },
908                        { "label": "Users", "value": 340 },
909                        { "label": "Growth", "value": 8 }
910                    ],
911                    "template": {
912                        "type": "card",
913                        "style": { "width": "200px" },
914                        "children": [
915                            { "type": "text", "content": "$label" },
916                            { "type": "counter", "value": "$value" }
917                        ]
918                    }
919                }]
920            }]
921        });
922
923        let hand_written = json!({
924            "video": { "width": 100, "height": 100 },
925            "scenes": [{
926                "duration": 1.0,
927                "children": [
928                    { "type": "card", "style": { "width": "200px" }, "children": [
929                        { "type": "text", "content": "Revenue" },
930                        { "type": "counter", "value": 120 }
931                    ]},
932                    { "type": "card", "style": { "width": "200px" }, "children": [
933                        { "type": "text", "content": "Users" },
934                        { "type": "counter", "value": 340 }
935                    ]},
936                    { "type": "card", "style": { "width": "200px" }, "children": [
937                        { "type": "text", "content": "Growth" },
938                        { "type": "counter", "value": 8 }
939                    ]}
940                ]
941            }]
942        });
943
944        let expanded = expand(generated).unwrap();
945        assert_eq!(
946            expanded, hand_written,
947            "the for-each-authored tree must be byte-for-byte identical (as JSON values) to the \
948             hand-written equivalent — this is the only proof that factoring changes nothing about \
949             what gets rendered"
950        );
951    }
952}