diff --git a/crates/oxide_builtins/src/temporal/difference.rs b/crates/oxide_builtins/src/temporal/difference.rs index b794830d..cb30ef9f 100644 --- a/crates/oxide_builtins/src/temporal/difference.rs +++ b/crates/oxide_builtins/src/temporal/difference.rs @@ -77,6 +77,41 @@ pub(crate) const DAY_NS: i128 = 86_400_000_000_000; pub(crate) const MAX_ISO_DAY: i128 = 100_000_000; +/// 时间单位长度表(时到纳秒,下标 = 单位索引 - 4)。 +pub(crate) const TIME_UNIT_NS: [i128; 6] = [3_600_000_000_000, 60_000_000_000, 1_000_000_000, 1_000_000, 1_000, 1]; + +/// 内部时长记录:日期部分(年/月/周/日)加时间部分(纳秒,不含日分量)。 +pub(crate) struct InternalDuration { + pub(crate) date: [f64; 4], + pub(crate) time_ns: i128, +} + +impl InternalDuration { + /// 转十分量数组:时间部分按向零截断分解为时/分/秒/毫秒/微秒/纳秒(与时长同号)。 + pub(crate) fn to_values(&self) -> [f64; 10] { + let mut values = [0.0; 10]; + values[0..4].copy_from_slice(&self.date); + let time = plain_time_components_signed(self.time_ns); + for index in 0..6 { + values[4 + index] = time[index] as f64; + } + values + } +} + +/// InternalDurationSign(规范 7.5.15):日期部分符号优先,其次时间部分符号。 +pub(crate) fn internal_duration_sign(values: &[f64; 10]) -> i128 { + let date_sign = date_duration_sign(values); + if date_sign != 0 { + return date_sign; + } + match super::duration::duration_time_only_nanoseconds(values) { + Some(time_ns) if time_ns < 0 => -1, + Some(time_ns) if time_ns > 0 => 1, + _ => 0, + } +} + /// 比较两个 ISO 日期,返回 -1/0/+1。 pub(crate) fn compare_iso_date(a: (i128, i128, i128), b: (i128, i128, i128)) -> i128 { if a.0 != b.0 { @@ -345,13 +380,25 @@ pub(crate) fn nudge_window( (r1, r2, start, end) } +/// NudgeToCalendarUnit 结果:取整后时长、取整后纪元、窗口是否扩窗(或取整到上界), +/// 以及以取整单位计的分数总量(窗口端点间的相对位置)。 +pub(crate) struct NudgeCalendarResult { + pub(crate) duration: [f64; 10], + pub(crate) nudged_epoch: i128, + pub(crate) did_expand: bool, + pub(crate) total: f64, +} + /// 对年/月/周单位的差值取整:把候选窗口端点换算成纪元纳秒,按目标时刻在窗口 /// 中的相对位置决定取整方向(NudgeToCalendarUnit 语义)。 +/// +/// `epoch_of_date` 是窗口端点日期的纪元换算闭包:plain 面传墙钟换算(行为逐字节 +/// 不变),zoned 面传 GetEpochNanosecondsFor(compatible) 换算。 #[allow(clippy::too_many_arguments)] -fn nudge_to_calendar_unit( - sign: i128, values: &[f64; 10], origin_epoch: i128, dest_epoch: i128, date1: (i128, i128, i128), time1_ns: i128, - increment: i128, unit: usize, mode: InstantRoundingMode, -) -> Result<([f64; 10], i128, bool), ()> { +pub(crate) fn nudge_to_calendar_unit( + sign: i128, values: &[f64; 10], origin_epoch: i128, dest_epoch: i128, date1: (i128, i128, i128), increment: i128, + unit: usize, mode: InstantRoundingMode, epoch_of_date: &dyn Fn((i128, i128, i128)) -> Result, +) -> Result { let epoch_of = |dur: &[f64; 10]| -> Result { if date_duration_sign(dur) == 0 { return Ok(origin_epoch); @@ -361,7 +408,7 @@ fn nudge_to_calendar_unit( if days.abs() > MAX_ISO_DAY { return Err(()); } - Ok(days * DAY_NS + time1_ns) + epoch_of_date(date) }; let mut did_expand = false; let (mut r1, mut r2, mut start_dur, mut end_dur) = nudge_window(sign, values, date1, increment, unit, false); @@ -400,14 +447,24 @@ fn nudge_to_calendar_unit( did_expand = did_expand || rounded_unit == r2.abs(); let duration = if rounded_unit == r2.abs() { end_dur } else { start_dur }; let nudged = if did_expand { end_epoch } else { start_epoch }; - Ok((duration, nudged, did_expand)) + // 分数总量:r1 加窗口内相对位置(increment 为 1 时即规范 [[Total]])。 + let total = (denominator as f64 * r1 as f64 + numerator as f64 * sign as f64) / denominator as f64; + Ok(NudgeCalendarResult { + duration, + nudged_epoch: nudged, + did_expand, + total, + }) } /// 舍入结果越过小单位边界时向更大的单位逐级进位,并把被进位单位的低位分量 /// 清零,最多进位到 largest 指定的单位(BubbleRelativeDuration 语义)。 -fn bubble_relative_duration( - sign: i128, mut values: [f64; 10], nudged_epoch: i128, date1: (i128, i128, i128), time1_ns: i128, largest: usize, - start_unit: usize, +/// +/// `epoch_of_date` 是进位候选日期的纪元换算闭包:plain 面传墙钟换算(行为逐字节 +/// 不变),zoned 面传 GetEpochNanosecondsFor(compatible) 换算。 +pub(crate) fn bubble_relative_duration( + sign: i128, mut values: [f64; 10], nudged_epoch: i128, date1: (i128, i128, i128), largest: usize, + start_unit: usize, epoch_of_date: &dyn Fn((i128, i128, i128)) -> Result, ) -> Result<[f64; 10], ()> { if start_unit == 0 { return Ok(values); @@ -446,7 +503,7 @@ fn bubble_relative_duration( let end_date = add_date_duration(date1, &end_dur); // 进位候选边界可能越出 ISO 日期范围,仅用于换算比较用的纪元纳秒并判断 // 是否进位,因此这里不做范围校验。 - let end_epoch = days_from_civil(end_date.0, end_date.1, end_date.2) * DAY_NS + time1_ns; + let end_epoch = epoch_of_date(end_date)?; let reached_end = if sign > 0 { nudged_epoch >= end_epoch } else { nudged_epoch <= end_epoch }; if reached_end { values = end_dur; @@ -742,6 +799,10 @@ pub(crate) fn nudge_iso_difference( let date1 = start; let time1_ns = start_time_ns; + // plain 面纪元换算闭包:墙钟(日 × 24h + 时间),行为与旧内联算法逐字节一致。 + let epoch_of_date = |date: (i128, i128, i128)| -> Result { + Ok(days_from_civil(date.0, date.1, date.2) * DAY_NS + time1_ns) + }; let mut date2 = end; let mut time_ns = end_time_ns - time1_ns; let time_sign = if time_ns > 0 { @@ -830,7 +891,7 @@ pub(crate) fn nudge_iso_difference( } if did_expand_days { let nudged = dest_epoch + (rounded_ns - total_ns); - match bubble_relative_duration(sign, values, nudged, date1, time1_ns, largest_index, 3) { + match bubble_relative_duration(sign, values, nudged, date1, largest_index, 3, &epoch_of_date) { Ok(bubbled) => values = bubbled, Err(()) => return Err(crate::error::create_range_error(vm, "difference is out of range")), } @@ -839,22 +900,33 @@ pub(crate) fn nudge_iso_difference( } else { // year/month/week 长度随日历变化,无法折算成固定纳秒:改用纪元纳秒窗口 // 取整,按目标时刻落在候选窗口内的相对位置决定取整方向(NudgeToCalendarUnit 语义)。 - let (mut values, nudged_epoch, did_expand) = match nudge_to_calendar_unit( + let outcome = match nudge_to_calendar_unit( sign, &date_values, origin_epoch, dest_epoch, date1, - time1_ns, increment, smallest_index, mode, + &epoch_of_date, ) { Ok(result) => result, Err(()) => return Err(crate::error::create_range_error(vm, "difference is out of range")), }; + let mut values = outcome.duration; + let nudged_epoch = outcome.nudged_epoch; + let did_expand = outcome.did_expand; if did_expand && smallest_index != 2 { - match bubble_relative_duration(sign, values, nudged_epoch, date1, time1_ns, largest_index, smallest_index) { + match bubble_relative_duration( + sign, + values, + nudged_epoch, + date1, + largest_index, + smallest_index, + &epoch_of_date, + ) { Ok(bubbled) => values = bubbled, Err(()) => return Err(crate::error::create_range_error(vm, "difference is out of range")), } diff --git a/crates/oxide_builtins/src/temporal/duration.rs b/crates/oxide_builtins/src/temporal/duration.rs index 190350dc..3fff94d1 100644 --- a/crates/oxide_builtins/src/temporal/duration.rs +++ b/crates/oxide_builtins/src/temporal/duration.rs @@ -13,12 +13,14 @@ use super::common::{ zoned_date_time_plain_parts, }; use super::difference::{ - add_date_duration, add_days_iso, compare_iso_date, date_duration_sign, date_until_iso, nudge_iso_difference, - nudge_window, plain_date_time_unit_index, round_instant_difference, DifferenceSettings, DAY_NS, MAX_ISO_DAY, + add_date_duration, add_days_iso, balance_instant_difference, compare_iso_date, date_duration_sign, date_until_iso, + nudge_iso_difference, nudge_window, plain_date_time_unit_index, round_instant_difference, DifferenceSettings, + InternalDuration, DAY_NS, MAX_ISO_DAY, }; use super::instant::{instant_rounding_mode, InstantRoundingMode}; use super::time_zone::zone_offset_seconds; use super::zoned_date_time::{ + add_zoned_datetime, difference_zoned_datetime_with_rounding_vm, difference_zoned_datetime_with_total_vm, extract_time_zone_annotation, parse_any_offset_seconds, valid_offset_fraction, zoned_date_time_string_parts, }; use super::{make_duration, parse_plain_date_time_string}; @@ -299,15 +301,13 @@ fn duration_values(obj: &JsObject) -> [f64; 10] { enum RelativePoint { Wall { date: (i128, i128, i128), - /// 本批调用方只消费日期,zoned epoch 路径消费日历。 + /// 当前仅 iso8601 日历,zoned 路径不消费日历。 #[allow(dead_code)] calendar: String, }, Zoned { epoch_ns: i128, time_zone: String, - /// 本批调用方只消费 epoch 与时区,zoned epoch 路径消费日历。 - #[allow(dead_code)] calendar: String, }, } @@ -319,8 +319,7 @@ enum RelativePoint { /// 2. Zoned → 区偏移(epoch 依赖查找)+ civil_from_days 反推墙历日期。 /// /// # 边界与前提 -/// - Zoned 支是本批过渡路径(TODO: 69.3.4.2 切 epoch 路径),算法与旧 zoned -/// 字符串支保持逐字节一致。 +/// - Zoned 支仅用于 compare 的两支纯时间落回路径(24h 语义与规范 plain 路径一致)。 fn relative_point_wall_date(vm: &mut H, point: &RelativePoint) -> Result<(i128, i128, i128), JsValue> { match point { RelativePoint::Wall { date, .. } => Ok(*date), @@ -548,6 +547,15 @@ pub fn duration_total(vm: &mut H, args: &[u8]) -> NativeResult { Err(error) => return NativeResult::Err(error), }; + // Zoned 载荷:AddZonedDateTime → DifferenceZonedDateTimeWithTotal(门与单位无关)。 + if let Some(RelativePoint::Zoned { epoch_ns, time_zone, calendar }) = &relative_date { + let target = native_try!(add_zoned_datetime(vm, *epoch_ns, time_zone, calendar, &values, true)); + let total = native_try!(difference_zoned_datetime_with_total_vm( + vm, *epoch_ns, target, time_zone, calendar, unit_index + )); + return NativeResult::Ok(JsValue::float(total)); + } + const UNIT_NS: [i128; 6] = [3_600_000_000_000, 60_000_000_000, 1_000_000_000, 1_000_000, 1_000, 1]; if unit_index <= 2 || (unit_index == 3 && has_calendar_units) { // 日历单位:需要 relativeTo,用纪元纳秒窗口计算分数总量。 @@ -557,7 +565,6 @@ pub fn duration_total(vm: &mut H, args: &[u8]) -> NativeResult { &format!("a starting point is required for {} total", unit_raw), )); }; - // Zoned 载荷暂走墙钟路径(TODO: 69.3.4.2 切 epoch 路径)。 let rel_date = native_try!(relative_point_wall_date(vm, &point)); let Some(time_ns_base) = duration_time_nanoseconds(&values) else { return NativeResult::Err(crate::error::create_range_error(vm, "duration is out of range")); @@ -900,6 +907,52 @@ fn duration_apply_to_relative( Ok((end, target_time)) } +/// DefaultTemporalLargestUnit(规范 7.5.17):首个非零分量索引(全零视为 nanosecond)。 +pub(crate) fn default_temporal_largest_unit(values: &[f64; 10]) -> usize { + for (index, value) in values.iter().enumerate() { + if *value != 0.0 { + return index; + } + } + 9 +} + +/// TemporalDurationFromInternal(规范 7.5.8):日期部分直传,时间部分按 largestUnit 平衡。 +/// +/// # 步骤 +/// 1. largestUnit 为日期类(day 及以上):时间全量分解,days 折回日期部分。 +/// 2. largestUnit 为时间类:从该单位向下分解。 +pub(crate) fn internal_duration_to_values(internal: &InternalDuration, largest: usize) -> [f64; 10] { + let mut values = [0.0; 10]; + values[0..4].copy_from_slice(&internal.date); + let sign = if internal.time_ns < 0 { -1 } else { 1 }; + if largest <= 3 { + let mut remainder = internal.time_ns.unsigned_abs(); + let hours = remainder / 3_600_000_000_000; + remainder %= 3_600_000_000_000; + let minutes = remainder / 60_000_000_000; + remainder %= 60_000_000_000; + let seconds = remainder / 1_000_000_000; + remainder %= 1_000_000_000; + let milliseconds = remainder / 1_000_000; + remainder %= 1_000_000; + let microseconds = remainder / 1_000; + let nanoseconds = remainder % 1_000; + let days = hours / 24; + values[3] = internal.date[3] + sign as f64 * days as f64; + values[4] = (hours % 24) as f64 * sign as f64; + values[5] = minutes as f64 * sign as f64; + values[6] = seconds as f64 * sign as f64; + values[7] = milliseconds as f64 * sign as f64; + values[8] = microseconds as f64 * sign as f64; + values[9] = nanoseconds as f64 * sign as f64; + } else { + let time_values = balance_instant_difference(internal.time_ns, largest - 4).unwrap_or([0.0; 10]); + values[4..10].copy_from_slice(&time_values[4..10]); + } + values +} + /// `Temporal.Duration.compare(one, two, options)`:按相对点比较两个时长。 /// /// # 步骤 @@ -957,8 +1010,22 @@ pub fn duration_compare(vm: &mut H, args: &[u8]) -> NativeResult { one_ns.cmp(&two_ns) } Some(point) => { + // Zoned 载荷且任一支最大单位为日期类:epoch 级比较(门是 OR)。 + // 两支均纯时间时落回 24h 墙钟路径(与规范 plain 路径一致)。 + if let RelativePoint::Zoned { epoch_ns, time_zone, calendar } = &point { + let largest1 = default_temporal_largest_unit(&one); + let largest2 = default_temporal_largest_unit(&two); + if largest1 <= 3 || largest2 <= 3 { + let after1 = native_try!(add_zoned_datetime(vm, *epoch_ns, time_zone, calendar, &one, true)); + let after2 = native_try!(add_zoned_datetime(vm, *epoch_ns, time_zone, calendar, &two, true)); + return NativeResult::Ok(JsValue::int(match after1.cmp(&after2) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Equal => 0, + std::cmp::Ordering::Greater => 1, + })); + } + } // 有 relativeTo:各 duration 应用后先比日期再比时间。 - // Zoned 载荷暂走墙钟路径(TODO: 69.3.4.2 切 epoch 路径)。 let rel = native_try!(relative_point_wall_date(vm, &point)); let (date1, time1) = native_try!(duration_apply_to_relative(vm, rel, &one)); let (date2, time2) = native_try!(duration_apply_to_relative(vm, rel, &two)); @@ -1136,7 +1203,30 @@ pub fn duration_round(vm: &mut H, args: &[u8]) -> NativeResult { }; // 日历单位存在或目标为日历单位时走 relativeTo 路径,否则走纯时间路径。 - let needs_relative = values[..3].iter().any(|value| *value != 0.0) || largest < 3 || smallest_index < 3; + // Zoned 相对点无条件走 zoned 路径(DST 感知的日长,24h 路径失准)。 + let needs_relative = values[..3].iter().any(|value| *value != 0.0) + || largest < 3 + || smallest_index < 3 + || matches!(&relative_date, Some(RelativePoint::Zoned { .. })); + // Zoned 载荷:AddZonedDateTime → DifferenceZonedDateTimeWithRounding(门与单位无关)。 + if let Some(RelativePoint::Zoned { epoch_ns, time_zone, calendar }) = &relative_date { + let target = native_try!(add_zoned_datetime(vm, *epoch_ns, time_zone, calendar, &values, true)); + let internal = native_try!(difference_zoned_datetime_with_rounding_vm( + vm, + *epoch_ns, + target, + time_zone, + calendar, + largest, + increment, + smallest_index, + mode, + )); + // largestUnit 为日期类时降为 hour。 + let largest = if largest <= 3 { 4 } else { largest }; + let result = internal_duration_to_values(&internal, largest); + return make_duration(vm, result); + } if !needs_relative { // 纯时间路径:按 24 小时/天将 days..nanoseconds 汇总为纳秒,按 smallest 舍入后平衡到 largest。 let Some(time_ns) = duration_time_nanoseconds(&values) else { @@ -1194,7 +1284,6 @@ pub fn duration_round(vm: &mut H, args: &[u8]) -> NativeResult { "a starting point is required for rounding calendar units", )); }; - // Zoned 载荷暂走墙钟路径(TODO: 69.3.4.2 切 epoch 路径)。 let rel = native_try!(relative_point_wall_date(vm, &point)); let Some(time_ns_total) = duration_time_nanoseconds(&values) else { return NativeResult::Err(crate::error::create_range_error(vm, "duration is out of range")); diff --git a/crates/oxide_builtins/src/temporal/tests.rs b/crates/oxide_builtins/src/temporal/tests.rs index 999fa71e..c411cefa 100644 --- a/crates/oxide_builtins/src/temporal/tests.rs +++ b/crates/oxide_builtins/src/temporal/tests.rs @@ -619,3 +619,108 @@ fn calendar_date_add_iso_constrain_semantics() { // 闰年二月:constrain 钳到 29。 assert_eq!(calendar_date_add_iso((2024, 1, 31), 0, 1, 0, 0, true), Ok((2024, 2, 29))); } + +#[test] +fn zoned_difference_day_correction() { + use super::zoned_date_time::{difference_zoned_datetime, get_epoch_nanos_for}; + + // 同日纯时间差:无日期部分,时间部分为纳秒差。 + let ns1 = get_epoch_nanos_for("America/New_York", (2025, 6, 15), 43_200_000_000_000).unwrap(); + let ns2 = get_epoch_nanos_for("America/New_York", (2025, 6, 15), 66_600_000_000_000).unwrap(); + let d = difference_zoned_datetime(ns1, ns2, "America/New_York", "iso8601", 3).unwrap(); + assert_eq!(d.date, [0.0, 0.0, 0.0, 0.0]); + assert_eq!(d.time_ns, 23_400_000_000_000); + + // 跨 DST 间隙日(23 小时):墙历时刻相同,日期部分 1 天、时间部分 0。 + let ns1 = get_epoch_nanos_for("America/New_York", (2025, 3, 8), 43_200_000_000_000).unwrap(); + let ns2 = get_epoch_nanos_for("America/New_York", (2025, 3, 9), 43_200_000_000_000).unwrap(); + let d = difference_zoned_datetime(ns1, ns2, "America/New_York", "iso8601", 3).unwrap(); + assert_eq!(d.date, [0.0, 0.0, 0.0, 1.0]); + assert_eq!(d.time_ns, 0); + + // dayCorrection 循环推进:12 小时跨间隙日,日期部分 0、时间部分 12 小时。 + let ns2b = get_epoch_nanos_for("America/New_York", (2025, 3, 9), 0).unwrap(); + let d = difference_zoned_datetime(ns1, ns2b, "America/New_York", "iso8601", 3).unwrap(); + assert_eq!(d.date, [0.0, 0.0, 0.0, 0.0]); + assert_eq!(d.time_ns, 43_200_000_000_000); + + // 反向(sign 为正):日期部分为负一天、时间部分 0。 + let d = difference_zoned_datetime(ns2, ns1, "America/New_York", "iso8601", 3).unwrap(); + assert_eq!(d.date, [0.0, 0.0, 0.0, -1.0]); + assert_eq!(d.time_ns, 0); +} + +#[test] +fn zoned_total_dst_fraction() { + use super::zoned_date_time::{difference_zoned_datetime_with_total, get_epoch_nanos_for}; + + // 整日差(23 小时间隙日):总量 1。 + let ns1 = get_epoch_nanos_for("America/New_York", (2025, 3, 8), 43_200_000_000_000).unwrap(); + let ns2 = get_epoch_nanos_for("America/New_York", (2025, 3, 9), 43_200_000_000_000).unwrap(); + let total = difference_zoned_datetime_with_total(ns1, ns2, "America/New_York", "iso8601", 3).unwrap(); + assert!((total - 1.0).abs() < 1e-12); + + // 12 小时跨 23 小时间隙日:总量 12/23(按实际日长折算,非 24 小时)。 + let ns2b = get_epoch_nanos_for("America/New_York", (2025, 3, 9), 0).unwrap(); + let total = difference_zoned_datetime_with_total(ns1, ns2b, "America/New_York", "iso8601", 3).unwrap(); + assert!((total - 12.0 / 23.0).abs() < 1e-12); +} + +#[test] +fn zoned_round_nudge_to_zoned_time() { + use super::duration::internal_duration_to_values; + use super::instant::InstantRoundingMode; + use super::zoned_date_time::{ + add_zoned_datetime_pure, difference_zoned_datetime_with_rounding, get_epoch_nanos_for, + }; + + // Apia 2011-12-30 整日跳过(24 小时间隙):起点墙历为 12-31 00:30。 + let origin = get_epoch_nanos_for("Pacific/Apia", (2011, 12, 30), 1_800_000_000_000).unwrap(); + + // P25H 按小时取整:跨间隙日不产生进位,保持 25 小时(非 1 天 1 小时)。 + let p25h = [0.0_f64, 0.0, 0.0, 0.0, 25.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let target = add_zoned_datetime_pure(origin, "Pacific/Apia", "iso8601", &p25h, true).unwrap(); + let internal = difference_zoned_datetime_with_rounding( + origin, + target, + "Pacific/Apia", + "iso8601", + 4, + 1, + 4, + InstantRoundingMode::Trunc, + ) + .unwrap(); + let values = internal_duration_to_values(&internal, 4); + assert_eq!(values, [0.0, 0.0, 0.0, 0.0, 25.0, 0.0, 0.0, 0.0, 0.0, 0.0]); + + // P1DT26H 按小时取整:日长 24 小时,取整后 2 天 2 小时。 + let p1dt26h = [0.0_f64, 0.0, 0.0, 1.0, 26.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let target = add_zoned_datetime_pure(origin, "Pacific/Apia", "iso8601", &p1dt26h, true).unwrap(); + let internal = difference_zoned_datetime_with_rounding( + origin, + target, + "Pacific/Apia", + "iso8601", + 3, + 1, + 4, + InstantRoundingMode::Trunc, + ) + .unwrap(); + let values = internal_duration_to_values(&internal, 4); + assert_eq!(values, [0.0, 0.0, 0.0, 2.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0]); +} + +#[test] +fn zoned_compare_pure_epoch() { + use super::zoned_date_time::{add_zoned_datetime_pure, get_epoch_nanos_for}; + + // 跨 23 小时间隙日:P1D(实际 23 小时)短于 P24H(实际 24 小时)。 + let origin = get_epoch_nanos_for("America/New_York", (2025, 3, 8), 43_200_000_000_000).unwrap(); + let p1d = [0.0_f64, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let p24h = [0.0_f64, 0.0, 0.0, 0.0, 24.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + let after1d = add_zoned_datetime_pure(origin, "America/New_York", "iso8601", &p1d, true).unwrap(); + let after24h = add_zoned_datetime_pure(origin, "America/New_York", "iso8601", &p24h, true).unwrap(); + assert!(after1d < after24h); +} diff --git a/crates/oxide_builtins/src/temporal/zoned_date_time.rs b/crates/oxide_builtins/src/temporal/zoned_date_time.rs index 2ec9d930..4b9b34f6 100644 --- a/crates/oxide_builtins/src/temporal/zoned_date_time.rs +++ b/crates/oxide_builtins/src/temporal/zoned_date_time.rs @@ -17,17 +17,19 @@ use super::common::{ zoned_date_time_plain_parts, FractionalSecondDigitsInput, MAX_INSTANT_NS, }; use super::difference::{ - add_days_iso, date_duration_sign, difference_core, parse_difference_settings, DAY_NS, MAX_ISO_DAY, + add_days_iso, bubble_relative_duration, compare_iso_date, date_duration_sign, date_until_iso, difference_core, + internal_duration_sign, nudge_to_calendar_unit, parse_difference_settings, round_instant_difference, + InternalDuration, DAY_NS, MAX_ISO_DAY, TIME_UNIT_NS, }; use super::instant::{ instant_rounding_mode, instant_string_without_annotations, parse_instant_string, primitive_to_bigint, - round_instant_ns, + round_instant_ns, InstantRoundingMode, }; use super::time_zone::{named_zone_possible_epoch_ns, tz_gap_offsets, zone_offset_seconds}; use super::{ days_in_month_iso, duration_like_values, duration_time_only_nanoseconds, is_leap_year_iso, make_duration, - make_plain_date, make_plain_date_time, make_plain_time, plain_time_components, plain_time_like_ns, valid_iso_date, - valid_plain_date_time_range, + make_plain_date, make_plain_date_time, make_plain_time, plain_time_components, plain_time_components_signed, + plain_time_like_ns, valid_iso_date, valid_plain_date_time_range, }; /// `Temporal.ZonedDateTime` 构造器:保存纪元纳秒、固定偏移或 UTC 时区以及 ISO 日历。 @@ -1835,12 +1837,7 @@ pub(crate) fn add_zoned_datetime_pure( return add_instant(epoch_ns, time_ns); } // GetISODateTimeFor:墙历分量。 - let offset_seconds = zone_offset_seconds(time_zone, epoch_ns.div_euclid(1_000_000_000) as i64) - .ok_or(AddZonedError::InvalidTimeZone)?; - let local_ns = epoch_ns + i128::from(offset_seconds) * 1_000_000_000; - let days = local_ns.div_euclid(DAY_NS); - let time_ns = local_ns.rem_euclid(DAY_NS); - let (year, month, day) = civil_from_days(days); + let ((year, month, day), time_ns) = get_iso_date_time_for(time_zone, epoch_ns)?; if !valid_plain_date_time_range(year as i32, month as u32, day as u32, time_ns as f64) { return Err(AddZonedError::OutOfDateTimeRange); } @@ -1868,10 +1865,16 @@ pub(crate) fn add_zoned_datetime_pure( } /// AddZonedDateTime 的 VM 包装:核心错误统一转 RangeError。 -fn add_zoned_datetime( +pub(crate) fn add_zoned_datetime( vm: &mut H, epoch_ns: i128, time_zone: &str, calendar: &str, values: &[f64; 10], constrain: bool, ) -> Result { - add_zoned_datetime_pure(epoch_ns, time_zone, calendar, values, constrain).map_err(|error| match error { + add_zoned_datetime_pure(epoch_ns, time_zone, calendar, values, constrain) + .map_err(|error| map_add_zoned_error(vm, error)) +} + +/// AddZonedError 到 JS RangeError 的映射(各 zoned 面 VM 包装共用)。 +fn map_add_zoned_error(vm: &mut H, error: AddZonedError) -> JsValue { + match error { AddZonedError::InvalidTimeZone => crate::error::create_range_error(vm, "invalid time zone"), AddZonedError::OutOfDateTimeRange => crate::error::create_range_error(vm, "invalid date-time"), AddZonedError::OutOfInstantRange => { @@ -1880,9 +1883,327 @@ fn add_zoned_datetime( AddZonedError::DayOutOfRange => crate::error::create_range_error(vm, "day out of range"), AddZonedError::InvalidDate => crate::error::create_range_error(vm, "invalid date"), AddZonedError::OutOfTimeRange => crate::error::create_range_error(vm, "duration is out of range"), + } +} + +/// DifferenceZonedDateTimeWithRounding 的 VM 包装:错误映射为 RangeError。 +#[allow(clippy::too_many_arguments)] +pub(crate) fn difference_zoned_datetime_with_rounding_vm( + vm: &mut H, ns1: i128, ns2: i128, time_zone: &str, calendar: &str, largest_unit: usize, increment: i128, + smallest_unit: usize, mode: InstantRoundingMode, +) -> Result { + difference_zoned_datetime_with_rounding(ns1, ns2, time_zone, calendar, largest_unit, increment, smallest_unit, mode) + .map_err(|error| map_add_zoned_error(vm, error)) +} + +/// DifferenceZonedDateTimeWithTotal 的 VM 包装:错误映射为 RangeError。 +pub(crate) fn difference_zoned_datetime_with_total_vm( + vm: &mut H, ns1: i128, ns2: i128, time_zone: &str, calendar: &str, unit: usize, +) -> Result { + difference_zoned_datetime_with_total(ns1, ns2, time_zone, calendar, unit) + .map_err(|error| map_add_zoned_error(vm, error)) +} + +/// GetISODateTimeFor(规范 11.1.10):epoch 对应的墙历日期时间。 +/// +/// # 步骤 +/// 1. 区偏移(epoch 依赖查找)加到 epoch 得墙历纳秒。 +/// 2. 拆日与时间(欧几里得除,负 epoch 同样正确)。 +/// +/// # 边界与前提 +/// - 时区名非法(不在区表且非固定偏移串)返回 InvalidTimeZone。 +pub(crate) fn get_iso_date_time_for( + time_zone: &str, epoch_ns: i128, +) -> Result<((i128, i128, i128), i128), AddZonedError> { + let offset_seconds = zone_offset_seconds(time_zone, epoch_ns.div_euclid(1_000_000_000) as i64) + .ok_or(AddZonedError::InvalidTimeZone)?; + let local_ns = epoch_ns + i128::from(offset_seconds) * 1_000_000_000; + let days = local_ns.div_euclid(DAY_NS); + let time_ns = local_ns.rem_euclid(DAY_NS); + let (year, month, day) = civil_from_days(days); + Ok(((year, month, day), time_ns)) +} + +/// GetEpochNanosecondsFor(规范 11.1.11,compatible 消歧):墙历日期时间的纪元。 +/// +/// # 步骤 +/// 1. 候选集(固定偏移区单候选;IANA 区 0/1/2 候选)。 +/// 2. compatible 消歧(重叠取早、间隙平移后取后侧)。 +/// +/// # 边界与前提 +/// - 日期年份越出 i32 表示范围视为越界(候选换算无意义)。 +/// - 候选超 Instant 范围或区表未命中返回对应错误。 +pub(crate) fn get_epoch_nanos_for( + time_zone: &str, date: (i128, i128, i128), time_ns: i128, +) -> Result { + if date.0 < i32::MIN as i128 || date.0 > i32::MAX as i128 { + return Err(AddZonedError::OutOfDateTimeRange); + } + let candidates = get_possible_epoch_nanoseconds(time_zone, date.0 as i32, date.1 as u32, date.2 as u32, time_ns)?; + disambiguate_possible_epoch_nanoseconds(time_zone, date, time_ns, "compatible", &candidates) +} + +/// zoned 面纪元换算闭包工厂:候选日期 → (日期, time1) 的 GetEpochNanosecondsFor(compatible) 纪元。 +fn zoned_epoch_of_date<'a>(time_zone: &'a str, time1_ns: i128) -> impl Fn((i128, i128, i128)) -> Result + 'a { + move |date| get_epoch_nanos_for(time_zone, date, time1_ns).map_err(|_| ()) +} + +/// 时间差纳秒的符号(-1/0/+1)。 +fn time_duration_sign(ns: i128) -> i128 { + if ns < 0 { + -1 + } else if ns > 0 { + 1 + } else { + 0 + } +} + +/// DifferenceZonedDateTime(规范 6.5.6):两个 epoch 的差,平衡后结果中无大于 +/// largestUnit 的非零单位,计入日历 reckoning 与时区偏移变化。 +/// +/// # 步骤 +/// 1. 同墙历日 → 纯时间差(ZeroDateDuration + 纳秒差)。 +/// 2. dayCorrection 循环:按 sign 平移终点日期,经 GetEpochNanosecondsFor(compatible) +/// 取候选点,直到时间差符号与总差符号相反。 +/// 3. 日期部分按 CalendarDateUntil(largestUnit 与 day 取大者)分解。 +/// +/// # 边界与前提 +/// - 全程 i128 纳秒;中间点时间取起点墙历时间(规范 (intermediateDate, startDT.Time))。 +/// - calendar 参数透传(引擎仅 iso8601 日历)。 +pub(crate) fn difference_zoned_datetime( + ns1: i128, ns2: i128, time_zone: &str, _calendar: &str, largest_unit: usize, +) -> Result { + if ns1 == ns2 { + return Ok(InternalDuration { date: [0.0; 4], time_ns: 0 }); + } + let (start_date, start_time) = get_iso_date_time_for(time_zone, ns1)?; + let (end_date, end_time) = get_iso_date_time_for(time_zone, ns2)?; + if compare_iso_date(start_date, end_date) == 0 { + return Ok(InternalDuration { + date: [0.0; 4], + time_ns: ns2 - ns1, + }); + } + let sign = if ns2 - ns1 < 0 { 1 } else { -1 }; + let max_day_correction = if sign == -1 { 2 } else { 1 }; + let mut day_correction = 0_i128; + let mut time_duration = end_time - start_time; + if time_duration_sign(time_duration) == sign { + day_correction += 1; + } + let mut success = false; + let mut intermediate_date = end_date; + while day_correction <= max_day_correction && !success { + intermediate_date = add_days_iso(end_date, day_correction * sign); + let intermediate_ns = get_epoch_nanos_for(time_zone, intermediate_date, start_time)?; + time_duration = ns2 - intermediate_ns; + if sign != time_duration_sign(time_duration) { + success = true; + } + day_correction += 1; + } + if !success { + return Err(AddZonedError::OutOfDateTimeRange); + } + let date_largest = largest_unit.min(3); + let date_values = date_until_iso(start_date, intermediate_date, date_largest); + Ok(InternalDuration { + date: [date_values[0], date_values[1], date_values[2], date_values[3]], + time_ns: time_duration, }) } +/// DifferenceZonedDateTimeWithRounding(规范 6.5.7):两个 epoch 的差按设置取整。 +/// +/// # 步骤 +/// 1. largestUnit 为时间单位 → DifferenceInstant(8.5.6:纳秒差按单位取整)。 +/// 2. 否则 DifferenceZonedDateTime;smallestUnit 为 nanosecond 且 increment 为 1 时直接返回。 +/// 3. 否则 RoundRelativeDuration(zoned 路由)。 +/// +/// # 边界与前提 +/// - smallestUnit 不大于 largestUnit(调用方已校验)。 +#[allow(clippy::too_many_arguments)] +pub(crate) fn difference_zoned_datetime_with_rounding( + ns1: i128, ns2: i128, time_zone: &str, _calendar: &str, largest_unit: usize, increment: i128, smallest_unit: usize, + mode: InstantRoundingMode, +) -> Result { + if largest_unit >= 4 { + let quantum = TIME_UNIT_NS[smallest_unit - 4] + .checked_mul(increment) + .ok_or(AddZonedError::OutOfTimeRange)?; + let rounded = round_instant_difference(ns2 - ns1, quantum, mode).ok_or(AddZonedError::OutOfTimeRange)?; + return Ok(InternalDuration { + date: [0.0; 4], + time_ns: rounded, + }); + } + let difference = difference_zoned_datetime(ns1, ns2, time_zone, _calendar, largest_unit)?; + if smallest_unit == 9 && increment == 1 { + return Ok(difference); + } + let (iso_date, iso_time) = get_iso_date_time_for(time_zone, ns1)?; + round_relative_duration_zoned( + difference, + ns1, + ns2, + (iso_date, iso_time), + time_zone, + largest_unit, + increment, + smallest_unit, + mode, + ) +} + +/// RoundRelativeDuration(规范 7.5.38)的 zoned 路由:日历单位(或 day)走日历 +/// 窗口取整,时间单位走 NudgeToZonedTime;扩窗后进位到 largestUnit。 +/// +/// # 步骤 +/// 1. 路由:smallestUnit 为日历单位或 day → NudgeToCalendarUnit(zoned 闭包); +/// 否则 NudgeToZonedTime。 +/// 2. 扩窗且 smallestUnit 非 week → BubbleRelativeDuration(startUnit 取 smallestUnit 与 day 的较大者)。 +#[allow(clippy::too_many_arguments)] +fn round_relative_duration_zoned( + difference: InternalDuration, origin_ns: i128, dest_ns: i128, iso: ((i128, i128, i128), i128), time_zone: &str, + largest_unit: usize, increment: i128, smallest_unit: usize, mode: InstantRoundingMode, +) -> Result { + let values = difference.to_values(); + let sign = if internal_duration_sign(&values) < 0 { -1 } else { 1 }; + let (iso_date, iso_time) = iso; + let epoch_of_date = zoned_epoch_of_date(time_zone, iso_time); + // zoned 面时区恒在场:irregular = smallestUnit 为日历单位或 day。 + let irregular = smallest_unit <= 3; + let (mut result, nudged_epoch, did_expand) = if irregular { + let outcome = nudge_to_calendar_unit( + sign, + &values, + origin_ns, + dest_ns, + iso_date, + increment, + smallest_unit, + mode, + &epoch_of_date, + ) + .map_err(|_| AddZonedError::OutOfDateTimeRange)?; + (outcome.duration, outcome.nudged_epoch, outcome.did_expand) + } else { + nudge_to_zoned_time(sign, &values, iso_date, iso_time, time_zone, increment, smallest_unit, mode)? + }; + if did_expand && smallest_unit != 2 { + let start_unit = smallest_unit.min(3); + result = + bubble_relative_duration(sign, result, nudged_epoch, iso_date, largest_unit, start_unit, &epoch_of_date) + .map_err(|_| AddZonedError::OutOfDateTimeRange)?; + } + Ok(InternalDuration { + date: [result[0], result[1], result[2], result[3]], + time_ns: duration_time_only_nanoseconds(&result).ok_or(AddZonedError::OutOfTimeRange)?, + }) +} + +/// NudgeToZonedTime(规范 7.5.35):相对 zoned 起点按时间单位取整,计入日长 +/// 可能非 24 小时(DST 日)的情形。 +/// +/// # 步骤 +/// 1. start = CalendarDateAdd(constrain);end = start 加 sign 天;两端点经 +/// GetEpochNanosecondsFor(compatible) 换算,daySpan 天然含 DST 日长。 +/// 2. 时间部分按 increment × 单位长度取整;越过日边界时记一天进位并重新取整余量。 +/// +/// # 边界与前提 +/// - unit 为时间单位(时到纳秒);day 单位走日历窗口路径,不进本函数。 +#[allow(clippy::too_many_arguments)] +fn nudge_to_zoned_time( + sign: i128, values: &[f64; 10], iso_date: (i128, i128, i128), iso_time: i128, time_zone: &str, increment: i128, + unit: usize, mode: InstantRoundingMode, +) -> Result<([f64; 10], i128, bool), AddZonedError> { + let time_ns = duration_time_only_nanoseconds(values).ok_or(AddZonedError::OutOfTimeRange)?; + let start = calendar_date_add_iso( + iso_date, + values[0] as i128, + values[1] as i128, + values[2] as i128, + values[3] as i128, + true, + )?; + let start_epoch = get_epoch_nanos_for(time_zone, start, iso_time)?; + let end_epoch = get_epoch_nanos_for(time_zone, add_days_iso(start, sign), iso_time)?; + let day_span = end_epoch - start_epoch; + let quantum = TIME_UNIT_NS[unit - 4] + .checked_mul(increment) + .ok_or(AddZonedError::OutOfTimeRange)?; + let mut rounded = round_instant_difference(time_ns, quantum, mode).ok_or(AddZonedError::OutOfTimeRange)?; + let beyond = rounded - day_span; + let (day_delta, nudged_epoch, did_round_beyond_day) = if time_duration_sign(beyond) != -sign { + rounded = round_instant_difference(beyond, quantum, mode).ok_or(AddZonedError::OutOfTimeRange)?; + (sign, end_epoch + rounded, true) + } else { + (0, start_epoch + rounded, false) + }; + let mut result = *values; + result[3] = (values[3] as i128 + day_delta) as f64; + let time_values = plain_time_components_signed(rounded); + for index in 0..6 { + result[4 + index] = time_values[index] as f64; + } + Ok((result, nudged_epoch, did_round_beyond_day)) +} + +/// DifferenceZonedDateTimeWithTotal(规范 6.5.8):两个 epoch 的差以 unit 计的总量。 +/// +/// # 步骤 +/// 1. unit 为时间单位 → TotalTimeDuration 快路径(纳秒差直接除单位长度)。 +/// 2. 否则 DifferenceZonedDateTime + TotalRelativeDuration。 +pub(crate) fn difference_zoned_datetime_with_total( + ns1: i128, ns2: i128, time_zone: &str, _calendar: &str, unit: usize, +) -> Result { + if unit >= 4 { + let difference = ns2 - ns1; + return Ok(difference as f64 / TIME_UNIT_NS[unit - 4] as f64); + } + let difference = difference_zoned_datetime(ns1, ns2, time_zone, _calendar, unit)?; + let (iso_date, iso_time) = get_iso_date_time_for(time_zone, ns1)?; + total_relative_duration(&difference, ns1, ns2, (iso_date, iso_time), time_zone, unit) +} + +/// TotalRelativeDuration(规范 7.5.39):时长以 unit 计的总量,相对起点折算。 +/// +/// # 步骤 +/// 1. unit 为日历单位或(时区在场且 unit 为 day)→ NudgeToCalendarUnit +/// (increment = 1、trunc)取 [[Total]]。 +/// 2. 否则 TotalTimeDuration(24h 日折算,zoned 面不可达)。 +fn total_relative_duration( + difference: &InternalDuration, origin_ns: i128, dest_ns: i128, iso: ((i128, i128, i128), i128), time_zone: &str, + unit: usize, +) -> Result { + let values = difference.to_values(); + // zoned 面时区恒在场:unit ≤ day 恒走日历窗口路径。 + if unit <= 3 { + let sign = if internal_duration_sign(&values) < 0 { -1 } else { 1 }; + let (iso_date, iso_time) = iso; + let epoch_of_date = zoned_epoch_of_date(time_zone, iso_time); + let outcome = nudge_to_calendar_unit( + sign, + &values, + origin_ns, + dest_ns, + iso_date, + 1, + unit, + InstantRoundingMode::Trunc, + &epoch_of_date, + ) + .map_err(|_| AddZonedError::OutOfDateTimeRange)?; + return Ok(outcome.total); + } + let time_ns = duration_time_only_nanoseconds(&values).ok_or(AddZonedError::OutOfTimeRange)?; + let with_days = time_ns + .checked_add((values[3] as i128) * DAY_NS) + .ok_or(AddZonedError::OutOfTimeRange)?; + Ok(with_days as f64 / TIME_UNIT_NS[unit - 4] as f64) +} + /// ZDT add/subtract 核心:读 receiver 槽后经 add_zoned_datetime 核心叠加 duration。 /// /// # 步骤