diff --git a/crates/oxide_builtins/src/temporal.rs b/crates/oxide_builtins/src/temporal.rs index fb34cdc7..72fee705 100644 --- a/crates/oxide_builtins/src/temporal.rs +++ b/crates/oxide_builtins/src/temporal.rs @@ -2962,6 +2962,72 @@ pub fn plain_date_time_to_string(vm: &mut H, args: &[u8]) -> NativeRe NativeResult::Ok(vm.new_string(&output)) } +/// 按 duration-like 分量对 PlainDateTime 做加减:先平衡时间(溢出为天), +/// 再按年/月/周/日推进日期,最后校验范围。 +fn plain_date_time_apply_duration(vm: &mut H, args: &[u8], sign: i64) -> NativeResult { + let (year, month, day, time_ns) = match plain_date_time_parts(vm, args) { + 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 plain_date_time_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); + let total_days = days_from_civil(ny, nm, new_day) + extra_days + day_delta * sign as i128; + 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")); + } + make_plain_date_time(vm, yy as i32, mm as u32, dd as u32, new_time_ns as f64) +} + +/// `Temporal.PlainDateTime.prototype.add(durationLike, options)`。 +pub fn plain_date_time_add(vm: &mut H, args: &[u8]) -> NativeResult { + plain_date_time_apply_duration(vm, args, 1) +} + +/// `Temporal.PlainDateTime.prototype.subtract(durationLike, options)`。 +pub fn plain_date_time_subtract(vm: &mut H, args: &[u8]) -> NativeResult { + plain_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 bcc8f900..9eb398e6 100644 --- a/crates/oxide_vm/src/bindings/bind_temporal.rs +++ b/crates/oxide_vm/src/bindings/bind_temporal.rs @@ -510,6 +510,16 @@ pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &m oxide_builtins::temporal::plain_date_time_to_json:: as *const (), 0, ), + ( + "add", + oxide_builtins::temporal::plain_date_time_add:: as *const (), + 1, + ), + ( + "subtract", + oxide_builtins::temporal::plain_date_time_subtract:: as *const (), + 1, + ), ], ); bind_well_known_data_property( diff --git a/crates/oxide_vm/tests/builtins_temporal_tests.rs b/crates/oxide_vm/tests/builtins_temporal_tests.rs index b245e6bf..77c518b2 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -821,3 +821,26 @@ fn zdt_subclass_compare_debug() { .unwrap(); assert_eq!(str_val(&vm, r), "number:-1"); } + +#[test] +fn plain_date_time_add_balances_months_days_and_time() { + let mut vm = Vm::new(); + let r = eval( + &mut vm, + "const a = Temporal.PlainDateTime.from('1997-12-01T12:34'); + const b = a.add(new Temporal.Duration(3, 6, 0, 17)); + const c = a.add(new Temporal.Duration(0, 1, 0, 0, 36)); + const d = a.subtract(new Temporal.Duration(0, 0, 0, 1)); + const constrained = new Temporal.PlainDateTime(2020, 1, 31, 15, 0).add({ months: 1 }); + const rejected = (() => { try { + new Temporal.PlainDateTime(2020, 1, 31, 15, 0).add({ months: 1 }, { overflow: 'reject' }); + return false; + } catch (e) { return e instanceof RangeError; } })(); + [b.year, b.month, b.day, b.hour, b.minute, b.second, + c.day, c.hour, + d.year, d.month, d.day, + constrained.month, constrained.day, rejected].join(':')", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "2001:6:18:12:34:0:3:0:1997:11:30:2:29:true"); +}