use std::env;
use std::process::Command;
const STARCAT_BIN: &str = env!("CARGO_BIN_EXE_starcat");
fn jpl_data_dir() -> Option<String> {
env::var("STARCAT_JPL_DATA").ok()
}
fn dms(base: f64, d: f64, m: f64, s: f64) -> f64 {
base + d + m / 60.0 + s / 3600.0
}
fn dlt(d: f64, tol: f64) -> (&'static str, &'static str) {
use std::io::IsTerminal;
if !std::io::stdout().is_terminal() {
return ("", "");
}
if d < tol {
("\x1b[32m", "\x1b[0m")
} else {
("\x1b[31m", "\x1b[0m")
}
}
fn find_by_id<'a>(arr: &'a serde_json::Value, id: &str) -> Option<&'a serde_json::Value> {
arr.as_array()?.iter().find(|v| v["id"] == id)
}
const ARI: f64 = 0.0;
const TAU: f64 = 30.0;
const GEM: f64 = 60.0;
const CAN: f64 = 90.0;
const LEO: f64 = 120.0;
const VIR: f64 = 150.0;
const LIB: f64 = 180.0;
const SCO: f64 = 210.0;
const SAG: f64 = 240.0;
const CAP: f64 = 270.0;
const AQU: f64 = 300.0;
const PIS: f64 = 330.0;
struct Case {
id: &'static str,
args: &'static [&'static str],
house_system: &'static str,
cusps_deg: [f64; 12],
tol_arcmin: f64,
}
fn run_case(case: &Case) {
let Some(jpl) = jpl_data_dir() else {
eprintln!("STARCAT_JPL_DATA not set — skipping integration test");
return;
};
let mut argv: Vec<String> = case.args.iter().map(|s| (*s).to_string()).collect();
argv.extend([
"--house".into(),
case.house_system.into(),
"--json".into(),
"--jpl-data".into(),
jpl,
]);
let output = Command::new(STARCAT_BIN)
.args(&argv)
.output()
.expect("failed to launch starcat binary");
assert!(
output.status.success(),
"{}: starcat exited with {:?}\nargs: {:?}\nstderr:\n{}",
case.id,
output.status,
argv,
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8(output.stdout).expect("starcat stdout must be UTF-8");
let json: serde_json::Value =
serde_json::from_str(&stdout).expect("starcat --json output must parse as JSON");
let chart = &json["charts"][0];
let cusps_obj = chart["houses"][case.house_system]
.as_object()
.unwrap_or_else(|| {
panic!(
"{}: houses.{} must be a JSON object keyed 1-12; got {}",
case.id, case.house_system, chart["houses"]
)
});
assert_eq!(
cusps_obj.len(),
12,
"{}: must emit exactly 12 cusps, got {}",
case.id,
cusps_obj.len()
);
println!("=== CLI {} {} ===", case.house_system, case.id);
let mut max_arcmin = 0.0_f64;
for (i, expected_deg) in case.cusps_deg.iter().enumerate() {
let house_key = (i + 1).to_string();
let got = cusps_obj[&house_key]["longitude"]
.as_f64()
.unwrap_or_else(|| panic!("{}: H{} cusp longitude not a number", case.id, i + 1));
let raw = (got - expected_deg).abs().rem_euclid(360.0);
let delta_arcmin = raw.min(360.0 - raw) * 60.0;
let (c, r) = dlt(delta_arcmin, case.tol_arcmin);
println!(
" H{:>2} starcat={:>10.4}° reference={:>10.4}° Δ: {c}{:>6.2}{r}′",
i + 1,
got,
expected_deg,
delta_arcmin
);
max_arcmin = max_arcmin.max(delta_arcmin);
assert!(
delta_arcmin < case.tol_arcmin,
"{}/H{}: starcat {:.4}° vs reference {:.4}° → Δ {:.2}′ exceeds {:.0}′",
case.id,
i + 1,
got,
expected_deg,
delta_arcmin,
case.tol_arcmin
);
}
println!(
" → max Δ = {:.2}′ (tol {:.0}′)",
max_arcmin, case.tol_arcmin
);
assert!(
chart["ephemeris"]["jd_ut"].is_number(),
"{}: missing ephemeris.jd_ut",
case.id
);
assert!(
chart["ephemeris"]["jd_tt"].is_number(),
"{}: missing ephemeris.jd_tt",
case.id
);
let angles = &chart["placements"]["angles"];
assert!(
find_by_id(angles, "ascendant")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"{}: missing ascendant in placements.angles",
case.id
);
assert!(
find_by_id(angles, "midheaven")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"{}: missing midheaven in placements.angles",
case.id
);
}
#[test]
fn lightning_strike_placidus_via_cli() {
let case = Case {
id: "lightning_strike",
args: &[
"compute",
"--date",
"1955-11-12",
"--time",
"22:04:00",
"--calendar",
"gregorian",
"--tz=-08:00",
"--lat",
"34.138889",
"--lon=-118.3525",
],
house_system: "placidus",
cusps_deg: [
dms(LEO, 5.0, 19.0, 30.0), dms(LEO, 27.0, 41.0, 52.0), dms(VIR, 24.0, 16.0, 24.0), dms(LIB, 26.0, 7.0, 43.0), dms(SAG, 1.0, 13.0, 48.0), dms(CAP, 5.0, 6.0, 57.0), dms(AQU, 5.0, 19.0, 30.0), dms(AQU, 27.0, 41.0, 52.0), dms(PIS, 24.0, 16.0, 24.0), dms(ARI, 26.0, 7.0, 43.0), dms(GEM, 1.0, 13.0, 48.0), dms(CAN, 5.0, 6.0, 57.0), ],
tol_arcmin: 5.0,
};
let _ = (TAU, SCO); run_case(&case);
}
#[test]
fn william_lilly_regiomontanus_via_cli() {
let case = Case {
id: "william_lilly",
args: &[
"compute",
"--date",
"1602-05-11",
"--time",
"02:00:00",
"--calendar",
"gregorian",
"--lat",
"52.7833",
"--lmt",
"--lon=-1.1833",
],
house_system: "regiomontanus",
cusps_deg: [
dms(PIS, 2.0, 6.0, 37.0), dms(TAU, 7.0, 31.0, 40.0), dms(GEM, 5.0, 20.0, 8.0), dms(GEM, 19.0, 30.0, 14.0), dms(CAN, 1.0, 48.0, 2.0), dms(CAN, 19.0, 25.0, 5.0), dms(VIR, 2.0, 6.0, 37.0), dms(SCO, 7.0, 31.0, 40.0), dms(SAG, 5.0, 20.0, 8.0), dms(SAG, 19.0, 30.0, 14.0), dms(CAP, 1.0, 48.0, 2.0), dms(CAP, 19.0, 25.0, 5.0), ],
tol_arcmin: 30.0,
};
let _ = (ARI, LEO, AQU); run_case(&case);
}
#[test]
fn vettius_valens_porphyry_via_cli() {
let case = Case {
id: "vettius_valens",
args: &[
"compute",
"--date",
"0120-02-08",
"--time",
"18:35:00",
"--calendar",
"julian",
"--lat",
"36.2333",
"--lmt",
"--lon=36.1167",
],
house_system: "porphyry",
cusps_deg: [
dms(VIR, 1.0, 29.0, 3.0), dms(LIB, 0.0, 12.0, 5.0), dms(LIB, 28.0, 55.0, 6.0), dms(SCO, 27.0, 38.0, 8.0), dms(SAG, 28.0, 55.0, 6.0), dms(AQU, 0.0, 12.0, 5.0), dms(PIS, 1.0, 29.0, 3.0), dms(ARI, 0.0, 12.0, 5.0), dms(ARI, 28.0, 55.0, 6.0), dms(TAU, 27.0, 38.0, 8.0), dms(GEM, 28.0, 55.0, 6.0), dms(LEO, 0.0, 12.0, 5.0), ],
tol_arcmin: 120.0,
};
let _ = (CAN, CAP); run_case(&case);
}
#[test]
fn json_placements_structure() {
let Some(jpl) = jpl_data_dir() else {
eprintln!("STARCAT_JPL_DATA not set — skipping integration test");
return;
};
let output = Command::new(STARCAT_BIN)
.args([
"compute",
"--date",
"1955-11-12",
"--time",
"22:04:00",
"--calendar",
"gregorian",
"--tz=-08:00",
"--lat",
"34.138889",
"--lon=-118.3525",
"--json",
"--jpl-data",
&jpl,
])
.output()
.expect("failed to launch starcat binary");
assert!(output.status.success());
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8(output.stdout).expect("utf-8"))
.expect("must parse as JSON");
assert!(json["version"].is_string(), "missing version");
assert!(json["charts"].is_array(), "missing charts array");
let chart = &json["charts"][0];
assert!(chart["placements"].is_object(), "missing placements");
assert!(
chart["placements"]["bodies"].is_array(),
"missing placements.bodies"
);
let angles = &chart["placements"]["angles"];
assert!(
find_by_id(angles, "ascendant")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"missing ascendant in placements.angles"
);
assert!(
find_by_id(angles, "midheaven")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"missing midheaven in placements.angles"
);
assert!(
find_by_id(angles, "vertex").is_none(),
"vertex must not be in angles"
);
let points = &chart["placements"]["points"];
assert!(
find_by_id(points, "vertex")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"missing vertex in placements.points"
);
assert!(
find_by_id(points, "north_node_mean")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"missing north_node_mean in placements.points"
);
assert!(
find_by_id(points, "north_node_true")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"missing north_node_true in placements.points"
);
assert!(
find_by_id(points, "black_moon_lilith_mean")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"missing black_moon_lilith_mean in placements.points"
);
assert!(
find_by_id(points, "black_moon_lilith_true")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"missing black_moon_lilith_true in placements.points"
);
let lots = &chart["placements"]["lots"];
assert!(
find_by_id(lots, "lot_of_fortune")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.is_some(),
"missing lot_of_fortune in placements.lots"
);
assert!(json["bodies"].is_null(), "bodies must live under charts[0]");
assert!(json["angles"].is_null(), "angles must live under charts[0]");
assert!(json["lots"].is_null(), "lots must live under charts[0]");
assert!(
json["placements"].is_null(),
"placements must live under charts[0]"
);
}
#[test]
fn json_house_cusps_keyed_by_house_number() {
let Some(jpl) = jpl_data_dir() else {
eprintln!("STARCAT_JPL_DATA not set — skipping integration test");
return;
};
let output = Command::new(STARCAT_BIN)
.args([
"compute",
"--date",
"1955-11-12",
"--time",
"22:04:00",
"--calendar",
"gregorian",
"--tz=-08:00",
"--lat",
"34.138889",
"--lon=-118.3525",
"--json",
"--jpl-data",
&jpl,
])
.output()
.expect("failed to launch starcat binary");
assert!(output.status.success());
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8(output.stdout).expect("utf-8"))
.expect("must parse as JSON");
let chart = &json["charts"][0];
assert!(
chart["houses"]["placidus"].is_object(),
"placidus cusps must be a JSON object keyed by house number"
);
for h in 1..=12_usize {
let key = h.to_string();
assert!(
chart["houses"]["placidus"][&key]["longitude"].is_number(),
"missing or non-numeric H{h}.longitude in placidus"
);
}
let h1 = chart["houses"]["placidus"]["1"]["longitude"]
.as_f64()
.unwrap();
let delta_arcmin = (h1 - dms(LEO, 5.0, 19.0, 30.0)).abs() * 60.0;
assert!(
delta_arcmin < 5.0,
"H1 should be Asc (~125.325°), got {h1:.4}°, Δ = {delta_arcmin:.2}′"
);
let h7 = chart["houses"]["placidus"]["7"]["longitude"]
.as_f64()
.unwrap();
let delta7 = (h7 - dms(AQU, 5.0, 19.0, 30.0)).abs() * 60.0;
assert!(
delta7 < 5.0,
"H7 should be Desc (~305.325°), got {h7:.4}°, Δ = {delta7:.2}′"
);
}
#[test]
fn json_degree_formatting() {
let Some(jpl) = jpl_data_dir() else {
eprintln!("STARCAT_JPL_DATA not set — skipping integration test");
return;
};
let output = Command::new(STARCAT_BIN)
.args([
"compute",
"--date",
"1955-11-12",
"--time",
"22:04:00",
"--calendar",
"gregorian",
"--tz=-08:00",
"--lat",
"34.138889",
"--lon=-118.3525",
"--json",
"--jpl-data",
&jpl,
])
.output()
.expect("failed to launch starcat binary");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf-8");
let compact: String = stdout.chars().filter(|c| !c.is_whitespace()).collect();
let lon_marker = "\"ecliptic_longitude\":";
let pos = compact
.find(lon_marker)
.expect("missing ecliptic_longitude");
let after = &compact[pos + lon_marker.len()..];
let end = after.find([',', '}']).unwrap();
let num = after[..end].trim();
let dp = num.split('.').nth(1).map_or(0, str::len);
assert_eq!(dp, 8, "ecliptic_longitude should have 8dp, got: {num}");
let json: serde_json::Value = serde_json::from_str(&stdout).expect("must parse as JSON");
let chart = &json["charts"][0];
let ws_h1 = &chart["houses"]["whole_sign"]["1"];
assert!(
ws_h1["longitude"].is_number(),
"whole_sign H1 must have a numeric longitude field"
);
assert_eq!(
ws_h1["sign"], "leo",
"Lightning Strike H1 whole_sign must be Leo"
);
let h1_lon = ws_h1["longitude"].as_f64().unwrap();
assert!(
(h1_lon - 120.0).abs() < 0.01,
"whole_sign H1 should be ~120°, got {h1_lon}"
);
let ws_h2_lon = chart["houses"]["whole_sign"]["2"]["longitude"]
.as_f64()
.unwrap();
assert!(
ws_h2_lon > 29.9999,
"H2 whole_sign cusp must not show float noise, got: {ws_h2_lon}"
);
}
#[test]
fn json_zero_cusp_no_scientific_notation() {
let Some(jpl) = jpl_data_dir() else {
eprintln!("STARCAT_JPL_DATA not set — skipping integration test");
return;
};
let output = Command::new(STARCAT_BIN)
.args([
"compute",
"--date",
"1895-12-03",
"--time",
"15:15:00",
"--calendar",
"gregorian",
"--tz=+01:00",
"--lat",
"48.208333",
"--lon=16.371667",
"--json",
"--jpl-data",
&jpl,
])
.output()
.expect("failed to launch starcat binary");
assert!(output.status.success());
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8(output.stdout).expect("utf-8"))
.expect("must parse as JSON");
let chart = &json["charts"][0];
let h12_lon = chart["houses"]["whole_sign"]["12"]["longitude"]
.as_f64()
.unwrap_or_else(|| panic!("H12 whole_sign longitude must be a number"));
assert!(
h12_lon.abs() < 0.001,
"H12 Aries cusp must be ~0°, got {h12_lon}"
);
assert_eq!(
chart["houses"]["whole_sign"]["12"]["sign"], "aries",
"H12 must be labeled aries"
);
assert_eq!(chart["houses"]["whole_sign"]["12"]["degree"], 0);
assert_eq!(chart["houses"]["whole_sign"]["12"]["minute"], 0);
assert_eq!(chart["houses"]["whole_sign"]["12"]["second"], 0);
}
#[test]
fn whole_sign_cusps_are_multiples_of_30() {
let Some(jpl) = jpl_data_dir() else {
eprintln!("STARCAT_JPL_DATA not set — skipping integration test");
return;
};
let output = Command::new(STARCAT_BIN)
.args([
"compute",
"--date",
"1955-11-12",
"--time",
"15:15:00",
"--calendar",
"gregorian",
"--tz=+01:00",
"--lat",
"48.208333",
"--lon=16.371667",
"--json",
"--jpl-data",
&jpl,
])
.output()
.expect("failed to launch starcat binary");
assert!(output.status.success());
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8(output.stdout).expect("utf-8"))
.expect("must parse as JSON");
let chart = &json["charts"][0];
let angles = &chart["placements"]["angles"];
let ac = find_by_id(angles, "ascendant")
.and_then(|v| v["ecliptic_longitude"].as_f64())
.expect("ascendant must be present");
assert!(
(0.0..30.0).contains(&ac),
"expected Aries rising, got ac = {ac:.4}"
);
let cusps = chart["houses"]["whole_sign"]
.as_object()
.expect("whole_sign must be a JSON object");
assert_eq!(cusps.len(), 12, "must have 12 cusps");
let asc_sign_start = (ac / 30.0).floor() as usize; for h in 1..=12_usize {
let key = h.to_string();
let lon = cusps[&key]["longitude"]
.as_f64()
.unwrap_or_else(|| panic!("H{h} longitude must be a number"));
#[allow(clippy::cast_precision_loss)]
let expected = (((asc_sign_start + h - 1) % 12) as f64) * 30.0;
let delta = (lon - expected).abs().rem_euclid(360.0);
let delta = delta.min(360.0 - delta);
assert!(
delta < 0.001,
"whole_sign H{h}: expected ~{expected}°, got {lon}° (Δ = {delta}°)"
);
assert_eq!(cusps[&key]["degree"], 0, "H{h} degree must be 0");
assert_eq!(cusps[&key]["minute"], 0, "H{h} minute must be 0");
assert_eq!(cusps[&key]["second"], 0, "H{h} second must be 0");
}
}
#[test]
fn json_bodies_have_speed_and_retrograde() {
let Some(jpl) = jpl_data_dir() else {
eprintln!("STARCAT_JPL_DATA not set — skipping integration test");
return;
};
let output = Command::new(STARCAT_BIN)
.args([
"compute",
"--date",
"1955-11-12",
"--time",
"22:04:00",
"--calendar",
"gregorian",
"--tz=-08:00",
"--lat",
"34.138889",
"--lon=-118.3525",
"--json",
"--jpl-data",
&jpl,
])
.output()
.expect("failed to launch starcat binary");
assert!(
output.status.success(),
"starcat failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8(output.stdout).expect("stdout must be UTF-8"))
.expect("output must parse as JSON");
let chart = &json["charts"][0];
let bodies = chart["placements"]["bodies"]
.as_array()
.expect("placements.bodies must be an array");
assert_eq!(bodies.len(), 10, "must emit exactly 10 bodies");
for body in bodies {
assert!(
body["daily_speed"].is_number(),
"missing daily_speed on {:?}",
body["id"]
);
assert!(
body["retrograde"].is_boolean(),
"missing retrograde on {:?}",
body["id"]
);
}
assert_eq!(bodies[0]["id"], "sun");
assert_eq!(bodies[0]["retrograde"], false, "Sun must not be retrograde");
let sun_speed = bodies[0]["daily_speed"].as_f64().unwrap();
assert!(
(0.9..=1.1).contains(&sun_speed),
"Sun daily speed should be ~1°/day, got {sun_speed:.4}"
);
assert_eq!(bodies[7]["id"], "uranus");
assert_eq!(
bodies[7]["retrograde"], true,
"Uranus should be retrograde on 1955-11-12"
);
assert!(
bodies[7]["daily_speed"].as_f64().unwrap() < 0.0,
"Uranus retrograde speed must be negative"
);
}
#[test]
fn heliocentric_speed_uses_helio_positions() {
let Some(jpl) = jpl_data_dir() else {
eprintln!("STARCAT_JPL_DATA not set — skipping integration test");
return;
};
let output = Command::new(STARCAT_BIN)
.args([
"compute",
"--date",
"2038-01-19",
"--time",
"03:14:07",
"--calendar",
"gregorian",
"--tz=+00:00",
"--helio",
"--json",
"--jpl-data",
&jpl,
])
.output()
.expect("failed to launch starcat binary");
assert!(
output.status.success(),
"starcat failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
let json: serde_json::Value =
serde_json::from_str(&String::from_utf8(output.stdout).expect("stdout must be UTF-8"))
.expect("output must parse as JSON");
let chart = &json["charts"][0];
let bodies = chart["placements"]["bodies"]
.as_array()
.expect("placements.bodies must be an array");
for body in bodies {
assert_eq!(
body["retrograde"], false,
"heliocentric body {:?} must not be retrograde",
body["id"]
);
}
assert_eq!(bodies[0]["id"], "earth");
let earth_speed = bodies[0]["daily_speed"].as_f64().unwrap();
assert!(
(0.95..=1.02).contains(&earth_speed),
"Earth heliocentric speed should be ~1°/day, got {earth_speed:.5}"
);
}