Repository: moment/luxon (js)

Issue:
Duck check fails in normalizeZone
**Describe the bug**
In my scenario, I believe I have a date whose zone instance came from a separate node module, and thus the `instanceof Zone` check fails.  I see you have duck checking in the `normalizeZone` function to handle this use-case.  

However, it appears to be written with the wrong assumption that `offset` is a getter property, whereas it seems actually to be [defined as a **function** in Zone.js](https://github.com/moment/luxon/blob/master/src/zone.js#L63-L71).  This causes the check to fail unexpectedly.

![image](https://user-images.githubusercontent.com/29075873/234978668-82f54a99-cc5e-4821-befe-710bd75d0ccb.png)


Pull request description:
Fix duck test to match Zone interface
Fixes #1426

`normalizeZone` uses `offset` on a Zone for the duck test, but incorrectly assumes it is a property that returns a number when in fact it is a function/method on Zone.

The duck test is particularly important for scenarios where package dependencies brought in their own copy of luxon (perhaps due to version mismatches), causing the `instance of` check to fail.  The duck test helps ensure compatibility with other instances of luxon modules.

Patch:
diff --git a/docs/math.md b/docs/math.md
index 6934c51a..4b21b0d3 100644
--- a/docs/math.md
+++ b/docs/math.md
@@ -23,7 +23,7 @@ More generally we can differentiate two modes of math:
 
 These units use calendar math:
 
- * **Years** vary because of years.
+ * **Years** vary because of leap years.
  * **Months** vary because they're just different lengths.
  * **Days** vary because DST transitions mean some days are 23 or 25 hours long.
  * **Quarters** are always three months, but months vary in length so quarters do too.
diff --git a/docs/zones.md b/docs/zones.md
index 6b21262a..60638397 100644
--- a/docs/zones.md
+++ b/docs/zones.md
@@ -276,14 +276,14 @@ start.plus({ hours: 24 }).hour; //=> 11, DST pushed forward an hour
 By default, Luxon creates DateTimes in the system's zone. However, you can override this behavior globally:
 
 ```js
-Settings.defaultZoneName = "Asia/Tokyo";
+Settings.defaultZone = "Asia/Tokyo";
 DateTime.local().zoneName; //=> 'Asia/Tokyo'
 
-Settings.defaultZoneName = "utc";
+Settings.defaultZone = "utc";
 DateTime.local().zoneName; //=> 'UTC'
 
 // you can reset by setting to 'system'
 
-Settings.defaultZoneName = "system";
+Settings.defaultZone = "system";
 DateTime.local().zoneName; //=> 'America/New_York'
 ```
diff --git a/src/datetime.js b/src/datetime.js
index 90279094..40c1e6ae 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -894,7 +894,7 @@ export default class DateTime {
 
   /**
    * Create an invalid DateTime.
-   * @param {DateTime} reason - simple string of why this DateTime is invalid. Should not contain parameters or anything else data-dependent
+   * @param {string} reason - simple string of why this DateTime is invalid. Should not contain parameters or anything else data-dependent.
    * @param {string} [explanation=null] - longer explanation, may include parameters and other useful debugging information
    * @return {DateTime}
    */
diff --git a/src/duration.js b/src/duration.js
index 72f67fb4..49033a47 100644
--- a/src/duration.js
+++ b/src/duration.js
@@ -126,20 +126,19 @@ function clone(dur, alts, clear = false) {
   return new Duration(conf);
 }
 
-function antiTrunc(n) {
-  return n < 0 ? Math.floor(n) : Math.ceil(n);
+// this is needed since in some test cases it would return 0.9999999999999999 instead of 1
+function removePrecisionIssue(a) {
+  return Math.trunc(a * 1e3) / 1e3;
 }
 
 // NB: mutates parameters
 function convert(matrix, fromMap, fromUnit, toMap, toUnit) {
   const conv = matrix[toUnit][fromUnit],
     raw = fromMap[fromUnit] / conv,
-    sameSign = Math.sign(raw) === Math.sign(toMap[toUnit]),
-    // ok, so this is wild, but see the matrix in the tests
-    added =
-      !sameSign && toMap[toUnit] !== 0 && Math.abs(raw) <= 1 ? antiTrunc(raw) : Math.trunc(raw);
-  toMap[toUnit] += added;
-  fromMap[fromUnit] -= added * conv;
+    added = Math.floor(raw);
+
+  toMap[toUnit] = removePrecisionIssue(toMap[toUnit] + added);
+  fromMap[fromUnit] = removePrecisionIssue(fromMap[fromUnit] - added * conv);
 }
 
 // NB: mutates parameters
@@ -577,7 +576,13 @@ export default class Duration {
    * @return {number}
    */
   toMillis() {
-    return this.as("milliseconds");
+    let sum = this.values.milliseconds ?? 0;
+    for (let unit of reverseUnits.slice(1)) {
+      if (this.values?.[unit]) {
+        sum += this.values[unit] * this.matrix[unit]["milliseconds"];
+      }
+    }
+    return sum;
   }
 
   /**
@@ -694,8 +699,11 @@ export default class Duration {
   normalize() {
     if (!this.isValid) return this;
     const vals = this.toObject();
-    normalizeValues(this.matrix, vals);
-    return clone(this, { values: vals }, true);
+    if (this.valueOf() >= 0) {
+      normalizeValues(this.matrix, vals);
+      return clone(this, { values: vals }, true);
+    }
+    return this.negate().normalize().negate();
   }
 
   /**
diff --git a/src/impl/locale.js b/src/impl/locale.js
index a1dcd33b..4a2f14ba 100644
--- a/src/impl/locale.js
+++ b/src/impl/locale.js
@@ -120,7 +120,7 @@ function intlConfigString(localeStr, numberingSystem, outputCalendar) {
 function mapMonths(f) {
   const ms = [];
   for (let i = 1; i <= 12; i++) {
-    const dt = DateTime.utc(2016, i, 1);
+    const dt = DateTime.utc(2009, i, 1);
     ms.push(f(dt));
   }
   return ms;
diff --git a/src/impl/zoneUtil.js b/src/impl/zoneUtil.js
index ad389db5..c3151a71 100644
--- a/src/impl/zoneUtil.js
+++ b/src/impl/zoneUtil.js
@@ -24,7 +24,7 @@ export function normalizeZone(input, defaultZone) {
     else return FixedOffsetZone.parseSpecifier(lowered) || IANAZone.create(input);
   } else if (isNumber(input)) {
     return FixedOffsetZone.instance(input);
-  } else if (typeof input === "object" && input.offset && typeof input.offset === "number") {
+  } else if (typeof input === "object" && "offset" in input && typeof input.offset === "function") {
     // This is dumb, but the instanceof check above doesn't seem to really work
     // so we're duck checking it
     return input;


Test patch:
diff --git a/test/duration/units.test.js b/test/duration/units.test.js
index 676f4d6f..2b37fad4 100644
--- a/test/duration/units.test.js
+++ b/test/duration/units.test.js
@@ -189,6 +189,10 @@ test("Duration#normalize handles the full grid partially negative durations", ()
       { months: 0, days: -28 },
       { months: 0, days: -28 },
     ],
+    [
+      { hours: 96, minutes: 0, seconds: -10 },
+      { hours: 95, minutes: 59, seconds: 50 },
+    ],
   ];
 
   sets.forEach(([from, to]) => {
diff --git a/test/info/listers.test.js b/test/info/listers.test.js
index b54deb59..7b4597d0 100644
--- a/test/info/listers.test.js
+++ b/test/info/listers.test.js
@@ -105,6 +105,8 @@ test("Info.months respects the numbering system", () => {
 
 test("Info.months respects the calendar", () => {
   expect(Info.months("long", { locale: "en", outputCalendar: "islamic" })).toEqual([
+    "Muharram",
+    "Safar",
     "Rabiʻ I",
     "Rabiʻ II",
     "Jumada I",
@@ -115,8 +117,6 @@ test("Info.months respects the calendar", () => {
     "Shawwal",
     "Dhuʻl-Qiʻdah",
     "Dhuʻl-Hijjah",
-    "Safar",
-    "Rabiʻ I",
   ]);
 });