Repository: moment/luxon
Pull request #861 (2021-01-31 23:17:08)

#859: Adding validation for mixing incompatible units
### Problem:
Luxon wasn't preventing from mixing incompatible units.

### Solution
Added validation.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c39fb4f5..c6d0f038 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,14 @@
 # Changelog
 
-## 1.25.0
+## 1.26.0 (2021-02-13)
+
+ * Add fromISOTime, toISOTime and toMillis to Duration (#803)
+ * Fix padding of negative years in IsoDate (#871)
+ * Fix hasSame unit comparison (#798)
+ * Export VERSION information (#794)
+ * Durations are considered equal with extra zero units. Fixes #809 (#811)
+
+## 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 ee932767..f2ea27a4 100644
--- a/docs/math.md
+++ b/docs/math.md
@@ -82,7 +82,7 @@ DateTime.fromISO('2017-04-30').plus({months: 1, days: 1}).toISODate();
 
 If the day is added first, we'll get an intermediate value of May 1. Adding a month to that gives us June 1. But if the month is added first, we'll an intermediate value of May 30 and day after that is May 31. (See "Calendar math vs time math above if this is confusing.) So the order matters.
 
-Luxon has a simple rule for this: **math is done from highest order to lowest order**. So the result of the example above is May 31. This rule isn't logically necessary, but it does seem reflect what people mean. Of course, Luxon can't enforce this rule if you do the math in separate operations:
+Luxon has a simple rule for this: **math is done from highest order to lowest order**. So the result of the example above is May 31. This rule isn't logically necessary, but it does seem to reflect what people mean. Of course, Luxon can't enforce this rule if you do the math in separate operations:
 
 ```js
 DateTime.fromISO('2017-04-30').plus({days: 1}).plus({months: 1}).toISODate() //=> '2017-06-01'
diff --git a/package-lock.json b/package-lock.json
index 14de6a9a..55c9bbea 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "luxon",
-  "version": "1.25.0",
+  "version": "1.26.0",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
diff --git a/package.json b/package.json
index e55d9086..cbba9a60 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "luxon",
-  "version": "1.25.0",
+  "version": "1.26.0",
   "description": "Immutable date wrapper",
   "author": "Isaac Cambron",
   "keywords": [
diff --git a/scripts/version b/scripts/version
index 1dd81a94..82ecd8ba 100755
--- a/scripts/version
+++ b/scripts/version
@@ -5,9 +5,16 @@ abort() {
   exit 1
 }
 
+if [[ -x $(which ggrep) ]]
+then
+  grepper='ggrep'
+else
+  grepper='grep'
+fi
+
 PACKAGE_VERSION=$(node -p "require('./package.json').version")
 PACKAGE_LOCK_VERSION=$(node -p "require('./package-lock.json').version")
-EXPORTED_VERSION=$(grep -oP "(?<=const VERSION = \").*(?=\";)" ./src/luxon.js)
+EXPORTED_VERSION=$($grepper -oP "(?<=const VERSION = \").*(?=\";)" ./src/luxon.js)
 
 if [ $PACKAGE_VERSION != $PACKAGE_LOCK_VERSION ]; then
   abort "package-lock.json's version differs from package.json's"
diff --git a/src/datetime.js b/src/datetime.js
index 0b3664c7..b3e4de92 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -197,6 +197,7 @@ function toTechTimeFormat(
     suppressSeconds = false,
     suppressMilliseconds = false,
     includeOffset,
+    includePrefix = false,
     includeZone = false,
     spaceZone = false,
     format = "extended"
@@ -221,7 +222,13 @@ function toTechTimeFormat(
     fmt += format === "basic" ? "ZZZ" : "ZZ";
   }
 
-  return toTechFormat(dt, fmt);
+  let str = toTechFormat(dt, fmt);
+
+  if (includePrefix) {
+    str = "T" + str;
+  }
+
+  return str;
 }
 
 // defaults for unspecified units in the supported calendars
@@ -1048,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() {
@@ -1332,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) {
@@ -1571,22 +1593,26 @@ export default class DateTime {
    * @param {boolean} [opts.suppressMilliseconds=false] - exclude milliseconds from the format if they're 0
    * @param {boolean} [opts.suppressSeconds=false] - exclude seconds from the format if they're 0
    * @param {boolean} [opts.includeOffset=true] - include the offset, such as 'Z' or '-04:00'
+   * @param {boolean} [opts.includePrefix=false] - include the `T` prefix
    * @param {string} [opts.format='extended'] - choose between the basic and extended format
    * @example DateTime.utc().set({ hour: 7, minute: 34 }).toISOTime() //=> '07:34:19.361Z'
    * @example DateTime.utc().set({ hour: 7, minute: 34, seconds: 0, milliseconds: 0 }).toISOTime({ suppressSeconds: true }) //=> '07:34Z'
    * @example DateTime.utc().set({ hour: 7, minute: 34 }).toISOTime({ format: 'basic' }) //=> '073419.361Z'
+   * @example DateTime.utc().set({ hour: 7, minute: 34 }).toISOTime({ includePrefix: true }) //=> 'T07:34:19.361Z'
    * @return {string}
    */
   toISOTime({
     suppressMilliseconds = false,
     suppressSeconds = false,
     includeOffset = true,
+    includePrefix = false,
     format = "extended"
   } = {}) {
     return toTechTimeFormat(this, {
       suppressSeconds,
       suppressMilliseconds,
       includeOffset,
+      includePrefix,
       format
     });
   }
diff --git a/src/duration.js b/src/duration.js
index 9bddefe8..b2a6679c 100644
--- a/src/duration.js
+++ b/src/duration.js
@@ -2,7 +2,7 @@ import { InvalidArgumentError, InvalidDurationError, InvalidUnitError } from "./
 import Formatter from "./impl/formatter.js";
 import Invalid from "./impl/invalid.js";
 import Locale from "./impl/locale.js";
-import { parseISODuration } from "./impl/regexParser.js";
+import { parseISODuration, parseISOTimeOnly } from "./impl/regexParser.js";
 import {
   asNumber,
   hasOwnProperty,
@@ -276,6 +276,31 @@ export default class Duration {
     }
   }
 
+  /**
+   * Create a Duration from an ISO 8601 time string.
+   * @param {string} text - text to parse
+   * @param {Object} opts - options for parsing
+   * @param {string} [opts.locale='en-US'] - the locale to use
+   * @param {string} opts.numberingSystem - the numbering system to use
+   * @param {string} [opts.conversionAccuracy='casual'] - the conversion system to use
+   * @see https://en.wikipedia.org/wiki/ISO_8601#Times
+   * @example Duration.fromISOTime('11:22:33.444').toObject() //=> { hours: 11, minutes: 22, seconds: 33, milliseconds: 444 }
+   * @example Duration.fromISOTime('11:00').toObject() //=> { hours: 11, minutes: 0, seconds: 0 }
+   * @example Duration.fromISOTime('T11:00').toObject() //=> { hours: 11, minutes: 0, seconds: 0 }
+   * @example Duration.fromISOTime('1100').toObject() //=> { hours: 11, minutes: 0, seconds: 0 }
+   * @example Duration.fromISOTime('T1100').toObject() //=> { hours: 11, minutes: 0, seconds: 0 }
+   * @return {Duration}
+   */
+  static fromISOTime(text, opts) {
+    const [parsed] = parseISOTimeOnly(text);
+    if (parsed) {
+      const obj = Object.assign(parsed, opts);
+      return Duration.fromObject(obj);
+    } else {
+      return Duration.invalid("unparsable", `the input "${text}" can't be parsed as ISO 8601`);
+    }
+  }
+
   /**
    * Create an invalid Duration.
    * @param {string} reason - simple string of why this datetime is invalid. Should not contain parameters or anything else data-dependent
@@ -433,6 +458,58 @@ export default class Duration {
     return s;
   }
 
+  /**
+   * Returns an ISO 8601-compliant string representation of this Duration, formatted as a time of day.
+   * Note that this will return null if the duration is invalid, negative, or equal to or greater than 24 hours.
+   * @see https://en.wikipedia.org/wiki/ISO_8601#Times
+   * @param {Object} opts - options
+   * @param {boolean} [opts.suppressMilliseconds=false] - exclude milliseconds from the format if they're 0
+   * @param {boolean} [opts.suppressSeconds=false] - exclude seconds from the format if they're 0
+   * @param {boolean} [opts.includePrefix=false] - include the `T` prefix
+   * @param {string} [opts.format='extended'] - choose between the basic and extended format
+   * @example Duration.fromObject({ hours: 11 }).toISOTime() //=> '11:00:00.000'
+   * @example Duration.fromObject({ hours: 11 }).toISOTime({ suppressMilliseconds: true }) //=> '11:00:00'
+   * @example Duration.fromObject({ hours: 11 }).toISOTime({ suppressSeconds: true }) //=> '11:00'
+   * @example Duration.fromObject({ hours: 11 }).toISOTime({ includePrefix: true }) //=> 'T11:00:00.000'
+   * @example Duration.fromObject({ hours: 11 }).toISOTime({ format: 'basic' }) //=> '110000.000'
+   * @return {string}
+   */
+  toISOTime(opts = {}) {
+    if (!this.isValid) return null;
+
+    const millis = this.toMillis();
+    if (millis < 0 || millis >= 86400000) return null;
+
+    opts = Object.assign(
+      {
+        suppressMilliseconds: false,
+        suppressSeconds: false,
+        includePrefix: false,
+        format: "extended"
+      },
+      opts
+    );
+
+    const value = this.shiftTo("hours", "minutes", "seconds", "milliseconds");
+
+    let fmt = opts.format === "basic" ? "hhmm" : "hh:mm";
+
+    if (!opts.suppressSeconds || value.seconds !== 0 || value.milliseconds !== 0) {
+      fmt += opts.format === "basic" ? "ss" : ":ss";
+      if (!opts.suppressMilliseconds || value.milliseconds !== 0) {
+        fmt += ".SSS";
+      }
+    }
+
+    let str = value.toFormat(fmt);
+
+    if (opts.includePrefix) {
+      str = "T" + str;
+    }
+
+    return str;
+  }
+
   /**
    * Returns an ISO 8601 representation of this Duration appropriate for use in JSON.
    * @return {string}
@@ -453,10 +530,18 @@ export default class Duration {
    * Returns an milliseconds value of this Duration.
    * @return {number}
    */
-  valueOf() {
+  toMillis() {
     return this.as("milliseconds");
   }
 
+  /**
+   * Returns an milliseconds value of this Duration. Alias of {@link toMillis}
+   * @return {number}
+   */
+  valueOf() {
+    return this.toMillis();
+  }
+
   /**
    * Make this Duration longer by the specified amount. Return a newly-constructed Duration.
    * @param {Duration|Object|number} duration - The amount to add. Either a Luxon Duration, a number of milliseconds, the object argument to Duration.fromObject()
diff --git a/src/impl/diff.js b/src/impl/diff.js
index 57246dc9..1b8afb36 100644
--- a/src/impl/diff.js
+++ b/src/impl/diff.js
@@ -13,6 +13,7 @@ function dayDiff(earlier, later) {
 function highOrderDiffs(cursor, later, units) {
   const differs = [
     ["years", (a, b) => b.year - a.year],
+    ["quarters", (a, b) => b.quarter - a.quarter],
     ["months", (a, b) => b.month - a.month + (b.year - a.year) * 12],
     [
       "weeks",
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/locale.js b/src/impl/locale.js
index e4f4022e..fe7a146d 100644
--- a/src/impl/locale.js
+++ b/src/impl/locale.js
@@ -183,20 +183,28 @@ class PolyDateFormatter {
 
     let z;
     if (dt.zone.universal && this.hasIntl) {
-      // Chromium doesn't support fixed-offset zones like Etc/GMT+8 in its formatter,
-      // See https://bugs.chromium.org/p/chromium/issues/detail?id=364374.
-      // So we have to make do. Two cases:
-      // 1. The format options tell us to show the zone. We can't do that, so the best
-      // we can do is format the date in UTC.
-      // 2. The format options don't tell us to show the zone. Then we can adjust them
-      // the time and tell the formatter to show it to us in UTC, so that the time is right
-      // and the bad zone doesn't show up.
-      // We can clean all this up when Chrome fixes this.
-      z = "UTC";
-      if (opts.timeZoneName) {
+      // 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.
+      const gmtOffset = -1 * (dt.offset / 60);
+      if (gmtOffset >= -14 && gmtOffset <= 12 && gmtOffset % 1 === 0) {
+        z = gmtOffset >= 0 ? `Etc/GMT+${gmtOffset}` : `Etc/GMT${gmtOffset}`;
         this.dt = dt;
       } else {
-        this.dt = dt.offset === 0 ? dt : DateTime.fromMillis(dt.ts + dt.offset * 60 * 1000);
+        // Not all fixed-offset zones like Etc/+4:30 are present in tzdata.
+        // So we have to make do. Two cases:
+        // 1. The format options tell us to show the zone. We can't do that, so the best
+        // we can do is format the date in UTC.
+        // 2. The format options don't tell us to show the zone. Then we can adjust them
+        // the time and tell the formatter to show it to us in UTC, so that the time is right
+        // and the bad zone doesn't show up.
+        z = "UTC";
+        if (opts.timeZoneName) {
+          this.dt = dt;
+        } else {
+          this.dt = dt.offset === 0 ? dt : DateTime.fromMillis(dt.ts + dt.offset * 60 * 1000);
+        }
       }
     } else if (dt.zone.type === "local") {
       this.dt = dt;
diff --git a/src/impl/regexParser.js b/src/impl/regexParser.js
index 74ba737c..8a000430 100644
--- a/src/impl/regexParser.js
+++ b/src/impl/regexParser.js
@@ -97,10 +97,10 @@ function extractISOYmd(match, cursor) {
 
 function extractISOTime(match, cursor) {
   const item = {
-    hour: int(match, cursor, 0),
-    minute: int(match, cursor + 1, 0),
-    second: int(match, cursor + 2, 0),
-    millisecond: parseMillis(match[cursor + 3])
+    hours: int(match, cursor, 0),
+    minutes: int(match, cursor + 1, 0),
+    seconds: int(match, cursor + 2, 0),
+    milliseconds: parseMillis(match[cursor + 3])
   };
 
   return [item, null, cursor + 4];
@@ -118,6 +118,10 @@ function extractIANAZone(match, cursor) {
   return [{}, zone, cursor + 1];
 }
 
+// ISO time parsing
+
+const isoTimeOnly = RegExp(`^T?${isoTimeBaseRegex.source}$`);
+
 // ISO duration parsing
 
 const isoDuration = /^-?P(?:(?:(-?\d{1,9})Y)?(?:(-?\d{1,9})M)?(?:(-?\d{1,9})W)?(?:(-?\d{1,9})D)?(?:T(?:(-?\d{1,9})H)?(?:(-?\d{1,9})M)?(?:(-?\d{1,20})(?:[.,](-?\d{1,9}))?S)?)?)$/;
@@ -295,6 +299,12 @@ export function parseISODuration(s) {
   return parse(s, [isoDuration, extractISODuration]);
 }
 
+const extractISOTimeOnly = combineExtractors(extractISOTime);
+
+export function parseISOTimeOnly(s) {
+  return parse(s, [isoTimeOnly, extractISOTimeOnly]);
+}
+
 const sqlYmdWithTimeExtensionRegex = combineRegexes(sqlYmdRegex, sqlTimeExtensionRegex);
 const sqlTimeCombinedRegex = combineRegexes(sqlTimeRegex);
 
diff --git a/src/impl/util.js b/src/impl/util.js
index b345c694..59f6f860 100644
--- a/src/impl/util.js
+++ b/src/impl/util.js
@@ -99,11 +99,17 @@ export function floorMod(x, n) {
 }
 
 export function padStart(input, n = 2) {
-  if (input.toString().length < n) {
-    return ("0".repeat(n) + input).slice(-n);
+  const minus = input < 0 ? "-" : "";
+  const target = minus ? input * -1 : input;
+  let result;
+
+  if (target.toString().length < n) {
+    result = ("0".repeat(n) + target).slice(-n);
   } else {
-    return input.toString();
+    result = target.toString();
   }
+
+  return `${minus}${result}`;
 }
 
 export function parseInteger(string) {
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/luxon.js b/src/luxon.js
index f84bb770..8da8d8d8 100644
--- a/src/luxon.js
+++ b/src/luxon.js
@@ -9,7 +9,7 @@ import InvalidZone from "./zones/invalidZone.js";
 import LocalZone from "./zones/localZone.js";
 import Settings from "./settings.js";
 
-const VERSION = "1.25.0";
+const VERSION = "1.26.0";
 
 export {
   VERSION,


Test patch:
diff --git a/test/datetime/diff.test.js b/test/datetime/diff.test.js
index e4e29f3c..cb439b6f 100644
--- a/test/datetime/diff.test.js
+++ b/test/datetime/diff.test.js
@@ -25,6 +25,14 @@ test("DateTime#diff makes simple diffs", () => {
     years: 1
   });
 
+  expect(diffObjs({ year: 2016, month: 4 }, { year: 2016, month: 1 }, "quarters")).toEqual({
+    quarters: 1
+  });
+
+  expect(diffObjs({ year: 2017, month: 10 }, { year: 2017, month: 4 }, "quarters")).toEqual({
+    quarters: 2
+  });
+
   expect(
     diffObjs({ year: 2016, month: 6, day: 28 }, { year: 2016, month: 5, day: 28 }, "months")
   ).toEqual({ months: 1 });
diff --git a/test/datetime/format.test.js b/test/datetime/format.test.js
index 516b5705..eb04f9d3 100644
--- a/test/datetime/format.test.js
+++ b/test/datetime/format.test.js
@@ -91,6 +91,15 @@ test("DateTime#toISODate() returns ISO 8601 date in format [±YYYYY]", () => {
   );
 });
 
+test("DateTime#toISODate() correctly pads negative years", () => {
+  expect(DateTime.fromObject({ year: -1, month: 1, day: 1, zone: "utc" }).toISODate()).toBe(
+    "-0001-01-01"
+  );
+  expect(DateTime.fromObject({ year: -10, month: 1, day: 1, zone: "utc" }).toISODate()).toBe(
+    "-0010-01-01"
+  );
+});
+
 //------
 // #toISOWeekDate()
 //------
@@ -133,10 +142,6 @@ test("DateTime#toISOTime({suppressSeconds: true}) will suppress milliseconds if
   expect(dt.set({ second: 0, millisecond: 0 }).toISOTime({ suppressSeconds: true })).toBe("09:23Z");
 });
 
-test("DateTime#toISOTime({suppressSeconds: true}) will suppress milliseconds if they're zero", () => {
-  expect(dt.set({ second: 0, millisecond: 0 }).toISOTime({ suppressSeconds: true })).toBe("09:23Z");
-});
-
 test("DateTime#toISOTime() handles other offsets", () => {
   expect(dt.setZone("America/New_York").toISOTime()).toBe("05:23:54.123-04:00");
 });
@@ -147,6 +152,14 @@ test("DateTime#toISOTime() can omit the offset", () => {
 
 test("DateTime#toISOTime() can output the basic format", () => {
   expect(dt.toISOTime({ format: "basic" })).toBe("092354.123Z");
+  const dt2 = dt.set({ second: 0, millisecond: 0 });
+  expect(dt2.toISOTime({ format: "basic", suppressMilliseconds: true })).toBe("092300Z");
+  expect(dt2.toISOTime({ format: "basic", suppressSeconds: true })).toBe("0923Z");
+});
+
+test("DateTime#toISOTime can include the prefix", () => {
+  expect(dt.toISOTime({ includePrefix: true })).toBe("T09:23:54.123Z");
+  expect(dt.toISOTime({ format: "basic", includePrefix: true })).toBe("T092354.123Z");
 });
 
 test("DateTime#toISOTime() returns null for invalid DateTimes", () => {
@@ -314,8 +327,14 @@ test("DateTime#toLocaleString() shows things in the right fixed-offset zone", ()
   expect(dt.setZone("UTC-8").toLocaleString(DateTime.DATETIME_SHORT)).toBe("5/25/1982, 1:23 AM");
 });
 
-test("DateTime#toLocaleString() does the best it can with a fixed-offset zone when showing the zone", () => {
+test("DateTime#toLocaleString() shows things in the right fixed-offset zone when showing the zone", () => {
   expect(dt.setZone("UTC-8").toLocaleString(DateTime.DATETIME_FULL)).toBe(
+    "May 25, 1982, 1:23 AM GMT-8"
+  );
+});
+
+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/format.test.js b/test/duration/format.test.js
index 699ecdaf..1555e536 100644
--- a/test/duration/format.test.js
+++ b/test/duration/format.test.js
@@ -16,6 +16,7 @@ const dur = () =>
 //------
 // #toISO()
 //------
+
 test("Duration#toISO fills out every field", () => {
   expect(dur().toISO()).toBe("P1Y2M1W3DT4H5M6.007S");
 });
@@ -62,6 +63,55 @@ test("Duration#toISO handles mixed negative/positive numbers in seconds/millisec
   expect(Duration.fromObject({ seconds: -17, milliseconds: 548 }).toISO()).toBe("PT-16.452S");
 });
 
+//------
+// #toISOTime()
+//------
+
+const hhmmssSSS = Duration.fromObject({ hours: 11, minutes: 22, seconds: 33, milliseconds: 444 });
+const hhSSS = Duration.fromObject({ hours: 11, milliseconds: 444 });
+const hhss = Duration.fromObject({ hours: 11, seconds: 33 });
+const hh = Duration.fromObject({ hours: 11 });
+
+test("Duration#toISOTime creates a correct extended string", () => {
+  expect(hhmmssSSS.toISOTime()).toBe("11:22:33.444");
+});
+
+test("Duration#toISOTime suppresses milliseconds correctly", () => {
+  expect(hhSSS.toISOTime({ suppressMilliseconds: true })).toBe("11:00:00.444");
+  expect(hhss.toISOTime({ suppressMilliseconds: true })).toBe("11:00:33");
+  expect(hh.toISOTime({ suppressMilliseconds: true })).toBe("11:00:00");
+});
+
+test("Duration#toISOTime suppresses seconds correctly", () => {
+  expect(hhSSS.toISOTime({ suppressSeconds: true })).toBe("11:00:00.444");
+  expect(hhss.toISOTime({ suppressSeconds: true })).toBe("11:00:33.000");
+  expect(hh.toISOTime({ suppressSeconds: true })).toBe("11:00");
+});
+
+test("Duration#toISOTime includes the prefix correctly", () => {
+  expect(hh.toISOTime({ includePrefix: true })).toBe("T11:00:00.000");
+});
+
+test("Duration#toISOTime creates a correct basic string", () => {
+  expect(hhmmssSSS.toISOTime({ format: "basic" })).toBe("112233.444");
+  expect(hh.toISOTime({ format: "basic", suppressMilliseconds: true })).toBe("110000");
+  expect(hh.toISOTime({ format: "basic", suppressSeconds: true })).toBe("1100");
+});
+
+test("Duration#toISOTime returns null if the value is outside the range of one day", () => {
+  expect(Duration.fromObject({ hours: 24 }).toISOTime()).toBe(null);
+  expect(Duration.fromObject({ milliseconds: -1 }).toISOTime()).toBe(null);
+});
+
+//------
+// #toMillis()
+//------
+
+test("Duration#toMillis returns the value in milliseconds", () => {
+  expect(Duration.fromMillis(1000).toMillis()).toBe(1000);
+  expect(dur().valueOf()).toBe(dur().toMillis());
+});
+
 //------
 // #toJSON()
 //------
@@ -81,6 +131,7 @@ test("Duration#toString returns the ISO representation", () => {
 //------
 // #toFormat()
 //------
+
 test("Duration#toFormat('S') returns milliseconds", () => {
   expect(dur().toFormat("S")).toBe("37598706007");
 
diff --git a/test/duration/parse.test.js b/test/duration/parse.test.js
index f8ec42db..cbabbf2d 100644
--- a/test/duration/parse.test.js
+++ b/test/duration/parse.test.js
@@ -61,11 +61,11 @@ test("Duration.fromISO can parse fractions of seconds", () => {
   });
 });
 
-test("Duration.fromISO rejects junk", () => {
-  const rejects = s => {
-    expect(Duration.fromISO(s).isValid).toBe(false);
-  };
+const rejects = s => {
+  expect(Duration.fromISO(s).isValid).toBe(false);
+};
 
+test("Duration.fromISO rejects junk", () => {
   rejects("poop");
   rejects("PTglorb");
   rejects("P5Y34S");
@@ -74,3 +74,36 @@ test("Duration.fromISO rejects junk", () => {
   rejects("P34K");
   rejects("P5D2W");
 });
+
+//------
+// #fromISOTime()
+//------
+
+const checkTime = (s, ob) => {
+  expect(Duration.fromISOTime(s).toObject()).toEqual(ob);
+};
+
+test("Duration.fromISOTime can parse a variety of extended ISO time formats", () => {
+  checkTime("11:22:33.444", { hours: 11, minutes: 22, seconds: 33, milliseconds: 444 });
+  checkTime("11:22:33", { hours: 11, minutes: 22, seconds: 33 });
+  checkTime("11:22", { hours: 11, minutes: 22, seconds: 0 });
+  checkTime("T11:22", { hours: 11, minutes: 22, seconds: 0 });
+});
+
+test("Duration.fromISOTime can parse a variety of basic ISO time formats", () => {
+  checkTime("112233.444", { hours: 11, minutes: 22, seconds: 33, milliseconds: 444 });
+  checkTime("112233", { hours: 11, minutes: 22, seconds: 33 });
+  checkTime("1122", { hours: 11, minutes: 22, seconds: 0 });
+  checkTime("11", { hours: 11, minutes: 0, seconds: 0 });
+  checkTime("T1122", { hours: 11, minutes: 22, seconds: 0 });
+});
+
+const rejectsTime = s => {
+  expect(Duration.fromISOTime(s).isValid).toBe(false);
+};
+
+test("Duration.fromISOTime rejects junk", () => {
+  rejectsTime("poop");
+  rejectsTime("Tglorb");
+  rejectsTime("-00:00");
+});
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()
 //-------