quatopsy 0.3.0

Offline Quatopsy command-line interface.
use std::fs;
use std::path::PathBuf;
use std::process::Command;

fn workspace_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
}

fn bin() -> PathBuf {
    env!("CARGO_BIN_EXE_quatopsy").into()
}

fn tempfile_dir() -> PathBuf {
    static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
    let dir = std::env::temp_dir().join(format!(
        "quatopsy-plan-{}-{}-{}",
        std::process::id(),
        COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos()
    ));
    fs::create_dir_all(&dir).unwrap();
    dir
}

#[test]
fn plan_then_analyze_keeps_verdict_ownership_in_the_kernel() {
    let root = workspace_root();
    let tmp = tempfile_dir();
    let planned = tmp.join("planned");
    let status = Command::new(bin())
        .args([
            "plan",
            "--problem",
            root.join("fixtures/plan/spherical_rest_to_rest/problem.json")
                .to_str()
                .unwrap(),
            "--output-dir",
            planned.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert!(status.success());
    let plan_body = fs::read_to_string(planned.join("plan.json")).unwrap();
    assert!(!plan_body.contains("\"result\""));
    assert!(plan_body.contains("feasible-candidate"));
    assert!(plan_body.contains("not-claimed"));
    assert!(!plan_body.contains("\"infeasibility\""));
    let csv = fs::read_to_string(planned.join("input.csv")).unwrap();
    assert_eq!(
        csv.lines().next().unwrap(),
        "t,qw,qx,qy,qz,wx,wy,wz,tx,ty,tz"
    );

    let report = tmp.join("report.json");
    let status = Command::new(bin())
        .args([
            "analyze",
            "--input",
            planned.join("input.csv").to_str().unwrap(),
            "--manifest",
            planned.join("manifest.json").to_str().unwrap(),
            "--report",
            report.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(0));
    let report_body = fs::read_to_string(&report).unwrap();
    assert!(report_body.contains("\"result\":\"pass\""));
    assert!(report_body.contains("omega-consistent"));
}

#[test]
fn wheel_plan_then_analyze_keeps_verdict_in_the_kernel() {
    let root = workspace_root();
    let tmp = tempfile_dir();
    let planned = tmp.join("planned");
    let status = Command::new(bin())
        .args([
            "plan",
            "--problem",
            root.join("fixtures/plan/wheels_rest_to_rest/problem.json")
                .to_str()
                .unwrap(),
            "--output-dir",
            planned.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert!(status.success());
    let plan_body = fs::read_to_string(planned.join("plan.json")).unwrap();
    assert!(!plan_body.contains("\"result\""));
    assert!(plan_body.contains("direct-shooting-lm"));
    let report = tmp.join("report.json");
    let status = Command::new(bin())
        .args([
            "analyze",
            "--input",
            planned.join("input.csv").to_str().unwrap(),
            "--manifest",
            planned.join("manifest.json").to_str().unwrap(),
            "--report",
            report.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(0));
    let report_body = fs::read_to_string(&report).unwrap();
    assert!(report_body.contains("\"result\":\"pass\""));
}

#[test]
fn unknown_plan_field_does_not_create_output_directory() {
    let tmp = tempfile_dir();
    let problem = tmp.join("problem.json");
    fs::write(
        &problem,
        r#"{"schema":"quatopsy.plan-problem/1","component_order":"wxyz","rotation_sense":"active","frame_from":"BODY","frame_to":"J2000","time_unit":"s","q_initial":[1,0,0,0],"q_final":[0.7071067811865476,0.7071067811865476,0,0],"omega_initial":[0,0,0],"omega_final":[0,0,0],"inertia":{"model":"spherical","j":1.0},"torque_limit_nm":0.05,"sample_count":8,"objective":"minimum-time","wheels":true}"#,
    )
    .unwrap();
    let planned = tmp.join("planned");
    let status = Command::new(bin())
        .args([
            "plan",
            "--problem",
            problem.to_str().unwrap(),
            "--output-dir",
            planned.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(2));
    assert!(!planned.exists());
}

#[test]
fn infeasible_plan_does_not_create_output_directory() {
    let tmp = tempfile_dir();
    let problem = tmp.join("problem.json");
    fs::write(
        &problem,
        r#"{"schema":"quatopsy.plan-problem/1","component_order":"wxyz","rotation_sense":"active","frame_from":"BODY","frame_to":"J2000","time_unit":"s","q_initial":[1,0,0,0],"q_final":[0.7071067811865476,0.5,0.5,0],"omega_initial":[0,0,0],"omega_final":[0,0,0],"inertia":{"model":"diagonal","jxx":1.0,"jyy":40.0,"jzz":0.2},"torque_limit_nm":[0.02,0.02,0.02],"sample_count":8,"objective":"minimum-time"}"#,
    )
    .unwrap();
    let planned = tmp.join("planned");
    let status = Command::new(bin())
        .args([
            "plan",
            "--problem",
            problem.to_str().unwrap(),
            "--output-dir",
            planned.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(2));
    assert!(!planned.exists());
}

#[cfg(unix)]
#[test]
fn symlink_output_parent_is_refused() {
    let root = workspace_root();
    let tmp = tempfile_dir();
    let real = tmp.join("real");
    let linked = tmp.join("linked");
    fs::create_dir_all(&real).unwrap();
    std::os::unix::fs::symlink(&real, &linked).unwrap();
    let status = Command::new(bin())
        .args([
            "plan",
            "--problem",
            root.join("fixtures/plan/spherical_rest_to_rest/problem.json")
                .to_str()
                .unwrap(),
            "--output-dir",
            linked.to_str().unwrap(),
        ])
        .status()
        .unwrap();
    assert_eq!(status.code(), Some(3));
    assert!(!real.join("input.csv").exists());
}