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, CheckEvaluation, CheckSelection, Config, ConfigurationState, CoverageGapCode,
EvaluationScopeCode, EvaluationState, MetricGrids, Severity, Value, all_checks,
evaluate_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 evaluate_with(doc: &Document, config: &Config) -> Vec<CheckEvaluation> {
let roles = roles(&doc.skeleton);
let grids = MetricGrids::new(doc);
let ctx = CheckCtx::new(&grids, &roles, config);
evaluate_checks(&ctx, &all_checks(), CheckSelection::All).expect("valid built-in catalog")
}
fn lint_with(doc: &Document, config: &Config) -> Vec<animsmith_core::Finding> {
evaluate_with(doc, config)
.into_iter()
.flat_map(|check| check.findings().to_vec())
.collect()
}
fn evaluate_unresolved(doc: &Document, config: &Config) -> Vec<CheckEvaluation> {
let roles = ResolvedRoles::default();
let grids = MetricGrids::new(doc);
let ctx = CheckCtx::new(&grids, &roles, config);
evaluate_checks(&ctx, &all_checks(), CheckSelection::All).expect("valid built-in catalog")
}
fn check<'a>(records: &'a [CheckEvaluation], id: &str) -> &'a CheckEvaluation {
records
.iter()
.find(|record| record.check_id() == id)
.unwrap_or_else(|| panic!("missing {id} record"))
}
fn assert_loop_seam_has_unresolved_roles_gap(records: &[CheckEvaluation]) {
let loop_seam = check(records, "loop-seam");
assert_eq!(loop_seam.evaluation(), EvaluationState::NotEvaluated);
assert!(loop_seam.findings().is_empty());
assert_eq!(loop_seam.gaps()[0].code.as_str(), "roles_unresolved");
assert!(loop_seam.gaps()[0].message.contains("hips/foot"));
let scope = loop_seam.gaps()[0]
.scope
.as_ref()
.expect("typed loop-seam scope");
assert_eq!(scope.code, EvaluationScopeCode::LOOP_SEAM);
assert_eq!(scope.subject.as_deref(), Some("walk"));
}
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 records = evaluate_with(&doc, &config);
let loop_seam = check(&records, "loop-seam");
assert!(
loop_seam.findings().is_empty(),
"clean loop flagged: {loop_seam:#?}"
);
assert_eq!(loop_seam.evaluated_scopes().len(), 1);
assert_eq!(
loop_seam.evaluated_scopes()[0].code,
EvaluationScopeCode::LOOP_SEAM
);
assert_eq!(
loop_seam.evaluated_scopes()[0].subject.as_deref(),
Some("walk")
);
}
#[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_has_unresolved_roles_gap(&evaluate_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_has_unresolved_roles_gap(&evaluate_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_ctx = CheckCtx::new(&default_grids, &roles, &default_floor);
let default_records = evaluate_checks(&default_ctx, &all_checks(), CheckSelection::All)
.expect("valid built-in catalog");
let seam = check(&default_records, "loop-seam");
assert_eq!(seam.evaluation(), EvaluationState::NotEvaluated);
assert!(seam.findings().is_empty());
assert_eq!(seam.gaps()[0].code.as_str(), "measurement_unavailable");
assert_eq!(
seam.gaps()[0].scope.as_ref().unwrap().subject.as_deref(),
Some("walk")
);
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 ctx = CheckCtx::new(&grids, &roles, &config);
let records =
evaluate_checks(&ctx, &all_checks(), CheckSelection::All).expect("valid built-in catalog");
let seam = check(&records, "loop-seam");
assert_eq!(seam.evaluation(), EvaluationState::NotEvaluated);
assert!(seam.findings().is_empty());
assert_eq!(seam.gaps()[0].code.as_str(), "measurement_unavailable");
}
#[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 unresolved_phase_coherence_is_a_typed_gap_not_completed_work() {
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 roles = roles(&doc.skeleton);
let grids = MetricGrids::new(&doc);
let ctx = CheckCtx::new(&grids, &roles, &config);
let checks = all_checks();
let records = evaluate_checks(&ctx, &checks, CheckSelection::All).unwrap();
let gait = records
.iter()
.find(|record| record.check_id() == "gait-group")
.expect("gait-group record");
assert_eq!(gait.evaluation(), EvaluationState::Partial);
assert!(
gait.evaluated_scopes()
.iter()
.any(|scope| scope.code.as_str() == "member_existence")
);
assert!(
!gait
.evaluated_scopes()
.iter()
.any(|scope| scope.code.as_str() == "phase_coherence")
);
assert_eq!(
gait.gaps()[0].code.as_str(),
"insufficient_measurable_members"
);
assert_eq!(
gait.gaps()[0].scope.as_ref().unwrap().subject.as_deref(),
Some("ring")
);
}
#[test]
fn gait_phase_coverage_distinguishes_complete_and_partial_groups() {
let mut doc = walk_doc();
let mut second = doc.clips[0].clone();
second.name = "walk_b".into();
doc.clips.push(second);
for (clips, expected_evaluation, expected_gap) in [
(vec!["walk", "walk_b"], EvaluationState::Complete, None),
(
vec!["walk", "walk_b", "missing"],
EvaluationState::Partial,
Some("members_not_evaluated"),
),
] {
let config = json_config(serde_json::json!({
"gait_groups": { "ring": {
"clips": clips,
"max_gait_phase_spread": 0.1,
"min_lr_amplitude_m": 0.05
}}
}));
let roles = roles(&doc.skeleton);
let grids = MetricGrids::new(&doc);
let ctx = CheckCtx::new(&grids, &roles, &config);
let checks = all_checks();
let records = evaluate_checks(&ctx, &checks, CheckSelection::All).unwrap();
let gait = records
.iter()
.find(|record| record.check_id() == "gait-group")
.expect("gait-group record");
assert_eq!(gait.evaluation(), expected_evaluation);
assert!(gait.evaluated_scopes().iter().any(|scope| {
scope.code.as_str() == "phase_coherence" && scope.subject.as_deref() == Some("ring")
}));
assert_eq!(
gait.gaps().first().map(|gap| gap.code.as_str()),
expected_gap
);
}
}
#[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 records = evaluate_unresolved(&doc, &config);
let gait = check(&records, "gait-group");
assert!(
gait.findings()
.iter()
.any(|finding| finding.clip.as_deref() == Some("no_such_clip")
&& finding.severity == Severity::Error),
"member-not-found Error hidden by unresolved roles: {gait:#?}"
);
assert_eq!(gait.evaluation(), EvaluationState::Partial);
assert_eq!(gait.gaps()[0].code.as_str(), "roles_unresolved");
}
#[test]
fn all_missing_group_members_remain_content_errors_without_a_role_gap() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"gait_groups": { "ring": {
"clips": ["missing_a", "missing_b"],
"max_gait_phase_spread": 0.1
}}
}));
let records = evaluate_unresolved(&doc, &config);
let gait = check(&records, "gait-group");
assert_eq!(gait.findings().len(), 2, "missing-member errors: {gait:#?}");
assert!(
gait.findings()
.iter()
.all(|finding| finding.severity == Severity::Error),
"missing members remain content errors: {gait:#?}"
);
assert!(
gait.gaps()
.iter()
.all(|gap| gap.code.as_str() != "roles_unresolved")
);
}
#[test]
fn missing_group_member_content_error_honors_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_a_typed_gap() {
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 roles = roles(&doc.skeleton);
let grids = MetricGrids::new(&doc);
let ctx = CheckCtx::new(&grids, &roles, &config);
let records = evaluate_checks(&ctx, &all_checks(), CheckSelection::All).unwrap();
let gait = check(&records, "gait-group");
assert!(
gait.findings().is_empty(),
"measurement gaps do not block: {gait:#?}"
);
let gap = gait
.gaps()
.iter()
.find(|gap| gap.code.as_str() == "insufficient_measurable_members")
.expect("too-short member leaves group coverage incomplete");
assert_eq!(gap.scope.as_ref().unwrap().subject.as_deref(), Some("ring"));
let member_gap = gait
.gaps()
.iter()
.find(|gap| {
gap.code == CoverageGapCode::MEASUREMENT_UNAVAILABLE
&& gap.scope.as_ref().is_some_and(|scope| {
scope.code.as_str() == "phase_measurement"
&& scope.subject.as_deref() == Some("stub")
})
})
.expect("unmeasurable member retains clip attribution");
assert!(member_gap.message.contains("could not be measured"));
}
#[test]
fn every_below_amplitude_group_member_retains_clip_attribution() {
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": 1.0
}}
}));
let roles = roles(&doc.skeleton);
let grids = MetricGrids::new(&doc);
let ctx = CheckCtx::new(&grids, &roles, &config);
let records = evaluate_checks(&ctx, &all_checks(), CheckSelection::All).unwrap();
let gait = check(&records, "gait-group");
let member_subjects: Vec<_> = gait
.gaps()
.iter()
.filter(|gap| {
gap.code == CoverageGapCode::MEASUREMENT_UNAVAILABLE
&& gap
.scope
.as_ref()
.is_some_and(|scope| scope.code.as_str() == "phase_measurement")
})
.map(|gap| {
assert!(gap.message.contains("below the 1.000 m evidence floor"));
gap.scope.as_ref().unwrap().subject.as_deref().unwrap()
})
.collect();
assert_eq!(member_subjects, ["walk", "walk_b"]);
}
#[test]
fn zero_amplitude_group_members_are_not_phase_subjects_at_a_zero_evidence_floor() {
let mut doc = doc_with_periods(0.0);
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.0
}}
}));
let roles = roles(&doc.skeleton);
let grids = MetricGrids::new(&doc);
let ctx = CheckCtx::new(&grids, &roles, &config);
let records = evaluate_checks(&ctx, &all_checks(), CheckSelection::All).unwrap();
let gait = check(&records, "gait-group");
assert_eq!(gait.evaluation(), EvaluationState::Partial, "{gait:#?}");
assert!(gait.findings().is_empty(), "{gait:#?}");
assert!(
gait.evaluated_scopes()
.iter()
.all(|scope| scope.code != EvaluationScopeCode::PHASE_COHERENCE),
"zero-swing members must not be consumed as a completed phase comparison: {gait:#?}"
);
let subjects: Vec<_> = gait
.gaps()
.iter()
.filter(|gap| {
gap.code == CoverageGapCode::MEASUREMENT_UNAVAILABLE
&& gap.message == "gait phase has no left/right foot-height swing"
})
.map(|gap| gap.scope.as_ref().unwrap().subject.as_deref().unwrap())
.collect();
assert_eq!(subjects, ["walk", "walk_b"]);
}
#[test]
fn positive_group_amplitude_at_the_configured_floor_is_inclusive() {
let mut doc = walk_doc();
let mut second = doc.clips[0].clone();
second.name = "walk_b".into();
let left = doc
.skeleton
.bones
.iter()
.position(|bone| bone.name == BONES.left_foot)
.expect("left foot");
let right = doc
.skeleton
.bones
.iter()
.position(|bone| bone.name == BONES.right_foot)
.expect("right foot");
for track in &mut second.tracks {
track.bone = match track.bone {
bone if bone == left => right,
bone if bone == right => left,
bone => bone,
};
}
doc.clips.push(second);
let roles = roles(&doc.skeleton);
let grids = MetricGrids::new(&doc);
let measured = animsmith_core::measure::measure_document(&grids, &roles, &Config::default());
let exact_floor = measured["walk"]
.gait
.as_ref()
.expect("analytic gait")
.lr_amplitude_m;
assert!(exact_floor > 0.0);
let config = json_config(serde_json::json!({
"gait_groups": { "ring": {
"clips": ["walk", "walk_b"],
"max_gait_phase_spread": 0.1,
"min_lr_amplitude_m": exact_floor
}}
}));
let ctx = CheckCtx::new(&grids, &roles, &config);
let records = evaluate_checks(&ctx, &all_checks(), CheckSelection::All).unwrap();
let gait = check(&records, "gait-group");
assert_eq!(gait.evaluation(), EvaluationState::Complete, "{gait:#?}");
assert_eq!(gait.findings().len(), 1, "{gait:#?}");
assert_eq!(gait.findings()[0].check_id, "gait-group");
assert!(
matches!(gait.findings()[0].measured, Some(Value::Number(spread)) if (spread - 0.25).abs() < 1.0e-6),
"the exact-floor members must be consumed in the phase comparison: {gait:#?}"
);
assert!(gait.gaps().is_empty(), "{gait:#?}");
}
#[test]
fn one_sided_group_member_without_a_phase_retains_clip_attribution() {
let doc = walk_doc();
let config = json_config(serde_json::json!({
"gait_groups": { "ring": {
"clips": ["walk"],
"max_gait_phase_spread": 0.1
}}
}));
let one_sided_roles = ResolvedRoles::from_names(
&doc.skeleton,
[
(Role::Hips, BONES.hips.to_string()),
(Role::LeftFoot, BONES.left_foot.to_string()),
],
);
let grids = MetricGrids::new(&doc);
let ctx = CheckCtx::new(&grids, &one_sided_roles, &config);
let records = evaluate_checks(&ctx, &all_checks(), CheckSelection::All).unwrap();
let gait = check(&records, "gait-group");
let gap = gait
.gaps()
.iter()
.find(|gap| {
gap.code == CoverageGapCode::MEASUREMENT_UNAVAILABLE
&& gap.scope.as_ref().is_some_and(|scope| {
scope.code.as_str() == "phase_measurement"
&& scope.subject.as_deref() == Some("walk")
})
})
.expect("unfittable per-member phase retains clip attribution");
assert!(gap.message.contains("no bilateral foot-role subject"));
}
#[test]
fn foot_slide_role_gap_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 records = evaluate_unresolved(&doc, &config);
let foot = check(&records, "foot-slide");
assert_eq!(foot.evaluation(), EvaluationState::NotEvaluated);
assert!(foot.findings().is_empty());
assert_eq!(foot.gaps()[0].code.as_str(), "roles_unresolved");
assert!(foot.gaps()[0].message.contains("root/hips"));
assert!(
check(&records, "root-motion-speed").applicability()
== animsmith_core::Applicability::NotApplicable,
"root-motion-speed should be inapplicable for an in-place pin: {records:#?}"
);
}
#[test]
fn gait_group_role_gap_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 records = evaluate_unresolved(&doc, &config);
let gait = check(&records, "gait-group");
assert_eq!(gait.evaluation(), EvaluationState::Partial);
assert!(gait.findings().is_empty());
assert_eq!(gait.gaps()[0].code.as_str(), "roles_unresolved");
assert!(gait.gaps()[0].message.contains("hips/foot"));
}
#[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 required_bones_is_non_work_without_a_nonempty_rig_declaration() {
for config in [
Config::default(),
json_config(serde_json::json!({ "rig": { "required_bones": [] } })),
] {
let doc = walk_doc();
let records = evaluate_with(&doc, &config);
let required = check(&records, "required-bones");
assert_eq!(
required.applicability(),
animsmith_core::Applicability::NotApplicable
);
assert_eq!(required.evaluation(), EvaluationState::NotEvaluated);
assert!(required.findings().is_empty());
assert!(required.gaps().is_empty());
}
}
#[test]
fn required_bones_reports_an_empty_skeleton_as_typed_unavailable_work() {
let doc = Document::default();
let config = json_config(serde_json::json!({
"rig": { "required_bones": ["socket"] }
}));
let records = evaluate_with(&doc, &config);
let required = check(&records, "required-bones");
assert_eq!(required.evaluation(), EvaluationState::NotEvaluated);
assert!(required.findings().is_empty());
assert_eq!(required.gaps().len(), 1);
let gap = &required.gaps()[0];
assert_eq!(gap.code.as_str(), "skeleton_unavailable");
assert_eq!(
gap.scope.as_ref().map(|scope| scope.code.clone()),
Some(EvaluationScopeCode::REQUIRED_BONE_PRESENCE)
);
}
#[test]
fn required_bones_accepts_static_bones_and_deduplicates_declarations() {
let mut doc = walk_doc();
doc.skeleton.bones.push(Bone {
name: "ik_target".into(),
parent: None,
rest: Transform::IDENTITY,
inverse_bind: None,
});
let config = json_config(serde_json::json!({
"rig": { "required_bones": ["ik_target", "missing", "missing"] }
}));
let records = evaluate_with(&doc, &config);
let required = check(&records, "required-bones");
assert_eq!(required.evaluation(), EvaluationState::Complete);
assert_eq!(required.evaluated_scopes().len(), 1);
assert_eq!(
required.evaluated_scopes()[0].code,
EvaluationScopeCode::REQUIRED_BONE_PRESENCE
);
let missing: Vec<_> = required
.findings()
.iter()
.filter(|finding| finding.bone.as_deref() == Some("missing"))
.collect();
assert_eq!(
missing.len(),
1,
"repeated declarations are one requirement"
);
assert_eq!(missing[0].check_id, "required-bones");
assert!(matches!(missing[0].measured, Some(Value::Number(0.0))));
assert!(matches!(missing[0].expected, Some(Value::Number(1.0))));
assert!(
required
.findings()
.iter()
.all(|finding| finding.bone.as_deref() != Some("ik_target")),
"a present static/unkeyed bone must pass: {required:#?}"
);
}
#[test]
fn required_bones_rejects_ambiguous_skeleton_names_once_per_declaration() {
let mut doc = walk_doc();
doc.skeleton.bones.push(Bone {
name: "socket".into(),
parent: None,
rest: Transform::IDENTITY,
inverse_bind: None,
});
doc.skeleton.bones.push(Bone {
name: "socket".into(),
parent: None,
rest: Transform::IDENTITY,
inverse_bind: None,
});
let config = json_config(serde_json::json!({
"rig": { "required_bones": ["socket", "socket"] }
}));
let records = evaluate_with(&doc, &config);
let required = check(&records, "required-bones");
assert_eq!(required.evaluation(), EvaluationState::Complete);
assert_eq!(required.findings().len(), 1);
let finding = &required.findings()[0];
assert_eq!(finding.bone.as_deref(), Some("socket"));
assert!(matches!(finding.measured, Some(Value::Number(2.0))));
assert!(matches!(finding.expected, Some(Value::Number(1.0))));
assert!(finding.message.contains("ambiguous"));
assert!(finding.message.contains("2 skeleton bones"));
}
#[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 coverage_gap_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 records = evaluate_unresolved(&doc, &config);
let seam = check(&records, "loop-seam");
assert_eq!(seam.evaluation(), EvaluationState::NotEvaluated);
assert!(seam.findings().is_empty());
assert_eq!(seam.gaps()[0].code.as_str(), "roles_unresolved");
}
#[test]
fn off_disables_check_without_emitting_a_gap() {
let doc = popped_doc();
let config = json_config(serde_json::json!({
"clips": { "walk": { "loop": true } },
"checks": { "loop-seam": { "severity": "off" } }
}));
let records = evaluate_unresolved(&doc, &config);
let seam = check(&records, "loop-seam");
assert_eq!(seam.configuration(), ConfigurationState::Disabled);
assert_eq!(seam.evaluation(), EvaluationState::NotEvaluated);
assert!(seam.gaps().is_empty());
}
#[test]
fn unresolved_roles_yield_gaps_for_every_applicable_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 records = evaluate_unresolved(&doc, &config);
for id in [
"loop-seam",
"root-motion-speed",
"in-place",
"foot-slide",
"gait-group",
] {
let record = check(&records, id);
assert!(record.findings().is_empty(), "{id}: gaps are not findings");
assert!(!record.gaps().is_empty(), "{id}: expected a coverage gap");
assert_eq!(record.gaps()[0].code.as_str(), "roles_unresolved");
}
}
#[test]
fn undeclared_checks_are_not_applicable_without_gaps() {
let doc = walk_doc(); let config = Config::default();
let records = evaluate_unresolved(&doc, &config);
for id in [
"loop-seam",
"root-motion-speed",
"in-place",
"foot-slide",
"gait-group",
] {
let record = check(&records, id);
assert_eq!(
record.applicability(),
animsmith_core::Applicability::NotApplicable
);
assert!(record.findings().is_empty());
assert!(record.gaps().is_empty());
}
}
#[test]
fn glob_expectations_merge_with_exact_winning() {
let config = json_config(serde_json::json!({
"clips": {
"walk_*": {
"loop": true,
"duration_s": { "value": 1.0, "tolerance": 0.02 },
"fps": 30.0
},
"walk_fast": {
"duration_s": { "value": 0.5, "tolerance": 0.01 },
"fps": 60.0
},
"walk_medium": {
"fps": 48.0
}
}
}));
let exp = config.expectations_for("walk_fast");
assert_eq!(exp.looping, Some(true)); let duration = exp.duration_s.expect("exact duration pin");
assert_eq!(duration.value, 0.5);
assert_eq!(duration.tolerance, 0.01);
assert_eq!(exp.fps, Some(60.0)); let inherited = config.expectations_for("walk_medium");
let duration = inherited.duration_s.expect("inherited glob duration pin");
assert_eq!(duration.value, 1.0);
assert_eq!(duration.tolerance, 0.02);
assert_eq!(inherited.fps, Some(48.0));
let other = config.expectations_for("run_fast");
assert_eq!(other.looping, None);
assert!(other.duration_s.is_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}");
}