diff --git a/crates/oxide_builtins/src/temporal/duration.rs b/crates/oxide_builtins/src/temporal/duration.rs index 75f7d425..9abbba1f 100644 --- a/crates/oxide_builtins/src/temporal/duration.rs +++ b/crates/oxide_builtins/src/temporal/duration.rs @@ -8,8 +8,8 @@ use oxide_types::value::JsValue; use super::common::native_try; use super::common::{ canonical_time_zone, civil_from_days, days_from_civil, ensure_duration, get_double_prop, get_instant_epoch_ns, - initialize_temporal_receiver, is_ctor_call, native_engine_error, plain_date_time_object_parts, receiver_obj, - temporal_option_number, temporal_option_string, temporal_option_value, zoned_date_time_plain_parts, + initialize_temporal_receiver, is_ctor_call, local_to_epoch_ns, native_engine_error, plain_date_time_object_parts, + receiver_obj, temporal_option_number, temporal_option_string, temporal_option_value, 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, @@ -17,7 +17,9 @@ use super::difference::{ }; use super::instant::{instant_rounding_mode, InstantRoundingMode}; use super::time_zone::zone_offset_seconds; -use super::zoned_date_time::{parse_any_offset_seconds, valid_offset_fraction, zoned_date_time_string_parts}; +use super::zoned_date_time::{ + 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}; fn parse_duration_string(input: &str) -> Option<[f64; 10]> { @@ -285,16 +287,18 @@ fn duration_values(obj: &JsObject) -> [f64; 10] { /// 归一 options.relativeTo 为相对日期 `(y, m, d)`。 /// /// # 步骤 -/// 1. string → parse_plain_date_time_string,失败回退 parse_plain_date_string(PDT 优先、PD 回退);失败 RangeError。 +/// 1. string 先检测 `[tz]` 注解:含注解走 zoned 解析(offset-vs-zone 校验,reject 语义); +/// 无注解走 plain 解析(纯日期、日期时间、带偏移的 plain 串)。 /// 2. PlainDateTime / PlainDate 对象 → 直读 prop 0/1/2。 /// 3. ZonedDateTime → zoned_date_time_plain_parts 取本地 (y,m,d)。 /// 4. Instant → epoch 纳秒按 UTC 分解(div_euclid(DAY_NS) → civil_from_days)。 -/// 5. 其他对象按 property bag 解析(ToTemporalDateTime,constrain)。 +/// 5. 其他对象按 property bag 解析(ToRelativeTemporalObject,constrain)。 /// 6. undefined → None;其余原始值 → TypeError。 /// /// # 边界与前提 /// - 调用方须已把 raw 从 options 取出(读序由调用方保证)。 /// - 本函数只取日期分量;时间分量由调用方的 duration 分量另行加。 +/// - bag 支 offset 语法校验先于数值字段转换;offset-vs-zone 匹配校验在字段转换之后。 fn duration_relative_to_date( vm: &mut H, relative_raw: JsValue, ) -> Result, JsValue> { @@ -303,18 +307,21 @@ fn duration_relative_to_date( } if relative_raw.is_string() { let text = to_string(relative_raw); - // 先按 PlainDateTime/PlainDate 解析(纯日期、日期时间、带 offset/注解的 plain 串)。 + // 含 [tz] 注解走 zoned 路径(offset-vs-zone 校验,reject 语义): + // 按 instant 解析 + 注解时区偏移(epoch 依赖查找)反推墙历日期。 + if extract_time_zone_annotation(&text).is_some() { + let (epoch_ns, time_zone_id, _calendar) = zoned_date_time_string_parts(vm, &text, "reject")?; + let offset_seconds = zone_offset_seconds(&time_zone_id, epoch_ns.div_euclid(1_000_000_000) as i64) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; + let wall_ns = epoch_ns + i128::from(offset_seconds) * 1_000_000_000; + let (year, month, day) = civil_from_days(wall_ns.div_euclid(DAY_NS)); + return Ok(Some((year, month, day))); + } + // 无注解走 plain 路径:纯日期、日期时间、带偏移的 plain 串(偏移不作时区标识符)。 if let Ok((year, month, day, _time_ns)) = parse_plain_date_time_string(&text) { return Ok(Some((i128::from(year), i128::from(month), i128::from(day)))); } - // 回退支:plain 首支解析失败的串(含 Z 的串、IANA 区注解串)。 - // 按 instant 解析 + 注解时区偏移(epoch 依赖查找)反推墙历日期。 - let (epoch_ns, time_zone_id, _calendar) = zoned_date_time_string_parts(vm, &text, "reject")?; - let offset_seconds = zone_offset_seconds(&time_zone_id, epoch_ns.div_euclid(1_000_000_000) as i64) - .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; - let wall_ns = epoch_ns + i128::from(offset_seconds) * 1_000_000_000; - let (year, month, day) = civil_from_days(wall_ns.div_euclid(DAY_NS)); - return Ok(Some((year, month, day))); + return Err(crate::error::create_range_error(vm, "invalid relativeTo")); } if !relative_raw.is_object() { return Err(crate::error::create_type_error(vm, "invalid relativeTo")); @@ -348,27 +355,42 @@ fn duration_relative_to_date( return Err(crate::error::create_type_error(vm, "invalid relativeTo")); } let time_zone_raw = temporal_option_value(vm, obj, relative_raw, "timeZone")?; - if !time_zone_raw.is_undefined() { + let time_zone_id = if time_zone_raw.is_undefined() { + None + } else { // 非字符串(对象/符号/null/数字)→ TypeError;字符串非合法时区 → RangeError。 if !time_zone_raw.is_string() { return Err(crate::error::create_type_error(vm, "invalid time zone")); } - if canonical_time_zone(&to_string(time_zone_raw)).is_none() { - return Err(crate::error::create_range_error(vm, "invalid time zone")); - } - } + let tz_input = to_string(time_zone_raw); + Some(canonical_time_zone(&tz_input).ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?) + }; let offset_raw = temporal_option_value(vm, obj, relative_raw, "offset")?; - if !offset_raw.is_undefined() { - // 非字符串 → TypeError;字符串格式非法(含亚秒偏移)→ RangeError。 + let bag_offset_seconds = if offset_raw.is_undefined() { + None + } else { + // 非字符串 → TypeError;字符串格式非法(含亚秒偏移位数超限)→ RangeError。 if !offset_raw.is_string() { return Err(crate::error::create_type_error(vm, "invalid offset")); } let offset_input = to_string(offset_raw); - if parse_any_offset_seconds(&offset_input).is_none() || !valid_offset_fraction(&offset_input) { - return Err(crate::error::create_range_error(vm, "invalid offset")); + let (seconds, _has_sub_minute) = parse_any_offset_seconds(&offset_input) + .filter(|_| valid_offset_fraction(&offset_input)) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid offset"))?; + Some(seconds) + }; + let (year, month, day, time_ns, _calendar) = plain_date_time_object_parts(vm, relative_raw, obj, true, false)?; + // offset-vs-zone 校验(对象支精确一致、reject 语义):offset 与 timeZone 同时在场时, + // 候选 epoch 点 zone 偏移须与输入偏移秒级相等。 + if let (Some(offset), Some(tz_id)) = (bag_offset_seconds, time_zone_id.as_deref()) { + let candidate_ns = local_to_epoch_ns(year, month, day, time_ns, 0) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid date-time"))?; + let zone_offset = zone_offset_seconds(tz_id, candidate_ns.div_euclid(1_000_000_000) as i64) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; + if zone_offset != offset { + return Err(crate::error::create_range_error(vm, "offset and time zone disagree")); } } - let (year, month, day, _time_ns, _calendar) = plain_date_time_object_parts(vm, relative_raw, obj, true, false)?; Ok(Some((i128::from(year), i128::from(month), i128::from(day)))) } diff --git a/crates/oxide_builtins/src/temporal/tests.rs b/crates/oxide_builtins/src/temporal/tests.rs index ea443451..db202dc2 100644 --- a/crates/oxide_builtins/src/temporal/tests.rs +++ b/crates/oxide_builtins/src/temporal/tests.rs @@ -176,11 +176,14 @@ fn canonical_time_zone_4_digit_offset() { #[test] fn offset_seconds_representation_sub_minute() { - // i64 秒表示保留亚分钟偏移(±HH:MM:SS 秒位),旧分钟表示丢弃秒位。 - assert_eq!(parse_any_offset_seconds("+00:44:30"), Some(2670)); - assert_eq!(parse_any_offset_seconds("-00:44:30"), Some(-2670)); - assert_eq!(parse_any_offset_seconds("+01:00:00"), Some(3600)); - assert_eq!(parse_any_offset_seconds("-23:59:59"), Some(-(23 * 3600 + 59 * 60 + 59))); + // i64 秒表示保留亚分钟偏移(±HH:MM:SS 秒位),旧分钟表示丢弃秒位; + // 第二分量标记秒分量在场(亚分钟精度,比较口径切精确一致)。 + assert_eq!(parse_any_offset_seconds("+00:44:30"), Some((2670, true))); + assert_eq!(parse_any_offset_seconds("-00:44:30"), Some((-2670, true))); + assert_eq!(parse_any_offset_seconds("+01:00:00"), Some((3600, true))); + assert_eq!(parse_any_offset_seconds("-23:59:59"), Some((-(23 * 3600 + 59 * 60 + 59), true))); + assert_eq!(parse_any_offset_seconds("+00:45"), Some((2700, false))); + assert_eq!(parse_any_offset_seconds("-00:45:00"), Some((-2700, true))); // 固定偏移秒值与分钟值 ×60 一致(±HH:MM 六字符形)。 assert_eq!(canonical_time_zone("+01:00"), Some("+01:00".to_string())); assert_eq!(canonical_time_zone("-05:30"), Some("-05:30".to_string())); @@ -433,3 +436,68 @@ fn sub_minute_offset_getter_branch() { Some("1969-12-31T23:15:30-00:44:30".to_string()) ); } + +#[test] +fn round_offset_to_minutes_half_expand() { + // 半值远离零:正负两侧对称,整分钟偏移不变;半分钟边界(±30 秒)向上舍入。 + assert_eq!(round_offset_to_minutes(2700), 2700); + assert_eq!(round_offset_to_minutes(-2700), -2700); + assert_eq!(round_offset_to_minutes(2670), 2700); // +00:44:30 → +00:45 + assert_eq!(round_offset_to_minutes(-2670), -2700); // -00:44:30 → -00:45 + assert_eq!(round_offset_to_minutes(2640), 2640); + assert_eq!(round_offset_to_minutes(2669), 2640); // 差 1 秒不到半分钟,仍舍入到 +00:44 + assert_eq!(round_offset_to_minutes(-2669), -2640); + assert_eq!(round_offset_to_minutes(0), 0); +} + +#[test] +fn offset_matches_exact_and_minutes() { + // 精确口径:秒级相等才成立。 + assert!(offset_matches(2700, 2700, true)); + assert!(!offset_matches(2670, 2700, true)); + assert!(!offset_matches(2670, 2669, true)); + // 分钟口径:zone 偏移舍入到分钟后与输入相等;精确相等是其子集。 + assert!(offset_matches(2700, 2700, false)); + assert!(offset_matches(2670, 2700, false)); // zone 亚分钟 -00:44:30 舍入后接受分钟输入 + assert!(offset_matches(-2670, -2700, false)); + assert!(!offset_matches(2670, 2640, false)); // 舍入到 2700,与 2640 不符 + assert!(!offset_matches(0, 19800, false)); // UTC 区对 +05:30 输入 +} + +#[test] +fn zone_aware_string_offset_vs_zone() { + // zone-aware 创建解析(纯函数层):注解经 canonical_time_zone 得规范名, + // 候选 epoch 点 zone 偏移经 zone_offset_seconds 读取,offset-vs-zone 判定走 offset_matches。 + // 固定偏移区退化情形:zone 偏移恒为常量,分钟输入精确一致。 + let tz = canonical_time_zone("+05:30").unwrap(); + let zone = zone_offset_seconds(&tz, 0).unwrap(); + let (offset, has_sub) = parse_any_offset_seconds("+05:30").unwrap(); + assert_eq!(offset, 19800); + assert!(!has_sub); + assert!(offset_matches(zone, offset, has_sub)); + // 亚分钟输入走精确口径:zone 偏移 -00:44:30 与输入 -00:44:30 秒级相等。 + let (offset, has_sub) = parse_any_offset_seconds("-00:44:30").unwrap(); + assert!(has_sub); + assert_eq!(offset, -2670); + assert!(offset_matches(-2670, offset, has_sub)); + // IANA 区亚分钟历史偏移:Monrovia 1970 年 zone 偏移 -00:44:30, + // 分钟输入 -00:45 经舍入口径接受,亚分钟输入 -00:44:30 精确一致、-00:44:31 精确口径拒绝。 + let zone = zone_offset_seconds("Africa/Monrovia", 0).unwrap(); + assert_eq!(zone, -2670); + let (minute_input, has_sub) = parse_any_offset_seconds("-00:45").unwrap(); + assert!(!has_sub); + assert!(offset_matches(zone, minute_input, has_sub)); + let (sub_input, has_sub) = parse_any_offset_seconds("-00:44:30").unwrap(); + assert!(has_sub); + assert!(offset_matches(zone, sub_input, has_sub)); + let (sub_input, has_sub) = parse_any_offset_seconds("-00:44:31").unwrap(); + assert!(has_sub); + assert!(!offset_matches(zone, sub_input, has_sub)); + // 注解提取:IANA 区注解命中,key 注解(u-ca)跳过,亚分钟偏移注解拒作时区标识符。 + assert_eq!( + extract_time_zone_annotation("2000-01-01T00:00[Africa/Monrovia]"), + Some(("Africa/Monrovia".to_string(), false)) + ); + assert_eq!(extract_time_zone_annotation("2000-01-01T00:00[u-ca=iso8601]"), None); + assert_eq!(extract_time_zone_annotation("2000-01-01T00:00[-00:44:30]"), None); +} diff --git a/crates/oxide_builtins/src/temporal/zoned_date_time.rs b/crates/oxide_builtins/src/temporal/zoned_date_time.rs index daf0e08d..64c6b733 100644 --- a/crates/oxide_builtins/src/temporal/zoned_date_time.rs +++ b/crates/oxide_builtins/src/temporal/zoned_date_time.rs @@ -211,7 +211,7 @@ fn zoned_date_time_options( /// 从剥注解后的 Instant 主体提取字符串内数值偏移秒数(±HH / ±HHMM / ±HH:MM / 亚秒形式)。 /// Z/z 结尾视为 0 秒;无偏移或偏移不可提取返回 None。 -fn extract_string_offset_seconds(body: &str) -> Option { +fn extract_string_offset_seconds(body: &str) -> Option<(i64, bool)> { let time_start = body.find(['T', 't', ' '])?; let time = &body[time_start + 1..]; let offset_start = time @@ -221,8 +221,9 @@ fn extract_string_offset_seconds(body: &str) -> Option { parse_any_offset_seconds(&time[offset_start..]) } -/// 解析数值偏移串(±HH / ±HHMM / ±HH:MM / ±HHMMSS / ±HH:MM:SS,可带小数秒)为秒数。 -pub(crate) fn parse_any_offset_seconds(value: &str) -> Option { +/// 解析数值偏移串(±HH / ±HHMM / ±HH:MM / ±HHMMSS / ±HH:MM:SS,可带小数秒)。 +/// 返回 (秒数, 是否含秒分量):秒分量在场即亚分钟精度,比较口径为精确一致。 +pub(crate) fn parse_any_offset_seconds(value: &str) -> Option<(i64, bool)> { let bytes = value.as_bytes(); let sign = match bytes.first() { Some(b'+') => 1_i64, @@ -240,13 +241,13 @@ pub(crate) fn parse_any_offset_seconds(value: &str) -> Option { } else { 0 }; - let second = if colon_format && bytes.get(cursor) == Some(&b':') { + let (second, has_seconds) = if colon_format && bytes.get(cursor) == Some(&b':') { cursor += 1; - parse_digits(bytes, &mut cursor, 2)? + (parse_digits(bytes, &mut cursor, 2)?, true) } else if !colon_format && matches!(bytes.get(cursor), Some(byte) if byte.is_ascii_digit()) { - parse_digits(bytes, &mut cursor, 2)? + (parse_digits(bytes, &mut cursor, 2)?, true) } else { - 0 + (0, false) }; if matches!(bytes.get(cursor), Some(b'.' | b',')) { cursor += 1; @@ -257,7 +258,28 @@ pub(crate) fn parse_any_offset_seconds(value: &str) -> Option { if cursor != bytes.len() || hour > 23 || minute > 59 || second > 59 { return None; } - Some((hour * 3600 + minute * 60 + second) as i64 * sign) + Some(((hour * 3600 + minute * 60 + second) as i64 * sign, has_seconds)) +} + +/// 偏移舍入到分钟(half-expand:半值远离零),用于分钟精度比较口径。 +/// 偏移量域 ±24h,unsigned_abs 无溢出面。 +pub(crate) fn round_offset_to_minutes(seconds: i64) -> i64 { + let sign = seconds.signum(); + let magnitude = seconds.unsigned_abs() + 30; + (magnitude / 60 * 60) as i64 * sign +} + +/// 输入偏移与 zone 偏移的一致性判定。 +/// +/// # 步骤 +/// 1. 精确口径(输入含秒分量 / 对象支):秒级相等。 +/// 2. 分钟口径(字符串支默认):zone 偏移舍入到分钟后与输入相等。 +pub(crate) fn offset_matches(zone_offset_seconds: i64, input_offset_seconds: i64, match_exactly: bool) -> bool { + if match_exactly { + zone_offset_seconds == input_offset_seconds + } else { + round_offset_to_minutes(zone_offset_seconds) == input_offset_seconds + } } /// 从 ZDT 对象 / ISO 字符串 / property bag 解析 (epoch_ns, time_zone_id, calendar_id) 三元组。 @@ -300,7 +322,8 @@ fn zoned_date_time_like_epoch_ns( /// 2. 主体剥注解后按 Z / 数值偏移 / 无偏移分路:Z 定 exact time,有偏移经 parse_instant_string /// 反推墙钟,无偏移直接解析墙钟。 /// 3. 时区偏移按候选 epoch(墙钟按 UTC 解释)单次查找。 -/// 4. 按 offset 选项 use/ignore/prefer/reject 决策 epoch,并做 Instant 范围校验。 +/// 4. 按 offset 选项 use/ignore/prefer/reject 决策 epoch:偏移串含秒分量走精确一致, +/// 分钟精度走 zone 偏移舍入到分钟后一致;并做 Instant 范围校验。 /// /// # 边界与前提 /// - 字符串缺时区注解或注解无法规范化抛 RangeError;主体非法抛 RangeError。 @@ -317,15 +340,18 @@ pub(crate) fn zoned_date_time_string_parts( return Err(crate::error::create_range_error(vm, "invalid ISO 8601 date-time")); }; let has_utc_designator = body.ends_with(['Z', 'z']); - let string_offset_seconds = if has_utc_designator { Some(0) } else { extract_string_offset_seconds(body) }; + let string_offset_seconds = if has_utc_designator { + Some((0, false)) + } else { + extract_string_offset_seconds(body) + }; // 有偏移/Z 时经 parse_instant_string 得 epoch 并反推墙钟;否则直接解析墙钟。 const DAY_NS: i128 = 86_400_000_000_000; - let (epoch_from_string, wall_parts) = if string_offset_seconds.is_some() { + let (epoch_from_string, wall_parts) = if let Some((offset, _has_sub_minute)) = string_offset_seconds { let epoch_ns = parse_instant_string(input) .ok_or_else(|| crate::error::create_range_error(vm, "invalid ISO 8601 date-time"))?; - let offset_seconds = string_offset_seconds.unwrap_or(0); - let wall_ns = epoch_ns + i128::from(offset_seconds) * 1_000_000_000; + let wall_ns = epoch_ns + i128::from(offset) * 1_000_000_000; let days = wall_ns.div_euclid(DAY_NS); let (year, month, day) = civil_from_days(days); (Some(epoch_ns), (year as i32, month as u32, day as u32, wall_ns.rem_euclid(DAY_NS) as f64)) @@ -349,24 +375,25 @@ pub(crate) fn zoned_date_time_string_parts( .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?; // 按 offsetBehaviour 决策:Z → exact(墙钟 epoch);无偏移 → wall(墙钟 + 时区偏移); - // 有偏移 → 按 offset 选项在字符串偏移与时区偏移之间选择。 + // 有偏移 → 按 offset 选项在字符串偏移与时区偏移之间选择(偏移串含秒分量走精确口径, + // 分钟精度走舍入到分钟口径)。 let epoch_ns = if has_utc_designator { epoch_from_string } else { match string_offset_seconds { None => wall_epoch(time_zone_offset), - Some(offset) => match offset_mode { + Some((offset, has_sub_minute)) => match offset_mode { "use" => epoch_from_string, "ignore" => wall_epoch(time_zone_offset), "prefer" => { - if offset == time_zone_offset { + if offset_matches(time_zone_offset, offset, has_sub_minute) { epoch_from_string } else { wall_epoch(time_zone_offset) } } "reject" => { - if offset == time_zone_offset { + if offset_matches(time_zone_offset, offset, has_sub_minute) { epoch_from_string } else { return Err(crate::error::create_range_error(vm, "offset and time zone disagree")); @@ -900,7 +927,7 @@ fn zoned_date_time_with_fields( None } else { let offset_input = temporal_string_strict(vm, offset_raw)?; - let seconds = parse_any_offset_seconds(&offset_input) + let (seconds, _has_sub_minute) = parse_any_offset_seconds(&offset_input) .ok_or_else(|| crate::error::create_range_error(vm, "invalid offset"))?; if !valid_offset_fraction(&offset_input) { return Err(crate::error::create_range_error(vm, "invalid offset")); @@ -933,7 +960,8 @@ fn zoned_date_time_with_fields( /// 2. receiver 本地分量与偏移;zoned_date_time_with_fields 读字段合并。 /// 3. options:disambiguation → offset(默认 prefer)→ overflow(逐项 Get/校验)。 /// 4. monthCode 闰月/超界/冲突校验 + constrain/reject 钳制 + PlainDateTime 范围校验。 -/// 5. offset 选项决策 → local_to_epoch_ns → Instant 范围校验 → make_zoned_date_time。 +/// 5. 候选 epoch 点 zone 偏移与合并偏移按 offset 选项决策 → local_to_epoch_ns +/// → Instant 范围校验 → make_zoned_date_time。 /// /// # 边界与前提 /// - 字段读取先于 options 解析:即使 options 本身类型不合法,也先读完 partial 字段, @@ -1045,21 +1073,29 @@ pub fn zoned_date_time_with(vm: &mut H, args: &[u8]) -> NativeResult return NativeResult::Err(crate::error::create_range_error(vm, "date-time out of range")); } - // InterpretISODateTimeOffset(固定偏移简化):按 offset 选项在 bag 偏移与时区偏移间选择。 - let epoch_ns = match (merged.bag_offset, offset_mode.as_str()) { - (None, _) => local_to_epoch_ns(year, month, day, total_ns, offset_seconds), - (Some(offset), "use") => local_to_epoch_ns(year, month, day, total_ns, offset), - (Some(_), "ignore") => local_to_epoch_ns(year, month, day, total_ns, offset_seconds), - (Some(offset), "prefer") => { - if offset == offset_seconds { - local_to_epoch_ns(year, month, day, total_ns, offset) + // InterpretISODateTimeOffset:时区偏移是 epoch 的函数,候选 epoch 取墙钟按 UTC 解释的 + // 单次查找近似,zone 偏移作 offset 选项判据与墙钟回退。 + let candidate_ns = native_try!(local_to_epoch_ns(year, month, day, total_ns, 0) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid date-time"))); + let zone_offset_at_candidate = + native_try!(zone_offset_seconds(&time_zone_id, candidate_ns.div_euclid(1_000_000_000) as i64) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))); + // 输入偏移为合并偏移(partial 的 offset 优先,缺省取 receiver 偏移); + // offset 选项在输入偏移与候选 epoch 点 zone 偏移之间决策。 + let input_offset = merged.bag_offset.unwrap_or(offset_seconds); + let epoch_ns = match offset_mode.as_str() { + "use" => local_to_epoch_ns(year, month, day, total_ns, input_offset), + "ignore" => local_to_epoch_ns(year, month, day, total_ns, zone_offset_at_candidate), + "prefer" => { + if input_offset == zone_offset_at_candidate { + local_to_epoch_ns(year, month, day, total_ns, input_offset) } else { - local_to_epoch_ns(year, month, day, total_ns, offset_seconds) + local_to_epoch_ns(year, month, day, total_ns, zone_offset_at_candidate) } } - (Some(offset), "reject") => { - if offset == offset_seconds { - local_to_epoch_ns(year, month, day, total_ns, offset) + "reject" => { + if input_offset == zone_offset_at_candidate { + local_to_epoch_ns(year, month, day, total_ns, input_offset) } else { return NativeResult::Err(crate::error::create_range_error(vm, "offset and time zone disagree")); }