DateTimeFormat.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507
  1. //-------------------------------------------------------------------------------------------------------
  2. // Copyright (C) Microsoft. All rights reserved.
  3. // Licensed under the MIT license. See LICENSE.txt file in the project root for full license information.
  4. //-------------------------------------------------------------------------------------------------------
  5. WScript.LoadScriptFile("..\\UnitTestFramework\\UnitTestFramework.js");
  6. // remove non-ascii characters from strings, mostly for stripping Bi-Di markers
  7. const nonAsciiRegex = /[^\x00-\x7F]/g;
  8. function ascii (str) {
  9. return str.replace(nonAsciiRegex, "");
  10. }
  11. const isICU = WScript.Platform.INTL_LIBRARY === "icu";
  12. const isWinGlob = WScript.Platform.INTL_LIBRARY === "winglob";
  13. function assertFormat(expected, fmt, date, msg = "assertFormat") {
  14. assert.areEqual(ascii(expected), ascii(fmt.format(date)), `${msg}: fmt.format(date) did not match expected value`);
  15. if (isICU) {
  16. const parts = fmt.formatToParts(date);
  17. assert.areEqual(expected, parts.map((part) => part.value).join(""), `${msg}: fmt.formatToParts(date) did not match expected value`);
  18. const types = parts.filter((part) => part.type != "literal").map((part) => part.type);
  19. assert.areEqual(new Set(types).size, types.length, `Duplicate non-literal parts detected in ${JSON.stringify(parts)}`)
  20. }
  21. }
  22. const tests = [
  23. {
  24. name: "Basic functionality",
  25. body() {
  26. const date = new Date(2000, 1, 1, 1, 1, 1);
  27. function test(options, expected) {
  28. assertFormat(expected, new Intl.DateTimeFormat("en-US", options), date);
  29. assert.areEqual(
  30. expected,
  31. ascii(date.toLocaleString("en-US", options)),
  32. `date.toLocaleString("en-US", ${JSON.stringify(options)})`
  33. );
  34. }
  35. function testPlatformSpecific(options, expectedWinGlob, expectedICU) {
  36. if (isICU) {
  37. test(options, expectedICU);
  38. } else {
  39. assert.isTrue(isWinGlob);
  40. test(options, expectedWinGlob);
  41. }
  42. }
  43. test({ year: "numeric" }, "2000");
  44. test({ year: "2-digit" }, "00");
  45. test({ month: "numeric" }, "2");
  46. test({ month: "2-digit" }, "02");
  47. test({ month: "long" }, "February");
  48. test({ month: "short" }, "Feb");
  49. // WinGlob narrow is Feb, ICU narrow is F
  50. testPlatformSpecific({ month: "narrow" }, "Feb", "F");
  51. test({ day: "2-digit" }, "01");
  52. test({ day: "numeric" }, "1");
  53. test({ hour: "2-digit" }, "01 AM");
  54. test({ hour: "numeric" }, "1 AM");
  55. test({ hour: "numeric", minute: "2-digit" }, "1:01 AM");
  56. test({ hour: "numeric", minute: "numeric" }, "1:01 AM");
  57. test({ hour: "numeric", minute: "2-digit", second: "2-digit" }, "1:01:01 AM");
  58. // WinGlob doesn't have non-2-digit seconds
  59. testPlatformSpecific({ hour: "numeric", minute: "2-digit", second: "numeric" }, "1:01:01 AM", "1:01:1 AM");
  60. test({ hour: "numeric", hour12: true }, "1 AM");
  61. testPlatformSpecific({ hour: "numeric", hour12: false }, "1:00", "01");
  62. const epochYear = parseInt(ascii(new Intl.DateTimeFormat("en-US", { year: "numeric", timeZone: "UTC" }).format(0)), 10);
  63. assert.isTrue(epochYear === 1970, "Allow falsy argument to format() to mean the epoch");
  64. // By default, DateTimeFormat formats year, month, and day. Technically, Date.now() in the second call will be slightly after
  65. // the defaulted Date.now() in the first call, but this should only produce a different string if the first call runs before
  66. // midnight on day X and the second call runs after midnight on day X+1. That seems unlikely enough that it will only cause
  67. // flakiness in the rarest of circumstances
  68. assert.areEqual(new Intl.DateTimeFormat().format(), new Intl.DateTimeFormat().format(Date.now()), "The default argument for DateTimeFormat.format should be Date.now()");
  69. }
  70. },
  71. {
  72. name: "Options resolution",
  73. body() {
  74. function test(locale, options, expected, message) {
  75. expected = Object.assign({}, {
  76. locale: /en/,
  77. numberingSystem: "latn",
  78. calendar: "gregory",
  79. timeZone: /.+/
  80. }, expected);
  81. const actual = new Intl.DateTimeFormat(locale, options).resolvedOptions();
  82. for (const key in expected) {
  83. if (expected[key] !== null) {
  84. expected[key] instanceof RegExp
  85. ? assert.matches(expected[key], actual[key], message)
  86. : assert.areEqual(expected[key], actual[key], message);
  87. } else {
  88. assert.isFalse(actual.hasOwnProperty(key), `${message} - ${key} should not be present in ${JSON.stringify(actual, null, 2)}`);
  89. }
  90. }
  91. }
  92. test("en-US", undefined, { year: "numeric", month: "numeric", day: "numeric" }, "Default options do not match");
  93. test("en-US", { year: "numeric" }, { year: "numeric", month: null, day: null }, "Requesting year should not fill in other date or time options");
  94. test("en-US", { hour: "numeric" }, { hour: "numeric", minute: null, month: null }, "Requesting hour should not fill in other date or time options");
  95. test("en-US", { hour12: false }, { hour12: null }, "Requesting hour12 without hour shouldn't do anything");
  96. test("en-US", { hour: "numeric", hour12: "non-falsy value" }, { hour: "numeric", hour12: true });
  97. }
  98. },
  99. {
  100. name: "Invalid options",
  101. body() {
  102. function test(key, validOptions, kind = RangeError) {
  103. for (const option of validOptions) {
  104. assert.doesNotThrow(
  105. () => new Intl.DateTimeFormat(undefined, { [key]: option }).format(),
  106. `Valid option { ${key}: "${option}" } threw`
  107. );
  108. }
  109. assert.throws(
  110. () => new Intl.DateTimeFormat(undefined, { [key]: "invalid" }).format(),
  111. kind,
  112. `Invalid option value for ${key} did not throw`
  113. );
  114. }
  115. const twoDigitNumeric = ["2-digit", "numeric"];
  116. const narrowShortLong = ["narrow", "short", "long"];
  117. const allOptionValues = twoDigitNumeric.concat(narrowShortLong);
  118. const options = {
  119. weekday: narrowShortLong,
  120. era: narrowShortLong,
  121. year: twoDigitNumeric,
  122. month: allOptionValues,
  123. day: twoDigitNumeric,
  124. hour: twoDigitNumeric,
  125. minute: twoDigitNumeric,
  126. second: twoDigitNumeric,
  127. localeMatcher: ["lookup", "best fit"],
  128. formatMatcher: ["basic", "best fit"],
  129. };
  130. // see https://github.com/Microsoft/ChakraCore/issues/3096
  131. if (isICU) {
  132. options.timeZoneName = narrowShortLong.slice(1);
  133. }
  134. for (const option of Object.keys(options)) {
  135. test(option, options[option]);
  136. }
  137. }
  138. },
  139. {
  140. name: "Intl.DateTimeFormat.prototype.formatToParts",
  141. body() {
  142. // WinGlob does not implement formatToParts
  143. if (isWinGlob) {
  144. return;
  145. }
  146. assert.isTrue(isICU, "This test requires an ICU implementation of Intl");
  147. const date = new Date(2000, 0, 1, 0, 0, 0);
  148. function test(options, key, value, message = "Error") {
  149. const fmt = new Intl.DateTimeFormat("en-US", options);
  150. const toString = fmt.format(date);
  151. const toParts = fmt.formatToParts(date);
  152. assertFormat(toString, fmt, date);
  153. if (typeof key === "string") {
  154. const part = toParts.find((p) => p.type === key);
  155. assert.isTrue(part && typeof part.value === "string", `${message} - ${JSON.stringify(toParts)} expected to have part with type "${key}"`);
  156. assert.areEqual(value, part.value, `${message} - expected ${key} to be ${value}, but was actually ${part.value}`);
  157. } else {
  158. assert.areEqual(key.length, value.length, "test called with invalid arguments");
  159. key.forEach(function (k, i) {
  160. const v = value[i];
  161. const part = toParts.find((p) => p.type === k);
  162. assert.isTrue(part && typeof part.value === "string", `${message} - ${JSON.stringify(toParts)} expected to have part with type "${k}"`);
  163. assert.areEqual(v, part.value, `${message} - expected ${k} to be ${v}, but was actually ${part.value}`);
  164. });
  165. }
  166. }
  167. test(undefined, ["year", "month", "day"], ["2000", "1", "1"]);
  168. test({ year: "2-digit", month: "2-digit", day: "2-digit" }, ["year", "month", "day"], ["00", "01", "01"]);
  169. test({ hour: "numeric", minute: "numeric", second: "numeric" }, ["hour", "minute", "second"], ["12", "00", "00"]);
  170. test({ month: "long" }, "month", "January");
  171. // the "literal" tested here is the first of two literals, the second of which is a space between "12" and "AM"
  172. test({ hour: "numeric", weekday: "long" }, ["weekday", "literal", "hour", "dayPeriod"], ["Saturday", ", ", "12", "AM"]);
  173. }
  174. },
  175. {
  176. name: "Intl.DateTimeFormat hourCycle option",
  177. body() {
  178. // WinGlob does not implement hourCycle
  179. if (isWinGlob) {
  180. return;
  181. }
  182. assert.isTrue(isICU, "This test requires an ICU implementation of Intl");
  183. const midnight = new Date(2000, 0, 1, 0, 0, 0);
  184. const noon = new Date(2000, 0, 1, 12, 0, 0);
  185. function test(locale, useHourField, options, expectedHC, expectedHour12) {
  186. options = useHourField === false ? options : Object.assign({}, { hour: "2-digit" }, options);
  187. const fmt = new Intl.DateTimeFormat(locale, options);
  188. const message = `locale: "${locale}", options: ${JSON.stringify(options)}`;
  189. assert.areEqual(expectedHC, fmt.resolvedOptions().hourCycle, `${message} - hourCycle is not correct`);
  190. assert.areEqual(expectedHour12, fmt.resolvedOptions().hour12, `${message} - hour12 is not correct`);
  191. if (useHourField === true) {
  192. const expectedNoon = {
  193. h11: "00",
  194. h12: "12",
  195. h23: "12",
  196. h24: "12",
  197. }[expectedHC];
  198. const expectedMidnight = {
  199. h11: "00",
  200. h12: "12",
  201. h23: "00",
  202. h24: "24",
  203. }[expectedHC];
  204. assert.areEqual(expectedMidnight, fmt.formatToParts(midnight).find((p) => p.type === "hour").value, `${message} - midnight value was incorrect`);
  205. assert.areEqual(expectedNoon, fmt.formatToParts(noon).find((p) => p.type === "hour").value, `${message} - noon value was incorrect`);
  206. } else {
  207. assert.isUndefined(fmt.formatToParts(midnight).find((p) => p.type === "hour"), `${message} - unexpected hour field`);
  208. }
  209. }
  210. function withoutHour(locale, options) {
  211. test(locale, false, options, undefined, undefined);
  212. }
  213. function withHour(locale, options, expectedHC, expectedHour12) {
  214. test(locale, true, options, expectedHC, expectedHour12);
  215. }
  216. // ensure hourCycle and hour properties are not there when we don't ask for them
  217. withoutHour("en-US", undefined);
  218. withoutHour("en-US", { hourCycle: "h11" });
  219. withoutHour("en-US", { hour12: true });
  220. withoutHour("en-US", { hourCycle: "h11", hour12: false });
  221. withoutHour("en-US-u-hc-h24", undefined);
  222. withoutHour("en-US-u-hc-h24", { hourCycle: "h11" });
  223. withoutHour("en-US-u-hc-h24", { hour12: true });
  224. withoutHour("en-US-u-hc-h24", { hourCycle: "h11", hour12: false });
  225. // ensure hourCycle and hour12 properties along with hour values are correct when we do ask for hour
  226. withHour("en-US", undefined, "h12", true);
  227. withHour("en-US", { hour12: false }, "h23", false);
  228. withHour("en-US", { hourCycle: "h24" }, "h24", false);
  229. withHour("en-US", { hourCycle: "h24", hour12: true }, "h12", true);
  230. withHour("en-US", { hourCycle: "h11", hour12: false }, "h23", false);
  231. withHour("en-US-u-hc-h24", undefined, "h24", false);
  232. withHour("en-US-u-hc-h24", { hourCycle: "h23" }, "h23", false);
  233. withHour("en-US-u-hc-h24", { hourCycle: "h11" }, "h11", true);
  234. withHour("en-US-u-hc-h24", { hour12: false }, "h23", false);
  235. withHour("en-US-u-hc-h24", { hour12: true }, "h12", true);
  236. if (new Intl.DateTimeFormat("en-GB").resolvedOptions().locale === "en-GB") {
  237. withoutHour("en-GB", undefined);
  238. withHour("en-GB", undefined, "h23", false);
  239. withHour("en-GB", { hour12: true }, "h12", true);
  240. withHour("en-GB-u-hc-h24", undefined, "h24", false);
  241. }
  242. if (new Intl.DateTimeFormat("ja-JP").resolvedOptions().locale === "ja-JP") {
  243. withoutHour("ja-JP", undefined);
  244. withHour("ja-JP", undefined, "h23", false);
  245. withHour("ja-JP", { hour12: true }, "h11", true);
  246. withHour("ja-JP-u-hc-h12", undefined, "h12", true);
  247. }
  248. }
  249. },
  250. {
  251. name: "options.timeZone + options.timeZoneName",
  252. body: function () {
  253. // WinGlob does not implement formatToParts, which is used for more easily testing
  254. // Also, bizarrely, WinGlob code throws an exception *only in ch* when using timeZoneName
  255. // Bug: https://github.com/Microsoft/ChakraCore/issues/3096
  256. if (isWinGlob) {
  257. return;
  258. }
  259. assert.isTrue(isICU, "This test requires an ICU implementation of Intl");
  260. function innerTest(date, timeZone, timeZoneName, expectedPart, expectedTimeZone) {
  261. const options = {
  262. hour: "numeric",
  263. timeZone,
  264. timeZoneName
  265. };
  266. const fmt = new Intl.DateTimeFormat("en-US", options);
  267. const actualTimeZone = fmt.resolvedOptions().timeZone;
  268. assert.areEqual(expectedTimeZone, actualTimeZone, `resolvedOptions().timeZone was incorrect`);
  269. const parts = fmt.formatToParts(date);
  270. assert.isTrue(parts.length > 2, `There must at least be a time and timeZone part of ${JSON.stringify(parts)}`);
  271. const actualPart = parts.filter((part) => part.type === "timeZoneName")[0];
  272. assert.isNotUndefined(actualPart, `No timeZone part in ${JSON.stringify(parts)}`);
  273. assert.areEqual(expectedPart, actualPart.value, `Incorrect timeZoneName for ${date.toString()} with options ${JSON.stringify(options)}`);
  274. }
  275. function test(date, timeZone, expectedShortPart, expectedLongPart, expectedTimeZone) {
  276. innerTest(date, timeZone, "short", expectedShortPart, expectedTimeZone);
  277. innerTest(date, timeZone, "long", expectedLongPart, expectedTimeZone);
  278. }
  279. const newYears = new Date(Date.parse("2018-01-01T00:00:00.000Z"));
  280. const juneFirst = new Date(Date.parse("2018-06-01T00:00:00.000Z"));
  281. // see https://github.com/tc39/ecma402/issues/121 for edge cases here
  282. // ICU ~55 formats GMT-like time zones as GMT, but ICU ~60 formats them as UTC
  283. const UTCshort = WScript.Platform.ICU_VERSION >= 59 ? "UTC" : "GMT";
  284. const UTClong = WScript.Platform.ICU_VERSION >= 59 ? "Coordinated Universal Time" : "GMT";
  285. test(newYears, "GMT", UTCshort, UTClong, "UTC");
  286. test(newYears, "Etc/GMT", UTCshort, UTClong, "UTC");
  287. test(newYears, "Etc/UTC", UTCshort, UTClong, "UTC");
  288. test(newYears, "Etc/UCT", UTCshort, UTClong, "UTC");
  289. test(newYears, "US/Pacific", "PST", "Pacific Standard Time", "America/Los_Angeles");
  290. test(newYears, "Etc/GMT-2", "GMT+2", "GMT+02:00", "Etc/GMT-2");
  291. test(newYears, "America/New_York", "EST", "Eastern Standard Time", "America/New_York");
  292. test(newYears, "America/Los_Angeles", "PST", "Pacific Standard Time","America/Los_Angeles");
  293. test(juneFirst, "America/New_York", "EDT", "Eastern Daylight Time", "America/New_York");
  294. test(juneFirst, "America/Los_Angeles", "PDT", "Pacific Daylight Time", "America/Los_Angeles");
  295. }
  296. },
  297. {
  298. name: "ca and nu extensions",
  299. body() {
  300. if (isWinGlob) {
  301. return;
  302. }
  303. // This test raised Microsoft/ChakraCore#4885 and tc39/ecma402#225 - In the original ICU implementation
  304. // of Intl.DateTimeFormat, we would generate the date pattern using the fully resolved locale, including
  305. // any unicode extension keys to specify the calendar and numbering system.
  306. // This caused ICU to generate more accurate patterns in the given calendar system, but is not spec
  307. // compliant by #sec-initializedatetimeformat as of Intl 2018.
  308. // Revisit the values for chinese and dangi calendars in particular in the future if pattern generation
  309. // switches to using the full locale instead of just the basename, as those calendar systems prefer to
  310. // to be represented in ICU by a year name and a related gregorian year.
  311. const d = new Date(Date.UTC(2018, 2, 27, 12, 0, 0));
  312. // lists of calendars and aliases taken from https://unicode.org/repos/cldr/trunk/common/bcp47/calendar.xml
  313. // as of March 27th, 2018
  314. const yearForCalendar = {
  315. buddhist: {
  316. latn: "2561",
  317. thai: "๒๕๖๑",
  318. },
  319. // TODO(jahorto): investigate chinese and dangi calendars - Microsoft/ChakraCore#4885
  320. chinese: {
  321. latn: "35",
  322. thai: "๓๕",
  323. },
  324. coptic: {
  325. latn: "1734",
  326. thai: "๑๗๓๔",
  327. },
  328. dangi: {
  329. latn: "35",
  330. thai: "๓๕",
  331. },
  332. ethioaa: {
  333. latn: "7510",
  334. thai: "๗๕๑๐",
  335. },
  336. ethiopic: {
  337. latn: "2010",
  338. thai: "๒๐๑๐",
  339. },
  340. gregory: {
  341. latn: "2018",
  342. thai: "๒๐๑๘",
  343. },
  344. hebrew: {
  345. latn: "5778",
  346. thai: "๕๗๗๘"
  347. },
  348. indian: {
  349. latn: "1940",
  350. thai: "๑๙๔๐",
  351. },
  352. islamic: {
  353. latn: "1439",
  354. thai: "๑๔๓๙",
  355. },
  356. "islamic-umalqura": {
  357. latn: "1439",
  358. thai: "๑๔๓๙",
  359. },
  360. "islamic-tbla": {
  361. latn: "1439",
  362. thai: "๑๔๓๙",
  363. },
  364. "islamic-civil": {
  365. latn: "1439",
  366. thai: "๑๔๓๙",
  367. },
  368. "islamic-rgsa": {
  369. latn: "1439",
  370. thai: "๑๔๓๙",
  371. },
  372. iso8601: {
  373. latn: "2018",
  374. thai: "๒๐๑๘",
  375. },
  376. japanese: {
  377. latn: "30",
  378. thai: "๓๐",
  379. },
  380. persian: {
  381. latn: "1397",
  382. thai: "๑๓๙๗",
  383. },
  384. roc: {
  385. latn: "107",
  386. thai: "๑๐๗",
  387. },
  388. };
  389. const calendarAliases = {
  390. ethioaa: ["ethiopic-amete-alem"],
  391. // ICU does not recognize "gregorian" as a valid alias
  392. // gregory: ["gregorian"],
  393. "islamic-civil": ["islamicc"],
  394. };
  395. function test(expected, base, calendar, numberingSystem) {
  396. let langtag = `${base}-u-ca-${calendar}`;
  397. if (numberingSystem) {
  398. langtag += `-nu-${numberingSystem}`;
  399. }
  400. // Extract just the year out of the string to ensure we don't get confused by added information, like eras.
  401. const fmt = new Intl.DateTimeFormat(langtag, { year: "numeric" });
  402. assertFormat(fmt.format(d), fmt, d);
  403. assert.areEqual(
  404. expected,
  405. fmt.formatToParts(d).filter((part) => part.type === "year")[0].value,
  406. `${langtag} did not produce the correct year`
  407. );
  408. }
  409. function testEachCalendar(numberingSystem) {
  410. for (const calendar of Object.getOwnPropertyNames(yearForCalendar)) {
  411. test(yearForCalendar[calendar][numberingSystem || "latn"], "en", calendar, numberingSystem);
  412. if (calendar in calendarAliases) {
  413. const aliases = calendarAliases[calendar];
  414. for (const alias of aliases) {
  415. test(yearForCalendar[calendar][numberingSystem || "latn"], "en", alias, numberingSystem);
  416. }
  417. }
  418. }
  419. }
  420. for (const numberingSystem of [undefined, "latn", "thai"]) {
  421. testEachCalendar(numberingSystem);
  422. }
  423. }
  424. },
  425. {
  426. name: "Supplied times should be clipped using TimeClip",
  427. body() {
  428. if (isWinGlob) {
  429. return;
  430. }
  431. const dtf = new Intl.DateTimeFormat("en", { hour: "numeric", minute: "numeric", second: "numeric" });
  432. for (const nearZero of [-0.9, -0.1, -Number.MIN_VALUE, -0, +0, Number.MIN_VALUE, 0.1, 0.9]) {
  433. assert.areEqual(dtf.format(0), dtf.format(nearZero), `Formatting 0 and ${nearZero} should produce the same result`);
  434. }
  435. assert.throws(() => dtf.format(-8.64e15 - 1), RangeError, "Formatting a time before the beginning of ES time");
  436. assert.doesNotThrow(() => dtf.format(-8.64e15), "Formatting the beginning of ES time");
  437. assert.doesNotThrow(() => dtf.format(8.64e15), "Formatting the end of ES time");
  438. assert.throws(() => dtf.format(8.64e15 + 1), RangeError, "Formatting a time after the end of ES time");
  439. }
  440. },
  441. ];
  442. testRunner.runTests(tests, { verbose: !WScript.Arguments.includes("summary") });