Repository: moment/luxon
Pull request #1083 (2021-11-16 22:20:49)

alternative fix to #1082


Patch:
diff --git a/src/datetime.js b/src/datetime.js
index b04d8f21..eb044c2e 100644
--- a/src/datetime.js
+++ b/src/datetime.js
@@ -160,13 +160,14 @@ function adjustTime(inst, dur) {
 
 // helper useful in turning the results of parsing into real dates
 // by handling the zone options
-function parseDataToDateTime(parsed, parsedZone, opts, format, text) {
+function parseDataToDateTime(parsed, parsedZone, opts, format, text, specificOffset) {
   const { setZone, zone } = opts;
   if (parsed && Object.keys(parsed).length !== 0) {
     const interpretationZone = parsedZone || zone,
       inst = DateTime.fromObject(parsed, {
         ...opts,
         zone: interpretationZone,
+        specificOffset,
       });
     return setZone ? inst : inst.setZone(zone);
   } else {
@@ -643,7 +644,9 @@ export default class DateTime {
     }
 
     const tsNow = Settings.now(),
-      offsetProvis = zoneToUse.offset(tsNow),
+      offsetProvis = !isUndefined(opts.specificOffset)
+        ? opts.specificOffset
+        : zoneToUse.offset(tsNow),
       normalized = normalizeObject(obj, normalizeUnit),
       containsOrdinal = !isUndefined(normalized.ordinal),
       containsGregorYear = !isUndefined(normalized.year),
@@ -821,11 +824,11 @@ export default class DateTime {
         numberingSystem,
         defaultToEN: true,
       }),
-      [vals, parsedZone, invalid] = parseFromTokens(localeToUse, text, fmt);
+      [vals, parsedZone, specificOffset, invalid] = parseFromTokens(localeToUse, text, fmt);
     if (invalid) {
       return DateTime.invalid(invalid);
     } else {
-      return parseDataToDateTime(vals, parsedZone, opts, `format ${fmt}`, text);
+      return parseDataToDateTime(vals, parsedZone, opts, `format ${fmt}`, text, specificOffset);
     }
   }
 
diff --git a/src/impl/tokenParser.js b/src/impl/tokenParser.js
index e5a1270e..781ef42c 100644
--- a/src/impl/tokenParser.js
+++ b/src/impl/tokenParser.js
@@ -318,13 +318,17 @@ function dateTimeFromMatches(matches) {
     }
   };
 
-  let zone;
-  if (!isUndefined(matches.Z)) {
-    zone = new FixedOffsetZone(matches.Z);
-  } else if (!isUndefined(matches.z)) {
+  let zone = null;
+  let specificOffset;
+  if (!isUndefined(matches.z)) {
     zone = IANAZone.create(matches.z);
-  } else {
-    zone = null;
+  }
+
+  if (!isUndefined(matches.Z)) {
+    if (!zone) {
+      zone = new FixedOffsetZone(matches.Z);
+    }
+    specificOffset = matches.Z;
   }
 
   if (!isUndefined(matches.q)) {
@@ -356,7 +360,7 @@ function dateTimeFromMatches(matches) {
     return r;
   }, {});
 
-  return [vals, zone];
+  return [vals, zone, specificOffset];
 }
 
 let dummyDateTimeCache = null;
@@ -411,17 +415,19 @@ export function explainFromTokens(locale, input, format) {
     const [regexString, handlers] = buildRegex(units),
       regex = RegExp(regexString, "i"),
       [rawMatches, matches] = match(input, regex, handlers),
-      [result, zone] = matches ? dateTimeFromMatches(matches) : [null, null];
+      [result, zone, specificOffset] = matches
+        ? dateTimeFromMatches(matches)
+        : [null, null, undefined];
     if (hasOwnProperty(matches, "a") && hasOwnProperty(matches, "H")) {
       throw new ConflictingSpecificationError(
         "Can't include meridiem when specifying 24-hour format"
       );
     }
-    return { input, tokens, regex, rawMatches, matches, result, zone };
+    return { input, tokens, regex, rawMatches, matches, result, zone, specificOffset };
   }
 }
 
 export function parseFromTokens(locale, input, format) {
-  const { result, zone, invalidReason } = explainFromTokens(locale, input, format);
-  return [result, zone, invalidReason];
+  const { result, zone, specificOffset, invalidReason } = explainFromTokens(locale, input, format);
+  return [result, zone, specificOffset, invalidReason];
 }


Test patch:
diff --git a/test/datetime/tokenParse.test.js b/test/datetime/tokenParse.test.js
index 327222c0..a3e7239c 100644
--- a/test/datetime/tokenParse.test.js
+++ b/test/datetime/tokenParse.test.js
@@ -565,6 +565,131 @@ test("DateTime.fromFormat() with setZone parses fixed offsets and sets it", () =
   }
 });
 
+test("DateTime.fromFormat() prefers IANA zone id", () => {
+  const i = DateTime.fromFormat(
+    "2021-11-12T09:07:13.000+08:00[Australia/Perth]",
+    "yyyy-MM-dd'T'HH:mm:ss.SSSZZ[z]",
+    { setZone: true }
+  );
+  expect(i.isValid).toBe(true);
+  expect(i.year).toBe(2021);
+  expect(i.month).toBe(11);
+  expect(i.day).toBe(12);
+  expect(i.hour).toBe(9);
+  expect(i.minute).toBe(7);
+  expect(i.second).toBe(13);
+  expect(i.millisecond).toBe(0);
+  expect(i.offset).toBe(480); //+08:00
+  expect(i.zoneName).toBe("Australia/Perth");
+});
+
+test("DateTime.fromFormat() ignores numerical offsets when they conflict with the zone", () => {
+  // +11:00 is not a valid offset for the Australia/Perth time zone
+  const i = DateTime.fromFormat(
+    "2021-11-12T09:07:13.000+11:00[Australia/Perth]",
+    "yyyy-MM-dd'T'HH:mm:ss.SSSZZ[z]",
+    { setZone: true }
+  );
+  expect(i.isValid).toBe(true);
+  expect(i.year).toBe(2021);
+  expect(i.month).toBe(11);
+  expect(i.day).toBe(12);
+  expect(i.hour).toBe(9);
+  expect(i.minute).toBe(7);
+  expect(i.second).toBe(13);
+  expect(i.millisecond).toBe(0);
+  expect(i.offset).toBe(480); //+08:00
+  expect(i.zoneName).toBe("Australia/Perth");
+});
+
+test("DateTime.fromFormat() ignores numerical offsets when they are are wrong right now", () => {
+  // DST is not in effect at this timestamp, so +10:00 is the correct offset
+  const i = DateTime.fromFormat(
+    "2021-10-03T01:30:00.000+11:00[Australia/Sydney]",
+    "yyyy-MM-dd'T'HH:mm:ss.SSSZZ[z]",
+    { setZone: true }
+  );
+  expect(i.isValid).toBe(true);
+  expect(i.year).toBe(2021);
+  expect(i.month).toBe(10);
+  expect(i.day).toBe(3);
+  expect(i.hour).toBe(1);
+  expect(i.minute).toBe(30);
+  expect(i.second).toBe(0);
+  expect(i.millisecond).toBe(0);
+  expect(i.offset).toBe(600); //+10:00
+  expect(i.zoneName).toBe("Australia/Sydney");
+});
+
+test("DateTime.fromFormat() maintains offset that belongs to time zone during overlap", () => {
+  // On this day, 02:30 exists for both offsets, due to DST ending.
+  let i = DateTime.fromFormat(
+    "2021-04-04T02:30:00.000+11:00[Australia/Sydney]",
+    "yyyy-MM-dd'T'HH:mm:ss.SSSZZ[z]",
+    { setZone: true }
+  );
+  expect(i.isValid).toBe(true);
+  expect(i.year).toBe(2021);
+  expect(i.month).toBe(4);
+  expect(i.day).toBe(4);
+  expect(i.hour).toBe(2);
+  expect(i.minute).toBe(30);
+  expect(i.second).toBe(0);
+  expect(i.millisecond).toBe(0);
+  expect(i.offset).toBe(660); //+11:00
+  expect(i.zoneName).toBe("Australia/Sydney");
+
+  i = DateTime.fromFormat(
+    "2021-04-04T02:30:00.000+10:00[Australia/Sydney]",
+    "yyyy-MM-dd'T'HH:mm:ss.SSSZZ[z]",
+    { setZone: true }
+  );
+  expect(i.isValid).toBe(true);
+  expect(i.year).toBe(2021);
+  expect(i.month).toBe(4);
+  expect(i.day).toBe(4);
+  expect(i.hour).toBe(2);
+  expect(i.minute).toBe(30);
+  expect(i.second).toBe(0);
+  expect(i.millisecond).toBe(0);
+  expect(i.offset).toBe(600); //+10:00
+  expect(i.zoneName).toBe("Australia/Sydney");
+});
+
+test("DateTime.format() uses local zone when setZone is false and offset in input", () => {
+  const i = DateTime.fromFormat("2021-11-12T09:07:13.000+08:00", "yyyy-MM-dd'T'HH:mm:ss.SSSZZ", {
+    setZone: false,
+  });
+  expect(i.isValid).toBe(true);
+  expect(i.year).toBe(2021);
+  expect(i.month).toBe(11);
+  expect(i.day).toBe(11);
+  expect(i.hour).toBe(20);
+  expect(i.minute).toBe(7);
+  expect(i.second).toBe(13);
+  expect(i.millisecond).toBe(0);
+  expect(i.offset).toBe(-300);
+  expect(i.zoneName).toBe("America/New_York");
+});
+
+test("DateTime.format() uses local zone when setZone is false and zone id in input", () => {
+  const i = DateTime.fromFormat(
+    "2021-11-12T09:07:13.000+08:00[Australia/Perth]",
+    "yyyy-MM-dd'T'HH:mm:ss.SSSZZ[z]",
+    { setZone: false }
+  );
+  expect(i.isValid).toBe(true);
+  expect(i.year).toBe(2021);
+  expect(i.month).toBe(11);
+  expect(i.day).toBe(11);
+  expect(i.hour).toBe(20);
+  expect(i.minute).toBe(7);
+  expect(i.second).toBe(13);
+  expect(i.millisecond).toBe(0);
+  expect(i.offset).toBe(-300);
+  expect(i.zoneName).toBe("America/New_York");
+});
+
 test("DateTime.fromFormat() parses localized macro tokens", () => {
   const formatGroups = [
     {