Repository: moment/luxon (js)

Issue:
Parsing fractional hours durations
**Describe the bug**
I've discovered this problem when trying to parse back ISO duration acquired from Luxon previously. Many of my durations ended up being empty objects. After some further investigation, it appears to be a problem in parsing fractional hours durations.


**To Reproduce**

I've made following test case to prove this problem: 478abdc82319336b5ab1b6b385927e5bde423eb4

You can also test this problem in my RunKit: https://runkit.com/spodjasek/luxon-fractional-hours

**Actual vs Expected behavior**

After running:
```javascript
Duration.fromISO('PT9.5H').toObject()
```

I expect returned object to be:
```javascript
{hours: 9.5}
```

Instead of above I receive empty object:
```
{}
```

**Desktop (please complete the following information):**

- OS: Linux
- Run on Node
- Luxon version 2.0.2
- Timezone `Europe/Warsaw`, but also tested in `America/New_York`



Pull request description:
Duration support for fractional duration
Improve duration with fractional support :
`Duration.fromObject({ days: 1.5 }) => { days: 1.5 }`
`Duration.fromISO("PT9.5H").toObject() => { hours: 9.5 }`

Closes #1058
Edit : I have updated this section to reflect the final result. 

Patch:
diff --git a/src/duration.js b/src/duration.js
index 5372a472..60122631 100644
--- a/src/duration.js
+++ b/src/duration.js
@@ -6,6 +6,7 @@ import { parseISODuration, parseISOTimeOnly } from "./impl/regexParser.js";
 import {
   asNumber,
   hasOwnProperty,
+  isInteger,
   isNumber,
   isUndefined,
   normalizeObject,
@@ -16,7 +17,7 @@ import Settings from "./settings.js";
 const INVALID = "Invalid Duration";
 
 // unit conversion constants
-const lowOrderMatrix = {
+export const lowOrderMatrix = {
     weeks: {
       days: 7,
       hours: 7 * 24,
@@ -239,6 +240,7 @@ export default class Duration {
         }`
       );
     }
+
     return new Duration({
       values: normalizeObject(obj, Duration.normalizeUnit),
       loc: Locale.fromObject(opts),
diff --git a/src/impl/regexParser.js b/src/impl/regexParser.js
index 65ba6be3..eb4fefc7 100644
--- a/src/impl/regexParser.js
+++ b/src/impl/regexParser.js
@@ -5,6 +5,7 @@ import {
   parseMillis,
   ianaRegex,
   isUndefined,
+  parseFloating,
 } from "./util.js";
 import * as English from "./english.js";
 import FixedOffsetZone from "../zones/fixedOffsetZone.js";
@@ -125,7 +126,7 @@ 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)?)?)$/;
+  /^-?P(?:(?:(-?\d{1,9}(?:\.\d{1,9})?)Y)?(?:(-?\d{1,9}(?:\.\d{1,9})?)M)?(?:(-?\d{1,9}(?:\.\d{1,9})?)W)?(?:(-?\d{1,9}(?:\.\d{1,9})?)D)?(?:T(?:(-?\d{1,9}(?:\.\d{1,9})?)H)?(?:(-?\d{1,9}(?:\.\d{1,9})?)M)?(?:(-?\d{1,20})(?:[.,](-?\d{1,9}))?S)?)?)$/;
 
 function extractISODuration(match) {
   const [s, yearStr, monthStr, weekStr, dayStr, hourStr, minuteStr, secondStr, millisecondsStr] =
@@ -139,13 +140,13 @@ function extractISODuration(match) {
 
   return [
     {
-      years: maybeNegate(parseInteger(yearStr)),
-      months: maybeNegate(parseInteger(monthStr)),
-      weeks: maybeNegate(parseInteger(weekStr)),
-      days: maybeNegate(parseInteger(dayStr)),
-      hours: maybeNegate(parseInteger(hourStr)),
-      minutes: maybeNegate(parseInteger(minuteStr)),
-      seconds: maybeNegate(parseInteger(secondStr), secondStr === "-0"),
+      years: maybeNegate(parseFloating(yearStr)),
+      months: maybeNegate(parseFloating(monthStr)),
+      weeks: maybeNegate(parseFloating(weekStr)),
+      days: maybeNegate(parseFloating(dayStr)),
+      hours: maybeNegate(parseFloating(hourStr)),
+      minutes: maybeNegate(parseFloating(minuteStr)),
+      seconds: maybeNegate(parseFloating(secondStr), secondStr === "-0"),
       milliseconds: maybeNegate(parseMillis(millisecondsStr), negativeSeconds),
     },
   ];
diff --git a/src/impl/util.js b/src/impl/util.js
index e25d6be8..f576c5e7 100644
--- a/src/impl/util.js
+++ b/src/impl/util.js
@@ -108,6 +108,14 @@ export function parseInteger(string) {
   }
 }
 
+export function parseFloating(string) {
+  if (isUndefined(string) || string === null || string === "") {
+    return undefined;
+  } else {
+    return parseFloat(string);
+  }
+}
+
 export function parseMillis(fraction) {
   // Return undefined (instead of 0) in these cases, where fraction is not set
   if (isUndefined(fraction) || fraction === null || fraction === "") {


Test patch:
diff --git a/test/duration/create.test.js b/test/duration/create.test.js
index ec24b745..a425dc6c 100644
--- a/test/duration/create.test.js
+++ b/test/duration/create.test.js
@@ -24,6 +24,22 @@ test("Duration.fromObject sets all the values", () => {
   expect(dur.milliseconds).toBe(7);
 });
 
+test("Duration.fromObject sets all the fractional values", () => {
+  const dur = Duration.fromObject({
+    years: 1,
+    months: 2,
+    days: 3,
+    hours: 4.5,
+  });
+  expect(dur.years).toBe(1);
+  expect(dur.months).toBe(2);
+  expect(dur.days).toBe(3);
+  expect(dur.hours).toBe(4.5);
+  expect(dur.minutes).toBe(0);
+  expect(dur.seconds).toBe(0);
+  expect(dur.milliseconds).toBe(0);
+});
+
 test("Duration.fromObject sets all the values from the object having string type values", () => {
   const dur = Duration.fromObject({
     years: "1",
diff --git a/test/duration/equality.test.js b/test/duration/equality.test.js
index bfbd50de..a7b7765e 100644
--- a/test/duration/equality.test.js
+++ b/test/duration/equality.test.js
@@ -12,12 +12,24 @@ test("equals identically constructed", () => {
   expect(l1.equals(l2)).toBe(true);
 });
 
-test("equals identically constructed but one has sting type values", () => {
+test("equals identically constructed with fractional values", () => {
+  const l1 = Duration.fromObject({ years: 5.5, days: 6 }),
+    l2 = Duration.fromObject({ years: 5.5, days: 6 });
+  expect(l1.equals(l2)).toBe(true);
+});
+
+test("equals identically constructed but one has string type values", () => {
   const l1 = Duration.fromObject({ years: 5, days: 6 }),
     l2 = Duration.fromObject({ years: "5", days: "6" });
   expect(l1.equals(l2)).toBe(true);
 });
 
+test("equals identically constructed but one has fractional string type values", () => {
+  const l1 = Duration.fromObject({ years: 5.5, days: 6 }),
+    l2 = Duration.fromObject({ years: "5.5", days: "6" });
+  expect(l1.equals(l2)).toBe(true);
+});
+
 // #809
 test("equals with extra zero units", () => {
   const l1 = Duration.fromObject({ years: 5, days: 6 }),
diff --git a/test/duration/flattenValues.test.js b/test/duration/flattenValues.test.js
new file mode 100644
index 00000000..1f5cfdf4
--- /dev/null
+++ b/test/duration/flattenValues.test.js
@@ -0,0 +1,46 @@
+import { flattenValues, casualMatrix, accurateMatrix } from "../../src/duration";
+
+test("flattenValues converts decimal days to hours", () => {
+  const dur = flattenValues(casualMatrix, {
+    days: 1.5,
+    hours: 4,
+  });
+  expect(dur.days).toBe(1);
+  expect(dur.hours).toBe(16);
+});
+
+test("flattenValues converts in cascade", () => {
+  const dur = flattenValues(casualMatrix, {
+    days: 1.5,
+    hours: 4.5,
+  });
+  expect(dur.days).toBe(1);
+  expect(dur.hours).toBe(16);
+  expect(dur.minutes).toBe(30);
+
+  const dur2 = flattenValues(casualMatrix, {
+    months: 1.5,
+  });
+  expect(dur2.months).toBe(1);
+  expect(dur2.weeks).toBe(2);
+
+  const dur3 = flattenValues(accurateMatrix, {
+    months: 1.5,
+  });
+  expect(dur3.months).toBe(1);
+  expect(dur3.weeks).toBe(2);
+  expect(dur3.days).toBe(1);
+  expect(dur3.hours).toBe(5);
+  expect(dur3.minutes).toBe(14);
+  expect(dur3.seconds).toBe(33);
+  expect(dur3.milliseconds).toBe(1);
+});
+
+test("flattenValues rounds milliseconds", () => {
+  const dur = flattenValues(casualMatrix, {
+    seconds: 1.5,
+    milliseconds: 100.5,
+  });
+  expect(dur.seconds).toBe(1);
+  expect(dur.milliseconds).toBe(601);
+});
diff --git a/test/duration/format.test.js b/test/duration/format.test.js
index ff1b77dd..9a50c9d7 100644
--- a/test/duration/format.test.js
+++ b/test/duration/format.test.js
@@ -21,6 +21,20 @@ test("Duration#toISO fills out every field", () => {
   expect(dur().toISO()).toBe("P1Y2M1W3DT4H5M6.007S");
 });
 
+test("Duration#toISO fills out every field with fractional", () => {
+  const dur = Duration.fromObject({
+    years: 1.1,
+    months: 2.2,
+    weeks: 1.1,
+    days: 3.3,
+    hours: 4.4,
+    minutes: 5.5,
+    seconds: 6.6,
+    milliseconds: 7,
+  });
+  expect(dur.toISO()).toBe("P1.1Y2.2M1.1W3.3DT4.4H5.5M6.607S");
+});
+
 test("Duration#toISO creates a minimal string", () => {
   expect(Duration.fromObject({ years: 3, seconds: 45 }).toISO()).toBe("P3YT45S");
   expect(Duration.fromObject({ months: 4, seconds: 45 }).toISO()).toBe("P4MT45S");
diff --git a/test/duration/getters.test.js b/test/duration/getters.test.js
index 7b313100..192cb3a6 100644
--- a/test/duration/getters.test.js
+++ b/test/duration/getters.test.js
@@ -44,6 +44,24 @@ test("Duration#hours returns the hours", () => {
   expect(inv.hours).toBeFalsy();
 });
 
+test("Duration#hours returns the fractional hours", () => {
+  const localDur = Duration.fromObject({
+      years: 1,
+      quarters: 2,
+      months: 2,
+      days: 3,
+      hours: 4.5,
+      minutes: 5,
+      seconds: 6,
+      milliseconds: 7,
+      weeks: 8,
+    }),
+    localInv = Duration.invalid("because i say so");
+
+  expect(localDur.hours).toBe(4.5);
+  expect(localInv.hours).toBeFalsy();
+});
+
 test("Duration#minutes returns the minutes", () => {
   expect(dur.minutes).toBe(5);
   expect(inv.minutes).toBeFalsy();
diff --git a/test/duration/info.test.js b/test/duration/info.test.js
index cedb8095..350a3a51 100644
--- a/test/duration/info.test.js
+++ b/test/duration/info.test.js
@@ -6,7 +6,7 @@ const dur = Duration.fromObject(
   {
     years: 1,
     months: 2,
-    days: 3,
+    days: 3.3,
   },
   {
     locale: "fr",
@@ -22,7 +22,7 @@ test("Duration#toObject returns the object", () => {
   expect(dur.toObject()).toEqual({
     years: 1,
     months: 2,
-    days: 3,
+    days: 3.3,
   });
 });
 
@@ -31,7 +31,7 @@ test("Duration#toObject accepts a flag to return config", () => {
     {
       years: 1,
       months: 2,
-      days: 3,
+      days: 3.3,
     },
     {
       locale: "fr",
diff --git a/test/duration/math.test.js b/test/duration/math.test.js
index 21d31ba1..536afe21 100644
--- a/test/duration/math.test.js
+++ b/test/duration/math.test.js
@@ -16,6 +16,17 @@ test("Duration#plus add straightforward durations", () => {
   expect(result.milliseconds).toBe(14);
 });
 
+test("Duration#plus add fractional durations", () => {
+  const first = Duration.fromObject({ hours: 4.2, minutes: 12, seconds: 2 }),
+    second = Duration.fromObject({ hours: 1, seconds: 6.8, milliseconds: 14 }),
+    result = first.plus(second);
+
+  expect(result.hours).toBeCloseTo(5.2, 8);
+  expect(result.minutes).toBe(12);
+  expect(result.seconds).toBeCloseTo(8.8, 8);
+  expect(result.milliseconds).toBe(14);
+});
+
 test("Duration#plus noops empty druations", () => {
   const first = Duration.fromObject({ hours: 4, minutes: 12, seconds: 2 }),
     second = Duration.fromObject({}),
@@ -87,6 +98,17 @@ test("Duration#minus subtracts durations", () => {
   expect(result.milliseconds).toBe(-14);
 });
 
+test("Duration#minus subtracts fractional durations", () => {
+  const first = Duration.fromObject({ hours: 4.2, minutes: 12, seconds: 2 }),
+    second = Duration.fromObject({ hours: 1, seconds: 6, milliseconds: 14 }),
+    result = first.minus(second);
+
+  expect(result.hours).toBeCloseTo(3.2, 8);
+  expect(result.minutes).toBe(12);
+  expect(result.seconds).toBe(-4);
+  expect(result.milliseconds).toBe(-14);
+});
+
 test("Duration#minus subtracts single values", () => {
   const first = Duration.fromObject({ hours: 4, minutes: 12, seconds: 2 }),
     result = first.minus({ minutes: 5 });
diff --git a/test/duration/parse.test.js b/test/duration/parse.test.js
index 22b0a314..a593e936 100644
--- a/test/duration/parse.test.js
+++ b/test/duration/parse.test.js
@@ -65,6 +65,24 @@ test("Duration.fromISO can parse fractions of seconds", () => {
   });
 });
 
+test("Duration.fromISO can parse fractions", () => {
+  expect(Duration.fromISO("P1.5Y").toObject()).toEqual({
+    years: 1.5,
+  });
+  expect(Duration.fromISO("P1.5M").toObject()).toEqual({
+    months: 1.5,
+  });
+  expect(Duration.fromISO("P1.5W").toObject()).toEqual({
+    weeks: 1.5,
+  });
+  expect(Duration.fromISO("P1.5D").toObject()).toEqual({
+    days: 1.5,
+  });
+  expect(Duration.fromISO("PT9.5H").toObject()).toEqual({
+    hours: 9.5,
+  });
+});
+
 const rejects = (s) => {
   expect(Duration.fromISO(s).isValid).toBe(false);
 };
diff --git a/test/duration/set.test.js b/test/duration/set.test.js
index fd63e91e..bd20ab75 100644
--- a/test/duration/set.test.js
+++ b/test/duration/set.test.js
@@ -20,6 +20,7 @@ test("Duration#set() sets the values", () => {
   expect(dur().set({ months: 2 }).months).toBe(2);
   expect(dur().set({ days: 2 }).days).toBe(2);
   expect(dur().set({ hours: 4 }).hours).toBe(4);
+  expect(dur().set({ hours: 4.5 }).hours).toBe(4.5);
   expect(dur().set({ minutes: 16 }).minutes).toBe(16);
   expect(dur().set({ seconds: 45 }).seconds).toBe(45);
   expect(dur().set({ milliseconds: 86 }).milliseconds).toBe(86);
diff --git a/test/duration/typecheck.test.js b/test/duration/typecheck.test.js
index cbc2a7af..4a30c2a6 100644
--- a/test/duration/typecheck.test.js
+++ b/test/duration/typecheck.test.js
@@ -6,7 +6,7 @@ import { Duration } from "../../src/luxon";
 // #isDuration
 //-------
 test("Duration#isDuration return true for valid duration", () => {
-  const dur = Duration.fromObject({ hours: 1 });
+  const dur = Duration.fromObject({ hours: 1, minutes: 4.5 });
   expect(Duration.isDuration(dur)).toBe(true);
 });
 
@@ -19,6 +19,7 @@ test("Duration#isDuration return false for primitives", () => {
   expect(Duration.isDuration({})).toBe(false);
   expect(Duration.isDuration({ hours: 60 })).toBe(false);
   expect(Duration.isDuration(1)).toBe(false);
+  expect(Duration.isDuration(1.1)).toBe(false);
   expect(Duration.isDuration("")).toBe(false);
   expect(Duration.isDuration(null)).toBe(false);
   expect(Duration.isDuration()).toBe(false);
diff --git a/test/duration/units.test.js b/test/duration/units.test.js
index da35d939..9d39df35 100644
--- a/test/duration/units.test.js
+++ b/test/duration/units.test.js
@@ -46,6 +46,14 @@ test("Duration#shiftTo deconstructs decimal inputs", () => {
   expect(dur.minutes).toBeCloseTo(18, 8);
 });
 
+test("Duration#shiftTo deconstructs in cascade and tacks decimal onto the end", () => {
+  const dur = Duration.fromObject({ hours: 1.17 }).shiftTo("hours", "minutes", "seconds");
+  expect(dur.isValid).toBe(true);
+  expect(dur.hours).toBe(1);
+  expect(dur.minutes).toBe(10);
+  expect(dur.seconds).toBeCloseTo(12, 8);
+});
+
 test("Duration#shiftTo maintains invalidity", () => {
   const dur = Duration.invalid("because").shiftTo("years");
   expect(dur.isValid).toBe(false);