use astrogram::adbxml::{parse_file, write_file};
use astrogram::chart::{
Chart, CoordinateSystem, EventType, HouseSystem, Latitude, Longitude, Zodiac,
};
use std::path::PathBuf;
fn specimen_path() -> Option<PathBuf> {
let val = std::env::var_os("ASTRO_SPECIMENS")?;
let dir = PathBuf::from(val).join("adb");
let found = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.filter_map(std::result::Result::ok)
.map(|e| e.path())
.find(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml")));
Some(found.unwrap_or_else(|| dir.join("specimen.xml")))
}
fn within(a: f64, b: f64, eps: f64) -> bool {
(a - b).abs() <= eps
}
fn wrap(entry_xml: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="utf-8"?>
<astrodatabank_export export_format="160715">
{entry_xml}
</astrodatabank_export>"#
)
}
#[allow(clippy::too_many_arguments)]
fn minimal_entry(
adb_id: u32,
name: &str,
csex: &str,
rrc: u32,
iyear: i32,
imonth: u32,
iday: u32,
time_val: &str,
jd_ut: &str,
ctimetype: &str,
sznabbr: &str,
slati: &str,
slong: &str,
city: &str,
country: &str,
) -> String {
format!(
r#" <adb_entry adb_id="{adb_id}">
<public_data>
<name>{name}</name>
<gender csex="{csex}">{}</gender>
<roddenrating rrc="{rrc}">{}</roddenrating>
<datatype sdatatype="Public Figure" dtc="1" />
<bdata>
<sbdate ccalendar="g" iyear="{iyear}" imonth="{imonth}" iday="{iday}">{iyear}/{imonth:02}/{iday:02}</sbdate>
<sbtime ctimetype="{ctimetype}" jd_ut="{jd_ut}" sznabbr="{sznabbr}">{time_val}</sbtime>
<place slati="{slati}" slong="{slong}">{city}</place>
<country>{country}</country>
</bdata>
</public_data>
</adb_entry>"#,
csex.to_uppercase(),
rrc_to_str(rrc),
)
}
fn rrc_to_str(rrc: u32) -> &'static str {
match rrc {
1 => "AA",
2 => "A",
3 => "B",
4 => "C",
5 => "DD",
6 => "X",
7 => "XX",
_ => "?",
}
}
#[test]
fn empty_document_gives_empty_vec() {
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<astrodatabank_export export_format="160715">
</astrodatabank_export>"#;
let charts = parse_file(xml).unwrap();
assert!(charts.is_empty());
}
#[test]
fn bad_xml_returns_error() {
let result = parse_file("<unclosed");
assert!(result.is_err());
}
#[test]
fn parses_date_fields() {
let xml = wrap(&minimal_entry(
1,
"Epoch Zero",
"m",
2,
1970,
1,
1,
"12:00",
"2440588.0",
"s",
"GMT",
"00n00",
"0e00",
"Null Island",
"Ocean",
));
let charts = parse_file(&xml).unwrap();
let c = &charts[0];
assert_eq!(c.year, 1970);
assert_eq!(c.month, 1);
assert_eq!(c.day, 1);
}
#[test]
fn parses_time_fields() {
let xml = wrap(&minimal_entry(
2,
"Time Test",
"f",
3,
2000,
6,
15,
"14:30",
"2451711.104167",
"s",
"UTC",
"51n30",
"0e00",
"London",
"England",
));
let charts = parse_file(&xml).unwrap();
let c = &charts[0];
assert_eq!(c.hour, 14);
assert_eq!(c.minute, 30);
assert_eq!(c.second, 0);
}
#[test]
fn tz_offset_zero_at_gmt() {
let xml = wrap(&minimal_entry(
3,
"GMT Test",
"m",
1,
1970,
1,
1,
"12:00",
"2440588.0",
"s",
"GMT",
"51n30",
"0e00",
"Greenwich",
"England",
));
let charts = parse_file(&xml).unwrap();
assert!(within(charts[0].tz_offset_hours, 0.0, 1e-3));
}
#[test]
fn tz_offset_plus_one_east() {
let xml = wrap(&minimal_entry(
4,
"CET Test",
"m",
1,
1970,
1,
1,
"12:00",
"2440587.958333",
"s",
"CET",
"48n52",
"2e20",
"Paris",
"France",
));
let charts = parse_file(&xml).unwrap();
assert!(within(charts[0].tz_offset_hours, 1.0, 1e-3));
}
#[test]
fn tz_offset_minus_five_west() {
let xml = wrap(&minimal_entry(
5,
"EST Test",
"m",
1,
1970,
1,
1,
"12:00",
"2440588.208333",
"s",
"EST",
"40n43",
"74w00",
"New York",
"USA",
));
let charts = parse_file(&xml).unwrap();
assert!(within(charts[0].tz_offset_hours, -5.0, 1e-3));
}
#[test]
fn parses_north_lat_degrees_and_minutes() {
let xml = wrap(&minimal_entry(
10,
"Lat Test",
"m",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"45n42",
"0e00",
"City",
"Country",
));
let charts = parse_file(&xml).unwrap();
assert!(within(charts[0].latitude.degrees(), 45.7, 1e-4));
}
#[test]
fn parses_north_lat_with_seconds() {
let xml = wrap(&minimal_entry(
11,
"Lat Sec Test",
"m",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"52n0445",
"0e00",
"City",
"Country",
));
let charts = parse_file(&xml).unwrap();
let expected = 52.0 + 4.0 / 60.0 + 45.0 / 3600.0;
assert!(within(charts[0].latitude.degrees(), expected, 1e-4));
}
#[test]
fn parses_south_lat() {
let xml = wrap(&minimal_entry(
12,
"Sydney",
"m",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"33s52",
"151e12",
"Sydney",
"Australia",
));
let charts = parse_file(&xml).unwrap();
let expected = -(33.0 + 52.0 / 60.0);
assert!(within(charts[0].latitude.degrees(), expected, 1e-4));
}
#[test]
fn parses_east_lon_degrees_and_minutes() {
let xml = wrap(&minimal_entry(
13,
"Lon Test",
"m",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"48n52",
"2e20",
"Paris",
"France",
));
let charts = parse_file(&xml).unwrap();
assert!(within(charts[0].longitude.degrees(), 2.3333, 1e-3));
}
#[test]
fn parses_west_lon_with_seconds() {
let xml = wrap(&minimal_entry(
14,
"LA",
"m",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"34n03",
"122w1959",
"Los Angeles",
"USA",
));
let charts = parse_file(&xml).unwrap();
let expected = -(122.0 + 19.0 / 60.0 + 59.0 / 3600.0);
assert!(within(charts[0].longitude.degrees(), expected, 1e-3));
}
#[test]
fn csex_m_gives_male() {
let xml = wrap(&minimal_entry(
20,
"Male",
"m",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"51n30",
"0e00",
"X",
"Y",
));
assert_eq!(parse_file(&xml).unwrap()[0].event_type, EventType::Male);
}
#[test]
fn csex_f_gives_female() {
let xml = wrap(&minimal_entry(
21,
"Female",
"f",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"51n30",
"0e00",
"X",
"Y",
));
assert_eq!(parse_file(&xml).unwrap()[0].event_type, EventType::Female);
}
#[test]
fn rrc_maps_to_rating_strings() {
for (rrc, expected) in [
(1, "AA"),
(2, "A"),
(3, "B"),
(4, "C"),
(5, "DD"),
(6, "X"),
(7, "XX"),
] {
let xml = wrap(&minimal_entry(
rrc,
"Rating Test",
"m",
rrc,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"51n30",
"0e00",
"X",
"Y",
));
let charts = parse_file(&xml).unwrap();
assert_eq!(
charts[0].source_rating.as_deref(),
Some(expected),
"rrc={rrc}"
);
}
}
#[test]
fn ctimetype_l_sets_is_lmt() {
let xml = wrap(&minimal_entry(
30,
"LMT",
"m",
1,
1494,
9,
12,
"22:00",
"2266996.417593",
"l",
"LMT",
"45n42",
"0w20",
"Cognac",
"France",
));
let charts = parse_file(&xml).unwrap();
assert!(charts[0].is_lmt);
}
#[test]
fn ctimetype_s_clears_is_lmt() {
let xml = wrap(&minimal_entry(
31,
"Standard",
"m",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"51n30",
"0e00",
"London",
"England",
));
assert!(!parse_file(&xml).unwrap()[0].is_lmt);
}
#[test]
fn city_and_region_populated() {
let xml = wrap(&minimal_entry(
40,
"Geo Test",
"m",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"48n52",
"2e20",
"Paris",
"France",
));
let charts = parse_file(&xml).unwrap();
assert_eq!(charts[0].city.as_deref(), Some("Paris"));
assert_eq!(charts[0].region.as_deref(), Some("France"));
}
#[test]
fn lon_exceeding_180w_wraps_to_east() {
let xml = wrap(
r#" <adb_entry adb_id="79883">
<public_data>
<name>Kiska</name>
<gender csex="e">N/A</gender>
<roddenrating rrc="1">AA</roddenrating>
<datatype sdatatype="Mundane" dtc="5" />
<bdata>
<sbdate ccalendar="g" iyear="2014" imonth="6" iday="23">2014/06/23</sbdate>
<sbtime ctimetype="s" jd_ut="2456832.370243" sznabbr="UTC">20:53:09</sbtime>
<place slati="51n5731" slong="182w3040">Kiska</place>
<country>Alaska</country>
</bdata>
</public_data>
</adb_entry>"#,
);
let charts = parse_file(&xml).unwrap();
let expected = -(182.0 + 30.0 / 60.0 + 40.0 / 3600.0) + 360.0;
assert!(within(charts[0].longitude.degrees(), expected, 1e-3));
}
#[test]
fn defaults_to_placidus_tropical_geocentric() {
let xml = wrap(&minimal_entry(
50,
"Defaults",
"m",
1,
2000,
1,
1,
"12:00",
"2451545.0",
"s",
"UTC",
"51n30",
"0e00",
"X",
"Y",
));
let c = &parse_file(&xml).unwrap()[0];
assert_eq!(c.house_system, HouseSystem::Placidus);
assert_eq!(c.zodiac, Zodiac::Tropical);
}
#[test]
fn parses_time_with_seconds() {
let xml = wrap(
r#" <adb_entry adb_id="1798">
<public_data>
<name>Seconds Test</name>
<gender csex="m">M</gender>
<roddenrating rrc="1">AA</roddenrating>
<datatype sdatatype="Public Figure" dtc="1" />
<bdata>
<sbdate ccalendar="g" iyear="2000" imonth="6" iday="15">2000/06/15</sbdate>
<sbtime ctimetype="s" jd_ut="2451711.0" sznabbr="UTC">05:09:25</sbtime>
<place slati="51n30" slong="0e00">London</place>
<country>England</country>
</bdata>
</public_data>
</adb_entry>"#,
);
let charts = parse_file(&xml).unwrap();
let c = &charts[0];
assert_eq!(c.hour, 5);
assert_eq!(c.minute, 9);
assert_eq!(c.second, 25);
}
#[test]
fn time_unknown_yes_uses_noon_placeholder() {
let xml = wrap(
r#" <adb_entry adb_id="15">
<public_data>
<name>Valens, Vettius</name>
<gender csex="m">M</gender>
<roddenrating rrc="6">X</roddenrating>
<datatype sdatatype="Public Figure" dtc="1" />
<bdata>
<sbdate ccalendar="j" iyear="120" imonth="2" iday="8">0120/02/08</sbdate>
<sbtime sbtime_ampm="" ctimetype="l" stimetype="local mean time" stmerid="m36e07" ctzauto="a" jd_ut="1764928.174" sznabbr="LMT" time_unknown="yes">unknown, 12:00 used</sbtime>
<place slati="36n14" slong="36e07">Antioch</place>
<country sctr="TUR">Türkiye</country>
</bdata>
</public_data>
</adb_entry>"#,
);
let charts = parse_file(&xml).unwrap();
assert_eq!(charts[0].hour, 12);
assert_eq!(charts[0].minute, 0);
}
#[test]
fn parses_multiple_entries() {
let entry1 = minimal_entry(
60,
"Alice",
"f",
1,
1980,
3,
15,
"08:00",
"2444313.833333",
"s",
"UTC",
"51n30",
"0e00",
"London",
"England",
);
let entry2 = minimal_entry(
61,
"Bob",
"m",
2,
1985,
7,
4,
"12:00",
"2446251.0",
"s",
"UTC",
"40n43",
"74w00",
"New York",
"USA",
);
let xml = wrap(&format!("{entry1}\n{entry2}"));
let charts = parse_file(&xml).unwrap();
assert_eq!(charts.len(), 2);
assert_eq!(charts[0].name, "Alice");
assert_eq!(charts[1].name, "Bob");
}
#[test]
fn acceptance_entry_0_francois_i() {
let Some(path) = specimen_path() else {
eprintln!("ASTRO_SPECIMENS not set — skipping integration test");
return;
};
if !path.exists() {
eprintln!("ADB specimen absent ({}); skipping", path.display());
return;
}
let xml = std::fs::read_to_string(&path).expect("read specimen");
let charts = parse_file(&xml).expect("parse specimen");
assert!(!charts.is_empty(), "expected at least one chart");
let c = &charts[0];
assert_eq!(c.name, "François I, King of France");
assert_eq!(c.event_type, EventType::Male);
assert_eq!(c.source_rating.as_deref(), Some("AA"));
assert_eq!(c.year, 1494);
assert_eq!(c.month, 9);
assert_eq!(c.day, 12);
assert_eq!(c.hour, 22);
assert_eq!(c.minute, 0);
assert_eq!(c.second, 0);
assert!(
within(c.latitude.degrees(), 45.0 + 42.0 / 60.0, 1e-4),
"lat={}",
c.latitude.degrees()
);
assert!(
within(c.longitude.degrees(), -(20.0 / 60.0), 1e-4),
"lon={}",
c.longitude.degrees()
);
assert!(c.is_lmt);
assert_eq!(c.tz_abbreviation.as_deref(), Some("LMT"));
assert!(
within(c.tz_offset_hours, -(20.0 / 60.0 / 15.0), 5e-3),
"tz={}",
c.tz_offset_hours
);
assert_eq!(c.city.as_deref(), Some("Cognac"));
assert_eq!(c.region.as_deref(), Some("France"));
assert_eq!(c.house_system, HouseSystem::Placidus);
assert_eq!(c.zodiac, Zodiac::Tropical);
}
#[test]
fn acceptance_entry_4_elizabeth_i() {
let Some(path) = specimen_path() else {
eprintln!("ASTRO_SPECIMENS not set — skipping integration test");
return;
};
if !path.exists() {
eprintln!("ADB specimen absent ({}); skipping", path.display());
return;
}
let xml = std::fs::read_to_string(&path).expect("read specimen");
let charts = parse_file(&xml).expect("parse specimen");
let c = &charts[4];
assert_eq!(c.name, "Elizabeth I, Queen of England");
assert_eq!(c.event_type, EventType::Female);
assert_eq!(c.year, 1533);
assert_eq!(c.month, 9);
assert_eq!(c.day, 7);
assert_eq!(c.hour, 14);
assert_eq!(c.minute, 54);
assert!(
within(c.longitude.degrees(), 0.0, 1e-4),
"lon={}",
c.longitude.degrees()
);
assert!(
within(c.latitude.degrees(), 51.0 + 29.0 / 60.0, 1e-4),
"lat={}",
c.latitude.degrees()
);
}
fn chart_for_write(name: &str) -> Chart {
Chart {
name: name.to_string(),
secondary_name: None,
city: Some("London".to_string()),
region: Some("England".to_string()),
longitude: Longitude::new(-0.117).unwrap(),
latitude: Latitude::new(51.5).unwrap(),
year: 2000,
month: 6,
day: 15,
hour: 14,
minute: 30,
second: 0,
tz_offset_hours: 1.0,
tz_abbreviation: Some("BST".to_string()),
is_lmt: false,
event_type: EventType::Male,
source_rating: Some("AA".to_string()),
house_system: HouseSystem::Placidus,
zodiac: Zodiac::Tropical,
coordinate_system: CoordinateSystem::Geocentric,
sub_charts: vec![],
notes: Some("Test notes.".to_string()),
}
}
#[test]
fn write_empty_slice_produces_parseable_xml() {
let xml = write_file(&[]);
let charts = parse_file(&xml).unwrap();
assert!(charts.is_empty());
}
#[test]
fn write_produces_valid_xml_declaration() {
let xml = write_file(&[]);
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"utf-8\"?>"));
}
#[test]
fn write_round_trips_name() {
let original = chart_for_write("Haenel, Adele");
let xml = write_file(&[original]);
let charts = parse_file(&xml).unwrap();
assert_eq!(charts[0].name, "Haenel, Adele");
}
#[test]
fn write_round_trips_date() {
let original = chart_for_write("Date Test");
let xml = write_file(&[original]);
let charts = parse_file(&xml).unwrap();
assert_eq!(charts[0].year, 2000);
assert_eq!(charts[0].month, 6);
assert_eq!(charts[0].day, 15);
}
#[test]
fn write_round_trips_time_and_seconds() {
let mut c = chart_for_write("Time Test");
c.hour = 9;
c.minute = 5;
c.second = 37;
let xml = write_file(&[c]);
let charts = parse_file(&xml).unwrap();
assert_eq!(charts[0].hour, 9);
assert_eq!(charts[0].minute, 5);
assert_eq!(charts[0].second, 37);
}
#[test]
fn write_round_trips_tz_offset() {
let original = chart_for_write("TZ Test");
let xml = write_file(&[original]);
let charts = parse_file(&xml).unwrap();
assert!(within(charts[0].tz_offset_hours, 1.0, 1e-4));
}
#[test]
fn write_round_trips_coordinates() {
let original = chart_for_write("Coord Test");
let xml = write_file(&[original]);
let charts = parse_file(&xml).unwrap();
assert!(within(charts[0].latitude.degrees(), 51.5, 5e-4));
assert!(within(charts[0].longitude.degrees(), -0.117, 5e-4));
}
#[test]
fn write_round_trips_female_event_type() {
let mut c = chart_for_write("Female Test");
c.event_type = EventType::Female;
let xml = write_file(&[c]);
let charts = parse_file(&xml).unwrap();
assert_eq!(charts[0].event_type, EventType::Female);
}
#[test]
fn write_round_trips_source_rating() {
for rating in ["AA", "A", "B", "C", "DD", "X", "XX"] {
let mut c = chart_for_write("Rating Test");
c.source_rating = Some(rating.to_string());
let xml = write_file(&[c]);
let charts = parse_file(&xml).unwrap();
assert_eq!(
charts[0].source_rating.as_deref(),
Some(rating),
"rating {rating}"
);
}
}
#[test]
fn write_round_trips_is_lmt() {
let mut c = chart_for_write("LMT Test");
c.is_lmt = true;
c.tz_abbreviation = Some("LMT".to_string());
let xml = write_file(&[c]);
let charts = parse_file(&xml).unwrap();
assert!(charts[0].is_lmt);
}
#[test]
fn write_round_trips_city_and_region() {
let original = chart_for_write("Place Test");
let xml = write_file(&[original]);
let charts = parse_file(&xml).unwrap();
assert_eq!(charts[0].city.as_deref(), Some("London"));
assert_eq!(charts[0].region.as_deref(), Some("England"));
}
#[test]
fn write_round_trips_notes() {
let original = chart_for_write("Notes Test");
let xml = write_file(&[original]);
let charts = parse_file(&xml).unwrap();
assert_eq!(charts[0].notes.as_deref(), Some("Test notes."));
}
#[test]
fn write_escapes_xml_special_chars_in_name() {
let mut c = chart_for_write("A & B <Test> \"quoted\"");
c.notes = None;
let xml = write_file(&[c]);
let charts = parse_file(&xml).unwrap();
assert_eq!(charts[0].name, "A & B <Test> \"quoted\"");
}
#[test]
fn write_multiple_charts_preserves_order() {
let a = chart_for_write("Alpha");
let b = chart_for_write("Beta");
let c = chart_for_write("Gamma");
let xml = write_file(&[a, b, c]);
let charts = parse_file(&xml).unwrap();
assert_eq!(charts.len(), 3);
assert_eq!(charts[0].name, "Alpha");
assert_eq!(charts[1].name, "Beta");
assert_eq!(charts[2].name, "Gamma");
}
#[test]
fn write_assigns_local_adb_ids_above_threshold() {
let xml = write_file(&[chart_for_write("ID Test")]);
assert!(xml.contains("adb_id=\"1000000"));
}
#[test]
fn acceptance_write_all_then_parse_back() {
let Some(path) = specimen_path() else {
eprintln!("ASTRO_SPECIMENS not set — skipping integration test");
return;
};
if !path.exists() {
eprintln!("ADB specimen absent ({}); skipping", path.display());
return;
}
let xml = std::fs::read_to_string(&path).expect("read specimen");
let original = parse_file(&xml).expect("parse specimen");
let written = write_file(&original);
let roundtripped = parse_file(&written).expect("parse written XML");
assert_eq!(roundtripped.len(), original.len());
assert_eq!(roundtripped[0].name, original[0].name);
assert_eq!(roundtripped[0].year, original[0].year);
assert_eq!(roundtripped[0].month, original[0].month);
assert_eq!(roundtripped[0].day, original[0].day);
assert!(within(
roundtripped[0].latitude.degrees(),
original[0].latitude.degrees(),
5e-4
));
assert!(within(
roundtripped[0].longitude.degrees(),
original[0].longitude.degrees(),
5e-4
));
assert!(within(
roundtripped[0].tz_offset_hours,
original[0].tz_offset_hours,
1e-4
));
}
#[test]
fn acceptance_parses_all_entries_without_error() {
let Some(path) = specimen_path() else {
eprintln!("ASTRO_SPECIMENS not set — skipping integration test");
return;
};
if !path.exists() {
eprintln!("ADB specimen absent ({}); skipping", path.display());
return;
}
let xml = std::fs::read_to_string(&path).expect("read specimen");
let charts = parse_file(&xml).expect("parse specimen");
assert!(
charts.len() > 10_000,
"expected >10k entries, got {}",
charts.len()
);
}