use super::*;
use core::time::Duration;
#[test]
fn angle_normalization_wraps_correctly() {
assert_eq!(
Angle::from_degrees(-30.0).normalized_0_360().degrees(),
330.0
);
assert_eq!(
Angle::from_degrees(390.0).normalized_0_360().degrees(),
30.0
);
assert_eq!(Angle::from_degrees(720.0).normalized_0_360().degrees(), 0.0);
assert_eq!(
Angle::from_degrees(190.0).normalized_signed().degrees(),
-170.0
);
assert_eq!(
Angle::from_degrees(180.0).normalized_signed().degrees(),
-180.0
);
assert_eq!(
Angle::from_degrees(-180.0).normalized_signed().degrees(),
-180.0
);
assert_eq!(Longitude::from_degrees(-720.0).degrees(), 0.0);
assert_eq!(Longitude::from_degrees(360.0).degrees(), 0.0);
}
#[test]
fn longitude_is_always_normalized() {
assert_eq!(Longitude::from_degrees(390.0).degrees(), 30.0);
assert_eq!(Longitude::from(Angle::from_degrees(-30.0)).degrees(), 330.0);
}
#[test]
fn celestial_body_classes_cover_the_built_in_catalog() {
assert_eq!(CelestialBody::Sun.class(), CelestialBodyClass::Luminary);
assert_eq!(CelestialBody::Moon.class(), CelestialBodyClass::Luminary);
assert_eq!(
CelestialBody::Mercury.class(),
CelestialBodyClass::MajorPlanet
);
assert_eq!(
CelestialBody::Pluto.class(),
CelestialBodyClass::MajorPlanet
);
assert_eq!(
CelestialBody::MeanNode.class(),
CelestialBodyClass::LunarPoint
);
assert_eq!(
CelestialBody::TruePerigee.class(),
CelestialBodyClass::LunarPoint
);
assert_eq!(
CelestialBody::Ceres.class(),
CelestialBodyClass::BuiltInAsteroid
);
assert_eq!(
CelestialBody::Custom(CustomBodyId::new("asteroid", "433-Eros")).class(),
CelestialBodyClass::Custom
);
assert_eq!(CelestialBodyClass::Luminary.label(), "luminary");
assert_eq!(
CelestialBodyClass::BuiltInAsteroid.to_string(),
"built-in asteroid"
);
}
#[test]
fn ecliptic_to_equatorial_preserves_zero_obliquity_identity() {
let ecliptic = EclipticCoordinates::new(
Longitude::from_degrees(123.45),
Latitude::from_degrees(-6.75),
Some(0.123),
);
assert_eq!(ecliptic.validate(), Ok(()));
let equatorial = ecliptic.to_equatorial(Angle::from_degrees(0.0));
assert_eq!(equatorial.validate(), Ok(()));
assert_eq!(equatorial.right_ascension.degrees(), 123.45);
assert!((equatorial.declination.degrees() + 6.75).abs() < 1e-12);
assert_eq!(equatorial.distance_au, Some(0.123));
}
#[test]
fn ecliptic_to_equatorial_rotates_by_mean_obliquity() {
let ecliptic = EclipticCoordinates::new(
Longitude::from_degrees(90.0),
Latitude::from_degrees(0.0),
Some(1.0),
);
assert_eq!(ecliptic.validate(), Ok(()));
let equatorial = ecliptic.to_equatorial(Angle::from_degrees(23.439_291_11));
assert_eq!(equatorial.validate(), Ok(()));
assert_eq!(equatorial.right_ascension.degrees(), 90.0);
assert!((equatorial.declination.degrees() - 23.439_291_11).abs() < 1e-10);
assert_eq!(equatorial.distance_au, Some(1.0));
}
#[test]
fn instant_mean_obliquity_matches_the_shared_cubic_approximation() {
let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
assert_eq!(instant.mean_obliquity().degrees(), 23.439_291_111_111_11);
}
#[test]
fn instant_has_a_compact_display() {
let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tdb);
assert_eq!(instant.summary_line(), "JD 2451545 TDB");
assert_eq!(instant.to_string(), "JD 2451545 TDB");
}
#[test]
fn observer_location_has_a_compact_display() {
let observer = ObserverLocation::new(
Latitude::from_degrees(51.5),
Longitude::from_degrees(-0.1),
Some(35.75),
);
assert_eq!(
observer.summary_line(),
"latitude=51.5°, longitude=359.9°, elevation=35.750 m"
);
assert_eq!(observer.to_string(), observer.summary_line());
}
#[test]
fn equatorial_to_ecliptic_round_trip_uses_the_same_obliquity() {
let ecliptic = EclipticCoordinates::new(
Longitude::from_degrees(123.45),
Latitude::from_degrees(-6.75),
Some(0.123),
);
let obliquity = Angle::from_degrees(23.439_291_11);
assert_eq!(ecliptic.validate(), Ok(()));
let equatorial = ecliptic.to_equatorial(obliquity);
assert_eq!(equatorial.validate(), Ok(()));
let round_trip = equatorial.to_ecliptic(obliquity);
assert_eq!(round_trip.validate(), Ok(()));
assert!((round_trip.longitude.degrees() - ecliptic.longitude.degrees()).abs() < 1e-10);
assert!((round_trip.latitude.degrees() - ecliptic.latitude.degrees()).abs() < 1e-10);
assert_eq!(round_trip.distance_au, Some(0.123));
}
#[test]
fn mean_obliquity_round_trip_stays_stable_across_quadrants() {
let cases = [
(
EclipticCoordinates::new(
Longitude::from_degrees(359.2),
Latitude::from_degrees(11.75),
None,
),
Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt),
),
(
EclipticCoordinates::new(
Longitude::from_degrees(27.5),
Latitude::from_degrees(-33.25),
Some(2.5),
),
Instant::new(JulianDay::from_days(2_459_000.5), TimeScale::Tt),
),
];
for (ecliptic, instant) in cases {
assert_eq!(ecliptic.validate(), Ok(()));
let obliquity = instant.mean_obliquity();
let equatorial = ecliptic.to_equatorial(obliquity);
assert_eq!(equatorial.validate(), Ok(()));
let round_trip = equatorial.to_ecliptic(obliquity);
assert_eq!(round_trip.validate(), Ok(()));
assert!((round_trip.longitude.degrees() - ecliptic.longitude.degrees()).abs() < 1e-10);
assert!((round_trip.latitude.degrees() - ecliptic.latitude.degrees()).abs() < 1e-10);
assert_eq!(round_trip.distance_au, ecliptic.distance_au);
}
}
#[test]
fn mean_obliquity_round_trip_stays_stable_near_the_poles() {
let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
let obliquity = instant.mean_obliquity();
for ecliptic in [
EclipticCoordinates::new(
Longitude::from_degrees(0.025),
Latitude::from_degrees(89.75),
Some(0.42),
),
EclipticCoordinates::new(
Longitude::from_degrees(179.975),
Latitude::from_degrees(-89.75),
Some(0.42),
),
] {
assert_eq!(ecliptic.validate(), Ok(()));
let equatorial = ecliptic.to_equatorial(obliquity);
assert_eq!(equatorial.validate(), Ok(()));
let round_trip = equatorial.to_ecliptic(obliquity);
assert_eq!(round_trip.validate(), Ok(()));
assert!((round_trip.longitude.degrees() - ecliptic.longitude.degrees()).abs() < 1e-10);
assert!((round_trip.latitude.degrees() - ecliptic.latitude.degrees()).abs() < 1e-10);
assert_eq!(round_trip.distance_au, Some(0.42));
}
}
#[test]
fn ecliptic_to_equatorial_normalizes_negative_right_ascension() {
let ecliptic = EclipticCoordinates::new(
Longitude::from_degrees(180.0),
Latitude::from_degrees(30.0),
None,
);
let obliquity = Angle::from_degrees(23.439_291_11);
assert_eq!(ecliptic.validate(), Ok(()));
let equatorial = ecliptic.to_equatorial(obliquity);
assert_eq!(equatorial.validate(), Ok(()));
assert!(equatorial.right_ascension.degrees() >= 0.0);
assert!(equatorial.right_ascension.degrees() < 360.0);
assert!((equatorial.right_ascension.degrees() - 192.934_084_332_518_07).abs() < 1e-10);
assert!((equatorial.declination.degrees() - 27.305_898_332_307_97).abs() < 1e-10);
}
#[test]
fn equatorial_to_ecliptic_round_trip_preserves_negative_declination_near_wraparound() {
let equatorial = EquatorialCoordinates::new(
Angle::from_degrees(359.5),
Latitude::from_degrees(-27.5),
Some(3.25),
);
let obliquity = Angle::from_degrees(23.439_291_11);
assert_eq!(equatorial.validate(), Ok(()));
let ecliptic = equatorial.to_ecliptic(obliquity);
assert_eq!(ecliptic.validate(), Ok(()));
let round_trip = ecliptic.to_equatorial(obliquity);
assert_eq!(round_trip.validate(), Ok(()));
assert!(
(round_trip.right_ascension.degrees() - equatorial.right_ascension.degrees()).abs() < 1e-10
);
assert!((round_trip.declination.degrees() - equatorial.declination.degrees()).abs() < 1e-10);
assert_eq!(round_trip.distance_au, equatorial.distance_au);
}
#[test]
fn equatorial_to_ecliptic_treats_negative_right_ascension_as_normalized_angle() {
let obliquity = Angle::from_degrees(23.439_291_11);
let normalized = EquatorialCoordinates::new(
Angle::from_degrees(359.75),
Latitude::from_degrees(12.5),
Some(1.23),
);
let wrapped = EquatorialCoordinates::new(
Angle::from_degrees(-0.25),
Latitude::from_degrees(12.5),
Some(1.23),
);
let normalized_ecliptic = normalized.to_ecliptic(obliquity);
let wrapped_ecliptic = wrapped.to_ecliptic(obliquity);
assert_eq!(normalized_ecliptic.validate(), Ok(()));
assert_eq!(wrapped_ecliptic.validate(), Ok(()));
assert!(
(normalized_ecliptic.longitude.degrees() - wrapped_ecliptic.longitude.degrees()).abs()
< 1e-10
);
assert!(
(normalized_ecliptic.latitude.degrees() - wrapped_ecliptic.latitude.degrees()).abs()
< 1e-10
);
assert_eq!(
normalized_ecliptic.distance_au,
wrapped_ecliptic.distance_au
);
let round_trip = wrapped_ecliptic.to_equatorial(obliquity);
assert_eq!(round_trip.validate(), Ok(()));
assert!(round_trip.right_ascension.degrees() >= 0.0);
assert!(round_trip.right_ascension.degrees() < 360.0);
assert!((round_trip.right_ascension.degrees() - 359.75).abs() < 1e-10);
}
#[test]
fn equatorial_to_ecliptic_treats_full_turn_right_ascension_as_normalized_angle() {
let obliquity = Angle::from_degrees(23.439_291_11);
let normalized = EquatorialCoordinates::new(
Angle::from_degrees(0.25),
Latitude::from_degrees(12.5),
Some(1.23),
);
let wrapped = EquatorialCoordinates::new(
Angle::from_degrees(360.25),
Latitude::from_degrees(12.5),
Some(1.23),
);
let normalized_ecliptic = normalized.to_ecliptic(obliquity);
let wrapped_ecliptic = wrapped.to_ecliptic(obliquity);
assert_eq!(normalized_ecliptic.validate(), Ok(()));
assert_eq!(wrapped_ecliptic.validate(), Ok(()));
assert!(
(normalized_ecliptic.longitude.degrees() - wrapped_ecliptic.longitude.degrees()).abs()
< 1e-10
);
assert!(
(normalized_ecliptic.latitude.degrees() - wrapped_ecliptic.latitude.degrees()).abs()
< 1e-10
);
assert_eq!(
normalized_ecliptic.distance_au,
wrapped_ecliptic.distance_au
);
let round_trip = wrapped_ecliptic.to_equatorial(obliquity);
assert_eq!(round_trip.validate(), Ok(()));
assert!(round_trip.right_ascension.degrees() >= 0.0);
assert!(round_trip.right_ascension.degrees() < 360.0);
assert!((round_trip.right_ascension.degrees() - 0.25).abs() < 1e-10);
}
#[test]
fn built_in_body_names_are_stable() {
assert_eq!(CelestialBody::Sun.built_in_name(), Some("Sun"));
assert_eq!(CelestialBody::Sun.to_string(), "Sun");
assert_eq!(
CelestialBody::MeanApogee.built_in_name(),
Some("Mean Apogee")
);
assert_eq!(CelestialBody::TruePerigee.to_string(), "True Perigee");
assert_eq!(
CelestialBody::Custom(CustomBodyId::new("asteroid", "433-Eros")).built_in_name(),
None
);
assert_eq!(
CelestialBody::Custom(CustomBodyId::new("asteroid", "433-Eros")).to_string(),
"asteroid:433-Eros"
);
}
#[test]
fn custom_body_id_validate_rejects_blank_padding_and_separators() {
assert_eq!(
CustomBodyId::new("", "433-Eros")
.validate()
.expect_err("blank catalogs should be rejected")
.to_string(),
"custom body id catalog must not be blank"
);
assert_eq!(
CustomBodyId::new("asteroid", " 433-Eros ")
.validate()
.expect_err("padded designations should be rejected")
.to_string(),
"custom body id designation must not have leading or trailing whitespace"
);
assert_eq!(
CustomBodyId::new("asteroid:catalog", "433-Eros")
.validate()
.expect_err("separator characters should be rejected")
.to_string(),
"custom body id catalog must not contain ':'"
);
}
#[test]
fn celestial_body_validate_reuses_custom_body_checks() {
assert!(CelestialBody::Sun.validate().is_ok());
assert_eq!(
CelestialBody::Custom(CustomBodyId::new("asteroid", " 433-Eros "))
.validate()
.expect_err("custom body identifiers should be validated")
.to_string(),
"custom body id designation must not have leading or trailing whitespace"
);
}
#[test]
fn custom_house_system_display_includes_aliases_and_notes() {
let mut custom = CustomHouseSystem::new("My Custom Houses");
custom.aliases.push("MCH".to_string());
custom.aliases.push("Test Houses".to_string());
custom.notes = Some("based on a user-defined formula".to_string());
assert_eq!(
custom.to_string(),
"My Custom Houses [aliases: MCH, Test Houses] (based on a user-defined formula)"
);
}
#[test]
fn custom_house_system_validate_rejects_whitespace_and_duplicate_aliases() {
assert_eq!(
CustomHouseSystem::new(" ")
.validate()
.expect_err("blank house system names should be rejected")
.to_string(),
"custom house system name must not be blank"
);
let mut custom = CustomHouseSystem::new("My Custom Houses");
custom.aliases.push("MCH".to_string());
custom.aliases.push("MCH".to_string());
assert_eq!(
custom
.validate()
.expect_err("duplicate aliases should be rejected")
.to_string(),
"custom house system aliases must be unique: duplicate MCH"
);
let mut custom = CustomHouseSystem::new("My Custom Houses");
custom.aliases.push("MCH".to_string());
custom.aliases.push("mch".to_string());
assert_eq!(
custom
.validate()
.expect_err("case-normalized aliases should be rejected")
.to_string(),
"custom house system aliases must be unique: duplicate mch"
);
let mut custom = CustomHouseSystem::new("My Custom Houses");
custom.aliases.push("my custom houses".to_string());
assert_eq!(
custom
.validate()
.expect_err("aliases should not duplicate the canonical name")
.to_string(),
"custom house system aliases must be unique: duplicate my custom houses"
);
let mut custom = CustomHouseSystem::new("My Custom Houses");
custom.notes = Some(" ".to_string());
assert_eq!(
custom
.validate()
.expect_err("blank notes should be rejected")
.to_string(),
"custom house system notes must not be blank"
);
}
#[test]
fn custom_house_system_validate_against_reserved_labels_rejects_builtin_collisions() {
let custom = CustomHouseSystem::new("Equal");
assert_eq!(
custom
.validate_against_reserved_labels(|label| label.eq_ignore_ascii_case("Equal"))
.expect_err("built-in labels should be rejected")
.to_string(),
"custom house system name must not match a built-in label: Equal"
);
let mut custom = CustomHouseSystem::new("My Custom Houses");
custom.aliases.push("Whole Sign".to_string());
assert_eq!(
custom
.validate_against_reserved_labels(|label| label.eq_ignore_ascii_case("Whole Sign"))
.expect_err("built-in aliases should be rejected")
.to_string(),
"custom house system alias[0] must not match a built-in label: Whole Sign"
);
}
#[test]
fn house_systems_have_stable_display_names() {
assert_eq!(HouseSystem::Placidus.to_string(), "Placidus");
assert_eq!(HouseSystem::EqualMidheaven.to_string(), "Equal (MC)");
assert_eq!(HouseSystem::Carter.to_string(), "Carter (poli-equatorial)");
assert_eq!(HouseSystem::Gauquelin.to_string(), "Gauquelin sectors");
let custom = CustomHouseSystem::new("My Custom Houses");
assert_eq!(
HouseSystem::Custom(custom.clone()).to_string(),
custom.to_string()
);
}
#[test]
fn house_system_validate_reuses_the_structured_validator() {
assert!(HouseSystem::WholeSign.validate().is_ok());
let mut custom = CustomHouseSystem::new("My Custom Houses");
custom.aliases.push("MCH".to_string());
custom.aliases.push("mch".to_string());
assert_eq!(
HouseSystem::Custom(custom)
.validate()
.expect_err("custom house systems should validate their aliases")
.to_string(),
"custom house system aliases must be unique: duplicate mch"
);
}
#[test]
fn time_scales_have_stable_display_names() {
assert_eq!(TimeScale::Utc.to_string(), "UTC");
assert_eq!(TimeScale::Ut1.to_string(), "UT1");
assert_eq!(TimeScale::Tt.to_string(), "TT");
assert_eq!(TimeScale::Tdb.to_string(), "TDB");
}
#[test]
fn coordinate_frames_have_stable_display_names() {
assert_eq!(CoordinateFrame::Ecliptic.to_string(), "Ecliptic");
assert_eq!(CoordinateFrame::Equatorial.to_string(), "Equatorial");
}
#[test]
fn zodiac_modes_have_stable_display_names() {
assert_eq!(ZodiacMode::Tropical.to_string(), "Tropical");
assert_eq!(
ZodiacMode::Sidereal {
ayanamsa: Ayanamsa::Lahiri
}
.to_string(),
"Sidereal (Lahiri)"
);
assert_eq!(
ZodiacMode::Sidereal {
ayanamsa: Ayanamsa::Custom(CustomAyanamsa::new("My Custom Sidereal"))
}
.to_string(),
"Sidereal (My Custom Sidereal)"
);
}
#[test]
fn custom_ayanamsas_have_stable_display_names() {
let custom = CustomAyanamsa::new("My Custom Sidereal");
assert_eq!(custom.to_string(), "My Custom Sidereal");
assert_eq!(Ayanamsa::Custom(custom).to_string(), "My Custom Sidereal");
}
#[test]
fn custom_ayanamsa_enum_validate_reuses_the_structured_validator() {
assert!(Ayanamsa::Lahiri.validate().is_ok());
let custom = CustomAyanamsa {
name: "My Custom Sidereal".to_string(),
description: Some("local calibration".to_string()),
epoch: Some(JulianDay::from_days(2_451_545.0)),
offset_degrees: None,
};
assert_eq!(
Ayanamsa::Custom(custom)
.validate()
.expect_err("custom ayanamsas should validate their descriptor")
.to_string(),
"custom ayanamsa requires both epoch and offset_degrees when one is present"
);
}
#[test]
fn custom_ayanamsa_validate_rejects_padded_or_incomplete_definitions() {
let mut custom = CustomAyanamsa::new("My Custom Sidereal");
custom.description = Some(" ".to_string());
assert_eq!(
custom
.validate()
.expect_err("blank descriptions should be rejected")
.to_string(),
"custom ayanamsa description must not be blank"
);
let custom = CustomAyanamsa {
name: "My Custom Sidereal".to_string(),
description: Some("local calibration".to_string()),
epoch: Some(JulianDay::from_days(2_451_545.0)),
offset_degrees: None,
};
assert_eq!(
custom
.validate()
.expect_err("partial offset definitions should be rejected")
.to_string(),
"custom ayanamsa requires both epoch and offset_degrees when one is present"
);
let custom = CustomAyanamsa {
name: "My Custom Sidereal".to_string(),
description: Some("local calibration".to_string()),
epoch: Some(JulianDay::from_days(f64::INFINITY)),
offset_degrees: Some(Angle::from_degrees(24.0)),
};
assert_eq!(
custom
.validate()
.expect_err("non-finite epochs should be rejected")
.to_string(),
"custom ayanamsa epoch must be finite"
);
}
#[test]
fn custom_ayanamsa_validate_against_reserved_labels_rejects_builtin_collisions() {
let custom = CustomAyanamsa::new("Lahiri");
assert_eq!(
custom
.validate_against_reserved_labels(|label| label.eq_ignore_ascii_case("Lahiri"))
.expect_err("built-in labels should be rejected")
.to_string(),
"custom ayanamsa name must not match a built-in label: Lahiri"
);
}
#[test]
fn time_scale_conversion_errors_use_stable_display_labels() {
let error = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt)
.tt_from_ut1(Duration::from_secs(1))
.expect_err("TT is not UT1");
assert_eq!(
error.to_string(),
"time-scale conversion expected UT1, got TT"
);
}
#[test]
fn zodiac_signs_follow_longitude_bands() {
assert_eq!(
ZodiacSign::from_longitude(Longitude::from_degrees(0.0)),
ZodiacSign::Aries
);
assert_eq!(
ZodiacSign::from_longitude(Longitude::from_degrees(29.999)),
ZodiacSign::Aries
);
assert_eq!(
ZodiacSign::from_longitude(Longitude::from_degrees(30.0)),
ZodiacSign::Taurus
);
}
#[test]
fn time_range_checks_scale_and_julian_day() {
let start = Instant::new(JulianDay::from_days(2451545.0), TimeScale::Tt);
let end = Instant::new(JulianDay::from_days(2451546.0), TimeScale::Tt);
let range = TimeRange::new(Some(start), Some(end));
assert!(range.contains(Instant::new(JulianDay::from_days(2451545.5), TimeScale::Tt)));
assert!(!range.contains(Instant::new(
JulianDay::from_days(2451545.5),
TimeScale::Utc
)));
assert_eq!(
range.summary_line(),
"JD 2451545.0 (TT) → JD 2451546.0 (TT)"
);
assert_eq!(range.to_string(), range.summary_line());
assert!(range.validate().is_ok());
assert_eq!(TimeRange::new(Some(start), None).validate(), Ok(()));
assert_eq!(
TimeRange::new(Some(start), None).to_string(),
"from JD 2451545.0 (TT)"
);
assert_eq!(
TimeRange::new(None, Some(end)).to_string(),
"through JD 2451546.0 (TT)"
);
assert_eq!(TimeRange::new(None, None).to_string(), "unbounded");
}
#[test]
fn time_range_validation_rejects_non_finite_bounds_and_invalid_order() {
let finite_start = Instant::new(JulianDay::from_days(2451545.0), TimeScale::Tt);
let finite_end = Instant::new(JulianDay::from_days(2451546.0), TimeScale::Tt);
let error = TimeRange::new(
Some(Instant::new(
JulianDay::from_days(f64::INFINITY),
TimeScale::Tt,
)),
Some(finite_end),
)
.validate()
.expect_err("non-finite start bounds should fail validation");
assert_eq!(
error.summary_line(),
"time range bound `start` must be finite: JD inf (TT)"
);
assert_eq!(error.to_string(), error.summary_line());
let error = TimeRange::new(
Some(finite_start),
Some(Instant::new(
JulianDay::from_days(f64::NEG_INFINITY),
TimeScale::Tt,
)),
)
.validate()
.expect_err("non-finite end bounds should fail validation");
assert_eq!(
error.summary_line(),
"time range bound `end` must be finite: JD -inf (TT)"
);
assert_eq!(error.to_string(), error.summary_line());
let error = TimeRange::new(
Some(Instant::new(JulianDay::from_days(2451545.0), TimeScale::Tt)),
Some(Instant::new(
JulianDay::from_days(2451546.0),
TimeScale::Tdb,
)),
)
.validate()
.expect_err("mixed time-scale bounds should fail validation");
assert_eq!(
error.summary_line(),
"time range bounds must use the same time scale: start=JD 2451545.0 (TT); end=JD 2451546.0 (TDB)"
);
assert_eq!(error.to_string(), error.summary_line());
let error = TimeRange::new(Some(finite_end), Some(finite_start))
.validate()
.expect_err("out-of-order ranges should fail validation");
assert_eq!(
error.summary_line(),
"time range end must not precede the start: start=JD 2451546.0 (TT); end=JD 2451545.0 (TT)"
);
assert_eq!(error.to_string(), error.summary_line());
}
#[test]
fn caller_supplied_time_scale_offsets_shift_julian_days() {
let ut1 = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
let tt = ut1
.tt_from_ut1(Duration::from_secs_f64(64.184))
.expect("UT1 to TT conversion should accept UT1 input");
assert_eq!(tt.scale, TimeScale::Tt);
assert!((tt.julian_day.days() - 2_451_545.000_742_870_4).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_utc_to_tt() {
let utc = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Utc);
let tt = utc
.tt_from_utc(Duration::from_secs_f64(64.184))
.expect("UTC to TT conversion should accept UTC input");
assert_eq!(tt.scale, TimeScale::Tt);
assert!((tt.julian_day.days() - 2_451_545.000_742_870_4).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_utc_to_tt_with_signed_offset() {
let utc = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Utc);
let tt = utc
.tt_from_utc_signed(64.184)
.expect("UTC to TT conversion should accept signed UTC input");
assert_eq!(tt.scale, TimeScale::Tt);
assert!((tt.julian_day.days() - 2_451_545.000_742_870_4).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_tt_to_tdb() {
let tt = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
let tdb = tt
.tdb_from_tt(Duration::from_secs_f64(0.001_657))
.expect("TT to TDB conversion should accept TT input");
assert_eq!(tdb.scale, TimeScale::Tdb);
let expected = 2_451_545.0 + 0.001_657 / 86_400.0;
assert!((tdb.julian_day.days() - expected).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_tt_to_tdb_with_signed_offset() {
let tt = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
let tdb = tt
.tdb_from_tt_signed(-0.001_657)
.expect("TT to TDB conversion should accept signed TT input");
assert_eq!(tdb.scale, TimeScale::Tdb);
let expected = 2_451_545.0 - 0.001_657 / 86_400.0;
assert!((tdb.julian_day.days() - expected).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_tdb_to_tt() {
let tdb = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tdb);
let tt = tdb
.tt_from_tdb(-0.001_657)
.expect("TDB to TT conversion should accept TDB input");
assert_eq!(tt.scale, TimeScale::Tt);
let expected = 2_451_545.0 - 0.001_657 / 86_400.0;
assert!((tt.julian_day.days() - expected).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_tdb_to_tt_with_signed_offset() {
let tdb = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tdb);
let tt = tdb
.tt_from_tdb_signed(-0.001_657)
.expect("TDB to TT conversion should accept signed TDB input");
assert_eq!(tt.scale, TimeScale::Tt);
let expected = 2_451_545.0 - 0.001_657 / 86_400.0;
assert!((tt.julian_day.days() - expected).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_utc_to_tdb() {
let utc = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Utc);
let tdb = utc
.tdb_from_utc(
Duration::from_secs_f64(64.184),
Duration::from_secs_f64(0.001_657),
)
.expect("UTC to TDB conversion should accept UTC input");
assert_eq!(tdb.scale, TimeScale::Tdb);
let expected = 2_451_545.0 + (64.184 + 0.001_657) / 86_400.0;
assert!((tdb.julian_day.days() - expected).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_utc_to_tdb_with_signed_offset() {
let utc = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Utc);
let tdb = utc
.tdb_from_utc_signed(Duration::from_secs_f64(64.184), -0.001_657)
.expect("UTC to TDB conversion should accept signed UTC input");
assert_eq!(tdb.scale, TimeScale::Tdb);
let expected = 2_451_545.0 + (64.184 - 0.001_657) / 86_400.0;
assert!((tdb.julian_day.days() - expected).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_ut1_to_tdb() {
let ut1 = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
let tdb = ut1
.tdb_from_ut1(
Duration::from_secs_f64(64.184),
Duration::from_secs_f64(0.001_657),
)
.expect("UT1 to TDB conversion should accept UT1 input");
assert_eq!(tdb.scale, TimeScale::Tdb);
let expected = 2_451_545.0 + (64.184 + 0.001_657) / 86_400.0;
assert!((tdb.julian_day.days() - expected).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_ut1_to_tdb_with_signed_offset() {
let ut1 = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
let tdb = ut1
.tdb_from_ut1_signed(Duration::from_secs_f64(64.184), -0.001_657)
.expect("UT1 to TDB conversion should accept signed UT1 input");
assert_eq!(tdb.scale, TimeScale::Tdb);
let expected = 2_451_545.0 + (64.184 - 0.001_657) / 86_400.0;
assert!((tdb.julian_day.days() - expected).abs() < 1e-12);
}
#[test]
fn caller_supplied_time_scale_offsets_can_convert_ut1_to_tt_with_signed_offset() {
let ut1 = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
let tt = ut1
.tt_from_ut1_signed(64.184)
.expect("UT1 to TT conversion should accept signed UT1 input");
assert_eq!(tt.scale, TimeScale::Tt);
let expected = 2_451_545.0 + 64.184 / 86_400.0;
assert!((tt.julian_day.days() - expected).abs() < 1e-12);
}
#[test]
fn time_scale_helpers_reject_the_wrong_source_scale() {
let utc = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Utc);
let ut1_error = utc
.tt_from_ut1(Duration::from_secs(64))
.expect_err("UTC is not UT1");
assert!(matches!(
ut1_error,
TimeScaleConversionError::Expected {
expected: TimeScale::Ut1,
actual: TimeScale::Utc,
}
));
let tdb_ut1_error = utc
.tdb_from_ut1(Duration::from_secs(64), Duration::from_secs(1))
.expect_err("UTC is not UT1 for UT1-to-TDB conversion");
assert!(matches!(
tdb_ut1_error,
TimeScaleConversionError::Expected {
expected: TimeScale::Ut1,
actual: TimeScale::Utc,
}
));
let tt = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
let utc_error = tt
.tt_from_utc(Duration::from_secs(64))
.expect_err("TT is not UTC");
assert!(matches!(
utc_error,
TimeScaleConversionError::Expected {
expected: TimeScale::Utc,
actual: TimeScale::Tt,
}
));
let utc_signed_error = tt
.tt_from_utc_signed(64.0)
.expect_err("TT is not UTC for signed UTC-to-TT conversion");
assert!(matches!(
utc_signed_error,
TimeScaleConversionError::Expected {
expected: TimeScale::Utc,
actual: TimeScale::Tt,
}
));
let tdb_error = tt
.tdb_from_utc(Duration::from_secs(64), Duration::from_secs(1))
.expect_err("TT is not UTC for UTC-to-TDB conversion");
assert!(matches!(
tdb_error,
TimeScaleConversionError::Expected {
expected: TimeScale::Utc,
actual: TimeScale::Tt,
}
));
let tt_error = utc
.tt_from_tdb(-0.001_657)
.expect_err("UTC is not TDB for TDB-to-TT conversion");
assert!(matches!(
tt_error,
TimeScaleConversionError::Expected {
expected: TimeScale::Tdb,
actual: TimeScale::Utc,
}
));
let ut1_signed_error = utc
.tt_from_ut1_signed(64.0)
.expect_err("UTC is not UT1 for signed UT1-to-TT conversion");
assert!(matches!(
ut1_signed_error,
TimeScaleConversionError::Expected {
expected: TimeScale::Ut1,
actual: TimeScale::Utc,
}
));
let wrong_scale_error = tt
.tt_from_tdb(-0.001_657)
.expect_err("TT is not TDB for TDB-to-TT conversion");
assert!(matches!(
wrong_scale_error,
TimeScaleConversionError::Expected {
expected: TimeScale::Tdb,
actual: TimeScale::Tt,
}
));
}
#[test]
fn signed_time_scale_helpers_reject_non_finite_offsets() {
let ut1 = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
let tt_nan_error = ut1
.tt_from_ut1_signed(f64::NAN)
.expect_err("non-finite UT1 offsets should be rejected");
assert!(matches!(
tt_nan_error,
TimeScaleConversionError::NonFiniteOffset
));
let tt = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
let tdb_inf_error = tt
.tdb_from_tt_signed(f64::INFINITY)
.expect_err("non-finite TDB offsets should be rejected");
assert!(matches!(
tdb_inf_error,
TimeScaleConversionError::NonFiniteOffset
));
let tdb = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tdb);
let tt_negative_inf_error = tdb
.tt_from_tdb(f64::NEG_INFINITY)
.expect_err("non-finite TDB-to-TT offsets should be rejected");
assert!(matches!(
tt_negative_inf_error,
TimeScaleConversionError::NonFiniteOffset
));
}
#[test]
fn time_scale_conversion_errors_render_stable_summary_lines() {
let expected = TimeScaleConversionError::Expected {
expected: TimeScale::Tt,
actual: TimeScale::Utc,
};
assert_eq!(
expected.summary_line(),
"time-scale conversion expected TT, got UTC"
);
assert_eq!(expected.to_string(), expected.summary_line());
let non_finite = TimeScaleConversionError::NonFiniteOffset;
assert_eq!(
non_finite.summary_line(),
"time-scale conversion offset must be finite"
);
assert_eq!(non_finite.to_string(), non_finite.summary_line());
}
#[test]
fn time_scale_conversion_policy_can_validate_and_apply_a_caller_supplied_rule() {
let policy = TimeScaleConversion::new(TimeScale::Ut1, TimeScale::Tt, 64.184);
let ut1 = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Ut1);
assert!(policy.validate(ut1).is_ok());
assert!(ut1.validate_time_scale_conversion(policy).is_ok());
let converted = policy
.apply(ut1)
.expect("caller-supplied policy should convert the source instant");
assert_eq!(converted.scale, TimeScale::Tt);
assert!((converted.julian_day.days() - 2_451_545.000_742_870_4).abs() < 1e-12);
assert_eq!(
policy.summary_line(),
"source=UT1; target=TT; offset_seconds=64.184 s"
);
assert_eq!(policy.to_string(), policy.summary_line());
}
#[test]
fn time_scale_conversion_policy_renders_signed_offsets_in_summary_lines() {
let policy = TimeScaleConversion::new(TimeScale::Tdb, TimeScale::Tt, -0.001_657);
assert_eq!(
policy.summary_line(),
"source=TDB; target=TT; offset_seconds=-0.001657 s"
);
assert_eq!(policy.to_string(), policy.summary_line());
}
#[test]
fn time_scale_conversion_policy_validated_summary_line_matches_the_plain_rendering() {
let policy = TimeScaleConversion::new(TimeScale::Tdb, TimeScale::Tt, -0.001_657);
let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tdb);
assert_eq!(
policy
.validated_summary_line(instant)
.expect("policy should validate"),
policy.summary_line()
);
}
#[test]
fn time_scale_conversion_policy_accepts_signed_tdb_to_tt_validation() {
let policy = TimeScaleConversion::new(TimeScale::Tdb, TimeScale::Tt, -0.001_657);
let tdb = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tdb);
assert!(policy.validate(tdb).is_ok());
assert!(tdb.validate_time_scale_conversion(policy).is_ok());
let converted = policy
.apply(tdb)
.expect("signed TDB-to-TT policy should convert the source instant");
assert_eq!(converted.scale, TimeScale::Tt);
assert!(
(converted.julian_day.days() - 2_451_544.999_999_981).abs() < 1e-12,
"signed TDB-to-TT conversion should apply the caller-supplied offset"
);
}
#[test]
fn instant_validate_time_scale_conversion_rejects_mismatched_scales_and_non_finite_offsets() {
let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
let policy = TimeScaleConversion::new(TimeScale::Ut1, TimeScale::Tt, 64.184);
let error = instant
.validate_time_scale_conversion(policy)
.expect_err("policy should reject the wrong source scale");
assert!(matches!(
error,
TimeScaleConversionError::Expected {
expected: TimeScale::Ut1,
actual: TimeScale::Tt,
}
));
let non_finite = TimeScaleConversion::new(TimeScale::Tt, TimeScale::Tdb, f64::NAN);
let error = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt)
.validate_time_scale_conversion(non_finite)
.expect_err("policy should reject non-finite offsets");
assert!(matches!(error, TimeScaleConversionError::NonFiniteOffset));
}
#[test]
fn time_scale_conversion_policy_rejects_mismatched_scales_and_non_finite_offsets() {
let policy = TimeScaleConversion::new(TimeScale::Ut1, TimeScale::Tt, 64.184);
let tt = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
let error = policy
.validate(tt)
.expect_err("policy should reject the wrong source scale");
assert!(matches!(
error,
TimeScaleConversionError::Expected {
expected: TimeScale::Ut1,
actual: TimeScale::Tt,
}
));
let non_finite = TimeScaleConversion::new(TimeScale::Tt, TimeScale::Tdb, f64::NAN);
let error = non_finite
.validate(Instant::new(
JulianDay::from_days(2_451_545.0),
TimeScale::Tt,
))
.expect_err("policy should reject non-finite offsets");
assert!(matches!(error, TimeScaleConversionError::NonFiniteOffset));
assert!(matches!(
non_finite.apply(Instant::new(
JulianDay::from_days(2_451_545.0),
TimeScale::Tt
)),
Err(TimeScaleConversionError::NonFiniteOffset)
));
}
#[test]
fn instant_with_time_scale_offset_checked_rejects_non_finite_offsets() {
let instant = Instant::new(JulianDay::from_days(2_451_545.0), TimeScale::Tt);
let error = instant
.with_time_scale_offset_checked(TimeScale::Tdb, f64::NEG_INFINITY)
.expect_err("checked offset conversion should reject non-finite offsets");
assert!(matches!(error, TimeScaleConversionError::NonFiniteOffset));
let converted = instant
.with_time_scale_offset_checked(TimeScale::Tdb, 0.001_657)
.expect("checked offset conversion should accept finite offsets");
assert_eq!(converted.scale, TimeScale::Tdb);
assert!((converted.julian_day.days() - 2_451_545.000_000_019).abs() < 1e-12);
}
#[test]
fn motion_accessors_return_the_original_speed_components() {
let motion = Motion::new(Some(0.12), Some(-0.03), Some(0.000_4));
assert_eq!(motion.longitude_speed(), Some(0.12));
assert_eq!(motion.latitude_speed(), Some(-0.03));
assert_eq!(motion.distance_speed(), Some(0.000_4));
}
#[test]
fn motion_summary_line_matches_display() {
let motion = Motion::new(Some(0.12), Some(-0.03), Some(0.000_4));
assert_eq!(
motion.summary_line(),
"longitude=0.12 deg/day; latitude=-0.03 deg/day; distance=0.0004 au/day"
);
assert_eq!(motion.to_string(), motion.summary_line());
}
#[test]
fn motion_direction_tracks_the_sign_of_longitude_speed() {
assert_eq!(
Motion::new(Some(0.12), None, None).longitude_direction(),
Some(MotionDirection::Direct)
);
assert_eq!(
Motion::new(Some(-0.04), None, None).longitude_direction(),
Some(MotionDirection::Retrograde)
);
assert_eq!(
Motion::new(Some(0.0), None, None).longitude_direction(),
Some(MotionDirection::Stationary)
);
assert_eq!(Motion::new(None, None, None).longitude_direction(), None);
assert_eq!(
Motion::new(Some(f64::NAN), None, None).longitude_direction(),
None
);
}
#[test]
fn motion_validation_rejects_non_finite_components() {
let longitude = Motion::new(Some(f64::INFINITY), None, None);
assert_eq!(
longitude.validate(),
Err(MotionValidationError::NonFiniteSpeed {
field: "longitude_deg_per_day",
value: f64::INFINITY,
})
);
let latitude = Motion::new(None, Some(f64::NAN), None);
assert!(matches!(
latitude.validate(),
Err(MotionValidationError::NonFiniteSpeed {
field: "latitude_deg_per_day",
value,
}) if value.is_nan()
));
let distance = Motion::new(None, None, Some(f64::NEG_INFINITY));
assert_eq!(
distance.validate(),
Err(MotionValidationError::NonFiniteSpeed {
field: "distance_au_per_day",
value: f64::NEG_INFINITY,
})
);
}
#[test]
fn coordinate_validation_rejects_non_finite_and_out_of_range_values() {
let bad_ecliptic = EclipticCoordinates::new(
Longitude::from_degrees(12.0),
Latitude::from_degrees(91.0),
Some(-1.0),
);
assert_eq!(
bad_ecliptic.validate(),
Err(CoordinateValidationError::LatitudeOutOfRange {
coordinate: "ecliptic",
field: "latitude",
value: 91.0,
})
);
assert_eq!(
CoordinateValidationError::LatitudeOutOfRange {
coordinate: "ecliptic",
field: "latitude",
value: 91.0,
}
.summary_line(),
"ecliptic coordinate field `latitude` must stay within [-90, 90], got 91"
);
let bad_distance = EclipticCoordinates::new(
Longitude::from_degrees(12.0),
Latitude::from_degrees(12.0),
Some(-1.0),
);
assert_eq!(
bad_distance.validate(),
Err(CoordinateValidationError::NegativeDistance {
coordinate: "ecliptic",
value: -1.0,
})
);
let bad_non_finite_distance = EclipticCoordinates::new(
Longitude::from_degrees(12.0),
Latitude::from_degrees(12.0),
Some(f64::NAN),
);
assert!(matches!(
bad_non_finite_distance.validate(),
Err(CoordinateValidationError::NonFiniteValue {
coordinate: "ecliptic",
field: "distance_au",
value,
}) if value.is_nan()
));
let bad_equatorial = EquatorialCoordinates::new(
Angle::from_degrees(360.0),
Latitude::from_degrees(0.0),
Some(1.0),
);
assert_eq!(
bad_equatorial.validate(),
Err(CoordinateValidationError::RightAscensionOutOfRange {
coordinate: "equatorial",
field: "right_ascension",
value: 360.0,
})
);
assert_eq!(
CoordinateValidationError::NegativeDistance {
coordinate: "ecliptic",
value: -0.5,
}
.summary_line(),
"ecliptic coordinate field `distance_au` must be non-negative, got -0.5"
);
}
#[test]
fn observer_location_validation_rejects_invalid_values() {
let valid = ObserverLocation::new(
Latitude::from_degrees(51.5),
Longitude::from_degrees(-0.1),
Some(45.0),
);
assert_eq!(valid.validate(), Ok(()));
assert_eq!(
valid.summary_line(),
"latitude=51.5°, longitude=359.9°, elevation=45.000 m"
);
assert_eq!(valid.validated_summary_line(), Ok(valid.summary_line()));
let bad_latitude = ObserverLocation::new(
Latitude::from_degrees(91.0),
Longitude::from_degrees(-0.1),
None,
);
assert_eq!(
bad_latitude.validate(),
Err(ObserverLocationValidationError::LatitudeOutOfRange { value: 91.0 })
);
assert_eq!(
ObserverLocationValidationError::LatitudeOutOfRange { value: 91.0 }.summary_line(),
"observer latitude must stay within [-90, 90], got 91"
);
let bad_longitude = ObserverLocation::new(
Latitude::from_degrees(51.5),
Longitude::from_degrees(f64::NAN),
None,
);
assert!(matches!(
bad_longitude.validate(),
Err(ObserverLocationValidationError::NonFiniteLongitude { value }) if value.is_nan()
));
let bad_elevation = ObserverLocation::new(
Latitude::from_degrees(51.5),
Longitude::from_degrees(-0.1),
Some(f64::INFINITY),
);
assert_eq!(
bad_elevation.validate(),
Err(ObserverLocationValidationError::NonFiniteElevation {
value: f64::INFINITY,
})
);
assert_eq!(
ObserverLocationValidationError::NonFiniteElevation {
value: f64::INFINITY,
}
.summary_line(),
"observer elevation must be finite, got inf"
);
assert!(bad_elevation.validated_summary_line().is_err());
}