1use std::collections::HashMap;
144
145use serde::Deserialize;
146use serde_json::Value;
147
148use crate::error::{Result, RustmotionError};
149use crate::schema::VariableType;
150use crate::variables::substitute;
151
152const MAX_EXPANSION_DEPTH: u32 = 64;
158
159#[derive(Debug, Clone, Deserialize)]
168#[serde(deny_unknown_fields)]
169struct ComponentDefinition {
170 #[serde(default)]
171 params: HashMap<String, ComponentParam>,
172 template: Value,
175}
176
177#[derive(Debug, Clone, Deserialize)]
178#[serde(deny_unknown_fields)]
179struct ComponentParam {
180 #[serde(rename = "type")]
181 #[allow(dead_code)]
182 param_type: VariableType,
184 #[serde(default)]
185 default: Option<Value>,
186 #[serde(default)]
187 #[allow(dead_code)]
188 description: Option<String>,
189}
190
191#[derive(Debug, Deserialize)]
193#[serde(deny_unknown_fields)]
194struct UseDirective {
195 #[serde(rename = "use")]
196 use_name: String,
197 #[serde(default)]
198 props: HashMap<String, Value>,
199}
200
201#[derive(Debug, Deserialize)]
204#[serde(deny_unknown_fields)]
205struct ForEachDirective {
206 #[serde(rename = "for-each")]
207 for_each: Value,
208 template: Value,
209}
210
211fn is_for_each(v: &Value) -> bool {
212 matches!(v, Value::Object(m) if m.contains_key("for-each"))
213}
214
215fn is_use(v: &Value) -> bool {
216 matches!(v, Value::Object(m) if m.contains_key("use"))
217}
218
219pub fn expand_directives(value: &mut Value, file_label: &str) -> Result<()> {
232 let defs = extract_component_definitions(value, file_label)?;
233
234 let Value::Object(root) = value else {
235 return Ok(());
236 };
237 root.remove("components");
238
239 if let Some(Value::Array(scenes)) = root.remove("scenes") {
240 let mut out = Vec::with_capacity(scenes.len());
241 for (i, mut scene) in scenes.into_iter().enumerate() {
242 let scene_path = format!("scenes[{i}]");
243 let mut stack = Vec::new();
244 walk_children(&mut scene, &defs, file_label, &scene_path, &mut stack, 0)?;
245 out.push(scene);
246 }
247 root.insert("scenes".to_string(), Value::Array(out));
248 }
249
250 if let Some(Value::Array(views)) = root.remove("composition") {
251 let mut out_views = Vec::with_capacity(views.len());
252 for (vi, mut view) in views.into_iter().enumerate() {
253 if let Value::Object(vmap) = &mut view {
254 if let Some(Value::Array(scenes)) = vmap.remove("scenes") {
255 let mut out = Vec::with_capacity(scenes.len());
256 for (si, mut scene) in scenes.into_iter().enumerate() {
257 let scene_path = format!("composition[{vi}].scenes[{si}]");
258 let mut stack = Vec::new();
259 walk_children(&mut scene, &defs, file_label, &scene_path, &mut stack, 0)?;
260 out.push(scene);
261 }
262 vmap.insert("scenes".to_string(), Value::Array(out));
263 }
264 }
265 out_views.push(view);
266 }
267 root.insert("composition".to_string(), Value::Array(out_views));
268 }
269
270 warn_unresolved_after_expansion(value, file_label);
271 Ok(())
272}
273
274fn warn_unresolved_after_expansion(value: &Value, file_label: &str) {
289 for name in crate::variables::find_unresolved(value) {
290 eprintln!(
291 "Warning: {}",
292 crate::error::RustmotionError::UnresolvedVariable {
293 name,
294 path: file_label.to_string(),
295 }
296 );
297 }
298}
299
300fn extract_component_definitions(
301 value: &Value,
302 file_label: &str,
303) -> Result<HashMap<String, ComponentDefinition>> {
304 let Value::Object(root) = value else {
305 return Ok(HashMap::new());
306 };
307 match root.get("components") {
308 None => Ok(HashMap::new()),
309 Some(Value::Object(defs_map)) => {
310 let mut out = HashMap::with_capacity(defs_map.len());
311 for (name, def_val) in defs_map {
312 let def: ComponentDefinition =
313 serde_json::from_value(def_val.clone()).map_err(|e| {
314 RustmotionError::ComponentDefinitionInvalid {
315 name: name.clone(),
316 path: file_label.to_string(),
317 reason: e.to_string(),
318 }
319 })?;
320 out.insert(name.clone(), def);
321 }
322 Ok(out)
323 }
324 Some(_) => Err(RustmotionError::ComponentsBlockNotObject {
325 path: file_label.to_string(),
326 }),
327 }
328}
329
330fn walk_children(
336 value: &mut Value,
337 defs: &HashMap<String, ComponentDefinition>,
338 file_label: &str,
339 location: &str,
340 stack: &mut Vec<String>,
341 depth: u32,
342) -> Result<()> {
343 match value {
344 Value::Object(map) => {
345 if matches!(map.get("children"), Some(Value::Array(_))) {
346 if let Some(Value::Array(arr)) = map.remove("children") {
347 let mut expanded = Vec::with_capacity(arr.len());
348 for (i, entry) in arr.into_iter().enumerate() {
349 let entry_loc = format!("{location}.children[{i}]");
350 expanded.extend(resolve_entry(
351 entry, defs, file_label, &entry_loc, stack, depth,
352 )?);
353 }
354 map.insert("children".to_string(), Value::Array(expanded));
355 }
356 }
357 for (k, v) in map.iter_mut() {
358 if k == "children" {
359 continue; }
361 walk_children(v, defs, file_label, location, stack, depth)?;
362 }
363 }
364 Value::Array(arr) => {
365 for v in arr.iter_mut() {
366 walk_children(v, defs, file_label, location, stack, depth)?;
367 }
368 }
369 _ => {}
370 }
371 Ok(())
372}
373
374fn resolve_entry(
389 entry: Value,
390 defs: &HashMap<String, ComponentDefinition>,
391 file_label: &str,
392 location: &str,
393 stack: &mut Vec<String>,
394 depth: u32,
395) -> Result<Vec<Value>> {
396 if depth > MAX_EXPANSION_DEPTH {
397 return Err(RustmotionError::ExpansionDepthExceeded {
398 limit: MAX_EXPANSION_DEPTH,
399 path: format!("{file_label}: {location}"),
400 });
401 }
402
403 if let Value::Array(fragment) = entry {
404 let mut out = Vec::with_capacity(fragment.len());
405 for (i, n) in fragment.into_iter().enumerate() {
406 let frag_loc = format!("{location}[{i}]");
407 out.extend(resolve_entry(
408 n,
409 defs,
410 file_label,
411 &frag_loc,
412 stack,
413 depth + 1,
414 )?);
415 }
416 return Ok(out);
417 }
418
419 if is_for_each(&entry) {
420 let produced = expand_for_each_directive(entry, file_label, location)?;
421 let mut out = Vec::with_capacity(produced.len());
422 for (i, node) in produced.into_iter().enumerate() {
423 let iter_loc = format!("{location}[{i}]");
424 out.extend(resolve_entry(
425 node,
426 defs,
427 file_label,
428 &iter_loc,
429 stack,
430 depth + 1,
431 )?);
432 }
433 return Ok(out);
434 }
435
436 if is_use(&entry) {
437 let (name, node) = expand_use_directive(entry, defs, file_label, location)?;
438 if stack.contains(&name) {
439 let mut chain = stack.clone();
440 chain.push(name);
441 return Err(RustmotionError::ComponentCycle {
442 chain: chain.join(" -> "),
443 path: format!("{file_label}: {location}"),
444 });
445 }
446 stack.push(name);
447 let result = resolve_entry(node, defs, file_label, location, stack, depth + 1);
448 stack.pop();
449 return result;
450 }
451
452 let mut node = entry;
453 walk_children(&mut node, defs, file_label, location, stack, depth)?;
454 Ok(vec![node])
455}
456
457fn expand_for_each_directive(entry: Value, file_label: &str, location: &str) -> Result<Vec<Value>> {
458 let directive: ForEachDirective =
459 serde_json::from_value(entry).map_err(|e| RustmotionError::ForEachDirectiveInvalid {
460 path: format!("{file_label}: {location}"),
461 reason: e.to_string(),
462 })?;
463
464 let items = match &directive.for_each {
465 Value::Array(items) => items.clone(),
466 other => {
467 return Err(RustmotionError::ForEachNotArray {
468 path: format!("{file_label}: {location}"),
469 found: describe_value(other),
470 })
471 }
472 };
473
474 let mut out = Vec::with_capacity(items.len());
475 for (idx, element) in items.into_iter().enumerate() {
476 let mut bindings: HashMap<String, Value> = HashMap::new();
477 if let Value::Object(obj) = &element {
478 for (k, v) in obj {
479 bindings.insert(k.clone(), v.clone());
480 }
481 }
482 bindings
485 .entry("index".to_string())
486 .or_insert_with(|| Value::from(idx));
487 bindings
488 .entry("item".to_string())
489 .or_insert_with(|| element.clone());
490
491 let mut node = directive.template.clone();
492 substitute(&mut node, &bindings, file_label)?;
493 out.push(node);
494 }
495 Ok(out)
496}
497
498fn expand_use_directive(
499 entry: Value,
500 defs: &HashMap<String, ComponentDefinition>,
501 file_label: &str,
502 location: &str,
503) -> Result<(String, Value)> {
504 let directive: UseDirective =
505 serde_json::from_value(entry).map_err(|e| RustmotionError::UseDirectiveInvalid {
506 path: format!("{file_label}: {location}"),
507 reason: e.to_string(),
508 })?;
509
510 let def = defs
511 .get(&directive.use_name)
512 .ok_or_else(|| RustmotionError::UnknownComponent {
513 name: directive.use_name.clone(),
514 path: format!("{file_label}: {location}"),
515 })?;
516
517 for key in directive.props.keys() {
518 if !def.params.contains_key(key) {
519 return Err(RustmotionError::UnknownComponentParam {
520 component: directive.use_name.clone(),
521 param: key.clone(),
522 path: format!("{file_label}: {location}"),
523 });
524 }
525 }
526
527 let mut bindings: HashMap<String, Value> = HashMap::with_capacity(def.params.len());
528 for (pname, pdef) in &def.params {
529 match directive.props.get(pname) {
530 Some(v) => {
531 bindings.insert(pname.clone(), v.clone());
532 }
533 None => match &pdef.default {
534 Some(d) => {
535 bindings.insert(pname.clone(), d.clone());
536 }
537 None => {
538 return Err(RustmotionError::ComponentParamMissing {
539 component: directive.use_name.clone(),
540 param: pname.clone(),
541 path: format!("{file_label}: {location}"),
542 })
543 }
544 },
545 }
546 }
547
548 let mut node = def.template.clone();
549 substitute(&mut node, &bindings, file_label)?;
550 Ok((directive.use_name.clone(), node))
551}
552
553fn describe_value(v: &Value) -> String {
554 match v {
555 Value::Null => "null".to_string(),
556 Value::Bool(b) => format!("boolean ({b})"),
557 Value::Number(n) => format!("number ({n})"),
558 Value::String(s) => {
559 let preview: String = s.chars().take(40).collect();
560 let ellipsis = if s.chars().count() > 40 { "…" } else { "" };
561 format!(
562 "string (\"{preview}{ellipsis}\"){}",
563 if s.starts_with('$') {
564 " — looks like an unresolved/undeclared variable reference"
565 } else {
566 ""
567 }
568 )
569 }
570 Value::Object(_) => "object".to_string(),
571 Value::Array(_) => "array".to_string(),
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use serde_json::json;
579
580 fn expand(mut value: Value) -> Result<Value> {
581 expand_directives(&mut value, "test.json")?;
582 Ok(value)
583 }
584
585 #[test]
593 fn template_bindings_are_not_reported_as_unresolved_before_expansion() {
594 let doc = json!({
595 "components": {
596 "card": {
597 "params": { "label": { "type": "string" } },
598 "template": { "type": "text", "content": "$label" }
599 }
600 },
601 "scenes": [{ "duration": 1.0, "children": [{
602 "for-each": [{ "label": "one" }],
603 "template": { "use": "card", "props": { "label": "$label" } }
604 }]}]
605 });
606 assert!(
607 crate::variables::find_unresolved(&doc).is_empty(),
608 "bindings inside components/template/props belong to expansion, \
609 not to the pre-expansion scan: {:?}",
610 crate::variables::find_unresolved(&doc)
611 );
612 }
613
614 #[test]
619 fn a_typo_inside_a_template_is_still_found_after_expansion() {
620 let expanded = expand(json!({
621 "video": { "width": 100, "height": 100 },
622 "scenes": [{ "duration": 1.0, "children": [{
623 "for-each": [{ "label": "one" }],
624 "template": { "type": "text", "content": "$labl" }
625 }]}]
626 }))
627 .expect("a typo is a warning, not a hard error");
628 assert_eq!(
629 crate::variables::find_unresolved(&expanded),
630 vec!["labl".to_string()],
631 "the leftover must be visible once template/props are gone"
632 );
633 }
634
635 #[test]
638 fn a_correct_binding_leaves_nothing_unresolved_after_expansion() {
639 let expanded = expand(json!({
640 "video": { "width": 100, "height": 100 },
641 "scenes": [{ "duration": 1.0, "children": [{
642 "for-each": [{ "label": "one" }],
643 "template": { "type": "text", "content": "$label" }
644 }]}]
645 }))
646 .expect("expands");
647 assert!(crate::variables::find_unresolved(&expanded).is_empty());
648 }
649
650 #[test]
653 fn for_each_repeats_template_once_per_element_binding_its_fields() {
654 let doc = json!({
655 "video": { "width": 100, "height": 100 },
656 "scenes": [{
657 "duration": 1.0,
658 "children": [{
659 "for-each": [
660 { "label": "Revenue", "value": 120 },
661 { "label": "Users", "value": 340 }
662 ],
663 "template": { "type": "text", "content": "$label: $value" }
664 }]
665 }]
666 });
667 let out = expand(doc).unwrap();
668 let children = out["scenes"][0]["children"].as_array().unwrap();
669 assert_eq!(children.len(), 2);
670 assert_eq!(children[0]["content"], json!("Revenue: 120"));
671 assert_eq!(children[1]["content"], json!("Users: 340"));
672 }
673
674 #[test]
675 fn for_each_binds_index_and_whole_item() {
676 let doc = json!({
677 "video": { "width": 100, "height": 100 },
678 "scenes": [{
679 "duration": 1.0,
680 "children": [{
681 "for-each": ["a", "b", "c"],
682 "template": { "type": "text", "content": "$index:$item" }
683 }]
684 }]
685 });
686 let out = expand(doc).unwrap();
687 let children = out["scenes"][0]["children"].as_array().unwrap();
688 assert_eq!(children.len(), 3);
689 assert_eq!(children[0]["content"], json!("0:a"));
690 assert_eq!(children[1]["content"], json!("1:b"));
691 assert_eq!(children[2]["content"], json!("2:c"));
692 }
693
694 #[test]
695 fn for_each_lets_explicit_item_fields_win_over_built_in_index() {
696 let doc = json!({
697 "video": { "width": 100, "height": 100 },
698 "scenes": [{
699 "duration": 1.0,
700 "children": [{
701 "for-each": [{ "index": "custom", "label": "x" }],
702 "template": { "type": "text", "content": "$index" }
703 }]
704 }]
705 });
706 let out = expand(doc).unwrap();
707 assert_eq!(out["scenes"][0]["children"][0]["content"], json!("custom"));
708 }
709
710 #[test]
711 fn for_each_over_empty_array_produces_nothing_and_is_not_an_error() {
712 let doc = json!({
713 "video": { "width": 100, "height": 100 },
714 "scenes": [{
715 "duration": 1.0,
716 "children": [{
717 "for-each": [],
718 "template": { "type": "text", "content": "unused" }
719 }]
720 }]
721 });
722 let out = expand(doc).unwrap();
723 assert_eq!(out["scenes"][0]["children"], json!([]));
724 }
725
726 #[test]
727 fn for_each_source_that_is_not_an_array_is_a_named_error_not_a_silent_empty_result() {
728 let doc = json!({
733 "video": { "width": 100, "height": 100 },
734 "scenes": [{
735 "duration": 1.0,
736 "children": [{
737 "for-each": "$itms",
738 "template": { "type": "text", "content": "$label" }
739 }]
740 }]
741 });
742 let err = expand(doc).expect_err("non-array for-each source must fail loudly");
743 assert!(
744 matches!(err, RustmotionError::ForEachNotArray { .. }),
745 "{err}"
746 );
747 let msg = err.to_string();
748 assert!(msg.contains("scenes[0].children[0]"), "{msg}");
749 assert!(msg.contains("unresolved"), "{msg}");
750 }
751
752 #[test]
753 fn for_each_missing_template_is_a_named_error() {
754 let doc = json!({
755 "video": { "width": 100, "height": 100 },
756 "scenes": [{
757 "duration": 1.0,
758 "children": [{ "for-each": [1, 2, 3] }]
759 }]
760 });
761 let err = expand(doc).expect_err("missing template must fail");
762 assert!(
763 matches!(err, RustmotionError::ForEachDirectiveInvalid { .. }),
764 "{err}"
765 );
766 }
767
768 #[test]
769 fn for_each_with_a_fragment_template_splices_every_sibling_in_place_not_a_nested_array() {
770 let doc = json!({
775 "video": { "width": 100, "height": 100 },
776 "scenes": [{
777 "duration": 1.0,
778 "children": [{
779 "for-each": [ { "label": "A" }, { "label": "B" } ],
780 "template": [
781 { "type": "icon", "icon": "lucide:dot" },
782 { "type": "text", "content": "$label" }
783 ]
784 }]
785 }]
786 });
787 let out = expand(doc).unwrap();
788 let children = out["scenes"][0]["children"].as_array().unwrap();
789 assert_eq!(
790 children.len(),
791 4,
792 "2 iterations x 2 fragment nodes = 4 flat siblings, got: {children:#?}"
793 );
794 assert!(children.iter().all(|c| c.is_object()), "{children:#?}");
795 assert_eq!(children[0]["type"], json!("icon"));
796 assert_eq!(children[1]["content"], json!("A"));
797 assert_eq!(children[2]["type"], json!("icon"));
798 assert_eq!(children[3]["content"], json!("B"));
799 }
800
801 fn doc_with_stat_card(props: Value) -> Value {
804 json!({
805 "video": { "width": 100, "height": 100 },
806 "components": {
807 "stat_card": {
808 "params": {
809 "label": { "type": "string" },
810 "value": { "type": "number", "default": 0 },
811 "color": { "type": "string", "default": "#6366F1" }
812 },
813 "template": {
814 "type": "card",
815 "style": { "background": "$color" },
816 "children": [
817 { "type": "text", "content": "$label" },
818 { "type": "counter", "value": "$value" }
819 ]
820 }
821 }
822 },
823 "scenes": [{
824 "duration": 1.0,
825 "children": [{ "use": "stat_card", "props": props }]
826 }]
827 })
828 }
829
830 #[test]
831 fn use_instantiates_a_component_with_props_overriding_defaults() {
832 let out = expand(doc_with_stat_card(
833 json!({ "label": "Revenue", "value": 42 }),
834 ))
835 .unwrap();
836 let card = &out["scenes"][0]["children"][0];
837 assert_eq!(card["type"], json!("card"));
838 assert_eq!(card["style"]["background"], json!("#6366F1"));
839 assert_eq!(card["children"][0]["content"], json!("Revenue"));
840 assert_eq!(card["children"][1]["value"], json!(42));
841 }
842
843 #[test]
844 fn use_falls_back_to_param_default_when_not_overridden() {
845 let out = expand(doc_with_stat_card(json!({ "label": "Users" }))).unwrap();
846 assert_eq!(
847 out["scenes"][0]["children"][0]["children"][1]["value"],
848 json!(0)
849 );
850 }
851
852 #[test]
853 fn components_block_does_not_survive_expansion() {
854 let out = expand(doc_with_stat_card(json!({ "label": "x" }))).unwrap();
855 assert!(out.get("components").is_none());
856 }
857
858 #[test]
859 fn use_of_unknown_component_is_a_named_error() {
860 let doc = json!({
861 "video": { "width": 100, "height": 100 },
862 "scenes": [{
863 "duration": 1.0,
864 "children": [{ "use": "does_not_exist", "props": {} }]
865 }]
866 });
867 let err = expand(doc).expect_err("unknown component must fail");
868 match &err {
869 RustmotionError::UnknownComponent { name, path } => {
870 assert_eq!(name, "does_not_exist");
871 assert!(path.contains("scenes[0].children[0]"), "{path}");
872 }
873 other => panic!("expected UnknownComponent, got {other}"),
874 }
875 }
876
877 #[test]
878 fn use_missing_a_required_parameter_is_a_named_error() {
879 let out = expand(doc_with_stat_card(json!({})));
882 let err = out.expect_err("missing required param must fail");
883 match &err {
884 RustmotionError::ComponentParamMissing {
885 component, param, ..
886 } => {
887 assert_eq!(component, "stat_card");
888 assert_eq!(param, "label");
889 }
890 other => panic!("expected ComponentParamMissing, got {other}"),
891 }
892 }
893
894 #[test]
895 fn use_with_an_undeclared_prop_key_is_a_named_error() {
896 let out = expand(doc_with_stat_card(
897 json!({ "label": "x", "labell": "typo" }),
898 ));
899 let err = out.expect_err("typo'd prop key must fail");
900 match &err {
901 RustmotionError::UnknownComponentParam { param, .. } => assert_eq!(param, "labell"),
902 other => panic!("expected UnknownComponentParam, got {other}"),
903 }
904 }
905
906 #[test]
907 fn use_of_a_component_that_uses_itself_is_a_named_cycle_not_a_stack_overflow() {
908 let doc = json!({
909 "video": { "width": 100, "height": 100 },
910 "components": {
911 "recursive": {
912 "params": {},
913 "template": { "type": "card", "children": [ { "use": "recursive", "props": {} } ] }
914 }
915 },
916 "scenes": [{
917 "duration": 1.0,
918 "children": [{ "use": "recursive", "props": {} }]
919 }]
920 });
921 let err = expand(doc).expect_err("self-referencing component must fail");
922 match &err {
923 RustmotionError::ComponentCycle { chain, .. } => {
924 assert!(chain.contains("recursive"), "{chain}");
925 }
926 other => panic!("expected ComponentCycle, got {other}"),
927 }
928 }
929
930 #[test]
931 fn indirect_two_hop_cycle_is_also_a_named_cycle() {
932 let doc = json!({
933 "video": { "width": 100, "height": 100 },
934 "components": {
935 "a": { "params": {}, "template": { "type": "card", "children": [ { "use": "b", "props": {} } ] } },
936 "b": { "params": {}, "template": { "type": "card", "children": [ { "use": "a", "props": {} } ] } }
937 },
938 "scenes": [{
939 "duration": 1.0,
940 "children": [{ "use": "a", "props": {} }]
941 }]
942 });
943 let err = expand(doc).expect_err("indirect cycle must fail");
944 match &err {
945 RustmotionError::ComponentCycle { chain, .. } => {
946 assert!(chain.contains('a') && chain.contains('b'), "{chain}");
947 }
948 other => panic!("expected ComponentCycle, got {other}"),
949 }
950 }
951
952 #[test]
953 fn use_with_a_fragment_template_splices_every_sibling_in_place() {
954 let doc = json!({
955 "video": { "width": 100, "height": 100 },
956 "components": {
957 "icon_label": {
958 "params": { "label": { "type": "string" } },
959 "template": [
960 { "type": "icon", "icon": "lucide:dot" },
961 { "type": "text", "content": "$label" }
962 ]
963 }
964 },
965 "scenes": [{
966 "duration": 1.0,
967 "children": [{ "use": "icon_label", "props": { "label": "hi" } }]
968 }]
969 });
970 let out = expand(doc).unwrap();
971 let children = out["scenes"][0]["children"].as_array().unwrap();
972 assert_eq!(children.len(), 2, "{children:#?}");
973 assert_eq!(children[0]["type"], json!("icon"));
974 assert_eq!(children[1]["content"], json!("hi"));
975 }
976
977 #[test]
980 fn for_each_template_can_be_a_use_directive() {
981 let doc = json!({
982 "video": { "width": 100, "height": 100 },
983 "components": {
984 "row": {
985 "params": { "label": { "type": "string" } },
986 "template": { "type": "text", "content": "$label" }
987 }
988 },
989 "scenes": [{
990 "duration": 1.0,
991 "children": [{
992 "for-each": [ { "label": "A" }, { "label": "B" } ],
993 "template": { "use": "row", "props": { "label": "$label" } }
994 }]
995 }]
996 });
997 let out = expand(doc).unwrap();
998 let children = out["scenes"][0]["children"].as_array().unwrap();
999 assert_eq!(children.len(), 2);
1000 assert_eq!(children[0]["content"], json!("A"));
1001 assert_eq!(children[1]["content"], json!("B"));
1002 }
1003
1004 #[test]
1005 fn use_template_can_contain_a_nested_for_each() {
1006 let doc = json!({
1007 "video": { "width": 100, "height": 100 },
1008 "components": {
1009 "list_card": {
1010 "params": { "items": { "type": "array" } },
1011 "template": {
1012 "type": "card",
1013 "children": [{
1014 "for-each": "$items",
1015 "template": { "type": "text", "content": "$item" }
1016 }]
1017 }
1018 }
1019 },
1020 "scenes": [{
1021 "duration": 1.0,
1022 "children": [{ "use": "list_card", "props": { "items": ["x", "y", "z"] } }]
1023 }]
1024 });
1025 let out = expand(doc).unwrap();
1026 let inner = out["scenes"][0]["children"][0]["children"]
1027 .as_array()
1028 .unwrap();
1029 assert_eq!(inner.len(), 3);
1030 assert_eq!(inner[2]["content"], json!("z"));
1031 }
1032
1033 #[test]
1034 fn nested_children_containers_are_expanded_recursively() {
1035 let doc = json!({
1036 "video": { "width": 100, "height": 100 },
1037 "scenes": [{
1038 "duration": 1.0,
1039 "children": [{
1040 "type": "card",
1041 "children": [{
1042 "for-each": [{ "v": 1 }, { "v": 2 }],
1043 "template": { "type": "text", "content": "$v" }
1044 }]
1045 }]
1046 }]
1047 });
1048 let out = expand(doc).unwrap();
1049 let inner = out["scenes"][0]["children"][0]["children"]
1050 .as_array()
1051 .unwrap();
1052 assert_eq!(inner.len(), 2);
1053 assert_eq!(inner[0]["content"], json!(1));
1054 assert_eq!(inner[1]["content"], json!(2));
1055 }
1056
1057 #[test]
1060 fn for_each_authored_tree_is_identical_to_the_hand_written_equivalent() {
1061 let generated = json!({
1062 "video": { "width": 100, "height": 100 },
1063 "scenes": [{
1064 "duration": 1.0,
1065 "children": [{
1066 "for-each": [
1067 { "label": "Revenue", "value": 120 },
1068 { "label": "Users", "value": 340 },
1069 { "label": "Growth", "value": 8 }
1070 ],
1071 "template": {
1072 "type": "card",
1073 "style": { "width": "200px" },
1074 "children": [
1075 { "type": "text", "content": "$label" },
1076 { "type": "counter", "value": "$value" }
1077 ]
1078 }
1079 }]
1080 }]
1081 });
1082
1083 let hand_written = json!({
1084 "video": { "width": 100, "height": 100 },
1085 "scenes": [{
1086 "duration": 1.0,
1087 "children": [
1088 { "type": "card", "style": { "width": "200px" }, "children": [
1089 { "type": "text", "content": "Revenue" },
1090 { "type": "counter", "value": 120 }
1091 ]},
1092 { "type": "card", "style": { "width": "200px" }, "children": [
1093 { "type": "text", "content": "Users" },
1094 { "type": "counter", "value": 340 }
1095 ]},
1096 { "type": "card", "style": { "width": "200px" }, "children": [
1097 { "type": "text", "content": "Growth" },
1098 { "type": "counter", "value": 8 }
1099 ]}
1100 ]
1101 }]
1102 });
1103
1104 let expanded = expand(generated).unwrap();
1105 assert_eq!(
1106 expanded, hand_written,
1107 "the for-each-authored tree must be byte-for-byte identical (as JSON values) to the \
1108 hand-written equivalent — this is the only proof that factoring changes nothing about \
1109 what gets rendered"
1110 );
1111 }
1112}