From fbe58e3db33ea8c50e2224e5e6f68435d142de85 Mon Sep 17 00:00:00 2001 From: RShields Date: Thu, 10 Apr 2025 08:49:45 -0700 Subject: [PATCH 1/3] Refactor DateTimeFormatter for testing --- datetime/_date_time_formatter.ts | 1068 +++++++++++++------------ datetime/_date_time_formatter_test.ts | 401 ++++++---- 2 files changed, 796 insertions(+), 673 deletions(-) diff --git a/datetime/_date_time_formatter.ts b/datetime/_date_time_formatter.ts index 5df590b778a5..936e7498c1d5 100644 --- a/datetime/_date_time_formatter.ts +++ b/datetime/_date_time_formatter.ts @@ -42,97 +42,125 @@ const LITERAL_REGEXP = /^(?.+?\s*)/; const SYMBOL_REGEXP = /^(?([a-zA-Z])\2*)/; // according to unicode symbols (http://www.unicode.org/reports/tr35/tr35-dates.html#Date_Field_Symbol_Table) -function formatToFormatParts(format: string) { +function symbolToFormatPart(symbol: string): FormatPart { + switch (symbol) { + // case "GGGGG": + // return { type: "era", value: "narrow" }; + // case "GGGG": + // return { type: "era", value: "long" }; + // case "GGG": + // case "GG": + // case "G": + // return { type: "era", value: "short" }; + case "yyyy": + return { type: "year", value: "numeric" }; + case "yy": + return { type: "year", value: "2-digit" }; + // case "MMMMM": + // return { type: "month", value: "narrow" }; + // case "MMMM": + // return { type: "month", value: "long" }; + // case "MMM": + // return { type: "month", value: "short" }; + case "MM": + return { type: "month", value: "2-digit" }; + case "M": + return { type: "month", value: "numeric" }; + case "dd": + return { type: "day", value: "2-digit" }; + case "d": + return { type: "day", value: "numeric" }; + // case "EEEEEE": + // return { type: "weekday", value: "short" }; + // case "EEEEE": + // return { type: "weekday", value: "narrow" }; + // case "EEEE": + // return { type: "weekday", value: "long" }; + // case "EEE": + // case "EE": + // case "E": + // return { type: "weekday", value: "short" }; + // case "aaaaa": + // return { type: "dayPeriod", value: "narrow" }; + // case "aaaa": + // return { type: "dayPeriod", value: "long" }; + // case "aaa": + // case "aa": + case "a": + return { type: "dayPeriod", value: "short" }; + case "HH": + return { type: "hour", value: "2-digit" }; + case "H": + return { type: "hour", value: "numeric" }; + case "hh": + return { type: "hour", value: "2-digit", hour12: true }; + case "h": + return { type: "hour", value: "numeric", hour12: true }; + case "mm": + return { type: "minute", value: "2-digit" }; + case "m": + return { type: "minute", value: "numeric" }; + case "ss": + return { type: "second", value: "2-digit" }; + case "s": + return { type: "second", value: "numeric" }; + case "SSS": + return { type: "fractionalSecond", value: 3 }; + case "SS": + return { type: "fractionalSecond", value: 2 }; + case "S": + return { type: "fractionalSecond", value: 1 }; + // case "zzzz": + // return { type: "timeZoneName", value: "long" }; + // case "zzz": + // case "zz": + // case "z": + // return { type: "timeZoneName", value: "short" }; + // case "ZZZZ": + // case "OOOO": + // return { type: "timeZoneName", value: "longOffset" }; + // case "O": + // return { type: "timeZoneName", value: "shortOffset" }; + // case "vvvv": + // return { type: "timeZoneName", value: "longGeneric" }; + // case "v": + // return { type: "timeZoneName", value: "shortGeneric" }; + default: + throw new Error( + `ParserError: Cannot parse format symbol "${symbol}"`, + ); + } +} + +/** + * Parses a datetime format string to FormatParts + * + * @param formatString The string to parse the format from + * @returns The FormatParts of the string + */ +export function formatStringToFormatParts(formatString: string): FormatPart[] { const formatParts: FormatPart[] = []; let index = 0; - while (index < format.length) { - const substring = format.slice(index); - const symbol = SYMBOL_REGEXP.exec(substring)?.groups?.symbol; - switch (symbol) { - case "yyyy": - formatParts.push({ type: "year", value: "numeric" }); - index += symbol.length; - continue; - case "yy": - formatParts.push({ type: "year", value: "2-digit" }); - index += symbol.length; - continue; - case "MM": - formatParts.push({ type: "month", value: "2-digit" }); - index += symbol.length; - continue; - case "M": - formatParts.push({ type: "month", value: "numeric" }); - index += symbol.length; - continue; - case "dd": - formatParts.push({ type: "day", value: "2-digit" }); - index += symbol.length; - continue; - case "d": - formatParts.push({ type: "day", value: "numeric" }); - index += symbol.length; - continue; - case "HH": - formatParts.push({ type: "hour", value: "2-digit" }); - index += symbol.length; - continue; - case "H": - formatParts.push({ type: "hour", value: "numeric" }); - index += symbol.length; - continue; - case "hh": - formatParts.push({ type: "hour", value: "2-digit", hour12: true }); - index += symbol.length; - continue; - case "h": - formatParts.push({ type: "hour", value: "numeric", hour12: true }); - index += symbol.length; - continue; - case "mm": - formatParts.push({ type: "minute", value: "2-digit" }); - index += symbol.length; - continue; - case "m": - formatParts.push({ type: "minute", value: "numeric" }); - index += symbol.length; - continue; - case "ss": - formatParts.push({ type: "second", value: "2-digit" }); - index += symbol.length; - continue; - case "s": - formatParts.push({ type: "second", value: "numeric" }); - index += symbol.length; - continue; - case "SSS": - formatParts.push({ type: "fractionalSecond", value: 3 }); - index += symbol.length; - continue; - case "SS": - formatParts.push({ type: "fractionalSecond", value: 2 }); - index += symbol.length; - continue; - case "S": - formatParts.push({ type: "fractionalSecond", value: 1 }); - index += symbol.length; - continue; - case "a": - formatParts.push({ type: "dayPeriod", value: 1 }); - index += symbol.length; - continue; + while (index < formatString.length) { + const substring = formatString.slice(index); + const symbolMatch = SYMBOL_REGEXP.exec(substring); + if (symbolMatch) { + const symbol = symbolMatch.groups!.symbol!; + formatParts.push(symbolToFormatPart(symbol)); + index += symbol.length; + continue; } const quotedLiteralMatch = QUOTED_LITERAL_REGEXP.exec(substring); if (quotedLiteralMatch) { - const value = quotedLiteralMatch.groups!.value as string; + const value = quotedLiteralMatch.groups!.value!; formatParts.push({ type: "literal", value }); index += quotedLiteralMatch[0].length; continue; } - const literalGroups = LITERAL_REGEXP.exec(substring)!.groups!; - const value = literalGroups.value as string; + const literalMatch = LITERAL_REGEXP.exec(substring)!; + const value = literalMatch.groups!.value!; formatParts.push({ type: "literal", value }); index += value.length; } @@ -141,8 +169,9 @@ function formatToFormatParts(format: string) { } function sortDateTimeFormatParts( - parts: DateTimeFormatPart[], + parts: readonly DateTimeFormatPart[], ): DateTimeFormatPart[] { + const remainingParts = [...parts]; let result: DateTimeFormatPart[] = []; const typeArray = [ "year", @@ -154,490 +183,525 @@ function sortDateTimeFormatParts( "fractionalSecond", ]; for (const type of typeArray) { - const current = parts.findIndex((el) => el.type === type); + const current = remainingParts.findIndex((el) => el.type === type); if (current !== -1) { - result = result.concat(parts.splice(current, 1)); + result = result.concat(remainingParts.splice(current, 1)); } } - result = result.concat(parts); + result = result.concat(remainingParts); return result; } -export class DateTimeFormatter { - #formatParts: FormatPart[]; - - constructor(formatString: string) { - this.#formatParts = formatToFormatParts(formatString); - } - - format(date: Date, options: Options = {}): string { - let string = ""; - - const utc = options.timeZone === "UTC"; - - for (const part of this.#formatParts) { - const type = part.type; - - switch (type) { - case "year": { - const value = utc ? date.getUTCFullYear() : date.getFullYear(); - switch (part.value) { - case "numeric": { - string += value; - break; - } - case "2-digit": { - string += digits(value, 2).slice(-2); - break; - } - default: - throw new Error( - `FormatterError: value "${part.value}" is not supported`, - ); - } - break; - } - case "month": { - const value = (utc ? date.getUTCMonth() : date.getMonth()) + 1; - switch (part.value) { - case "numeric": { - string += value; - break; - } - case "2-digit": { - string += digits(value, 2); - break; - } - default: - throw new Error( - `FormatterError: value "${part.value}" is not supported`, - ); +/** + * Formats a date using FormatParts. + * + * @param date The date to format + * @param formatParts The parts to format the date to + * @param options Formatting options + * @returns The formatted date + */ +export function formatDate( + date: Date, + formatParts: FormatPart[], + options: Options = {}, +): string { + let string = ""; + + const utc = options.timeZone === "UTC"; + + for (const part of formatParts) { + const type = part.type; + + switch (type) { + case "year": { + const value = utc ? date.getUTCFullYear() : date.getFullYear(); + switch (part.value) { + case "numeric": { + string += value; + break; } - break; - } - case "day": { - const value = utc ? date.getUTCDate() : date.getDate(); - switch (part.value) { - case "numeric": { - string += value; - break; - } - case "2-digit": { - string += digits(value, 2); - break; - } - default: - throw new Error( - `FormatterError: value "${part.value}" is not supported`, - ); + case "2-digit": { + string += digits(value, 2).slice(-2); + break; } - break; + default: + throw new Error( + `FormatterError: value "${part.value}" is not supported`, + ); } - case "hour": { - let value = utc ? date.getUTCHours() : date.getHours(); - if (part.hour12) { - if (value === 0) value = 12; - else if (value > 12) value -= 12; + break; + } + case "month": { + const value = (utc ? date.getUTCMonth() : date.getMonth()) + 1; + switch (part.value) { + case "numeric": { + string += value; + break; } - switch (part.value) { - case "numeric": { - string += value; - break; - } - case "2-digit": { - string += digits(value, 2); - break; - } - default: - throw new Error( - `FormatterError: value "${part.value}" is not supported`, - ); + case "2-digit": { + string += digits(value, 2); + break; } - break; + default: + throw new Error( + `FormatterError: value "${part.value}" is not supported`, + ); } - case "minute": { - const value = utc ? date.getUTCMinutes() : date.getMinutes(); - switch (part.value) { - case "numeric": { - string += value; - break; - } - case "2-digit": { - string += digits(value, 2); - break; - } - default: - throw new Error( - `FormatterError: value "${part.value}" is not supported`, - ); + break; + } + case "day": { + const value = utc ? date.getUTCDate() : date.getDate(); + switch (part.value) { + case "numeric": { + string += value; + break; } - break; - } - case "second": { - const value = utc ? date.getUTCSeconds() : date.getSeconds(); - switch (part.value) { - case "numeric": { - string += value; - break; - } - case "2-digit": { - string += digits(value, 2); - break; - } - default: - throw new Error( - `FormatterError: value "${part.value}" is not supported`, - ); + case "2-digit": { + string += digits(value, 2); + break; } - break; + default: + throw new Error( + `FormatterError: value "${part.value}" is not supported`, + ); } - case "fractionalSecond": { - const value = utc - ? date.getUTCMilliseconds() - : date.getMilliseconds(); - string += digits(value, 3).slice(0, Number(part.value)); - break; + break; + } + case "hour": { + let value = utc ? date.getUTCHours() : date.getHours(); + if (part.hour12) { + if (value === 0) value = 12; + else if (value > 12) value -= 12; } - // FIXME(bartlomieju) - case "timeZoneName": { - // string += utc ? "Z" : part.value - break; + switch (part.value) { + case "numeric": { + string += value; + break; + } + case "2-digit": { + string += digits(value, 2); + break; + } + default: + throw new Error( + `FormatterError: value "${part.value}" is not supported`, + ); } - case "dayPeriod": { - string += date.getHours() >= 12 ? "PM" : "AM"; - break; + break; + } + case "minute": { + const value = utc ? date.getUTCMinutes() : date.getMinutes(); + switch (part.value) { + case "numeric": { + string += value; + break; + } + case "2-digit": { + string += digits(value, 2); + break; + } + default: + throw new Error( + `FormatterError: value "${part.value}" is not supported`, + ); } - case "literal": { - string += part.value; - break; + break; + } + case "second": { + const value = utc ? date.getUTCSeconds() : date.getSeconds(); + switch (part.value) { + case "numeric": { + string += value; + break; + } + case "2-digit": { + string += digits(value, 2); + break; + } + default: + throw new Error( + `FormatterError: value "${part.value}" is not supported`, + ); } - - default: - throw new Error(`FormatterError: { ${part.type} ${part.value} }`); + break; + } + case "fractionalSecond": { + const value = utc ? date.getUTCMilliseconds() : date.getMilliseconds(); + string += digits(value, 3).slice(0, Number(part.value)); + break; + } + // FIXME(bartlomieju) + case "timeZoneName": { + // string += utc ? "Z" : part.value + break; + } + case "dayPeriod": { + string += date.getHours() >= 12 ? "PM" : "AM"; + break; + } + case "literal": { + string += part.value; + break; } - } - return string; + default: + throw new Error(`FormatterError: { ${part.type} ${part.value} }`); + } } - formatToParts(string: string): DateTimeFormatPart[] { - const parts: DateTimeFormatPart[] = []; - - for (const part of this.#formatParts) { - const type = part.type; - let length = 0; - let value = ""; - switch (part.type) { - case "year": { - switch (part.value) { - case "numeric": { - value = /^\d{4}/.exec(string)?.[0] as string; - length = value?.length; - break; - } - case "2-digit": { - value = /^\d{2}/.exec(string)?.[0] as string; - length = value?.length; - break; - } - default: - throw new Error( - `ParserError: value "${part.value}" is not supported`, - ); + return string; +} + +/** + * Parses a format string and FormatParts to DateTimeFormatParts. + * + * @param dateString The date string to parse + * @param formatParts The format of `dateString` + * @returns The DateTimeFormatParts parsed + */ +export function dateStringToDateTimeFormatParts( + dateString: string, + formatParts: FormatPart[], +): DateTimeFormatPart[] { + const parts: DateTimeFormatPart[] = []; + + for (const part of formatParts) { + const type = part.type; + let length = 0; + let value = ""; + switch (part.type) { + case "year": { + switch (part.value) { + case "numeric": { + value = /^\d{4}/.exec(dateString)?.[0] as string; + length = value?.length; + break; } - break; - } - case "month": { - switch (part.value) { - case "numeric": { - value = /^\d{1,2}/.exec(string)?.[0] as string; - length = value?.length; - break; - } - case "2-digit": { - value = /^\d{2}/.exec(string)?.[0] as string; - length = value?.length; - break; - } - case "narrow": { - value = /^[a-zA-Z]+/.exec(string)?.[0] as string; - length = value?.length; - break; - } - case "short": { - value = /^[a-zA-Z]+/.exec(string)?.[0] as string; - length = value?.length; - break; - } - case "long": { - value = /^[a-zA-Z]+/.exec(string)?.[0] as string; - length = value?.length; - break; - } - default: - throw new Error( - `ParserError: value "${part.value}" is not supported`, - ); + case "2-digit": { + value = /^\d{2}/.exec(dateString)?.[0] as string; + length = value?.length; + break; } - break; + default: + throw new Error( + `ParserError: value "${part.value}" is not supported`, + ); } - case "day": { - switch (part.value) { - case "numeric": { - value = /^\d{1,2}/.exec(string)?.[0] as string; - length = value?.length; - break; - } - case "2-digit": { - value = /^\d{2}/.exec(string)?.[0] as string; - length = value?.length; - break; - } - default: - throw new Error( - `ParserError: value "${part.value}" is not supported`, - ); + break; + } + case "month": { + switch (part.value) { + case "numeric": { + value = /^\d{1,2}/.exec(dateString)?.[0] as string; + length = value?.length; + break; } - break; - } - case "hour": { - switch (part.value) { - case "numeric": { - value = /^\d{1,2}/.exec(string)?.[0] as string; - length = value?.length; - if (part.hour12 && parseInt(value) > 12) { - // TODO(iuioiua): Replace with throwing an error - // deno-lint-ignore no-console - console.error( - `Trying to parse hour greater than 12, use 'H' instead of 'h'.`, - ); - } - break; - } - case "2-digit": { - value = /^\d{2}/.exec(string)?.[0] as string; - length = value?.length; - if (part.hour12 && parseInt(value) > 12) { - // TODO(iuioiua): Replace with throwing an error - // deno-lint-ignore no-console - console.error( - `Trying to parse hour greater than 12, use 'HH' instead of 'hh'.`, - ); - } - break; - } - default: - // TODO(iuioiua): Correct error type and message - throw new Error( - `ParserError: value "${part.value}" is not supported`, - ); + case "2-digit": { + value = /^\d{2}/.exec(dateString)?.[0] as string; + length = value?.length; + break; + } + case "narrow": { + value = /^[a-zA-Z]+/.exec(dateString)?.[0] as string; + length = value?.length; + break; + } + case "short": { + value = /^[a-zA-Z]+/.exec(dateString)?.[0] as string; + length = value?.length; + break; } - break; + case "long": { + value = /^[a-zA-Z]+/.exec(dateString)?.[0] as string; + length = value?.length; + break; + } + default: + throw new Error( + `ParserError: value "${part.value}" is not supported`, + ); } - case "minute": { - switch (part.value) { - case "numeric": { - value = /^\d{1,2}/.exec(string)?.[0] as string; - length = value?.length; - break; - } - case "2-digit": { - value = /^\d{2}/.exec(string)?.[0] as string; - length = value?.length; - break; - } - default: - throw new Error( - `ParserError: value "${part.value}" is not supported`, - ); + break; + } + case "day": { + switch (part.value) { + case "numeric": { + value = /^\d{1,2}/.exec(dateString)?.[0] as string; + length = value?.length; + break; + } + case "2-digit": { + value = /^\d{2}/.exec(dateString)?.[0] as string; + length = value?.length; + break; } - break; + default: + throw new Error( + `ParserError: value "${part.value}" is not supported`, + ); } - case "second": { - switch (part.value) { - case "numeric": { - value = /^\d{1,2}/.exec(string)?.[0] as string; - length = value?.length; - break; - } - case "2-digit": { - value = /^\d{2}/.exec(string)?.[0] as string; - length = value?.length; - break; + break; + } + case "hour": { + switch (part.value) { + case "numeric": { + value = /^\d{1,2}/.exec(dateString)?.[0] as string; + length = value?.length; + if (part.hour12 && parseInt(value) > 12) { + // TODO(iuioiua): Replace with throwing an error + // deno-lint-ignore no-console + console.error( + `Trying to parse hour greater than 12, use 'H' instead of 'h'.`, + ); } - default: - throw new Error( - `ParserError: value "${part.value}" is not supported`, + break; + } + case "2-digit": { + value = /^\d{2}/.exec(dateString)?.[0] as string; + length = value?.length; + if (part.hour12 && parseInt(value) > 12) { + // TODO(iuioiua): Replace with throwing an error + // deno-lint-ignore no-console + console.error( + `Trying to parse hour greater than 12, use 'HH' instead of 'hh'.`, ); + } + break; } - break; - } - case "fractionalSecond": { - value = new RegExp(`^\\d{${part.value}}`).exec(string) - ?.[0] as string; - length = value?.length; - break; - } - case "timeZoneName": { - value = part.value as string; - length = value?.length; - break; + default: + // TODO(iuioiua): Correct error type and message + throw new Error( + `ParserError: value "${part.value}" is not supported`, + ); } - case "dayPeriod": { - value = /^[AP](?:\.M\.|M\.?)/i.exec(string)?.[0] as string; - switch (value.toUpperCase()) { - case "AM": - value = "AM"; - length = 2; - break; - case "AM.": - value = "AM"; - length = 3; - break; - case "A.M.": - value = "AM"; - length = 4; - break; - case "PM": - value = "PM"; - length = 2; - break; - case "PM.": - value = "PM"; - length = 3; - break; - case "P.M.": - value = "PM"; - length = 4; - break; - default: - throw new Error(`DayPeriod '${value}' is not supported.`); + break; + } + case "minute": { + switch (part.value) { + case "numeric": { + value = /^\d{1,2}/.exec(dateString)?.[0] as string; + length = value?.length; + break; } - break; - } - case "literal": { - if (!string.startsWith(part.value as string)) { + case "2-digit": { + value = /^\d{2}/.exec(dateString)?.[0] as string; + length = value?.length; + break; + } + default: throw new Error( - `Literal "${part.value}" not found "${string.slice(0, 25)}"`, + `ParserError: value "${part.value}" is not supported`, ); + } + break; + } + case "second": { + switch (part.value) { + case "numeric": { + value = /^\d{1,2}/.exec(dateString)?.[0] as string; + length = value?.length; + break; } - value = part.value as string; - length = value?.length; - break; + case "2-digit": { + value = /^\d{2}/.exec(dateString)?.[0] as string; + length = value?.length; + break; + } + default: + throw new Error( + `ParserError: value "${part.value}" is not supported`, + ); } - - default: + break; + } + case "fractionalSecond": { + value = new RegExp(`^\\d{${part.value}}`).exec(dateString) + ?.[0] as string; + length = value?.length; + break; + } + case "timeZoneName": { + value = part.value as string; + length = value?.length; + break; + } + case "dayPeriod": { + value = /^[AP](?:\.M\.|M\.?)/i.exec(dateString)?.[0] as string; + switch (value.toUpperCase()) { + case "AM": + value = "AM"; + length = 2; + break; + case "AM.": + value = "AM"; + length = 3; + break; + case "A.M.": + value = "AM"; + length = 4; + break; + case "PM": + value = "PM"; + length = 2; + break; + case "PM.": + value = "PM"; + length = 3; + break; + case "P.M.": + value = "PM"; + length = 4; + break; + default: + throw new Error(`DayPeriod '${value}' is not supported.`); + } + break; + } + case "literal": { + if (!dateString.startsWith(part.value as string)) { throw new Error( - `Cannot format the date, the value (${part.value}) of the type (${part.type}) is given`, + `Literal "${part.value}" not found "${dateString.slice(0, 25)}"`, ); + } + value = part.value as string; + length = value?.length; + break; } - if (!value) { + default: throw new Error( - `Cannot format value: The value is not valid for part { ${type} ${value} } ${ - string.slice( - 0, - 25, - ) - }`, + `Cannot format the date, the value (${part.value}) of the type (${part.type}) is given`, ); - } - parts.push({ type, value }); - - string = string.slice(length); } - if (string.length) { + if (!value) { throw new Error( - `datetime string was not fully parsed! ${string.slice(0, 25)}`, + `Cannot format value: The value is not valid for part { ${type} ${value} } ${ + dateString.slice( + 0, + 25, + ) + }`, ); } + parts.push({ type, value }); - return parts; + dateString = dateString.slice(length); } - partsToDate(parts: DateTimeFormatPart[]): Date { - parts = sortDateTimeFormatParts(parts); - - const date = new Date(); - const utc = parts.find( - (part) => part.type === "timeZoneName" && part.value === "UTC", + if (dateString.length) { + throw new Error( + `datetime string was not fully parsed! ${dateString.slice(0, 25)}`, ); + } - const dayPart = parts.find((part) => part.type === "day"); + return parts; +} - utc ? date.setUTCHours(0, 0, 0, 0) : date.setHours(0, 0, 0, 0); - for (const part of parts) { - switch (part.type) { - case "year": { - const value = Number(part.value.padStart(4, "20")); - utc ? date.setUTCFullYear(value) : date.setFullYear(value); - break; - } - case "month": { - const value = Number(part.value) - 1; - if (dayPart) { - utc - ? date.setUTCMonth(value, Number(dayPart.value)) - : date.setMonth(value, Number(dayPart.value)); - } else { - utc ? date.setUTCMonth(value) : date.setMonth(value); - } - break; - } - case "day": { - const value = Number(part.value); - utc ? date.setUTCDate(value) : date.setDate(value); - break; +/** + * Converts DateTimeFormatParts to a Date. + * + * @param parts The DateTimeFormatParts to convert + * @returns The Date represented by `parts` + */ +export function dateTimeFormatPartsToDate( + parts: readonly DateTimeFormatPart[], +): Date { + const sortedParts = sortDateTimeFormatParts(parts); + + const date = new Date(); + const utc = sortedParts.find( + (part) => part.type === "timeZoneName" && part.value === "UTC", + ); + + const dayPart = sortedParts.find((part) => part.type === "day"); + + utc ? date.setUTCHours(0, 0, 0, 0) : date.setHours(0, 0, 0, 0); + for (const part of sortedParts) { + switch (part.type) { + case "year": { + const value = Number(part.value.padStart(4, "20")); + utc ? date.setUTCFullYear(value) : date.setFullYear(value); + break; + } + case "month": { + const value = Number(part.value) - 1; + if (dayPart) { + utc + ? date.setUTCMonth(value, Number(dayPart.value)) + : date.setMonth(value, Number(dayPart.value)); + } else { + utc ? date.setUTCMonth(value) : date.setMonth(value); } - case "hour": { - let value = Number(part.value); - const dayPeriod = parts.find( - (part: DateTimeFormatPart) => part.type === "dayPeriod", - ); - if (dayPeriod) { - switch (dayPeriod.value.toUpperCase()) { - case "AM": - case "AM.": - case "A.M.": - // ignore - break; - case "PM": - case "PM.": - case "P.M.": - value += 12; - break; - default: - throw new Error( - `dayPeriod '${dayPeriod.value}' is not supported.`, - ); - } + break; + } + case "day": { + const value = Number(part.value); + utc ? date.setUTCDate(value) : date.setDate(value); + break; + } + case "hour": { + let value = Number(part.value); + const dayPeriod = sortedParts.find( + (part: DateTimeFormatPart) => part.type === "dayPeriod", + ); + if (dayPeriod) { + switch (dayPeriod.value.toUpperCase()) { + case "AM": + case "AM.": + case "A.M.": + // ignore + break; + case "PM": + case "PM.": + case "P.M.": + value += 12; + break; + default: + throw new Error( + `dayPeriod '${dayPeriod.value}' is not supported.`, + ); } - utc ? date.setUTCHours(value) : date.setHours(value); - break; - } - case "minute": { - const value = Number(part.value); - utc ? date.setUTCMinutes(value) : date.setMinutes(value); - break; - } - case "second": { - const value = Number(part.value); - utc ? date.setUTCSeconds(value) : date.setSeconds(value); - break; - } - case "fractionalSecond": { - const value = Number(part.value); - utc ? date.setUTCMilliseconds(value) : date.setMilliseconds(value); - break; } + utc ? date.setUTCHours(value) : date.setHours(value); + break; + } + case "minute": { + const value = Number(part.value); + utc ? date.setUTCMinutes(value) : date.setMinutes(value); + break; + } + case "second": { + const value = Number(part.value); + utc ? date.setUTCSeconds(value) : date.setSeconds(value); + break; + } + case "fractionalSecond": { + const value = Number(part.value); + utc ? date.setUTCMilliseconds(value) : date.setMilliseconds(value); + break; } } - return date; + } + return date; +} + +export class DateTimeFormatter { + #formatParts: FormatPart[]; + + constructor(formatString: string) { + this.#formatParts = formatStringToFormatParts(formatString); } - parse(string: string): Date { - const parts = this.formatToParts(string); - return this.partsToDate(parts); + format(date: Date, options: Options = {}) { + return formatDate(date, this.#formatParts, options); + } + + parse(dateString: string): Date { + const parts = dateStringToDateTimeFormatParts( + dateString, + this.#formatParts, + ); + return dateTimeFormatPartsToDate(parts); } } diff --git a/datetime/_date_time_formatter_test.ts b/datetime/_date_time_formatter_test.ts index 6d34788baf33..9d7c655cf8fd 100644 --- a/datetime/_date_time_formatter_test.ts +++ b/datetime/_date_time_formatter_test.ts @@ -1,7 +1,13 @@ // Copyright 2018-2025 the Deno authors. MIT license. import { assertEquals, assertThrows } from "@std/assert"; import { FakeTime } from "@std/testing/time"; -import { DateTimeFormatter } from "./_date_time_formatter.ts"; +import { + dateStringToDateTimeFormatParts, + dateTimeFormatPartsToDate, + DateTimeFormatter, + formatDate, + formatStringToFormatParts, +} from "./_date_time_formatter.ts"; Deno.test("dateTimeFormatter.format()", async (t) => { await t.step("handles basic cases", () => { @@ -60,6 +66,14 @@ Deno.test("dateTimeFormatter.format()", async (t) => { assertEquals(formatter.format(new Date(2020, 0, 22)), "22"); }); + await t.step("handles a", () => { + const formatter = new DateTimeFormatter("a"); + assertEquals(formatter.format(new Date(2020, 0, 1, 0, 0, 0)), "AM"); + assertEquals(formatter.format(new Date(2020, 0, 1, 1, 0, 0)), "AM"); + assertEquals(formatter.format(new Date(2020, 0, 1, 12, 0, 0)), "PM"); + assertEquals(formatter.format(new Date(2020, 0, 1, 22, 0, 0)), "PM"); + }); + await t.step("handles HH", () => { const formatter = new DateTimeFormatter("HH"); assertEquals(formatter.format(new Date(2020, 0, 1, 0, 0, 0)), "00"); @@ -155,11 +169,17 @@ Deno.test("dateTimeFormatter.parse()", () => { assertEquals(formatter.parse("2020-01-01"), new Date(2020, 0, 1)); }); -Deno.test("dateTimeFormatter.formatToParts()", async (t) => { +Deno.test("new DateTimeFormatter() errors on unknown or unsupported format", () => { + assertThrows(() => new DateTimeFormatter("yyyy-nn-dd")); + assertThrows(() => new DateTimeFormatter("G")); + assertThrows(() => new DateTimeFormatter("E")); + assertThrows(() => new DateTimeFormatter("z")); +}); + +Deno.test("dateStringToDateTimeFormatParts()", async (t) => { await t.step("handles basic", () => { - const format = "yyyy-MM-dd"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("2020-01-01"), [ + const formatParts = formatStringToFormatParts("yyyy-MM-dd"); + assertEquals(dateStringToDateTimeFormatParts("2020-01-01", formatParts), [ { type: "year", value: "2020" }, { type: "literal", value: "-" }, { type: "month", value: "01" }, @@ -168,9 +188,8 @@ Deno.test("dateTimeFormatter.formatToParts()", async (t) => { ]); }); await t.step("handles case without separators", () => { - const format = "yyyyMMdd"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("20200101"), [ + const formatParts = formatStringToFormatParts("yyyyMMdd"); + assertEquals(dateStringToDateTimeFormatParts("20200101", formatParts), [ { type: "year", value: "2020" }, { type: "month", value: "01" }, { type: "day", value: "01" }, @@ -178,253 +197,300 @@ Deno.test("dateTimeFormatter.formatToParts()", async (t) => { }); await t.step("throws on an empty string", () => { - const format = "yyyy-MM-dd"; - const formatter = new DateTimeFormatter(format); + const formatParts = formatStringToFormatParts("yyyy-MM-dd"); assertThrows( - () => formatter.formatToParts(""), + () => dateStringToDateTimeFormatParts("", formatParts), Error, "Cannot format value: The value is not valid for part { year undefined } ", ); }); + await t.step("throws on a string which does not match the format", () => { + const formatParts = formatStringToFormatParts("yyyy-MM-dd"); + assertThrows( + () => dateStringToDateTimeFormatParts("2020-Feb-01", formatParts), + Error, + "Cannot format value: The value is not valid for part { month undefined } Feb-01", + ); + }); await t.step("throws on a string which exceeds the format", () => { - const format = "yyyy-MM-dd"; - const formatter = new DateTimeFormatter(format); + const formatParts = formatStringToFormatParts("yyyy-MM-dd"); assertThrows( - () => formatter.formatToParts("2020-01-01T00:00:00.000Z"), + () => + dateStringToDateTimeFormatParts( + "2020-01-01T00:00:00.000Z", + formatParts, + ), Error, "datetime string was not fully parsed!", ); }); await t.step("throws on malformatted year", () => { - const format = "yyyy-MM-dd"; - const formatter = new DateTimeFormatter(format); + const formatParts = formatStringToFormatParts("yyyy-MM-dd"); assertThrows( - () => formatter.formatToParts("20-01-01"), + () => dateStringToDateTimeFormatParts("20-01-01", formatParts), Error, "Cannot format value: The value is not valid for part { year undefined } 20", ); }); await t.step("handles yy", () => { - const format = "yy"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("20"), [ + const formatParts = formatStringToFormatParts("yy"); + assertEquals(dateStringToDateTimeFormatParts("20", formatParts), [ { type: "year", value: "20" }, ]); - assertEquals(formatter.formatToParts("00"), [ + assertEquals(dateStringToDateTimeFormatParts("00", formatParts), [ { type: "year", value: "00" }, ]); - assertThrows(() => formatter.formatToParts("2")); - assertThrows(() => formatter.formatToParts("202")); - assertThrows(() => formatter.formatToParts("2020")); + assertThrows(() => dateStringToDateTimeFormatParts("2", formatParts)); + assertThrows(() => dateStringToDateTimeFormatParts("202", formatParts)); + assertThrows(() => dateStringToDateTimeFormatParts("2020", formatParts)); }); await t.step("handles yyyy", () => { - const format = "yyyy"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("2020"), [ + const formatParts = formatStringToFormatParts("yyyy"); + assertEquals(dateStringToDateTimeFormatParts("2020", formatParts), [ { type: "year", value: "2020" }, ]); - assertThrows(() => formatter.formatToParts("20")); - assertThrows(() => formatter.formatToParts("202")); - assertThrows(() => formatter.formatToParts("20202")); + assertThrows(() => dateStringToDateTimeFormatParts("20", formatParts)); + assertThrows(() => dateStringToDateTimeFormatParts("202", formatParts)); + assertThrows(() => dateStringToDateTimeFormatParts("20202", formatParts)); }); await t.step("handles M", () => { - const format = "M"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ + const formatParts = formatStringToFormatParts("M"); + assertEquals(dateStringToDateTimeFormatParts("10", formatParts), [ { type: "month", value: "10" }, ]); }); await t.step("handles MM", () => { - const format = "MM"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ + const formatParts = formatStringToFormatParts("MM"); + assertEquals(dateStringToDateTimeFormatParts("10", formatParts), [ { type: "month", value: "10" }, ]); }); await t.step("handles d", () => { - const format = "d"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ + const formatParts = formatStringToFormatParts("d"); + assertEquals(dateStringToDateTimeFormatParts("10", formatParts), [ { type: "day", value: "10" }, ]); }); await t.step("handles dd", () => { - const format = "dd"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ + const formatParts = formatStringToFormatParts("dd"); + assertEquals(dateStringToDateTimeFormatParts("10", formatParts), [ { type: "day", value: "10" }, ]); }); await t.step("handles h", () => { - const format = "h"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("1"), [ + const formatParts = formatStringToFormatParts("h"); + assertEquals(dateStringToDateTimeFormatParts("1", formatParts), [ { type: "hour", value: "1" }, ]); }); await t.step("handles hh", () => { - const format = "hh"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("11"), [ + const formatParts = formatStringToFormatParts("hh"); + assertEquals(dateStringToDateTimeFormatParts("11", formatParts), [ { type: "hour", value: "11" }, ]); }); await t.step("handles h value bigger than 12 warning", () => { - const format = "h"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("13"), [ + const formatParts = formatStringToFormatParts("h"); + assertEquals(dateStringToDateTimeFormatParts("13", formatParts), [ { type: "hour", value: "13" }, ]); }); await t.step("handles hh value bigger than 12 warning", () => { - const format = "hh"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("13"), [ + const formatParts = formatStringToFormatParts("hh"); + assertEquals(dateStringToDateTimeFormatParts("13", formatParts), [ { type: "hour", value: "13" }, ]); }); await t.step("handles H", () => { - const format = "H"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("13"), [ + const formatParts = formatStringToFormatParts("H"); + assertEquals(dateStringToDateTimeFormatParts("13", formatParts), [ { type: "hour", value: "13" }, ]); }); await t.step("handles HH", () => { - const format = "HH"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("13"), [ + const formatParts = formatStringToFormatParts("HH"); + assertEquals(dateStringToDateTimeFormatParts("13", formatParts), [ { type: "hour", value: "13" }, ]); }); await t.step("handles m", () => { - const format = "m"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ + const formatParts = formatStringToFormatParts("m"); + assertEquals(dateStringToDateTimeFormatParts("10", formatParts), [ { type: "minute", value: "10" }, ]); }); await t.step("handles mm", () => { - const format = "mm"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ + const formatParts = formatStringToFormatParts("mm"); + assertEquals(dateStringToDateTimeFormatParts("10", formatParts), [ { type: "minute", value: "10" }, ]); }); - await t.step("handles s", () => { - const format = "s"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ - { type: "second", value: "10" }, - ]); - }); await t.step("handles ss", () => { - const format = "ss"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ + const formatParts = formatStringToFormatParts("ss"); + assertEquals(dateStringToDateTimeFormatParts("10", formatParts), [ { type: "second", value: "10" }, ]); }); await t.step("handles s", () => { - const format = "s"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ + const formatParts = formatStringToFormatParts("s"); + assertEquals(dateStringToDateTimeFormatParts("10", formatParts), [ { type: "second", value: "10" }, ]); }); await t.step("handles S", () => { - const format = "S"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("1"), [ + const formatParts = formatStringToFormatParts("S"); + assertEquals(dateStringToDateTimeFormatParts("1", formatParts), [ { type: "fractionalSecond", value: "1" }, ]); - assertEquals(formatter.formatToParts("0"), [ + assertEquals(dateStringToDateTimeFormatParts("0", formatParts), [ { type: "fractionalSecond", value: "0" }, ]); - assertThrows(() => formatter.formatToParts("00")); + assertThrows(() => dateStringToDateTimeFormatParts("00", formatParts)); }); await t.step("handles SS", () => { - const format = "SS"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("10"), [ + const formatParts = formatStringToFormatParts("SS"); + assertEquals(dateStringToDateTimeFormatParts("10", formatParts), [ { type: "fractionalSecond", value: "10" }, ]); - assertEquals(formatter.formatToParts("01"), [ + assertEquals(dateStringToDateTimeFormatParts("01", formatParts), [ { type: "fractionalSecond", value: "01" }, ]); - assertEquals(formatter.formatToParts("00"), [ + assertEquals(dateStringToDateTimeFormatParts("00", formatParts), [ { type: "fractionalSecond", value: "00" }, ]); - assertThrows(() => formatter.formatToParts("0")); - assertThrows(() => formatter.formatToParts("000")); + assertThrows(() => dateStringToDateTimeFormatParts("0", formatParts)); + assertThrows(() => dateStringToDateTimeFormatParts("000", formatParts)); }); await t.step("handles SSS", () => { - const format = "SSS"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("100"), [ + const formatParts = formatStringToFormatParts("SSS"); + assertEquals(dateStringToDateTimeFormatParts("100", formatParts), [ { type: "fractionalSecond", value: "100" }, ]); - assertEquals(formatter.formatToParts("010"), [ + assertEquals(dateStringToDateTimeFormatParts("010", formatParts), [ { type: "fractionalSecond", value: "010" }, ]); - assertEquals(formatter.formatToParts("000"), [ + assertEquals(dateStringToDateTimeFormatParts("000", formatParts), [ { type: "fractionalSecond", value: "000" }, ]); - assertThrows(() => formatter.formatToParts("0")); - assertThrows(() => formatter.formatToParts("0000")); + assertThrows(() => dateStringToDateTimeFormatParts("0", formatParts)); + assertThrows(() => dateStringToDateTimeFormatParts("0000", formatParts)); }); - await t.step("handles a: AM", () => { - const format = "a"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("AM"), [ + await t.step("handles a", () => { + const formatParts = formatStringToFormatParts("a"); + assertEquals(dateStringToDateTimeFormatParts("AM", formatParts), [ { type: "dayPeriod", value: "AM" }, ]); - }); - await t.step("handles a: AM.", () => { - const format = "a"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("AM."), [ + assertEquals(dateStringToDateTimeFormatParts("AM.", formatParts), [ { type: "dayPeriod", value: "AM" }, ]); - }); - await t.step("handles a: A.M.", () => { - const format = "a"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("A.M."), [ + assertEquals(dateStringToDateTimeFormatParts("A.M.", formatParts), [ { type: "dayPeriod", value: "AM" }, ]); - }); - await t.step("handles a: PM", () => { - const format = "a"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("PM"), [ + assertEquals(dateStringToDateTimeFormatParts("PM", formatParts), [ { type: "dayPeriod", value: "PM" }, ]); - }); - await t.step("handles a: PM.", () => { - const format = "a"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("PM."), [ + assertEquals(dateStringToDateTimeFormatParts("PM.", formatParts), [ { type: "dayPeriod", value: "PM" }, ]); - }); - await t.step("handles a: P.M.", () => { - const format = "a"; - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.formatToParts("P.M."), [ + assertEquals(dateStringToDateTimeFormatParts("P.M.", formatParts), [ { type: "dayPeriod", value: "PM" }, ]); }); }); -Deno.test("dateTimeFormatter.partsToDate()", () => { +Deno.test("dateStringToDateTimeFormatParts() throws on unsupported values", () => { + const testValue = "testUnsupportedValue"; + const partTypes = [ + "day", + "dayPeriod", + "hour", + "minute", + "month", + "second", + //"timeZoneName", + "year", + "fractionalSecond", + ] as const; + + for (const partType of partTypes) { + assertThrows( + () => + dateStringToDateTimeFormatParts( + "2020", + [{ type: partType, value: testValue }], + ), + Error, + ); + } + + assertThrows( + () => + dateStringToDateTimeFormatParts( + "2020", + [{ type: "literal", value: 5 }], + ), + Error, + `Literal "5" not found "2020"`, + ); +}); + +Deno.test("dateStringToDateTimeFormatParts() throws on invalid dayPeriod", () => { + assertThrows( + () => + dateStringToDateTimeFormatParts( + "A", + [{ type: "dayPeriod", value: "short" }], + ), + Error, + `Cannot read properties of undefined (reading 'toUpperCase')`, + ); + assertThrows( + () => + dateStringToDateTimeFormatParts( + "A", + [{ type: "dayPeriod", value: "long" }], + ), + Error, + `Cannot read properties of undefined (reading 'toUpperCase')`, + ); + assertThrows( + () => + dateStringToDateTimeFormatParts( + "X", + [{ type: "dayPeriod", value: "narrow" }], + ), + Error, + `Cannot read properties of undefined (reading 'toUpperCase')`, + ); +}); + +Deno.test("dateStringToDateTimeFormatParts() throws if literal is not found", () => { + assertThrows( + () => + dateStringToDateTimeFormatParts( + "B", + [{ type: "literal", value: "A" }], + ), + Error, + `Literal "A" not found "B"`, + ); + assertThrows( + () => + dateStringToDateTimeFormatParts( + "", + [{ type: "literal", value: "A" }], + ), + Error, + `Literal "A" not found ""`, + ); +}); + +Deno.test("dateTimeFormatPartsToDate()", () => { const date = new Date("2020-01-01T00:00:00.000Z"); using _time = new FakeTime(date); - const format = "yyyy-MM-dd HH:mm:ss.SSS a"; - const formatter = new DateTimeFormatter(format); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "year", value: "2020" }, { type: "month", value: "01" }, { type: "day", value: "01" }, @@ -438,7 +504,7 @@ Deno.test("dateTimeFormatter.partsToDate()", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "year", value: "20" }, { type: "month", value: "1" }, { type: "day", value: "1" }, @@ -452,19 +518,17 @@ Deno.test("dateTimeFormatter.partsToDate()", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "timeZoneName", value: "UTC" }, ]), date, ); }); -Deno.test("dateTimeFormatter.partsToDate() works with am dayPeriod", () => { +Deno.test("dateTimeFormatPartsToDate() works with am dayPeriod", () => { const date = new Date("2020-01-01T00:00:00.000Z"); using _time = new FakeTime(date); - const format = "HH a"; - const formatter = new DateTimeFormatter(format); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "AM" }, { type: "timeZoneName", value: "UTC" }, @@ -472,7 +536,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with am dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "AM." }, { type: "timeZoneName", value: "UTC" }, @@ -480,7 +544,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with am dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "A.M." }, { type: "timeZoneName", value: "UTC" }, @@ -488,7 +552,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with am dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "am" }, { type: "timeZoneName", value: "UTC" }, @@ -496,7 +560,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with am dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "am." }, { type: "timeZoneName", value: "UTC" }, @@ -504,7 +568,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with am dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "a.m." }, { type: "timeZoneName", value: "UTC" }, @@ -512,14 +576,12 @@ Deno.test("dateTimeFormatter.partsToDate() works with am dayPeriod", () => { date, ); }); -Deno.test("dateTimeFormatter.partsToDate() works with pm dayPeriod", () => { +Deno.test("dateTimeFormatPartsToDate() works with pm dayPeriod", () => { const date = new Date("2020-01-01T13:00:00.000Z"); using _time = new FakeTime(date); - const format = "HH a"; - const formatter = new DateTimeFormatter(format); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "01" }, { type: "dayPeriod", value: "PM" }, { type: "timeZoneName", value: "UTC" }, @@ -527,7 +589,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with pm dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "01" }, { type: "dayPeriod", value: "PM." }, { type: "timeZoneName", value: "UTC" }, @@ -535,7 +597,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with pm dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "01" }, { type: "dayPeriod", value: "P.M." }, { type: "timeZoneName", value: "UTC" }, @@ -543,7 +605,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with pm dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "01" }, { type: "dayPeriod", value: "pm" }, { type: "timeZoneName", value: "UTC" }, @@ -551,7 +613,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with pm dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "01" }, { type: "dayPeriod", value: "pm." }, { type: "timeZoneName", value: "UTC" }, @@ -559,7 +621,7 @@ Deno.test("dateTimeFormatter.partsToDate() works with pm dayPeriod", () => { date, ); assertEquals( - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "01" }, { type: "dayPeriod", value: "p.m." }, { type: "timeZoneName", value: "UTC" }, @@ -567,12 +629,10 @@ Deno.test("dateTimeFormatter.partsToDate() works with pm dayPeriod", () => { date, ); }); -Deno.test("dateTimeFormatter.partsToDate() throws with invalid dayPeriods", () => { - const format = "HH a"; - const formatter = new DateTimeFormatter(format); +Deno.test("dateTimeFormatPartsToDate() throws with invalid dayPeriods", () => { assertThrows( () => - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "A.M" }, { type: "timeZoneName", value: "UTC" }, @@ -582,7 +642,7 @@ Deno.test("dateTimeFormatter.partsToDate() throws with invalid dayPeriods", () = ); assertThrows( () => - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "a.m" }, { type: "timeZoneName", value: "UTC" }, @@ -592,7 +652,7 @@ Deno.test("dateTimeFormatter.partsToDate() throws with invalid dayPeriods", () = ); assertThrows( () => - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "P.M" }, { type: "timeZoneName", value: "UTC" }, @@ -602,7 +662,7 @@ Deno.test("dateTimeFormatter.partsToDate() throws with invalid dayPeriods", () = ); assertThrows( () => - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "p.m" }, { type: "timeZoneName", value: "UTC" }, @@ -612,7 +672,7 @@ Deno.test("dateTimeFormatter.partsToDate() throws with invalid dayPeriods", () = ); assertThrows( () => - formatter.partsToDate([ + dateTimeFormatPartsToDate([ { type: "hour", value: "00" }, { type: "dayPeriod", value: "noon" }, { type: "timeZoneName", value: "UTC" }, @@ -622,11 +682,11 @@ Deno.test("dateTimeFormatter.partsToDate() throws with invalid dayPeriods", () = ); }); -Deno.test("dateTimeFormatter.partsToDate() sets utc", () => { +Deno.test("dateTimeFormatPartsToDate() sets utc", () => { const date = new Date("2020-01-01T00:00:00.000Z"); using _time = new FakeTime(date); const cases = [ - ["yyyy-MM-dd HH:mm:ss.SSS a", [ + [ { type: "year", value: "2020" }, { type: "month", value: "01" }, { type: "day", value: "01" }, @@ -636,25 +696,24 @@ Deno.test("dateTimeFormatter.partsToDate() sets utc", () => { { type: "fractionalSecond", value: "000" }, { type: "timeZoneName", value: "UTC" }, { type: "dayPeriod", value: "AM" }, - ], date], - ["yyyy-MM-dd", [ + ], + [ { type: "year", value: "2020" }, { type: "month", value: "01" }, { type: "day", value: "01" }, { type: "timeZoneName", value: "UTC" }, - ], date], - ["yyyy-MM", [ + ], + [ { type: "year", value: "2020" }, { type: "month", value: "01" }, { type: "timeZoneName", value: "UTC" }, - ], date], - ["MM", [ + ], + [ { type: "month", value: "01" }, { type: "timeZoneName", value: "UTC" }, - ], date], + ], ] as const; - for (const [format, input, output] of cases) { - const formatter = new DateTimeFormatter(format); - assertEquals(formatter.partsToDate([...input]), output); + for (const input of cases) { + assertEquals(dateTimeFormatPartsToDate(input), date); } }); From 471a374b01576a79377d4f7175b48a55f70635a5 Mon Sep 17 00:00:00 2001 From: RShields Date: Thu, 10 Apr 2025 09:17:11 -0700 Subject: [PATCH 2/3] Improve coverage --- datetime/_date_time_formatter_test.ts | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/datetime/_date_time_formatter_test.ts b/datetime/_date_time_formatter_test.ts index 9d7c655cf8fd..38e6149f1c63 100644 --- a/datetime/_date_time_formatter_test.ts +++ b/datetime/_date_time_formatter_test.ts @@ -176,6 +176,44 @@ Deno.test("new DateTimeFormatter() errors on unknown or unsupported format", () assertThrows(() => new DateTimeFormatter("z")); }); +Deno.test("formatDate() throws on unsupported values", () => { + const testValue = "testUnsupportedValue"; + const partTypes = [ + "day", + //"dayPeriod", + "hour", + "minute", + "month", + "second", + //"timeZoneName", + "year", + //"fractionalSecond", + ] as const; + + for (const partType of partTypes) { + assertThrows( + () => + formatDate( + new Date(2020, 0, 1), + [{ type: partType, value: testValue }], + ), + Error, + `FormatterError: value "${testValue}" is not supported`, + ); + } + + assertThrows( + () => + formatDate( + new Date(2020, 0, 1), + // deno-lint-ignore no-explicit-any + [{ type: "testUnsupportedType" as any, value: testValue }], + ), + Error, + `FormatterError: { testUnsupportedType testUnsupportedValue }`, + ); +}); + Deno.test("dateStringToDateTimeFormatParts()", async (t) => { await t.step("handles basic", () => { const formatParts = formatStringToFormatParts("yyyy-MM-dd"); From cbc909ad98fb19da2230e02b0b23f1c26a892859 Mon Sep 17 00:00:00 2001 From: RShields Date: Tue, 6 May 2025 20:28:40 -0700 Subject: [PATCH 3/3] Improve coverage --- datetime/_date_time_formatter_test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/datetime/_date_time_formatter_test.ts b/datetime/_date_time_formatter_test.ts index 38e6149f1c63..c32b37684a2e 100644 --- a/datetime/_date_time_formatter_test.ts +++ b/datetime/_date_time_formatter_test.ts @@ -437,6 +437,19 @@ Deno.test("dateStringToDateTimeFormatParts()", async (t) => { }); }); +Deno.test("dateStringToDateTimeFormatParts() throws on unsupported type", () => { + assertThrows( + () => + dateStringToDateTimeFormatParts( + "2020", + // deno-lint-ignore no-explicit-any + [{ type: "testUnsupportedType" as any, value: 0 }], + ), + Error, + "Cannot format the date, the value (0) of the type (testUnsupportedType) is given", + ); +}); + Deno.test("dateStringToDateTimeFormatParts() throws on unsupported values", () => { const testValue = "testUnsupportedValue"; const partTypes = [