diff --git a/lib/calendar.ts b/lib/calendar.ts index cacf9eba..e840acc6 100644 --- a/lib/calendar.ts +++ b/lib/calendar.ts @@ -1,3 +1,4 @@ +import { assert } from './assert'; import * as ES from './ecmascript'; import { DefineIntrinsic } from './intrinsicclass'; import type { Temporal } from '..'; @@ -16,6 +17,8 @@ import type { } from './internaltypes'; import { CreateMonthCode, ParseMonthCode } from './monthcode'; +const midnightTimeRecord = ES.MidnightTimeRecord(); + function arrayFromSet(src: Set): T[] { return [...src]; } @@ -356,24 +359,38 @@ type CalendarYM = { year: number; month: number }; type CalendarYearOnly = { year: number }; type EraAndEraYear = { era: string; eraYear: number }; -const monthCodeInfo: Partial> = { +type CycleInfo = { + years: number; + months: number; +}; +type MonthCodeInfo = { + additionalMonths: string[]; + cycleInfo?: CycleInfo; +}; + +const monthCodeInfo: Partial> = { chinese: { additionalMonths: ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'] }, coptic: { - additionalMonths: ['M13'] + additionalMonths: ['M13'], + cycleInfo: { years: 1, months: 13 } }, dangi: { additionalMonths: ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'] }, ethioaa: { - additionalMonths: ['M13'] + additionalMonths: ['M13'], + cycleInfo: { years: 1, months: 13 } }, ethiopic: { - additionalMonths: ['M13'] + additionalMonths: ['M13'], + cycleInfo: { years: 1, months: 13 } }, hebrew: { - additionalMonths: ['M05L'] + additionalMonths: ['M05L'], + // Metonic cycle: 7 leap years every 19 years + cycleInfo: { years: 19, months: 19 * 12 + 7 } } }; @@ -426,43 +443,43 @@ function weekNumber(firstDayOfWeek: number, minimalDaysInFirstWeek: number, desi return weekNo; } -const eraInfo: Partial>> = { - buddhist: { +const eraInfoEntries: Partial> = { + buddhist: Object.entries({ be: {} - }, - coptic: { + }), + coptic: Object.entries({ am: {} - }, - ethioaa: { + }), + ethioaa: Object.entries({ aa: { aliases: ['mundi'] } - }, - ethiopic: { + }), + ethiopic: Object.entries({ am: { aliases: ['incar'] }, aa: { aliases: ['mundi'] } - }, - gregory: { + }), + gregory: Object.entries({ ce: { aliases: ['ad'] }, bce: { aliases: ['bc'] } - }, - hebrew: { + }), + hebrew: Object.entries({ am: {} - }, - indian: { + }), + indian: Object.entries({ shaka: {} - }, - 'islamic-civil': { + }), + 'islamic-civil': Object.entries({ ah: {}, bh: {} - }, - 'islamic-tbla': { + }), + 'islamic-tbla': Object.entries({ ah: {}, bh: {} - }, - 'islamic-umalqura': { + }), + 'islamic-umalqura': Object.entries({ ah: {}, bh: {} - }, - japanese: { + }), + japanese: Object.entries({ reiwa: {}, heisei: {}, showa: {}, @@ -470,28 +487,27 @@ const eraInfo: Partial0.2ms). This trivial cache will speed * up these repeat accesses. Each cache instance is associated (via a WeakMap) - * to a specific Temporal object, which speeds up multiple calendar calls on the - * same Temporal object instance. No invalidation or pruning is necessary - * because each object's cache is thrown away when the object is GC-ed. + * to a specific ISO Date Record object, which speeds up multiple calendar calls + * on Temporal objects with the same ISO Date Record instance. No invalidation + * or pruning is necessary because each object's cache is thrown away when the + * object is GC-ed. */ class OneObjectCache { + id: BuiltinCalendarId; map = new Map(); calls = 0; // now = OneObjectCache.monotonicTimestamp(); @@ -516,8 +534,10 @@ class OneObjectCache { // return performance?.now() ?? Date.now(); // } - constructor(cacheToClone?: OneObjectCache) { + constructor(id: BuiltinCalendarId, cacheToClone?: OneObjectCache) { + this.id = id; if (cacheToClone !== undefined) { + assert(cacheToClone.id === this.id, 'should not clone cache from a different calendar'); let i = 0; for (const entry of cacheToClone.map.entries()) { if (++i > OneObjectCache.MAX_CACHE_ENTRIES) break; @@ -525,7 +545,7 @@ class OneObjectCache { } } } - get(key: string) { + get(key: number) { const result = this.map.get(key); if (result) { this.hits++; @@ -534,7 +554,7 @@ class OneObjectCache { this.calls++; return result; } - set(key: string, value: unknown) { + set(key: number, value: unknown) { this.map.set(key, value); this.misses++; this.report(); @@ -552,6 +572,34 @@ class OneObjectCache { OneObjectCache.objectMap.set(obj, this); this.report(); } + // Cache keys are int32 + // int32 msb fedcba9876543210fedcba9876543210 lsb + // uyyyyyyyyyyyyyyyyyyyymmmmdddddff + // u = unused (1 bit) + // y = year + 280804 (20 bits; max is 564387) + // m = month (4 bits; max is 13) + // d = day (5 bits; max is 31) + // f = flags (indicates type of key, and overflow for calendar-to-ISO type) + // 00 = Chinese/Dangi month list + // 01 = ISO-to-calendar + // 10 = calendar-to-ISO, overflow constrain + // 11 = calendar-to-ISO, overflow reject + static privKey(year: number, month: number, day: number, flags: number) { + // -280804 is the earliest year number in any supported calendar (in this + // case, Hijri calendars) + const unsignedYear = year + 280804; + return (unsignedYear << 11) | (month << 7) | (day << 2) | flags; + } + static generateCalendarToISOKey({ year, month, day }: CalendarYMD, overflow: Overflow) { + const flags = overflow === 'constrain' ? 0b10 : 0b11; + return this.privKey(year, month, day, flags); + } + static generateISOToCalendarKey({ year, month, day }: ISODate) { + return this.privKey(year, month, day, 1); + } + static generateMonthListKey(year: number) { + return this.privKey(year, 0, 0, 0); + } static objectMap = new WeakMap(); static MAX_CACHE_ENTRIES = 1000; @@ -560,19 +608,20 @@ class OneObjectCache { * Returns a WeakMap-backed cache that's used to store expensive results * that are associated with a particular Temporal object instance. * + * @param id - calendar ID for the cache * @param obj - object to associate with the cache */ - static getCacheForObject(obj: ISODate) { + static getCacheForObject(id: BuiltinCalendarId, obj: ISODate) { let cache = OneObjectCache.objectMap.get(obj); if (!cache) { - cache = new OneObjectCache(); + cache = new OneObjectCache(id); OneObjectCache.objectMap.set(obj, cache); } return cache; } } -function toUtcIsoDateString({ isoYear, isoMonth, isoDay }: { isoYear: number; isoMonth: number; isoDay: number }) { +function toUtcIsoDateString(isoYear: number, isoMonth: number, isoDay: number) { const yearString = ES.ISOYearString(isoYear); const monthString = ES.ISODateTimePartString(isoMonth); const dayString = ES.ISODateTimePartString(isoDay); @@ -656,16 +705,17 @@ abstract class HelperBase { try { return dateTimeFormat.formatToParts(legacyDate); } catch (e) { - throw new RangeError(`Invalid ISO date: ${isoString}`); + if (e instanceof RangeError) throw new RangeError(`Invalid ISO date: ${isoString}`); + throw e; } } isoToCalendarDate(isoDate: ISODate, cache: OneObjectCache): FullCalendarDate { const { year: isoYear, month: isoMonth, day: isoDay } = isoDate; - const key = JSON.stringify({ func: 'isoToCalendarDate', isoYear, isoMonth, isoDay, id: this.id }); + const key = OneObjectCache.generateISOToCalendarKey(isoDate); const cached = cache.get(key); if (cached) return cached; - const isoString = toUtcIsoDateString({ isoYear, isoMonth, isoDay }); + const isoString = toUtcIsoDateString(isoYear, isoMonth, isoDay); const parts = this.getCalendarParts(isoString); const hasEra = CalendarSupportsEra(this.id); const result: Partial = {}; @@ -757,14 +807,7 @@ abstract class HelperBase { cache.set(key, calendarDate); // Also cache the reverse mapping const cacheReverse = (overflow: Overflow) => { - const keyReverse = JSON.stringify({ - func: 'calendarToIsoDate', - year: calendarDate.year, - month: calendarDate.month, - day: calendarDate.day, - overflow, - id: this.id - }); + const keyReverse = OneObjectCache.generateCalendarToISOKey(calendarDate, overflow); cache.set(keyReverse, isoDate); }; (['constrain', 'reject'] as const).forEach(cacheReverse); @@ -791,51 +834,51 @@ abstract class HelperBase { } } } + /** Private helper function */ + eraFromYear(calendarDate: Partial & { year: number }) { + const { year } = calendarDate; + let eraYear; + const ix = this.eras.findIndex((eParam, i) => { + let e = eParam; + if (i === this.eras.length - 1) { + if (e.skip) { + // This last era is only present for legacy ICU data. Treat the + // previous era as the last era. + e = this.eras[i - 1]; + } + if (e.reverseOf) { + // This is a reverse-sign era (like BCE) which must be the oldest + // era. Count years backwards. + if (year > 0) throw new RangeError(`Signed year ${year} is invalid for era ${e.code}`); + eraYear = e.anchorEpoch.year - year; + return true; + } + // last era always gets all "leftover" (older than epoch) years, + // so no need for a comparison like below. + eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); + return true; + } + // FIXME: This cast may not be correct. I think month and day are always + // present when we get here, but the type system does not prove it + const comparison = this.compareCalendarDates(calendarDate as CalendarYMD, e.anchorEpoch); + if (comparison >= 0) { + eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); + return true; + } + return false; + }); + if (ix === -1) throw new RangeError(`Year ${year} was not matched by any era`); + let matchingEra = this.eras[ix]; + if (matchingEra.skip) matchingEra = this.eras[ix - 1]; + return { eraYear, era: matchingEra.code }; + } /** Fill in missing parts of the (year, era, eraYear) tuple */ completeEraYear>( calendarDate: T ): T & Required> { - const eraFromYear = (year: number) => { - let eraYear; - const adjustedCalendarDate = { ...calendarDate, year }; - const ix = this.eras.findIndex((eParam, i) => { - let e = eParam; - if (i === this.eras.length - 1) { - if (e.skip) { - // This last era is only present for legacy ICU data. Treat the - // previous era as the last era. - e = this.eras[i - 1]; - } - if (e.reverseOf) { - // This is a reverse-sign era (like BCE) which must be the oldest - // era. Count years backwards. - if (year > 0) throw new RangeError(`Signed year ${year} is invalid for era ${e.code}`); - eraYear = e.anchorEpoch.year - year; - return true; - } - // last era always gets all "leftover" (older than epoch) years, - // so no need for a comparison like below. - eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); - return true; - } - // FIXME: This cast may not be correct. I think month and day are always - // present when we get here, but the type system does not prove it - const comparison = this.compareCalendarDates(adjustedCalendarDate as CalendarYMD, e.anchorEpoch); - if (comparison >= 0) { - eraYear = year - e.anchorEpoch.year + (e.hasYearZero ? 0 : 1); - return true; - } - return false; - }); - if (ix === -1) throw new RangeError(`Year ${year} was not matched by any era`); - let matchingEra = this.eras[ix]; - if (matchingEra.skip) matchingEra = this.eras[ix - 1]; - return { eraYear, era: matchingEra.code }; - }; - let { year, eraYear, era } = calendarDate; if (year !== undefined) { - const matchData = eraFromYear(year); + const matchData = this.eraFromYear(calendarDate as typeof calendarDate & { year: number }); ({ eraYear, era } = matchData); if (calendarDate.era !== undefined && CanonicalizeEraInCalendar(this.id, calendarDate.era) !== era) { throw new RangeError(`Input era ${calendarDate.era} doesn't match calculated value ${era}`); @@ -860,7 +903,8 @@ abstract class HelperBase { // the era or after its end as long as it's in the same year. If that // happens, we'll adjust the era/eraYear pair to be the correct era for // the `year`. - ({ eraYear, era } = eraFromYear(year)); + const adjustedCalendarDate = { year, month: calendarDate.month, day: calendarDate.day }; + ({ eraYear, era } = this.eraFromYear(adjustedCalendarDate)); } // validateCalendarDate already ensured that either year or era+eraYear are // present @@ -922,7 +966,7 @@ abstract class HelperBase { date = this.regulateMonthDayNaive(date, overflow, cache); const { year, month, day } = date; - const key = JSON.stringify({ func: 'calendarToIsoDate', year, month, day, overflow, id: this.id }); + const key = OneObjectCache.generateCalendarToISOKey(date, overflow); let cached = cache.get(key); if (cached) return cached; // If YMD are present in the input but the input has been constrained @@ -934,14 +978,7 @@ abstract class HelperBase { originalDate.day !== undefined && (originalDate.year !== date.year || originalDate.month !== date.month || originalDate.day !== date.day) ) { - keyOriginal = JSON.stringify({ - func: 'calendarToIsoDate', - year: originalDate.year, - month: originalDate.month, - day: originalDate.day, - overflow, - id: this.id - }); + keyOriginal = OneObjectCache.generateCalendarToISOKey(originalDate as CalendarYMD, overflow); cached = cache.get(keyOriginal); if (cached) return cached; } @@ -1100,11 +1137,20 @@ abstract class HelperBase { } addCalendar( calendarDate: CalendarYMD & { monthCode: string }, - { years = 0, months = 0, weeks = 0, days = 0 }, + { years: yearsParam = 0, months: monthsParam = 0, weeks = 0, days = 0 }, overflow: Overflow, cache: OneObjectCache ): FullCalendarDate { + let years = yearsParam; + let months = monthsParam; const { year, day, monthCode } = calendarDate; + const monthInfo = monthCodeInfo[this.id]; + const cycleInfo = monthInfo ? monthInfo.cycleInfo : { years: 1, months: 12 }; + if (cycleInfo && Math.abs(months) > cycleInfo.months) { + const cycleCount = Math.trunc(months / cycleInfo.months); + years += cycleCount * cycleInfo.years; + months %= cycleInfo.months; + } const addedYears = this.adjustCalendarDate({ year: year + years, monthCode, day }, cache); const addedMonths = this.addMonthsCalendar(addedYears, months, overflow, cache); const initialDays = days + weeks * 7; @@ -1139,7 +1185,9 @@ abstract class HelperBase { } const diffYears = calendarTwo.year - calendarOne.year; const diffDays = calendarTwo.day - calendarOne.day; - if (largestUnit === 'year' && diffYears) { + const monthInfo = monthCodeInfo[this.id]; + const cycleInfo = monthInfo ? monthInfo.cycleInfo : { years: 1, months: 12 }; + if (diffYears && (largestUnit === 'year' || cycleInfo)) { let diffInYearSign = 0; if (calendarTwo.monthCode > calendarOne.monthCode) diffInYearSign = 1; if (calendarTwo.monthCode < calendarOne.monthCode) diffInYearSign = -1; @@ -1147,19 +1195,30 @@ abstract class HelperBase { const isOneFurtherInYear = diffInYearSign * sign < 0; years = isOneFurtherInYear ? diffYears - sign : diffYears; } - const yearsAdded = years ? this.addCalendar(calendarOne, { years }, 'constrain', cache) : calendarOne; - // Now we have less than one year remaining. Add one month at a time + // Try to skip ahead as many months as possible for this calendar + // without adding month by month in a loop + if (largestUnit === 'month') { + if (cycleInfo && Math.abs(years) >= cycleInfo.years) { + const cycleCount = Math.trunc(years / cycleInfo.years); + months = cycleCount * cycleInfo.months; + } + years = 0; + } + const intermediate = + years || months ? this.addCalendar(calendarOne, { years, months }, 'constrain', cache) : calendarOne; + // Now we have less than one cycle remaining. Add one month at a time // until we go over the target, then back up one month and calculate - // remaining days and weeks. + // remaining days. let current; - let next: CalendarYMD = yearsAdded; + let next: CalendarYMD = intermediate; do { months += sign; current = next; next = this.addMonthsCalendar(current, sign, 'constrain', cache); if (next.day !== calendarOne.day) { - // In case the day was constrained down, try to un-constrain it - next = this.regulateDate({ ...next, day: calendarOne.day }, 'constrain', cache); + // In case the day was constrained down, un-constrain it (even if + // that's not a real date) + next = { ...next, day: calendarOne.day }; } } while (this.compareCalendarDates(calendarTwo, next) * sign >= 0); months -= sign; // correct for loop above which overshoots by 1 @@ -1310,7 +1369,7 @@ abstract class HelperBase { } interface HebrewMonthInfo { - [m: string]: ( + [m: string]: | { leap: undefined; regular: number; @@ -1322,17 +1381,9 @@ interface HebrewMonthInfo { | { leap: number; regular: number; - } - ) & { - monthCode: string; - days: - | number - | { - min: number; - max: number; - }; - }; + }; } +type HebrewMonthCodeInfo = Record; class HebrewHelper extends HelperBase { constructor() { @@ -1353,21 +1404,21 @@ class HebrewHelper extends HelperBase { return this.inLeapYear(calendarDate) ? 13 : 12; } minimumMonthLength(calendarDate: CalendarYM) { - return this.minMaxMonthLength(calendarDate, 'min'); + return this.minMaxMonthLength(calendarDate, 0); } maximumMonthLength(calendarDate: CalendarYM) { - return this.minMaxMonthLength(calendarDate, 'max'); + return this.minMaxMonthLength(calendarDate, 1); } - minMaxMonthLength(calendarDate: CalendarYM, minOrMax: 'min' | 'max') { + minMaxMonthLength(calendarDate: CalendarYM & { monthCode?: string }, minOrMax: 0 | 1) { const { month, year } = calendarDate; - const monthCode = this.getMonthCode(year, month); - const monthInfo = Object.entries(this.months).find((m) => m[1].monthCode === monthCode); - if (monthInfo === undefined) throw new RangeError(`unmatched Hebrew month: ${month}`); - const daysInMonth = monthInfo[1].days; + const monthCode = calendarDate.monthCode ?? this.getMonthCode(year, month); + const daysInMonth = this.monthLengths[monthCode]; + if (daysInMonth === undefined) throw new RangeError(`unmatched Hebrew month: ${month}`); return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[minOrMax]; } maxLengthOfMonthCodeInAnyYear(monthCode: string) { - return ['M04', 'M06', 'M08', 'M10', 'M12'].includes(monthCode) ? 29 : 30; + const daysInMonth = this.monthLengths[monthCode]; + return typeof daysInMonth === 'number' ? daysInMonth : daysInMonth[1]; } /** Take a guess at what ISO date a particular calendar date corresponds to */ estimateIsoDate(calendarDate: CalendarYMD) { @@ -1375,20 +1426,35 @@ class HebrewHelper extends HelperBase { return { year: year - 3760, month: 1, day: 1 }; } months: HebrewMonthInfo = { - Tishri: { leap: 1, regular: 1, monthCode: 'M01', days: 30 }, - Heshvan: { leap: 2, regular: 2, monthCode: 'M02', days: { min: 29, max: 30 } }, - Kislev: { leap: 3, regular: 3, monthCode: 'M03', days: { min: 29, max: 30 } }, - Tevet: { leap: 4, regular: 4, monthCode: 'M04', days: 29 }, - Shevat: { leap: 5, regular: 5, monthCode: 'M05', days: 30 }, - Adar: { leap: undefined, regular: 6, monthCode: 'M06', days: 29 }, - 'Adar I': { leap: 6, regular: undefined, monthCode: 'M05L', days: 30 }, - 'Adar II': { leap: 7, regular: undefined, monthCode: 'M06', days: 29 }, - Nisan: { leap: 8, regular: 7, monthCode: 'M07', days: 30 }, - Iyar: { leap: 9, regular: 8, monthCode: 'M08', days: 29 }, - Sivan: { leap: 10, regular: 9, monthCode: 'M09', days: 30 }, - Tamuz: { leap: 11, regular: 10, monthCode: 'M10', days: 29 }, - Av: { leap: 12, regular: 11, monthCode: 'M11', days: 30 }, - Elul: { leap: 13, regular: 12, monthCode: 'M12', days: 29 } + Tishri: { leap: 1, regular: 1 }, + Heshvan: { leap: 2, regular: 2 }, + Kislev: { leap: 3, regular: 3 }, + Tevet: { leap: 4, regular: 4 }, + Shevat: { leap: 5, regular: 5 }, + Adar: { leap: undefined, regular: 6 }, + 'Adar I': { leap: 6, regular: undefined }, + 'Adar II': { leap: 7, regular: undefined }, + Nisan: { leap: 8, regular: 7 }, + Iyar: { leap: 9, regular: 8 }, + Sivan: { leap: 10, regular: 9 }, + Tamuz: { leap: 11, regular: 10 }, + Av: { leap: 12, regular: 11 }, + Elul: { leap: 13, regular: 12 } + }; + monthLengths: HebrewMonthCodeInfo = { + M01: 30, + M02: [29, 30], + M03: [29, 30], + M04: 29, + M05: 30, + M05L: 30, + M06: 29, + M07: 30, + M08: 29, + M09: 30, + M10: 29, + M11: 30, + M12: 29 }; getMonthCode(year: number, month: number) { if (this.inLeapYear({ year })) { @@ -2099,12 +2165,16 @@ class JapaneseHelper extends SameMonthDayAsGregorianBaseHelper { } } -interface ChineseMonthInfo { - [key: string]: { monthIndex: number; daysInMonth: number }; -} -interface ChineseDraftMonthInfo { - [key: string]: { monthIndex: number; daysInMonth?: number }; -} +type ChineseMonthInfo = { monthCode: string; daysInMonth: number }[] & { + monthsInYear: number; +} & { + [key: string]: number; +}; +type ChineseDraftMonthInfo = { monthCode: string; daysInMonth?: number }[] & { + monthsInYear?: number; +} & { + [key: string]: number; +}; abstract class ChineseBaseHelper extends HelperBase { constructor() { @@ -2113,11 +2183,27 @@ abstract class ChineseBaseHelper extends HelperBase { abstract override id: BuiltinCalendarId; calendarType = 'lunisolar' as const; inLeapYear(calendarDate: CalendarYearOnly, cache: OneObjectCache) { - const months = this.getMonthList(calendarDate.year, cache); - return Object.entries(months).length === 13; + return this.getMonthList(calendarDate.year, cache).monthsInYear === 13; } monthsInYear(calendarDate: CalendarYearOnly, cache: OneObjectCache) { - return this.inLeapYear(calendarDate, cache) ? 13 : 12; + return this.getMonthList(calendarDate.year, cache).monthsInYear; + } + override daysInMonth(calendarDate: CalendarYM, cache: OneObjectCache) { + const { month, year } = calendarDate; + const matchingMonthEntry = this.getMonthList(year, cache)[month]; + if (matchingMonthEntry === undefined) { + throw new RangeError(`Invalid month ${month} in Chinese year ${year}`); + } + return matchingMonthEntry.daysInMonth; + } + override daysInPreviousMonth(calendarDate: CalendarYM, cache: OneObjectCache) { + const { month, year } = calendarDate; + + const previousMonthYear = month > 1 ? year : year - 1; + const previousMonthDate = { year: previousMonthYear, month, day: 1 }; + const previousMonth = month > 1 ? month - 1 : this.monthsInYear(previousMonthDate, cache); + + return this.daysInMonth({ year: previousMonthYear, month: previousMonth }, cache); } minimumMonthLength(/* calendarDate */) { return 29; @@ -2156,77 +2242,83 @@ abstract class ChineseBaseHelper extends HelperBase { if (calendarYear === undefined) { throw new TypeError('Missing year'); } - const key = JSON.stringify({ func: 'getMonthList', calendarYear, id: this.id }); + const key = OneObjectCache.generateMonthListKey(calendarYear); const cached = cache.get(key); if (cached) return cached; + + // Reuse the same local object for calendar-specific results, starting with + // a date close to Chinese New Year. Feb 17 will either be in the new year + // or near the end of the previous year's final month. + let daysPastJan31 = 17; + const calendarFields: { day: number; monthString: string; relatedYear: number | undefined } = { + day: 0, + monthString: '', + relatedYear: undefined + }; const dateTimeFormat = this.getFormatter(); - const getCalendarDate = (isoYear: number, daysPastFeb1: number) => { - const isoStringFeb1 = toUtcIsoDateString({ isoYear, isoMonth: 2, isoDay: 1 }); - const legacyDate = new Date(isoStringFeb1); - // Now add the requested number of days, which may wrap to the next month. - legacyDate.setUTCDate(daysPastFeb1 + 1); - const newYearGuess = dateTimeFormat.formatToParts(legacyDate); - // The 'month' and 'day' parts are guaranteed to be present because the - // formatter was created with month and day options. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const calendarMonthString = newYearGuess.find((tv) => tv.type === 'month')!.value; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const calendarDay = +newYearGuess.find((tv) => tv.type === 'day')!.value; - const calendarYearPartToVerify = newYearGuess.find((tv) => (tv.type as string) === 'relatedYear'); - let calendarYearToVerify: number | undefined; - if (calendarYearPartToVerify !== undefined) { - calendarYearToVerify = +calendarYearPartToVerify.value; - } else { + const updateCalendarFields = () => { + // Abuse GetUTCEpochMilliseconds for automatic rebalancing. + const isoNumbers = { year: calendarYear, month: 2, day: daysPastJan31 }; + const ms = ES.GetUTCEpochMilliseconds(isoNumbers, midnightTimeRecord); + const fieldEntries = dateTimeFormat.formatToParts(ms); + for (let i = 0; i < fieldEntries.length; i++) { + const { type, value } = fieldEntries[i]; + // day and year should be decimal strings, but month values like "5bis" are not number-coercible. + // TODO: remove this type annotation when `relatedYear` gets into TS lib types + if (type === 'day' || type === ('relatedYear' as Intl.DateTimeFormatPartTypes)) { + calendarFields[type as 'day' | 'relatedYear'] = +value; + } else if (type === 'month') { + calendarFields.monthString = value; + } + } + if (calendarFields.relatedYear === undefined) { // Node 12 has outdated ICU data that lacks the `relatedYear` field in the // output of Intl.DateTimeFormat.formatToParts. throw new RangeError( `Intl.DateTimeFormat.formatToParts lacks relatedYear in ${this.id} calendar. Try Node 14+ or modern browsers.` ); } - return { calendarMonthString, calendarDay, calendarYearToVerify }; + return calendarFields; }; - // First, find a date close to Chinese New Year. Feb 17 will either be in - // the first month or near the end of the last month of the previous year. - let isoDaysDelta = 17; - let { calendarMonthString, calendarDay, calendarYearToVerify } = getCalendarDate(calendarYear, isoDaysDelta); - - // If we didn't guess the first month correctly, add (almost in some months) - // a lunar month - if (calendarMonthString !== '1') { - isoDaysDelta += 29; - ({ calendarMonthString, calendarDay } = getCalendarDate(calendarYear, isoDaysDelta)); + // Ensure that we're in the first month. + updateCalendarFields(); + if (calendarFields.monthString !== '1') { + daysPastJan31 += 29; + updateCalendarFields(); } - // Now back up to near the start of the first month, but not too near that + // Now back up to near the start of the first month, but not so near that // off-by-one issues matter. - isoDaysDelta -= calendarDay - 5; - const result = {} as ChineseDraftMonthInfo; + daysPastJan31 -= calendarFields.day - 5; + + const monthList = [] as unknown as ChineseDraftMonthInfo; let monthIndex = 1; - let oldCalendarDay: number | undefined; - let oldMonthString: string | undefined; - let done = false; - do { - ({ calendarMonthString, calendarDay, calendarYearToVerify } = getCalendarDate(calendarYear, isoDaysDelta)); - if (oldCalendarDay) { - result[oldMonthString as string].daysInMonth = oldCalendarDay + 30 - calendarDay; + let oldDay: number | undefined; + for (;;) { + const { day, monthString, relatedYear } = updateCalendarFields(); + if (monthIndex === 1) assert(monthString === '1', `we didn't back up to the beginning of year ${calendarYear}`); + const isLeapMonth = monthString.endsWith('bis'); + const monthCode = CreateMonthCode(+(isLeapMonth ? monthString.slice(0, -3) : monthString), isLeapMonth); + if (oldDay) { + monthList[monthIndex - 1].daysInMonth = oldDay + 30 - day; } - if (calendarYearToVerify !== calendarYear) { - done = true; - } else { - result[calendarMonthString] = { monthIndex: monthIndex++ }; - // Move to the next month. Because months are sometimes 29 days, the day of the - // calendar month will move forward slowly but not enough to flip over to a new - // month before the loop ends at 12-13 months. - isoDaysDelta += 30; - } - oldCalendarDay = calendarDay; - oldMonthString = calendarMonthString; - } while (!done); - result[oldMonthString].daysInMonth = oldCalendarDay + 30 - calendarDay; + oldDay = day; + + if (relatedYear !== calendarYear) break; - cache.set(key, result); - return result as ChineseMonthInfo; + monthList[monthIndex] = { monthCode }; + monthList[monthCode] = monthIndex++; + + // Move to the next month. Because months are sometimes 29 days, the day of the + // calendar month will move forward slowly but not enough to flip over to a new + // month before the loop ends at 12-13 months. + daysPastJan31 += 30; + } + monthList.monthsInYear = monthIndex - 1; // subtract 1, it was incremented after the loop + + cache.set(key, monthList); + return monthList as ChineseMonthInfo; } estimateIsoDate(calendarDate: CalendarYMD) { const { year, month } = calendarDate; @@ -2246,11 +2338,11 @@ abstract class ChineseBaseHelper extends HelperBase { // month. Below we'll normalize the output. if (monthExtra && monthExtra !== 'bis') throw new RangeError(`Unexpected leap month suffix: ${monthExtra}`); const monthCode = CreateMonthCode(month as number, monthExtra !== undefined); - const monthString = `${month}${monthExtra || ''}`; const months = this.getMonthList(year, cache); - const monthInfo = months[monthString]; - if (monthInfo === undefined) throw new RangeError(`Unmatched month ${monthString} in Chinese year ${year}`); - month = monthInfo.monthIndex; + month = months[monthCode]; + if (month === undefined) { + throw new RangeError(`Unmatched month ${month}${monthExtra || ''} in Chinese year ${year}`); + } return { year, month, day: day as number, monthCode }; } else { // When called without input coming from legacy Date output, @@ -2260,26 +2352,20 @@ abstract class ChineseBaseHelper extends HelperBase { ES.assertExists(monthCode); const months = this.getMonthList(year, cache); const { monthNumber, isLeapMonth } = ParseMonthCode(monthCode); - const numberPart = `${monthNumber}${isLeapMonth ? 'bis' : ''}`; - let monthInfo = months[numberPart]; - month = monthInfo && monthInfo.monthIndex; - + month = months[monthCode]; // If this leap month isn't present in this year, constrain to the same // day of the previous month. - if (month === undefined && isLeapMonth && monthNumber !== 13 && overflow === 'constrain') { - monthInfo = months[monthNumber]; - if (monthInfo) { - month = monthInfo.monthIndex; - monthCode = CreateMonthCode(monthNumber, false); - } + if (month === undefined && isLeapMonth && overflow === 'constrain') { + const adjustedMonthCode = CreateMonthCode(monthNumber, false); + month = months[adjustedMonthCode]; + monthCode = adjustedMonthCode; } if (month === undefined) { throw new RangeError(`Unmatched month ${monthCode} in Chinese year ${year}`); } } else if (monthCode === undefined) { const months = this.getMonthList(year, cache); - const monthEntries = Object.entries(months); - const largestMonth = monthEntries.length; + const largestMonth = months.monthsInYear; if (overflow === 'reject') { ES.RejectToRange(month, 1, largestMonth); ES.RejectToRange(day as number, 1, this.maximumMonthLength()); @@ -2287,22 +2373,16 @@ abstract class ChineseBaseHelper extends HelperBase { month = ES.ConstrainToRange(month, 1, largestMonth); day = ES.ConstrainToRange(day, 1, this.maximumMonthLength()); } - const matchingMonthEntry = monthEntries.find((entry) => entry[1].monthIndex === month); - if (matchingMonthEntry === undefined) { + monthCode = months[month].monthCode; + if (monthCode === undefined) { throw new RangeError(`Invalid month ${month} in Chinese year ${year}`); } - monthCode = CreateMonthCode( - +matchingMonthEntry[0].replace('bis', ''), - matchingMonthEntry[0].indexOf('bis') !== -1 - ); } else { // Both month and monthCode are present. Make sure they don't conflict. const months = this.getMonthList(year, cache); - const { monthNumber, isLeapMonth } = ParseMonthCode(monthCode); - const numberPart = `${monthNumber}${isLeapMonth ? 'bis' : ''}`; - const monthInfo = months[numberPart]; - if (!monthInfo) throw new RangeError(`Unmatched monthCode ${monthCode} in Chinese year ${year}`); - if (month !== monthInfo.monthIndex) { + const monthIndex = months[monthCode]; + if (!monthIndex) throw new RangeError(`Unmatched monthCode ${monthCode} in Chinese year ${year}`); + if (month !== monthIndex) { throw new RangeError(`monthCode ${monthCode} doesn't correspond to month ${month} in Chinese year ${year}`); } } @@ -2340,13 +2420,13 @@ class NonIsoCalendar implements CalendarImpl { } } dateToISO(fields: CalendarDateFields, overflow: Overflow) { - const cache = new OneObjectCache(); + const cache = new OneObjectCache(this.helper.id); const result = this.helper.calendarToIsoDate(fields, overflow, cache); cache.setObject(result); return result; } monthDayToISOReferenceDate(fields: MonthDayFromFieldsObject, overflow: Overflow) { - const cache = new OneObjectCache(); + const cache = new OneObjectCache(this.helper.id); const result = this.helper.monthDayFromFields(fields, overflow, cache); // result.year is a reference year where this month/day exists in this calendar cache.setObject(result); @@ -2398,20 +2478,20 @@ class NonIsoCalendar implements CalendarImpl { return arrayFromSet(result); } dateAdd(isoDate: ISODate, { years, months, weeks, days }: DateDuration, overflow: Overflow) { - const cache = OneObjectCache.getCacheForObject(isoDate); + const cache = OneObjectCache.getCacheForObject(this.helper.id, isoDate); const calendarDate = this.helper.isoToCalendarDate(isoDate, cache); const added = this.helper.addCalendar(calendarDate, { years, months, weeks, days }, overflow, cache); const isoAdded = this.helper.calendarToIsoDate(added, 'constrain', cache); // The new object's cache starts with the cache of the old object - if (!OneObjectCache.getCacheForObject(isoAdded)) { - const newCache = new OneObjectCache(cache); + if (!OneObjectCache.getCacheForObject(this.helper.id, isoAdded)) { + const newCache = new OneObjectCache(this.helper.id, cache); newCache.setObject(isoAdded); } return isoAdded; } dateUntil(one: ISODate, two: ISODate, largestUnit: Temporal.DateUnit) { - const cacheOne = OneObjectCache.getCacheForObject(one); - const cacheTwo = OneObjectCache.getCacheForObject(two); + const cacheOne = OneObjectCache.getCacheForObject(this.helper.id, one); + const cacheTwo = OneObjectCache.getCacheForObject(this.helper.id, two); const calendarOne = this.helper.isoToCalendarDate(one, cacheOne); const calendarTwo = this.helper.isoToCalendarDate(two, cacheTwo); const result = this.helper.untilCalendar(calendarOne, calendarTwo, largestUnit, cacheOne); @@ -2423,7 +2503,7 @@ class NonIsoCalendar implements CalendarImpl { [Field in keyof CalendarDateRecord]: Request extends { [K in Field]: true } ? CalendarDateRecord[Field] : never; } >(isoDate: ISODate, requestedFields: Request): T { - const cache = OneObjectCache.getCacheForObject(isoDate); + const cache = OneObjectCache.getCacheForObject(this.helper.id, isoDate); const calendarDate: Partial & FullCalendarDate = this.helper.isoToCalendarDate(isoDate, cache); if (requestedFields.dayOfWeek) { calendarDate.dayOfWeek = impl['iso8601'].isoToDate(isoDate, { dayOfWeek: true }).dayOfWeek; diff --git a/lib/duration.ts b/lib/duration.ts index 608816af..69b9198d 100644 --- a/lib/duration.ts +++ b/lib/duration.ts @@ -178,7 +178,7 @@ export class Duration implements Temporal.Duration { let largestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'largestUnit'); let { plainRelativeTo, zonedRelativeTo } = ES.GetTemporalRelativeToOption(roundTo); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); let smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit'); ES.ValidateTemporalUnitValue(smallestUnit, 'datetime'); diff --git a/lib/ecmascript.ts b/lib/ecmascript.ts index 81faa9e3..8e7dd0e3 100644 --- a/lib/ecmascript.ts +++ b/lib/ecmascript.ts @@ -928,7 +928,7 @@ export function GetDirectionOption(options: { direction?: 'next' | 'previous' }) return GetOption(options, 'direction', ['next', 'previous'], REQUIRED); } -export function GetTemporalRoundingIncrementOption(options: { roundingIncrement?: number }) { +export function GetRoundingIncrementOption(options: { roundingIncrement?: number }) { let increment = options.roundingIncrement; if (increment === undefined) return 1; const integerIncrement = ToIntegerWithTruncation(increment); @@ -2264,7 +2264,6 @@ function GetPossibleEpochNanoseconds(timeZone: string, isoDateTime: ISODateTime) return [epochNs]; } - CheckISODaysRange(isoDateTime.isoDate); return GetNamedTimeZoneEpochNanoseconds(timeZone, isoDateTime); } @@ -2403,16 +2402,20 @@ export function TemporalDurationToString( return result; } +function ISODateToString({ year, month, day }: ISODate) { + const yearString = ISOYearString(year); + const monthString = ISODateTimePartString(month); + const dayString = ISODateTimePartString(day); + return `${yearString}-${monthString}-${dayString}`; +} + export function TemporalDateToString( date: Temporal.PlainDate, showCalendar: Temporal.ShowCalendarOption['calendarName'] = 'auto' ) { - const { year, month, day } = GetSlot(date, ISO_DATE); - const yearString = ISOYearString(year); - const monthString = ISODateTimePartString(month); - const dayString = ISODateTimePartString(day); + const dateString = ISODateToString(GetSlot(date, ISO_DATE)); const calendar = FormatCalendarAnnotation(GetSlot(date, CALENDAR), showCalendar); - return `${yearString}-${monthString}-${dayString}${calendar}`; + return dateString + calendar; } export function TimeRecordToString( @@ -2634,7 +2637,7 @@ function GetNamedTimeZoneOffsetNanosecondsImpl(id: string, epochMilliseconds: nu const { year, month, day, hour, minute, second } = GetFormatterParts(id, epochMilliseconds); let millisecond = epochMilliseconds % 1000; if (millisecond < 0) millisecond += 1000; - const utc = GetUTCEpochMilliseconds({ isoDate: { year, month, day }, time: { hour, minute, second, millisecond } }); + const utc = GetUTCEpochMilliseconds({ year, month, day }, { hour, minute, second, millisecond }); return (utc - epochMilliseconds) * 1e6; } @@ -2659,13 +2662,10 @@ function FormatDateTimeUTCOffsetRounded(offsetNanosecondsParam: number): string return FormatOffsetTimeZoneIdentifier(offsetNanoseconds / 60e9); } -function GetUTCEpochMilliseconds({ - isoDate: { year, month, day }, - time: { hour, minute, second, millisecond } -}: { - isoDate: ISODate; - time: Omit; -}) { +export function GetUTCEpochMilliseconds(isoDate: ISODate, time: Omit) { + const { year, month, day } = isoDate; + const { hour, minute, second, millisecond /* ignored: microsecond, nanosecond */ } = time; + // The pattern of leap years in the ISO 8601 calendar repeats every 400 // years. To avoid overflowing at the edges of the range, we reduce the year // to the remainder after dividing by 400, and then add back all the @@ -2673,18 +2673,18 @@ function GetUTCEpochMilliseconds({ const reducedYear = year % 400; const yearCycles = (year - reducedYear) / 400; - // Note: Date.UTC() interprets one and two-digit years as being in the - // 20th century, so don't use it - const legacyDate = new Date(); - legacyDate.setUTCHours(hour, minute, second, millisecond); - legacyDate.setUTCFullYear(reducedYear, month - 1, day); - const ms = legacyDate.getTime(); - return ms + MS_IN_400_YEAR_CYCLE * yearCycles; + // `Date.UTC(year, monthIndex, days)` maps year [0, 99] to [1900, 1999], so + // avoid that range. + const extraCycles = reducedYear >= 0 ? 5 : 0; + const ms = Date.UTC(reducedYear + 400 * extraCycles, month - 1, day, hour, minute, second, millisecond); + + return ms + MS_IN_400_YEAR_CYCLE * (yearCycles - extraCycles); } function GetUTCEpochNanoseconds(isoDateTime: ISODateTime) { - const ms = GetUTCEpochMilliseconds(isoDateTime); - const subMs = isoDateTime.time.microsecond * 1e3 + isoDateTime.time.nanosecond; + const { isoDate, time } = isoDateTime; + const ms = GetUTCEpochMilliseconds(isoDate, time); + const subMs = time.microsecond * 1e3 + time.nanosecond; return JSBI.add(epochMsToNs(ms), JSBI.BigInt(subMs)); } @@ -2903,10 +2903,14 @@ function GetNamedTimeZoneEpochNanoseconds(id: string, isoDateTime: ISODateTime) // Get the offset of one day before and after the requested calendar date and // clock time, avoiding overflows if near the edge of the Instant range. let ns = GetUTCEpochNanoseconds(isoDateTime); + // Note, ns may be up to DAY_NANOS outside the NS_MIN...NS_MAX range here, + // even if isoDateTime represents a valid wall time in a non-UTC time zone. + // The TZDB does not describe any transitions that would occur in that extra + // day, so we just clip in order not to throw in GetFormatterParts. let nsEarlier = JSBI.subtract(ns, DAY_NANOS_JSBI); - if (JSBI.lessThan(nsEarlier, NS_MIN)) nsEarlier = ns; + if (JSBI.lessThan(nsEarlier, NS_MIN)) nsEarlier = NS_MIN; let nsLater = JSBI.add(ns, DAY_NANOS_JSBI); - if (JSBI.greaterThan(nsLater, NS_MAX)) nsLater = ns; + if (JSBI.greaterThan(nsLater, NS_MAX)) nsLater = NS_MAX; const earlierOffsetNs = GetNamedTimeZoneOffsetNanoseconds(id, nsEarlier); const laterOffsetNs = GetNamedTimeZoneOffsetNanoseconds(id, nsLater); @@ -3171,7 +3175,12 @@ export function RejectISODate(year: number, month: number, day: number) { function RejectDateRange(isoDate: ISODate) { // Noon avoids trouble at edges of DateTime range (excludes midnight) - RejectDateTimeRange(CombineISODateAndTimeRecord(isoDate, NoonTimeRecord())); + const isoDateTime = CombineISODateAndTimeRecord(isoDate, NoonTimeRecord()); + const ns = GetUTCEpochNanoseconds(isoDateTime); + if (JSBI.lessThan(ns, DATETIME_NS_MIN) || JSBI.greaterThan(ns, DATETIME_NS_MAX)) { + const dateString = ISODateToString(isoDateTime.isoDate); + throw new RangeError(`${dateString} is outside of supported range`); + } } export function RejectTime( @@ -3208,10 +3217,8 @@ export function RejectDateTime( export function RejectDateTimeRange(isoDateTime: ISODateTime) { const ns = GetUTCEpochNanoseconds(isoDateTime); if (JSBI.lessThan(ns, DATETIME_NS_MIN) || JSBI.greaterThan(ns, DATETIME_NS_MAX)) { - // Because PlainDateTime's range is wider than Instant's range, the line - // below will always throw. Calling `ValidateEpochNanoseconds` avoids - // repeating the same error message twice. - ValidateEpochNanoseconds(ns); + const dateTimeString = ISODateTimeToString(isoDateTime, 'iso8601', undefined, 'never'); + throw new RangeError(`${dateTimeString} is outside of supported range`); } } @@ -3220,7 +3227,7 @@ function AssertISODateTimeWithinLimits(isoDateTime: ISODateTime) { const ns = GetUTCEpochNanoseconds(isoDateTime); assert( JSBI.greaterThanOrEqual(ns, DATETIME_NS_MIN) && JSBI.lessThanOrEqual(ns, DATETIME_NS_MAX), - `${ISODateTimeToString(isoDateTime, 'iso8601', 'auto')} is outside the representable range` + `${ISODateTimeToString(isoDateTime, 'iso8601', undefined, 'never')} is outside the representable range` ); } @@ -3438,10 +3445,7 @@ export function CombineDateAndTimeDuration(dateDuration: DateDuration, timeDurat // Caution: month is 0-based export function ISODateToEpochDays(year: number, month: number, day: number) { return ( - GetUTCEpochMilliseconds({ - isoDate: { year, month: month + 1, day }, - time: { hour: 0, minute: 0, second: 0, millisecond: 0 } - }) / DAY_MS + GetUTCEpochMilliseconds({ year, month: month + 1, day }, { hour: 0, minute: 0, second: 0, millisecond: 0 }) / DAY_MS ); } @@ -3524,6 +3528,20 @@ function DifferenceZonedDateTime( const isoDtStart = GetISODateTimeFor(timeZone, ns1); const isoDtEnd = GetISODateTimeFor(timeZone, ns2); + // If year-month-day values are same-day, + // there's no point involving the calendar/timezone for zdt->pdt conversions. + // It also avoids a situation where dayCorrection backs up too far on same-day diffs + // with reverse-direction wallclock delta due to DST: + // https://github.com/tc39/proposal-temporal/issues/3141 + if ( + isoDtStart.isoDate.year === isoDtEnd.isoDate.year && + isoDtStart.isoDate.month === isoDtEnd.isoDate.month && + isoDtStart.isoDate.day === isoDtEnd.isoDate.day + ) { + const timeDuration = new TimeDuration(nsDiff); + return { date: ZeroDateDuration(), time: timeDuration }; + } + // Simulate moving ns1 as many years/months/weeks/days as possible without // surpassing ns2. This value is stored in intermediateDateTime/intermediateInstant/intermediateNs. // We do not literally move years/months/weeks/days with calendar arithmetic, @@ -3589,6 +3607,7 @@ function DifferenceZonedDateTime( function NudgeToCalendarUnit( sign: -1 | 1, durationParam: InternalDuration, + originEpochNs: JSBI, destEpochNs: JSBI, isoDateTime: ISODateTime, timeZone: string | null, @@ -3648,22 +3667,29 @@ function NudgeToCalendarUnit( if (sign === 1) assert(r1 >= 0 && r1 < r2, `positive ordering of r1, r2: 0 ≤ ${r1} < ${r2}`); if (sign === -1) assert(r1 <= 0 && r1 > r2, `negative ordering of r1, r2: 0 ≥ ${r1} > ${r2}`); - // Apply to origin, output PlainDateTimes - const start = CalendarDateAdd(calendar, isoDateTime.isoDate, startDuration, 'constrain'); - const end = CalendarDateAdd(calendar, isoDateTime.isoDate, endDuration, 'constrain'); - - // Convert to epoch-nanoseconds - let startEpochNs, endEpochNs; - const startDateTime = CombineISODateAndTimeRecord(start, isoDateTime.time); - const endDateTime = CombineISODateAndTimeRecord(end, isoDateTime.time); - if (timeZone) { - startEpochNs = GetEpochNanosecondsFor(timeZone, startDateTime, 'compatible'); - endEpochNs = GetEpochNanosecondsFor(timeZone, endDateTime, 'compatible'); + // Convert to bound-START to epoch-nanoseconds + let startEpochNs; + if (!r1) { + // If the start of the bound is the same as the "origin" (aka relativeTo), + // use the origin's epoch-nanoseconds as-is instead of relying on isoDateTime, + // which then gets zoned and converted back to epoch-nanoseconds, + // which looses precision and creates a distorted bounding window. + startEpochNs = originEpochNs; } else { - startEpochNs = GetUTCEpochNanoseconds(startDateTime); - endEpochNs = GetUTCEpochNanoseconds(endDateTime); + const start = CalendarDateAdd(calendar, isoDateTime.isoDate, startDuration, 'constrain'); + const startDateTime = CombineISODateAndTimeRecord(start, isoDateTime.time); + startEpochNs = timeZone + ? GetEpochNanosecondsFor(timeZone, startDateTime, 'compatible') + : GetUTCEpochNanoseconds(startDateTime); } + // Convert to bound-END to epoch-nanoseconds + const end = CalendarDateAdd(calendar, isoDateTime.isoDate, endDuration, 'constrain'); + const endDateTime = CombineISODateAndTimeRecord(end, isoDateTime.time); + const endEpochNs = timeZone + ? GetEpochNanosecondsFor(timeZone, endDateTime, 'compatible') + : GetUTCEpochNanoseconds(endDateTime); + // Round the smallestUnit within the epoch-nanosecond span if (sign === 1) { assert( @@ -3897,6 +3923,7 @@ function BubbleRelativeDuration( function RoundRelativeDuration( durationParam: InternalDuration, + originEpochNs: JSBI, destEpochNs: JSBI, isoDateTime: ISODateTime, timeZone: string | null, @@ -3922,6 +3949,7 @@ function RoundRelativeDuration( ({ nudgeResult } = NudgeToCalendarUnit( sign, duration, + originEpochNs, destEpochNs, isoDateTime, timeZone, @@ -3978,6 +4006,7 @@ function RoundRelativeDuration( function TotalRelativeDuration( duration: InternalDuration, + originEpochNs: JSBI, destEpochNs: JSBI, isoDateTime: ISODateTime, timeZone: string | null, @@ -3993,7 +4022,18 @@ function TotalRelativeDuration( if (IsCalendarUnit(unit) || (timeZone && unit === 'day')) { // Rounding an irregular-length unit? Use epoch-nanosecond-bounding technique const sign = InternalDurationSign(duration) < 0 ? -1 : 1; - return NudgeToCalendarUnit(sign, duration, destEpochNs, isoDateTime, timeZone, calendar, 1, unit, 'trunc').total; + return NudgeToCalendarUnit( + sign, + duration, + originEpochNs, + destEpochNs, + isoDateTime, + timeZone, + calendar, + 1, + unit, + 'trunc' + ).total; } // Rounding uniform-length days/hours/minutes/etc units. Simple nanosecond // math. years/months/weeks unchanged @@ -4020,9 +4060,11 @@ export function DifferencePlainDateTimeWithRounding( if (smallestUnit === 'nanosecond' && roundingIncrement === 1) return duration; + const originEpochNs = GetUTCEpochNanoseconds(isoDateTime1); const destEpochNs = GetUTCEpochNanoseconds(isoDateTime2); return RoundRelativeDuration( duration, + originEpochNs, destEpochNs, isoDateTime1, null, @@ -4048,8 +4090,9 @@ export function DifferencePlainDateTimeWithTotal( if (unit === 'nanosecond') return JSBI.toNumber(duration.time.totalNs); + const originEpochNs = GetUTCEpochNanoseconds(isoDateTime1); const destEpochNs = GetUTCEpochNanoseconds(isoDateTime2); - return TotalRelativeDuration(duration, destEpochNs, isoDateTime1, null, calendar, unit); + return TotalRelativeDuration(duration, originEpochNs, destEpochNs, isoDateTime1, null, calendar, unit); } export function DifferenceZonedDateTimeWithRounding( @@ -4074,6 +4117,7 @@ export function DifferenceZonedDateTimeWithRounding( const dateTime = GetISODateTimeFor(timeZone, ns1); return RoundRelativeDuration( duration, + ns1, ns2, dateTime, timeZone, @@ -4099,7 +4143,7 @@ export function DifferenceZonedDateTimeWithTotal( const duration = DifferenceZonedDateTime(ns1, ns2, timeZone, calendar, unit); const dateTime = GetISODateTimeFor(timeZone, ns1); - return TotalRelativeDuration(duration, ns2, dateTime, timeZone, calendar, unit); + return TotalRelativeDuration(duration, ns1, ns2, dateTime, timeZone, calendar, unit); } type DifferenceOperation = 'since' | 'until'; @@ -4123,18 +4167,18 @@ function GetDifferenceSettings( }, [] as (Temporal.DateTimeUnit | Temporal.PluralUnit)[]); let largestUnit = GetTemporalUnitValuedOption(options, 'largestUnit'); + const roundingIncrement = GetRoundingIncrementOption(options); + let roundingMode = GetRoundingModeOption(options, 'trunc'); + let smallestUnit = GetTemporalUnitValuedOption(options, 'smallestUnit'); + ValidateTemporalUnitValue(largestUnit, group, ['auto']); if (!largestUnit) largestUnit = 'auto'; if (disallowed.includes(largestUnit)) { throw new RangeError(`largestUnit must be one of ${ALLOWED_UNITS.join(', ')}, not ${largestUnit}`); } - const roundingIncrement = GetTemporalRoundingIncrementOption(options); - - let roundingMode = GetRoundingModeOption(options, 'trunc'); if (op === 'since') roundingMode = NegateRoundingMode(roundingMode); - let smallestUnit = GetTemporalUnitValuedOption(options, 'smallestUnit'); ValidateTemporalUnitValue(smallestUnit, group); if (!smallestUnit) smallestUnit = fallbackSmallest; if (disallowed.includes(smallestUnit)) { @@ -4213,9 +4257,11 @@ export function DifferenceTemporalPlainDate( if (!roundingIsNoop) { const isoDateTime = CombineISODateAndTimeRecord(isoDate, MidnightTimeRecord()); const isoDateTimeOther = CombineISODateAndTimeRecord(isoOther, MidnightTimeRecord()); + const originEpochNs = GetUTCEpochNanoseconds(isoDateTime); const destEpochNs = GetUTCEpochNanoseconds(isoDateTimeOther); duration = RoundRelativeDuration( duration, + originEpochNs, destEpochNs, isoDateTime, null, @@ -4326,9 +4372,11 @@ export function DifferenceTemporalPlainYearMonth( if (settings.smallestUnit !== 'month' || settings.roundingIncrement !== 1) { const isoDateTime = CombineISODateAndTimeRecord(thisDate, MidnightTimeRecord()); const isoDateTimeOther = CombineISODateAndTimeRecord(otherDate, MidnightTimeRecord()); + const originEpochNs = GetUTCEpochNanoseconds(isoDateTime); const destEpochNs = GetUTCEpochNanoseconds(isoDateTimeOther); duration = RoundRelativeDuration( duration, + originEpochNs, destEpochNs, isoDateTime, null, @@ -4406,15 +4454,22 @@ export function DifferenceTemporalZonedDateTime( } export function AddTime( - { hour, minute, second: secondParam, millisecond, microsecond, nanosecond: nanosecondParam }: TimeRecord, + { hour, minute, second, millisecond, microsecond, nanosecond }: TimeRecord, timeDuration: TimeDuration ) { - let second = secondParam; - let nanosecond = nanosecondParam; - - second += timeDuration.sec; - nanosecond += timeDuration.subsec; - return BalanceTime(hour, minute, second, millisecond, microsecond, nanosecond); + // timeDuration.sec is a safe integer, but second+timeDuration.sec may not be. + // minute+trunc(timeDuration.sec/60) is safe. nanosecond+timeDuration.subsec + // is also safe. + const minutes = Math.trunc(timeDuration.sec / 60); + const seconds = timeDuration.sec - minutes * 60; + return BalanceTime( + hour, + minute + minutes, + second + seconds, + millisecond, + microsecond, + nanosecond + timeDuration.subsec + ); } function AddInstant(epochNanoseconds: JSBI, timeDuration: TimeDuration) { diff --git a/lib/instant.ts b/lib/instant.ts index 836e32e1..fbd94f37 100644 --- a/lib/instant.ts +++ b/lib/instant.ts @@ -52,7 +52,7 @@ export class Instant implements Temporal.Instant { typeof roundToParam === 'string' ? (ES.CreateOnePropObject('smallestUnit', roundToParam) as Exclude) : ES.GetOptionsObject(roundToParam); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); const smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit', ES.REQUIRED); ES.ValidateTemporalUnitValue(smallestUnit, 'time'); @@ -82,9 +82,9 @@ export class Instant implements Temporal.Instant { const digits = ES.GetTemporalFractionalSecondDigitsOption(resolvedOptions); const roundingMode = ES.GetRoundingModeOption(resolvedOptions, 'trunc'); const smallestUnit = ES.GetTemporalUnitValuedOption(resolvedOptions, 'smallestUnit'); + let timeZone = resolvedOptions.timeZone; ES.ValidateTemporalUnitValue(smallestUnit, 'time'); if (smallestUnit === 'hour') throw new RangeError('smallestUnit must be a time unit other than "hour"'); - let timeZone = resolvedOptions.timeZone; if (timeZone !== undefined) timeZone = ES.ToTemporalTimeZoneIdentifier(timeZone); const { precision, unit, increment } = ES.ToSecondsStringPrecisionRecord(smallestUnit, digits); const ns = GetSlot(this, EPOCHNANOSECONDS); diff --git a/lib/monthcode.ts b/lib/monthcode.ts index 195f7237..71d403bf 100644 --- a/lib/monthcode.ts +++ b/lib/monthcode.ts @@ -1,14 +1,21 @@ import { ToPrimitiveRequireString } from './primitive'; -import { monthCode as MONTH_CODE_REGEX } from './regex'; + +const digitsForMonthNumber = Array.from({ length: 100 }, (_, i) => (i < 10 ? `0${i}` : `${i}`)); export function ParseMonthCode(argument: unknown) { const value = ToPrimitiveRequireString(argument); - const match = MONTH_CODE_REGEX.exec(value); - if (!match) throw new RangeError(`bad month code ${value}; must match M01-M99 or M00L-M99L`); - return { - monthNumber: +(match[1] ?? match[3] ?? match[5]), - isLeapMonth: (match[2] ?? match[4] ?? match[6]) === 'L' - }; + const digits = value.slice(1, 3); + const monthNumber = digits.length === 2 ? +digits : -1; // -1 ensures failure + const isLeapMonth = value.length === 4; + if ( + !(monthNumber >= 0) || + digits !== digitsForMonthNumber[monthNumber] || + value[0] !== 'M' || + (isLeapMonth ? value[3] !== 'L' : value.length !== 3 || monthNumber === 0) + ) { + throw new RangeError(`bad month code ${value}; must match M01-M99 or M00L-M99L`); + } + return { monthNumber, isLeapMonth }; } export function CreateMonthCode(monthNumber: number, isLeapMonth: boolean) { diff --git a/lib/plaindate.ts b/lib/plaindate.ts index 8946c536..e1311a49 100644 --- a/lib/plaindate.ts +++ b/lib/plaindate.ts @@ -94,7 +94,10 @@ export class PlainDate implements Temporal.PlainDate { withCalendar(calendarParam: Params['withCalendar'][0]): Return['withCalendar'] { ES.CheckReceiver(this, ES.IsTemporalDate); const calendar = ES.ToTemporalCalendarIdentifier(calendarParam); - return ES.CreateTemporalDate(GetSlot(this, ISO_DATE), calendar); + // Don't reuse the same ISODate object, as it should start with a fresh + // calendar cache + const { year, month, day } = GetSlot(this, ISO_DATE); + return ES.CreateTemporalDate({ year, month, day }, calendar); } add(temporalDurationLike: Params['add'][0], options: Params['add'][1] = undefined): Return['add'] { ES.CheckReceiver(this, ES.IsTemporalDate); diff --git a/lib/plaindatetime.ts b/lib/plaindatetime.ts index 26e71ded..5439f74e 100644 --- a/lib/plaindatetime.ts +++ b/lib/plaindatetime.ts @@ -140,7 +140,13 @@ export class PlainDateTime implements Temporal.PlainDateTime { withCalendar(calendarParam: Params['withCalendar'][0]): Return['withCalendar'] { ES.CheckReceiver(this, ES.IsTemporalDateTime); const calendar = ES.ToTemporalCalendarIdentifier(calendarParam); - return ES.CreateTemporalDateTime(GetSlot(this, ISO_DATE_TIME), calendar); + // Don't reuse the same ISODate object, as it should start with a fresh + // calendar cache + const { + isoDate: { year, month, day }, + time + } = GetSlot(this, ISO_DATE_TIME); + return ES.CreateTemporalDateTime(ES.CombineISODateAndTimeRecord({ year, month, day }, time), calendar); } add(temporalDurationLike: Params['add'][0], options: Params['add'][1] = undefined): Return['add'] { ES.CheckReceiver(this, ES.IsTemporalDateTime); @@ -168,7 +174,7 @@ export class PlainDateTime implements Temporal.PlainDateTime { typeof roundToParam === 'string' ? (ES.CreateOnePropObject('smallestUnit', roundToParam) as Exclude) : ES.GetOptionsObject(roundToParam); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); const smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit', ES.REQUIRED); ES.ValidateTemporalUnitValue(smallestUnit, 'time', ['day']); diff --git a/lib/plaintime.ts b/lib/plaintime.ts index 771e8354..f7b9945a 100644 --- a/lib/plaintime.ts +++ b/lib/plaintime.ts @@ -92,7 +92,7 @@ export class PlainTime implements Temporal.PlainTime { typeof roundToParam === 'string' ? (ES.CreateOnePropObject('smallestUnit', roundToParam) as Exclude) : ES.GetOptionsObject(roundToParam); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); const smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit', ES.REQUIRED); ES.ValidateTemporalUnitValue(smallestUnit, 'time'); diff --git a/lib/regex.ts b/lib/regex.ts index 1267e3df..52c56927 100644 --- a/lib/regex.ts +++ b/lib/regex.ts @@ -57,5 +57,3 @@ const fraction = /(\d+)(?:[.,](\d{1,9}))?/; const durationDate = /(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)W)?(?:(\d+)D)?/; const durationTime = new RegExp(`(?:${fraction.source}H)?(?:${fraction.source}M)?(?:${fraction.source}S)?`); export const duration = new RegExp(`^([+-])?P${durationDate.source}(?:T(?!$)${durationTime.source})?$`, 'i'); - -export const monthCode = /^M(?:(00)(L)|(0[1-9])(L)?|([1-9][0-9])(L)?)$/; diff --git a/lib/zoneddatetime.ts b/lib/zoneddatetime.ts index 53c8e4d3..29b82e61 100644 --- a/lib/zoneddatetime.ts +++ b/lib/zoneddatetime.ts @@ -236,7 +236,7 @@ export class ZonedDateTime implements Temporal.ZonedDateTime { typeof roundToParam === 'string' ? (ES.CreateOnePropObject('smallestUnit', roundToParam) as Exclude) : ES.GetOptionsObject(roundToParam); - const roundingIncrement = ES.GetTemporalRoundingIncrementOption(roundTo); + const roundingIncrement = ES.GetRoundingIncrementOption(roundTo); const roundingMode = ES.GetRoundingModeOption(roundTo, 'halfExpand'); const smallestUnit = ES.GetTemporalUnitValuedOption(roundTo, 'smallestUnit', ES.REQUIRED); ES.ValidateTemporalUnitValue(smallestUnit, 'time', ['day']); @@ -331,9 +331,9 @@ export class ZonedDateTime implements Temporal.ZonedDateTime { const showOffset = ES.GetTemporalShowOffsetOption(resolvedOptions); const roundingMode = ES.GetRoundingModeOption(resolvedOptions, 'trunc'); const smallestUnit = ES.GetTemporalUnitValuedOption(resolvedOptions, 'smallestUnit'); + const showTimeZone = ES.GetTemporalShowTimeZoneNameOption(resolvedOptions); ES.ValidateTemporalUnitValue(smallestUnit, 'time'); if (smallestUnit === 'hour') throw new RangeError('smallestUnit must be a time unit other than "hour"'); - const showTimeZone = ES.GetTemporalShowTimeZoneNameOption(resolvedOptions); const { precision, unit, increment } = ES.ToSecondsStringPrecisionRecord(smallestUnit, digits); return ES.TemporalZonedDateTimeToString(this, precision, showCalendar, showTimeZone, showOffset, { unit, diff --git a/test/datemath.mjs b/test/datemath.mjs deleted file mode 100644 index 340a2551..00000000 --- a/test/datemath.mjs +++ /dev/null @@ -1,97 +0,0 @@ -/* - ** Copyright (C) 2018-2019 Bloomberg LP. All rights reserved. - ** This code is governed by the license found in the LICENSE file. - */ - -import { describe, expect, it } from '@jest/globals'; -import * as Temporal from '@js-temporal/polyfill'; - -// Avoid these wrappers in new tests: -function assert(cond) { - expect(cond).toBeTruthy(); -} -function equal(a, b) { - expect(a).toBe(b); -} - -describe('Date.since(simple, simple)', () => { - build('Before Leap Day', '2020-01-03', '2020-02-15'); - build('Before Leap Day', '2020-01-28', '2020-02-15'); - build('Before Leap Day', '2020-01-31', '2020-02-15'); - build('Cross Leap Day', '2020-01-31', '2020-06-30'); - build('After Leap Day', '2020-03-31', '2020-06-30'); - build('After Leap Day', '2020-03-25', '2020-07-31'); -}); -describe('Date.since(normal, normal)', () => { - build('Month<2 & Month<2', '2018-01-20', '2019-01-05'); - build('Month>2 & Month>2', '2018-03-20', '2019-03-05'); - build('Month>2 & Month>2', '2018-04-20', '2019-04-05'); - build('Month<2 & Month>2', '2018-01-20', '2019-04-05'); - build('Month>2 & Month<2', '2018-03-20', '2019-01-05'); - build('Month>2 & Month<2', '2018-04-20', '2019-01-05'); -}); -describe('Date.since(leap, leap)', () => { - build('Month<2 & Month<2', '2016-01-20', '2020-01-05'); - build('Month>2 & Month>2', '2016-03-20', '2020-04-05'); - build('Month>2 & Month>2', '2016-03-20', '2020-03-05'); - build('Month<2 & Month>2', '2016-01-20', '2020-02-05'); - build('Month>2 & Month<2', '2016-03-20', '2020-01-05'); - build('Month>2 & Month<2', '2016-04-20', '2020-01-05'); -}); -describe('Date.since(leap, normal)', () => { - build('Month<2 & Month<2', '2016-01-20', '2017-01-05'); - build('Month>2 & Month>2', '2016-03-20', '2017-04-05'); - build('Month>2 & Month>2', '2016-04-20', '2017-03-05'); - build('Month<2 & Month>2', '2016-01-20', '2017-04-05'); - build('Month>2 & Month<2', '2016-03-20', '2017-01-05'); - build('Month>2 & Month<2', '2016-04-20', '2017-01-05'); -}); -describe('Date.since(normal, leap)', () => { - build('Month<2 & Month<2', '2019-01-20', '2020-01-05'); - build('Month>2 & Month>2', '2019-03-20', '2020-04-05'); - build('Month>2 & Month>2', '2019-04-20', '2020-03-05'); - build('Month<2 & Month>2', '2019-01-20', '2020-04-05'); - build('Month>2 & Month<2', '2019-03-20', '2020-01-05'); - build('Month>2 & Month<2', '2019-04-20', '2020-01-05'); -}); - -function build(name, sone, stwo) { - const calendars = ['iso8601', 'gregory']; - describe(name, () => { - const largestUnits = ['years', 'months', 'weeks', 'days']; - for (const calendar of calendars) { - const [one, two] = [ - Temporal.PlainDate.from(sone).withCalendar(calendar), - Temporal.PlainDate.from(stwo).withCalendar(calendar) - ].sort(Temporal.PlainDate.compare); - buildSub(one, two, largestUnits); - buildSub(one.with({ day: 25 }), two.with({ day: 5 }), largestUnits); - buildSub(one.with({ day: 30 }), two.with({ day: 29 }), largestUnits); - buildSub(one.with({ day: 30 }), two.with({ day: 5 }), largestUnits); - } - }); -} -function buildSub(one, two, largestUnits) { - largestUnits.forEach((largestUnit) => { - describe(`< ${one} : ${two} (${largestUnit})>`, () => { - const dif = two.since(one, { largestUnit }); - const overflow = 'reject'; - if (largestUnit === 'months' || largestUnit === 'years') { - // For months and years, `until` and `since` won't agree because the - // starting point is always `this` and month-aware arithmetic behavior - // varies based on the starting point. - it(`(${two}).subtract(${dif}) => ${one}`, () => assert(two.subtract(dif).equals(one))); - it(`(${two}).add(-${dif}) => ${one}`, () => assert(two.add(dif.negated()).equals(one))); - const difUntil = one.until(two, { largestUnit }); - it(`(${one}).subtract(-${difUntil}) => ${two}`, () => assert(one.subtract(difUntil.negated()).equals(two))); - it(`(${one}).add(${difUntil}) => ${two}`, () => assert(one.add(difUntil).equals(two))); - } else { - it('until() and since() agree', () => equal(`${dif}`, `${one.until(two, { largestUnit })}`)); - it(`(${one}).add(${dif}) => ${two}`, () => assert(one.add(dif, { overflow }).equals(two))); - it(`(${two}).subtract(${dif}) => ${one}`, () => assert(two.subtract(dif, { overflow }).equals(one))); - it(`(${one}).subtract(-${dif}) => ${two}`, () => assert(one.subtract(dif.negated(), { overflow }).equals(two))); - it(`(${two}).add(-${dif}) => ${one}`, () => assert(two.add(dif.negated(), { overflow }).equals(one))); - } - }); - }); -} diff --git a/test/monthcode.mjs b/test/monthcode.mjs new file mode 100644 index 00000000..9fedbea5 --- /dev/null +++ b/test/monthcode.mjs @@ -0,0 +1,138 @@ +import { describe, expect, it } from '@jest/globals'; +import { CreateMonthCode, ParseMonthCode } from '../lib/monthcode'; + +// Avoid these wrappers in new tests: +function deepEqual(a, b) { + expect(a).toEqual(b); +} +function equal(a, b) { + expect(a).toBe(b); +} +function throws(f, cons) { + expect(f).toThrow(cons); +} + +function badMonthCode(code) { + throws(() => ParseMonthCode(code), RangeError, code); +} + +describe('ParseMonthCode', () => { + it('all Gregorian month codes', () => { + ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07', 'M08', 'M09', 'M10', 'M11', 'M12'].forEach((code, ix) => { + deepEqual(ParseMonthCode(code), { monthNumber: ix + 1, isLeapMonth: false }); + }); + }); + it('Intercalary month 13', () => { + deepEqual(ParseMonthCode('M13'), { monthNumber: 13, isLeapMonth: false }); + }); + it('all Chinese leap month codes', () => { + ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'].forEach( + (code, ix) => { + deepEqual(ParseMonthCode(code), { monthNumber: ix + 1, isLeapMonth: true }); + } + ); + }); + it('M00L (valid but does not occur in currently supported calendars)', () => { + deepEqual(ParseMonthCode('M00L'), { monthNumber: 0, isLeapMonth: true }); + }); + it('various other month codes that do not occur in currently supported calendars', () => { + const tests = [ + ['M14', 14, false], + ['M13L', 13, true], + ['M99', 99, false], + ['M99L', 99, true], + ['M42', 42, false], + ['M57L', 57, true] + ]; + for (const [code, monthNumber, isLeapMonth] of tests) { + deepEqual(ParseMonthCode(code), { monthNumber, isLeapMonth }); + } + }); + it('goes through ToPrimitive', () => { + ['toString', Symbol.toPrimitive].forEach((prop) => { + const convertibleObject = { + [prop]() { + return 'M01'; + } + }; + deepEqual(ParseMonthCode(convertibleObject), { monthNumber: 1, isLeapMonth: false }, prop); + }); + }); + it('no M00', () => badMonthCode('M00')); + it('missing leading zero', () => { + badMonthCode('M1'); + badMonthCode('M5L'); + }); + it('number too big', () => { + badMonthCode('M100'); + badMonthCode('M999L'); + }); + it('negative number', () => { + badMonthCode('M-3'); + badMonthCode('M-7L'); + }); + it('decimal point', () => { + badMonthCode('M2.'); + badMonthCode('M.9L'); + badMonthCode('M0.L'); + }); + it('no leading space', () => { + badMonthCode('M 5'); + badMonthCode('M 9L'); + }); + it('not a number', () => { + badMonthCode('M__'); + badMonthCode('MffL'); + }); + it('wrong leading character', () => { + badMonthCode('m11'); + badMonthCode('N11L'); + }); + it('missing leading character', () => { + badMonthCode('12'); + badMonthCode('03L'); + }); + it('wrong leap signifier', () => { + badMonthCode('M06l'); + badMonthCode('M06T'); + }); + it('junk at end of string', () => badMonthCode('M04L+')); + it('wrong primitive type', () => { + [true, 3, Symbol('M01'), 7n].forEach((wrongType) => { + throws(() => ParseMonthCode(wrongType), TypeError, typeof wrongType); + }); + }); + it('wrong toString', () => { + throws(() => ParseMonthCode({}), RangeError); + }); +}); + +describe('CreateMonthCode', () => { + it('all Gregorian month codes', () => { + ['M01', 'M02', 'M03', 'M04', 'M05', 'M06', 'M07', 'M08', 'M09', 'M10', 'M11', 'M12'].forEach((code, ix) => { + equal(CreateMonthCode(ix + 1, false), code); + }); + }); + it('Intercalary month 13', () => equal(CreateMonthCode(13, false), 'M13')); + it('all Chinese leap month codes', () => { + ['M01L', 'M02L', 'M03L', 'M04L', 'M05L', 'M06L', 'M07L', 'M08L', 'M09L', 'M10L', 'M11L', 'M12L'].forEach( + (code, ix) => { + equal(CreateMonthCode(ix + 1, true), code); + } + ); + }); + it('M00L (valid but does not occur in currently supported calendars)', () => equal(CreateMonthCode(0, true), 'M00L')); + it('various other month codes that do not occur in currently supported calendars', () => { + const tests = [ + [14, false, 'M14'], + [13, true, 'M13L'], + [99, false, 'M99'], + [99, true, 'M99L'], + [42, false, 'M42'], + [57, true, 'M57L'] + ]; + for (const [monthNumber, isLeapMonth, code] of tests) { + equal(CreateMonthCode(monthNumber, isLeapMonth), code); + } + }); +}); diff --git a/test262 b/test262 index a9ac87d3..ac3035f0 160000 --- a/test262 +++ b/test262 @@ -1 +1 @@ -Subproject commit a9ac87d3175a05bba8482c621062f905ed30f3a4 +Subproject commit ac3035f0d11b520016b33ff1401ecced29fef5a4