use animsmith_core::fixtures::{
self, WALK_FOOT_AMPLITUDE as FOOT_AMPLITUDE, WALK_KEYS as KEYS, WALK_STRIDE as STRIDE,
WalkBones,
};
use animsmith_core::metrics::MIN_STRIDE_STEP_M;
use animsmith_core::model::*;
use animsmith_core::profile::{ResolvedRoles, Role};
use animsmith_core::{CheckCtx, Config, MetricGrids, Severity, all_checks, run_checks};
use glam::Vec3;
use std::f64::consts::TAU;
const BONES: WalkBones = WalkBones {
hips: "pelvis",
left_foot: "l_foot",
right_foot: "r_foot",
};
fn skeleton() -> Skeleton {
BONES.skeleton()
}
fn roles(skel: &Skeleton) -> ResolvedRoles {
ResolvedRoles::from_names(
skel,
[
(Role::Hips, BONES.hips.to_string()),
(Role::LeftFoot, BONES.left_foot.to_string()),
(Role::RightFoot, BONES.right_foot.to_string()),
],
)
}
fn foot_track(bone: BoneId, rest: Vec3, sign: f32, periods: f64) -> Track {
fixtures::foot_track(bone, rest, sign, periods, STRIDE, f64::sin)
}
fn doc_with_periods(periods: f64) -> Document {
doc_with_periods_and_stride(periods, STRIDE)
}
fn doc_with_periods_and_stride(periods: f64, stride: f32) -> Document {
fixtures::walk_doc(&BONES, "walk", periods, stride, f64::sin)
}
fn walk_doc() -> Document {
doc_with_periods(1.0)
}
fn popped_doc() -> Document {
doc_with_periods(0.75)
}
fn lint_with(doc: &Document, config: &Config) -> Vec<animsmith_core::Finding> {
let roles = roles(&doc.skeleton);
let grids = MetricGrids::new(doc);
let ctx = CheckCtx::new(&grids, &roles, config);
run_checks(&ctx, &all_checks())
}
fn lint_unresolved(doc: &Document, config: &Config) -> Vec<animsmith_core::Finding> {
let roles = ResolvedRoles::default();
let grids = MetricGrids::new(doc);
let ctx = CheckCtx::new(&grids, &roles, config);
run_checks(&ctx, &all_checks())
}
fn assert_loop_seam_is_skipped_for_unresolved_roles(findings: &[animsmith_core::Finding]) {
let loop_seam: Vec<_> = findings
.iter()
.filter(|f| f.check_id == "loop-seam")
.collect();
assert_eq!(
loop_seam.len(),
1,
"expected unresolved-role note: {loop_seam:#?}"
);
assert_eq!(loop_seam[0].severity, Severity::Note);
assert!(loop_seam[0].message.contains("hips/foot"));
}
fn json_config(json: serde_json::Value) -> Config {
serde_json::from_value(json).expect("config parses")
}
#[test]
fn default_min_stride_step_matches_metric_default() {
assert_eq!(
Config::default().loop_seam_min_stride_step_m(),
MIN_STRIDE_STEP_M
);
}
#[test]
fn analytic_walk_metrics() {
let doc = walk_doc();
let roles = roles(&doc.skeleton);
let config = Config::default();
let grids = MetricGrids::new(&doc);
let measurements = animsmith_core::measure::measure_document(&grids, &roles, &config);
let walk = &measurements["walk"];
let gait = walk.gait.as_ref().expect("gait measured");
let phase = gait.phase.expect("phase measured");
assert!(
(phase - 0.75).abs() < 1e-3,
"expected phase 0.75, got {phase}"
);
assert!(
(gait.lr_amplitude_m - 4.0 * FOOT_AMPLITUDE as f64).abs() < 0.01,
"expected ~{}, got {}",
4.0 * FOOT_AMPLITUDE as f64,
gait.lr_amplitude_m
);
let seam = walk.loop_seam_ratio.expect("seam measured");
assert!(seam < 0.1, "expected ~0 seam, got {seam}");
}
#[test]
fn clean_loop_passes_loop_seam() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } }
}));
let findings = lint_with(&doc, &config);
assert!(
!findings.iter().any(|f| f.check_id == "loop-seam"),
"clean loop flagged: {findings:#?}"
);
}
#[test]
fn check_ctx_does_not_resolve_declarative_rig_config() {
let doc = walk_doc();
let inline_config = json_config(serde_json::json!({
"rig": {
"profile": "auto",
"roles": {
"hips": "pelvis",
"left_foot": "l_foot",
"right_foot": "r_foot"
}
},
"clips": { "walk": { "loop": true } }
}));
assert_loop_seam_is_skipped_for_unresolved_roles(&lint_unresolved(&doc, &inline_config));
let mut profile_doc = doc;
profile_doc.skeleton.bones[1].name = "foot_l".into();
profile_doc.skeleton.bones[2].name = "foot_r".into();
let profile_config = json_config(serde_json::json!({
"rig": { "profile": "ue-mannequin" },
"clips": { "walk": { "loop": true } }
}));
assert_loop_seam_is_skipped_for_unresolved_roles(&lint_unresolved(
&profile_doc,
&profile_config,
));
}
#[test]
fn seam_pop_is_flagged_on_declared_loop() {
let doc = popped_doc();
let config = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } }
}));
let findings = lint_with(&doc, &config);
let seam = findings
.iter()
.find(|f| f.check_id == "loop-seam")
.expect("seam finding");
assert_eq!(seam.severity, Severity::Error);
assert_eq!(seam.clip.as_deref(), Some("walk"));
}
#[test]
fn min_stride_step_config_controls_tiny_stride_ratio() {
let doc = doc_with_periods_and_stride(0.75, 0.01);
let roles = roles(&doc.skeleton);
let default_floor = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } }
}));
let tuned_floor = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } },
"checks": { "loop-seam": { "min_stride_step_m": 0.001 } }
}));
let default_grids = MetricGrids::new(&doc);
let default_measurements =
animsmith_core::measure::measure_document(&default_grids, &roles, &default_floor);
assert_eq!(default_measurements["walk"].loop_seam_ratio, None);
let default_findings = lint_with(&doc, &default_floor);
assert!(
!default_findings.iter().any(|f| f.check_id == "loop-seam"),
"default floor should suppress tiny-stride seam ratio: {default_findings:#?}"
);
let tuned_grids = MetricGrids::new(&doc);
let tuned_measurements =
animsmith_core::measure::measure_document(&tuned_grids, &roles, &tuned_floor);
let ratio = tuned_measurements["walk"]
.loop_seam_ratio
.expect("configured stride floor reports ratio");
let expected_ratio = 1.0 / ((0.75 * TAU / (KEYS - 1) as f64) as f32).sin() as f64;
assert!(
(ratio - expected_ratio).abs() < 1e-4,
"expected {expected_ratio}, got {ratio}"
);
let tuned_findings = lint_with(&doc, &tuned_floor);
assert!(
tuned_findings.iter().any(|f| f.check_id == "loop-seam"),
"tuned floor should expose loop-seam finding: {tuned_findings:#?}"
);
}
#[test]
fn zero_stride_floor_does_not_report_stationary_ratio() {
let doc = doc_with_periods_and_stride(0.0, 0.0);
let roles = roles(&doc.skeleton);
let config = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } },
"checks": { "loop-seam": { "min_stride_step_m": 0.0 } }
}));
let grids = MetricGrids::new(&doc);
let measurements = animsmith_core::measure::measure_document(&grids, &roles, &config);
assert_eq!(measurements["walk"].loop_seam_ratio, None);
let findings = lint_with(&doc, &config);
assert!(
!findings.iter().any(|f| f.check_id == "loop-seam"),
"zero stride should not produce loop-seam finding: {findings:#?}"
);
}
#[test]
fn seam_pop_is_ignored_without_loop_declaration() {
let doc = popped_doc();
let findings = lint_with(&doc, &Config::default());
assert!(
!findings.iter().any(|f| f.check_id == "loop-seam"),
"undeclared clip judged: {findings:#?}"
);
}
#[test]
fn root_motion_speed_matches_declared() {
let mut doc = walk_doc();
doc.clips[0].tracks.push(Track {
bone: 0,
property: Property::Translation,
interpolation: Interpolation::Linear,
times: vec![0.0, 1.0],
values: TrackValues::Vec3s(vec![Vec3::new(0.0, 1.0, 0.0), Vec3::new(0.0, 1.0, 3.0)]),
});
let good = json_config(serde_json::json!({
"clips": { "walk": { "speed_mps": { "value": 3.0, "tolerance": 0.25 } } }
}));
let findings = lint_with(&doc, &good);
assert!(
!findings.iter().any(|f| f.check_id == "root-motion-speed"),
"correct pin flagged: {findings:#?}"
);
let stale = json_config(serde_json::json!({
"clips": { "walk": { "speed_mps": { "value": 4.0, "tolerance": 0.25 } } }
}));
let findings = lint_with(&doc, &stale);
let f = findings
.iter()
.find(|f| f.check_id == "root-motion-speed")
.expect("speed finding");
assert_eq!(f.severity, Severity::Error);
}
#[test]
fn stray_speed_pin_on_stationary_clip_is_flagged() {
let doc = walk_doc(); let config = json_config(serde_json::json!({
"clips": { "walk": { "speed_mps": { "value": 3.0, "tolerance": 0.25 } } }
}));
let findings = lint_with(&doc, &config);
let f = findings
.iter()
.find(|f| f.check_id == "root-motion-speed")
.expect("stray-pin finding");
assert!(f.message.contains("stray"), "got: {}", f.message);
}
#[test]
fn gait_group_spread_is_flagged() {
let mut doc = walk_doc();
let skel = skeleton();
doc.clips.push(Clip {
name: "walk_swapped".into(),
duration_s: 1.0,
tracks: vec![
foot_track(1, skel.bones[1].rest.translation, -1.0, 1.0),
foot_track(2, skel.bones[2].rest.translation, 1.0, 1.0),
],
});
let config = json_config(serde_json::json!({
"gait_groups": { "ring": {
"clips": ["walk", "walk_swapped"],
"max_gait_phase_spread": 0.1,
"min_lr_amplitude_m": 0.05
}}
}));
let findings = lint_with(&doc, &config);
let f = findings
.iter()
.find(|f| f.check_id == "gait-group")
.expect("gait-group finding");
assert_eq!(f.severity, Severity::Error);
}
#[test]
fn coherent_gait_group_is_clean() {
let mut doc = walk_doc();
let mut second = doc.clips[0].clone();
second.name = "walk_b".into();
doc.clips.push(second);
let config = json_config(serde_json::json!({
"gait_groups": { "ring": {
"clips": ["walk", "walk_b"],
"max_gait_phase_spread": 0.1,
"min_lr_amplitude_m": 0.05
}}
}));
let findings = lint_with(&doc, &config);
assert!(
!findings.iter().any(|f| f.check_id == "gait-group"),
"coherent group flagged: {findings:#?}"
);
}
#[test]
fn missing_group_member_is_flagged() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"gait_groups": { "ring": {
"clips": ["walk", "no_such_clip"],
"max_gait_phase_spread": 0.1
}}
}));
let findings = lint_with(&doc, &config);
assert!(
findings.iter().any(|f| f.check_id == "gait-group"
&& f.clip.as_deref() == Some("no_such_clip")
&& f.severity == Severity::Error),
"got: {findings:#?}"
);
}
#[test]
fn missing_group_member_is_flagged_even_when_roles_unresolved() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"gait_groups": { "ring": {
"clips": ["walk", "no_such_clip"],
"max_gait_phase_spread": 0.1
}}
}));
let findings = lint_unresolved(&doc, &config);
assert!(
findings.iter().any(|f| f.check_id == "gait-group"
&& f.clip.as_deref() == Some("no_such_clip")
&& f.severity == Severity::Error),
"member-not-found Error hidden by unresolved roles: {findings:#?}"
);
let notes: Vec<_> = findings
.iter()
.filter(|f| f.check_id == "gait-group" && f.severity == Severity::Note)
.collect();
assert_eq!(notes.len(), 1, "expected one skip-note: {notes:#?}");
}
#[test]
fn missing_group_member_error_survives_severity_override() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"checks": { "gait-group": { "severity": "warn" } },
"gait_groups": { "ring": {
"clips": ["walk", "no_such_clip"],
"max_gait_phase_spread": 0.1
}}
}));
let findings = lint_with(&doc, &config);
let member = findings
.iter()
.find(|f| f.check_id == "gait-group" && f.clip.as_deref() == Some("no_such_clip"))
.expect("member-not-found reported");
assert_eq!(member.severity, Severity::Warning);
}
#[test]
fn too_short_group_member_is_an_error() {
let mut doc = walk_doc();
let short = Clip {
name: "stub".into(),
duration_s: 1.0,
tracks: vec![Track {
bone: 1,
property: Property::Translation,
interpolation: Interpolation::Linear,
times: vec![0.0, 1.0],
values: TrackValues::Vec3s(vec![Vec3::new(0.1, -1.0, 0.0); 2]),
}],
};
doc.clips.push(short);
let config = json_config(serde_json::json!({
"gait_groups": { "ring": {
"clips": ["walk", "stub"],
"max_gait_phase_spread": 0.1
}}
}));
let findings = lint_with(&doc, &config); let stub = findings
.iter()
.find(|f| f.check_id == "gait-group" && f.clip.as_deref() == Some("stub"))
.expect("too-short member flagged");
assert_eq!(stub.severity, Severity::Error);
assert!(stub.message.contains("too short"), "{}", stub.message);
}
#[test]
fn foot_slide_skip_note_is_isolated_and_reasoned() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"clips": { "walk": { "speed_mps": { "value": 1.0, "tolerance": 0.25 }, "in_place": true } }
}));
let findings = lint_unresolved(&doc, &config);
let foot: Vec<_> = findings
.iter()
.filter(|f| f.check_id == "foot-slide")
.collect();
assert_eq!(foot.len(), 1, "one foot-slide note: {foot:#?}");
assert_eq!(foot[0].severity, Severity::Note);
assert!(foot[0].message.contains("root/hips"), "{}", foot[0].message);
assert!(
!findings.iter().any(|f| f.check_id == "root-motion-speed"),
"root-motion-speed should be idle for an in-place pin: {findings:#?}"
);
}
#[test]
fn gait_group_skip_note_is_isolated_and_reasoned() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"gait_groups": { "ring": { "clips": ["walk"], "max_gait_phase_spread": 0.1 } }
}));
let findings = lint_unresolved(&doc, &config);
let group: Vec<_> = findings
.iter()
.filter(|f| f.check_id == "gait-group")
.collect();
assert_eq!(group.len(), 1, "one gait-group note: {group:#?}");
assert_eq!(group[0].severity, Severity::Note);
assert!(
group[0].message.contains("hips/foot"),
"{}",
group[0].message
);
}
#[test]
fn frozen_required_bone_is_flagged() {
let doc = walk_doc(); let config = json_config(serde_json::json!({
"clips": { "walk": { "animates_bones": ["l_foot"] } }
}));
let findings = lint_with(&doc, &config);
let f = findings
.iter()
.find(|f| f.check_id == "frozen-bone")
.expect("frozen finding");
assert_eq!(f.bone.as_deref(), Some("l_foot"));
}
#[test]
fn missing_required_bone_is_flagged() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"clips": { "walk": { "animates_bones": ["no_such_bone"] } }
}));
let findings = lint_with(&doc, &config);
assert!(
findings
.iter()
.any(|f| f.check_id == "missing-bones" && f.bone.as_deref() == Some("no_such_bone")),
"got: {findings:#?}"
);
}
#[test]
fn severity_override_and_off() {
let doc = popped_doc();
let demoted = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } },
"checks": { "loop-seam": { "severity": "note" } }
}));
let findings = lint_with(&doc, &demoted);
let f = findings
.iter()
.find(|f| f.check_id == "loop-seam")
.expect("demoted finding");
assert_eq!(f.severity, Severity::Note);
let off = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } },
"checks": { "loop-seam": { "severity": "off" } }
}));
let findings = lint_with(&doc, &off);
assert!(!findings.iter().any(|f| f.check_id == "loop-seam"));
}
#[test]
fn skip_note_is_exempt_from_severity_override() {
let doc = popped_doc(); let config = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } },
"checks": { "loop-seam": { "severity": "error" } }
}));
let findings = lint_unresolved(&doc, &config);
let seam: Vec<_> = findings
.iter()
.filter(|f| f.check_id == "loop-seam")
.collect();
assert_eq!(seam.len(), 1, "one skip-note, not silence: {seam:#?}");
assert_eq!(
seam[0].severity,
Severity::Note,
"skip-note must stay a Note despite severity = error"
);
assert!(seam[0].message.contains("skipped"), "{}", seam[0].message);
}
#[test]
fn off_removes_check_including_its_skip_note() {
let doc = popped_doc();
let config = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } },
"checks": { "loop-seam": { "severity": "off" } }
}));
let findings = lint_unresolved(&doc, &config);
assert!(!findings.iter().any(|f| f.check_id == "loop-seam"));
}
#[test]
fn unresolved_roles_yield_one_note_per_pending_check() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"clips": { "walk": { "loop": true, "speed_mps": { "value": 1.0, "tolerance": 0.25 }, "in_place": false } },
"gait_groups": { "ring": { "clips": ["walk"], "max_gait_phase_spread": 0.1 } }
}));
let findings = lint_unresolved(&doc, &config);
for id in [
"loop-seam",
"root-motion-speed",
"in-place",
"foot-slide",
"gait-group",
] {
let notes: Vec<_> = findings.iter().filter(|f| f.check_id == id).collect();
assert_eq!(
notes.len(),
1,
"{id}: expected one skip-note, got {notes:#?}"
);
assert_eq!(notes[0].severity, Severity::Note, "{id} not a Note");
}
}
#[test]
fn idle_checks_emit_no_skip_notes() {
let doc = walk_doc(); let config = Config::default();
let findings = lint_unresolved(&doc, &config);
for id in [
"loop-seam",
"root-motion-speed",
"in-place",
"foot-slide",
"gait-group",
] {
assert!(
!findings.iter().any(|f| f.check_id == id),
"{id} emitted a note with nothing to do: {findings:#?}"
);
}
}
#[test]
fn glob_expectations_merge_with_exact_winning() {
let config = json_config(serde_json::json!({
"clips": {
"walk_*": { "loop": true, "fps": 30.0 },
"walk_fast": { "fps": 60.0 }
}
}));
let exp = config.expectations_for("walk_fast");
assert_eq!(exp.looping, Some(true)); assert_eq!(exp.fps, Some(60.0)); let other = config.expectations_for("run_fast");
assert_eq!(other.looping, None);
}
#[test]
fn circular_spread_handles_wrap() {
use animsmith_core::metrics::circular_phase_spread;
let spread = circular_phase_spread(&[0.98, 0.02]);
assert!(spread < 0.03, "wrap over-reported: {spread}");
let spread = circular_phase_spread(&[0.25, 0.75]);
assert!((spread - 0.25).abs() < 1e-6, "got {spread}");
}