use crate::core::error::ChartError;
use serde::{Deserialize, Serialize};
const MIN_UTC_OFFSET_MINUTES: i32 = -12 * 60;
const MAX_UTC_OFFSET_MINUTES: i32 = 14 * 60;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Longitude {
degrees: f64,
}
impl Longitude {
pub fn new(degrees: f64) -> Result<Self, ChartError> {
if degrees.is_nan() || !(-180.0..=180.0).contains(°rees) {
return Err(ChartError::InvalidLongitude { value: degrees });
}
Ok(Self { degrees })
}
pub const fn degrees(self) -> f64 {
self.degrees
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct UtcOffset {
minutes: i32,
}
impl UtcOffset {
pub const fn from_minutes(minutes: i32) -> Result<Self, ChartError> {
if minutes < MIN_UTC_OFFSET_MINUTES || minutes > MAX_UTC_OFFSET_MINUTES {
return Err(ChartError::InvalidUtcOffset { minutes });
}
Ok(Self { minutes })
}
pub const fn from_hours(hours: i32) -> Result<Self, ChartError> {
Self::from_minutes(hours * 60)
}
pub const fn minutes(self) -> i32 {
self.minutes
}
pub fn hours(self) -> f64 {
f64::from(self.minutes) / 60.0
}
pub fn meridian_degrees(self) -> f64 {
self.hours() * 15.0
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct ClockBirthTime {
hour: u8,
minute: u8,
timezone: UtcOffset,
}
impl ClockBirthTime {
pub const fn new(hour: u8, minute: u8, timezone: UtcOffset) -> Result<Self, ChartError> {
if hour > 23 || minute > 59 {
return Err(ChartError::InvalidClockTime { hour, minute });
}
Ok(Self {
hour,
minute,
timezone,
})
}
pub const fn hour(self) -> u8 {
self.hour
}
pub const fn minute(self) -> u8 {
self.minute
}
pub const fn timezone(self) -> UtcOffset {
self.timezone
}
pub const fn minutes_since_midnight(self) -> i32 {
self.hour as i32 * 60 + self.minute as i32
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EquationOfTimePolicy {
#[default]
Disabled,
Approximate,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ApparentSolarTimeConfig {
pub longitude: Longitude,
pub equation_of_time: EquationOfTimePolicy,
}
impl ApparentSolarTimeConfig {
pub const fn new(longitude: Longitude, equation_of_time: EquationOfTimePolicy) -> Self {
Self {
longitude,
equation_of_time,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum SolarTimePolicy {
#[default]
ClockTime,
ApparentSolarTime(ApparentSolarTimeConfig),
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum YearBoundary {
#[default]
ChineseNewYearEve,
LiChun,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LeapMonthBoundary {
AsPreviousMonth,
#[default]
MidMonth,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NominalAgeBoundary {
#[default]
NaturalYear,
Birthday,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ChartCalculationConfig {
pub solar_time: SolarTimePolicy,
pub year_boundary: YearBoundary,
pub leap_month_boundary: LeapMonthBoundary,
pub nominal_age_boundary: NominalAgeBoundary,
}
impl ChartCalculationConfig {
pub const fn new(solar_time: SolarTimePolicy) -> Self {
Self {
solar_time,
year_boundary: YearBoundary::ChineseNewYearEve,
leap_month_boundary: LeapMonthBoundary::MidMonth,
nominal_age_boundary: NominalAgeBoundary::NaturalYear,
}
}
pub const fn clock_time() -> Self {
Self::new(SolarTimePolicy::ClockTime)
}
pub const fn apparent_solar_time(config: ApparentSolarTimeConfig) -> Self {
Self::new(SolarTimePolicy::ApparentSolarTime(config))
}
pub const fn with_year_boundary(mut self, year_boundary: YearBoundary) -> Self {
self.year_boundary = year_boundary;
self
}
pub const fn with_leap_month_boundary(
mut self,
leap_month_boundary: LeapMonthBoundary,
) -> Self {
self.leap_month_boundary = leap_month_boundary;
self
}
pub const fn with_nominal_age_boundary(
mut self,
nominal_age_boundary: NominalAgeBoundary,
) -> Self {
self.nominal_age_boundary = nominal_age_boundary;
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn longitude_accepts_in_range() {
assert_eq!(Longitude::new(120.0).expect("valid").degrees(), 120.0);
assert_eq!(Longitude::new(-180.0).expect("valid").degrees(), -180.0);
assert_eq!(Longitude::new(180.0).expect("valid").degrees(), 180.0);
}
#[test]
fn longitude_rejects_out_of_range() {
assert_eq!(
Longitude::new(180.5),
Err(ChartError::InvalidLongitude { value: 180.5 }),
);
assert_eq!(
Longitude::new(-181.0),
Err(ChartError::InvalidLongitude { value: -181.0 }),
);
}
#[test]
fn utc_offset_accepts_real_world_range() {
assert_eq!(UtcOffset::from_hours(8).expect("valid").minutes(), 480);
assert_eq!(UtcOffset::from_hours(-12).expect("valid").minutes(), -720);
assert_eq!(UtcOffset::from_hours(14).expect("valid").minutes(), 840);
}
#[test]
fn utc_offset_rejects_out_of_range() {
assert_eq!(
UtcOffset::from_minutes(841),
Err(ChartError::InvalidUtcOffset { minutes: 841 }),
);
assert_eq!(
UtcOffset::from_hours(-13),
Err(ChartError::InvalidUtcOffset { minutes: -780 }),
);
}
#[test]
fn utc_offset_meridian_is_offset_hours_times_fifteen() {
assert_eq!(
UtcOffset::from_hours(8).expect("valid").meridian_degrees(),
120.0
);
assert_eq!(
UtcOffset::from_hours(0).expect("valid").meridian_degrees(),
0.0
);
}
#[test]
fn clock_birth_time_accepts_valid_time() {
let tz = UtcOffset::from_hours(8).expect("valid offset");
let clock = ClockBirthTime::new(1, 5, tz).expect("valid clock time");
assert_eq!(clock.hour(), 1);
assert_eq!(clock.minute(), 5);
assert_eq!(clock.minutes_since_midnight(), 65);
}
#[test]
fn clock_birth_time_rejects_invalid_time() {
let tz = UtcOffset::from_hours(8).expect("valid offset");
assert_eq!(
ClockBirthTime::new(24, 0, tz),
Err(ChartError::InvalidClockTime {
hour: 24,
minute: 0,
}),
);
assert_eq!(
ClockBirthTime::new(0, 60, tz),
Err(ChartError::InvalidClockTime {
hour: 0,
minute: 60,
}),
);
}
#[test]
fn calculation_config_defaults_to_clock_time() {
assert_eq!(
ChartCalculationConfig::default().solar_time,
SolarTimePolicy::ClockTime,
);
}
#[test]
fn calculation_config_boundary_defaults_preserve_existing_behaviour() {
let config = ChartCalculationConfig::default();
assert_eq!(config.year_boundary, YearBoundary::ChineseNewYearEve);
assert_eq!(config.leap_month_boundary, LeapMonthBoundary::MidMonth);
assert_eq!(config.nominal_age_boundary, NominalAgeBoundary::NaturalYear);
}
#[test]
fn enum_defaults_match_existing_behaviour() {
assert_eq!(YearBoundary::default(), YearBoundary::ChineseNewYearEve);
assert_eq!(LeapMonthBoundary::default(), LeapMonthBoundary::MidMonth);
assert_eq!(
NominalAgeBoundary::default(),
NominalAgeBoundary::NaturalYear
);
}
#[test]
fn constructors_set_boundary_defaults() {
for config in [
ChartCalculationConfig::clock_time(),
ChartCalculationConfig::new(SolarTimePolicy::ClockTime),
ChartCalculationConfig::apparent_solar_time(ApparentSolarTimeConfig::new(
Longitude::new(120.0).expect("valid longitude"),
EquationOfTimePolicy::Disabled,
)),
] {
assert_eq!(config.year_boundary, YearBoundary::ChineseNewYearEve);
assert_eq!(config.leap_month_boundary, LeapMonthBoundary::MidMonth);
assert_eq!(config.nominal_age_boundary, NominalAgeBoundary::NaturalYear);
}
}
#[test]
fn with_builders_replace_each_boundary() {
let config = ChartCalculationConfig::clock_time()
.with_year_boundary(YearBoundary::LiChun)
.with_leap_month_boundary(LeapMonthBoundary::AsPreviousMonth)
.with_nominal_age_boundary(NominalAgeBoundary::Birthday);
assert_eq!(config.year_boundary, YearBoundary::LiChun);
assert_eq!(
config.leap_month_boundary,
LeapMonthBoundary::AsPreviousMonth
);
assert_eq!(config.nominal_age_boundary, NominalAgeBoundary::Birthday);
assert_eq!(config.solar_time, SolarTimePolicy::ClockTime);
}
}