Repository: moment/luxon
Pull request #918 (2021-04-08 18:56:30)

Add check for etc zone support before using for formatting
This fixes issues like #912 and #883.

- Instead of checking only if the range is likely supported, the zone is really tested before it is used. While this will have likely some performance impact, I cannot think of some other reliable way to do this.
- TZ used for formatting when the offset is 0 is UTC. This will fix issues like #878 because it'll revert things to the previous behaviour. It is not ideal, because if somebody uses explicit GMT it still becomes UTC, but AFAIK FixedOffsetZone doesn't hold the original name so this is the best that can be done.



Patch:
diff --git a/docs/math.md b/docs/math.md
index f2ea27a4..cbaa3f8f 100644
--- a/docs/math.md
+++ b/docs/math.md
@@ -263,7 +263,7 @@ var diffInMonths = end.diff(start, 'months');
 diffInMonths.as('days'); //=> 30
 ```
 
-That's our conversion between months and days (you could also do a longterm-accurate conversion; it wouldn't fix the issue ahead). But this isn't the number of days between February 15 and March 15!
+That's our conversion between months and days (you could also do a longterm-accurate conversion; it wouldn't fix the issue ahead). But this isn't the number of days between February 13 and March 13!
 
 ```js
 var diffInDays = end.diff(start, 'days');
diff --git a/src/datetime.js b/src/datetime.js
index aa563c10..b165cc6c 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -479,7 +479,7 @@ export default class DateTime {
    */
   static local(year, month, day, hour, minute, second, millisecond) {
     if (isUndefined(year)) {
-      return new DateTime({});
+      return DateTime.now();
     } else {
       return quickDT(
         {
@@ -1339,7 +1339,22 @@ export default class DateTime {
       settingWeekStuff =
         !isUndefined(normalized.weekYear) ||
         !isUndefined(normalized.weekNumber) ||
-        !isUndefined(normalized.weekday);
+        !isUndefined(normalized.weekday),
+      containsOrdinal = !isUndefined(normalized.ordinal),
+      containsGregorYear = !isUndefined(normalized.year),
+      containsGregorMD = !isUndefined(normalized.month) || !isUndefined(normalized.day),
+      containsGregor = containsGregorYear || containsGregorMD,
+      definiteWeekDef = normalized.weekYear || normalized.weekNumber;
+
+    if ((containsGregor || containsOrdinal) && definiteWeekDef) {
+      throw new ConflictingSpecificationError(
+        "Can't mix weekYear/weekNumber units with year/month/day or ordinals"
+      );
+    }
+
+    if (containsGregorMD && containsOrdinal) {
+      throw new ConflictingSpecificationError("Can't mix ordinal dates with month/day");
+    }
 
     let mixed;
     if (settingWeekStuff) {
diff --git a/src/impl/locale.js b/src/impl/locale.js
index fe7a146d..44a92660 100644
--- a/src/impl/locale.js
+++ b/src/impl/locale.js
@@ -3,6 +3,7 @@ import * as English from "./english.js";
 import Settings from "../settings.js";
 import DateTime from "../datetime.js";
 import Formatter from "./formatter.js";
+import IANAZone from "../zones/IANAZone.js";
 
 let intlDTCache = {};
 function getCachedDTF(locString, opts = {}) {
@@ -185,11 +186,15 @@ class PolyDateFormatter {
     if (dt.zone.universal && this.hasIntl) {
       // UTC-8 or Etc/UTC-8 are not part of tzdata, only Etc/GMT+8 and the like.
       // That is why fixed-offset TZ is set to that unless it is:
-      // 1. Outside of the supported range Etc/GMT-14 to Etc/GMT+12.
-      // 2. Not a whole hour, e.g. UTC+4:30.
+      // 1. Representing offset 0 when UTC is used to maintain previous behavior and does not become GMT.
+      // 2. Unsupported by the browser:
+      //    - some do not support Etc/
+      //    - < Etc/GMT-14, > Etc/GMT+12, and 30-minute or 45-minute offsets are not part of tzdata
       const gmtOffset = -1 * (dt.offset / 60);
-      if (gmtOffset >= -14 && gmtOffset <= 12 && gmtOffset % 1 === 0) {
-        z = gmtOffset >= 0 ? `Etc/GMT+${gmtOffset}` : `Etc/GMT${gmtOffset}`;
+      const offsetZ = gmtOffset >= 0 ? `Etc/GMT+${gmtOffset}` : `Etc/GMT${gmtOffset}`;
+      const isOffsetZoneSupported = IANAZone.isValidZone(offsetZ);
+      if (dt.offset !== 0 && isOffsetZoneSupported) {
+        z = offsetZ;
         this.dt = dt;
       } else {
         // Not all fixed-offset zones like Etc/+4:30 are present in tzdata.
diff --git a/src/impl/regexParser.js b/src/impl/regexParser.js
index 8a000430..8e4308b6 100644
--- a/src/impl/regexParser.js
+++ b/src/impl/regexParser.js
@@ -140,8 +140,10 @@ function extractISODuration(match) {
   ] = match;
 
   const hasNegativePrefix = s[0] === "-";
+  const negativeSeconds = secondStr && secondStr[0] === "-";
 
-  const maybeNegate = num => (num && hasNegativePrefix ? -num : num);
+  const maybeNegate = (num, force = false) =>
+    num !== undefined && (force || (num && hasNegativePrefix)) ? -num : num;
 
   return [
     {
@@ -151,8 +153,8 @@ function extractISODuration(match) {
       days: maybeNegate(parseInteger(dayStr)),
       hours: maybeNegate(parseInteger(hourStr)),
       minutes: maybeNegate(parseInteger(minuteStr)),
-      seconds: maybeNegate(parseInteger(secondStr)),
-      milliseconds: maybeNegate(parseMillis(millisecondsStr))
+      seconds: maybeNegate(parseInteger(secondStr), secondStr === "-0"),
+      milliseconds: maybeNegate(parseMillis(millisecondsStr), negativeSeconds)
     }
   ];
 }
diff --git a/src/interval.js b/src/interval.js
index e8a419bc..71273fd1 100644
--- a/src/interval.js
+++ b/src/interval.js
@@ -429,7 +429,7 @@ export default class Interval {
     const s = this.s > other.s ? this.s : other.s,
       e = this.e < other.e ? this.e : other.e;
 
-    if (s > e) {
+    if (s >= e) {
       return null;
     } else {
       return Interval.fromDateTimes(s, e);
diff --git a/src/zones/IANAZone.js b/src/zones/IANAZone.js
index e2ad296e..0e319d0f 100644
--- a/src/zones/IANAZone.js
+++ b/src/zones/IANAZone.js
@@ -109,7 +109,7 @@ export default class IANAZone extends Zone {
   /** @ignore */
   static parseGMTOffset(specifier) {
     if (specifier) {
-      const match = specifier.match(/^Etc\/GMT([+-]\d{1,2})$/i);
+      const match = specifier.match(/^Etc\/GMT(0|[+-]\d{1,2})$/i);
       if (match) {
         return -60 * parseInt(match[1]);
       }


Test patch:
diff --git a/test/datetime/format.test.js b/test/datetime/format.test.js
index eb04f9d3..b3383d78 100644
--- a/test/datetime/format.test.js
+++ b/test/datetime/format.test.js
@@ -333,6 +333,12 @@ test("DateTime#toLocaleString() shows things in the right fixed-offset zone when
   );
 });
 
+test("DateTime#toLocaleString() shows things with UTC if fixed-offset zone with 0 offset is used", () => {
+  expect(dt.setZone("UTC").toLocaleString(DateTime.DATETIME_FULL)).toBe(
+    "May 25, 1982, 9:23 AM UTC"
+  );
+});
+
 test("DateTime#toLocaleString() does the best it can with unsupported fixed-offset zone when showing the zone", () => {
   expect(dt.setZone("UTC+4:30").toLocaleString(DateTime.DATETIME_FULL)).toBe(
     "May 25, 1982, 9:23 AM UTC"
diff --git a/test/datetime/set.test.js b/test/datetime/set.test.js
index aecd7807..9da2c52c 100644
--- a/test/datetime/set.test.js
+++ b/test/datetime/set.test.js
@@ -123,6 +123,12 @@ test("DateTime#set throws for metadata", () => {
   expect(() => dt.set({ invalid: true })).toThrow();
 });
 
+test("DateTime#set throws for mixing incompatible units", () => {
+  expect(() => dt.set({ year: 2020, weekNumber: 22 })).toThrow();
+  expect(() => dt.set({ ordinal: 200, weekNumber: 22 })).toThrow();
+  expect(() => dt.set({ ordinal: 200, month: 8 })).toThrow();
+});
+
 test("DateTime#set maintains invalidity", () => {
   expect(DateTime.invalid("because").set({ ordinal: 200 }).isValid).toBe(false);
 });
diff --git a/test/duration/parse.test.js b/test/duration/parse.test.js
index cbabbf2d..8704b958 100644
--- a/test/duration/parse.test.js
+++ b/test/duration/parse.test.js
@@ -31,6 +31,10 @@ test("Duration.fromISO can parse mixed or negative durations", () => {
   check("-P5Y3M", { years: -5, months: -3 });
   check("-P-5Y-3M", { years: 5, months: 3 });
   check("-P-1W1DT13H-23M34S", { weeks: 1, days: -1, hours: -13, minutes: 23, seconds: -34 });
+  check("PT-1.5S", { seconds: -1, milliseconds: -500 });
+  check("PT-0.5S", { seconds: 0, milliseconds: -500 });
+  check("PT1.5S", { seconds: 1, milliseconds: 500 });
+  check("PT0.5S", { seconds: 0, milliseconds: 500 });
 });
 
 test("Duration.fromISO can parse fractions of seconds", () => {
diff --git a/test/interval/many.test.js b/test/interval/many.test.js
index 002bd97b..69d3aec4 100644
--- a/test/interval/many.test.js
+++ b/test/interval/many.test.js
@@ -111,12 +111,8 @@ test("Interval#intersection returns the intersection for overlapping intervals",
   ).toBeTruthy();
 });
 
-test("Interval#intersection returns empty for adjacent intervals", () => {
-  expect(
-    todayFrom(5, 8)
-      .intersection(todayFrom(8, 10))
-      .isEmpty()
-  ).toBeTruthy();
+test("Interval#intersection returns null for adjacent intervals", () => {
+  expect(todayFrom(5, 8).intersection(todayFrom(8, 10))).toBeNull();
 });
 
 test("Interval#intersection returns invalid for invalid intervals", () => {
diff --git a/test/zones/IANA.test.js b/test/zones/IANA.test.js
index f5f4a671..15e101b4 100644
--- a/test/zones/IANA.test.js
+++ b/test/zones/IANA.test.js
@@ -33,6 +33,9 @@ test("IANAZone.isValidZone", () => {
 
 test("IANAZone.parseGMTOffset returns a number for a valid input", () => {
   expect(IANAZone.parseGMTOffset("Etc/GMT+8")).toBe(-480);
+  expect(IANAZone.parseGMTOffset("Etc/GMT+0")).toBe(-0);
+  expect(IANAZone.parseGMTOffset("Etc/GMT-0")).toBe(+0);
+  expect(IANAZone.parseGMTOffset("Etc/GMT0")).toBe(-0);
 });
 
 test("IANAZone.parseGMTOffset returns null for invalid input", () => {