astrogram 0.0.0

Astrology data-format conversion library.
Documentation
//! Golden tests for the full `SFcht` record parser.
//!
//! For each JSON fixture in `tests/fixtures/sfcht/`, parses the corresponding
//! specimen file with `sfcht::parse_file` and asserts every field matches the
//! Python-oracle output. Skips cleanly when either the fixtures dir or the
//! specimens dir is absent.
//!
//! Fixtures are generated by `scripts/gen_sfcht_fixtures.py` and store ISO 6709
//! conventions (East-positive longitude and `tz_offset`) — matching `Chart`.

// One golden test body covers all fixtures and is deliberately long — splitting
// it would only fragment the per-field oracle assertions.
#![allow(clippy::too_many_lines)]
// Casts in this file extract small-int fields (u8 enums, i16 minutes-of-arc)
// from u64/i64 JSON numbers parsed by serde_json. The fixtures originate from
// these same enum types, so the round-trip values are always in range.
#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
// trivially_copy_pass_by_ref: helpers take `&u8`/`&u16` to mirror the parsed-
// field reference shapes from the SFcht struct types.
#![allow(clippy::trivially_copy_pass_by_ref)]

use astrogram::chart::{CoordinateSystem, EventType, HouseSystem, Zodiac};
use astrogram::sfcht::parse_file;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};

fn fixtures_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sfcht")
}

fn walk_specimens(dir: &Path, map: &mut std::collections::HashMap<String, PathBuf>) {
    let Ok(rd) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in rd.flatten() {
        let path = entry.path();
        if path.is_dir() {
            walk_specimens(&path, map);
        } else if path
            .extension()
            .is_some_and(|e| e.eq_ignore_ascii_case("sfcht"))
        {
            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
                map.insert(stem.to_owned(), path);
            }
        }
    }
}

fn specimens_map() -> Option<std::collections::HashMap<String, PathBuf>> {
    let root = PathBuf::from(std::env::var_os("ASTRO_SPECIMENS")?);
    let mut map = std::collections::HashMap::new();
    walk_specimens(&root, &mut map);
    Some(map)
}

// helpers to convert chart enums back to the raw u8 stored in fixtures
fn event_type_u8(et: &EventType) -> u8 {
    match et {
        EventType::Unspecified => 0,
        EventType::Male => 1,
        EventType::Female => 2,
        EventType::Event => 3,
        EventType::Horary => 4,
    }
}

fn house_system_u8(hs: &HouseSystem) -> u8 {
    match hs {
        HouseSystem::Campanus => 1,
        HouseSystem::Koch => 2,
        HouseSystem::Meridian => 3,
        HouseSystem::Morinus => 4,
        HouseSystem::Placidus => 5,
        HouseSystem::Porphyry => 6,
        HouseSystem::Regiomontanus => 7,
        HouseSystem::Topocentric => 8,
        HouseSystem::Equal => 9,
        HouseSystem::ZeroAries => 10,
        HouseSystem::SolarSign => 11,
        HouseSystem::WholeSign => 26,
        HouseSystem::HinduBhava => 27,
        HouseSystem::Alcabitius => 28,
        HouseSystem::Other(n) => *n,
    }
}

fn zodiac_u8(z: &Zodiac) -> u8 {
    match z {
        Zodiac::Tropical => 1,
        Zodiac::FaganAllen => 2,
        Zodiac::Lahiri => 3,
        Zodiac::DeLuce => 4,
        Zodiac::Raman => 5,
        Zodiac::UshaShashi => 6,
        Zodiac::Krishnamurti => 7,
        Zodiac::DjwhalKhul => 8,
        Zodiac::Draconic => 9,
        Zodiac::Svp => 10,
        Zodiac::SriYukteswar => 11,
        Zodiac::JnBhasin => 12,
        Zodiac::LarryEly => 13,
        Zodiac::TakraI => 14,
        Zodiac::TakraII => 15,
        Zodiac::SundaraRajan => 16,
        Zodiac::ShillPond => 17,
        Zodiac::Other(n) => *n,
    }
}

fn coord_system_u8(cs: &CoordinateSystem) -> u8 {
    match cs {
        CoordinateSystem::Geocentric => 1,
        CoordinateSystem::Heliocentric => 2,
    }
}

fn opt_str(v: &Value) -> Option<&str> {
    if v.is_null() { None } else { v.as_str() }
}

fn approx_eq(a: f64, b: f64) -> bool {
    (a - b).abs() < 1e-4
}

#[test]
fn golden_sfcht_records() {
    let fix_dir = fixtures_dir();
    let Some(spec_map) = specimens_map() else {
        eprintln!("ASTRO_SPECIMENS not set — skipping integration test");
        return;
    };

    if !fix_dir.exists() {
        eprintln!("fixtures dir absent; skipping");
        return;
    }

    let mut files_checked = 0usize;
    let mut records_checked = 0usize;

    let mut fixture_paths: Vec<_> = fs::read_dir(&fix_dir)
        .expect("read fixtures dir")
        .filter_map(std::result::Result::ok)
        .map(|e| e.path())
        .filter(|p| p.extension().is_some_and(|e| e == "json"))
        .collect();
    fixture_paths.sort();

    for fix_path in &fixture_paths {
        let fix_text = fs::read_to_string(fix_path).expect("read fixture");
        let fix: Value = serde_json::from_str(&fix_text).expect("parse fixture json");

        let stem = fix_path.file_stem().unwrap().to_string_lossy();
        let Some(sfcht_path) = spec_map.get(stem.as_ref()) else {
            eprintln!("  specimen missing for {stem}; skipping");
            continue;
        };

        let bytes = fs::read(sfcht_path).expect("read specimen");
        let (header, charts) =
            parse_file(&bytes).unwrap_or_else(|e| panic!("{stem}: parse_file failed: {e}"));

        let expected_count = fix["record_count"].as_u64().unwrap() as usize;
        assert_eq!(
            charts.len(),
            expected_count,
            "{stem}: expected {expected_count} charts, got {}",
            charts.len()
        );
        assert_eq!(header.record_count as usize, expected_count);

        let fix_records = fix["records"].as_array().unwrap();

        for (i, (chart, fix_rec)) in charts.iter().zip(fix_records.iter()).enumerate() {
            let ctx = format!("{stem} record[{i}] {:?}", chart.name);

            // strings
            assert_eq!(
                chart.name.as_str(),
                fix_rec["name"].as_str().unwrap_or(""),
                "{ctx} name"
            );
            assert_eq!(
                chart.secondary_name.as_deref(),
                opt_str(&fix_rec["secondary_name"]),
                "{ctx} secondary_name"
            );
            assert_eq!(
                chart.city.as_deref(),
                opt_str(&fix_rec["city"]),
                "{ctx} city"
            );
            assert_eq!(
                chart.region.as_deref(),
                opt_str(&fix_rec["region"]),
                "{ctx} region"
            );
            assert_eq!(
                chart.tz_abbreviation.as_deref(),
                opt_str(&fix_rec["tz_abbrev"]),
                "{ctx} tz_abbrev"
            );
            assert_eq!(
                chart.source_rating.as_deref(),
                opt_str(&fix_rec["source_rating"]),
                "{ctx} source_rating"
            );

            // coordinates (approximate — f32 → f64 promotion)
            assert!(
                approx_eq(
                    chart.longitude.degrees(),
                    fix_rec["longitude"].as_f64().unwrap()
                ),
                "{ctx} longitude: {} vs {}",
                chart.longitude.degrees(),
                fix_rec["longitude"]
            );
            assert!(
                approx_eq(
                    chart.latitude.degrees(),
                    fix_rec["latitude"].as_f64().unwrap()
                ),
                "{ctx} latitude: {} vs {}",
                chart.latitude.degrees(),
                fix_rec["latitude"]
            );
            assert!(
                approx_eq(
                    chart.tz_offset_hours,
                    fix_rec["tz_offset"].as_f64().unwrap()
                ),
                "{ctx} tz_offset: {} vs {}",
                chart.tz_offset_hours,
                fix_rec["tz_offset"]
            );

            // datetime
            assert_eq!(
                chart.year,
                fix_rec["year"].as_i64().unwrap() as i16,
                "{ctx} year"
            );
            assert_eq!(
                chart.month,
                fix_rec["month"].as_u64().unwrap() as u8,
                "{ctx} month"
            );
            assert_eq!(
                chart.day,
                fix_rec["day"].as_u64().unwrap() as u8,
                "{ctx} day"
            );
            assert_eq!(
                chart.hour,
                fix_rec["hour"].as_u64().unwrap() as u8,
                "{ctx} hour"
            );
            assert_eq!(
                chart.minute,
                fix_rec["minute"].as_u64().unwrap() as u8,
                "{ctx} minute"
            );
            assert_eq!(
                chart.second,
                fix_rec["second"].as_u64().unwrap() as u8,
                "{ctx} second"
            );

            // flags and enums
            assert_eq!(
                chart.is_lmt,
                fix_rec["is_lmt"].as_bool().unwrap(),
                "{ctx} is_lmt"
            );
            assert_eq!(
                event_type_u8(&chart.event_type),
                fix_rec["event_type"].as_u64().unwrap() as u8,
                "{ctx} event_type"
            );
            assert_eq!(
                house_system_u8(&chart.house_system),
                fix_rec["house_system"].as_u64().unwrap() as u8,
                "{ctx} house_system"
            );
            assert_eq!(
                zodiac_u8(&chart.zodiac),
                fix_rec["zodiac"].as_u64().unwrap() as u8,
                "{ctx} zodiac"
            );
            assert_eq!(
                coord_system_u8(&chart.coordinate_system),
                fix_rec["coordinate_system"].as_u64().unwrap() as u8,
                "{ctx} coordinate_system"
            );

            // sub-charts: verify count and first sub-chart fields if present
            let fix_scs = fix_rec["sub_charts"].as_array().unwrap();
            assert_eq!(
                chart.sub_charts.len(),
                fix_scs.len(),
                "{ctx} sub_chart count"
            );
            for (si, (sc, fix_sc)) in chart.sub_charts.iter().zip(fix_scs.iter()).enumerate() {
                let sc_ctx = format!("{ctx} sub[{si}]");
                assert_eq!(
                    sc.name.as_str(),
                    fix_sc["name"].as_str().unwrap_or(""),
                    "{sc_ctx} name"
                );
                assert!(
                    approx_eq(
                        sc.longitude.degrees(),
                        fix_sc["longitude"].as_f64().unwrap()
                    ),
                    "{sc_ctx} longitude"
                );
                assert!(
                    approx_eq(sc.latitude.degrees(), fix_sc["latitude"].as_f64().unwrap()),
                    "{sc_ctx} latitude"
                );
                assert!(
                    approx_eq(sc.tz_offset_hours, fix_sc["tz_offset"].as_f64().unwrap()),
                    "{sc_ctx} tz_offset"
                );
                assert_eq!(
                    sc.year,
                    fix_sc["year"].as_i64().unwrap() as i16,
                    "{sc_ctx} year"
                );
                assert_eq!(
                    sc.month,
                    fix_sc["month"].as_u64().unwrap() as u8,
                    "{sc_ctx} month"
                );
                assert_eq!(
                    sc.day,
                    fix_sc["day"].as_u64().unwrap() as u8,
                    "{sc_ctx} day"
                );
                assert_eq!(
                    sc.hour,
                    fix_sc["hour"].as_u64().unwrap() as u8,
                    "{sc_ctx} hour"
                );
                assert_eq!(
                    sc.minute,
                    fix_sc["minute"].as_u64().unwrap() as u8,
                    "{sc_ctx} minute"
                );
                assert_eq!(
                    sc.second,
                    fix_sc["second"].as_u64().unwrap() as u8,
                    "{sc_ctx} second"
                );
                assert_eq!(
                    sc.is_lmt,
                    fix_sc["is_lmt"].as_bool().unwrap(),
                    "{sc_ctx} is_lmt"
                );
            }

            records_checked += 1;
        }

        eprintln!("  {stem}: {expected_count} records ok");
        files_checked += 1;
    }

    if files_checked == 0 {
        eprintln!("no fixture files found in {}; skipping", fix_dir.display());
        return;
    }
    eprintln!("golden: {files_checked} files, {records_checked} records verified");
}