use serde_json::Value;
use std::path::PathBuf;
use std::process::{Command, Output};
fn animsmith() -> Command {
Command::new(env!("CARGO_BIN_EXE_animsmith"))
}
fn asset(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../examples/assets")
.join(name)
}
fn unique_temp_dir(name: &str) -> tempfile::TempDir {
tempfile::Builder::new()
.prefix(&format!("animsmith-cookbook-{name}-"))
.tempdir()
.expect("creates temp dir")
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn run(args: &[&str]) -> (Option<i32>, String) {
let output = animsmith().args(args).output().expect("runs animsmith");
(output.status.code(), stdout(&output))
}
#[test]
fn example_assets_match_generator_output() {
let tmp = unique_temp_dir("gen");
animsmith_testkit::write_example_assets(tmp.path(), |doc, path| {
animsmith_gltf::write::write(doc, path).map(|_| ())
})
.expect("writes example assets");
for name in ["clip.glb", "clip-dirty.glb", "walk.glb", "walk-dirty.glb"] {
let committed = std::fs::read(asset(name)).expect("reads committed asset");
let regenerated = std::fs::read(tmp.path().join(name))
.unwrap_or_else(|e| panic!("generator did not write {name}: {e}"));
if committed != regenerated {
let offset = committed
.iter()
.zip(®enerated)
.position(|(a, b)| a != b)
.unwrap_or(committed.len().min(regenerated.len()));
panic!(
"examples/assets/{name} is stale ({} committed bytes vs {} regenerated, \
first difference at byte {offset}) — regenerate with \
`cargo run -p animsmith --example gen_example_assets`",
committed.len(),
regenerated.len(),
);
}
}
}
#[test]
fn cookbook_first_gate() {
let clean = asset("clip.glb");
let clean = clean.to_str().unwrap();
let dirty = asset("clip-dirty.glb");
let dirty = dirty.to_str().unwrap();
let (code, out) = run(&["inspect", clean]);
assert_eq!(code, Some(0), "inspect clean");
assert!(out.contains("swing"), "inspect names the clip: {out}");
let (code, out) = run(&["measure", "--format", "json", clean]);
assert_eq!(code, Some(0), "measure clean exits 0");
let doc: Value = serde_json::from_str(&out).expect("measure --format json is valid JSON");
assert!(
doc["files"][0]["measurements"].get("swing").is_some(),
"measure reports the clip's metrics: {out}"
);
let (code, out) = run(&["lint", clean]);
assert_eq!(code, Some(0), "lint clean exits 0");
assert!(out.contains("clean"), "lint reports clean: {out}");
let (code, out) = run(&["lint", dirty]);
assert_eq!(code, Some(1), "lint dirty exits 1");
assert!(
out.contains("quat-norm") && out.contains("quat-flip"),
"lint dirty names both checks: {out}"
);
let (code, out) = run(&["lint", "--deny-warnings", dirty]);
assert_eq!(code, Some(1), "--deny-warnings dirty exits 1");
assert!(
out.contains("quat-norm") && out.contains("quat-flip"),
"--deny-warnings still reports the findings: {out}"
);
let (code, out) = run(&["lint", "--select", "quat-flip", dirty]);
assert_eq!(code, Some(0), "warning-only run exits 0");
assert!(
out.contains("quat-flip") && !out.contains("quat-norm"),
"--select isolates the warning: {out}"
);
let (code, _) = run(&["lint", "--deny-warnings", "--select", "quat-flip", dirty]);
assert_eq!(code, Some(1), "--deny-warnings promotes the warning");
let (code, out) = run(&["lint", "--format", "json", dirty]);
assert_eq!(code, Some(1), "json lint dirty exits 1");
let doc: Value = serde_json::from_str(&out).expect("lint --format json is valid JSON");
let ids: Vec<&str> = doc["files"][0]["findings"]
.as_array()
.expect("findings array")
.iter()
.filter_map(|f| f["check_id"].as_str())
.collect();
assert!(
ids.contains(&"quat-norm") && ids.contains(&"quat-flip"),
"json findings name both checks: {ids:?}"
);
}
#[test]
fn cookbook_repair_roundtrip() {
let dirty = asset("clip-dirty.glb");
let dirty = dirty.to_str().unwrap();
let tmp = unique_temp_dir("repair");
let fixed = tmp.path().join("fixed.glb");
let fixed = fixed.to_str().unwrap();
let (code, out) = run(&["fix", "--dry-run", dirty]);
assert_eq!(code, Some(1), "dry-run with pending repairs exits 1");
assert!(out.contains("would fix"), "dry-run reports repairs: {out}");
let (code, _) = run(&["fix", dirty, "-o", fixed]);
assert_eq!(code, Some(0), "fix -o exits 0");
let (code, out) = run(&["lint", fixed]);
assert_eq!(code, Some(0), "repaired asset lints clean");
assert!(out.contains("clean"), "repaired asset is clean: {out}");
let (code, out) = run(&["diff", dirty, fixed]);
assert_eq!(code, Some(0), "lossless repair diffs clean");
assert!(
out.contains("no significant movement"),
"diff reports no movement: {out}"
);
}
#[test]
fn cookbook_transform() {
let clean = asset("clip.glb");
let clean = clean.to_str().unwrap();
let tmp = unique_temp_dir("transform");
let sliced = tmp.path().join("sliced.glb");
let sliced = sliced.to_str().unwrap();
let held = tmp.path().join("held.glb");
let held = held.to_str().unwrap();
let (code, out) = run(&["transform", clean, "-o", sliced, "--slice", "0.5:1.0"]);
assert_eq!(code, Some(0), "slice exits 0");
assert_eq!(
out,
format!(
" sliced 'swing' to [0.5:1]s (3 keys max)\nwrote {sliced} (2 node(s), 1 clip(s), 0 mesh(es) / 0 position(s), 0 material(s))\n"
),
"slice transcript matches the cookbook"
);
let (code, out) = run(&["diff", clean, sliced]);
assert_eq!(code, Some(1), "slice moves measurements, diff exits 1");
assert!(out.contains("moved"), "diff lists the moved metrics: {out}");
let (code, out) = run(&["transform", clean, "-o", held, "--hold-extend", "0.5"]);
assert_eq!(code, Some(0), "hold-extend exits 0");
assert_eq!(
out,
format!(
" hold-extended 'swing' by 0.5s\nwrote {held} (2 node(s), 1 clip(s), 0 mesh(es) / 0 position(s), 0 material(s))\n"
),
"hold transcript matches the cookbook"
);
let (code, out) = run(&["diff", clean, held]);
assert_eq!(code, Some(1), "hold-extend changes the clip, diff exits 1");
assert!(out.contains("moved"), "diff lists the moved metrics: {out}");
}
#[test]
fn cookbook_config_steering() {
let dirty = asset("clip-dirty.glb");
let dirty = dirty.to_str().unwrap();
let (code, out) = run(&["lint", "--select", "quat-norm", dirty]);
assert_eq!(code, Some(1), "select quat-norm still errors");
assert!(out.contains("quat-norm"), "select keeps quat-norm: {out}");
assert!(!out.contains("quat-flip"), "select drops quat-flip: {out}");
let (code, out) = run(&["lint", "--allow", "quat-flip", dirty]);
assert_eq!(code, Some(1), "allow keeps the quat-norm error");
assert!(
out.contains("quat-norm") && !out.contains("quat-flip"),
"allow hides only quat-flip, keeping quat-norm: {out}"
);
let tmp = unique_temp_dir("config");
let cfg = tmp.path().join("demote.toml");
std::fs::write(&cfg, "[checks.quat-flip]\nseverity = \"note\"\n").expect("writes config");
let (code, out) = run(&["lint", "--config", cfg.to_str().unwrap(), dirty]);
assert_eq!(code, Some(1), "quat-norm error keeps exit 1");
assert!(
out.contains("note[quat-flip]"),
"override demotes quat-flip to a note: {out}"
);
}
#[test]
fn cookbook_semantic_contract() {
let walk = asset("walk.glb");
let walk = walk.to_str().unwrap();
let dirty = asset("walk-dirty.glb");
let dirty = dirty.to_str().unwrap();
let config =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/walk.animsmith.toml");
let config = config.to_str().unwrap();
let (code, out) = run(&["inspect", walk]);
assert_eq!(code, Some(0), "inspect walk");
assert!(
out.contains("ue-mannequin"),
"inspect detects the profile: {out}"
);
let (code, out) = run(&["lint", "--config", config, walk]);
assert_eq!(code, Some(0), "clean walk passes its contract");
assert!(out.contains("clean"), "walk is clean: {out}");
let (code, out) = run(&["lint", "--config", config, dirty]);
assert_eq!(code, Some(1), "popped seam fails loop-seam");
assert!(out.contains("loop-seam"), "names loop-seam: {out}");
let (_, json) = run(&["lint", "--config", config, "--format", "json", dirty]);
let doc: Value = serde_json::from_str(&json).expect("lint --format json is valid JSON");
let findings = doc["files"][0]["findings"]
.as_array()
.expect("findings array");
let ids: Vec<(&str, &str)> = findings
.iter()
.map(|f| {
(
f["check_id"].as_str().unwrap_or_default(),
f["severity"].as_str().unwrap_or_default(),
)
})
.collect();
assert_eq!(
ids,
vec![("loop-seam", "error")],
"the popped seam is the only finding: {ids:?}"
);
let (code, out) = run(&["lint", dirty]);
assert_eq!(code, Some(0), "bare lint skips loop-seam");
assert!(out.contains("clean"), "bare lint is clean: {out}");
}