Files
av3-animation-as-crab/rust/tests/dsl.rs
T
2026-09-18 14:01:10 +01:00

322 lines
12 KiB
Rust

use aac::graph::*;
use serde_json::Value;
const AVATAR: &str = include_str!("../examples/avatar.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(), 3);
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 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<f64> = 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"]);
}