diff --git a/src/builtins/compiled/duration.rs b/src/builtins/compiled/duration.rs index 2828c9dd..5ca94e52 100644 --- a/src/builtins/compiled/duration.rs +++ b/src/builtins/compiled/duration.rs @@ -1,6 +1,7 @@ use crate::{ builtins::TZ_PROVIDER, options::{RelativeTo, RoundingOptions, TemporalUnit}, + primitive::FiniteF64, Duration, TemporalError, TemporalResult, }; @@ -44,7 +45,7 @@ impl Duration { &self, unit: TemporalUnit, relative_to: Option, - ) -> TemporalResult { + ) -> TemporalResult { let provider = TZ_PROVIDER .lock() .map_err(|_| TemporalError::general("Unable to acquire lock"))?; diff --git a/src/builtins/compiled/duration/tests.rs b/src/builtins/compiled/duration/tests.rs index ee3cd84f..e6f168a5 100644 --- a/src/builtins/compiled/duration/tests.rs +++ b/src/builtins/compiled/duration/tests.rs @@ -685,3 +685,118 @@ fn test_duration_compare() { ) } } + +#[test] +fn test_duration_total() { + let d1 = Duration::from_partial_duration(PartialDuration { + hours: Some(FiniteF64::from(130)), + minutes: Some(FiniteF64::from(20)), + ..Default::default() + }) + .unwrap(); + assert_eq!(d1.total(TemporalUnit::Second, None).unwrap(), 469200.0); + + // How many 24-hour days is 123456789 seconds? + let d2 = Duration::from_str("PT123456789S").unwrap(); + assert_eq!( + d2.total(TemporalUnit::Day, None).unwrap(), + 1428.8980208333332 + ); + + // Find totals in months, with and without taking DST into account + let d3 = Duration::from_partial_duration(PartialDuration { + hours: Some(FiniteF64::from(2756)), + ..Default::default() + }) + .unwrap(); + let relative_to = ZonedDateTime::from_str( + "2020-01-01T00:00+01:00[Europe/Rome]", + Default::default(), + OffsetDisambiguation::Reject, + ) + .unwrap(); + assert_eq!( + d3.total( + TemporalUnit::Month, + Some(RelativeTo::ZonedDateTime(relative_to)) + ) + .unwrap(), + 3.7958333333333334 + ); + assert_eq!( + d3.total( + TemporalUnit::Month, + Some(RelativeTo::PlainDate( + PlainDate::new(2020, 1, 1, Calendar::default()).unwrap() + )) + ) + .unwrap(), + 3.7944444444444443 + ); +} + +// balance-subseconds.js +#[test] +fn balance_subseconds() { + // Test positive + let pos = Duration::from_partial_duration(PartialDuration { + milliseconds: Some(FiniteF64::from(999)), + microseconds: Some(FiniteF64::from(999999)), + nanoseconds: Some(FiniteF64::from(999999999)), + ..Default::default() + }) + .unwrap(); + assert_eq!(pos.total(TemporalUnit::Second, None).unwrap(), 2.998998999); + + // Test negative + let neg = Duration::from_partial_duration(PartialDuration { + milliseconds: Some(FiniteF64::from(-999)), + microseconds: Some(FiniteF64::from(-999999)), + nanoseconds: Some(FiniteF64::from(-999999999)), + ..Default::default() + }) + .unwrap(); + assert_eq!(neg.total(TemporalUnit::Second, None).unwrap(), -2.998998999); +} + +// balances-days-up-to-both-years-and-months.js +#[test] +fn balance_days_up_to_both_years_and_months() { + // Test positive + let two_years = Duration::from_partial_duration(PartialDuration { + months: Some(FiniteF64::from(11)), + days: Some(FiniteF64::from(396)), + ..Default::default() + }) + .unwrap(); + + let relative_to = PlainDate::new(2017, 1, 1, Calendar::default()).unwrap(); + + assert_eq!( + two_years + .total( + TemporalUnit::Year, + Some(RelativeTo::PlainDate(relative_to.clone())) + ) + .unwrap(), + 2.0 + ); + + // Test negative + let two_years_negative = Duration::from_partial_duration(PartialDuration { + months: Some(FiniteF64::from(-11)), + days: Some(FiniteF64::from(-396)), + ..Default::default() + }) + .unwrap(); + + assert_eq!( + two_years_negative + .total( + TemporalUnit::Year, + Some(RelativeTo::PlainDate(relative_to.clone())) + ) + .unwrap(), + -2.0 + ); +} diff --git a/src/builtins/core/datetime.rs b/src/builtins/core/datetime.rs index 62472543..5391b21b 100644 --- a/src/builtins/core/datetime.rs +++ b/src/builtins/core/datetime.rs @@ -12,6 +12,7 @@ use crate::{ ResolvedRoundingOptions, RoundingOptions, TemporalUnit, ToStringRoundingOptions, UnitGroup, }, parsers::{parse_date_time, IxdtfStringBuilder}, + primitive::FiniteF64, provider::NeverProvider, temporal_assert, MonthCode, TemporalError, TemporalResult, TemporalUnwrap, TimeZone, }; @@ -202,6 +203,38 @@ impl PlainDateTime { options, ) } + + // 5.5.14 DifferencePlainDateTimeWithTotal ( isoDateTime1, isoDateTime2, calendar, unit ) + pub(crate) fn diff_dt_with_total( + &self, + other: &Self, + unit: TemporalUnit, + ) -> TemporalResult { + // 1. If CompareISODateTime(isoDateTime1, isoDateTime2) = 0, then + // a. Return 0. + if matches!(self.iso.cmp(&other.iso), Ordering::Equal) { + return FiniteF64::try_from(0.0); + } + // 2. If ISODateTimeWithinLimits(isoDateTime1) is false or ISODateTimeWithinLimits(isoDateTime2) is false, throw a RangeError exception. + if !self.iso.is_within_limits() || !other.iso.is_within_limits() { + return Err(TemporalError::range().with_message("DateTime is not within valid limits.")); + } + // 3. Let diff be DifferenceISODateTime(isoDateTime1, isoDateTime2, calendar, unit). + let diff = self.iso.diff(&other.iso, &self.calendar, unit)?; + // 4. If unit is nanosecond, return diff.[[Time]]. + if unit == TemporalUnit::Nanosecond { + return FiniteF64::try_from(diff.normalized_time_duration().0); + } + // 5. Let destEpochNs be GetUTCEpochNanoseconds(isoDateTime2). + let dest_epoch_ns = other.iso.as_nanoseconds()?; + // 6. Return ? TotalRelativeDuration(diff, destEpochNs, isoDateTime1, unset, calendar, unit). + diff.total_relative_duration( + dest_epoch_ns.0, + self, + Option::<(&TimeZone, &NeverProvider)>::None, + unit, + ) + } } // ==== Public PlainDateTime API ==== diff --git a/src/builtins/core/duration.rs b/src/builtins/core/duration.rs index 875b5b99..6cfcf12d 100644 --- a/src/builtins/core/duration.rs +++ b/src/builtins/core/duration.rs @@ -686,11 +686,79 @@ impl Duration { /// Returns the total of the `Duration` pub fn total_with_provider( &self, - _unit: TemporalUnit, - _relative_to: Option, - _provider: &impl TimeZoneProvider, - ) -> TemporalResult { - Err(TemporalError::general("Not yet implemented")) + unit: TemporalUnit, + relative_to: Option, + provider: &impl TimeZoneProvider, + // Review question what is the return type of duration.prototye.total? + ) -> TemporalResult { + match relative_to { + // 11. If zonedRelativeTo is not undefined, then + Some(RelativeTo::ZonedDateTime(zoned_datetime)) => { + // a. Let internalDuration be ToInternalDurationRecord(duration). + // b. Let timeZone be zonedRelativeTo.[[TimeZone]]. + // c. Let calendar be zonedRelativeTo.[[Calendar]]. + // d. Let relativeEpochNs be zonedRelativeTo.[[EpochNanoseconds]]. + // e. Let targetEpochNs be ? AddZonedDateTime(relativeEpochNs, timeZone, calendar, internalDuration, constrain). + let target_epcoh_ns = + zoned_datetime.add_as_instant(self, ArithmeticOverflow::Constrain, provider)?; + // f. Let total be ? DifferenceZonedDateTimeWithTotal(relativeEpochNs, targetEpochNs, timeZone, calendar, unit). + let total = zoned_datetime.diff_with_total( + &ZonedDateTime::new_unchecked( + target_epcoh_ns, + zoned_datetime.calendar().clone(), + zoned_datetime.timezone().clone(), + ), + unit, + provider, + )?; + Ok(total) + } + // 12. Else if plainRelativeTo is not undefined, then + Some(RelativeTo::PlainDate(plain_date)) => { + // a. Let internalDuration be ToInternalDurationRecordWith24HourDays(duration). + // b. Let targetTime be AddTime(MidnightTimeRecord(), internalDuration.[[Time]]). + let (balanced_days, time) = + PlainTime::default().add_normalized_time_duration(self.time.to_normalized()); + // c. Let calendar be plainRelativeTo.[[Calendar]]. + // d. Let dateDuration be ! AdjustDateDurationRecord(internalDuration.[[Date]], targetTime.[[Days]]). + let date_duration = DateDuration::new( + self.years(), + self.months(), + self.weeks(), + self.days().checked_add(&FiniteF64::from(balanced_days))?, + )?; + // e. Let targetDate be ? CalendarDateAdd(calendar, plainRelativeTo.[[ISODate]], dateDuration, constrain). + let target_date = plain_date.calendar().date_add( + &plain_date.iso, + &Duration::from(date_duration), + ArithmeticOverflow::Constrain, + )?; + // f. Let isoDateTime be CombineISODateAndTimeRecord(plainRelativeTo.[[ISODate]], MidnightTimeRecord()). + let iso_date_time = IsoDateTime::new_unchecked(plain_date.iso, IsoTime::default()); + // g. Let targetDateTime be CombineISODateAndTimeRecord(targetDate, targetTime). + let target_date_time = IsoDateTime::new_unchecked(target_date.iso, time.iso); + // h. Let total be ? DifferencePlainDateTimeWithTotal(isoDateTime, targetDateTime, calendar, unit). + let plain_dt = + PlainDateTime::new_unchecked(iso_date_time, plain_date.calendar().clone()); + let total = plain_dt.diff_dt_with_total( + &PlainDateTime::new_unchecked(target_date_time, plain_date.calendar().clone()), + unit, + )?; + Ok(total) + } + None => { + // a. Let largestUnit be DefaultTemporalLargestUnit(duration). + let largest_unit = self.default_largest_unit(); + // b. If IsCalendarUnit(largestUnit) is true, or IsCalendarUnit(unit) is true, throw a RangeError exception. + if largest_unit.is_calendar_unit() || unit.is_calendar_unit() { + return Err(TemporalError::range()); + } + // c. Let internalDuration be ToInternalDurationRecordWith24HourDays(duration). + // d. Let total be TotalTimeDuration(internalDuration.[[Time]], unit). + let total = self.time.to_normalized().total(unit)?; + Ok(total) + } + } } /// Returns the `Duration` as a formatted string diff --git a/src/builtins/core/duration/normalized.rs b/src/builtins/core/duration/normalized.rs index d718b489..92f21284 100644 --- a/src/builtins/core/duration/normalized.rs +++ b/src/builtins/core/duration/normalized.rs @@ -8,8 +8,8 @@ use crate::{ builtins::core::{timezone::TimeZone, PlainDate, PlainDateTime}, iso::{IsoDate, IsoDateTime}, options::{ - ArithmeticOverflow, Disambiguation, ResolvedRoundingOptions, TemporalRoundingMode, - TemporalUnit, + ArithmeticOverflow, Disambiguation, ResolvedRoundingOptions, RoundingIncrement, + TemporalRoundingMode, TemporalUnit, }, primitive::FiniteF64, provider::TimeZoneProvider, @@ -197,6 +197,18 @@ impl NormalizedTimeDuration { )) } + /// Equivalent: 7.5.31 TotalTimeDuration ( timeDuration, unit ) + /// TODO Fix: Arithemtic on floating point numbers is not safe. According to NOTE 2 in the spec + pub(crate) fn total(&self, unit: TemporalUnit) -> TemporalResult { + let time_duration = self.0; + // 1. Let divisor be the value in the "Length in Nanoseconds" column of the row of Table 21 whose "Value" column contains unit. + let unit_nanoseconds = unit.as_nanoseconds().temporal_unwrap()?; + // 2. NOTE: The following step cannot be implemented directly using floating-point arithmetic when 𝔽(timeDuration) is not a safe integer. + // The division can be implemented in C++ with the __float128 type if the compiler supports it, or with software emulation such as in the SoftFP library. + // 3. Return timeDuration / divisor. + DurationTotal::new(time_duration, unit_nanoseconds).to_fractional_total() + } + /// Round the current `NormalizedTimeDuration`. pub(super) fn round_inner( &self, @@ -236,6 +248,32 @@ impl Add for NormalizedTimeDuration { } } +// Struct to handle division steps in `TotalTimeDuration` +struct DurationTotal { + quotient: i128, + remainder: i128, + unit_nanoseconds: u64, +} + +impl DurationTotal { + pub fn new(time_duration: i128, unit_nanoseconds: u64) -> Self { + let quotient = time_duration.div_euclid(unit_nanoseconds as i128); + let remainder = time_duration.rem_euclid(unit_nanoseconds as i128); + + Self { + quotient, + remainder, + unit_nanoseconds, + } + } + + pub(crate) fn to_fractional_total(&self) -> TemporalResult { + let fractional = FiniteF64::try_from(self.remainder)? + .checked_div(&FiniteF64::try_from(self.unit_nanoseconds)?)?; + FiniteF64::try_from(self.quotient)?.checked_add(&fractional) + } +} + // ==== NormalizedDurationRecord ==== // // A record consisting of a DateDuration and NormalizedTimeDuration @@ -289,7 +327,7 @@ impl NormalizedDurationRecord { #[derive(Debug)] struct NudgeRecord { normalized: NormalizedDurationRecord, - _total: Option, // TODO: adjust + total: Option, nudge_epoch_ns: i128, expanded: bool, } @@ -565,7 +603,7 @@ impl NormalizedDurationRecord { end_duration, NormalizedTimeDuration::default(), )?, - _total: Some(total as i128), + total: Some(FiniteF64::try_from(total)?), nudge_epoch_ns: end_epoch_ns.0, expanded: true, }) @@ -579,7 +617,7 @@ impl NormalizedDurationRecord { start_duration, NormalizedTimeDuration::default(), )?, - _total: Some(total as i128), + total: Some(FiniteF64::try_from(total)?), nudge_epoch_ns: start_epoch_ns.0, expanded: false, }) @@ -672,7 +710,7 @@ impl NormalizedDurationRecord { Ok(NudgeRecord { normalized, nudge_epoch_ns: nudge_ns.0, - _total: None, + total: None, expanded, }) } @@ -748,7 +786,7 @@ impl NormalizedDurationRecord { // [[NudgedEpochNs]]: nudgedEpochNs, [[DidExpandCalendarUnit]]: didExpandDays }. Ok(NudgeRecord { normalized: result_duration, - _total: Some(total), + total: Some(FiniteF64::try_from(total)?), nudge_epoch_ns: nudged_ns, expanded: did_expand_days, }) @@ -946,6 +984,43 @@ impl NormalizedDurationRecord { Ok(duration) } + + // 7.5.38 TotalRelativeDuration ( duration, destEpochNs, isoDateTime, timeZone, calendar, unit ) + pub(crate) fn total_relative_duration( + &self, + dest_epoch_ns: i128, + dt: &PlainDateTime, + tz: Option<(&TimeZone, &impl TimeZoneProvider)>, + unit: TemporalUnit, + ) -> TemporalResult { + // 1. If IsCalendarUnit(unit) is true, or timeZone is not unset and unit is day, then + if unit.is_calendar_unit() || (tz.is_some() && unit == TemporalUnit::Day) { + // a. Let sign be InternalDurationSign(duration). + let sign = self.sign()?; + // b. Let record be ? NudgeToCalendarUnit(sign, duration, destEpochNs, isoDateTime, timeZone, calendar, 1, unit, trunc). + let record = self.nudge_calendar_unit( + sign, + dest_epoch_ns, + dt, + tz, + ResolvedRoundingOptions { + largest_unit: unit, + smallest_unit: unit, + increment: RoundingIncrement::default(), + rounding_mode: TemporalRoundingMode::Trunc, + }, + )?; + + // c. Return record.[[Total]]. + return record.total.temporal_unwrap(); + } + // 2. Let timeDuration be ! Add24HourDaysToTimeDuration(duration.[[Time]], duration.[[Date]].[[Days]]). + let time_duration = self + .normalized_time_duration() + .add_days(self.date().days.as_())?; + // Return TotalTimeDuration(timeDuration, unit). + Ok(time_duration.total(unit))? + } } mod tests { diff --git a/src/builtins/core/zoneddatetime.rs b/src/builtins/core/zoneddatetime.rs index c5fa0efb..ab389a1d 100644 --- a/src/builtins/core/zoneddatetime.rs +++ b/src/builtins/core/zoneddatetime.rs @@ -21,6 +21,7 @@ use crate::{ }, parsers::{self, FormattableOffset, FormattableTime, IxdtfStringBuilder, Precision}, partial::{PartialDate, PartialTime}, + primitive::FiniteF64, provider::{TimeZoneProvider, TransitionDirection}, rounding::{IncrementRounder, Round}, temporal_assert, @@ -199,6 +200,39 @@ impl ZonedDateTime { ) } + /// Internal representation of Abstract Op 6.5.8 + pub(crate) fn diff_with_total( + &self, + other: &Self, + unit: TemporalUnit, + provider: &impl TimeZoneProvider, + ) -> TemporalResult { + // 1. If TemporalUnitCategory(unit) is time, then + if unit.is_time_unit() { + // a. Let difference be TimeDurationFromEpochNanosecondsDifference(ns2, ns1). + let diff = NormalizedTimeDuration::from_nanosecond_difference( + other.epoch_nanoseconds().as_i128(), + self.epoch_nanoseconds().as_i128(), + )?; + // b. Return TotalTimeDuration(difference, unit). + return Ok(diff.total(unit))?; + } + + // 2. Let difference be ? DifferenceZonedDateTime(ns1, ns2, timeZone, calendar, unit). + let diff = self.diff_zoned_datetime(other, unit, provider)?; + // 3. Let dateTime be GetISODateTimeFor(timeZone, ns1). + let iso = self + .timezone() + .get_iso_datetime_for(&self.instant, provider)?; + // 4. Return ? TotalRelativeDuration(difference, ns2, dateTime, timeZone, calendar, unit). + diff.total_relative_duration( + other.epoch_nanoseconds().as_i128(), + &PlainDateTime::new_unchecked(iso, self.calendar().clone()), + Some((self.timezone(), provider)), + unit, + ) + } + pub(crate) fn diff_zoned_datetime( &self, other: &Self, diff --git a/src/primitive.rs b/src/primitive.rs index 623757ea..f723faa2 100644 --- a/src/primitive.rs +++ b/src/primitive.rs @@ -55,6 +55,15 @@ impl FiniteF64 { Ok(result) } + #[inline] + pub fn checked_div(&self, other: &Self) -> TemporalResult { + let result = Self(self.0 / other.0); + if !result.0.is_finite() { + return Err(TemporalError::range().with_message("number value is not a finite value.")); + } + Ok(result) + } + pub fn copysign(&self, other: f64) -> Self { if !self.is_zero() { Self(self.0.copysign(other))