use crate::ecs::SYSTEMS;
use std::time::{Duration, Instant};
use super::resumed_origin;
use crate::components::{Animation, AnimationGraph, AnimationParams};
use crate::ecs::SkinnedMeshHandle;
use crate::ecs::World;
use crate::ecs::asset_id::intern;
#[test]
fn resumed_origin_freezes_clip_time_across_pause() {
let start = Instant::now();
let anchor = start + Duration::from_secs(5);
let now = anchor + Duration::from_secs(30);
let t_at_pause = (anchor - start).as_secs_f32();
let new_origin = resumed_origin(start, anchor, now);
let t_on_resume = (now - new_origin).as_secs_f32();
assert!(
(t_on_resume - t_at_pause).abs() < 1e-6,
"clip time jumped across the pause: {t_at_pause} -> {t_on_resume}"
);
}
#[test]
fn animation_component_spawns_internal_system() {
let mut world = World::new();
world.add_component(Animation::default());
world.start(SYSTEMS).unwrap();
let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
assert_eq!(names, ["AnimationSystem"]);
}
#[test]
fn no_animation_no_internal_system() {
let mut world = World::new();
world.start(SYSTEMS).unwrap();
assert!(world.systems().is_empty());
}
#[test]
fn anim_graph_component_spawns_internal_system() {
let mut world = World::new();
world.add_component(AnimationGraph::default());
world.start(SYSTEMS).unwrap();
let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
assert_eq!(names, ["AnimationSystem"]);
}
fn clip(name: &str, duration: f32) -> Animation {
crate::ecs::asset_id::ensure_name_resolver();
let mut a: Animation = serde_json::from_value(serde_json::json!({
"target": "hero",
"duration": duration,
"looping": true,
}))
.unwrap();
a.asset_id = intern(name);
a
}
fn hero_graph() -> AnimationGraph {
let mut g: AnimationGraph = serde_json::from_value(serde_json::json!({
"target": "hero",
"parameters": [{"name": "speed", "default": 0.0}],
"initial": "idle",
"states": [
{"name": "idle", "clip": "idle_clip"},
{"name": "run", "clip": "run_clip"}
],
"transitions": [
{"from": "idle", "to": "run",
"conditions": [{"parameter": "speed", "op": "gt", "value": 0.5}]},
{"from": "run", "to": "idle",
"conditions": [{"parameter": "speed", "op": "le", "value": 0.5}]}
]
}))
.unwrap();
g.asset_id = intern("hero_graph");
g
}
fn graph_world() -> World {
let mut world = World::new();
world.add_component(clip("idle_clip", 1.0));
world.add_component(clip("run_clip", 0.8));
world.add_component(hero_graph());
world.start(SYSTEMS).unwrap();
world
}
fn hero() -> SkinnedMeshHandle {
SkinnedMeshHandle(intern("hero").0)
}
fn with_anim<R>(world: &mut World, f: impl FnOnce(&mut super::AnimationSystem) -> R) -> R {
for system in world.systems_mut() {
if let Some(anim) = system.downcast_mut::<super::AnimationSystem>() {
return f(anim);
}
}
panic!("AnimationSystem not constructed");
}
#[test]
fn graph_init_seeds_params_and_initial_state() {
let mut world = graph_world();
world.step();
let params: Vec<&AnimationParams> = world.query::<AnimationParams>().collect();
assert_eq!(params.len(), 1);
assert_eq!(params[0].target, hero());
assert_eq!(params[0].values, vec![0.0]);
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "idle");
assert_eq!(report.params, vec![("speed".to_string(), 0.0)]);
assert!(
report.blend_weights.is_none(),
"single-clip states report no blend weights"
);
}
#[test]
fn blendspace_weights_follow_the_parameter() {
let target = SkinnedMeshHandle(intern("hero_blend").0);
let mut world = World::new();
for (name, duration) in [("bl_idle", 1.0), ("bl_walk", 0.8), ("bl_run", 0.6)] {
let mut a = clip(name, duration);
a.target = Some(SkinnedMeshHandle(target.0));
world.add_component(a);
}
let mut g: AnimationGraph = serde_json::from_value(serde_json::json!({
"target": "hero_blend",
"parameters": [{"name": "speed", "default": 0.0}],
"states": [
{"name": "locomotion", "blend": {"kind": "blend1d", "parameter": "speed",
"sync": true,
"points": [
{"value": 0.0, "clip": "bl_idle"},
{"value": 1.6, "clip": "bl_walk"},
{"value": 5.0, "clip": "bl_run"}
]}}
]
}))
.unwrap();
g.asset_id = intern("hero_blend_graph");
world.add_component(g);
world.start(SYSTEMS).unwrap();
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(target).unwrap());
assert_eq!(report.state, "locomotion");
assert_eq!(report.blend_weights, Some(vec![1.0, 0.0, 0.0]));
with_anim(&mut world, |anim| {
anim.queue_param(target, "speed", 3.3).unwrap();
});
world.step();
let w = with_anim(&mut world, |anim| anim.graph_report(target).unwrap())
.blend_weights
.unwrap();
assert_eq!(w[0], 0.0);
assert!(
(w[1] - 0.5).abs() < 1e-4 && (w[2] - 0.5).abs() < 1e-4,
"{w:?}"
);
}
#[test]
fn graph_transitions_on_component_write() {
let mut world = graph_world();
world.step();
for p in world.query_mut::<AnimationParams>() {
p.set(0, 2.0);
}
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "run");
for p in world.query_mut::<AnimationParams>() {
p.set(0, 0.0);
}
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "idle");
}
#[test]
fn queued_param_writes_component_and_drives_graph() {
let mut world = graph_world();
world.step();
with_anim(&mut world, |anim| {
anim.queue_param(hero(), "speed", 3.0).unwrap();
assert!(
anim.queue_param(hero(), "nope", 1.0)
.unwrap_err()
.contains("no parameter"),
);
});
world.step();
let params: Vec<&AnimationParams> = world.query::<AnimationParams>().collect();
assert_eq!(params[0].values, vec![3.0], "write landed in the component");
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "run");
}
#[test]
fn mode_mismatched_commands_are_rejected() {
let mut world = graph_world();
world.step();
with_anim(&mut world, |anim| {
let err = anim
.apply_crossfade(hero(), vec![1.0, 0.0], 0.0, 0.0)
.unwrap_err();
assert!(err.contains("graph-driven"));
assert!(err.contains("anim-param"));
});
let mut flat_world = World::new();
let mut a = clip("solo_clip", 1.0);
a.target = Some(SkinnedMeshHandle(intern("flat_hero").0));
flat_world.add_component(a);
flat_world.start(SYSTEMS).unwrap();
flat_world.step();
with_anim(&mut flat_world, |anim| {
let target = SkinnedMeshHandle(intern("flat_hero").0);
anim.apply_crossfade(target, vec![0.5], 0.0, 0.0).unwrap();
let err = anim.queue_param(target, "speed", 1.0).unwrap_err();
assert!(err.contains("anim-crossfade"));
let err = anim.graph_report(target).unwrap_err();
assert!(err.contains("no AnimationGraph"));
});
}
#[test]
fn root_motion_clip_publishes_displacement_events() {
let target = SkinnedMeshHandle(intern("hero_rm").0);
let mut world = World::new();
let mut a: Animation = serde_json::from_value(serde_json::json!({
"target": "hero_rm",
"duration": 1.0,
"looping": true,
"root_motion": true,
"root_track": [
{"time": 0.0, "translation": [0.0, 0.0, 0.0]},
{"time": 1.0, "translation": [2.0, 0.0, 0.0]}
],
}))
.unwrap();
a.asset_id = intern("hero_rm_walk");
world.add_component(a);
world.start(SYSTEMS).unwrap();
world.step();
std::thread::sleep(Duration::from_millis(5));
world.step();
let events = world
.events::<crate::components::RootMotionEvent>()
.expect("RootMotionEvent queue exists");
let mut cursor = crate::ecs::EventCursor::default();
let motions: Vec<_> = events.read(&mut cursor).collect();
assert!(!motions.is_empty(), "expected displacement events");
let total: f32 = motions
.iter()
.filter(|m| m.target == target)
.map(|m| m.delta[0])
.sum();
assert!(total > 0.0, "walk moves +X: {total}");
assert!(
motions
.iter()
.all(|m| m.delta[1] == 0.0 && m.delta[2] == 0.0)
);
}
#[test]
fn root_motion_events_emit_in_handle_order() {
let mut world = World::new();
let mut handles = Vec::new();
for name in ["rm_ord_c", "rm_ord_a", "rm_ord_b"] {
handles.push(SkinnedMeshHandle(intern(name).0));
let mut a: Animation = serde_json::from_value(serde_json::json!({
"target": name,
"duration": 1.0,
"looping": true,
"root_motion": true,
"root_track": [
{"time": 0.0, "translation": [0.0, 0.0, 0.0]},
{"time": 1.0, "translation": [2.0, 0.0, 0.0]}
],
}))
.unwrap();
a.asset_id = intern(&format!("{name}_clip"));
world.add_component(a);
}
world.start(SYSTEMS).unwrap();
world.step();
std::thread::sleep(Duration::from_millis(5));
world.step();
let events = world
.events::<crate::components::RootMotionEvent>()
.expect("RootMotionEvent queue exists");
let mut cursor = crate::ecs::EventCursor::default();
let order: Vec<_> = events.read(&mut cursor).map(|m| m.target).collect();
assert_eq!(order.len(), handles.len(), "one event per moving target");
let mut sorted = order.clone();
sorted.sort();
assert_eq!(order, sorted, "events are published in handle order");
}
#[test]
fn ik_pins_the_foot_to_a_raised_ledge() {
use crate::gfx::skeleton::{Joint, JointPose, Skeleton};
let target = SkinnedMeshHandle(intern("hero_ik").0);
let mut world = World::new();
let joint = |name: &str, parent: Option<usize>, t: [f32; 3]| Joint {
name: name.to_string(),
parent,
bind: JointPose {
translation: t,
..JointPose::default()
},
};
let skeleton = Skeleton::new(vec![
joint("hip", None, [0.6, 2.0, 0.0]),
joint("knee", Some(0), [0.0, -1.0, 0.0]),
joint("foot", Some(1), [0.0, -1.0, 0.0]),
]);
world.add_component(crate::components::SkeletonPose::new(target, 0, skeleton));
world.add_component(crate::components::CharacterRig::new(
target,
0,
crate::gfx::transform::IDENTITY,
0.5,
0.3,
));
let mut stand: Animation = serde_json::from_value(serde_json::json!({
"target": "hero_ik",
"duration": 1.0,
"looping": true,
"tracks": [{"joint": 0, "keyframes": [
{"time": 0.0, "translation": [0.6, 2.0, 0.0]},
{"time": 1.0, "translation": [0.6, 2.0, 0.0]}
]}],
}))
.unwrap();
stand.asset_id = intern("hero_ik_stand");
world.add_component(stand);
let mut graph: AnimationGraph = serde_json::from_value(serde_json::json!({
"target": "hero_ik",
"states": [{"name": "stand", "clip": "hero_ik_stand"}],
"ik_chains": [{"joints": ["hip", "knee", "foot"], "pole": [0.0, 0.0, 1.0]}],
}))
.unwrap();
graph.asset_id = intern("hero_ik_graph");
world.add_component(graph);
world.add_component(crate::components::PhysicsConfig::default());
world.add_component(crate::components::Prop {
asset_id: intern("ledge"),
position: [0.75, 0.1, 0.0],
collider: Some(crate::components::PropCollider {
shape: "cuboid".to_string(),
half_extents: [0.3, 0.15, 0.3],
radius: 0.0,
half_height: 0.0,
layer: String::new(),
}),
..Default::default()
});
world.start(SYSTEMS).unwrap();
for _ in 0..8 {
world.step();
std::thread::sleep(Duration::from_millis(5));
}
let pose = world
.query::<crate::components::SkeletonPose>()
.next()
.expect("pose survives");
let foot_mesh = {
let m = pose.joint_matrices[2];
let b = pose.skeleton.bind_position(2);
[
m[0][0] * b[0] + m[1][0] * b[1] + m[2][0] * b[2] + m[3][0],
m[0][1] * b[0] + m[1][1] * b[1] + m[2][1] * b[2] + m[3][1],
m[0][2] * b[0] + m[1][2] * b[1] + m[2][2] * b[2] + m[3][2],
]
};
let rig_y = world
.query::<crate::components::CharacterRig>()
.next()
.unwrap()
.position[1];
let foot_world_y = foot_mesh[1] + rig_y;
assert!(
(foot_world_y - 0.25).abs() < 0.03,
"foot pinned to the ledge top: world y = {foot_world_y}"
);
assert!((foot_mesh[0] - 0.6).abs() < 0.02, "{foot_mesh:?}");
}
#[test]
fn rig_capsule_follows_root_motion() {
let target = SkinnedMeshHandle(intern("hero_rig").0);
let mut world = World::new();
let mut a: Animation = serde_json::from_value(serde_json::json!({
"target": "hero_rig",
"duration": 1.0,
"looping": true,
"root_motion": true,
"root_track": [
{"time": 0.0, "translation": [0.0, 0.0, 0.0]},
{"time": 1.0, "translation": [2.0, 0.0, 0.0]}
],
}))
.unwrap();
a.asset_id = intern("hero_rig_walk");
world.add_component(a);
world.add_component(crate::components::PhysicsConfig::default());
world.add_component(crate::components::CharacterRig::new(
target,
0,
crate::gfx::transform::IDENTITY,
0.5,
0.3,
));
world.start(SYSTEMS).unwrap();
for _ in 0..4 {
world.step();
std::thread::sleep(Duration::from_millis(5));
}
let rig = world
.query::<crate::components::CharacterRig>()
.next()
.expect("rig survives");
assert!(
rig.position[0] > 0.0,
"capsule advanced along the walk: {:?}",
rig.position
);
assert!(
rig.moved,
"render follow flag set (no GraphicsSystem to clear it)"
);
assert!(
rig.position[1] > -0.2,
"flat floor holds the capsule up: {:?}",
rig.position
);
}
#[test]
fn graph_freezes_while_menu_open() {
let mut world = graph_world();
world.step();
world.insert_resource(crate::ecs::MenuActive(true));
for p in world.query_mut::<AnimationParams>() {
p.set(0, 2.0);
}
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(
report.state, "idle",
"paused step must not take transitions"
);
world.insert_resource(crate::ecs::MenuActive(false));
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "run", "resumed step sees the parameter");
}
fn runtime_clip(duration: f32) -> crate::gfx::skeleton::AnimationClip {
crate::gfx::skeleton::AnimationClip {
morph_keys: Vec::new(),
duration,
looping: true,
tracks: Vec::new(),
root: None,
}
}
fn flat_clip(name: &str, target: SkinnedMeshHandle) -> Animation {
let mut a = clip(name, 1.0);
a.target = Some(SkinnedMeshHandle(target.0));
a
}
#[test]
fn apply_reloaded_clip_reseats_a_flat_slot_and_rejects_bad_targets() {
let target = SkinnedMeshHandle(intern("flat_reload").0);
let mut world = World::new();
world.add_component(flat_clip("fr_solo", target));
world.start(SYSTEMS).unwrap();
world.step();
with_anim(&mut world, |anim| {
assert!(
anim.apply_reloaded_clip(target, 0, runtime_clip(3.0), 0.5),
"a valid slot accepts the reload"
);
assert!(
!anim.apply_reloaded_clip(
SkinnedMeshHandle(intern("nobody").0),
0,
runtime_clip(1.0),
1.0
),
"an unknown target is refused"
);
assert!(
!anim.apply_reloaded_clip(target, 9, runtime_clip(1.0), 1.0),
"an out-of-range slot is refused"
);
});
}
#[test]
fn apply_reloaded_clip_refreshes_graph_clip_duration() {
let mut world = graph_world();
world.step();
let applied = with_anim(&mut world, |anim| {
anim.apply_reloaded_clip(hero(), 0, runtime_clip(2.5), 1.0)
});
assert!(applied, "the graph bucket's idle slot reloads");
world.step();
let report = with_anim(&mut world, |anim| anim.graph_report(hero()).unwrap());
assert_eq!(report.state, "idle");
}
#[test]
fn reload_entries_is_empty_without_captured_sources() {
let mut world = graph_world();
world.step();
let count = with_anim(&mut world, |anim| anim.reload_entries().len());
assert_eq!(count, 0);
}
#[test]
fn debug_impl_summarizes_target_and_reload_counts() {
let mut world = graph_world();
world.step();
let text = with_anim(&mut world, |anim| format!("{anim:?}"));
assert!(text.contains("AnimationSystem"), "{text}");
assert!(text.contains("targets: 1"), "{text}");
assert!(text.contains("reload_entries: 0"), "{text}");
}
fn single_joint_pose(target: SkinnedMeshHandle) -> crate::components::SkeletonPose {
use crate::gfx::skeleton::{Joint, JointPose, Skeleton};
let skeleton = Skeleton::new(vec![Joint {
name: "root".to_string(),
parent: None,
bind: JointPose::default(),
}]);
crate::components::SkeletonPose::new(target, 0, skeleton)
}
#[test]
fn flat_single_clip_samples_the_pose() {
let target = SkinnedMeshHandle(intern("flat_single_pose").0);
let mut world = World::new();
world.add_component(flat_clip("fs_solo", target));
world.add_component(single_joint_pose(target));
world.start(SYSTEMS).unwrap();
world.step();
let matrices = world
.query::<crate::components::SkeletonPose>()
.next()
.map(|p| p.joint_matrices.len())
.unwrap();
assert_eq!(matrices, 1, "one skinning matrix for the one joint");
}
#[test]
fn flat_fade_in_blends_multiple_clips_into_the_pose() {
let target = SkinnedMeshHandle(intern("flat_blend_pose").0);
let mut world = World::new();
let mut faded = flat_clip("fb_a", target);
faded.fade_in_secs = 0.5;
world.add_component(faded);
world.add_component(flat_clip("fb_b", target));
world.add_component(single_joint_pose(target));
world.start(SYSTEMS).unwrap();
world.step();
world.step();
let matrices = world
.query::<crate::components::SkeletonPose>()
.next()
.map(|p| p.joint_matrices.len())
.unwrap();
assert_eq!(matrices, 1, "the weighted blend produced a pose");
}
#[test]
fn morph_base_layer_composes_with_clip_morph_tracks() {
use crate::components::MorphKey;
use crate::gfx::proportions::ProportionLayer;
let target = SkinnedMeshHandle(intern("morph_base_pose").0);
let mut world = World::new();
let mut a = flat_clip("mb_clip", target);
a.morph_track = vec![
MorphKey {
time: 0.0,
weights: vec![0.3, 0.9],
},
MorphKey {
time: 1.0,
weights: vec![0.3, 0.9],
},
];
world.add_component(a);
world.add_component(
single_joint_pose(target).with_shape(vec![0.5, 0.5], ProportionLayer::default()),
);
world.start(SYSTEMS).unwrap();
world.step();
let weights = world
.query::<crate::components::SkeletonPose>()
.next()
.map(|p| p.morph_weights.clone())
.unwrap();
assert!((weights[0] - 0.8).abs() < 1e-5, "{weights:?}");
assert_eq!(weights[1], 1.0, "base + clip clamps at 1");
let target = SkinnedMeshHandle(intern("morph_base_only").0);
let mut world = World::new();
world.add_component(flat_clip("mb_plain", target));
world.add_component(
single_joint_pose(target).with_shape(vec![0.25], ProportionLayer::default()),
);
world.start(SYSTEMS).unwrap();
world.step();
let pose = world
.query::<crate::components::SkeletonPose>()
.next()
.unwrap();
assert_eq!(pose.morph_weights, [0.25]);
assert!(pose.updated);
}
#[test]
fn proportions_apply_to_the_sampled_pose() {
use crate::components::JointProportion;
use crate::gfx::proportions::ProportionLayer;
let target = SkinnedMeshHandle(intern("proportioned_pose").0);
let mut world = World::new();
world.add_component(flat_clip("pp_clip", target));
let pose = single_joint_pose(target);
let layer = ProportionLayer::resolve(
&pose.skeleton,
&[JointProportion {
joint: "root".into(),
scale: 3.0,
length: 0.0,
}],
);
world.add_component(pose.with_shape(Vec::new(), layer));
world.start(SYSTEMS).unwrap();
world.step();
let pose = world
.query::<crate::components::SkeletonPose>()
.next()
.unwrap();
assert_eq!(pose.joint_matrices[0][0][0], 3.0);
}