From 6dbf6be3a8795e6d42718981963b5072dc1f0a9f Mon Sep 17 00:00:00 2001 From: pjh456 <147148383@qq.com> Date: Fri, 16 Oct 2026 01:58:42 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20toZonedDateTime=20?= =?UTF-8?q?=E4=B8=8E=20getTimeZoneTransition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.toml | 2 +- crates/oxide_builtins/src/temporal/common.rs | 36 +++++ .../oxide_builtins/src/temporal/plain_date.rs | 77 ++++++++++- .../src/temporal/plain_date_time.rs | 80 +++++++++++- .../oxide_builtins/src/temporal/time_zone.rs | 32 ++++- .../src/temporal/zoned_date_time.rs | 123 +++++++++++++++++- crates/oxide_vm/src/bindings/bind_temporal.rs | 15 +++ .../oxide_vm/tests/builtins_temporal_tests.rs | 64 +++++++++ 8 files changed, 408 insertions(+), 21 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2e9cf753..5806041f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,7 @@ members = [ ] [workspace.package] -version = "0.19.21" +version = "0.19.22" edition = "2021" license = "MIT" rust-version = "1.80" diff --git a/crates/oxide_builtins/src/temporal/common.rs b/crates/oxide_builtins/src/temporal/common.rs index a43cacd1..08e12838 100644 --- a/crates/oxide_builtins/src/temporal/common.rs +++ b/crates/oxide_builtins/src/temporal/common.rs @@ -975,6 +975,42 @@ pub(crate) fn parse_time_zone_string(value: &str) -> Option { whole_minute_offset(offset_id).map(|_| offset_id.to_string()) } +/// ToTemporalTimeZoneIdentifier:ZDT 对象直返时区槽,字符串经 parse_time_zone_string +/// 规范化,其余值抛 TypeError。 +/// +/// # 步骤 +/// 1. ZDT 对象读槽 1(时区标识符)直接返回。 +/// 2. 字符串走 parse_time_zone_string(ParseTemporalTimeZoneString 语义, +/// 未命中 RangeError)。 +/// 3. 非字符串非 ZDT(含普通对象)TypeError。 +/// +/// # 边界与前提 +/// - 字符串接受面与规范一致:UTC/Z、整数分钟偏移串、IANA 区名、带注解与尾偏移的 +/// 日期时间串;亚分钟偏移拒绝。 +pub(crate) fn to_temporal_time_zone_identifier( + vm: &mut H, value: JsValue, +) -> Result { + if value.is_object() { + let ptr = value.as_js_object_ptr(); + if !ptr.is_null() { + let obj = unsafe { &*ptr }; + if obj.is_zoned_date_time_obj() { + let slot = obj.get_prop_at(1); + if slot.is_string() { + return Ok(to_string(slot)); + } + } + } + return Err(crate::error::create_type_error(vm, "invalid time zone")); + } + if value.is_string() { + let input = to_string(value); + return parse_time_zone_string(&input) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone")); + } + Err(crate::error::create_type_error(vm, "invalid time zone")) +} + pub(crate) fn civil_from_days(days: i128) -> (i128, i128, i128) { let shifted = days + 719_468; let era = shifted.div_euclid(146_097); diff --git a/crates/oxide_builtins/src/temporal/plain_date.rs b/crates/oxide_builtins/src/temporal/plain_date.rs index 69280c52..86e8514a 100644 --- a/crates/oxide_builtins/src/temporal/plain_date.rs +++ b/crates/oxide_builtins/src/temporal/plain_date.rs @@ -12,13 +12,17 @@ use super::common::{ parse_round_options, plain_date_time_object_parts, receiver_obj, reject_partial_object_with_calendar_or_time_zone, resolve_date_time_parts, temporal_calendar_id, temporal_calendar_id_strict, temporal_number_component, temporal_option_string, temporal_option_value, temporal_overflow, temporal_to_show_calendar, - zoned_date_time_plain_parts, DateTimeParts, ShowCalendar, + to_temporal_time_zone_identifier, zoned_date_time_plain_parts, DateTimeParts, ShowCalendar, }; use super::difference::{difference_core, parse_difference_settings, IsoDateTime}; use super::duration::duration_like_values; +use super::zoned_date_time::{ + disambiguate_possible_epoch_nanoseconds, get_possible_epoch_nanoseconds, get_start_of_day_epoch_ns, + map_add_zoned_error, +}; use super::{ - make_plain_date, make_plain_date_time, make_plain_month_day, make_plain_year_month, plain_time_like_ns, - round_instant_ns, + make_plain_date, make_plain_date_time, make_plain_month_day, make_plain_year_month, make_zoned_date_time, + plain_time_like_ns, round_instant_ns, }; /// 校验 ISO 日期分量(month 1-12、day 按月份与闰年,不依赖 chrono 年份范围)。 @@ -669,6 +673,73 @@ pub fn plain_date_to_plain_date_time(vm: &mut H, args: &[u8]) -> Nati make_plain_date_time(vm, year, month, day, time_ns, &calendar) } +/// `Temporal.PlainDate.prototype.toZonedDateTime(item)`:解析时区与 plainTime, +/// plainTime 缺省时走 GetStartOfDay 取当日首个有效墙历时刻的纪元。 +/// +/// # 步骤 +/// 1. receiver branding;读槽 0-3(年/月/日/日历)。 +/// 2. item 对象:先 Get timeZone(undefined 回落 item 本身),再 Get plainTime; +/// 非对象:timeZone 即 item,plainTime 取 undefined。 +/// 3. plainTime 为 undefined:GetStartOfDay(午夜候选为空时取间隙 transition 纪元)。 +/// 4. plainTime 在场:ToTemporalTime → ISODateTimeWithinLimits → +/// GetPossibleEpochNanoseconds + Disambiguate(compatible)。 +/// 5. make_zoned_date_time 收尾。 +/// +/// # 边界与前提 +/// - 读序:timeZone 先于 plainTime(语料 order-of-operations 钉死)。 +/// - 时区臂非字符串非 ZDT 是 TypeError,非法区名是 RangeError。 +pub fn plain_date_to_zoned_datetime(vm: &mut H, args: &[u8]) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_plain_date(vm, obj)); + let year = get_double_prop(obj, 0) as i32; + let month = get_double_prop(obj, 1) as u32; + let day = get_double_prop(obj, 2) as u32; + let calendar = get_calendar_id(obj, 3); + let item = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + // 读序:timeZone 先于 plainTime。 + let (time_zone_like, temporal_time) = if item.is_object() { + let ptr = item.as_js_object_ptr(); + if ptr.is_null() { + return NativeResult::Err(crate::error::create_type_error(vm, "invalid time zone")); + } + let item_obj = unsafe { &*ptr }; + let time_zone_like = native_try!(temporal_option_value(vm, item_obj, item, "timeZone")); + if time_zone_like.is_undefined() { + (item, JsValue::undefined()) + } else { + let temporal_time = native_try!(temporal_option_value(vm, item_obj, item, "plainTime")); + (time_zone_like, temporal_time) + } + } else { + (item, JsValue::undefined()) + }; + let time_zone_id = native_try!(to_temporal_time_zone_identifier(vm, time_zone_like)); + let time_ns = native_try!(plain_time_like_ns(vm, temporal_time)); + if temporal_time.is_undefined() { + let epoch = get_start_of_day_epoch_ns(&time_zone_id, year, month, day) + .map_err(|error| map_add_zoned_error(vm, error)); + let epoch_ns = native_try!(epoch); + return make_zoned_date_time(vm, epoch_ns, &time_zone_id, &calendar); + } + if !valid_plain_date_time_range(year, month, day, time_ns) { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid date-time")); + } + let candidates = get_possible_epoch_nanoseconds(&time_zone_id, year, month, day, time_ns as i128) + .map_err(|error| map_add_zoned_error(vm, error)); + let candidates = native_try!(candidates); + let epoch = disambiguate_possible_epoch_nanoseconds( + &time_zone_id, + (i128::from(year), i128::from(month), i128::from(day)), + time_ns as i128, + "compatible", + &candidates, + ) + .map_err(|error| map_add_zoned_error(vm, error)); + let epoch_ns = native_try!(epoch); + make_zoned_date_time(vm, epoch_ns, &time_zone_id, &calendar) +} + /// `Temporal.PlainDate.prototype.toPlainMonthDay()`:纯拷贝月/日,参考年固定 1972。 pub fn plain_date_to_plain_month_day(vm: &mut H, args: &[u8]) -> NativeResult { let ptr = native_try!(receiver_obj(vm, args)); diff --git a/crates/oxide_builtins/src/temporal/plain_date_time.rs b/crates/oxide_builtins/src/temporal/plain_date_time.rs index db8606b1..28d0842c 100644 --- a/crates/oxide_builtins/src/temporal/plain_date_time.rs +++ b/crates/oxide_builtins/src/temporal/plain_date_time.rs @@ -10,13 +10,16 @@ use super::{ civil_from_days, days_from_civil, days_in_month, days_in_month_iso, difference_core, duration_component_integer, duration_like_values, ensure_plain_date_time, format_iso_year, get_calendar_id, get_double_prop, initialize_temporal_receiver, instant_rounding_mode, is_ctor_call, is_leap_year_iso, make_plain_date, - make_plain_date_time, make_plain_time, parse_difference_settings, parse_fractional_second_digits, - parse_round_options, parse_temporal_string_impl, plain_date_time_object_parts, plain_time_components, - plain_time_like_ns, receiver_obj, reject_partial_object_with_calendar_or_time_zone, resolve_date_time_parts, - round_instant_ns, temporal_calendar_id, temporal_calendar_id_strict, temporal_number_component, - temporal_option_number, temporal_option_string, temporal_option_value, temporal_overflow, valid_iso_date, - valid_plain_date_time_range, valid_plain_time, zoned_date_time_plain_parts, DateTimeParts, - FractionalSecondDigitsInput, IsoDateTime, + make_plain_date_time, make_plain_time, make_zoned_date_time, parse_difference_settings, + parse_fractional_second_digits, parse_round_options, parse_temporal_string_impl, plain_date_time_object_parts, + plain_time_components, plain_time_like_ns, receiver_obj, reject_partial_object_with_calendar_or_time_zone, + resolve_date_time_parts, round_instant_ns, temporal_calendar_id, temporal_calendar_id_strict, + temporal_number_component, temporal_option_number, temporal_option_string, temporal_option_value, + temporal_overflow, to_temporal_time_zone_identifier, valid_iso_date, valid_plain_date_time_range, valid_plain_time, + zoned_date_time_plain_parts, DateTimeParts, FractionalSecondDigitsInput, IsoDateTime, +}; +use super::zoned_date_time::{ + disambiguate_possible_epoch_nanoseconds, get_possible_epoch_nanoseconds, map_add_zoned_error, }; // ───────────────────── PlainDateTime 基础方法 ───────────────────── @@ -531,6 +534,69 @@ pub fn plain_date_time_to_plain_time(vm: &mut H, args: &[u8]) -> Nati make_plain_time(vm, total_ns) } +/// `Temporal.PlainDateTime.prototype.toZonedDateTime(temporalTimeZoneLike, [options])`: +/// 解析时区与 disambiguation 后求墙历日期时间的纪元,返回新 ZDT。 +/// +/// # 步骤 +/// 1. receiver branding;读槽 0-3(年/月/日/时间纳秒)与槽 4 日历。 +/// 2. ToTemporalTimeZoneIdentifier(ZDT 对象 / 字符串 / 其余 TypeError)。 +/// 3. options 读 disambiguation(缺省 compatible;白名单校验),先于算法校验。 +/// 4. GetPossibleEpochNanoseconds + DisambiguatePossibleEpochNanoseconds。 +/// 5. make_zoned_date_time 收尾。 +/// +/// # 边界与前提 +/// - 读序:timeZone 先于 options(语料 order-of-operations 钉死)。 +/// - options 非对象原始值(含 null)TypeError;非法 disambiguation 值 RangeError。 +pub fn plain_date_time_to_zoned_datetime(vm: &mut H, args: &[u8]) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_plain_date_time(vm, obj)); + let year = get_double_prop(obj, 0) as i32; + let month = get_double_prop(obj, 1) as u32; + let day = get_double_prop(obj, 2) as u32; + let time_ns = get_double_prop(obj, 3); + let calendar = get_calendar_id(obj, 4); + let time_zone_like = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + let time_zone_id = native_try!(to_temporal_time_zone_identifier(vm, time_zone_like)); + // options:非对象原始值 TypeError;读 disambiguation(先于算法校验)。 + let options = if args.len() > 2 { vm.reg(args[2]) } else { JsValue::undefined() }; + let disambiguation = if options.is_undefined() { + "compatible".to_string() + } else { + if !options.is_object() { + return NativeResult::Err(crate::error::create_type_error(vm, "options must be an object")); + } + let ptr = options.as_js_object_ptr(); + if ptr.is_null() { + return NativeResult::Err(crate::error::create_type_error(vm, "options must be an object")); + } + let options_obj = unsafe { &*ptr }; + let raw = native_try!(temporal_option_value(vm, options_obj, options, "disambiguation")); + if raw.is_undefined() { + "compatible".to_string() + } else { + let value = native_try!(temporal_option_string(vm, raw)); + if !matches!(value.as_str(), "compatible" | "earlier" | "later" | "reject") { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid disambiguation")); + } + value + } + }; + let candidates = get_possible_epoch_nanoseconds(&time_zone_id, year, month, day, time_ns as i128) + .map_err(|error| map_add_zoned_error(vm, error)); + let candidates = native_try!(candidates); + let epoch = disambiguate_possible_epoch_nanoseconds( + &time_zone_id, + (i128::from(year), i128::from(month), i128::from(day)), + time_ns as i128, + &disambiguation, + &candidates, + ) + .map_err(|error| map_add_zoned_error(vm, error)); + let epoch_ns = native_try!(epoch); + make_zoned_date_time(vm, epoch_ns, &time_zone_id, &calendar) +} + /// `Temporal.PlainDateTime.prototype.withCalendar(calendar)`:只换日历槽,日期时间分量不变。 /// /// # 步骤 diff --git a/crates/oxide_builtins/src/temporal/time_zone.rs b/crates/oxide_builtins/src/temporal/time_zone.rs index c8f0a619..de5b6e96 100644 --- a/crates/oxide_builtins/src/temporal/time_zone.rs +++ b/crates/oxide_builtins/src/temporal/time_zone.rs @@ -1221,7 +1221,7 @@ pub(crate) fn named_zone_possible_epoch_ns(zone: &str, wall_ns: i128) -> Option< Some(candidates) } -/// 找 0 候选墙历所落间隙的 transition,返回间隙前后偏移(秒)。 +/// 找 0 候选墙历所落间隙的 transition:返回 (transition 纪元秒, 间隙前偏移, 间隙后偏移)。 /// /// # 步骤 /// 1. 墙历(按 UTC 解释)在 transition 前区间:查下一 transition 是否正偏移跳变 @@ -1229,13 +1229,12 @@ pub(crate) fn named_zone_possible_epoch_ns(zone: &str, wall_ns: i128) -> Option< /// 2. 墙历在 transition 后区间:查上一 transition 同条件。 /// /// # 边界与前提 -/// - 区表未命中或墙历不落在任何间隙内返回 `None`(调用方决定错误)。 -pub(crate) fn tz_gap_offsets(zone: &str, wall_ns: i128) -> Option<(i64, i64)> { - let z = tz_zone_data(zone)?; +/// - 墙历不落在任何间隙内返回 `None`(调用方决定错误)。 +/// - 间隙起点(墙历 = t + o_before)候选集为空,计入间隙。 +fn tz_gap_transition(z: &TzifZone, wall_ns: i128) -> Option<(i64, i64, i64)> { let wall_s = wall_ns.div_euclid(1_000_000_000) as i64; let idx = z.transitions.partition_point(|t| t.0 <= wall_s); // 墙历在 transition 前区间:间隙由下一 transition 产生。 - // 间隙起点(墙历 = t + o_before)候选集为空,计入间隙。 if idx < z.transitions.len() { let (t, offset_after) = z.transitions[idx]; let offset_before = if idx == 0 { z.lmt_offset } else { z.transitions[idx - 1].1 }; @@ -1243,7 +1242,7 @@ pub(crate) fn tz_gap_offsets(zone: &str, wall_ns: i128) -> Option<(i64, i64)> { && wall_ns >= i128::from(t) * 1_000_000_000 + i128::from(offset_before) * 1_000_000_000 && wall_ns < i128::from(t) * 1_000_000_000 + i128::from(offset_after) * 1_000_000_000 { - return Some((i64::from(offset_before), i64::from(offset_after))); + return Some((t, i64::from(offset_before), i64::from(offset_after))); } } // 墙历在 transition 后区间:间隙由上一 transition 产生。 @@ -1254,12 +1253,31 @@ pub(crate) fn tz_gap_offsets(zone: &str, wall_ns: i128) -> Option<(i64, i64)> { && wall_ns >= i128::from(t) * 1_000_000_000 + i128::from(offset_before) * 1_000_000_000 && wall_ns < i128::from(t) * 1_000_000_000 + i128::from(offset_after) * 1_000_000_000 { - return Some((i64::from(offset_before), i64::from(offset_after))); + return Some((t, i64::from(offset_before), i64::from(offset_after))); } } None } +/// 找 0 候选墙历所落间隙的 transition,返回间隙前后偏移(秒)。 +/// +/// # 边界与前提 +/// - 区表未命中或墙历不落在任何间隙内返回 `None`(调用方决定错误)。 +pub(crate) fn tz_gap_offsets(zone: &str, wall_ns: i128) -> Option<(i64, i64)> { + let z = tz_zone_data(zone)?; + let (_, before, after) = tz_gap_transition(z, wall_ns)?; + Some((before, after)) +} + +/// 找 0 候选墙历所落间隙的 transition 纪元秒(GetStartOfDay 间隙臂专用)。 +/// +/// # 边界与前提 +/// - 区表未命中或墙历不落在任何间隙内返回 `None`(调用方决定错误)。 +pub(crate) fn tz_gap_transition_epoch(zone: &str, wall_ns: i128) -> Option { + let z = tz_zone_data(zone)?; + Some(tz_gap_transition(z, wall_ns)?.0) +} + /// 统一时区偏移查找(epoch 依赖):固定偏移区(数值偏移串)走快速路径恒返常量, /// IANA 区走 transition 表二分。 /// diff --git a/crates/oxide_builtins/src/temporal/zoned_date_time.rs b/crates/oxide_builtins/src/temporal/zoned_date_time.rs index 467d074b..a5f26b3a 100644 --- a/crates/oxide_builtins/src/temporal/zoned_date_time.rs +++ b/crates/oxide_builtins/src/temporal/zoned_date_time.rs @@ -25,7 +25,10 @@ use super::instant::{ instant_rounding_mode, instant_string_without_annotations, parse_instant_string, primitive_to_bigint, round_instant_ns, InstantRoundingMode, }; -use super::time_zone::{named_zone_possible_epoch_ns, tz_gap_offsets, zone_offset_seconds}; +use super::time_zone::{ + named_zone_possible_epoch_ns, tz_gap_offsets, tz_gap_transition_epoch, tz_next_transition, + tz_prev_transition, 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_components_signed, @@ -1621,6 +1624,89 @@ pub fn zoned_date_time_offset_nanoseconds(vm: &mut H, args: &[u8]) -> NativeResult::Ok(JsValue::float(offset_seconds as f64 * 1_000_000_000.0)) } +/// `Temporal.ZonedDateTime.prototype.getTimeZoneTransition(directionParam)`: +/// 查 next/previous 时间区 transition,返回 transition 纪元处的新 ZDT; +/// 固定偏移区与无 transition 返回 null。 +/// +/// # 步骤 +/// 1. receiver branding;读槽 0(纪元)/1(时区)/2(日历)。 +/// 2. directionParam:undefined → TypeError;字符串直接取值;对象读 +/// direction 属性;其余原始值 → TypeError。 +/// 3. GetDirectionOption:缺省/undefined → RangeError;ToString(Symbol → +/// TypeError);非 next/previous → RangeError。 +/// 4. 固定偏移区 → null。 +/// 5. next 取严格大于当前纪元的 transition,previous 取严格小于的; +/// next 超上界(t > nsMaxInstant)或 previous 超下界(t < nsMinInstant) +/// 或无 transition → null。 +/// 6. make_zoned_date_time 收尾。 +/// +/// # 边界与前提 +/// - 读序先于固定偏移判定(direction 读取在 IsOffsetTimeZoneIdentifier 之前)。 +pub fn zoned_date_time_get_time_zone_transition(vm: &mut H, args: &[u8]) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_zoned_date_time(vm, obj)); + let time_zone_id = to_string(obj.get_prop_at(1)); + let calendar = get_calendar_id(obj, 2); + let Some(epoch_ns) = get_instant_epoch_ns(obj) else { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid ZonedDateTime")); + }; + let direction_param = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + if direction_param.is_undefined() { + return NativeResult::Err(crate::error::create_type_error(vm, "direction is required")); + } + // 读序:direction 先于固定偏移判定。字符串包装 bag 后取值即原串; + // 对象走 Get(getter 可观察)。 + let direction_raw = if direction_param.is_string() { + direction_param + } else if direction_param.is_object() { + let ptr = direction_param.as_js_object_ptr(); + if ptr.is_null() { + return NativeResult::Err(crate::error::create_type_error( + vm, + "direction must be a string or an object", + )); + } + let options = unsafe { &*ptr }; + native_try!(temporal_option_value(vm, options, direction_param, "direction")) + } else { + return NativeResult::Err(crate::error::create_type_error( + vm, + "direction must be a string or an object", + )); + }; + let direction = if direction_raw.is_undefined() { + return NativeResult::Err(crate::error::create_range_error(vm, "direction is required")); + } else { + let value = native_try!(temporal_option_string(vm, direction_raw)); + if !matches!(value.as_str(), "next" | "previous") { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid direction")); + } + value + }; + // 固定偏移区:无 transition。 + if fixed_offset_seconds(&time_zone_id).is_some() { + return NativeResult::Ok(JsValue::null()); + } + let epoch_s = epoch_ns.div_euclid(1_000_000_000) as i64; + let transition_s = if direction == "next" { + tz_next_transition(&time_zone_id, epoch_s) + } else { + tz_prev_transition(&time_zone_id, epoch_s) + }; + let Some(transition_s) = transition_s else { + return NativeResult::Ok(JsValue::null()); + }; + let transition_ns = i128::from(transition_s) * 1_000_000_000; + // 规范 11.1.3/11.1.4:next 要求 t ≤ nsMaxInstant,previous 要求 t ≥ nsMinInstant。 + if (direction == "next" && transition_ns > MAX_INSTANT_NS) + || (direction == "previous" && transition_ns < -MAX_INSTANT_NS) + { + return NativeResult::Ok(JsValue::null()); + } + make_zoned_date_time(vm, transition_ns, &time_zone_id, &calendar) +} + /// `Temporal.ZonedDateTime.prototype.monthCode`:ISO 日历下恒为 `M{month:02}` 补零格式。 pub fn zoned_date_time_month_code(vm: &mut H, args: &[u8]) -> NativeResult { let ptr = native_try!(receiver_obj(vm, args)); @@ -1659,6 +1745,33 @@ pub(crate) fn start_of_day_epoch_ns_by_days(days: i128, offset_seconds: i64) -> (start.unsigned_abs() <= MAX_INSTANT_NS as u128).then_some(start) } +/// GetStartOfDay(规范 11.1.14):当地日期首个有效墙历时刻的纪元纳秒。 +/// +/// # 步骤 +/// 1. 枚举午夜候选集(GetPossibleEpochNanoseconds):非空取首(固定偏移区候选唯一)。 +/// 2. 候选集为空(午夜落 DST 间隙):取间隙 transition 纪元 t——间隙 +/// [t + oBefore, t + oAfter) 中首个有效墙历 t + oAfter 恰对应纪元 t。 +/// +/// # 边界与前提 +/// - 不复用 start_of_day_epoch_ns(naive `days*DAY_NS - offset`,午夜落间隙时 +/// 返回 transition 前墙历,合规范错误)。 +/// - 候选超 Instant 范围抛 OutOfInstantRange;transition 纪元超范围同理。 +pub(crate) fn get_start_of_day_epoch_ns( + time_zone: &str, year: i32, month: u32, day: u32, +) -> Result { + let candidates = get_possible_epoch_nanoseconds(time_zone, year, month, day, 0)?; + if let Some(&first) = candidates.first() { + return Ok(first); + } + let wall_ns = days_from_civil(i128::from(year), i128::from(month), i128::from(day)) * DAY_NS; + let transition = tz_gap_transition_epoch(time_zone, wall_ns).ok_or(AddZonedError::OutOfDateTimeRange)?; + let epoch = i128::from(transition) * 1_000_000_000; + if epoch.unsigned_abs() > MAX_INSTANT_NS as u128 { + return Err(AddZonedError::OutOfInstantRange); + } + Ok(epoch) +} + /// AddZonedDateTime 核心的错误分类(包装层统一转 RangeError)。 #[derive(Debug, PartialEq, Eq)] pub(crate) enum AddZonedError { @@ -1749,14 +1862,18 @@ pub(crate) fn get_possible_epoch_nanoseconds( /// DisambiguatePossibleEpochNanoseconds(规范 11.1.12):从候选集选唯一纪元。 /// /// # 步骤 -/// 1. 1 候选 → 直接返回。 +/// 1. 1 候选 → 直接返回(含 reject,规范 2a 在 reject 判定之前)。 /// 2. 2 候选(重叠)→ compatible/earlier 取较早纪元,later 取较晚,reject 抛 RangeError。 /// 3. 0 候选(间隙)→ 按间隙前后偏移差平移墙历(earlier 反向、compatible/later 正向), -/// 重枚举候选集后取首(earlier)/末(compatible、later)。 +/// 重枚举候选集后取首(earlier)/末(compatible、later);reject 抛 RangeError。 pub(crate) fn disambiguate_possible_epoch_nanoseconds( time_zone: &str, iso_date: (i128, i128, i128), time_ns: i128, disambiguation: &str, candidates: &[i128], ) -> Result { if !candidates.is_empty() { + // 单候选(固定偏移区 / 无歧义墙历):直接返回,含 reject 也不抛。 + if candidates.len() == 1 { + return Ok(candidates[0]); + } if disambiguation == "reject" { return Err(AddZonedError::OutOfDateTimeRange); } diff --git a/crates/oxide_vm/src/bindings/bind_temporal.rs b/crates/oxide_vm/src/bindings/bind_temporal.rs index bf4c6339..6d54c70d 100644 --- a/crates/oxide_vm/src/bindings/bind_temporal.rs +++ b/crates/oxide_vm/src/bindings/bind_temporal.rs @@ -464,6 +464,11 @@ pub fn bind_temporal(env: BindEnv<'_>, global: &mut JsObject) { oxide_builtins::temporal::zoned_date_time_start_of_day:: as *const (), 0, ), + ( + "getTimeZoneTransition", + oxide_builtins::temporal::zoned_date_time_get_time_zone_transition:: as *const (), + 1, + ), ], ); @@ -628,6 +633,11 @@ pub fn bind_temporal(env: BindEnv<'_>, global: &mut JsObject) { oxide_builtins::temporal::plain_date_to_plain_year_month:: as *const (), 0, ), + ( + "toZonedDateTime", + oxide_builtins::temporal::plain_date_to_zoned_datetime:: as *const (), + 1, + ), ("round", oxide_builtins::temporal::plain_date_round:: as *const (), 1), ], ); @@ -854,6 +864,11 @@ pub fn bind_temporal(env: BindEnv<'_>, global: &mut JsObject) { oxide_builtins::temporal::plain_date_time_to_plain_time:: as *const (), 0, ), + ( + "toZonedDateTime", + oxide_builtins::temporal::plain_date_time_to_zoned_datetime:: as *const (), + 1, + ), ( "valueOf", oxide_builtins::temporal::plain_date_time_value_of:: as *const (), diff --git a/crates/oxide_vm/tests/builtins_temporal_tests.rs b/crates/oxide_vm/tests/builtins_temporal_tests.rs index f57f4525..7d9f932c 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -5017,3 +5017,67 @@ fn now_iso_method_bindings_are_plain_functions() { .unwrap(); assert_eq!(str_val(&vm, r), "true|true|true|true|true"); } + +// -- toZonedDateTime / getTimeZoneTransition 回归钉 -- + +#[test] +fn plain_date_to_zoned_datetime_fixed_offset_and_errors() { + let mut vm = Vm::new(); + // 固定偏移区:GetStartOfDay 取当地午夜(墙历减偏移),候选唯一; + // reject 对单候选不抛(规范 2a 在 reject 判定之前);缺区 / 非法区抛错。 + let r = eval( + &mut vm, + "(new Temporal.PlainDate(2019, 2, 16).toZonedDateTime('+03:30').epochNanoseconds === 1550262600000000000n) + '|' + + (() => { try { return new Temporal.PlainDate(2019, 2, 16).toZonedDateTime('+03:30', { disambiguation: 'reject' }).epochNanoseconds === 1550262600000000000n; } + catch (e) { return false; } })() + '|' + + (() => { try { new Temporal.PlainDate(2019, 2, 16).toZonedDateTime(); return 'no'; } + catch (e) { return e instanceof TypeError; } })() + '|' + + (() => { try { new Temporal.PlainDate(2019, 2, 16).toZonedDateTime('Not/AZone'); return 'no'; } + catch (e) { return e instanceof RangeError; } })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true|true|true"); +} + +#[test] +fn plain_date_time_to_zoned_datetime_fixed_offset_and_errors() { + let mut vm = Vm::new(); + // 固定偏移区:墙历减偏移得唯一候选;reject 对单候选不抛(曾误抛 RangeError); + // options 为原始值 / 非法 disambiguation 抛错。 + let r = eval( + &mut vm, + "(new Temporal.PlainDateTime(2019, 2, 16, 23, 45).toZonedDateTime('+03:30').epochNanoseconds === 1550348100000000000n) + '|' + + (() => { try { return new Temporal.PlainDateTime(2019, 2, 16, 23, 45).toZonedDateTime('+03:30', { disambiguation: 'reject' }).epochNanoseconds === 1550348100000000000n; } + catch (e) { return false; } })() + '|' + + (() => { try { new Temporal.PlainDateTime(2019, 2, 16, 23, 45).toZonedDateTime('+03:30', 'earlier'); return 'no'; } + catch (e) { return e instanceof TypeError; } })() + '|' + + (() => { try { new Temporal.PlainDateTime(2019, 2, 16, 23, 45).toZonedDateTime('+03:30', { disambiguation: 'sideways' }); return 'no'; } + catch (e) { return e instanceof RangeError; } })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true|true|true"); +} + +#[test] +fn zdt_get_time_zone_transition_fixed_and_errors() { + let mut vm = Vm::new(); + // 固定偏移区无 transition → null;缺 direction / 非法值 / 对象缺 direction 抛错; + // IANA 区 next/previous 严格大于/小于当前纪元,对象形式与字符串形式等价。 + let r = eval( + &mut vm, + "(new Temporal.ZonedDateTime(1000n, '+03:30').getTimeZoneTransition('next') === null) + '|' + + (() => { try { new Temporal.ZonedDateTime(1000n, 'America/Santiago').getTimeZoneTransition(); return 'no'; } + catch (e) { return e instanceof TypeError; } })() + '|' + + (() => { try { new Temporal.ZonedDateTime(1000n, 'America/Santiago').getTimeZoneTransition('sideways'); return 'no'; } + catch (e) { return e instanceof RangeError; } })() + '|' + + (() => { try { new Temporal.ZonedDateTime(1000n, 'America/Santiago').getTimeZoneTransition({}); return 'no'; } + catch (e) { return e instanceof RangeError; } })() + '|' + + (() => { const z = new Temporal.ZonedDateTime(1520817600000000000n, 'America/Santiago'); + return z.getTimeZoneTransition('next').epochNanoseconds > z.epochNanoseconds && + z.epochNanoseconds > z.getTimeZoneTransition('previous').epochNanoseconds; })() + '|' + + (() => { const z = new Temporal.ZonedDateTime(1520817600000000000n, 'America/Santiago'); + return z.getTimeZoneTransition({ direction: 'next' }).epochNanoseconds === z.getTimeZoneTransition('next').epochNanoseconds; })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true|true|true|true|true"); +}