322 lines
12 KiB
Rust
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"]);
|
|
}
|