use aac::graph::*; use serde_json::Value; const AVATAR: &str = include_str!("../examples/avatar.rhai"); const ANIMATION_STORE: &str = include_str!("../examples/animation_store.rhai"); fn json(script: &str) -> Value { serde_json::from_str(&aac::evaluate_to_json(script).expect("script should evaluate")).expect("valid json") } fn error(script: &str) -> String { aac::evaluate_to_json(script).expect_err("script should fail") } /// A minimal but valid graph, for mutating into invalid shapes. fn valid_graph() -> ControllerGraph { let script = r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let speed = aac.float_param("Speed", 0.0); let ctrl = aac.new_controller(); let base = ctrl.layer("Base"); let a = base.state("A", 0, 0); let b = base.state("B", 1, 0); let clip = aac.clip("clip"); clip.looping(true); a.set_clip(clip); a.transition_to(b).when(speed > 0.5).duration(0.1); "#; serde_json::from_str(&aac::evaluate_to_json(script).expect("valid")).expect("deserializes") } #[test] fn example_script_describes_the_whole_controller() { let value = json(AVATAR); assert_eq!(value["system_name"], "MyAvatar"); assert_eq!(value["asset_key"], "AAC_"); assert_eq!(value["parameters"].as_array().unwrap().len(), 3); assert_eq!(value["clips"].as_array().unwrap().len(), 4); assert_eq!(value["blend_trees"].as_array().unwrap().len(), 1); let layers = value["controller"]["layers"].as_array().unwrap(); assert_eq!(layers.len(), 1); assert_eq!(layers[0]["name"], "Base"); let states = layers[0]["state_machine"]["states"].as_array().unwrap(); assert_eq!(states.len(), 4); assert_eq!(states[0]["name"], "Idle"); assert_eq!(states[0]["position"]["x"], 0); // A clip used both as a state motion and as a blend tree child. assert_eq!(states[0]["motion"]["type"], "clip"); assert_eq!(states[0]["motion"]["name"], "idle_anim"); assert_eq!(states[2]["motion"]["type"], "blend_tree"); assert_eq!(states[2]["motion"]["name"], "locomotion"); // `speed > 0.1` becomes a greater-than condition. let transition = &states[0]["transitions"][0]; assert_eq!(transition["conditions"][0]["parameter"], "Speed"); assert_eq!(transition["conditions"][0]["mode"], "greater"); assert_eq!(transition["conditions"][0]["threshold"], 0.1); assert_eq!(transition["duration"], 0.25); assert_eq!(transition["has_exit_time"], false); // `when_all([...])` produces two conditions on one transition. let both = states[1]["transitions"][1]["conditions"].as_array().unwrap(); assert_eq!(both.len(), 2); assert_eq!(both[1]["parameter"], "IsSitting"); assert_eq!(both[1]["mode"], "if_not"); let any_state = layers[0]["state_machine"]["any_state_transitions"].as_array().unwrap(); assert_eq!(any_state.len(), 1); assert_eq!(any_state[0]["to"], "Sit"); let sub = layers[0]["state_machine"]["sub_machines"].as_array().unwrap(); assert_eq!(sub.len(), 1); assert_eq!(sub[0]["name"], "Gestures"); assert_eq!(sub[0]["states"].as_array().unwrap().len(), 2); // Blend shape keys landed in time order on one curve. let clip = find_clip(&value, "idle_anim"); let curve = curve_of(clip, "blendShape.Smile"); assert_eq!(curve["target"], "skinned_mesh_renderer"); assert_eq!(curve["keys"].as_array().unwrap().len(), 2); assert_eq!(curve["keys"][0]["time"], 0.0); assert_eq!(curve["keys"][1]["time"], 1.0); // A toggle is a one-frame constant: two keys with the same value. let toggle = curve_of(find_clip(&value, "walk_anim"), "m_IsActive"); assert_eq!(toggle["target"], "game_object"); let keys = toggle["keys"].as_array().unwrap(); assert_eq!(keys.len(), 2); assert_eq!(keys[0]["value"], keys[1]["value"]); } fn find_clip<'a>(value: &'a Value, name: &str) -> &'a Value { value["clips"].as_array().unwrap().iter().find(|c| c["name"] == name).unwrap() } fn curve_of<'a>(clip: &'a Value, property: &str) -> &'a Value { clip["curves"] .as_array() .unwrap() .iter() .find(|c| c["property"] == property) .unwrap() } #[test] fn animation_store_clips_are_referenced_not_generated() { let value = json( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let store = aac.AnimationStore("Assets/Doloro/Clips/"); let walk = store.clip("Walk"); let ctrl = aac.new_controller(); let base = ctrl.layer("Base"); let s = base.state("S", 0, 0); s.set_clip(walk); "#, ); let clips = value["clips"].as_array().unwrap(); assert_eq!(clips.len(), 1); assert_eq!(clips[0]["name"], "Assets/Doloro/Clips/Walk.anim"); assert_eq!(clips[0]["source"], "Assets/Doloro/Clips/Walk.anim"); assert!(clips[0]["curves"].as_array().unwrap().is_empty()); // Untouched, it keeps the source asset's looping setting. assert!(clips[0].get("looping").is_none()); // A generated clip keeps its old shape: no `source` key at all. let generated = json( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let c = aac.clip("c"); "#, ); assert!(generated["clips"][0].get("source").is_none()); } #[test] fn animation_store_resolves_one_clip_per_asset() { // The same asset asked for twice, with and without the extension, stays a single clip. let value = json( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let store = aac.AnimationStore("Assets/Doloro/Clips"); let x = store.clip("Walk"); let y = store.clip("Walk.anim"); let ctrl = aac.new_controller(); let base = ctrl.layer("Base"); let s = base.state("S", 0, 0); s.set_clip(x); "#, ); assert_eq!(value["clips"].as_array().unwrap().len(), 1); } #[test] fn animation_store_clips_can_be_edited_on_a_clone() { let value = json( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let store = aac.AnimationStore("Assets/Doloro/Clips"); let walk = store.clip("Walk"); walk.looping(true); walk.keyframe("Body", "m_IsActive", 0.0, 1.0); let ctrl = aac.new_controller(); let base = ctrl.layer("Base"); let s = base.state("S", 0, 0); s.set_clip(walk); "#, ); let clip = &value["clips"][0]; // The source is still carried, so the Unity side knows which asset to clone. assert_eq!(clip["source"], "Assets/Doloro/Clips/Walk.anim"); assert_eq!(clip["looping"], true); assert_eq!(clip["curves"][0]["property"], "m_IsActive"); } /// The store example is a whole controller, so evaluating it exercises every store path end to end. #[test] fn animation_store_example_sources_every_clip() { let value = json(ANIMATION_STORE); assert_eq!(value["system_name"], "AnimationStore"); assert_eq!(value["parameters"].as_array().unwrap().len(), 5); assert_eq!(value["controller"]["layers"].as_array().unwrap().len(), 2); // Every clip comes from an asset, and each asset is declared exactly once. let clips = value["clips"].as_array().unwrap(); let sources: Vec<&str> = clips .iter() .map(|clip| clip["source"].as_str().expect("every clip has a source")) .collect(); let unique: std::collections::HashSet<&str> = sources.iter().copied().collect(); assert_eq!(unique.len(), sources.len(), "the same asset must not be declared twice"); assert_eq!(clips.len(), 11); // An edited clip carries its override and curves; an untouched one carries neither. let walk = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Walk.anim"); assert_eq!(walk["looping"], true); assert_eq!(curve_of(walk, "m_IsActive")["target"], "game_object"); let idle = find_clip(&value, "Assets/Doloro/Clips/Locomotion/Idle.anim"); assert!(idle.get("looping").is_none()); assert!(idle["curves"].as_array().unwrap().is_empty()); // A store clip feeds a state and a blend tree, and a tree may nest a tree declared before it. let trees = value["blend_trees"].as_array().unwrap(); assert_eq!(trees.len(), 4); let movement = trees.iter().find(|t| t["name"] == "movement").unwrap(); assert_eq!(movement["children"].as_array().unwrap().len(), 3); let full_body = trees.iter().find(|t| t["name"] == "full_body").unwrap(); assert_eq!(full_body["children"][0]["motion"]["type"], "blend_tree"); assert_eq!(full_body["children"][0]["motion"]["name"], "movement"); } #[test] fn keyframes_are_sorted_by_time() { let value = json( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let c = aac.clip("c"); c.keyframe("Body", "m_IsActive", 2.0, 1.0); c.keyframe("Body", "m_IsActive", 0.5, 0.0); c.keyframe("Body", "m_IsActive", 1.0, 1.0); "#, ); let times: Vec = value["clips"][0]["curves"][0]["keys"] .as_array() .unwrap() .iter() .map(|k| k["time"].as_f64().unwrap()) .collect(); assert_eq!(times, vec![0.5, 1.0, 2.0]); } #[test] fn blend_tree_shapes() { let value = json( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let x = aac.float_param("X", 0.0); let y = aac.float_param("Y", 0.0); let direct = aac.float_param("Direct", 0.0); let ctrl = aac.new_controller(); let base = ctrl.layer("Base"); let s = base.state("S", 0, 0); let one = aac.clip("one"); let two = aac.clip("two"); let inner = aac.blend_tree("inner"); inner.simple_1d(x); inner.add_motion(one, 0.0); let outer = aac.blend_tree("outer"); outer.simple_directional_2d(x, y); outer.add_motion(two, 0.0, 1.0); outer.add_motion(inner, 2.0, 3.0); outer.automatic_thresholds(true); s.set_motion(outer); let flat = aac.blend_tree("flat"); flat.direct(); flat.add_motion_direct(one, direct); "#, ); let trees = value["blend_trees"].as_array().unwrap(); let outer = trees.iter().find(|t| t["name"] == "outer").unwrap(); assert_eq!(outer["blend_type"], "simple_directional_2d"); assert_eq!(outer["param_x"], "X"); assert_eq!(outer["param_y"], "Y"); assert_eq!(outer["use_automatic_thresholds"], true); let children = outer["children"].as_array().unwrap(); assert_eq!(children.len(), 2); assert_eq!(children[0]["threshold_y"], 1.0); assert_eq!(children[1]["motion"]["type"], "blend_tree"); assert_eq!(children[1]["motion"]["name"], "inner"); let flat = trees.iter().find(|t| t["name"] == "flat").unwrap(); assert_eq!(flat["blend_type"], "direct"); assert_eq!(flat["param_x"], ""); assert_eq!(flat["children"][0]["direct_param"], "Direct"); } #[test] fn json_round_trips_through_the_graph_type() { // Compare text, not `Value`: `serde_json::Value` widens f32 to f64, which changes the digits. let json = aac::evaluate_to_json(AVATAR).expect("evaluates"); let graph: ControllerGraph = serde_json::from_str(&json).expect("deserializes"); let reserialized = serde_json::to_string_pretty(&graph).expect("serializes"); assert_eq!(reserialized, json); } #[test] fn missing_configuration_is_reported() { assert!(error("let aac = AnimatorAsCode(); aac.asset_key(\"K\");").contains("system_name is not set")); assert!(error("let aac = AnimatorAsCode(); aac.system_name(\"S\");").contains("asset_key is not set")); } #[test] fn duplicate_names_are_rejected_at_the_call_site() { assert!(error( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let a = aac.float_param("Speed", 0.0); let b = aac.float_param("Speed", 0.0); "# ) .contains("already declared")); assert!(error( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let a = aac.clip("c"); let b = aac.clip("c"); "# ) .contains("already declared")); } #[test] fn unknown_curve_property_is_rejected() { let message = error( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let c = aac.clip("c"); c.keyframe("Body", "m_NoSuchThing", 0.0, 1.0); "#, ); assert!(message.contains("cannot tell which component"), "unexpected: {message}"); } #[test] fn and_operator_is_not_available() { // The one DSL limitation: Rhai's `&&` cannot be overloaded, so use `when_all([...])`. let message = error( r#" let aac = AnimatorAsCode(); aac.system_name("S"); aac.asset_key("K"); let a = aac.float_param("A", 0.0); let b = aac.bool_param("B", false); let ctrl = aac.new_controller(); let base = ctrl.layer("Base"); let s = base.state("S", 0, 0); let t = base.state("T", 1, 0); s.transition_to(t).when(a > 0.0 && b == true); "#, ); assert!(message.contains("bool"), "unexpected: {message}"); } /// The validator is a safety net for graphs that do not come from the typed Rhai DSL. #[test] fn validator_rejects_broken_graphs() { let mut graph = valid_graph(); graph.controller.layers[0].state_machine.states[0].transitions[0].to = "Nowhere".to_string(); let message = aac::export::to_json(&graph).expect_err("dangling target"); assert!(message.contains("not a state in that layer"), "unexpected: {message}"); let mut graph = valid_graph(); graph.controller.layers[0].state_machine.states[0].transitions[0].conditions[0].parameter = "Ghost".to_string(); let message = aac::export::to_json(&graph).expect_err("undeclared parameter"); assert!(message.contains("not declared"), "unexpected: {message}"); let mut graph = valid_graph(); graph.clips.push(graph.clips[0].clone()); let message = aac::export::to_json(&graph).expect_err("duplicate clip"); assert!(message.contains("declared more than once"), "unexpected: {message}"); let mut graph = valid_graph(); graph.controller.layers[0].state_machine.states[0].motion = Some(MotionRef::Clip { name: "ghost".into() }); let message = aac::export::to_json(&graph).expect_err("dangling motion"); assert!(message.contains("not declared"), "unexpected: {message}"); // Forward references across states must resolve. let graph = valid_graph(); aac::export::to_json(&graph).expect("a valid graph stays valid"); } /// The Unity side deserializes this JSON with `MissingMemberHandling.Error`, so a field added or /// renamed here breaks it at runtime, where no test could catch it. Pin every object's key set. #[test] fn json_keys_are_the_ones_the_unity_dtos_expect() { let value = json(AVATAR); fn keys(value: &Value) -> Vec<&str> { value.as_object().expect("object").keys().map(String::as_str).collect() } assert_eq!(keys(&value), ["asset_key", "blend_trees", "clips", "controller", "parameters", "system_name"]); assert_eq!(keys(&value["parameters"][0]), ["default", "name", "type"]); assert_eq!(keys(&value["clips"][0]), ["curves", "looping", "name"]); assert_eq!(keys(&value["clips"][0]["curves"][0]), ["keys", "path", "property", "target"]); assert_eq!(keys(&value["clips"][0]["curves"][0]["keys"][0]), ["in_tangent", "out_tangent", "time", "value"]); let tree = &value["blend_trees"][0]; assert_eq!(keys(tree), ["blend_type", "children", "name", "param_x", "param_y", "use_automatic_thresholds"]); assert_eq!(keys(&tree["children"][0]), ["direct_param", "motion", "threshold", "threshold_y"]); assert_eq!(keys(&tree["children"][0]["motion"]), ["name", "type"]); let layer = &value["controller"]["layers"][0]; assert_eq!(keys(layer), ["name", "state_machine"]); assert_eq!(keys(&layer["state_machine"]), ["any_state_transitions", "name", "position", "states", "sub_machines"]); assert_eq!(keys(&layer["state_machine"]["position"]), ["x", "y"]); assert_eq!(keys(&layer["state_machine"]["states"][0]), ["motion", "name", "position", "transitions"]); assert_eq!( keys(&layer["state_machine"]["states"][0]["transitions"][0]), [ "can_transition_to_self", "conditions", "duration", "exit_time", "has_exit_time", "ordered_interruption", "source_interruption", "to" ] ); assert_eq!(keys(&layer["state_machine"]["states"][0]["transitions"][0]["conditions"][0]), ["mode", "parameter", "threshold"]); }