use animsmith_core::glam::Quat;
use animsmith_core::model::*;
use animsmith_gltf::fix::{FixSession, Repair as GltfRepair};
use animsmith_testkit::{quats_from_angles, scaled_quat, two_bone_rotation_doc};
use serde_json::{Value, json};
use std::path::PathBuf;
use std::process::{Command, Output};
fn animsmith() -> Command {
Command::new(env!("CARGO_BIN_EXE_animsmith"))
}
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("testdata")
.join(name)
}
fn unique_temp_dir(name: &str) -> tempfile::TempDir {
tempfile::Builder::new()
.prefix(&format!("animsmith-cli-{name}-"))
.tempdir()
.expect("creates temp dir")
}
fn sway_quats(flipped: bool) -> Vec<Quat> {
let mut quats = quats_from_angles(&[0.0, 0.4, 0.8, 1.2, 1.6]);
if flipped {
quats[1] = -quats[1];
quats[3] = -quats[3];
}
quats
}
fn sway_doc_with_quats(quats: Vec<Quat>) -> Document {
two_bone_rotation_doc("sway", quats, false)
}
fn sway_doc(flipped: bool) -> Document {
sway_doc_with_quats(sway_quats(flipped))
}
fn sway_doc_with_distinct_repairs() -> Document {
let mut quats = sway_quats(true);
quats[1] = scaled_quat(quats[1], 1.2);
sway_doc_with_quats(quats)
}
fn write_flipped_glb(path: &std::path::Path) {
animsmith_gltf::write::write(&sway_doc(true), path).expect("writes flipped fixture");
}
fn write_distinct_repair_glb(path: &std::path::Path) {
animsmith_gltf::write::write(&sway_doc_with_distinct_repairs(), path)
.expect("writes distinct repair fixture");
}
fn write_clean_glb(path: &std::path::Path) {
animsmith_gltf::write::write(&sway_doc(false), path).expect("writes clean fixture");
}
fn write_json(path: &std::path::Path, value: &Value) {
std::fs::write(
path,
serde_json::to_vec_pretty(value).expect("serializes JSON fixture"),
)
.expect("writes JSON fixture");
}
fn measurement_report(duration_s: f64) -> Value {
json!({
"schema_version": 1,
"files": [{
"path": "fixture.gltf",
"rig": { "profile": "unknown" },
"measurements": {
"walk": {
"duration_s": duration_s,
"frame_count": 31,
"animated_bones": [],
"bone_rotation_range_deg": {}
}
}
}]
})
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
const EMPTY_ANIMATION_GLTF: &str = r#"{
"asset": { "version": "2.0" },
"nodes": [{ "name": "root" }],
"animations": [{ "name": "empty", "samplers": [], "channels": [] }],
"scenes": [{ "nodes": [0] }],
"scene": 0
}"#;
#[test]
fn transform_summary_reports_a_loaded_clip_omitted_from_the_artifact() {
let dir = unique_temp_dir("transform-empty-animation");
let input = dir.path().join("empty-animation.gltf");
let output_path = dir.path().join("transformed.glb");
std::fs::write(&input, EMPTY_ANIMATION_GLTF).expect("writes empty animation fixture");
let output = animsmith()
.arg("transform")
.arg(&input)
.arg("-o")
.arg(&output_path)
.output()
.expect("runs transform");
assert_eq!(
output.status.code(),
Some(0),
"stderr:\n{}",
stderr(&output)
);
let written = animsmith_gltf::load(&output_path).expect("loads transformed output");
assert!(written.clips.is_empty(), "empty animation is not emitted");
assert_eq!(
stdout(&output),
format!(
"wrote {} (1 node(s), 0 clip(s), 0 mesh(es) / 0 position(s), 0 material(s)); dropped 1 clip(s) with no writable tracks\n",
output_path.display()
)
);
}
#[test]
fn fix_rejects_unknown_repair_ids() {
let output = animsmith()
.args(["fix", "clip.glb", "--dry-run", "--repair", "no-such-repair"])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("quat-flip"),
"stderr should list valid repair ids:\n{}",
stderr(&output)
);
assert!(
stderr(&output).contains("quat-norm"),
"stderr should list valid repair ids:\n{}",
stderr(&output)
);
}
#[test]
fn fix_rejects_removed_group_flags() {
for removed in [&["--group", "default"][..], &["--list-repairs"][..]] {
let output = animsmith()
.args(["fix", "clip.glb"])
.args(removed)
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"{removed:?} must be rejected; stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("unexpected argument"),
"stderr:\n{}",
stderr(&output)
);
}
}
#[test]
fn fix_requires_an_explicit_write_target() {
let output = animsmith()
.args(["fix", "clip.glb"])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("fix requires --output <PATH> or --in-place"),
"stderr:\n{}",
stderr(&output)
);
}
#[test]
fn fix_dry_run_reports_without_writing() {
let dir = unique_temp_dir("fix-dry-run");
let input = dir.path().join("dirty.glb");
write_flipped_glb(&input);
let before = std::fs::read(&input).expect("reads input");
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--dry-run",
"--repair",
"quat-flip",
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(1),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
assert!(
stdout(&output).contains("would be fixed"),
"stdout:\n{}",
stdout(&output)
);
assert_eq!(before, std::fs::read(&input).expect("reads input"));
}
#[test]
fn fix_dry_run_dedupes_duplicate_repairs() {
let dir = unique_temp_dir("fix-dry-run-dedup");
let input = dir.path().join("dirty.glb");
write_flipped_glb(&input);
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--dry-run",
"--repair",
"quat-flip,quat-flip",
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(1),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(
out.contains("2 key(s) would be fixed across 1 track(s)"),
"stdout:\n{out}"
);
assert_eq!(
out.matches("key(s) would be fixed across").count(),
1,
"duplicate repairs should be reported once:\n{out}"
);
}
#[test]
fn fix_dry_run_dedupes_non_adjacent_distinct_repairs_without_writing() {
let dir = unique_temp_dir("fix-dry-run-compose");
let input = dir.path().join("dirty.glb");
write_distinct_repair_glb(&input);
let before = std::fs::read(&input).expect("reads input");
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--dry-run",
"--repair",
"quat-norm,quat-flip,quat-norm",
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(1),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(out.contains("would fix[quat-norm]"), "stdout:\n{out}");
assert!(out.contains("would fix[quat-flip]"), "stdout:\n{out}");
assert_eq!(
out.matches("would fix[quat-norm]").count(),
1,
"non-adjacent duplicate repairs should be reported once:\n{out}"
);
assert_eq!(before, std::fs::read(&input).expect("reads input"));
assert_eq!(
FixSession::inspect(&input, GltfRepair::QuatNorm)
.expect("inspects dirty input")
.total_fixed(),
1
);
assert_eq!(
FixSession::inspect(&input, GltfRepair::QuatFlip)
.expect("inspects dirty input")
.total_fixed(),
2
);
}
#[test]
fn fix_dry_run_labels_each_repair_with_its_action() {
let dir = unique_temp_dir("fix-action-labels");
let input = dir.path().join("dirty.glb");
write_distinct_repair_glb(&input);
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--dry-run",
"--repair",
"quat-norm,quat-flip",
])
.output()
.expect("runs animsmith");
let out = stdout(&output);
let norm_line = out
.lines()
.find(|l| l.contains("would fix[quat-norm]"))
.unwrap_or_else(|| panic!("no quat-norm track line:\n{out}"));
assert!(
norm_line.contains("unit-normalized"),
"quat-norm line must report unit-normalized: {norm_line}"
);
let flip_line = out
.lines()
.find(|l| l.contains("would fix[quat-flip]"))
.unwrap_or_else(|| panic!("no quat-flip track line:\n{out}"));
assert!(
flip_line.contains("hemisphere-normalized"),
"quat-flip line must report hemisphere-normalized: {flip_line}"
);
}
#[test]
fn fix_dry_run_on_clean_input_exits_zero() {
let dir = unique_temp_dir("fix-dry-run-clean");
let input = dir.path().join("clean.glb");
write_clean_glb(&input);
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--dry-run",
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(0),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
assert!(
stdout(&output).contains("0 key(s) would be fixed"),
"stdout:\n{}",
stdout(&output)
);
}
#[test]
fn fix_dry_run_skipped_tracks_do_not_fail_the_check() {
let dir = unique_temp_dir("fix-dry-run-skip");
let input = dir.path().join("dirty.gltf");
animsmith_gltf::write::write(&sway_doc(true), &input).expect("writes gltf fixture");
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--dry-run",
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(0),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
assert!(
stdout(&output).contains("skipped[quat-flip]"),
"stdout:\n{}",
stdout(&output)
);
}
#[test]
fn fix_dry_run_conflicts_with_write_targets() {
for write_flag in [&["-o", "out.glb"][..], &["--in-place"][..]] {
let output = animsmith()
.args(["fix", "clip.glb", "--dry-run"])
.args(write_flag)
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"--dry-run with {write_flag:?} must be rejected; stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("--dry-run"),
"stderr:\n{}",
stderr(&output)
);
}
}
#[test]
fn fix_default_repairs_write_output() {
let dir = unique_temp_dir("fix-output");
let input = dir.path().join("dirty.glb");
let output_path = dir.path().join("fixed.glb");
write_flipped_glb(&input);
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--output",
output_path.to_str().expect("utf-8 output path"),
])
.output()
.expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
assert!(output_path.exists());
let fixed = animsmith_gltf::load(&output_path).expect("loads fixed output");
let TrackValues::Quats(quats) = &fixed.clips[0].tracks[0].values else {
panic!("rotation track expected");
};
let expected = sway_quats(false);
for (got, want) in quats.iter().zip(&expected) {
assert_eq!(got.to_array(), want.to_array());
}
}
#[test]
fn fix_write_composes_distinct_repairs() {
let dir = unique_temp_dir("fix-output-compose");
let input = dir.path().join("dirty.glb");
let output_path = dir.path().join("fixed.glb");
write_distinct_repair_glb(&input);
assert_eq!(
FixSession::inspect(&input, GltfRepair::QuatNorm)
.expect("inspects dirty input")
.total_fixed(),
1
);
assert_eq!(
FixSession::inspect(&input, GltfRepair::QuatFlip)
.expect("inspects dirty input")
.total_fixed(),
2
);
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--output",
output_path.to_str().expect("utf-8 output path"),
"--repair",
"quat-norm,quat-flip",
])
.output()
.expect("runs animsmith");
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(out.contains("fixed[quat-norm]"), "stdout:\n{out}");
assert!(out.contains("fixed[quat-flip]"), "stdout:\n{out}");
assert_eq!(
FixSession::inspect(&output_path, GltfRepair::QuatNorm)
.expect("inspects fixed output")
.total_fixed(),
0
);
assert_eq!(
FixSession::inspect(&output_path, GltfRepair::QuatFlip)
.expect("inspects fixed output")
.total_fixed(),
0
);
let fixed = animsmith_gltf::load(&output_path).expect("loads fixed output");
let TrackValues::Quats(quats) = &fixed.clips[0].tracks[0].values else {
panic!("rotation track expected");
};
for (got, want) in quats.iter().zip(sway_quats(false)) {
assert!(
got.dot(want).abs() > 1.0 - 1e-5,
"composed repairs must preserve the represented rotation"
);
}
}
#[test]
fn fix_write_dedupes_duplicate_repairs() {
let dir = unique_temp_dir("fix-output-dedup");
let input = dir.path().join("dirty.glb");
let output_path = dir.path().join("fixed.glb");
write_flipped_glb(&input);
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--output",
output_path.to_str().expect("utf-8 output path"),
"--repair",
"quat-flip,quat-flip",
])
.output()
.expect("runs animsmith");
assert!(
output.status.success(),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert_eq!(
out.matches("key(s) fixed across").count(),
1,
"duplicate repairs should be reported once:\n{out}"
);
assert_eq!(
FixSession::inspect(&output_path, GltfRepair::QuatFlip)
.expect("inspects fixed output")
.total_fixed(),
0
);
}
#[test]
fn fix_in_place_writes_selected_repair() {
let dir = unique_temp_dir("fix-in-place");
let input = dir.path().join("dirty.glb");
write_flipped_glb(&input);
assert_eq!(
FixSession::inspect(&input, GltfRepair::QuatFlip)
.expect("inspects dirty input")
.total_fixed(),
2
);
let output = animsmith()
.args([
"fix",
input.to_str().expect("utf-8 input path"),
"--in-place",
"--repair",
"quat-flip",
])
.output()
.expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
assert_eq!(
FixSession::inspect(&input, GltfRepair::QuatFlip)
.expect("inspects fixed input")
.total_fixed(),
0
);
}
#[test]
fn help_matches_compiled_feature_set() {
let output = animsmith().arg("--help").output().expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
let out = stdout(&output);
assert!(out.contains("inspect"));
assert!(out.contains("measure"));
assert!(out.contains("lint"));
assert!(out.contains("transform"));
assert!(out.contains("fix"));
assert!(out.contains("diff"));
assert!(out.contains("Repair safe mechanical glTF/GLB defects"));
assert!(out.contains("Apply mechanical clip transforms"));
assert!(out.contains("Compare animation measurements"));
assert_eq!(out.contains("\n convert "), cfg!(feature = "fbx"), "{out}");
assert_eq!(
out.contains("\n report "),
cfg!(feature = "report"),
"{out}"
);
}
#[test]
fn fix_help_lists_repair_possible_values() {
let output = animsmith()
.args(["fix", "--help"])
.output()
.expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
let out = stdout(&output);
assert!(
out.contains("[possible values: quat-norm, quat-flip]"),
"stdout:\n{out}"
);
}
#[test]
fn version_starts_with_manifest_version() {
let output = animsmith()
.arg("--version")
.output()
.expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
let out = stdout(&output);
assert!(
out.starts_with(concat!("animsmith ", env!("CARGO_PKG_VERSION"))),
"{out}"
);
}
#[test]
fn measure_json_uses_versioned_envelope() {
let output = animsmith()
.args([
"measure",
fixture("rig.gltf").to_str().expect("utf-8 fixture path"),
"--format",
"json",
])
.output()
.expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
let json: Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
assert_eq!(json["schema_version"], 1);
assert_eq!(json["tool"]["name"], "animsmith");
assert!(
json["tool"]["version"]
.as_str()
.is_some_and(|s| s.starts_with(env!("CARGO_PKG_VERSION"))),
"{json:#}"
);
assert_eq!(json["command"], "measure");
assert_eq!(json["summary"]["files"], 1);
assert_eq!(json["summary"]["findings"]["error"], 0);
assert!(
json["schema"]
.as_str()
.is_some_and(|s| s.ends_with("output-v1.schema.json"))
);
let files = json["files"].as_array().expect("files array");
assert_eq!(files.len(), 1);
assert_eq!(files[0]["rig"]["profile"], "unknown");
assert!(files[0]["findings"].is_null());
assert!(files[0]["measurements"]["walk"]["duration_s"].is_number());
}
#[test]
fn lint_json_uses_versioned_envelope() {
let output = animsmith()
.args([
"lint",
fixture("rig.gltf").to_str().expect("utf-8 fixture path"),
"--format",
"json",
])
.output()
.expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
let json: Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
assert_eq!(json["schema_version"], 1);
assert!(
json["schema"]
.as_str()
.is_some_and(|s| s.ends_with("output-v1.schema.json"))
);
assert_eq!(json["tool"]["name"], "animsmith");
assert!(
json["tool"]["version"]
.as_str()
.is_some_and(|s| s.starts_with(env!("CARGO_PKG_VERSION")))
);
assert_eq!(json["command"], "lint");
assert_eq!(json["summary"]["files"], 1);
assert!(json["files"][0]["findings"].is_array());
assert!(json["files"][0]["measurements"]["walk"]["duration_s"].is_number());
}
#[test]
fn diff_json_uses_versioned_envelope() {
let path = fixture("rig.gltf");
let output = animsmith()
.args([
"diff",
path.to_str().expect("utf-8 fixture path"),
path.to_str().expect("utf-8 fixture path"),
"--format",
"json",
])
.output()
.expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
let json: Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
assert_eq!(json["schema_version"], 1);
assert!(
json["schema"]
.as_str()
.is_some_and(|s| s.ends_with("output-v1.schema.json"))
);
assert_eq!(json["tool"]["name"], "animsmith");
assert!(
json["tool"]["version"]
.as_str()
.is_some_and(|s| s.starts_with(env!("CARGO_PKG_VERSION")))
);
assert_eq!(json["command"], "diff");
assert_eq!(json["summary"]["deltas"], 0);
assert_eq!(json["deltas"].as_array().expect("deltas array").len(), 0);
assert!(json["inputs"]["before"].is_string());
assert!(json["inputs"]["after"].is_string());
}
#[test]
fn diff_accepts_single_file_measure_report_round_trip() {
let dir = unique_temp_dir("diff-round-trip");
let asset = fixture("rig.gltf");
let report_path = dir.path().join("measure.json");
let measured = animsmith()
.args([
"measure",
asset.to_str().expect("utf-8 fixture path"),
"--format",
"json",
])
.output()
.expect("runs animsmith");
assert!(measured.status.success(), "stderr:\n{}", stderr(&measured));
std::fs::write(&report_path, &measured.stdout).expect("writes report");
let output = animsmith()
.args([
"diff",
report_path.to_str().expect("utf-8 report path"),
asset.to_str().expect("utf-8 fixture path"),
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(0),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
}
#[test]
fn diff_accepts_measurement_json_and_exits_one_for_deltas() {
let dir = unique_temp_dir("diff-json-deltas");
let before = dir.path().join("before.json");
let after = dir.path().join("after.json");
write_json(&before, &measurement_report(1.0));
write_json(&after, &measurement_report(1.1));
let output = animsmith()
.args([
"diff",
before.to_str().expect("utf-8 before path"),
after.to_str().expect("utf-8 after path"),
"--format",
"json",
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(1),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let json: Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
assert_eq!(json["summary"]["deltas"].as_u64(), Some(1));
assert_eq!(json["deltas"][0]["clip"], "walk");
}
#[test]
fn diff_accepts_measurement_json_and_exits_zero_without_deltas() {
let dir = unique_temp_dir("diff-json-clean");
let before = dir.path().join("before.json");
let after = dir.path().join("after.json");
let report = measurement_report(1.0);
write_json(&before, &report);
write_json(&after, &report);
let output = animsmith()
.args([
"diff",
before.to_str().expect("utf-8 before path"),
after.to_str().expect("utf-8 after path"),
])
.output()
.expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
}
#[test]
fn diff_text_format_renders_deltas_and_clean_summary() {
let dir = unique_temp_dir("diff-text-format");
let before = dir.path().join("before.json");
let after = dir.path().join("after.json");
write_json(&before, &measurement_report(1.0));
write_json(&after, &measurement_report(1.1));
let dirty = animsmith()
.args([
"diff",
before.to_str().expect("utf-8 before path"),
after.to_str().expect("utf-8 after path"),
])
.output()
.expect("runs animsmith");
assert_eq!(dirty.status.code(), Some(1), "stderr:\n{}", stderr(&dirty));
let out = stdout(&dirty);
assert!(
out.contains("walk"),
"dirty Text output names the clip:\n{out}"
);
assert!(
out.contains("significant change"),
"dirty Text output summarizes the change count:\n{out}"
);
let clean = animsmith()
.args([
"diff",
before.to_str().expect("utf-8 before path"),
before.to_str().expect("utf-8 before path"),
])
.output()
.expect("runs animsmith");
assert_eq!(clean.status.code(), Some(0), "stderr:\n{}", stderr(&clean));
assert!(
stdout(&clean).contains("no significant movement"),
"clean Text output states no movement:\n{}",
stdout(&clean)
);
}
#[test]
fn diff_rejects_json_without_schema_version() {
let dir = unique_temp_dir("diff-bare-map");
let bare = dir.path().join("bare.json");
std::fs::write(&bare, r#"{"walk": {"duration_s": 1.0}}"#).expect("writes bare map");
let output = animsmith()
.args([
"diff",
bare.to_str().expect("utf-8 path"),
fixture("rig.gltf").to_str().expect("utf-8 fixture path"),
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("not an animsmith report envelope"),
"stderr:\n{}",
stderr(&output)
);
assert!(
stderr(&output).contains("regenerate it with"),
"stderr:\n{}",
stderr(&output)
);
}
#[test]
fn diff_rejects_unsupported_schema_versions() {
let dir = unique_temp_dir("diff-future-schema");
let future = dir.path().join("future.json");
std::fs::write(
&future,
r#"{"schema_version": 99, "files": [{"measurements": {}}]}"#,
)
.expect("writes future report");
let output = animsmith()
.args([
"diff",
future.to_str().expect("utf-8 path"),
fixture("rig.gltf").to_str().expect("utf-8 fixture path"),
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("schema_version 99"),
"stderr:\n{}",
stderr(&output)
);
}
#[test]
fn diff_rejects_envelope_without_files() {
let dir = unique_temp_dir("diff-no-files");
let report = dir.path().join("no-files.json");
std::fs::write(&report, r#"{"schema_version": 1}"#).expect("writes report");
let output = animsmith()
.args([
"diff",
report.to_str().expect("utf-8 path"),
fixture("rig.gltf").to_str().expect("utf-8 fixture path"),
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("no `files` array"),
"stderr:\n{}",
stderr(&output)
);
}
#[test]
fn lint_counts_severities_in_summary_and_text() {
let dir = unique_temp_dir("lint-severity-counts");
let input = dir.path().join("dirty.glb");
write_flipped_glb(&input);
let output = animsmith()
.args([
"lint",
input.to_str().expect("utf-8 input path"),
"--format",
"json",
"--select",
"quat-flip",
])
.output()
.expect("runs animsmith");
assert!(output.status.success(), "stderr:\n{}", stderr(&output));
let json: Value = serde_json::from_slice(&output.stdout).expect("valid JSON");
assert_eq!(json["summary"]["findings"]["warning"], 1, "{json:#}");
assert_eq!(json["summary"]["findings"]["error"], 0, "{json:#}");
assert_eq!(json["summary"]["findings"]["note"], 0, "{json:#}");
assert_eq!(json["files"][0]["findings"][0]["severity"], "warning");
let output = animsmith()
.args([
"lint",
input.to_str().expect("utf-8 input path"),
"--select",
"quat-flip",
])
.output()
.expect("runs animsmith");
assert!(
stdout(&output).contains("1 warning(s)"),
"stdout:\n{}",
stdout(&output)
);
}
#[test]
fn fix_reports_unreadable_input_as_operator_error() {
let output = animsmith()
.args(["fix", "missing.glb", "--dry-run"])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("failed to read"),
"stderr:\n{}",
stderr(&output)
);
}
const COUNT_MISMATCH_GLTF: &str = r#"{
"asset": { "version": "2.0" },
"buffers": [{ "uri": "data:application/octet-stream;base64,AAAAAAAAAD8AAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8=", "byteLength": 44 }],
"bufferViews": [
{ "buffer": 0, "byteOffset": 0, "byteLength": 12 },
{ "buffer": 0, "byteOffset": 12, "byteLength": 32 }
],
"accessors": [
{ "bufferView": 0, "componentType": 5126, "count": 3, "type": "SCALAR", "min": [0], "max": [1] },
{ "bufferView": 1, "componentType": 5126, "count": 2, "type": "VEC4" }
],
"nodes": [{ "name": "root" }],
"animations": [{
"name": "bad",
"samplers": [{ "input": 0, "output": 1, "interpolation": "LINEAR" }],
"channels": [{ "sampler": 0, "target": { "node": 0, "path": "rotation" } }]
}],
"scenes": [{ "nodes": [0] }],
"scene": 0
}"#;
const NAN_TIME_GLTF: &str = r#"{
"asset": { "version": "2.0" },
"buffers": [{ "uri": "data:application/octet-stream;base64,AADAfwAAAD8AAIA/AAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAIA/", "byteLength": 60 }],
"bufferViews": [
{ "buffer": 0, "byteOffset": 0, "byteLength": 12 },
{ "buffer": 0, "byteOffset": 12, "byteLength": 48 }
],
"accessors": [
{ "bufferView": 0, "componentType": 5126, "count": 3, "type": "SCALAR", "min": [0], "max": [1] },
{ "bufferView": 1, "componentType": 5126, "count": 3, "type": "VEC4" }
],
"nodes": [{ "name": "root" }],
"animations": [{
"name": "poisoned",
"samplers": [{ "input": 0, "output": 1, "interpolation": "LINEAR" }],
"channels": [{ "sampler": 0, "target": { "node": 0, "path": "rotation" } }]
}],
"scenes": [{ "nodes": [0] }],
"scene": 0
}"#;
#[test]
fn malformed_track_counts_are_operator_errors_everywhere() {
let dir = unique_temp_dir("count-mismatch-cli");
let input = dir.path().join("bad.gltf");
std::fs::write(&input, COUNT_MISMATCH_GLTF).expect("writes fixture");
let out = dir.path().join("out.glb");
let commands: [&[&str]; 3] = [
&["measure", input.to_str().expect("utf-8 path")],
&["lint", input.to_str().expect("utf-8 path")],
&[
"transform",
input.to_str().expect("utf-8 path"),
"-o",
out.to_str().expect("utf-8 path"),
],
];
for args in commands {
let output = animsmith().args(args).output().expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"{args:?}: stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
assert!(
stderr(&output).contains("malformed animation data"),
"{args:?}: stderr:\n{}",
stderr(&output)
);
}
}
#[test]
fn nan_key_times_lint_as_errors_and_never_crash() {
let dir = unique_temp_dir("nan-time-cli");
let input = dir.path().join("nan.gltf");
std::fs::write(&input, NAN_TIME_GLTF).expect("writes fixture");
let output = animsmith()
.args(["measure", input.to_str().expect("utf-8 path")])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(0),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let output = animsmith()
.args(["lint", input.to_str().expect("utf-8 path")])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(1),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
assert!(
stdout(&output).contains("error[nan]") && stdout(&output).contains("non-finite key time"),
"stdout:\n{}",
stdout(&output)
);
}
fn write_config(dir: &std::path::Path, name: &str, toml: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, toml).expect("writes config");
path
}
fn example_config() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/character.animsmith.toml")
}
#[test]
fn lint_clean_file_exits_zero() {
let output = animsmith()
.args(["lint", fixture("rig.gltf").to_str().expect("utf-8 path")])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(0),
"stderr:\n{}",
stderr(&output)
);
assert!(
stdout(&output).contains("clean"),
"stdout:\n{}",
stdout(&output)
);
}
#[test]
fn lint_markdown_renders_findings_for_failing_asset() {
let dir = unique_temp_dir("markdown-findings");
let input = dir.path().join("dirty.glb");
write_distinct_repair_glb(&input); let path = input.to_str().expect("utf-8 path");
let output = animsmith()
.args(["lint", path, "--format", "markdown"])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(1),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
let out = stdout(&output);
assert!(out.contains("## animsmith lint"), "stdout:\n{out}");
assert!(
out.contains("| Severity | Check | Location | Measured | Expected | Message |"),
"stdout:\n{out}"
);
assert!(out.contains("<details"), "stdout:\n{out}");
assert!(out.contains("#### clip `sway`"), "stdout:\n{out}");
assert!(out.contains("`quat-norm`"), "stdout:\n{out}");
assert!(out.contains("`quat-flip`"), "stdout:\n{out}");
assert!(
out.contains("**1 file** — ❌ 1 error(s) · ⚠️ 1 warning(s)"),
"stdout:\n{out}"
);
}
#[test]
fn lint_markdown_summarizes_clean_asset() {
let dir = unique_temp_dir("markdown-clean");
let input = dir.path().join("clean.glb");
write_clean_glb(&input);
let path = input.to_str().expect("utf-8 path");
let output = animsmith()
.args(["lint", path, "--format", "markdown"])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(0),
"stderr:\n{}",
stderr(&output)
);
let out = stdout(&output);
assert!(out.contains("✅ Clean — no findings."), "stdout:\n{out}");
assert!(!out.contains("<details"), "stdout:\n{out}");
}
#[test]
fn lint_warnings_pass_but_deny_warnings_fails() {
let dir = unique_temp_dir("deny-warnings");
let input = dir.path().join("flipped.glb");
write_flipped_glb(&input); let path = input.to_str().expect("utf-8 path");
let output = animsmith().args(["lint", path]).output().expect("runs");
assert_eq!(
output.status.code(),
Some(0),
"stderr:\n{}",
stderr(&output)
);
assert!(
stdout(&output).contains("quat-flip"),
"stdout:\n{}",
stdout(&output)
);
let output = animsmith()
.args(["lint", path, "--deny-warnings"])
.output()
.expect("runs");
assert_eq!(
output.status.code(),
Some(1),
"stdout:\n{}\nstderr:\n{}",
stdout(&output),
stderr(&output)
);
}
#[test]
fn lint_allow_suppresses_a_check() {
let dir = unique_temp_dir("allow");
let input = dir.path().join("flipped.glb");
write_flipped_glb(&input);
let path = input.to_str().expect("utf-8 path");
let baseline = animsmith().args(["lint", path]).output().expect("runs");
assert!(
stdout(&baseline).contains("quat-flip"),
"fixture no longer produces quat-flip; suppression test would be vacuous:\n{}",
stdout(&baseline)
);
let output = animsmith()
.args(["lint", path, "--allow", "quat-flip"])
.output()
.expect("runs");
assert_eq!(
output.status.code(),
Some(0),
"stderr:\n{}",
stderr(&output)
);
assert!(
!stdout(&output).contains("quat-flip"),
"allowed check still reported:\n{}",
stdout(&output)
);
}
#[test]
fn lint_unknown_select_is_operator_error() {
let output = animsmith()
.args([
"lint",
fixture("rig.gltf").to_str().expect("utf-8 path"),
"--select",
"no-such-check",
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}",
stdout(&output)
);
let err = stderr(&output);
assert!(
err.contains("unknown check 'no-such-check'"),
"stderr:\n{err}"
);
assert!(
err.contains("known:") && err.contains("quat-flip"),
"error should list known check ids:\n{err}"
);
}
#[test]
fn lint_missing_file_is_operator_error() {
let output = animsmith()
.args(["lint", "/no/such/file.glb"])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("failed to read"),
"stderr:\n{}",
stderr(&output)
);
}
#[test]
fn lint_bad_config_is_operator_error() {
let dir = unique_temp_dir("bad-config");
let config = write_config(dir.path(), "bad.toml", "not valid = = toml [[[\n");
let output = animsmith()
.args([
"--config",
config.to_str().expect("utf-8 path"),
"lint",
fixture("rig.gltf").to_str().expect("utf-8 path"),
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(2),
"stdout:\n{}",
stdout(&output)
);
assert!(
stderr(&output).contains("bad config"),
"stderr:\n{}",
stderr(&output)
);
}
#[test]
fn config_toml_path_drives_check_behaviour() {
let dir = unique_temp_dir("config-toml");
let input = dir.path().join("flipped.glb");
write_flipped_glb(&input);
let path = input.to_str().expect("utf-8 path");
let config = write_config(
dir.path(),
"animsmith.toml",
"[checks.quat-flip]\nseverity = \"off\"\n",
);
let baseline = animsmith().args(["lint", path]).output().expect("runs");
assert!(
stdout(&baseline).contains("quat-flip"),
"fixture no longer produces quat-flip; the config test would be vacuous:\n{}",
stdout(&baseline)
);
let output = animsmith()
.args([
"--config",
config.to_str().expect("utf-8 path"),
"lint",
path,
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(0),
"stderr:\n{}",
stderr(&output)
);
assert!(
!stdout(&output).contains("quat-flip"),
"off check still reported via TOML config:\n{}",
stdout(&output)
);
}
#[test]
fn example_config_parses_verbatim() {
let config = example_config();
assert!(config.exists(), "example config missing at {config:?}");
let output = animsmith()
.args([
"--config",
config.to_str().expect("utf-8 path"),
"inspect",
fixture("rig.gltf").to_str().expect("utf-8 path"),
])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(0),
"example config did not parse:\nstderr:\n{}",
stderr(&output)
);
}
#[test]
fn inspect_reports_clip_and_profile() {
let output = animsmith()
.args(["inspect", fixture("rig.gltf").to_str().expect("utf-8 path")])
.output()
.expect("runs animsmith");
assert_eq!(
output.status.code(),
Some(0),
"stderr:\n{}",
stderr(&output)
);
let out = stdout(&output);
assert!(
out.contains("walk: 1.000s, 2 tracks, 3 keys max"),
"clip summary missing/changed:\n{out}"
);
assert!(out.contains("rig profile:"), "no profile line:\n{out}");
assert!(
out.contains("skeleton: 3 bones"),
"no skeleton line:\n{out}"
);
}