From eb2e999612fd77c9782993f409b8ac0f996bcc93 Mon Sep 17 00:00:00 2001 From: pjh456 <147148383@qq.com> Date: Mon, 28 Sep 2026 15:57:35 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20temporal=20relativeTo=20property=20bag?= =?UTF-8?q?=20=E8=AF=BB=E5=BA=8F=E5=AF=B9=E9=BD=90=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=B9=B6=E5=8A=A0=20era=20=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/oxide_builtins/src/temporal/common.rs | 80 +++++++- .../oxide_builtins/src/temporal/duration.rs | 181 +++++++++++++++--- .../oxide_vm/tests/builtins_temporal_tests.rs | 82 ++++++++ 3 files changed, 309 insertions(+), 34 deletions(-) diff --git a/crates/oxide_builtins/src/temporal/common.rs b/crates/oxide_builtins/src/temporal/common.rs index 33ffdd8e..e35d8584 100644 --- a/crates/oxide_builtins/src/temporal/common.rs +++ b/crates/oxide_builtins/src/temporal/common.rs @@ -335,14 +335,10 @@ pub(crate) fn plain_date_time_object_parts( return Err(crate::error::create_type_error(vm, "monthCode must be a string")); } let code = to_string(month_code_raw); - // 语法(well-formed):M 后两位数字,可选 L 后缀。 - let digits_ok = - code.len() >= 3 && code.starts_with('M') && code.as_bytes()[1..3].iter().all(u8::is_ascii_digit); - let well_formed = digits_ok && (code.len() == 3 || (code.len() == 4 && code.ends_with('L'))); - if !well_formed { + if !month_code_well_formed(&code) { return Err(crate::error::create_range_error(vm, "invalid monthCode")); } - Some((code.clone(), code.ends_with('L'))) + Some(code) }; // 数值字段类型转换:Symbol/BigInt 抛 TypeError。 @@ -365,10 +361,78 @@ pub(crate) fn plain_date_time_object_parts( convert_number("year", year_raw)?, ); + resolve_date_time_parts( + vm, + calendar, + DateTimeParts { + year, + month, + month_code, + day, + hour, + minute, + second, + millisecond, + microsecond, + nanosecond, + }, + constrain, + ignore_time, + ) +} + +/// monthCode 语法(well-formed):M 后两位数字,可选 L 后缀(第 4 位)。 +pub(crate) fn month_code_well_formed(code: &str) -> bool { + code.len() >= 3 + && code.starts_with('M') + && code.as_bytes()[1..3].iter().all(u8::is_ascii_digit) + && (code.len() == 3 || (code.len() == 4 && code.ends_with('L'))) +} + +/// 已转换的日期时间 property bag 字段值(resolve_date_time_parts 的输入)。 +pub(crate) struct DateTimeParts { + pub year: Option, + pub month: Option, + pub month_code: Option, + pub day: Option, + pub hour: f64, + pub minute: f64, + pub second: f64, + pub millisecond: f64, + pub microsecond: f64, + pub nanosecond: f64, +} + +/// 日期时间 property bag 校验共享核:monthCode ISO 适配 + constrain 钳制 + 范围检查。 +/// +/// # 步骤 +/// 1. monthCode ISO 适配:仅 M01-M12,L 后缀与超界拒绝,与 month 冲突拒绝(RangeError)。 +/// 2. day/year 兜底必填(调用方已保证);全分量有限性 + 非负检查(RangeError)。 +/// 3. constrain 钳制:月 1..12、日当月末日、时 0..23、分秒 0..59、毫秒/微秒/纳秒 0..999。 +/// 4. 有界整型转换 + valid_iso_date / valid_plain_time / valid_plain_date_time_range 检查。 +/// +/// # 边界与前提 +/// - 调用方须已完成必填字段检查(year/day/month 或 monthCode)与逐字段类型转换。 +/// - 字段读序与转换由调用方负责,本核只消费已转换值。 +pub(crate) fn resolve_date_time_parts( + vm: &mut H, calendar: Option, parts: DateTimeParts, constrain: bool, ignore_time: bool, +) -> Result<(i32, u32, u32, f64, Option), JsValue> { + let DateTimeParts { + year, + month, + month_code, + day, + hour, + minute, + second, + millisecond, + microsecond, + nanosecond, + } = parts; // monthCode 适配 ISO 日历:仅 M01-M12,L 后缀不支持;无效抛 RangeError。 let month_code = match month_code { - Some((code, leap)) => { - if leap { + Some(code) => { + if code.ends_with('L') { return Err(crate::error::create_range_error(vm, "monthCode is not valid for ISO calendar")); } let number = code[1..3] diff --git a/crates/oxide_builtins/src/temporal/duration.rs b/crates/oxide_builtins/src/temporal/duration.rs index 3fff94d1..c4b5a4d5 100644 --- a/crates/oxide_builtins/src/temporal/duration.rs +++ b/crates/oxide_builtins/src/temporal/duration.rs @@ -8,9 +8,9 @@ use oxide_types::value::JsValue; use super::common::native_try; use super::common::{ canonical_time_zone, civil_from_days, days_from_civil, ensure_duration, get_calendar_id, get_double_prop, - get_instant_epoch_ns, initialize_temporal_receiver, is_ctor_call, local_to_epoch_ns, native_engine_error, - plain_date_time_object_parts, receiver_obj, temporal_option_number, temporal_option_string, temporal_option_value, - zoned_date_time_plain_parts, + get_instant_epoch_ns, initialize_temporal_receiver, is_ctor_call, local_to_epoch_ns, month_code_well_formed, + native_engine_error, receiver_obj, resolve_date_time_parts, temporal_calendar_id, temporal_option_number, + temporal_option_string, temporal_option_value, temporal_string_strict, zoned_date_time_plain_parts, DateTimeParts, }; use super::difference::{ add_date_duration, add_days_iso, balance_instant_difference, compare_iso_date, date_duration_sign, date_until_iso, @@ -339,12 +339,14 @@ fn relative_point_wall_date(vm: &mut H, point: &RelativePoint) -> Res /// 得 Zoned 载荷;无注解走 plain 解析(纯日期、日期时间、带偏移的 plain 串)得 Wall 载荷。 /// 2. PlainDateTime / PlainDate 对象 → 直读 prop 0/1/2,Wall 载荷。 /// 3. ZonedDateTime → Zoned 载荷(zoned_date_time_plain_parts 范围检查后直读三槽)。 -/// 4. 其他对象(含 Instant)按 property bag 解析(ToRelativeTemporalObject,constrain),Wall 载荷。 +/// 4. 其他对象(含 Instant)按 property bag 解析:字段按规范字典序单读(calendar 先, +/// 非 iso8601 日历读 era/eraYear),逐字段 Get 后立即转换,constrain,Wall 载荷。 /// 5. undefined → None;其余原始值 → TypeError。 /// /// # 边界与前提 /// - 调用方须已把 raw 从 options 取出(读序由调用方保证)。 -/// - bag 支 offset 语法校验先于数值字段转换;offset-vs-zone 匹配校验在字段转换之后。 +/// - bag 支 offset 语法校验在字典序位(nanosecond 后)立即完成,先于后续字段转换; +/// offset-vs-zone 匹配校验在字段转换之后。 fn duration_relative_to_date(vm: &mut H, relative_raw: JsValue) -> Result, JsValue> { if relative_raw.is_undefined() { return Ok(None); @@ -401,38 +403,109 @@ fn duration_relative_to_date(vm: &mut H, relative_raw: JsValue) -> Re calendar: get_calendar_id(obj, 2), })); } - // property bag:按 ToRelativeTemporalObject 依次校验 calendar/timeZone/offset 再读字段。 - // 规范 13.19 无 Instant 分支:Instant 对象落入此处按字段解析(缺 day 抛错)。 + // property bag:字段按规范字典序单读(calendar 先),逐字段 Get 后立即转换。 + // calendar 经宽松版接受:白名单/ISO 串(非法 RangeError)、Temporal 实例读日历槽、其余 TypeError。 let calendar_raw = temporal_option_value(vm, obj, relative_raw, "calendar")?; - if !calendar_raw.is_undefined() { - return Err(crate::error::create_type_error(vm, "invalid relativeTo")); - } + let calendar = temporal_calendar_id(vm, calendar_raw)?; + let non_iso_calendar = calendar.as_deref() != Some("iso8601"); + + let day_raw = temporal_option_value(vm, obj, relative_raw, "day")?; + let day = bag_number_field(vm, day_raw)?; + // era/eraYear 仅非 iso8601 日历读取(规范扩展字段);iso8601 袋不读,读到即违反读序。 + let era_raw = if non_iso_calendar { + temporal_option_value(vm, obj, relative_raw, "era")? + } else { + JsValue::undefined() + }; + let era = bag_string_field(vm, era_raw)?; + let era_year_raw = if non_iso_calendar { + temporal_option_value(vm, obj, relative_raw, "eraYear")? + } else { + JsValue::undefined() + }; + let era_year = bag_number_field(vm, era_year_raw)?; + let hour_raw = temporal_option_value(vm, obj, relative_raw, "hour")?; + let hour = bag_number_field(vm, hour_raw)?.unwrap_or(0.0); + let microsecond_raw = temporal_option_value(vm, obj, relative_raw, "microsecond")?; + let microsecond = bag_number_field(vm, microsecond_raw)?.unwrap_or(0.0); + let millisecond_raw = temporal_option_value(vm, obj, relative_raw, "millisecond")?; + let millisecond = bag_number_field(vm, millisecond_raw)?.unwrap_or(0.0); + let minute_raw = temporal_option_value(vm, obj, relative_raw, "minute")?; + let minute = bag_number_field(vm, minute_raw)?.unwrap_or(0.0); + let month_raw = temporal_option_value(vm, obj, relative_raw, "month")?; + let month = bag_number_field(vm, month_raw)?; + // monthCode 经 ToPrimitive(String):非串 TypeError,语法不合法 RangeError。 + let month_code_raw = temporal_option_value(vm, obj, relative_raw, "monthCode")?; + let month_code = if month_code_raw.is_undefined() { + None + } else { + let code = temporal_string_strict(vm, month_code_raw)?; + if !month_code_well_formed(&code) { + return Err(crate::error::create_range_error(vm, "invalid monthCode")); + } + Some(code) + }; + let nanosecond_raw = temporal_option_value(vm, obj, relative_raw, "nanosecond")?; + let nanosecond = bag_number_field(vm, nanosecond_raw)?.unwrap_or(0.0); + // offset 经 ToPrimitive(String) 后语法校验(RangeError),先于后续字段转换。 + let offset_raw = temporal_option_value(vm, obj, relative_raw, "offset")?; + let bag_offset_seconds = if offset_raw.is_undefined() { + None + } else { + let offset_input = temporal_string_strict(vm, offset_raw)?; + let (seconds, _has_sub_minute) = parse_any_offset_seconds(&offset_input) + .filter(|_| valid_offset_fraction(&offset_input)) + .ok_or_else(|| crate::error::create_range_error(vm, "invalid offset"))?; + Some(seconds) + }; + let second_raw = temporal_option_value(vm, obj, relative_raw, "second")?; + let second = bag_number_field(vm, second_raw)?.unwrap_or(0.0); + // timeZone 保持原始类型检查:非字符串(对象/符号/null/数字)→ TypeError;字符串非合法时区 → RangeError。 let time_zone_raw = temporal_option_value(vm, obj, relative_raw, "timeZone")?; let time_zone_id = if time_zone_raw.is_undefined() { None } else { - // 非字符串(对象/符号/null/数字)→ TypeError;字符串非合法时区 → RangeError。 if !time_zone_raw.is_string() { return Err(crate::error::create_type_error(vm, "invalid time zone")); } let tz_input = to_string(time_zone_raw); Some(canonical_time_zone(&tz_input).ok_or_else(|| crate::error::create_range_error(vm, "invalid time zone"))?) }; - let offset_raw = temporal_option_value(vm, obj, relative_raw, "offset")?; - let bag_offset_seconds = if offset_raw.is_undefined() { - None - } else { - // 非字符串 → TypeError;字符串格式非法(含亚秒偏移位数超限)→ RangeError。 - if !offset_raw.is_string() { - return Err(crate::error::create_type_error(vm, "invalid offset")); - } - let offset_input = to_string(offset_raw); - let (seconds, _has_sub_minute) = parse_any_offset_seconds(&offset_input) - .filter(|_| valid_offset_fraction(&offset_input)) - .ok_or_else(|| crate::error::create_range_error(vm, "invalid offset"))?; - Some(seconds) - }; - let (year, month, day, time_ns, calendar) = plain_date_time_object_parts(vm, relative_raw, obj, true, false)?; + let year_raw = temporal_option_value(vm, obj, relative_raw, "year")?; + let year = bag_number_field(vm, year_raw)?; + + // 缺失必填字段先抛 TypeError(先于任何 RangeError 值校验)。 + if day.is_none() { + return Err(crate::error::create_type_error(vm, "day is required")); + } + if month.is_none() && month_code.is_none() { + return Err(crate::error::create_type_error(vm, "month is required")); + } + + // era/eraYear 合并(非 iso8601 日历,gregory 最小语义):三者不全(仅 era 或仅 eraYear) + // TypeError;eraYear 0 归 1、负值换 era 续数;ad → year=eraYear、bc → year=1-eraYear; + // year 与 era/eraYear 同场指向不同年 RangeError。 + let year = merge_bag_era_year(vm, year, era, era_year)?; + + // 袋支尾部保持 Wall 载荷(Zoned 载荷归后续任务)。 + let (year, month, day, time_ns, calendar) = resolve_date_time_parts( + vm, + calendar, + DateTimeParts { + year: Some(year), + month, + month_code, + day, + hour, + minute, + second, + millisecond, + microsecond, + nanosecond, + }, + true, + false, + )?; // offset-vs-zone 校验(对象支精确一致、reject 语义):offset 与 timeZone 同时在场时, // 候选 epoch 点 zone 偏移须与输入偏移秒级相等。 if let (Some(offset), Some(tz_id)) = (bag_offset_seconds, time_zone_id.as_deref()) { @@ -450,6 +523,62 @@ fn duration_relative_to_date(vm: &mut H, relative_raw: JsValue) -> Re })) } +/// 袋支数值字段:undefined → None,其余 ToPrimitive(Number) 截断(Symbol/BigInt TypeError)。 +fn bag_number_field(vm: &mut H, raw: JsValue) -> Result, JsValue> { + if raw.is_undefined() { + Ok(None) + } else { + temporal_option_number(vm, raw).map(|number| Some(number.trunc())) + } +} + +/// 袋支字符串字段:undefined → None,其余 ToPrimitive(String)(非串 TypeError)。 +fn bag_string_field(vm: &mut H, raw: JsValue) -> Result, JsValue> { + if raw.is_undefined() { + Ok(None) + } else { + temporal_string_strict(vm, raw).map(Some) + } +} + +/// era/eraYear 合并到 year(gregory 最小语义): +/// 仅 era 或仅 eraYear 在场 → TypeError;era 非 ad/bc(大小写不敏感)→ RangeError; +/// eraYear 0 归 1,负值换 era 续数(bc 的 -1 → ad 2 式);ad → year=eraYear、bc → year=1-eraYear。 +fn merge_bag_era_year( + vm: &mut H, year: Option, era: Option, era_year: Option, +) -> Result { + match (year, era, era_year) { + (Some(year), Some(era), Some(era_year)) => { + let merged = compute_era_year(vm, &era, era_year)?; + if merged != year { + return Err(crate::error::create_range_error(vm, "era and year disagree")); + } + Ok(merged) + } + (Some(year), None, None) => Ok(year), + (None, Some(era), Some(era_year)) => compute_era_year(vm, &era, era_year), + (None, None, None) => Err(crate::error::create_type_error(vm, "year is required")), + _ => Err(crate::error::create_type_error(vm, "era and eraYear must both be present")), + } +} + +fn compute_era_year(vm: &mut H, era: &str, era_year: f64) -> Result { + let era = era.to_ascii_lowercase(); + if era != "ad" && era != "bc" { + return Err(crate::error::create_range_error(vm, "invalid era")); + } + let mut era_is_ad = era == "ad"; + let mut era_year = era_year; + if era_year == 0.0 { + era_year = 1.0; + } + if era_year < 0.0 { + era_is_ad = !era_is_ad; + era_year = 1.0 - era_year; + } + Ok(if era_is_ad { era_year } else { 1.0 - era_year }) +} + fn format_duration_number(value: f64) -> String { format!("{}", value as i64) } diff --git a/crates/oxide_vm/tests/builtins_temporal_tests.rs b/crates/oxide_vm/tests/builtins_temporal_tests.rs index cc9e66c0..a4e62f2c 100644 --- a/crates/oxide_vm/tests/builtins_temporal_tests.rs +++ b/crates/oxide_vm/tests/builtins_temporal_tests.rs @@ -3946,3 +3946,85 @@ fn plain_date_time_ctor_undefined_time_components_default() { "2020-01-01T00:00:00|2020-01-01T10:00:00.005|2020-01-01T00:00:00|RangeError|RangeError|RangeError" ); } + +// -- relativeTo property bag(袋支读序 / calendar 接受面 / era 合并) -- + +#[test] +fn relativeto_bag_calendar_acceptance() { + let mut vm = Vm::new(); + // calendar 经宽松版接受:白名单命中、ISO 日期串归一 iso8601、非法串 RangeError、 + // 非字符串(数字/null)TypeError。 + let r = eval( + &mut vm, + "(() => { + const kind = (fn) => { try { fn(); return 'ok'; } catch (e) { return e.constructor.name; } }; + const d1 = new Temporal.Duration(1); + const d2 = new Temporal.Duration(0); + const cmp = (cal) => Temporal.Duration.compare(d1, d2, { relativeTo: { year: 2020, month: 1, day: 1, calendar: cal } }); + return [ + kind(() => cmp('iso8601')), + kind(() => cmp('gregory')), + kind(() => cmp('2020-01-01')), + kind(() => cmp('notacal')), + kind(() => cmp('')), + kind(() => cmp(1)), + kind(() => cmp(null)), + ].join('|'); + })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "ok|ok|ok|RangeError|RangeError|TypeError|TypeError"); +} + +#[test] +fn relativeto_bag_era_merge() { + let mut vm = Vm::new(); + // era/eraYear 仅非 iso8601 日历读取,gregory 最小合并语义:ad/eraYear 正常合并、 + // eraYear 0 归 1、bc 负年换 era 续数、year 与 era/eraYear 同场指向不同年 RangeError、 + // eraYear Infinity RangeError、仅 era 或仅 eraYear(三者不全)TypeError。 + let r = eval( + &mut vm, + "(() => { + const kind = (fn) => { try { fn(); return 'ok'; } catch (e) { return e.constructor.name; } }; + const d = new Temporal.Duration(1, 0, 0, 0, 24); + const round = (rel) => d.round({ largestUnit: 'years', relativeTo: rel }); + return [ + kind(() => round({ month: 5, day: 2, hour: 15, calendar: 'gregory', era: 'ad', eraYear: 2020 })), + kind(() => round({ month: 5, day: 2, hour: 15, calendar: 'gregory', era: 'ad', eraYear: 0 })), + kind(() => round({ month: 5, day: 2, hour: 15, calendar: 'gregory', era: 'bc', eraYear: -1 })), + kind(() => round({ year: 2020, month: 5, day: 2, hour: 15, calendar: 'gregory', era: 'ad', eraYear: 2020 })), + kind(() => round({ year: 2020, month: 5, day: 2, hour: 15, calendar: 'gregory', era: 'ad', eraYear: 2021 })), + kind(() => round({ month: 5, day: 2, hour: 15, calendar: 'gregory', era: 'ad', eraYear: Infinity })), + kind(() => round({ year: 2020, month: 5, day: 2, hour: 15, calendar: 'gregory', era: 'ad' })), + kind(() => round({ year: 2020, month: 5, day: 2, hour: 15, calendar: 'gregory', eraYear: 2020 })), + ].join('|'); + })()", + ) + .unwrap(); + assert_eq!(str_val(&vm, r), "ok|ok|ok|ok|RangeError|RangeError|TypeError|TypeError"); +} + +#[test] +fn relativeto_bag_read_order_calendar_first_dictionary() { + let mut vm = Vm::new(); + // 袋支读序:calendar 单次读(先),其余字段按规范字典序逐字段 Get。 + // 用 getter 记录读序;calendar 只出现一次(旧实现二次读 calendar 违反读序钉)。 + let r = eval( + &mut vm, + "(() => { + const order = []; + const values = { calendar: 'iso8601', day: 2, hour: 6, microsecond: 2, millisecond: 1, minute: 30, month: 5, monthCode: 'M05', nanosecond: 3, second: 15, year: 2020 }; + const rel = {}; + for (const k of Object.keys(values)) { + Object.defineProperty(rel, k, { get() { order.push(k); return values[k]; }, configurable: true }); + } + Temporal.Duration.compare(new Temporal.Duration(1), new Temporal.Duration(0), { relativeTo: rel }); + return order.join(','); + })()", + ) + .unwrap(); + assert_eq!( + str_val(&vm, r), + "calendar,day,hour,microsecond,millisecond,minute,month,monthCode,nanosecond,second,year" + ); +}