diff --git a/crates/api/src/yaml/compiler.rs b/crates/api/src/yaml/compiler.rs index fc085616..85046a9c 100644 --- a/crates/api/src/yaml/compiler.rs +++ b/crates/api/src/yaml/compiler.rs @@ -220,35 +220,46 @@ fn compile_dag( .map(|(name, def)| { let mut params = def.params; - if def.kind == "audio::mixer" && mode != EngineMode::Dynamic { - if let Some(count) = incoming_counts.get(&name) { - if *count > 1 { - if let Some(serde_json::Value::Object(ref mut map)) = params { - let should_inject = matches!( - map.get("num_inputs"), - Some(serde_json::Value::Null) | None - ); - if should_inject { + if def.kind == "audio::mixer" { + if let Some(ref p) = params { + if !p.is_object() { + return Err(format!( + "audio::mixer node '{name}' params must be an object, got {}", + value_type_name(p), + )); + } + } + + if mode != EngineMode::Dynamic { + if let Some(count) = incoming_counts.get(&name) { + if *count > 1 { + if let Some(serde_json::Value::Object(ref mut map)) = params { + let should_inject = matches!( + map.get("num_inputs"), + Some(serde_json::Value::Null) | None + ); + if should_inject { + map.insert( + "num_inputs".to_string(), + serde_json::Value::Number((*count).into()), + ); + } + } else if params.is_none() { + let mut map = serde_json::Map::new(); map.insert( "num_inputs".to_string(), serde_json::Value::Number((*count).into()), ); + params = Some(serde_json::Value::Object(map)); } - } else if params.is_none() { - let mut map = serde_json::Map::new(); - map.insert( - "num_inputs".to_string(), - serde_json::Value::Number((*count).into()), - ); - params = Some(serde_json::Value::Object(map)); } } } } - (name, Node { kind: def.kind, params, state: None }) + Ok((name, Node { kind: def.kind, params, state: None })) }) - .collect(); + .collect::, _>>()?; Ok(Pipeline { name, @@ -262,6 +273,17 @@ fn compile_dag( }) } +fn value_type_name(v: &serde_json::Value) -> &'static str { + match v { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "bool", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } +} + #[cfg(test)] mod tests { use super::*; @@ -335,7 +357,7 @@ mod tests { } #[test] - fn compile_dag_oneshot_audio_mixer_skips_inject_when_params_is_non_object() { + fn compile_dag_audio_mixer_rejects_non_object_params() { let mixer = UserNode { kind: "audio::mixer".to_string(), params: Some(serde_json::Value::String("scalar".into())), @@ -346,8 +368,46 @@ mod tests { ("b", user_node("core::source", Needs::None)), ("mixer", mixer), ]); - let compiled = compile(dag_pipeline(nodes, EngineMode::OneShot)).expect("compile"); - let mixer = compiled.nodes.get("mixer").expect("mixer present"); - assert_eq!(mixer.params.as_ref().and_then(serde_json::Value::as_str), Some("scalar")); + let err = compile(dag_pipeline(nodes, EngineMode::OneShot)) + .expect_err("non-object params should be rejected"); + assert!( + err.contains("audio::mixer node 'mixer' params must be an object"), + "error should mention the node name and requirement: {err}", + ); + assert!(err.contains("string"), "error should mention the actual type: {err}"); + } + + #[test] + fn compile_dag_dynamic_audio_mixer_also_rejects_non_object_params() { + let mixer = UserNode { + kind: "audio::mixer".to_string(), + params: Some(serde_json::Value::Number(42.into())), + needs: mixer_needs(), + }; + let nodes = dag_nodes(vec![ + ("a", user_node("core::source", Needs::None)), + ("b", user_node("core::source", Needs::None)), + ("mixer", mixer), + ]); + let err = compile(dag_pipeline(nodes, EngineMode::Dynamic)) + .expect_err("non-object params should be rejected even in dynamic mode"); + assert!(err.contains("number"), "error should mention the actual type: {err}"); + } + + #[test] + fn compile_dag_audio_mixer_rejects_null_params() { + let mixer = UserNode { + kind: "audio::mixer".to_string(), + params: Some(serde_json::Value::Null), + needs: mixer_needs(), + }; + let nodes = dag_nodes(vec![ + ("a", user_node("core::source", Needs::None)), + ("b", user_node("core::source", Needs::None)), + ("mixer", mixer), + ]); + let err = compile(dag_pipeline(nodes, EngineMode::OneShot)) + .expect_err("null params should be rejected"); + assert!(err.contains("got null"), "error should mention null type: {err}"); } }