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}