Repository: moment/luxon
Pull request #592 (2019-10-08 19:20:26)

Add Duration#mapUnits
Fix for #557 


Patch:
diff --git a/changelog.md b/changelog.md
index 3574d7cf..7cba7a16 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,15 +1,15 @@
 # Changelog
 
-# 1.19.3
+## 1.19.3
 
  * Cache offset values
  * Fix handling of negative sub 1-hour offsets
 
-# 1.19.2
+## 1.19.2
 
  * Speculative fix for Node 6
 
-# 1.19.1
+## 1.19.1
 
  * Fix Intl.DateTimeFormat usage for polyfills
 
diff --git a/src/duration.js b/src/duration.js
index da3a7531..fbef70f3 100644
--- a/src/duration.js
+++ b/src/duration.js
@@ -1,4 +1,4 @@
-import { isUndefined, isNumber, normalizeObject, hasOwnProperty } from "./impl/util.js";
+import { isUndefined, isNumber, normalizeObject, hasOwnProperty, asNumber } from "./impl/util.js";
 import Locale from "./impl/locale.js";
 import Formatter from "./impl/formatter.js";
 import { parseISODuration } from "./impl/regexParser.js";
@@ -477,6 +477,22 @@ export default class Duration {
     return this.plus(dur.negate());
   }
 
+  /**
+   * Scale this Duration by the specified amount. Return a newly-constructed Duration.
+   * @param {function} fn - The function to apply to each unit. Arity is 1 or 2: the value of the unit and, optionally, the unit name. Must return a number.
+   * @example Duration.fromObject({ hours: 1, minutes: 30 }).mapUnit(x => x * 2) //=> { hours: 2, minutes: 60 }
+   * @example Duration.fromObject({ hours: 1, minutes: 30 }).mapUnit((x, u) => u === "hour" ? x * 2 : x) //=> { hours: 2, minutes: 30 }
+   * @return {Duration}
+   */
+  mapUnits(fn) {
+    if (!this.isValid) return this;
+    const result = {};
+    for (const k of Object.keys(this.values)) {
+      result[k] = asNumber(fn(this.values[k], k));
+    }
+    return clone(this, { values: result }, true);
+  }
+
   /**
    * Get the value of unit.
    * @param {string} unit - a unit such as 'minute' or 'day'
diff --git a/src/impl/util.js b/src/impl/util.js
index 824718c4..19d0c1e7 100644
--- a/src/impl/util.js
+++ b/src/impl/util.js
@@ -242,7 +242,7 @@ export function signedOffset(offHourStr, offMinuteStr) {
 
 // COERCION
 
-function asNumber(value) {
+export function asNumber(value) {
   const numericValue = Number(value);
   if (typeof value === "boolean" || value === "" || Number.isNaN(numericValue))
     throw new InvalidArgumentError(`Invalid unit value ${value}`);


Test patch:
diff --git a/test/duration/math.test.js b/test/duration/math.test.js
index 496898f8..457a6237 100644
--- a/test/duration/math.test.js
+++ b/test/duration/math.test.js
@@ -137,3 +137,38 @@ test("Duration#negate preserves conversionAccuracy", () => {
     result = dur.negate();
   expect(result.conversionAccuracy).toBe("longterm");
 });
+
+//------
+// #mapUnits
+//------
+
+test("Duration#units can multiply durations", () => {
+  const dur = Duration.fromObject({ hours: 1, minutes: 2, seconds: -3, milliseconds: -4 }),
+    result = dur.mapUnits(x => x * 5);
+
+  expect(result.hours).toBe(5);
+  expect(result.minutes).toBe(10);
+  expect(result.seconds).toBe(-15);
+  expect(result.milliseconds).toBe(-20);
+});
+
+test("Duration#units can take the unit into account", () => {
+  const dur = Duration.fromObject({ hours: 1, minutes: 2, seconds: -3, milliseconds: -4 }),
+    result = dur.mapUnits((x, u) => x * (u === "milliseconds" ? 2 : 5));
+
+  expect(result.hours).toBe(5);
+  expect(result.minutes).toBe(10);
+  expect(result.seconds).toBe(-15);
+  expect(result.milliseconds).toBe(-8);
+});
+
+test("Duration#mapUnits maintains invalidity", () => {
+  const dur = Duration.invalid("because").mapUnits(x => x * 5);
+  expect(dur.isValid).toBe(false);
+  expect(dur.invalidReason).toBe("because");
+});
+
+test("Duration#mapUnits requires that fn return a number", () => {
+  const dur = Duration.fromObject({ hours: 1, minutes: 2, seconds: -3, milliseconds: -4 });
+  expect(() => dur.mapUnits(() => "hello?")).toThrow();
+});