From f6f5628cba222acde29b7164a9c2da358bbd93b8 Mon Sep 17 00:00:00 2001 From: pjh456 <147148383@qq.com> Date: Mon, 17 Aug 2026 00:02:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ZonedDateTime.add/subtract=20=E6=97=B6?= =?UTF-8?q?=E9=95=BF=E8=BF=90=E7=AE=97=EF=BC=88=E5=A4=8D=E7=94=A8=20PDT=20?= =?UTF-8?q?=E5=8F=A0=E5=8A=A0=E7=AE=97=E6=B3=95=20+=20=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E6=97=A5=E6=9C=9F=E6=A3=80=E6=9F=A5=20+=20=E6=97=B6=E5=8C=BA/?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E6=A7=BD=E4=BF=9D=E7=9C=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/oxide_builtins/src/temporal.rs | 96 ++++++++++++++ crates/oxide_vm/src/bindings/bind_temporal.rs | 6 + .../oxide_vm/tests/builtins_temporal_tests.rs | 124 ++++++++++++++++++ 3 files changed, 226 insertions(+) diff --git a/crates/oxide_builtins/src/temporal.rs b/crates/oxide_builtins/src/temporal.rs index c07f6ba..3211f98 100644 --- a/crates/oxide_builtins/src/temporal.rs +++ b/crates/oxide_builtins/src/temporal.rs @@ -6162,6 +6162,102 @@ pub fn plain_date_time_subtract(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(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(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(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(vm: &mut H, args: &[u8]) -> NativeResult { plain_date_time_iso_string(vm, args) diff --git a/crates/oxide_vm/src/bindings/bind_temporal.rs b/crates/oxide_vm/src/bindings/bind_temporal.rs index d63b206..c8feaeb 100644 --- a/crates/oxide_vm/src/bindings/bind_temporal.rs +++ b/crates/oxide_vm/src/bindings/bind_temporal.rs @@ -425,6 +425,12 @@ pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &m oxide_builtins::temporal::zoned_date_time_with_plain_time:: as *const (), 1, ), + ("add", oxide_builtins::temporal::zoned_date_time_add:: as *const (), 1), + ( + "subtract", + oxide_builtins::temporal::zoned_date_time_subtract:: as *const (), + 1, + ), ], ); diff --git a/crates/oxide_vm/tests/builtins_temporal_tests.rs b/crates/oxide_vm/tests/builtins_temporal_tests.rs index 6704430..05e6d11 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -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"); +}