Repository: moment/luxon (js)

Issue:
Duration fromISO / toISO don't handle correctly negative second/millisecond components
**Describe the bug**
#### case A
Parsing is good but Formating is wrong.
```js
const duration = luxon.Duration.fromISO("PT-1.5S")
duration.toObject()
=> Object { seconds: -1, milliseconds: 500 }
duration.toISO()
=> "PT-0.5S"
```

#### case B
Parsing is wrong
```js
const duration = luxon.Duration.fromISO("PT-0.5S")
duration.toObject()
=> Object { seconds: -0, milliseconds: 500 }
duration.toISO()
=> "PT0.5S"
```

**Actual vs Expected behavior**
#### case A
expected
```js
luxon.Duration.fromISO("PT-1.5S").toISO()
=> "PT-1.5S"
```

#### case B
expected
```js
luxon.Duration.fromISO("PT-0.5S").toObject()
=> Object { seconds: 0, milliseconds: -500 }
```

Pull request description:
Add duration fromISO negative millisecond handling
Add handling of negative millisecond cases to Duration.fromISO (fixes #882)



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0c11b416..c6d0f038 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,6 @@
 # Changelog
 
-## 1.26.0
+## 1.26.0 (2021-02-13)
 
  * Add fromISOTime, toISOTime and toMillis to Duration (#803)
  * Fix padding of negative years in IsoDate (#871)
@@ -8,7 +8,7 @@
  * Export VERSION information (#794)
  * Durations are considered equal with extra zero units. Fixes #809 (#811)
 
-## 1.25.0
+## 1.25.0 (2020-08-23)
 
  * fix fromFormat with Intl formats containing non-breaking spaces
  * Support higher precisision in ISO milliseconds
diff --git a/docs/calendars.md b/docs/calendars.md
index 2118bfff..c1bfef4d 100644
--- a/docs/calendars.md
+++ b/docs/calendars.md
@@ -57,5 +57,5 @@ DateTime.fromObject({ outputCalendar: c }).toLocaleString(DateTime.DATE_FULL);
 You can set the default output calendar for new DateTime instances like this:
 
 ```js
-Settings.defaultOuputCalendar = 'persian';
+Settings.defaultOutputCalendar = 'persian';
 ```
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 04b01ec7..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(
         {
@@ -1055,7 +1055,7 @@ export default class DateTime {
   /**
    * Get the week year
    * @see https://en.wikipedia.org/wiki/ISO_week_date
-   * @example DateTime.local(2014, 11, 31).weekYear //=> 2015
+   * @example DateTime.local(2014, 12, 31).weekYear //=> 2015
    * @type {number}
    */
   get weekYear() {
@@ -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/english.js b/src/impl/english.js
index 633f5943..0ae2069a 100644
--- a/src/impl/english.js
+++ b/src/impl/english.js
@@ -44,11 +44,11 @@ export const monthsNarrow = ["J", "F", "M", "A", "M", "J", "J", "A", "S", "O", "
 export function months(length) {
   switch (length) {
     case "narrow":
-      return monthsNarrow;
+      return [...monthsNarrow];
     case "short":
-      return monthsShort;
+      return [...monthsShort];
     case "long":
-      return monthsLong;
+      return [...monthsLong];
     case "numeric":
       return ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"];
     case "2-digit":
@@ -75,11 +75,11 @@ export const weekdaysNarrow = ["M", "T", "W", "T", "F", "S", "S"];
 export function weekdays(length) {
   switch (length) {
     case "narrow":
-      return weekdaysNarrow;
+      return [...weekdaysNarrow];
     case "short":
-      return weekdaysShort;
+      return [...weekdaysShort];
     case "long":
-      return weekdaysLong;
+      return [...weekdaysLong];
     case "numeric":
       return ["1", "2", "3", "4", "5", "6", "7"];
     default:
@@ -98,11 +98,11 @@ export const erasNarrow = ["B", "A"];
 export function eras(length) {
   switch (length) {
     case "narrow":
-      return erasNarrow;
+      return [...erasNarrow];
     case "short":
-      return erasShort;
+      return [...erasShort];
     case "long":
-      return erasLong;
+      return [...erasLong];
     default:
       return null;
   }
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 f2cfce8b..71273fd1 100644
--- a/src/interval.js
+++ b/src/interval.js
@@ -340,15 +340,16 @@ export default class Interval {
     }
 
     let { s } = this,
-      added,
+      idx = 1,
       next;
 
     const results = [];
     while (s < this.e) {
-      added = s.plus(dur);
+      const added = this.start.plus(dur.mapUnits(x => x * idx));
       next = +added > +this.e ? this.e : added;
       results.push(Interval.fromDateTimes(s, next));
       s = next;
+      idx += 1;
     }
 
     return results;
@@ -428,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/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/info/listers.test.js b/test/info/listers.test.js
index c58419fe..2b0b834e 100644
--- a/test/info/listers.test.js
+++ b/test/info/listers.test.js
@@ -2,6 +2,9 @@
 
 import { Info } from "../../src/luxon";
 
+import Helpers from "../helpers";
+const withDefaultLocale = Helpers.withDefaultLocale;
+
 //------
 // .months()
 //------
@@ -374,3 +377,33 @@ test("Info.eras lists both eras", () => {
   expect(Info.eras("short", { locale: "fr" })).toEqual(["av. J.-C.", "ap. J.-C."]);
   expect(Info.eras("long", { locale: "fr" })).toEqual(["avant Jésus-Christ", "après Jésus-Christ"]);
 });
+
+//------
+// general
+//------
+test("Info English lists are not mutable", () => {
+  withDefaultLocale("en-US", () => {
+    const cachingMethods = [
+      ["weekdays", "short"],
+      ["weekdays", "long"],
+      ["weekdays", "narrow"],
+      ["weekdays", "numeric"],
+      ["months", "short"],
+      ["months", "long"],
+      ["months", "narrow"],
+      ["months", "numeric"],
+      ["months", "2-digit"],
+      ["eras", "narrow"],
+      ["eras", "short"],
+      ["eras", "long"]
+    ];
+
+    for (const [method, arg] of cachingMethods) {
+      const fn = Info[method];
+      const original = [...fn(arg)];
+      fn(arg).pop();
+      const expected = fn(arg);
+      expect(expected).toEqual(original);
+    }
+  });
+});
diff --git a/test/interval/many.test.js b/test/interval/many.test.js
index f074316b..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", () => {
@@ -371,6 +367,31 @@ test("Interval#split by returns [] for durations of length 0", () => {
   expect(split).toEqual([]);
 });
 
+test("Interval#split by works across varying length months", () => {
+  Helpers.withDefaultZone("Europe/London", () => {
+    const start = DateTime.fromISO("2019-12-30T00:00:00.000+00:00");
+    const end = DateTime.fromISO("2020-05-02T23:30:00.000+00:00");
+    const interval = Interval.fromDateTimes(start, end);
+
+    const months = interval.splitBy(Duration.fromISO("P1M"));
+    expect(months.length).toEqual(5);
+
+    for (let i = 0; i < months.length; i++) {
+      const month = months[i];
+      const expectedStart = start.plus({ month: i });
+      const expectedEnd = start.plus({ month: i + 1 });
+
+      expect(month.start).toEqual(expectedStart);
+
+      if (expectedEnd > end) {
+        expect(month.end).toEqual(end);
+      } else {
+        expect(month.end).toEqual(expectedEnd);
+      }
+    }
+  });
+});
+
 //-------
 // #divideEqually()
 //-------
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", () => {