From f52cfc61da8981e58fb71366c487763a03609603 Mon Sep 17 00:00:00 2001 From: Tony Samperi Date: Sun, 26 May 2024 11:59:02 +0200 Subject: [PATCH] v4.6.0 * Validate time zone in quickDT prior to guessing offset (#1575) * Use getPrototypeOf instead of __proto__ (#1592) * Perf: Memoize digitsRegex (#1581) * Add DateTime.buildFormatParser and DateTime.fromFormatParser (#1582) * Perf: Use computed offset passed in DateTime constructor (#1576) * Update interval.js doc per #742 (#1565) * Added some JS doc for time zones (#1499) * Fix cutoff year docs * Perf: Cache ts offset guesses for quickDT (#1579) --- CHANGELOG.md | 11 + README.md | 6 +- benchmarks/datetime.ts | 16 +- package.json | 2 +- src/datetime.ts | 156 ++- src/impl/digits.ts | 27 +- src/impl/locale.ts | 104 +- src/impl/tokenParser.ts | 91 +- src/impl/util.ts | 7 + src/interval.ts | 7 +- src/settings.ts | 15 +- src/zone.ts | 8 +- src/zones/fixedOffsetZone.ts | 104 +- test/datetime/dst.test.ts | 298 ++++-- test/datetime/proto.test.ts | 9 +- test/datetime/regexParse.test.ts | 1539 +++++++++++++++--------------- test/datetime/tokenParse.test.ts | 26 + test/interval/proto.test.ts | 7 +- 18 files changed, 1422 insertions(+), 1011 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e89ca05..47d57a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 4.6.0 (Luxon next 3.4.4) +* Validate time zone in quickDT prior to guessing offset (#1575) +* Use getPrototypeOf instead of __proto__ (#1592) +* Perf: Memoize digitsRegex (#1581) +* Add DateTime.buildFormatParser and DateTime.fromFormatParser (#1582) +* Perf: Use computed offset passed in DateTime constructor (#1576) +* Update interval.js doc per #742 (#1565) +* Added some JS doc for time zones (#1499) +* Fix cutoff year docs +* Perf: Cache ts offset guesses for quickDT (#1579) + ## 4.5.2 (Luxon 3.4.4) * Fixed Datetime docs for: fromJSDate, fromMillis, fromSeconds, diff (Closes [#9](https://github.com/tonysamperi/ts-luxon/issues/9)) diff --git a/README.md b/README.md index ebd766d..feb6e02 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # TS Luxon [![MIT License][license-image]][license] [![Build Status][gh-actions-image]][gh-actions-url] [![NPM version][npm-version-image]][npm-url] [![Coverage Status][test-coverage-image]][test-coverage-url] ![PRs welcome][contributing-image] -[![Size](https://img.shields.io/bundlephobia/minzip/ts-luxon)](https://unpkg.com/ts-luxon@latest/bundles/ts-luxon.umd.min.js) +[![Size](https://img.shields.io/bundlephobia/minzip/ts-luxon)](https://unpkg.com/ts-luxon@latest/dist/ts-luxon.min.umd.js) TS Luxon is a library for working with dates and times in Javscript and Typescript. @@ -70,8 +70,8 @@ Pleas, read the CONTRIBUTING.md you can find in the master branch. [license-image]: https://img.shields.io/badge/license-MIT-blue.svg [license]: LICENSE.md -[gh-actions-url]: https://github.com/tonysamperi/ts-luxon/actions?query=workflow%3A%22Docker+tests%22 -[gh-actions-image]: https://github.com/tonysamperi/ts-luxon/workflows/Docker%20tests/badge.svg?branch=master +[gh-actions-url]: https://github.com/tonysamperi/ts-luxon/actions?query=workflow%3A%22Test%22 +[gh-actions-image]: https://github.com/tonysamperi/ts-luxon/workflows/Test/badge.svg?branch=master [npm-url]: https://npmjs.org/package/ts-luxon [npm-version-image]: https://badge.fury.io/js/ts-luxon.svg diff --git a/benchmarks/datetime.ts b/benchmarks/datetime.ts index 217c28d..4e0ac3f 100644 --- a/benchmarks/datetime.ts +++ b/benchmarks/datetime.ts @@ -8,9 +8,10 @@ function runDateTimeSuite() { const suite = new Benchmark.Suite(); const dt = DateTime.now(); + const formatParser = DateTime.buildFormatParser("yyyy/MM/dd HH:mm:ss.SSS"); suite - .add("DateTime.local", () => { + .add("DateTime.now", () => { DateTime.now(); }) .add("DateTime.fromObject with locale", () => { @@ -19,6 +20,9 @@ function runDateTimeSuite() { .add("DateTime.local with numbers", () => { DateTime.local(2017, 5, 15); }) + .add("DateTime.local with numbers and zone", () => { + DateTime.local(2017, 5, 15, 11, 7, 35, { zone: "America/New_York" }); + }) .add("DateTime.fromISO", () => { DateTime.fromISO("1982-05-25T09:10:11.445Z"); }) @@ -30,7 +34,15 @@ function runDateTimeSuite() { }) .add("DateTime.fromFormat with zone", () => { DateTime.fromFormat("1982/05/25 09:10:11.445", "yyyy/MM/dd HH:mm:ss.SSS", { - zone: "America/Los_Angeles", + zone: "America/Los_Angeles" + }); + }) + .add("DateTime.fromFormatParser", () => { + DateTime.fromFormatParser("1982/05/25 09:10:11.445", formatParser); + }) + .add("DateTime.fromFormatParser with zone", () => { + DateTime.fromFormatParser("1982/05/25 09:10:11.445", formatParser, { + zone: "America/Los_Angeles" }); }) .add("DateTime#setZone", () => { diff --git a/package.json b/package.json index 4936480..ea4a2e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts-luxon", - "version": "4.5.2", + "version": "4.6.0", "license": "MIT", "description": "Typescript version of the \"Immutable date wrapper\"", "author": "Tony Samperi", diff --git a/src/datetime.ts b/src/datetime.ts index 647a3d0..de005b8 100644 --- a/src/datetime.ts +++ b/src/datetime.ts @@ -29,7 +29,8 @@ import { parseFromTokens, explainFromTokens, formatOptsToTokens, - expandMacroTokens + expandMacroTokens, + TokenParser } from "./impl/tokenParser"; import { gregorianToWeek, @@ -271,7 +272,7 @@ interface Config extends DateTimeConfig { * {@link DateTime#day}, {@link DateTime#hour}, {@link DateTime#minute}, {@link DateTime#second}, {@link DateTime#millisecond} accessors. * * **Week calendar**: For ISO week calendar attributes, see the {@link DateTime#weekYear}, {@link DateTime#weekNumber}, and {@link DateTime#weekday} accessors. * * **Configuration** See the {@link DateTime#locale} and {@link DateTime#numberingSystem} accessors. - * * **Transformation**: To transform the DateTime into other DateTimes, use {@link DateTime#set}, {@link DateTime#reconfigure}, {@link DateTime#setZone}, {@link DateTime#setLocale}, {@link DateTime.plus}, {@link DateTime#minus}, {@link DateTime#endOf}, {@link DateTime#startOf}, {@link DateTime#toUTC}, and {@link DateTime#toLocal}. + * * **Transformation**: To transform the DateTime into other DateTimes, use {@link DateTime#set}, {@link DateTime#reconfigure}, {@link DateTime#setZone}, {@link DateTime#setLocale}, {@link DateTime#plus}, {@link DateTime#minus}, {@link DateTime#endOf}, {@link DateTime#startOf}, {@link DateTime#toUTC}, and {@link DateTime#toLocal}. * * **Output**: To convert the DateTime to other representations, use the {@link DateTime#toRelative}, {@link DateTime#toRelativeCalendar}, {@link DateTime#toJSON}, {@link DateTime#toISO}, {@link DateTime#toHTTP}, {@link DateTime#toObject}, {@link DateTime#toRFC2822}, {@link DateTime#toString}, {@link DateTime#toLocaleString}, {@link DateTime#toFormat}, {@link DateTime#toMillis} and {@link DateTime#toJSDate}. * * There's plenty others documented below. In addition, for more information on subtler topics like internationalization, time zones, alternative calendars, validity, and so on, see the external documentation. @@ -391,6 +392,9 @@ export class DateTime { */ static readonly TIME_WITH_SHORT_OFFSET = Formats.TIME_WITH_SHORT_OFFSET; + private static _zoneOffsetGuessCache: Map = new Map(); + private static _zoneOffsetTs: number; + /** * Get the day of the month (1-30ish). * @example DateTime.local(2017, 5, 25).day //=> 25 @@ -555,7 +559,7 @@ export class DateTime { * Defaults to the system's locale if no locale has been specified * @example DateTime.local(2017, 10, 30).monthLong //=> October */ - get monthLong(): string { + get monthLong(): string { return this.isValid ? Info.months("long", { locObj: this._loc })[this.month - 1] : null; } @@ -564,7 +568,7 @@ export class DateTime { * Defaults to the system's locale if no locale has been specified * @example DateTime.local(2017, 10, 30).monthShort //=> Oct */ - get monthShort(): string { + get monthShort(): string { return this.isValid ? Info.months("short", { locObj: this._loc })[this.month - 1] : null; } @@ -588,7 +592,7 @@ export class DateTime { * Get the long human name for the zone's current offset, for example "Eastern Standard Time" or "Eastern Daylight Time". * Defaults to the system's locale if no locale has been specified */ - get offsetNameLong(): string { + get offsetNameLong(): string { if (!this.isValid) { return null; } @@ -603,7 +607,7 @@ export class DateTime { * Get the short human name for the zone's current offset, for example "EST" or "EDT". * Defaults to the system's locale if no locale has been specified */ - get offsetNameShort(): string { + get offsetNameShort(): string { if (!this.isValid) { return null; } @@ -687,7 +691,7 @@ export class DateTime { * Defaults to the system's locale if no locale has been specified * @example DateTime.local(2017, 10, 30).weekdayLong //=> Monday */ - get weekdayLong(): string { + get weekdayLong(): string { return this.isValid ? Info.weekdays("long", { locObj: this._loc })[this.weekday - 1] : null; } @@ -696,7 +700,7 @@ export class DateTime { * Defaults to the system's locale if no locale has been specified * @example DateTime.local(2017, 10, 30).weekdayShort //=> Mon */ - get weekdayShort(): string { + get weekdayShort(): string { return this.isValid ? Info.weekdays("short", { locObj: this._loc })[this.weekday - 1] : null; } @@ -744,7 +748,7 @@ export class DateTime { /** * Get the name of the time zone. */ - get zoneName(): string { + get zoneName(): string { return this.isValid ? this.zone.name : null; } @@ -786,7 +790,8 @@ export class DateTime { [c, o] = [config.old.c, config.old.o]; } else { - const ot = zone.offset(this._ts); + // If an offset has been passed + we have not been called from clone(), we can trust it and avoid the offset calculation. + const ot = isNumber(config.o) && !config.old ? config.o : zone.offset(this.ts); c = tsToObj(this._ts, ot); invalid = Number.isNaN(c.year) ? new Invalid("invalid input") : null; c = invalid ? void 0 : c; @@ -824,6 +829,28 @@ export class DateTime { this._isLuxonDateTime = true; } + /** + * Build a parser for `fmt` using the given locale. This parser can be passed + * to {@link DateTime.fromFormatParser} to a parse a date in this format. This + * can be used to optimize cases where many dates need to be parsed in a + * specific format. + * + * @param {String} fmt - the format the string is expected to be in (see + * description) + * @param {Object} options - options used to set locale and numberingSystem + * for parser + * @returns {TokenParser} - opaque object to be used + */ + static buildFormatParser(fmt: string, options: LocaleOptions = {}): TokenParser { + const { locale = null, numberingSystem = null } = options, + localeToUse = Locale.fromOpts({ + locale, + numberingSystem, + defaultToEN: true + }); + return new TokenParser(localeToUse, fmt); + } + /** * Produce the fully expanded format token for the locale * Does NOT quote characters, so quoted tokens will not round trip correctly @@ -872,7 +899,7 @@ export class DateTime { * Explain how a string would be parsed by fromFormat() * @param {string} text - the string to parse * @param {string} fmt - the format the string is expected to be in (see description) - * @param {Object} options - options taken by fromFormat() + * @param {DateTimeOptions} options - options taken by fromFormat() */ static fromFormatExplain(text: string, fmt: string, options: DateTimeOptions = {}): ExplainedFormat { const { locale, numberingSystem } = options, @@ -884,6 +911,53 @@ export class DateTime { return explainFromTokens(localeToUse, text, fmt); } + /** + * Create a DateTime from an input string and format parser. + * + * The format parser must have been created with the same locale as this call. + * + * @param {String} text - the string to parse + * @param {TokenParser} formatParser - parser from {@link DateTime.buildFormatParser} + * @param {DateTimeOptions} opts - options taken by fromFormat() + * @returns {DateTime} + */ + static fromFormatParser(text: string, formatParser: TokenParser, opts: DateTimeOptions = {}): DateTime { + if (isUndefined(text) || isUndefined(formatParser)) { + throw new InvalidArgumentError( + "fromFormatParser requires an input string and a format parser" + ); + } + const { locale = null, numberingSystem = null } = opts, + localeToUse = Locale.fromOpts({ + locale, + numberingSystem, + defaultToEN: true + }); + + if (!localeToUse.equals(formatParser.locale)) { + throw new InvalidArgumentError( + `fromFormatParser called with a locale of ${localeToUse}, ` + + `but the format parser was created for ${formatParser.locale}` + ); + } + + const { result, zone, specificOffset, invalidReason } = formatParser.explainFromTokens(text); + + if (invalidReason) { + return DateTime.invalid(invalidReason); + } + else { + return parseDataToDateTime( + result, + zone, + opts, + `format ${formatParser.format}`, + text, + specificOffset + ); + } + } + /** * Create a DateTime from an HTTP header date * @see https://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.3.1 @@ -1281,6 +1355,11 @@ export class DateTime { return !tokenList ? null : tokenList.map((t) => (t ? t.val : null)).join(""); } + static resetCache(): void { + this._zoneOffsetTs = void 0; + this._zoneOffsetGuessCache = new Map(); + } + /** * Create a DateTime in UTC * @param args - The date values (year, month, etc.) and/or the configuration options for the DateTime @@ -1413,6 +1492,38 @@ export class DateTime { return format(start > end ? -0 : 0, opts.units[opts.units.length - 1]); } + /** + * @private + * cache offsets for zones based on the current timestamp when this function is + * first called. When we are handling a datetime from components like (year, + * month, day, hour) in a time zone, we need a guess about what the timezone + * offset is so that we can convert into a UTC timestamp. One way is to find the + * offset of now in the zone. The actual date may have a different offset (for + * example, if we handle a date in June while we're in December in a zone that + * observes DST), but we can check and adjust that. + * When handling many dates, calculating the offset for now every time is + * expensive. It's just a guess, so we can cache the offset to use even if we + * are right on a time change boundary (we'll just correct in the other + * direction). Using a timestamp from first read is a slight optimization for + * handling dates close to the current date, since those dates will usually be + * in the same offset (we could set the timestamp statically, instead). We use a + * single timestamp for all zones to make things a bit more predictable. + * This is safe for quickDT (used by local() and utc()) because we don't fill in + * higher-order units from tsNow (as we do in fromObject, this requires that + * offset is calculated from tsNow). + */ + private static _guessOffsetForZone(zone: Zone): number { + if (!this._zoneOffsetGuessCache.has(zone)) { + if (this._zoneOffsetTs === undefined) { + this._zoneOffsetTs = Settings.now(); + } + + this._zoneOffsetGuessCache.set(zone, zone.offset(this._zoneOffsetTs)); + } + + return this._zoneOffsetGuessCache.get(zone); + } + /** * @private */ @@ -1438,9 +1549,13 @@ export class DateTime { // but doesn't do any validation, makes a bunch of assumptions about what units // are present, and so on. private static _quickDT(obj: GregorianDateTime, opts: DateTimeOptions): DateTime { - const zone = normalizeZone(opts.zone, Settings.defaultZone), - loc = Locale.fromObject(opts), - tsNow = Settings.now(); + const zone = normalizeZone(opts.zone, Settings.defaultZone); + if (!zone.isValid) { + return DateTime.invalid(this._unsupportedZone(zone)); + } + + const loc = Locale.fromObject(opts); + const tsNow = Settings.now(); let ts, o; @@ -1459,7 +1574,7 @@ export class DateTime { return DateTime.invalid(invalid); } - const offsetProvis = zone.offset(tsNow); + const offsetProvis = this._guessOffsetForZone(zone); [ts, o] = objToTS(obj, offsetProvis, zone); } else { @@ -1485,7 +1600,8 @@ export class DateTime { [Symbol.for("nodejs.util.inspect.custom")](): string { if (this.isValid) { return `DateTime { ts: ${this.toISO()}, zone: ${this.zone.name}, locale: ${this.locale} }`; - } else { + } + else { return `DateTime { Invalid, reason: ${this.invalidReason} }`; } } @@ -1946,7 +2062,7 @@ export class DateTime { * @example DateTime.utc(1982, 5, 25).toISODate({ format: 'basic' }) //=> '19820525' * @return {string} */ - toISODate({format = "extended"} : { format?: ToISOFormat } = { format: "extended" }): string { + toISODate({ format = "extended" }: { format?: ToISOFormat } = { format: "extended" }): string { if (!this.isValid) { return null; } @@ -1992,7 +2108,7 @@ export class DateTime { * @example DateTime.utc(1982, 5, 25).toISOWeekDate() //=> '1982-W21-2' * @return {string} */ - toISOWeekDate(): string { + toISOWeekDate(): string { return toTechFormat(this, "kkkk-'W'WW-c"); } @@ -2189,7 +2305,7 @@ export class DateTime { * @example DateTime.local(2014, 7, 13).toSQL({ includeZone: true }) //=> '2014-07-13 00:00:00.000 America/New_York' * @return {string} */ - toSQL(opts: ToSQLOptions = {}): string { + toSQL(opts: ToSQLOptions = {}): string { if (!this.isValid) { return null; } @@ -2250,7 +2366,7 @@ export class DateTime { * Returns a string representation of this DateTime appropriate for debugging * @return {string} */ - toString(): string { + toString(): string { return this.isValid ? this.toISO() : INVALID; } diff --git a/src/impl/digits.ts b/src/impl/digits.ts index 56e1e69..dfb8ab8 100644 --- a/src/impl/digits.ts +++ b/src/impl/digits.ts @@ -60,13 +60,16 @@ export function parseDigits(str: string) { } let digits = ""; - for (let i = 0; i < str.length; i++) { + for (let i = 0; + i < str.length; + i++) { const code = str.charCodeAt(i); if (str[i].search(numberingSystems.hanidec) !== -1) { digits += hanidecChars.indexOf(str[i]); } else { - for (const key in numberingSystemsUTF16) { + for (const key in + numberingSystemsUTF16) { const [min, max] = numberingSystemsUTF16[key as NumberingSystemUTF16]; if (code >= min && code <= max) { digits += code - min; @@ -78,6 +81,22 @@ export function parseDigits(str: string) { return parseInt(digits, 10); } -export function digitRegex(locale: Locale, append = "") { - return new RegExp(`${numberingSystems[locale.numberingSystem || "latn"]}${append}`); +// cache of {numberingSystem: {append: regex}} +let digitRegexCache: Record, Record> = {} as Record, Record>; + +export function resetDigitRegexCache(): void { + digitRegexCache = {} as Record, Record>; +} + +export function digitRegex({ numberingSystem }: Locale, append: string = ""): RegExp { + const ns = numberingSystem || "latn" as NumberingSystem; + + if (!digitRegexCache[ns]) { + digitRegexCache[ns] = {}; + } + if (!digitRegexCache[ns][append]) { + digitRegexCache[ns][append] = new RegExp(`${numberingSystems[ns]}${append}`); + } + + return digitRegexCache[ns][append]; } diff --git a/src/impl/locale.ts b/src/impl/locale.ts index 3ad7cf2..b36c2cb 100644 --- a/src/impl/locale.ts +++ b/src/impl/locale.ts @@ -159,10 +159,10 @@ function mapWeekdays(f: (d: DateTime) => T): T[] { } function listStuff( - loc: Locale, - length: T, - englishFn: (length: T) => string[], - intlFn: (length: T) => string[] + loc: Locale, + length: T, + englishFn: (length: T) => string[], + intlFn: (length: T) => string[] ): string[] { const mode = loc.listingMode(); @@ -339,10 +339,10 @@ class PolyRelFormatter { } else { return English.formatRelativeTime( - unit, - count, - this._opts.numeric, - this._opts.style !== "long" + unit, + count, + this._opts.numeric, + this._opts.style !== "long" ); } } @@ -398,11 +398,11 @@ export class Locale { private _weekdaysCache: Readonly; private constructor( - locale: string, - numberingSystem?: NumberingSystem, - outputCalendar?: CalendarSystem, - weekSettings?: WeekSettings | void, - specifiedLocale?: string) { + locale: string, + numberingSystem?: NumberingSystem, + outputCalendar?: CalendarSystem, + weekSettings?: WeekSettings | void, + specifiedLocale?: string) { const [parsedLocale, parsedNumberingSystem, parsedOutputCalendar] = parseLocaleString(locale); @@ -454,11 +454,11 @@ export class Locale { } else { return Locale.create( - alts.locale || this._specifiedLocale, - alts.numberingSystem || this.numberingSystem, - alts.outputCalendar || this.outputCalendar, - validateWeekSettings(alts.weekSettings) || this._weekSettings, - alts.defaultToEN || false + alts.locale || this._specifiedLocale, + alts.numberingSystem || this.numberingSystem, + alts.outputCalendar || this.outputCalendar, + validateWeekSettings(alts.weekSettings) || this._weekSettings, + alts.defaultToEN || false ); } } @@ -469,9 +469,9 @@ export class Locale { equals(other: Locale): boolean { return ( - this.locale === other.locale && - this.numberingSystem === other.numberingSystem && - this.outputCalendar === other.outputCalendar + this.locale === other.locale && + this.numberingSystem === other.numberingSystem && + this.outputCalendar === other.outputCalendar ); } @@ -483,7 +483,7 @@ export class Locale { // to definitely enumerate them. if (!this._eraCache[len]) { this._eraCache[len] = [DateTime.utc(-40, 1, 1), DateTime.utc(2017, 1, 1)].map(dt => - this.extract(dt, intl, "era") + this.extract(dt, intl, "era") ); } @@ -493,8 +493,8 @@ export class Locale { extract(dt: DateTime, intlOptions: Intl.DateTimeFormatOptions, field: Intl.DateTimeFormatPartTypes): string { const df = this.dtFormatter(dt, intlOptions), - results = df.formatToParts(), - matching = results.find((m: Intl.DateTimeFormatPart) => m.type.toLowerCase() === field.toLowerCase()); + results = df.formatToParts(), + matching = results.find((m: Intl.DateTimeFormatPart) => m.type.toLowerCase() === field.toLowerCase()); if (!matching) { throw new Error(`Invalid extract field ${field}`); } @@ -528,9 +528,9 @@ export class Locale { isEnglish(): boolean { return ( - // tslint:disable-next-line:no-bitwise - !!~["en", "en-us"].indexOf(this.locale.toLowerCase()) || - new Intl.DateTimeFormat(this._intl).resolvedOptions().locale.startsWith("en-us") + // tslint:disable-next-line:no-bitwise + !!~["en", "en-us"].indexOf(this.locale.toLowerCase()) || + new Intl.DateTimeFormat(this._intl).resolvedOptions().locale.startsWith("en-us") ); } @@ -542,28 +542,28 @@ export class Locale { listingMode(): "en" | "intl" { const isActuallyEn = this.isEnglish(); const hasNoWeirdness = - (this.numberingSystem === null || this.numberingSystem === "latn") && - (this.outputCalendar === null || this.outputCalendar === "gregory"); + (this.numberingSystem === null || this.numberingSystem === "latn") && + (this.outputCalendar === null || this.outputCalendar === "gregory"); return isActuallyEn && hasNoWeirdness ? "en" : "intl"; } meridiems(): string[] { return listStuff( - this, - "long", // arbitrary unused value - () => English.meridiems, - () => { - // In theory there could be aribitrary day periods. We're gonna assume there are exactly two - // for AM and PM. This is probably wrong, but it makes parsing way easier. - if (this._meridiemCache === undefined) { - this._meridiemCache = [ - DateTime.utc(2016, 11, 13, 9), - DateTime.utc(2016, 11, 13, 19) - ].map(dt => this.extract(dt, { hour: "numeric", hourCycle: "h12" }, "dayPeriod")); - } - - return this._meridiemCache as string[]; - } + this, + "long", // arbitrary unused value + () => English.meridiems, + () => { + // In theory there could be aribitrary day periods. We're gonna assume there are exactly two + // for AM and PM. This is probably wrong, but it makes parsing way easier. + if (this._meridiemCache === undefined) { + this._meridiemCache = [ + DateTime.utc(2016, 11, 13, 9), + DateTime.utc(2016, 11, 13, 19) + ].map(dt => this.extract(dt, { hour: "numeric", hourCycle: "h12" }, "dayPeriod")); + } + + return this._meridiemCache as string[]; + } ); } @@ -594,11 +594,15 @@ export class Locale { return new PolyRelFormatter(this._intl, this.isEnglish(), options); } + toString(): string { + return `Locale(${this.locale}, ${this.numberingSystem}, ${this.outputCalendar})`; + } + weekdays(length: WeekUnitLengths, format: boolean = false): string[] { return listStuff(this, length, English.weekdays, len => { const intl: Intl.DateTimeFormatOptions = format - ? { weekday: len, year: "numeric", month: "long", day: "numeric" } - : { weekday: len }; + ? { weekday: len, year: "numeric", month: "long", day: "numeric" } + : { weekday: len }; const formatStr = format ? "format" : "standalone"; if (!this._weekdaysCache[formatStr][len]) { this._weekdaysCache[formatStr][len] = mapWeekdays(dt => this.extract(dt, intl, "weekday")); @@ -628,10 +632,10 @@ export class Locale { } else { return ( - this.numberingSystem === "latn" || - !this.locale || - this.locale.startsWith("en") || - Intl.DateTimeFormat(this._intl).resolvedOptions().numberingSystem === "latn" + this.numberingSystem === "latn" || + !this.locale || + this.locale.startsWith("en") || + Intl.DateTimeFormat(this._intl).resolvedOptions().numberingSystem === "latn" ); } } diff --git a/src/impl/tokenParser.ts b/src/impl/tokenParser.ts index 01e37f2..c61922c 100644 --- a/src/impl/tokenParser.ts +++ b/src/impl/tokenParser.ts @@ -327,9 +327,9 @@ function tokenForPart(part: Intl.DateTimeFormatPart, return void 0; } -function buildRegex(units: UnitParser[]): string { - const re = units.map(u => u.regex).reduce((f, r) => `${f}(${r.source})`, ""); - return `^${re}$`; +function buildRegex(units: UnitParser[]): [string, UnitParser[]] { + const re = units.map((u) => u.regex).reduce((f, r) => `${f}(${r.source})`, ""); + return [`^${re}$`, units]; } function match(input: string, regex: RegExp, handlers: UnitParser[]): [RegExpMatchArray | null, Record] { @@ -458,10 +458,6 @@ function maybeExpandMacroToken(token: FormatToken, locale: Locale): FormatToken return tokens; } -function isInvalidUnitParser(parser: unknown): parser is InvalidUnitParser { - return !!parser && !!(parser as { invalidReason: string | undefined }).invalidReason; -} - export function expandMacroTokens(tokens: FormatToken[], locale: Locale): Array { return Array.prototype.concat(...tokens.map(t => maybeExpandMacroToken(t, locale))); } @@ -469,28 +465,73 @@ export function expandMacroTokens(tokens: FormatToken[], locale: Locale): Array< /** * @private */ -export function explainFromTokens(locale: Locale, input: string, format: string): ExplainedFormat { - const tokens = expandMacroTokens(Formatter.parseFormat(format), locale); - const units = tokens.map((t: FormatToken) => unitForToken(t, locale)); - const disqualifyingUnit = units.find(isInvalidUnitParser); - if (disqualifyingUnit) { - return { input, tokens, invalidReason: disqualifyingUnit.invalidReason }; +export class TokenParser { + + get invalidReason(): string | null { + return this.disqualifyingUnit ? this.disqualifyingUnit.invalidReason : null; + } + + get isValid(): boolean { + return !this.disqualifyingUnit; } - else { - const regexString = buildRegex(units as UnitParser[]), - regex = RegExp(regexString, "i"), - [rawMatches, matches] = match(input, regex, units as UnitParser[]), - [result, zone, specificOffset] = matches - ? dateTimeFromMatches(matches) - : [null, null, void 0]; - if ("a" in matches && "H" in matches) { - throw new ConflictingSpecificationError( - "Can't include meridiem when specifying 24-hour format" - ); + + disqualifyingUnit: { invalidReason: string }; + handlers: UnitParser[]; + regex: RegExp; + tokens: Array = expandMacroTokens(Formatter.parseFormat(this.format), this.locale); + units: UnitParser[]; + + constructor(public locale: Locale, public format: string) { + this._mapTokens(); + } + + explainFromTokens(input: string): ExplainedFormat { + if (!this.isValid) { + return { input, tokens: this.tokens, invalidReason: this.invalidReason }; + } + else { + const [rawMatches, matches] = match(input, this.regex, this.handlers), + [result, zone, specificOffset] = matches + ? dateTimeFromMatches(matches) + : [null, null, undefined]; + if (matches.hasOwnProperty("a") && matches.hasOwnProperty("H")) { + throw new ConflictingSpecificationError( + "Can't include meridiem when specifying 24-hour format" + ); + } + return { + input, + tokens: this.tokens, + regex: this.regex, + rawMatches, + matches, + result, + zone, + specificOffset + }; } - return { input, tokens, regex, rawMatches, matches, result, zone, specificOffset }; } + + private _mapTokens(): void { + const units = this.tokens.map((t) => unitForToken(t, this.locale)); + this.disqualifyingUnit = units.find((t) => (t as InvalidUnitParser).invalidReason) as { + invalidReason: string + }; + this.units = units.filter(u => !(u as InvalidUnitParser).invalidReason) as UnitParser[]; + + if (!this.disqualifyingUnit) { + const [regexString, handlers] = buildRegex(this.units); + this.regex = RegExp(regexString, "i"); + this.handlers = handlers; + } + } + +} + +export function explainFromTokens(locale: Locale, input: string, format: string): ExplainedFormat { + const parser = new TokenParser(locale, format); + return parser.explainFromTokens(input); } export function sanitizeSpaces(input: string): string { diff --git a/src/impl/util.ts b/src/impl/util.ts index 4de2e73..a3e2930 100644 --- a/src/impl/util.ts +++ b/src/impl/util.ts @@ -302,6 +302,13 @@ export function normalizeObject(obj: Record, } +/** + * Returns the offset's value as a string + * @param {number} offset - Epoch milliseconds for which to get the offset + * @param {string} format - What style of offset to return. + * Accepts 'narrow', 'short', or 'techie'. Returning '+6', '+06:00', or '+0600' respectively + * @return {string} + */ export function formatOffset(offset: number, format: ZoneOffsetFormat): string { const hours = Math.trunc(Math.abs(offset / 60)), minutes = Math.trunc(Math.abs(offset % 60)), diff --git a/src/interval.ts b/src/interval.ts index 067e2b6..7778207 100644 --- a/src/interval.ts +++ b/src/interval.ts @@ -324,7 +324,8 @@ export class Interval { [Symbol.for("nodejs.util.inspect.custom")](): string { if (this.isValid) { return `Interval { start: ${this._s.toISO()}, end: ${this._e.toISO()} }`; - } else { + } + else { return `Interval { Invalid, reason: ${this.invalidReason} }`; } } @@ -403,7 +404,9 @@ export class Interval { } /** - * Return whether this Interval engulfs the start and end of the specified Interval. + * Returns true if this Interval fully contains the specified Interval, + * specifically if the intersection (of this Interval and the other Interval) is equal to the other Interval; + * false otherwise. * @param {Interval} other * @return {boolean} */ diff --git a/src/settings.ts b/src/settings.ts index 3ca4b4f..e13313c 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -6,6 +6,8 @@ import { Zone } from "./zone"; import { SystemZone } from "./zones/systemZone"; import { ZoneLike } from "./types/zone"; import { validateWeekSettings } from "./impl/util"; +import { DateTime } from "./datetime"; +import { resetDigitRegexCache } from "./impl/digits"; let now = (): number => Date.now(), defaultZone: ZoneLike | null = "system", @@ -140,9 +142,11 @@ export class Settings { } /** - * Set the cutoff year after which a string encoding a year as two digits is interpreted to occur in the current century. - * @example Settings.twoDigitCutoffYear = 0 // cut-off year is 0, so all 'yy' are interpreted as current century - * @example Settings.twoDigitCutoffYear = 50 // '49' -> 1949; '50' -> 2050 + * Set the cutoff year for whether a 2-digit year string is interpreted in the current or previous century. Numbers higher than the cutoff will be considered to mean 19xx and numbers lower or equal to the cutoff will be considered 20xx. + * @type {number} + * @example Settings.twoDigitCutoffYear = 0 // all 'yy' are interpreted as 20th century + * @example Settings.twoDigitCutoffYear = 99 // all 'yy' are interpreted as 21st century + * @example Settings.twoDigitCutoffYear = 50 // '49' -> 2049; '50' -> 1950 * @example Settings.twoDigitCutoffYear = 1950 // interpreted as 50 * @example Settings.twoDigitCutoffYear = 2050 // ALSO interpreted as 50 */ @@ -151,7 +155,8 @@ export class Settings { } /** - * Get the cutoff year after which a string encoding a year as two digits is interpreted to occur in the current century. + * Get the cutoff year for whether a 2-digit year string is interpreted in the current or previous century. Numbers higher than the cutoff will be considered to mean 19xx and numbers lower or equal to the cutoff will be considered 20xx. + * @type {number} */ static get twoDigitCutoffYear(): number { return twoDigitCutoffYear; @@ -165,6 +170,8 @@ export class Settings { static resetCaches(): void { Locale.resetCache(); IANAZone.resetCache(); + DateTime.resetCache(); + resetDigitRegexCache(); } } diff --git a/src/zone.ts b/src/zone.ts index 365ad0f..2967a97 100644 --- a/src/zone.ts +++ b/src/zone.ts @@ -20,7 +20,13 @@ export abstract class Zone { throw new ZoneIsAbstractError(); } - get ianaName() { + /** + * The IANA name of this zone. + * Defaults to `name` if not overwritten by a subclass. + * @abstract + * @type {string} + */ + get ianaName(): string { return this.name; } diff --git a/src/zones/fixedOffsetZone.ts b/src/zones/fixedOffsetZone.ts index 4a87782..4f254a4 100644 --- a/src/zones/fixedOffsetZone.ts +++ b/src/zones/fixedOffsetZone.ts @@ -14,38 +14,63 @@ export class FixedOffsetZone extends Zone { * Get a singleton instance of UTC * @return {FixedOffsetZone} */ - static get utcInstance() { + static get utcInstance(): FixedOffsetZone { if (singleton === null) { singleton = new FixedOffsetZone(0); } return singleton; } - /** @override **/ - get isValid() { - return true; - } - - get ianaName() { + /** + * The IANA name of this zone, i.e. `Etc/UTC` or `Etc/GMT+/-nn` + * + * @override + * @type {string} + */ + get ianaName(): string { return this._fixed === 0 ? "Etc/UTC" : `Etc/GMT${formatOffset(-this._fixed, "narrow")}`; } - /** @override **/ - get name() { - return this._fixed === 0 ? "UTC" : `UTC${formatOffset(this._fixed, "narrow")}`; + /** + * Returns whether the offset is known to be fixed for the whole year: + * Always returns true for all fixed offset zones. + * @override + * @type {boolean} + */ + get isUniversal(): boolean { + return true; } - /** @override **/ - get type() { - return "fixed"; + /** + * Return whether this Zone is valid: + * All fixed offset zones are valid. + * @override + * @type {boolean} + */ + get isValid(): true { + return true; } - /** @override **/ - get isUniversal() { - return true; + /** + * The name of this zone. + * All fixed zones' names always start with "UTC" (plus optional offset) + * @override + * @type {string} + */ + get name(): string { + return this._fixed === 0 ? "UTC" : `UTC${formatOffset(this._fixed, "narrow")}`; + } + + /** + * The type of zone. `fixed` for all instances of `FixedOffsetZone`. + * @override + * @type {string} + */ + get type(): "fixed" { + return "fixed"; } private readonly _fixed: number; @@ -62,7 +87,7 @@ export class FixedOffsetZone extends Zone { * @param {number} offset - The offset in minutes * @return {FixedOffsetZone} */ - static instance(offset: number) { + static instance(offset: number): FixedOffsetZone { return offset === 0 ? FixedOffsetZone.utcInstance : new FixedOffsetZone(offset); } @@ -74,7 +99,7 @@ export class FixedOffsetZone extends Zone { * @example FixedOffsetZone.parseSpecifier("UTC-6:00") * @return {FixedOffsetZone} */ - static parseSpecifier(s: string) { + static parseSpecifier(s: string): FixedOffsetZone { if (s) { const r = s.match(/^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$/i); if (r) { @@ -84,24 +109,47 @@ export class FixedOffsetZone extends Zone { return null; } - /** @override **/ - offsetName() { - return this.name; + /** + * Return whether this Zone is equal to another zone (i.e. also fixed and same offset) + * @override + * @param {Zone} otherZone - the zone to compare + * @return {boolean} + */ + equals(otherZone: FixedOffsetZone): boolean { + return otherZone.type === "fixed" && otherZone._fixed === this._fixed; } - /** @override **/ - formatOffset(_ts_: number, format: ZoneOffsetFormat) { + /** + * Returns the offset's value as a string + * @override + * @param {number} ts - Epoch milliseconds for which to get the offset + * @param {string} format - What style of offset to return. + * Accepts 'narrow', 'short', or 'techie'. Returning '+6', '+06:00', or '+0600' respectively + * @return {string} + */ + formatOffset(_ts_: number, format: ZoneOffsetFormat): string { return formatOffset(this._fixed, format); } - /** @override **/ - offset() { + /** + * Return the offset in minutes for this zone at the specified timestamp. + * + * For fixed offset zones, this is constant and does not depend on a timestamp. + * @override + * @return {number} + */ + offset(): number { return this._fixed; } - /** @override **/ - equals(otherZone: FixedOffsetZone) { - return otherZone.type === "fixed" && otherZone._fixed === this._fixed; + /** + * Returns the offset's common name at the specified timestamp. + * + * For fixed offset zones this equals to the zone name. + * @override + */ + offsetName(): string { + return this.name; } diff --git a/test/datetime/dst.test.ts b/test/datetime/dst.test.ts index bc22538..c77da2a 100644 --- a/test/datetime/dst.test.ts +++ b/test/datetime/dst.test.ts @@ -1,110 +1,198 @@ import { DateTime, Settings } from "../../src"; -const local = (year: number, month: number, day: number, hour: number) => - DateTime.fromObject({ year, month, day, hour }, { zone: "America/New_York" }); - -test("Hole dates are bumped forward", () => { - const d = local(2017, 3, 12, 2); - expect(d.hour).toBe(3); - expect(d.offset).toBe(-4 * 60); -}); - -// this is questionable behavior, but I wanted to document it -test("Ambiguous dates pick the one with the current offset", () => { - const oldSettings = Settings.now; - try { - Settings.now = () => 1495653314595; // May 24, 2017 - let d = local(2017, 11, 5, 1); - expect(d.hour).toBe(1); - expect(d.offset).toBe(-4 * 60); - - Settings.now = () => 1484456400000; // Jan 15, 2017 - d = local(2017, 11, 5, 1); - expect(d.hour).toBe(1); - expect(d.offset).toBe(-5 * 60); - } finally { - Settings.now = oldSettings; - } -}); - -test("Adding an hour to land on the Spring Forward springs forward", () => { - const d = local(2017, 3, 12, 1).plus({ hour: 1 }); - expect(d.hour).toBe(3); - expect(d.offset).toBe(-4 * 60); -}); - -test("Subtracting an hour to land on the Spring Forward springs forward", () => { - const d = local(2017, 3, 12, 3).minus({ hour: 1 }); - expect(d.hour).toBe(1); - expect(d.offset).toBe(-5 * 60); -}); - -test("Adding an hour to land on the Fall Back falls back", () => { - const d = local(2017, 11, 5, 0).plus({ hour: 2 }); - expect(d.hour).toBe(1); - expect(d.offset).toBe(-5 * 60); -}); - -test("Subtracting an hour to land on the Fall Back falls back", () => { - let d = local(2017, 11, 5, 3).minus({ hour: 2 }); - expect(d.hour).toBe(1); - expect(d.offset).toBe(-5 * 60); - - d = d.minus({ hour: 1 }); - expect(d.hour).toBe(1); - expect(d.offset).toBe(-4 * 60); -}); - -test("Changing a calendar date to land on a hole bumps forward", () => { - let d = local(2017, 3, 11, 2).plus({ day: 1 }); - expect(d.hour).toBe(3); - expect(d.offset).toBe(-4 * 60); - - d = local(2017, 3, 13, 2).minus({ day: 1 }); - expect(d.hour).toBe(3); - expect(d.offset).toBe(-4 * 60); -}); - -test("Changing a calendar date to land on an ambiguous time chooses the closest one", () => { - let d = local(2017, 11, 4, 1).plus({ day: 1 }); - expect(d.hour).toBe(1); - expect(d.offset).toBe(-4 * 60); - - d = local(2017, 11, 6, 1).minus({ day: 1 }); - expect(d.hour).toBe(1); - expect(d.offset).toBe(-5 * 60); -}); - -test("Start of a 0:00->1:00 DST day is 1:00", () => { - const d = DateTime.fromObject( - { - year: 2017, - month: 10, - day: 15 - }, - { - zone: "America/Sao_Paulo" +const dateTimeConstructors = { + fromObject: (year: number, month: number, day: number, hour: number) => + DateTime.fromObject({ year, month, day, hour }, { zone: "America/New_York" }), + local: (year: number, month: number, day: number, hour: number) => + DateTime.local(year, month, day, hour, { zone: "America/New_York" }) +}; + +for (const [name, local] of + Object.entries(dateTimeConstructors)) { + describe(`DateTime.${name}`, () => { + test("Hole dates are bumped forward", () => { + const d = local(2017, 3, 12, 2); + expect(d.hour).toBe(3); + expect(d.offset).toBe(-4 * 60); + }); + + if (name == "fromObject") { + // this is questionable behavior, but I wanted to document it + test("Ambiguous dates pick the one with the current offset", () => { + const oldSettings = Settings.now; + try { + Settings.now = () => 1495653314595; // May 24, 2017 + let d = local(2017, 11, 5, 1); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-4 * 60); + + Settings.now = () => 1484456400000; // Jan 15, 2017 + d = local(2017, 11, 5, 1); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-5 * 60); + } + finally { + Settings.now = oldSettings; + } + }); } - ).startOf("day"); - expect(d.day).toBe(15); - expect(d.hour).toBe(1); - expect(d.minute).toBe(0); - expect(d.second).toBe(0); -}); - -test("End of a 0:00->1:00 DST day is 23:59", () => { - const d = DateTime.fromObject( - { - year: 2017, - month: 10, - day: 15 - }, - { - zone: "America/Sao_Paulo" + else { + test("Ambiguous dates pick the one with the cached offset", () => { + const oldSettings = Settings.now; + try { + Settings.resetCaches(); + Settings.now = () => 1495653314595; // May 24, 2017 + let d = local(2017, 11, 5, 1); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-4 * 60); + + Settings.now = () => 1484456400000; // Jan 15, 2017 + d = local(2017, 11, 5, 1); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-4 * 60); + + Settings.resetCaches(); + + Settings.now = () => 1484456400000; // Jan 15, 2017 + d = local(2017, 11, 5, 1); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-5 * 60); + + Settings.now = () => 1495653314595; // May 24, 2017 + d = local(2017, 11, 5, 1); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-5 * 60); + } + finally { + Settings.now = oldSettings; + } + }); + } + + test("Adding an hour to land on the Spring Forward springs forward", () => { + const d = local(2017, 3, 12, 1).plus({ hour: 1 }); + expect(d.hour).toBe(3); + expect(d.offset).toBe(-4 * 60); + }); + + test("Subtracting an hour to land on the Spring Forward springs forward", () => { + const d = local(2017, 3, 12, 3).minus({ hour: 1 }); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-5 * 60); + }); + + test("Adding an hour to land on the Fall Back falls back", () => { + const d = local(2017, 11, 5, 0).plus({ hour: 2 }); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-5 * 60); + }); + + test("Subtracting an hour to land on the Fall Back falls back", () => { + let d = local(2017, 11, 5, 3).minus({ hour: 2 }); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-5 * 60); + + d = d.minus({ hour: 1 }); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-4 * 60); + }); + + test("Changing a calendar date to land on a hole bumps forward", () => { + let d = local(2017, 3, 11, 2).plus({ day: 1 }); + expect(d.hour).toBe(3); + expect(d.offset).toBe(-4 * 60); + + d = local(2017, 3, 13, 2).minus({ day: 1 }); + expect(d.hour).toBe(3); + expect(d.offset).toBe(-4 * 60); + }); + + test("Changing a calendar date to land on an ambiguous time chooses the closest one", () => { + let d = local(2017, 11, 4, 1).plus({ day: 1 }); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-4 * 60); + + d = local(2017, 11, 6, 1).minus({ day: 1 }); + expect(d.hour).toBe(1); + expect(d.offset).toBe(-5 * 60); + }); + + test("Start of a 0:00->1:00 DST day is 1:00", () => { + const d = DateTime.fromObject( + { + year: 2017, + month: 10, + day: 15 + }, + { + zone: "America/Sao_Paulo" + } + ).startOf("day"); + expect(d.day).toBe(15); + expect(d.hour).toBe(1); + expect(d.minute).toBe(0); + expect(d.second).toBe(0); + }); + + test("End of a 0:00->1:00 DST day is 23:59", () => { + const d = DateTime.fromObject( + { + year: 2017, + month: 10, + day: 15 + }, + { + zone: "America/Sao_Paulo" + } + ).endOf("day"); + expect(d.day).toBe(15); + expect(d.hour).toBe(23); + expect(d.minute).toBe(59); + expect(d.second).toBe(59); + }); + }); +} + +describe("DateTime.local() with offset caching", () => { + const edtTs = 1495653314000; // May 24, 2017 15:15:14 -0400 + const estTs = 1484456400000; // Jan 15, 2017 00:00 -0500 + + const edtDate = [2017, 5, 24, 15, 15, 14, 0]; + const estDate = [2017, 1, 15, 0, 0, 0, 0]; + + const timestamps = { EDT: edtTs, EST: estTs }; + const dates = { EDT: edtDate, EST: estDate }; + const zoneObj = { zone: "America/New_York" }; + + for (const [cacheName, cacheTs] of + Object.entries(timestamps)) { + for (const [nowName, nowTs] of + Object.entries(timestamps)) { + for (const [dateName, date] of + Object.entries(dates)) { + test(`cache = ${cacheName}, now = ${nowName}, date = ${dateName}`, () => { + const oldSettings = Settings.now; + try { + Settings.now = () => cacheTs; + Settings.resetCaches(); + // load cache + DateTime.local(2020, 1, 1, 0, zoneObj); + + Settings.now = () => nowTs; + const dt = DateTime.local(...date, zoneObj); + expect(dt.toMillis()).toBe((timestamps as any)[dateName]); + expect(dt.year).toBe(date[0]); + expect(dt.month).toBe(date[1]); + expect(dt.day).toBe(date[2]); + expect(dt.hour).toBe(date[3]); + expect(dt.minute).toBe(date[4]); + expect(dt.second).toBe(date[5]); + } + finally { + Settings.now = oldSettings; + } + }); + } } - ).endOf("day"); - expect(d.day).toBe(15); - expect(d.hour).toBe(23); - expect(d.minute).toBe(59); - expect(d.second).toBe(59); + } }); \ No newline at end of file diff --git a/test/datetime/proto.test.ts b/test/datetime/proto.test.ts index 56418c8..3db248d 100644 --- a/test/datetime/proto.test.ts +++ b/test/datetime/proto.test.ts @@ -3,9 +3,8 @@ import { DateTime } from "../../src"; test("DateTime prototype properties should not throw when accessed", () => { - const d = DateTime.now(); - expect(() => - // @ts-ignore - Object.getOwnPropertyNames(d.__proto__).forEach(name => d.__proto__[name]) - ).not.toThrow(); + const d = DateTime.now(); + expect(() => + Object.getOwnPropertyNames(Object.getPrototypeOf(d)).forEach((name) => Object.getPrototypeOf(d)[name]) + ).not.toThrow(); }); diff --git a/test/datetime/regexParse.test.ts b/test/datetime/regexParse.test.ts index 07cc0fa..f670204 100644 --- a/test/datetime/regexParse.test.ts +++ b/test/datetime/regexParse.test.ts @@ -1,437 +1,438 @@ import { DateTime, GregorianDateTime } from "../../src"; +import { Helpers } from "../helpers"; // ------ // .fromISO // ------- test("DateTime.fromISO() parses as local by default", () => { - const dt = DateTime.fromISO("2016-05-25T09:08:34.123"); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 8, - second: 34, - millisecond: 123 - }); + const dt = DateTime.fromISO("2016-05-25T09:08:34.123"); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 8, + second: 34, + millisecond: 123 + }); }); test("DateTime.fromISO() uses the offset provided, but keeps the dateTime as local", () => { - const dt = DateTime.fromISO("2016-05-25T09:08:34.123+06:00"); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 5, - day: 25, - hour: 3, - minute: 8, - second: 34, - millisecond: 123 - }); + const dt = DateTime.fromISO("2016-05-25T09:08:34.123+06:00"); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 5, + day: 25, + hour: 3, + minute: 8, + second: 34, + millisecond: 123 + }); }); test("DateTime.fromISO() uses the Z if provided, but keeps the dateTime as local", () => { - const dt = DateTime.fromISO("2016-05-25T09:08:34.123Z"); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 8, - second: 34, - millisecond: 123 - }); + const dt = DateTime.fromISO("2016-05-25T09:08:34.123Z"); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 8, + second: 34, + millisecond: 123 + }); }); test("DateTime.fromISO() optionally adopts the UTC offset provided", () => { - let dt = DateTime.fromISO("2016-05-25T09:08:34.123+06:00", { setZone: true }); - expect(dt.zone.name).toBe("UTC+6"); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 8, - second: 34, - millisecond: 123 - }); - - dt = DateTime.fromISO("1983-10-14T13:30Z", { setZone: true }); - expect(dt.zone.name).toBe("UTC"); - expect(dt.offset).toBe(0); - expect(dt.toObject()).toEqual({ - year: 1983, - month: 10, - day: 14, - hour: 13, - minute: 30, - second: 0, - millisecond: 0 - }); - - // #580 - dt = DateTime.fromISO("2016-05-25T09:08:34.123-00:30", { setZone: true }); - expect(dt.zone.name).toBe("UTC-0:30"); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 8, - second: 34, - millisecond: 123 - }); + let dt = DateTime.fromISO("2016-05-25T09:08:34.123+06:00", { setZone: true }); + expect(dt.zone.name).toBe("UTC+6"); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 8, + second: 34, + millisecond: 123 + }); + + dt = DateTime.fromISO("1983-10-14T13:30Z", { setZone: true }); + expect(dt.zone.name).toBe("UTC"); + expect(dt.offset).toBe(0); + expect(dt.toObject()).toEqual({ + year: 1983, + month: 10, + day: 14, + hour: 13, + minute: 30, + second: 0, + millisecond: 0 + }); + + // #580 + dt = DateTime.fromISO("2016-05-25T09:08:34.123-00:30", { setZone: true }); + expect(dt.zone.name).toBe("UTC-0:30"); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 8, + second: 34, + millisecond: 123 + }); }); test("DateTime.fromISO() can optionally specify a zone", () => { - let dt = DateTime.fromISO("2016-05-25T09:08:34.123", { zone: "utc" }); - expect(dt.offset).toEqual(0); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 8, - second: 34, - millisecond: 123 - }); - - dt = DateTime.fromISO("2016-05-25T09:08:34.123+06:00", { zone: "utc" }); - expect(dt.offset).toEqual(0); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 25, - hour: 3, - minute: 8, - second: 34, - millisecond: 123 - }); + let dt = DateTime.fromISO("2016-05-25T09:08:34.123", { zone: "utc" }); + expect(dt.offset).toEqual(0); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 8, + second: 34, + millisecond: 123 + }); + + dt = DateTime.fromISO("2016-05-25T09:08:34.123+06:00", { zone: "utc" }); + expect(dt.offset).toEqual(0); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 25, + hour: 3, + minute: 8, + second: 34, + millisecond: 123 + }); }); const isSame = (s: string, expected: GregorianDateTime) => - expect(DateTime.fromISO(s).toObject()).toEqual(expected); + expect(DateTime.fromISO(s).toObject()).toEqual(expected); test("DateTime.fromISO() accepts just the year", () => { - isSame("2016", { - year: 2016, - month: 1, - day: 1, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("2016", { + year: 2016, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts year-month", () => { - isSame("2016-05", { - year: 2016, - month: 5, - day: 1, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("2016-05", { + year: 2016, + month: 5, + day: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts yearmonth", () => { - isSame("201605", { - year: 2016, - month: 5, - day: 1, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("201605", { + year: 2016, + month: 5, + day: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts year-month-day", () => { - isSame("2016-05-25", { - year: 2016, - month: 5, - day: 25, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("2016-05-25", { + year: 2016, + month: 5, + day: 25, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts yearmonthday", () => { - isSame("20160525", { - year: 2016, - month: 5, - day: 25, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("20160525", { + year: 2016, + month: 5, + day: 25, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts extend years", () => { - isSame("+002016-05-25", { - year: 2016, - month: 5, - day: 25, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); - - isSame("-002016-05-25", { - year: -2016, - month: 5, - day: 25, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("+002016-05-25", { + year: 2016, + month: 5, + day: 25, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); + + isSame("-002016-05-25", { + year: -2016, + month: 5, + day: 25, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts year-month-dayThour", () => { - isSame("2016-05-25T09", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("2016-05-25T09", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts year-month-dayThour:minute", () => { - isSame("2016-05-25T09:24", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 0, - millisecond: 0 - }); - - isSame("2016-05-25T0924", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 0, - millisecond: 0 - }); + isSame("2016-05-25T09:24", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 0, + millisecond: 0 + }); + + isSame("2016-05-25T0924", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts year-month-dayThour:minute:second", () => { - isSame("2016-05-25T09:24:15", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 0 - }); - - isSame("2016-05-25T092415", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 0 - }); + isSame("2016-05-25T09:24:15", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 0 + }); + + isSame("2016-05-25T092415", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts year-month-dayThour:minute:second.millisecond", () => { - isSame("2016-05-25T09:24:15.123", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); - - isSame("2016-05-25T092415.123", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); - - isSame("2016-05-25T09:24:15,123", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); - - isSame("2016-05-25T09:24:15.1239999", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); - - isSame("2016-05-25T09:24:15.023", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 23 - }); - - // we round down always - isSame("2016-05-25T09:24:15.3456", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 345 - }); - - isSame("2016-05-25T09:24:15.999999", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 999 - }); - - // Support up to 20 digits - isSame("2016-05-25T09:24:15.12345678901234567890123456789", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); - - isSame("2016-05-25T09:24:15.1", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 100 - }); + isSame("2016-05-25T09:24:15.123", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); + + isSame("2016-05-25T092415.123", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); + + isSame("2016-05-25T09:24:15,123", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); + + isSame("2016-05-25T09:24:15.1239999", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); + + isSame("2016-05-25T09:24:15.023", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 23 + }); + + // we round down always + isSame("2016-05-25T09:24:15.3456", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 345 + }); + + isSame("2016-05-25T09:24:15.999999", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 999 + }); + + // Support up to 20 digits + isSame("2016-05-25T09:24:15.12345678901234567890123456789", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); + + isSame("2016-05-25T09:24:15.1", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 100 + }); }); test("DateTime.fromISO() accepts year-week", () => { - isSame("2016-W21", { - year: 2016, - month: 5, - day: 23, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("2016-W21", { + year: 2016, + month: 5, + day: 23, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts year-week-day", () => { - isSame("2016-W21-3", { - year: 2016, - month: 5, - day: 25, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); - - isSame("2016W213", { - year: 2016, - month: 5, - day: 25, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("2016-W21-3", { + year: 2016, + month: 5, + day: 25, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); + + isSame("2016W213", { + year: 2016, + month: 5, + day: 25, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts year-week-dayTtime", () => { - isSame("2016-W21-3T09:24:15.123", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); - - isSame("2016W213T09:24:15.123", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); + isSame("2016-W21-3T09:24:15.123", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); + + isSame("2016W213T09:24:15.123", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); }); test("DateTime.fromISO() accepts year-ordinal", () => { - isSame("2016-200", { - year: 2016, - month: 7, - day: 18, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); - - isSame("2016200", { - year: 2016, - month: 7, - day: 18, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("2016-200", { + year: 2016, + month: 7, + day: 18, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); + + isSame("2016200", { + year: 2016, + month: 7, + day: 18, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts year-ordinalTtime", () => { - isSame("2016-200T09:24:15.123", { - year: 2016, - month: 7, - day: 18, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); + isSame("2016-200T09:24:15.123", { + year: 2016, + month: 7, + day: 18, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); }); test("DateTime.fromISO() accepts year-ordinalTtime+offset", () => { @@ -449,89 +450,89 @@ test("DateTime.fromISO() accepts year-ordinalTtime+offset", () => { }); test("DateTime.fromISO() accepts hour:minute:second.millisecond", () => { - const { year, month, day } = DateTime.now(); - isSame("09:24:15.123", { - year, - month, - day, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); + const { year, month, day } = DateTime.now(); + isSame("09:24:15.123", { + year, + month, + day, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); }); test("DateTime.fromISO() accepts hour:minute:second,millisecond", () => { - const { year, month, day } = DateTime.now(); - isSame("09:24:15,123", { - year, - month, - day, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); + const { year, month, day } = DateTime.now(); + isSame("09:24:15,123", { + year, + month, + day, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); }); test("DateTime.fromISO() accepts hour:minute:second", () => { - const { year, month, day } = DateTime.now(); - isSame("09:24:15", { - year, - month, - day, - hour: 9, - minute: 24, - second: 15, - millisecond: 0 - }); + const { year, month, day } = DateTime.now(); + isSame("09:24:15", { + year, + month, + day, + hour: 9, + minute: 24, + second: 15, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts hour:minute", () => { - const { year, month, day } = DateTime.now(); - isSame("09:24", { - year, - month, - day, - hour: 9, - minute: 24, - second: 0, - millisecond: 0 - }); + const { year, month, day } = DateTime.now(); + isSame("09:24", { + year, + month, + day, + hour: 9, + minute: 24, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts hour:minute", () => { - const { year, month, day } = DateTime.now(); - isSame("09:24", { - year, - month, - day, - hour: 9, - minute: 24, - second: 0, - millisecond: 0 - }); + const { year, month, day } = DateTime.now(); + isSame("09:24", { + year, + month, + day, + hour: 9, + minute: 24, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() accepts 24:00", () => { - isSame("2018-01-04T24:00", { - year: 2018, - month: 1, - day: 5, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + isSame("2018-01-04T24:00", { + year: 2018, + month: 1, + day: 5, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() doesn't accept 24:23", () => { - expect(DateTime.fromISO("2018-05-25T24:23").isValid).toBe(false); + expect(DateTime.fromISO("2018-05-25T24:23").isValid).toBe(false); }); test("DateTime.fromISO() accepts extended zones", () => { let dt = DateTime.fromISO("2016-05-14T10:23:54[Europe/Paris]", { - setZone: true, + setZone: true }); expect(dt.isValid).toBe(true); expect(dt.zoneName).toBe("Europe/Paris"); @@ -542,7 +543,7 @@ test("DateTime.fromISO() accepts extended zones", () => { hour: 10, minute: 23, second: 54, - millisecond: 0, + millisecond: 0 }); dt = DateTime.fromISO("2016-05-14T10:23:54[UTC]", { setZone: true }); @@ -556,7 +557,7 @@ test("DateTime.fromISO() accepts extended zones", () => { hour: 10, minute: 23, second: 54, - millisecond: 0, + millisecond: 0 }); dt = DateTime.fromISO("2016-05-14T10:23:54[Etc/UTC]", { setZone: true }); @@ -570,13 +571,13 @@ test("DateTime.fromISO() accepts extended zones", () => { hour: 10, minute: 23, second: 54, - millisecond: 0, + millisecond: 0 }); }); test("DateTime.fromISO() accepts extended zones and offsets", () => { let dt = DateTime.fromISO("2016-05-14T10:23:54+01:00[Europe/Paris]", { - setZone: true, + setZone: true }); expect(dt.isValid).toBe(true); expect(dt.zoneName).toBe("Europe/Paris"); @@ -587,7 +588,7 @@ test("DateTime.fromISO() accepts extended zones and offsets", () => { hour: 10, minute: 23, second: 54, - millisecond: 0, + millisecond: 0 }); dt = DateTime.fromISO("2016-05-14T10:23:54+00:00[Etc/UTC]", { setZone: true }); @@ -601,14 +602,14 @@ test("DateTime.fromISO() accepts extended zones and offsets", () => { hour: 10, minute: 23, second: 54, - millisecond: 0, + millisecond: 0 }); }); test("DateTime.fromISO() accepts extended zones on bare times", () => { const { year, month, day } = DateTime.now().setZone("Europe/Paris"); const dt = DateTime.fromISO("10:23:54[Europe/Paris]", { - setZone: true, + setZone: true }); expect(dt.isValid).toBe(true); expect(dt.zoneName).toBe("Europe/Paris"); @@ -619,60 +620,82 @@ test("DateTime.fromISO() accepts extended zones on bare times", () => { hour: 10, minute: 23, second: 54, - millisecond: 0, + millisecond: 0 }); }); +Helpers.withNow( + "DateTime.fromISO() accepts extended zones on bare times when UTC and zone are in different days", + DateTime.fromISO("2023-11-20T23:30:00.000Z"), + () => { + const { year, month, day } = DateTime.now().setZone("Europe/Paris"); + let dt = DateTime.fromISO("10:23:54[Europe/Paris]", { + setZone: true + }); + expect(dt.isValid).toBe(true); + expect(dt.zoneName).toBe("Europe/Paris"); + expect(dt.toObject()).toEqual({ + year, + month, + day, + hour: 10, + minute: 23, + second: 54, + millisecond: 0 + }); + } +); + test("DateTime.fromISO() accepts some technically incorrect stuff", () => { - // these are formats that aren't technically valid but we parse anyway. - // Testing them more to document them than anything else - isSame("2016-05-25T0924:15.123", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); - - isSame("2016-05-25T09:2415.123", { - year: 2016, - month: 5, - day: 25, - hour: 9, - minute: 24, - second: 15, - millisecond: 123 - }); - - isSame("2016-W213", { - year: 2016, - month: 5, - day: 25, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + // these are formats that aren't technically valid but we parse anyway. + // Testing them more to document them than anything else + isSame("2016-05-25T0924:15.123", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); + + isSame("2016-05-25T09:2415.123", { + year: 2016, + month: 5, + day: 25, + hour: 9, + minute: 24, + second: 15, + millisecond: 123 + }); + + isSame("2016-W213", { + year: 2016, + month: 5, + day: 25, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromISO() rejects poop", () => { - const rejects = (s: string) => expect(DateTime.fromISO(s).isValid).toBeFalsy(); - rejects(null); - rejects(""); - rejects(" "); - rejects("2016-1"); - rejects("2016-1-15"); - rejects("2016-01-5"); - rejects("2016-05-25 08:34:34"); - rejects("2016-05-25Q08:34:34"); - rejects("2016-05-25T8:04:34"); - rejects("2016-05-25T08:4:34"); - rejects("2016-05-25T08:04:4"); - rejects("2016-05-25T:03:4"); - rejects("2016-05-25T08::4"); - rejects("2016-W32-02"); + const rejects = (s: string) => expect(DateTime.fromISO(s).isValid).toBeFalsy(); + rejects(null); + rejects(""); + rejects(" "); + rejects("2016-1"); + rejects("2016-1-15"); + rejects("2016-01-5"); + rejects("2016-05-25 08:34:34"); + rejects("2016-05-25Q08:34:34"); + rejects("2016-05-25T8:04:34"); + rejects("2016-05-25T08:4:34"); + rejects("2016-05-25T08:04:4"); + rejects("2016-05-25T:03:4"); + rejects("2016-05-25T08::4"); + rejects("2016-W32-02"); }); // ------ @@ -680,98 +703,98 @@ test("DateTime.fromISO() rejects poop", () => { // ------- test("DateTime.fromRFC2822() accepts full format", () => { - const dt = DateTime.fromRFC2822("Tue, 01 Nov 2016 13:23:12 +0630"); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 11, - day: 1, - hour: 6, - minute: 53, - second: 12, - millisecond: 0 - }); + const dt = DateTime.fromRFC2822("Tue, 01 Nov 2016 13:23:12 +0630"); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 11, + day: 1, + hour: 6, + minute: 53, + second: 12, + millisecond: 0 + }); }); test("DateTime.fromRFC2822 parses a range of dates", () => { - const testCases: [string, number[]][] = [ - ["Sun, 12 Apr 2015 05:06:07 GMT", [2015, 4, 12, 5, 6, 7]], - ["Tue, 01 Nov 2016 01:23:45 +0000", [2016, 11, 1, 1, 23, 45]], - ["Tue, 01 Nov 16 04:23:45 Z", [2016, 11, 1, 4, 23, 45]], - ["01 Nov 2016 05:23:45 z", [2016, 11, 1, 5, 23, 45]], - ["Mon, 02 Jan 2017 06:00:00 -0800", [2017, 1, 2, 6 + 8, 0, 0]], - ["Mon, 02 Jan 2017 06:00:00 +0800", [2017, 1, 1, 22, 0, 0]], - ["Mon, 02 Jan 2017 06:00:00 +0330", [2017, 1, 2, 2, 30, 0]], - ["Mon, 02 Jan 2017 06:00:00 -0330", [2017, 1, 2, 9, 30, 0]], - ["Mon, 02 Jan 2017 06:00:00 PST", [2017, 1, 2, 6 + 8, 0, 0]], - ["Mon, 02 Jan 2017 06:00:00 PDT", [2017, 1, 2, 6 + 7, 0, 0]] - ]; - - testCases.forEach(([testString, expected]) => { - const r = DateTime.fromRFC2822(testString).toUTC(), - actual = [r.year, r.month, r.day, r.hour, r.minute, r.second]; - expect(expected).toEqual(actual); - }); + const testCases: [string, number[]][] = [ + ["Sun, 12 Apr 2015 05:06:07 GMT", [2015, 4, 12, 5, 6, 7]], + ["Tue, 01 Nov 2016 01:23:45 +0000", [2016, 11, 1, 1, 23, 45]], + ["Tue, 01 Nov 16 04:23:45 Z", [2016, 11, 1, 4, 23, 45]], + ["01 Nov 2016 05:23:45 z", [2016, 11, 1, 5, 23, 45]], + ["Mon, 02 Jan 2017 06:00:00 -0800", [2017, 1, 2, 6 + 8, 0, 0]], + ["Mon, 02 Jan 2017 06:00:00 +0800", [2017, 1, 1, 22, 0, 0]], + ["Mon, 02 Jan 2017 06:00:00 +0330", [2017, 1, 2, 2, 30, 0]], + ["Mon, 02 Jan 2017 06:00:00 -0330", [2017, 1, 2, 9, 30, 0]], + ["Mon, 02 Jan 2017 06:00:00 PST", [2017, 1, 2, 6 + 8, 0, 0]], + ["Mon, 02 Jan 2017 06:00:00 PDT", [2017, 1, 2, 6 + 7, 0, 0]] + ]; + + testCases.forEach(([testString, expected]) => { + const r = DateTime.fromRFC2822(testString).toUTC(), + actual = [r.year, r.month, r.day, r.hour, r.minute, r.second]; + expect(expected).toEqual(actual); + }); }); test("DateTime.fromRFC2822() rejects incorrect days of the week", () => { - const dt = DateTime.fromRFC2822("Wed, 01 Nov 2016 13:23:12 +0600"); - expect(dt.isValid).toBe(false); + const dt = DateTime.fromRFC2822("Wed, 01 Nov 2016 13:23:12 +0600"); + expect(dt.isValid).toBe(false); }); test("DateTime.fromRFC2822() can elide the day of the week", () => { - const dt = DateTime.fromRFC2822("01 Nov 2016 13:23:12 +0600"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 11, - day: 1, - hour: 7, - minute: 23, - second: 12, - millisecond: 0 - }); + const dt = DateTime.fromRFC2822("01 Nov 2016 13:23:12 +0600"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 11, + day: 1, + hour: 7, + minute: 23, + second: 12, + millisecond: 0 + }); }); test("DateTime.fromRFC2822() can elide seconds", () => { - const dt = DateTime.fromRFC2822("01 Nov 2016 13:23 +0600"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 11, - day: 1, - hour: 7, - minute: 23, - second: 0, - millisecond: 0 - }); + const dt = DateTime.fromRFC2822("01 Nov 2016 13:23 +0600"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 11, + day: 1, + hour: 7, + minute: 23, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromRFC2822() can use Z", () => { - const dt = DateTime.fromRFC2822("01 Nov 2016 13:23:12 Z"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 11, - day: 1, - hour: 13, - minute: 23, - second: 12, - millisecond: 0 - }); + const dt = DateTime.fromRFC2822("01 Nov 2016 13:23:12 Z"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 11, + day: 1, + hour: 13, + minute: 23, + second: 12, + millisecond: 0 + }); }); test("DateTime.fromRFC2822() can use a weird subset of offset abbreviations", () => { - const dt = DateTime.fromRFC2822("01 Nov 2016 13:23:12 EST"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 11, - day: 1, - hour: 18, - minute: 23, - second: 12, - millisecond: 0 - }); + const dt = DateTime.fromRFC2822("01 Nov 2016 13:23:12 EST"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 11, + day: 1, + hour: 18, + minute: 23, + second: 12, + millisecond: 0 + }); }); // ------ @@ -779,59 +802,59 @@ test("DateTime.fromRFC2822() can use a weird subset of offset abbreviations", () // ------- test("DateTime.fromHTTP() can parse RFC 1123", () => { - const dt = DateTime.fromHTTP("Sun, 06 Nov 1994 08:49:37 GMT"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 1994, - month: 11, - day: 6, - hour: 8, - minute: 49, - second: 37, - millisecond: 0 - }); + const dt = DateTime.fromHTTP("Sun, 06 Nov 1994 08:49:37 GMT"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 1994, + month: 11, + day: 6, + hour: 8, + minute: 49, + second: 37, + millisecond: 0 + }); }); test("DateTime.fromHTTP() can parse RFC 850", () => { - const dt = DateTime.fromHTTP("Sunday, 06-Nov-94 08:49:37 GMT"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 1994, - month: 11, - day: 6, - hour: 8, - minute: 49, - second: 37, - millisecond: 0 - }); + const dt = DateTime.fromHTTP("Sunday, 06-Nov-94 08:49:37 GMT"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 1994, + month: 11, + day: 6, + hour: 8, + minute: 49, + second: 37, + millisecond: 0 + }); }); test("DateTime.fromHTTP() can parse ASCII dates with one date digit", () => { - const dt = DateTime.fromHTTP("Sun Nov 6 08:49:37 1994"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 1994, - month: 11, - day: 6, - hour: 8, - minute: 49, - second: 37, - millisecond: 0 - }); + const dt = DateTime.fromHTTP("Sun Nov 6 08:49:37 1994"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 1994, + month: 11, + day: 6, + hour: 8, + minute: 49, + second: 37, + millisecond: 0 + }); }); test("DateTime.fromHTTP() can parse ASCII dates with two date digits", () => { - const dt = DateTime.fromHTTP("Wed Nov 16 08:49:37 1994"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 1994, - month: 11, - day: 16, - hour: 8, - minute: 49, - second: 37, - millisecond: 0 - }); + const dt = DateTime.fromHTTP("Wed Nov 16 08:49:37 1994"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 1994, + month: 11, + day: 16, + hour: 8, + minute: 49, + second: 37, + millisecond: 0 + }); }); // ------ @@ -839,185 +862,185 @@ test("DateTime.fromHTTP() can parse ASCII dates with two date digits", () => { // ------- test("DateTime.fromSQL() can parse SQL dates", () => { - const dt = DateTime.fromSQL("2016-05-14"); - expect(dt.isValid).toBe(true); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 0, - minute: 0, - second: 0, - millisecond: 0 - }); + const dt = DateTime.fromSQL("2016-05-14"); + expect(dt.isValid).toBe(true); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 0, + minute: 0, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromSQL() can parse SQL times", () => { - const dt = DateTime.fromSQL("04:12:00.123"); - expect(dt.isValid).toBe(true); - const now = new Date(); - expect(dt.toObject()).toEqual({ - year: now.getFullYear(), - month: now.getMonth() + 1, - day: now.getDate(), - hour: 4, - minute: 12, - second: 0, - millisecond: 123 - }); + const dt = DateTime.fromSQL("04:12:00.123"); + expect(dt.isValid).toBe(true); + const now = new Date(); + expect(dt.toObject()).toEqual({ + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + hour: 4, + minute: 12, + second: 0, + millisecond: 123 + }); }); test("DateTime.fromSQL() handles times without fractional seconds", () => { - const dt = DateTime.fromSQL("04:12:00"); - expect(dt.isValid).toBe(true); - const now = new Date(); - expect(dt.toObject()).toEqual({ - year: now.getFullYear(), - month: now.getMonth() + 1, - day: now.getDate(), - hour: 4, - minute: 12, - second: 0, - millisecond: 0 - }); + const dt = DateTime.fromSQL("04:12:00"); + expect(dt.isValid).toBe(true); + const now = new Date(); + expect(dt.toObject()).toEqual({ + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + hour: 4, + minute: 12, + second: 0, + millisecond: 0 + }); }); test("DateTime.fromSQL() can parse SQL datetimes with sub-millisecond precision", () => { - let dt = DateTime.fromSQL("2016-05-14 10:23:54.2346"); - expect(dt.isValid).toBe(true); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 10, - minute: 23, - second: 54, - millisecond: 234 - }); - - dt = DateTime.fromSQL("2016-05-14 10:23:54.2341"); - expect(dt.isValid).toBe(true); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 10, - minute: 23, - second: 54, - millisecond: 234 - }); + let dt = DateTime.fromSQL("2016-05-14 10:23:54.2346"); + expect(dt.isValid).toBe(true); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 10, + minute: 23, + second: 54, + millisecond: 234 + }); + + dt = DateTime.fromSQL("2016-05-14 10:23:54.2341"); + expect(dt.isValid).toBe(true); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 10, + minute: 23, + second: 54, + millisecond: 234 + }); }); test("DateTime.fromSQL() handles deciseconds in SQL datetimes", () => { - const dt = DateTime.fromSQL("2016-05-14 10:23:54.1"); - expect(dt.isValid).toBe(true); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 10, - minute: 23, - second: 54, - millisecond: 100 - }); + const dt = DateTime.fromSQL("2016-05-14 10:23:54.1"); + expect(dt.isValid).toBe(true); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 10, + minute: 23, + second: 54, + millisecond: 100 + }); }); test("DateTime.fromSQL() handles datetimes without fractional seconds", () => { - const dt = DateTime.fromSQL("2016-05-14 10:23:54"); - expect(dt.isValid).toBe(true); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 10, - minute: 23, - second: 54, - millisecond: 0 - }); + const dt = DateTime.fromSQL("2016-05-14 10:23:54"); + expect(dt.isValid).toBe(true); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 10, + minute: 23, + second: 54, + millisecond: 0 + }); }); test("DateTime.fromSQL() accepts a zone to default to", () => { - const dt = DateTime.fromSQL("2016-05-14 10:23:54.023", { zone: "utc" }); - expect(dt.isValid).toBe(true); - expect(dt.offset).toBe(0); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 10, - minute: 23, - second: 54, - millisecond: 23 - }); + const dt = DateTime.fromSQL("2016-05-14 10:23:54.023", { zone: "utc" }); + expect(dt.isValid).toBe(true); + expect(dt.offset).toBe(0); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 10, + minute: 23, + second: 54, + millisecond: 23 + }); }); test("DateTime.fromSQL() can parse an optional offset", () => { - let dt = DateTime.fromSQL("2016-05-14 10:23:54.023 +06:00"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 4, - minute: 23, - second: 54, - millisecond: 23 - }); - - // no space before the zone - dt = DateTime.fromSQL("2016-05-14 10:23:54.023+06:00"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 4, - minute: 23, - second: 54, - millisecond: 23 - }); - - // no milliseconds - dt = DateTime.fromSQL("2016-05-14 10:23:54 +06:00"); - expect(dt.isValid).toBe(true); - expect(dt.toUTC().toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 4, - minute: 23, - second: 54, - millisecond: 0 - }); + let dt = DateTime.fromSQL("2016-05-14 10:23:54.023 +06:00"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 4, + minute: 23, + second: 54, + millisecond: 23 + }); + + // no space before the zone + dt = DateTime.fromSQL("2016-05-14 10:23:54.023+06:00"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 4, + minute: 23, + second: 54, + millisecond: 23 + }); + + // no milliseconds + dt = DateTime.fromSQL("2016-05-14 10:23:54 +06:00"); + expect(dt.isValid).toBe(true); + expect(dt.toUTC().toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 4, + minute: 23, + second: 54, + millisecond: 0 + }); }); test("DateTime.fromSQL() can parse an optional zone", () => { - let dt = DateTime.fromSQL("2016-05-14 10:23:54 Europe/Paris", { - setZone: true - }); - expect(dt.isValid).toBe(true); - expect(dt.zoneName).toBe("Europe/Paris"); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 10, - minute: 23, - second: 54, - millisecond: 0 - }); - - dt = DateTime.fromSQL("2016-05-14 10:23:54 UTC", { setZone: true }); - expect(dt.isValid).toBe(true); - expect(dt.zoneName).toBe("UTC"); - expect(dt.offset).toBe(0); - expect(dt.toObject()).toEqual({ - year: 2016, - month: 5, - day: 14, - hour: 10, - minute: 23, - second: 54, - millisecond: 0 - }); + let dt = DateTime.fromSQL("2016-05-14 10:23:54 Europe/Paris", { + setZone: true + }); + expect(dt.isValid).toBe(true); + expect(dt.zoneName).toBe("Europe/Paris"); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 10, + minute: 23, + second: 54, + millisecond: 0 + }); + + dt = DateTime.fromSQL("2016-05-14 10:23:54 UTC", { setZone: true }); + expect(dt.isValid).toBe(true); + expect(dt.zoneName).toBe("UTC"); + expect(dt.offset).toBe(0); + expect(dt.toObject()).toEqual({ + year: 2016, + month: 5, + day: 14, + hour: 10, + minute: 23, + second: 54, + millisecond: 0 + }); }); diff --git a/test/datetime/tokenParse.test.ts b/test/datetime/tokenParse.test.ts index f17c420..2263898 100644 --- a/test/datetime/tokenParse.test.ts +++ b/test/datetime/tokenParse.test.ts @@ -1097,4 +1097,30 @@ test("DateTime.expandFormat works with the default locale", () => { test("DateTime.expandFormat works with other locales", () => { const format = DateTime.expandFormat("D", { locale: "en-gb" }); expect(format).toBe("d/M/yyyyy"); +}); + + +// ------ +// .fromFormatParser +// ------- + +test("DateTime.fromFormatParser behaves equivalently to DateTime.fromFormat", () => { + const dateTimeStr = "1982/05/25 09:10:11.445"; + const format = "yyyy/MM/dd HH:mm:ss.SSS"; + const formatParser = DateTime.buildFormatParser(format); + const ff1 = DateTime.fromFormat(dateTimeStr, format), + ffP1 = DateTime.fromFormatParser(dateTimeStr, formatParser); + + expect(ffP1).toEqual(ff1); + expect(ffP1.isValid).toBe(true); +}); + +test("DateTime.fromFormatParser throws error when used with a different locale than it was created with", () => { + const format = "yyyy/MM/dd HH:mm:ss.SSS"; + const formatParser = DateTime.buildFormatParser(format, { locale: "es-ES" }); + expect(() => + DateTime.fromFormatParser("1982/05/25 09:10:11.445", formatParser, { locale: "es-MX" }) + ).toThrowError( + "fromFormatParser called with a locale of Locale(es-MX, undefined, undefined), but the format parser was created for Locale(es-ES, undefined, undefined)" + ); }); \ No newline at end of file diff --git a/test/interval/proto.test.ts b/test/interval/proto.test.ts index 796ccfc..5483348 100644 --- a/test/interval/proto.test.ts +++ b/test/interval/proto.test.ts @@ -1,7 +1,8 @@ import { DateTime } from "../../src"; test("Interval prototype properties should not throw when addressed", () => { - const i = DateTime.fromISO("2018-01-01").until(DateTime.fromISO("2018-01-02")); - const proto = Object.getPrototypeOf(i); - expect(() => Object.getOwnPropertyNames(proto).forEach(name => proto[name])).not.toThrow(); + const i = DateTime.fromISO("2018-01-01").until(DateTime.fromISO("2018-01-02")); + expect(() => + Object.getOwnPropertyNames(Object.getPrototypeOf(i)).forEach((name) => Object.getPrototypeOf(i)[name]) + ).not.toThrow(); });