feat: temporal relativeTo property bag 读序对齐规范并加 era 合并

This commit is contained in:
2026-09-28 15:57:35 +00:00
parent 9a9264c2e1
commit eb2e999612
3 changed files with 309 additions and 34 deletions
+72 -8
View File
@@ -335,14 +335,10 @@ pub(crate) fn plain_date_time_object_parts<H: VmHost>(
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<H: VmHost>(
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<f64>,
pub month: Option<f64>,
pub month_code: Option<String>,
pub day: Option<f64>,
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<H: VmHost>(
vm: &mut H, calendar: Option<String>, parts: DateTimeParts, constrain: bool, ignore_time: bool,
) -> Result<(i32, u32, u32, f64, Option<String>), 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]
+153 -24
View File
@@ -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<H: VmHost>(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<H: VmHost>(vm: &mut H, relative_raw: JsValue) -> Result<Option<RelativePoint>, JsValue> {
if relative_raw.is_undefined() {
return Ok(None);
@@ -401,38 +403,109 @@ fn duration_relative_to_date<H: VmHost>(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 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"));
}
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)?;
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<H: VmHost>(vm: &mut H, relative_raw: JsValue) -> Re
}))
}
/// 袋支数值字段:undefined → None,其余 ToPrimitive(Number) 截断(Symbol/BigInt TypeError)。
fn bag_number_field<H: VmHost>(vm: &mut H, raw: JsValue) -> Result<Option<f64>, 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<H: VmHost>(vm: &mut H, raw: JsValue) -> Result<Option<String>, 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<H: VmHost>(
vm: &mut H, year: Option<f64>, era: Option<String>, era_year: Option<f64>,
) -> Result<f64, JsValue> {
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<H: VmHost>(vm: &mut H, era: &str, era_year: f64) -> Result<f64, JsValue> {
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)
}
@@ -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"
);
}