feat: ZonedDateTime.add/subtract 时长运算(复用 PDT 叠加算法 + 中间日期检查 + 时区/日历槽保真)

This commit is contained in:
2026-08-17 00:02:00 +08:00
parent 5bf674e1c6
commit 68f8637acf
3 changed files with 226 additions and 0 deletions
+96
View File
@@ -6162,6 +6162,102 @@ pub fn plain_date_time_subtract<H: VmHost>(vm: &mut H, args: &[u8]) -> NativeRes
plain_date_time_apply_duration(vm, args, -1)
}
/// ZDT add/subtract 核心:本地分量叠加 duration(复用 plain_date_time_apply_duration 算法),
/// 先做中间日期检查(AddZonedDateTime 的中间日期越界校验),再经 local_to_epoch_ns 转回。
///
/// # 步骤
/// 1. branding + receiver 本地分量 + 时区偏移。
/// 2. duration_like_values 归一 + temporal_overflow 解析(读序:duration → options)。
/// 3. 时间增量(hours 起)与日期增量(y/m/w/d)分别按 sign 取反;时间部分
/// div_euclid/rem_euclid 拆出 extra_days/new_time_ns。
/// 4. 日期部分月份/日钳制叠加后先验中间日期(+ 原 time_ns 须在 PlainDateTime 范围)→ RangeError。
/// 5. 时间进位合并 → 最终 (yy, mm, dd, new_time_ns) → valid_plain_date_time_range 校验。
/// 6. local_to_epoch_ns + MAX_INSTANT_NS 校验 → make_zoned_date_time 保时区/日历槽。
///
/// # 边界与前提
/// - duration 全 0 → 值不变的新对象(blank-duration 语义)。
/// - 固定偏移下"中间 epoch + 时间增量"与"合并后 local_to_epoch_ns"严格相等,无需分步换算。
fn zoned_date_time_apply_duration<H: VmHost>(vm: &mut H, args: &[u8], sign: i64) -> 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 offset_minutes = native_try!(zoned_date_time_offset_minutes(vm, obj));
let (year, month, day, time_ns) = match zoned_date_time_plain_parts(vm, obj) {
Ok(parts) => parts,
Err(error) => return NativeResult::Err(error),
};
let val = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() };
let values = match duration_like_values(vm, val) {
Ok(values) => values,
Err(error) => return NativeResult::Err(error),
};
let constrain = match temporal_overflow(vm, args) {
Ok(constrain) => constrain,
Err(error) => return NativeResult::Err(error),
};
const DAY_NS: i128 = 86_400_000_000_000;
// 时间字段(hours 起)单独换算纳秒;days 由日期部分处理,避免重复计入。
let [_, _, _, _, h, min, s, ms, us, ns] = values;
let time_delta = duration_component_integer(h).unwrap_or(0) * 3_600_000_000_000
+ duration_component_integer(min).unwrap_or(0) * 60_000_000_000
+ duration_component_integer(s).unwrap_or(0) * 1_000_000_000
+ duration_component_integer(ms).unwrap_or(0) * 1_000_000
+ duration_component_integer(us).unwrap_or(0) * 1_000
+ duration_component_integer(ns).unwrap_or(0);
let total_ns = time_ns as i128 + time_delta * sign as i128;
let extra_days = total_ns.div_euclid(DAY_NS);
let new_time_ns = total_ns.rem_euclid(DAY_NS);
let [y, m, w, d, ..] = values;
let months =
(duration_component_integer(y).unwrap_or(0) * 12 + duration_component_integer(m).unwrap_or(0)) * sign as i128;
let total_month = i128::from(year) * 12 + i128::from(month) - 1 + months;
let ny = total_month.div_euclid(12);
let nm = total_month.rem_euclid(12) + 1;
let Some(max_day) = days_in_month(ny, nm) else {
return NativeResult::Err(crate::error::create_range_error(vm, "invalid date"));
};
let day = i128::from(day);
let new_day = if day > max_day {
if !constrain {
return NativeResult::Err(crate::error::create_range_error(vm, "day out of range"));
}
max_day
} else {
day
};
let day_delta = duration_component_integer(w).unwrap_or(0) * 7 + duration_component_integer(d).unwrap_or(0);
// 中间检查:CalendarDateAdd 后的日期 + 原 time_ns 须在 PlainDateTime 范围
// (±MAX instant 的 {days:∓1} 在此拦截)。
let added_days = days_from_civil(ny, nm, new_day) + day_delta * sign as i128;
let (iy, im, id) = civil_from_days(added_days);
if !valid_plain_date_time_range(iy as i32, im as u32, id as u32, time_ns) {
return NativeResult::Err(crate::error::create_range_error(vm, "invalid date-time"));
}
let total_days = added_days + extra_days;
let (yy, mm, dd) = civil_from_days(total_days);
if !valid_plain_date_time_range(yy as i32, mm as u32, dd as u32, new_time_ns as f64) {
return NativeResult::Err(crate::error::create_range_error(vm, "invalid date-time"));
}
let epoch_ns = match local_to_epoch_ns(yy as i32, mm as u32, dd as u32, new_time_ns as f64, offset_minutes) {
Some(epoch_ns) if epoch_ns.unsigned_abs() <= MAX_INSTANT_NS as u128 => epoch_ns,
_ => return NativeResult::Err(crate::error::create_range_error(vm, "ZonedDateTime outside supported range")),
};
let calendar_id = get_calendar_id(obj, 2);
make_zoned_date_time(vm, epoch_ns, &time_zone_id, &calendar_id)
}
/// `Temporal.ZonedDateTime.prototype.add(durationLike, options)`。
pub fn zoned_date_time_add<H: VmHost>(vm: &mut H, args: &[u8]) -> NativeResult {
zoned_date_time_apply_duration(vm, args, 1)
}
/// `Temporal.ZonedDateTime.prototype.subtract(durationLike, options)`。
pub fn zoned_date_time_subtract<H: VmHost>(vm: &mut H, args: &[u8]) -> NativeResult {
zoned_date_time_apply_duration(vm, args, -1)
}
/// `Temporal.PlainDateTime.prototype.toJSON()`:输出默认 ISO 日期时间。
pub fn plain_date_time_to_json<H: VmHost>(vm: &mut H, args: &[u8]) -> NativeResult {
plain_date_time_iso_string(vm, args)
@@ -425,6 +425,12 @@ pub fn bind_temporal(core: &Arc<KernelCore>, session: &KernelSession, global: &m
oxide_builtins::temporal::zoned_date_time_with_plain_time::<crate::vm::Vm> as *const (),
1,
),
("add", oxide_builtins::temporal::zoned_date_time_add::<crate::vm::Vm> as *const (), 1),
(
"subtract",
oxide_builtins::temporal::zoned_date_time_subtract::<crate::vm::Vm> as *const (),
1,
),
],
);
@@ -1919,3 +1919,127 @@ fn zoned_date_time_with_family_branding() {
.unwrap();
assert_eq!(str_val(&vm, r), "true|true|true");
}
#[test]
fn zoned_date_time_add_duration_object() {
let mut vm = Vm::new();
// add-duration.js 对拍:240h + 800ns 叠加到负 epoch。
let r = eval(
&mut vm,
"const z = new Temporal.ZonedDateTime(-560174321098766n, 'UTC');
z.add(new Temporal.Duration(0, 0, 0, 0, 240, 0, 0, 0, 0, 800)).epochNanoseconds === 303825678902034n",
)
.unwrap();
assert!(r.as_bool());
}
#[test]
fn zoned_date_time_add_constrain_and_month_boundary() {
let mut vm = Vm::new();
// 月末 + 1 月 constrain 钳制到 2 月 28;reject 抛 RangeError;月份/日叠加顺序一致。
let r = eval(
&mut vm,
"const z = new Temporal.ZonedDateTime(0n, 'UTC');
z.with({ day: 31 }).add({ months: 1 }).toString() + '|' +
(() => { try { z.with({ day: 31 }).add({ months: 1 }, { overflow: 'reject' }); return 'no-throw'; }
catch (e) { return e instanceof RangeError; } })() + '|' +
(z.add({ months: 1 }).add({ days: 1 }).epochNanoseconds === z.add({ months: 1, days: 1 }).epochNanoseconds)",
)
.unwrap();
assert_eq!(str_val(&vm, r), "1970-02-28T00:00:00+00:00[UTC]|true|true");
}
#[test]
fn zoned_date_time_add_subtract_are_inverse() {
let mut vm = Vm::new();
// 同一 duration 符号反转互逆;blank duration 返回等值对象。
let r = eval(
&mut vm,
"const z = new Temporal.ZonedDateTime(1_700_000_000_123_456_789n, '+05:30');
(z.add({ days: 5, hours: 3 }).subtract({ days: 5, hours: 3 }).epochNanoseconds === z.epochNanoseconds) + '|' +
(z.subtract({ months: 1 }).add({ months: 1 }).epochNanoseconds === z.epochNanoseconds) + '|' +
(z.add(new Temporal.Duration()).epochNanoseconds === z.epochNanoseconds) + '|' +
(z.add('PT0S').toString() === z.toString())",
)
.unwrap();
assert_eq!(str_val(&vm, r), "true|true|true|true");
}
#[test]
fn zoned_date_time_add_subtract_intermediate_check() {
let mut vm = Vm::new();
// ±MAX instant 的 {days:∓1}:中间日期越 PlainDateTime 范围 → RangeError。
let r = eval(
&mut vm,
"const min = new Temporal.ZonedDateTime(-8640000000000000000000n, 'UTC');
const max = new Temporal.ZonedDateTime(8640000000000000000000n, 'UTC');
(() => { try { min.add({ days: -1 }); return 'no-throw'; }
catch (e) { return e instanceof RangeError; } })() + '|' +
(() => { try { min.subtract({ days: 1 }); return 'no-throw'; }
catch (e) { return e instanceof RangeError; } })() + '|' +
(() => { try { max.add({ days: 1 }); return 'no-throw'; }
catch (e) { return e instanceof RangeError; } })() + '|' +
(() => { try { max.subtract({ days: -1 }); return 'no-throw'; }
catch (e) { return e instanceof RangeError; } })()",
)
.unwrap();
assert_eq!(str_val(&vm, r), "true|true|true|true");
}
#[test]
fn zoned_date_time_add_slots_preserved() {
let mut vm = Vm::new();
// 运算结果保留 receiver 时区/日历槽。
let r = eval(
&mut vm,
"const z = new Temporal.ZonedDateTime(0n, 'UTC', 'gregory');
const a = z.add({ years: 1 });
(a.timeZoneId === 'UTC') + '|' +
(a.calendarId === 'gregory') + '|' +
(a.toString() === '1971-01-01T00:00:00+00:00[UTC]') + '|' +
(z.subtract({ years: 1 }).toString() === '1969-01-01T00:00:00+00:00[UTC]')",
)
.unwrap();
assert_eq!(str_val(&vm, r), "true|true|true|true");
}
#[test]
fn zoned_date_time_add_string_and_time_fields() {
let mut vm = Vm::new();
// duration 字符串与时间各字段(含跨天进位)合成正确。
let r = eval(
&mut vm,
"const z = new Temporal.ZonedDateTime(0n, 'UTC');
z.add('PT2H30M').toString() + '|' +
z.add({ hours: 24, minutes: 30 }).toString() + '|' +
z.add({ milliseconds: 1500, microseconds: 2, nanoseconds: 3 }).toString() + '|' +
z.subtract('PT1H').toString()",
)
.unwrap();
assert_eq!(
str_val(&vm, r),
"1970-01-01T02:30:00+00:00[UTC]|1970-01-02T00:30:00+00:00[UTC]|1970-01-01T00:00:01.500002003+00:00[UTC]|1969-12-31T23:00:00+00:00[UTC]"
);
}
#[test]
fn zoned_date_time_add_error_paths() {
let mut vm = Vm::new();
// 缺参 TypeError、混合符号 duration RangeError、epoch 越界 RangeError、branding TypeError。
let r = eval(
&mut vm,
"const z = new Temporal.ZonedDateTime(0n, 'UTC');
(() => { try { z.add(); return 'no-throw'; }
catch (e) { return e instanceof TypeError; } })() + '|' +
(() => { try { z.add({ days: 1, hours: -1 }); return 'no-throw'; }
catch (e) { return e instanceof RangeError; } })() + '|' +
(() => { try { new Temporal.ZonedDateTime(8640000000000000000000n, 'UTC').add({ hours: 1 }); return 'no-throw'; }
catch (e) { return e instanceof RangeError; } })() + '|' +
(() => { try { Temporal.ZonedDateTime.prototype.add.call({}, {}); return 'no-throw'; }
catch (e) { return e instanceof TypeError; } })() + '|' +
(() => { try { Temporal.ZonedDateTime.prototype.subtract.call({}, {}); return 'no-throw'; }
catch (e) { return e instanceof TypeError; } })()",
)
.unwrap();
assert_eq!(str_val(&vm, r), "true|true|true|true|true");
}