diff --git a/crates/oxide_builtins/src/temporal.rs b/crates/oxide_builtins/src/temporal.rs index 50cfcaee..c49e3de9 100644 --- a/crates/oxide_builtins/src/temporal.rs +++ b/crates/oxide_builtins/src/temporal.rs @@ -2868,6 +2868,7 @@ fn zoned_date_time_difference(vm: &mut H, args: &[u8], since: bool) - time_ns_o, settings, since, + 9, ) } @@ -4339,7 +4340,7 @@ pub fn duration_round(vm: &mut H, args: &[u8]) -> NativeResult { increment, mode, }; - let result = match nudge_iso_difference(vm, rel, 0, end, target_time, settings, false) { + let result = match nudge_iso_difference(vm, rel, 0, end, target_time, settings, false, 9) { Ok(values) => values, Err(error) => return NativeResult::Err(error), }; @@ -4915,7 +4916,7 @@ fn plain_time_difference(vm: &mut H, args: &[u8], since: bool) -> Nat Ok(settings) => settings, Err(error) => return NativeResult::Err(error), }; - difference_core(vm, (0, 0, 0), receiver_ns as i128, (0, 0, 0), other_ns as i128, settings, since) + difference_core(vm, (0, 0, 0), receiver_ns as i128, (0, 0, 0), other_ns as i128, settings, since, 9) } /// `Temporal.PlainTime.prototype.until(other, options)`。 @@ -6690,6 +6691,15 @@ fn plain_date_unit_index(value: &str) -> Option { } } +/// PlainYearMonth 差值单位层级:仅 0=year 1=month(week/day 及时间单位均不合法)。 +fn plain_year_month_unit_index(value: &str) -> Option { + match value { + "year" | "years" => Some(0), + "month" | "months" => Some(1), + _ => None, + } +} + /// 解析差值选项(对齐 GetDifferenceSettings):读取顺序 largestUnit → /// roundingIncrement → roundingMode → smallestUnit。date_only 时单位限定 /// year/month/week/day,smallestUnit 缺省 "day"(含时间时缺省 "nanosecond")。 @@ -6783,14 +6793,93 @@ fn parse_difference_settings( }) } +/// 解析 PlainYearMonth 差值选项(对齐 GetDifferenceSettings,unitGroup=date): +/// 单位表仅 year/month(week/day/时间单位不合法);smallestUnit 缺省 "month", +/// largestUnit 缺省/auto 为 LargerOfTwo(year, smallest) = year。 +/// 读取顺序 largestUnit → roundingIncrement → roundingMode → smallestUnit(字典序)。 +fn parse_year_month_difference_settings( + vm: &mut H, options_value: JsValue, +) -> Result { + let default_smallest = "month"; + let default_largest = 0usize; // year + let (largest_raw, increment_value, mode_value, smallest_raw) = if options_value.is_undefined() { + (None, 1.0, "trunc".to_string(), default_smallest.to_string()) + } else { + if !options_value.is_object() { + return Err(crate::error::create_type_error(vm, "options must be an object")); + } + let options_ptr = options_value.as_js_object_ptr(); + if options_ptr.is_null() { + return Err(crate::error::create_type_error(vm, "options must be an object")); + } + let options = unsafe { &*options_ptr }; + let largest_raw = match temporal_option_value(vm, options, options_value, "largestUnit") { + Ok(raw) if raw.is_undefined() => None, + Ok(raw) => Some(temporal_option_string(vm, raw)?), + Err(error) => return Err(error), + }; + let increment_raw = match temporal_option_value(vm, options, options_value, "roundingIncrement") { + Ok(raw) if raw.is_undefined() => 1.0, + Ok(raw) => temporal_option_number(vm, raw)?, + Err(error) => return Err(error), + }; + let mode_raw = match temporal_option_value(vm, options, options_value, "roundingMode") { + Ok(raw) if raw.is_undefined() => "trunc".to_string(), + Ok(raw) => temporal_option_string(vm, raw)?, + Err(error) => return Err(error), + }; + let smallest_raw = match temporal_option_value(vm, options, options_value, "smallestUnit") { + Ok(raw) if raw.is_undefined() => default_smallest.to_string(), + Ok(raw) => temporal_option_string(vm, raw)?, + Err(error) => return Err(error), + }; + (largest_raw, increment_raw, mode_raw, smallest_raw) + }; + + let smallest_index = match plain_year_month_unit_index(&smallest_raw) { + Some(index) => index, + None => return Err(crate::error::create_range_error(vm, "invalid smallestUnit")), + }; + // auto/缺省:LargerOfTwoTemporalUnits(year, smallestUnit)。更大单位取更小索引。 + let largest_index = match largest_raw { + Some(value) if value == "auto" => smallest_index.min(default_largest), + Some(value) => match plain_year_month_unit_index(&value) { + Some(index) => index, + None => return Err(crate::error::create_range_error(vm, "invalid largestUnit")), + }, + None => smallest_index.min(default_largest), + }; + if largest_index > smallest_index { + return Err(crate::error::create_range_error(vm, "smallestUnit exceeds largestUnit")); + } + let Some(mode) = instant_rounding_mode(&mode_value) else { + return Err(crate::error::create_range_error(vm, "invalid roundingMode")); + }; + if !increment_value.is_finite() { + return Err(crate::error::create_range_error(vm, "invalid roundingIncrement")); + } + let increment = increment_value.trunc(); + if !(1.0..=1_000_000_000.0).contains(&increment) { + return Err(crate::error::create_range_error(vm, "invalid roundingIncrement")); + } + Ok(DifferenceSettings { + largest_index, + smallest_index, + increment: increment as i128, + mode, + }) +} + /// 差值核心:internal = end - start,按设置取整;since 用 NegateRoundingMode /// 的舍入模式并在最后整体取反(不调换两端,调换会改变 0.5 边界所在的年长)。 +/// no_rounding = 单位索引空间中"最细单位"的索引:该单位且 increment=1 时 +/// 舍入为恒等变换,跳过 nudge(边界处避免窗口端点的无效范围抛错)。 #[allow(clippy::too_many_arguments)] fn difference_core( vm: &mut H, start: (i128, i128, i128), start_time_ns: i128, end: (i128, i128, i128), end_time_ns: i128, - settings: DifferenceSettings, since: bool, + settings: DifferenceSettings, since: bool, no_rounding: usize, ) -> NativeResult { - let values = match nudge_iso_difference(vm, start, start_time_ns, end, end_time_ns, settings, since) { + let values = match nudge_iso_difference(vm, start, start_time_ns, end, end_time_ns, settings, since, no_rounding) { Ok(values) => values, Err(error) => return NativeResult::Err(error), }; @@ -6812,7 +6901,7 @@ fn difference_core( #[allow(clippy::too_many_arguments)] fn nudge_iso_difference( vm: &mut H, start: (i128, i128, i128), start_time_ns: i128, end: (i128, i128, i128), end_time_ns: i128, - settings: DifferenceSettings, since: bool, + settings: DifferenceSettings, since: bool, no_rounding: usize, ) -> Result<[f64; 10], JsValue> { let DifferenceSettings { largest_index, @@ -6878,7 +6967,7 @@ fn nudge_iso_difference( } }; - let mut values = if smallest_index == 9 && increment == 1 { + let mut values = if smallest_index == no_rounding && increment == 1 { // 不要求舍入:时间按最大单位拆回。 let mut values = date_values; if largest_index >= 4 { @@ -6990,6 +7079,7 @@ fn plain_date_time_difference(vm: &mut H, args: &[u8], since: bool) - ot as i128, settings, since, + 9, ) } @@ -7475,6 +7565,7 @@ pub fn plain_date_until(vm: &mut H, args: &[u8]) -> NativeResult { 0, settings, false, + 9, ) } @@ -7502,6 +7593,7 @@ pub fn plain_date_since(vm: &mut H, args: &[u8]) -> NativeResult { 0, settings, true, + 9, ) } @@ -8979,6 +9071,63 @@ pub fn plain_year_month_to_plain_date(vm: &mut H, args: &[u8]) -> Nat make_plain_date(vm, year, month, day, &calendar) } +/// `Temporal.PlainYearMonth.prototype.until(other[, options])`。 +pub fn plain_year_month_until(vm: &mut H, args: &[u8]) -> NativeResult { + plain_year_month_difference(vm, args, false) +} + +/// `Temporal.PlainYearMonth.prototype.since(other[, options])`。 +pub fn plain_year_month_since(vm: &mut H, args: &[u8]) -> NativeResult { + plain_year_month_difference(vm, args, true) +} + +/// until/since 差值核心(DifferenceTemporalPlainYearMonth)。 +/// +/// # 步骤 +/// 1. receiver branding(TypeError)。 +/// 2. other 经 ToTemporalYearMonth(串 / bag / PD / PYM 实例;day 不读)。 +/// 3. 日历相等(不等 RangeError;在 options 读取之前)。 +/// 4. options 对象 + 差值设置(单位表仅 year/month,smallestUnit 缺省 month, +/// largestUnit 缺省/auto 为 year)。 +/// 5. (年, 月, 参考日) 三元全等 → 空 Duration(先于可表示性检查)。 +/// 6. 两端按日 1 查可表示性(ISODateWithinLimits,day 级)→ RangeError。 +/// 7. 既有差值机件(nudge 窗口端点同样带 day 级范围检查);since 整体取反。 +/// +/// # 边界与副作用 +/// - 参考日只参与第 5 步的全等判断;差值本身恒按日 1 计算。 +/// - 第 2 步的字符串/袋路径为年月级范围检查(自 ToTemporalYearMonth), +/// day 级补查在第 6 步,两步错误类相同(RangeError),仅抛错时点不同。 +fn plain_year_month_difference(vm: &mut H, args: &[u8], since: bool) -> NativeResult { + let ptr = native_try!(receiver_obj(vm, args)); + let obj = unsafe { &*ptr }; + native_try!(ensure_plain_year_month(vm, obj)); + let other_val = if args.len() > 1 { vm.reg(args[1]) } else { JsValue::undefined() }; + let (oy, om, od, ocal) = native_try!(year_month_like_parts(vm, other_val, true)); + // 日历相等(ToTemporalYearMonth 之后、options 读取之前)。 + if get_calendar_id(obj, 3) != ocal { + return NativeResult::Err(crate::error::create_range_error(vm, "calendars must be equal")); + } + let options_value = if args.len() > 2 { vm.reg(args[2]) } else { JsValue::undefined() }; + let settings = match parse_year_month_difference_settings(vm, options_value) { + Ok(settings) => settings, + Err(error) => return NativeResult::Err(error), + }; + // (年, 月, 参考日) 三元全等 → 空 Duration(先于可表示性检查)。 + let ry = get_double_prop(obj, 0) as i128; + let rm = get_double_prop(obj, 1) as i128; + let rd = get_double_prop(obj, 2) as i128; + if compare_iso_date((ry, rm, rd), (i128::from(oy), i128::from(om), i128::from(od))) == 0 { + return make_duration(vm, [0.0; 10]); + } + // 两端按日 1 的可表示性检查(ISODateWithinLimits,day 级)。 + if days_from_civil(ry, rm, 1).abs() > MAX_ISO_DAY + || days_from_civil(i128::from(oy), i128::from(om), 1).abs() > MAX_ISO_DAY + { + return NativeResult::Err(crate::error::create_range_error(vm, "date is out of range")); + } + difference_core(vm, (ry, rm, 1), 0, (i128::from(oy), i128::from(om), 1), 0, settings, since, 1) +} + /// `Temporal.PlainMonthDay.prototype.equals(other)`:比较 (月, 日, 参考年) 三元与日历标识; /// other 经 ToTemporalMonthDay(串 / 字段对象 / 实例)转换。 pub fn plain_month_day_equals(vm: &mut H, args: &[u8]) -> NativeResult { diff --git a/crates/oxide_vm/src/bindings/bind_temporal.rs b/crates/oxide_vm/src/bindings/bind_temporal.rs index b6b9f683..ca614139 100644 --- a/crates/oxide_vm/src/bindings/bind_temporal.rs +++ b/crates/oxide_vm/src/bindings/bind_temporal.rs @@ -1155,6 +1155,8 @@ pub fn bind_temporal(core: &Arc, session: &KernelSession, global: &m oxide_builtins::temporal::plain_year_month_to_plain_date:: as *const (), 1, ), + ("until", oxide_builtins::temporal::plain_year_month_until:: as *const (), 1), + ("since", oxide_builtins::temporal::plain_year_month_since:: 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 70ec190b..ffcbd5c4 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -3489,3 +3489,82 @@ fn plain_year_month_to_plain_date_matrix() { "2002-01-22|TypeError|TypeError|TypeError|TypeError|2023-02-28|1998-06-30|2000-05-01|2000-05-01|-271821-04-19|RangeError|275760-09-13|RangeError|RangeError" ); } + +#[test] +fn plain_year_month_until_since_matrix() { + let mut vm = Vm::new(); + // until/since:other 四分支(串/bag/PD/PYM 实例);日历相等(RangeError); + // 单位表仅 year/month(缺省 smallest month / largest year);参考日三元全等 + // 先于可表示性检查返回空 Duration;两端按日 1 的 day 级范围;nudge 舍入 + // (跨单位进位、half 边界、窗口端点范围);since 整体取反。 + let r = eval( + &mut vm, + "(() => { + const kind = (fn) => { try { fn(); return 'no'; } catch (e) { return e.constructor.name; } }; + const ym = (y, m) => new Temporal.PlainYearMonth(y, m); + const d = (x) => x.years + ',' + x.months; + return [ + d(ym(2000, 5).until(ym(2001, 6))), + d(ym(2001, 6).until(ym(2000, 5))), + d(ym(2000, 5).until(ym(2001, 6), { largestUnit: 'months' })), + d(ym(2000, 5).until(ym(2001, 6), { largestUnit: 'years' })), + d(ym(2000, 5).until(ym(2001, 6), { largestUnit: 'auto' })), + d(ym(2000, 5).until(ym(2001, 6), { largestUnit: 'years', smallestUnit: 'months' })), + kind(() => ym(2000, 5).until(ym(2001, 6), { largestUnit: 'months', smallestUnit: 'years' })), + d(ym(1976, 11).until('1976-11-18T15:23:30.1+00:00')), + d(ym(2016, 12).since('2016-12-31T23:59:60')), + d(ym(-9999, 11).until('-009999-11')), + d(ym(1994, 11).until(ym(2013, 6))), + d(ym(1994, 11).until({ year: 2013, month: 6 })), + d(ym(1994, 11).until('2013-06')), + d(ym(1994, 11).until(Temporal.PlainDate.from('2013-06-15'))), + d(ym(2013, 6).since(ym(1994, 11))), + d(ym(2013, 6).since(ym(2013, 6))), + kind(() => ym(2019, 10).until(ym(1976, 11), null)), + d(ym(2019, 10).until(ym(1976, 11), {})), + d(ym(2019, 10).until(ym(1976, 11), () => {})), + kind(() => ym(2000, 5).until(1)), + kind(() => ym(2000, 5).until('')), + kind(() => ym(2000, 5).until({})), + kind(() => ym(2000, 5).until({ month: 6 })), + kind(() => ym(2000, 5).until({ year: 2000, month: Infinity })), + kind(() => ym(2000, 5).until({ year: 2000, month: 6, calendar: 'hebrew' })), + d(ym(2000, 5).until({ year: 2005, month: 6, calendar: 'iso8601' })), + kind(() => ym(2019, 1).until(ym(2021, 9), { largestUnit: 'weeks' })), + kind(() => ym(2019, 1).until(ym(2021, 9), { smallestUnit: 'days' })), + kind(() => ym(2019, 1).until(ym(2021, 9), { smallestUnit: 'hours' })), + kind(() => ym(2000, 5).until(ym(2001, 6), { largestUnit: 'bogus' })), + kind(() => ym(2000, 5).until(ym(2001, 6), { smallestUnit: 'bogus' })), + kind(() => ym(2000, 5).until(ym(2001, 6), { roundingMode: 'bogus' })), + kind(() => ym(2000, 5).until(ym(2001, 6), { roundingIncrement: NaN })), + kind(() => ym(2000, 5).until(ym(2001, 6), { roundingIncrement: 0 })), + kind(() => ym(2000, 5).until(ym(2001, 6), { roundingIncrement: 1e9 + 1 })), + ym(2000, 5).until(ym(2001, 6), { roundingIncrement: '1' }).years, + d(ym(1970, 1).since('-271821-05')), + kind(() => ym(1970, 1).since('-271821-04')), + kind(() => ym(1970, 1).since('+275760-10')), + d(ym(1970, 1).since('+275760-09')), + d(ym(-271821, 4).until(ym(-271821, 4))), + kind(() => ym(-271821, 4).until(ym(1970, 1))), + kind(() => ym(2019, 10).until('-000000-06')), + d(ym(2022, 1).until(ym(2023, 12), { largestUnit: 'years', smallestUnit: 'months', roundingIncrement: 3, roundingMode: 'expand' })), + d(ym(2012, 1).until(ym(2013, 1), { smallestUnit: 'months', largestUnit: 'years', roundingIncrement: 2, roundingMode: 'halfEven' })), + d(ym(2012, 1).until(ym(2011, 1), { smallestUnit: 'months', largestUnit: 'years', roundingIncrement: 2, roundingMode: 'halfEven' })), + d(ym(2018, 6).until(ym(2019, 12), { smallestUnit: 'years', roundingMode: 'trunc' })), + d(ym(2018, 6).until(ym(2019, 12), { smallestUnit: 'years', roundingMode: 'halfEven' })), + d(ym(2017, 6).until(ym(2019, 12), { smallestUnit: 'years', roundingMode: 'halfEven' })), + d(ym(2017, 6).until(ym(2019, 12), { smallestUnit: 'years', roundingMode: 'halfExpand' })), + d(ym(2019, 1).until(ym(2021, 9), { smallestUnit: 'years', roundingIncrement: 4, roundingMode: 'halfExpand' })), + d(ym(2019, 1).until(ym(2021, 9), { smallestUnit: 'months', roundingIncrement: 5 })), + d(ym(2019, 1).until(ym(2021, 9), { largestUnit: 'months', smallestUnit: 'months', roundingIncrement: 10 })), + kind(() => ym(1970, 1).until(ym(1971, 1), { roundingIncrement: 100000000 })), + d(ym(1970, 1).until(ym(1971, 1))), + ].join('|'); + })()", + ) + .unwrap(); + assert_eq!( + str_val(&vm, r), + "1,1|-1,-1|0,13|1,1|1,1|1,1|RangeError|0,0|0,0|0,0|18,7|18,7|18,7|18,7|18,7|0,0|TypeError|-42,-11|-42,-11|TypeError|RangeError|TypeError|TypeError|RangeError|RangeError|5,1|RangeError|RangeError|RangeError|RangeError|RangeError|RangeError|RangeError|RangeError|RangeError|1|273790,8|RangeError|RangeError|-273790,-8|0,0|RangeError|RangeError|2,0|1,0|-1,0|1,0|2,0|2,0|3,0|4,0|2,5|0,30|RangeError|1,0" + ); +}