diff --git a/crates/oxide_builtins/src/temporal.rs b/crates/oxide_builtins/src/temporal.rs index 9bdcec6f..319f9b7c 100644 --- a/crates/oxide_builtins/src/temporal.rs +++ b/crates/oxide_builtins/src/temporal.rs @@ -2876,6 +2876,153 @@ pub fn zoned_date_time_since(vm: &mut H, args: &[u8]) -> NativeResult zoned_date_time_difference(vm, args, true) } +/// ZDT round 的最小单位表:day + 6 个时间单位(不含 year/month/week)。 +/// 返回 (单位纳秒, 每更高一级单位的数量);day 的"更高一级数量"= 1(增量仅 1 合法)。 +fn zoned_date_time_round_unit(value: &str) -> Option<(i128, i128)> { + match value { + "day" | "days" => Some((86_400_000_000_000, 1)), + "hour" | "hours" => Some((3_600_000_000_000, 24)), + "minute" | "minutes" => Some((60_000_000_000, 60)), + "second" | "seconds" => Some((1_000_000_000, 60)), + "millisecond" | "milliseconds" => Some((1_000_000, 1_000)), + "microsecond" | "microseconds" => Some((1_000, 1_000)), + "nanosecond" | "nanoseconds" => Some((1, 1_000)), + _ => None, + } +} + +/// `Temporal.ZonedDateTime.prototype.round(roundTo)`:按 smallestUnit 在"本地日"域内舍入。 +/// +/// # 步骤 +/// 1. roundTo 解析:undefined → TypeError;字符串 → {smallestUnit: 串};对象 → 依次 Get +/// roundingIncrement → roundingMode → smallestUnit,全部读完再统一校验。 +/// 2. receiver:ensure_zoned_date_time → epoch_r;instant_time_zone_offset(tz) 得 offset_min; +/// zoned_date_time_plain_parts 得 (y, m, d, time_ns)。 +/// 3. 单位表查 smallestUnit(None → RangeError "invalid smallest unit")。 +/// 4. roundingIncrement 校验:1..=1e9 且真因子(increment < units_per_day 且 +/// units_per_day % increment == 0,否则 RangeError)。day 仅 increment=1 合法。 +/// 5. mode = instant_rounding_mode(None → RangeError);缺省 "halfExpand"。 +/// 6. day 路径(smallestUnit = day):startNs/endNs 双算(越界 → RangeError), +/// dayProgress = epoch_r − startNs,rounded = round_instant_ns(dayProgress, DAY*increment), +/// result = startNs + rounded。 +/// 7. else 路径(时间单位):rounded_time = round_instant_ns(time_ns, unit_ns*increment) +/// (可进位到 DAY → 本地墙钟自动跨日),result = local_to_epoch_ns(y, m, d, rounded_time)。 +/// 8. 范围校验:|result| > MAX_INSTANT_NS → RangeError。 +/// 9. make_zoned_date_time(vm, result, tz_id, cal_id)(保时区/日历槽)。 +pub fn zoned_date_time_round(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 Some(epoch_r) = get_instant_epoch_ns(obj) else { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid ZonedDateTime")); + }; + let time_zone_id = to_string(obj.get_prop_at(1)); + let Some(offset_min) = instant_time_zone_offset(&time_zone_id) else { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid time zone")); + }; + let calendar_id = get_calendar_id(obj, 2); + let (y, m, d, time_ns) = native_try!(zoned_date_time_plain_parts(vm, obj)); + + // roundTo 解析:字符串简写或对象选项,读序对齐 order-of-operations。 + let round_to = if args.len() < 2 { JsValue::undefined() } else { vm.reg(args[1]) }; + let (increment_value, mode_value, unit_value) = if round_to.is_string() { + (1.0, "halfExpand".to_string(), Some(to_string(round_to))) + } else { + if !round_to.is_object() { + return NativeResult::Err(crate::error::create_type_error(vm, "roundTo must be a string or object")); + } + let options_ptr = round_to.as_js_object_ptr(); + if options_ptr.is_null() { + return NativeResult::Err(crate::error::create_type_error(vm, "roundTo must be a string or object")); + } + let options = unsafe { &*options_ptr }; + let increment_raw = native_try!(temporal_option_value(vm, options, round_to, "roundingIncrement")); + let increment = if increment_raw.is_undefined() { + 1.0 + } else { + native_try!(temporal_option_number(vm, increment_raw)) + }; + let mode_raw = native_try!(temporal_option_value(vm, options, round_to, "roundingMode")); + let mode = if mode_raw.is_undefined() { + "halfExpand".to_string() + } else { + native_try!(temporal_option_string(vm, mode_raw)) + }; + let unit_raw = native_try!(temporal_option_value(vm, options, round_to, "smallestUnit")); + let unit = if unit_raw.is_undefined() { + None + } else { + Some(native_try!(temporal_option_string(vm, unit_raw))) + }; + (increment, mode, unit) + }; + + let Some(mode) = instant_rounding_mode(&mode_value) else { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid rounding mode")); + }; + let Some(unit_value) = unit_value else { + return NativeResult::Err(crate::error::create_range_error(vm, "smallestUnit is required")); + }; + let Some((unit_ns, units_per_day)) = zoned_date_time_round_unit(&unit_value) else { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid smallest unit")); + }; + if !increment_value.is_finite() { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid rounding increment")); + } + let increment = increment_value.trunc(); + if !(1.0..=1_000_000_000.0).contains(&increment) { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid rounding increment")); + } + let increment = increment as i128; + // 真因子校验:时间单位增量须小于更高一级单位数量且整除(hour/24、minute/60、second/60 拒绝); + // day 特殊:更高一级数量=1,仅 increment==1 合法。 + let increment_valid = if unit_ns == DAY_NS { + increment == 1 + } else { + increment < units_per_day && units_per_day % increment == 0 + }; + if !increment_valid { + return NativeResult::Err(crate::error::create_range_error(vm, "rounding increment must divide a day")); + } + + const DAY_NS: i128 = 86_400_000_000_000; + let result_ns = if unit_ns == DAY_NS { + // day 路径:startNs/endNs 双算(越界 → RangeError),dayProgress 固定偏移下 ∈ [0, DAY)。 + let start_ns = native_try!(start_of_day_epoch_ns(y, m, d, offset_min) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid start of day"))); + native_try!(start_of_day_epoch_ns_by_days( + days_from_civil(i128::from(y), i128::from(m), i128::from(d)) + 1, + offset_min, + ) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid start of day"))); + let day_progress = epoch_r - start_ns; + let Some(quantum_ns) = DAY_NS.checked_mul(increment) else { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid rounding increment")); + }; + let Some(rounded) = round_instant_ns(day_progress, quantum_ns, mode) else { + return NativeResult::Err(crate::error::create_range_error(vm, "ZonedDateTime outside supported range")); + }; + start_ns + rounded + } else { + // else 路径:本地墙钟时间舍入(可进位到 DAY → 自动跨日),再换算回 epoch。 + let Some(quantum_ns) = unit_ns.checked_mul(increment) else { + return NativeResult::Err(crate::error::create_range_error(vm, "invalid rounding increment")); + }; + let Some(rounded_time) = round_instant_ns(time_ns as i128, quantum_ns, mode) else { + return NativeResult::Err(crate::error::create_range_error(vm, "ZonedDateTime outside supported range")); + }; + let Some(result) = local_to_epoch_ns(y, m, d, rounded_time as f64, offset_min) else { + return NativeResult::Err(crate::error::create_range_error(vm, "ZonedDateTime outside supported range")); + }; + result + }; + + if result_ns.unsigned_abs() > MAX_INSTANT_NS as u128 { + return NativeResult::Err(crate::error::create_range_error(vm, "ZonedDateTime outside supported range")); + } + make_zoned_date_time(vm, result_ns, &time_zone_id, &calendar_id) +} + /// ZDT 字段 getter 宏:branding 后读本地分量,按选择函数取字段。 macro_rules! zoned_date_time_parts_getter { ($name:ident, $select:expr) => { @@ -6913,4 +7060,45 @@ mod tests { assert_eq!(parse_plain_time_string("2022-09-15Z"), None); assert_eq!(parse_plain_time_string("2022-09-15+00:00"), None); } + + #[test] + fn zoned_date_time_round_unit_table() { + // day 含独立条目,每单位 (ns, 更高一级数量) 与 spec 对齐;day 更高一级数量为 1。 + assert_eq!(zoned_date_time_round_unit("day"), Some((86_400_000_000_000, 1))); + assert_eq!(zoned_date_time_round_unit("days"), Some((86_400_000_000_000, 1))); + assert_eq!(zoned_date_time_round_unit("hour"), Some((3_600_000_000_000, 24))); + assert_eq!(zoned_date_time_round_unit("minute"), Some((60_000_000_000, 60))); + assert_eq!(zoned_date_time_round_unit("second"), Some((1_000_000_000, 60))); + assert_eq!(zoned_date_time_round_unit("millisecond"), Some((1_000_000, 1_000))); + assert_eq!(zoned_date_time_round_unit("microsecond"), Some((1_000, 1_000))); + assert_eq!(zoned_date_time_round_unit("nanosecond"), Some((1, 1_000))); + // year/month/week 及拼写错误不在单位表。 + assert_eq!(zoned_date_time_round_unit("years"), None); + assert_eq!(zoned_date_time_round_unit("months"), None); + assert_eq!(zoned_date_time_round_unit("weeks"), None); + assert_eq!(zoned_date_time_round_unit("hourz"), None); + } + + #[test] + fn zoned_date_time_round_else_path_epoch() { + // 217175010123456789n +01:00:本地 time_ns = 55_410_123_456_789。 + // hour/4 quantum 14_400e9 → rounded_time 57_600e9 → local_to_epoch_ns 回推 217177200000000000。 + let quantum = 3_600_000_000_000 * 4; + let rounded = round_instant_ns(55_410_123_456_789, quantum, InstantRoundingMode::HalfExpand).unwrap(); + assert_eq!(rounded, 57_600_000_000_000); + // 本地墙钟日回推:offset 60 分,local_to_epoch_ns 得目标 epoch。 + let epoch = local_to_epoch_ns(1976, 11, 18, rounded as f64, 60).unwrap(); + assert_eq!(epoch, 217_177_200_000_000_000); + } + + #[test] + fn zoned_date_time_round_day_path_epoch() { + // 同日本地午夜 startNs(2513 天,offset 60 分),dayProgress 舍到次日 → 217206000000000000。 + let start_ns = start_of_day_epoch_ns(1976, 11, 18, 60).unwrap(); + assert_eq!(start_ns, 217_119_600_000_000_000); + let day_progress = 217_175_010_123_456_789 - start_ns; + let rounded = round_instant_ns(day_progress, 86_400_000_000_000, InstantRoundingMode::HalfExpand).unwrap(); + assert_eq!(rounded, 86_400_000_000_000); + assert_eq!(start_ns + rounded, 217_206_000_000_000_000); + } } diff --git a/crates/oxide_vm/src/bindings/bind_temporal.rs b/crates/oxide_vm/src/bindings/bind_temporal.rs index ad454902..4f03860f 100644 --- a/crates/oxide_vm/src/bindings/bind_temporal.rs +++ b/crates/oxide_vm/src/bindings/bind_temporal.rs @@ -419,6 +419,7 @@ pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &m oxide_builtins::temporal::zoned_date_time_equals:: as *const (), 1, ), + ("round", oxide_builtins::temporal::zoned_date_time_round:: as *const (), 1), ("until", oxide_builtins::temporal::zoned_date_time_until:: as *const (), 1), ("since", oxide_builtins::temporal::zoned_date_time_since:: as *const (), 1), ("with", oxide_builtins::temporal::zoned_date_time_with:: as *const (), 1), diff --git a/crates/oxide_vm/tests/builtins_temporal_tests.rs b/crates/oxide_vm/tests/builtins_temporal_tests.rs index c221b296..64da5fa9 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -2090,6 +2090,112 @@ fn zoned_date_time_add_string_and_time_fields() { ); } +#[test] +fn zoned_date_time_round_hour_increment() { + let mut vm = Vm::new(); + // 217175010123456789n +01:00 本地 15:23:30.123456789,hour/4 舍到 16:00 + // (rounding-increments.js 期望 epoch 217177200000000000n)。 + let r = eval( + &mut vm, + "const z = new Temporal.ZonedDateTime(217175010123456789n, '+01:00'); + z.round({ smallestUnit: 'hour', roundingIncrement: 4 }).toString() + '|' + + z.round({ smallestUnit: 'hour', roundingIncrement: 4 }).epochNanoseconds", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "1976-11-18T16:00:00+01:00[+01:00]|217177200000000000"); +} + +#[test] +fn zoned_date_time_round_day_path_cross_midnight() { + let mut vm = Vm::new(); + // day 双路径:23:59:59.999999999 本地舍到次日 00:00(对象与字符串简写同效)。 + let r = eval( + &mut vm, + "const z = Temporal.ZonedDateTime.from('1976-11-18T23:59:59.999999999+01:00[+01:00]'); + z.round({ smallestUnit: 'day' }).toString() + '|' + + z.round('day').toString() + '|' + + Temporal.ZonedDateTime.from('1976-11-18T11:59:59.999999999+01:00[+01:00]').round('day').toString()", + ) + .unwrap(); + assert_eq!( + str_val(&vm, r), + "1976-11-19T00:00:00+01:00[+01:00]|1976-11-19T00:00:00+01:00[+01:00]|1976-11-18T00:00:00+01:00[+01:00]" + ); +} + +#[test] +fn zoned_date_time_round_time_unit_epoch_values() { + let mut vm = Vm::new(); + // minute/15 与 nanosecond 舍入的 epoch 对拍(rounding-increments.js 合法表)。 + let r = eval( + &mut vm, + "const z = new Temporal.ZonedDateTime(217175010123456789n, '+01:00'); + z.round({ smallestUnit: 'minute', roundingIncrement: 15 }).epochNanoseconds + '|' + + z.round({ smallestUnit: 'nanosecond' }).epochNanoseconds + '|' + + z.round({ smallestUnit: 'hour', roundingIncrement: 12 }).epochNanoseconds", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "217175400000000000|217175010123456789|217162800000000000"); +} + +#[test] +fn zoned_date_time_round_true_factor_increment_validation() { + let mut vm = Vm::new(); + // 真因子校验:increment==units_per_day(hour/24、minute/60)与 day>1 均 RangeError; + // 合法因子放行(hour/12、minute/15、day/1)。 + let r = eval( + &mut vm, + "const z = new Temporal.ZonedDateTime(0n, 'UTC'); + const tryRound = (o) => { try { z.round(o); return 'ok'; } catch (e) { return e instanceof RangeError; } }; + tryRound({ smallestUnit: 'hour', roundingIncrement: 24 }) + '|' + + tryRound({ smallestUnit: 'minute', roundingIncrement: 60 }) + '|' + + tryRound({ smallestUnit: 'day', roundingIncrement: 2 }) + '|' + + tryRound({ smallestUnit: 'hour', roundingIncrement: 12 }) + '|' + + tryRound({ smallestUnit: 'minute', roundingIncrement: 15 }) + '|' + + tryRound({ smallestUnit: 'day' })", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true|true|ok|ok|ok"); +} + +#[test] +fn zoned_date_time_round_out_of_range_errors() { + let mut vm = Vm::new(); + // 越界三例:day 路径 startNs/endNs 越 MAX,else 路径进位越 MAX,均 RangeError。 + let r = eval( + &mut vm, + "const tryR = (z, o) => { try { z.round(o); return 'ok'; } catch (e) { return e instanceof RangeError; } }; + tryR(new Temporal.ZonedDateTime(-8640000000000000000000n, '-01:00'), { smallestUnit: 'days' }) + '|' + + tryR(new Temporal.ZonedDateTime(8640000000000000000000n, 'UTC'), { smallestUnit: 'day' }) + '|' + + tryR(new Temporal.ZonedDateTime(8640000000000000000000n, '+23:59'), { smallestUnit: 'minutes', roundingIncrement: 10, roundingMode: 'ceil' })", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true|true"); +} + +#[test] +fn zoned_date_time_round_branding_and_arg_validation() { + let mut vm = Vm::new(); + // branding TypeError、round() 无参 TypeError、对象无 smallestUnit RangeError、 + // 非法单位串 RangeError、非法舍入模式 RangeError。 + let r = eval( + &mut vm, + "const z = new Temporal.ZonedDateTime(0n, 'UTC'); + (() => { try { Temporal.ZonedDateTime.prototype.round.call({}, { smallestUnit: 'day' }); return 'no-throw'; } + catch (e) { return e instanceof TypeError; } })() + '|' + + (() => { try { z.round(); return 'no-throw'; } + catch (e) { return e instanceof TypeError; } })() + '|' + + (() => { try { z.round({}); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })() + '|' + + (() => { try { z.round({ smallestUnit: 'years' }); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })() + '|' + + (() => { try { z.round({ smallestUnit: 'hour', roundingMode: 'bogus' }); return 'no-throw'; } + catch (e) { return e instanceof RangeError; } })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "true|true|true|true|true"); +} + #[test] fn zoned_date_time_add_error_paths() { let mut vm = Vm::new();