feat: ZonedDateTime.round 舍入(day 双路径 + 真因子校验)

This commit is contained in:
2026-08-17 01:55:52 +08:00
parent 04bef9f9ac
commit 006d88fb04
3 changed files with 295 additions and 0 deletions
+188
View File
@@ -2876,6 +2876,153 @@ pub fn zoned_date_time_since<H: VmHost>(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<H: VmHost>(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);
}
}
@@ -419,6 +419,7 @@ pub fn bind_temporal(core: &Arc<KernelCore>, session: &KernelSession, global: &m
oxide_builtins::temporal::zoned_date_time_equals::<crate::vm::Vm> as *const (),
1,
),
("round", oxide_builtins::temporal::zoned_date_time_round::<crate::vm::Vm> as *const (), 1),
("until", oxide_builtins::temporal::zoned_date_time_until::<crate::vm::Vm> as *const (), 1),
("since", oxide_builtins::temporal::zoned_date_time_since::<crate::vm::Vm> as *const (), 1),
("with", oxide_builtins::temporal::zoned_date_time_with::<crate::vm::Vm> as *const (), 1),
@@ -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();