From effe587bfb391792bef053098eeff41c68919c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Serkan=20=C3=96zel?= Date: Tue, 15 Feb 2022 11:28:32 +0300 Subject: [PATCH] Fix LocalDateTime.fromDate (#1180) LocalDateTime.fromDate using Date's month wrongly. This fixes that. It was throwing right away. I think we should backport this and release soon. Also adds fromDate to LocalDate and LocalTime. Date.UTC is deleted since I realized that they are not necessary. Tests pass without it. new Date() returns the local time of the system which is like LocalDateTime.now() in java. The issue happens due to the fact that we don't convert month field of JS Date into our month field. JS Date is 0-11 based and we use 1-12 in LocalDate. Therefore, we should have added 1 to the month field in fromDate constructor. If a user gives a date in january fromDate throws validation error. --- src/core/DateTimeClasses.ts | 71 +++++++++++++----- test/unit/core/DatetimeClasses.js | 121 +++++++++++++++++++++++++----- 2 files changed, 152 insertions(+), 40 deletions(-) diff --git a/src/core/DateTimeClasses.ts b/src/core/DateTimeClasses.ts index 123735ff7..d63dd7951 100644 --- a/src/core/DateTimeClasses.ts +++ b/src/core/DateTimeClasses.ts @@ -87,6 +87,27 @@ export class LocalTime { return new LocalTime(hours, minutes, seconds, nano); } + /** + * Constructs a new instance from Date. + * @param date must be a valid Date. `date.getTime()` should be not NaN + * @throws TypeError if the passed param is not a Date + * @throws RangeError if an invalid Date is passed + */ + static fromDate(date: Date): LocalTime { + if (!(date instanceof Date)) { + throw new TypeError('A Date is not passed'); + } + if (isNaN(date.getTime())) { + throw new RangeError('Invalid Date is passed.'); + } + return new LocalTime( + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds() * 1_000_000 + ); + } + /** * Returns the string representation of this local time. * @@ -221,6 +242,26 @@ export class LocalDate { return new LocalDate(yearNumber, monthNumber, dateNumber); } + /** + * Constructs a new instance from Date. + * @param date must be a valid Date. `date.getTime()` should be not NaN + * @throws TypeError if the passed param is not a Date + * @throws RangeError if an invalid Date is passed + */ + static fromDate(date: Date): LocalDate { + if (!(date instanceof Date)) { + throw new TypeError('A Date is not passed'); + } + if (isNaN(date.getTime())) { + throw new RangeError('Invalid Date is passed.'); + } + return new LocalDate( + date.getFullYear(), + date.getMonth() + 1, // month start with 0 in Date + date.getDate() + ); + } + /** * Returns the string representation of this local date. * @returns A string in the form yyyy:mm:dd. Values are zero padded from left @@ -278,21 +319,19 @@ export class LocalDateTime { */ asDate(): Date { return new Date( - Date.UTC( - this.localDate.year, - this.localDate.month - 1, // month start with 0 in Date - this.localDate.date, - this.localTime.hour, - this.localTime.minute, - this.localTime.second, - Math.floor(this.localTime.nano / 1_000_000) - ) + this.localDate.year, + this.localDate.month - 1, // month start with 0 in Date + this.localDate.date, + this.localTime.hour, + this.localTime.minute, + this.localTime.second, + Math.floor(this.localTime.nano / 1_000_000) ); } /** * Constructs a new instance from Date. - * @param date Must be a valid Date. So `date.getTime()` should be not NaN + * @param date must be a valid Date. `date.getTime()` should be not NaN * @throws TypeError if the passed param is not a Date * @throws RangeError if an invalid Date is passed */ @@ -303,15 +342,7 @@ export class LocalDateTime { if (isNaN(date.getTime())) { throw new RangeError('Invalid Date is passed.'); } - return LocalDateTime.from( - date.getUTCFullYear(), - date.getUTCMonth(), - date.getUTCDate(), - date.getUTCHours(), - date.getUTCMinutes(), - date.getUTCSeconds(), - date.getUTCMilliseconds() * 1_000_000 - ); + return new LocalDateTime(LocalDate.fromDate(date), LocalTime.fromDate(date)); } /** @@ -375,7 +406,7 @@ export class OffsetDateTime { /** * Constructs a new instance from Date and offset seconds. - * @param date Must be a valid Date. So `date.getTime()` should be not NaN + * @param date must be a valid Date. `date.getTime()` should be not NaN * @param offsetSeconds Offset in seconds, must be between [-64800, 64800] * @throws TypeError if a wrong type is passed as argument * @throws RangeError if an invalid argument value is passed diff --git a/test/unit/core/DatetimeClasses.js b/test/unit/core/DatetimeClasses.js index 8b5cb429b..60ecb1322 100644 --- a/test/unit/core/DatetimeClasses.js +++ b/test/unit/core/DatetimeClasses.js @@ -23,6 +23,7 @@ const { LocalDateTime, OffsetDateTime, } = require('../../../lib/core/DateTimeClasses'); +const { leftZeroPadInteger } = require('../../../lib/util/DateTimeUtil'); describe('DateTimeClassesTest', function () { describe('LocalTimeTest', function () { @@ -110,21 +111,43 @@ describe('DateTimeClassesTest', function () { (() => LocalTime.fromString(null)).should.throw(TypeError, 'String expected'); (() => LocalTime.fromString()).should.throw(TypeError, 'String expected'); }); + + it('should construct from fromDate correctly', function () { + const localTime1 = LocalTime.fromDate(new Date(2000, 2, 29, 0, 0, 0, 0)); + localTime1.toString().should.be.eq('00:00:00'); + const localTime2 = LocalTime.fromDate(new Date(2000, 0, 29, 2, 3, 4, 6)); + localTime2.toString().should.be.eq('02:03:04.006000000'); + }); + + it('should throw when constructed from fromDate with a non-date thing', function () { + const nonDateThings = [1, null, '', {}, [], function() {}, class A {}, LocalDateTime.fromDate(new Date())]; + nonDateThings.forEach(nonDateThing => { + (() => LocalTime.fromDate(nonDateThing)).should.throw(TypeError, 'A Date is not passed'); + }); + }); + + it('should throw when constructed from fromDate with an invalid date', function () { + const invalidDates = [new Date('aa'), new Date({}), new Date(undefined)]; + invalidDates.forEach(invalidDate => { + isNaN(invalidDate.getTime).should.be.true; + (() => LocalTime.fromDate(invalidDate)).should.throw(RangeError, 'Invalid Date is passed'); + }); + }); }); describe('LocalDateTest', function () { - it('should throw RangeError if year is not an integer between -999_999_999-999_999_999(inclusive)', - function () { - (() => new LocalDate(1e9, 1, 1)).should.throw(RangeError, 'Year'); - (() => new LocalDate(-1e9, 1, 1)).should.throw(RangeError, 'Year'); - (() => new LocalDate(1.1, 1, 1)).should.throw(RangeError, 'All arguments must be integers'); - (() => new LocalDate('1', 1, 1)).should.throw(TypeError, 'All arguments must be numbers'); - (() => new LocalDate({ 1: 1 }, 1, 1)).should.throw(TypeError, 'All arguments must be numbers'); - (() => new LocalDate([], 1, 1)).should.throw(TypeError, 'All arguments must be numbers'); - (() => new LocalDate(1e12, 1, 1)).should.throw(RangeError, 'Year'); - }); + it('should throw RangeError if year is not an integer between -999_999_999-999_999_999(inclusive)', function () { + (() => new LocalDate(1e9, 1, 1)).should.throw(RangeError, 'Year'); + (() => new LocalDate(-1e9, 1, 1)).should.throw(RangeError, 'Year'); + (() => new LocalDate(1.1, 1, 1)).should.throw(RangeError, 'All arguments must be integers'); + (() => new LocalDate('1', 1, 1)).should.throw(TypeError, 'All arguments must be numbers'); + (() => new LocalDate({ 1: 1 }, 1, 1)).should.throw(TypeError, 'All arguments must be numbers'); + (() => new LocalDate([], 1, 1)).should.throw(TypeError, 'All arguments must be numbers'); + (() => new LocalDate(1e12, 1, 1)).should.throw(RangeError, 'Year'); + }); - it('should throw RangeError if month is not an integer between 0-59(inclusive)', function () { + it('should throw RangeError if month is not an integer between 1-12(inclusive)', function () { (() => new LocalDate(1, -1, 1)).should.throw(RangeError, 'Month'); + (() => new LocalDate(1, 0, 1)).should.throw(RangeError, 'Month'); (() => new LocalDate(1, 1.1, 1)).should.throw(RangeError, 'All arguments must be integers'); (() => new LocalDate(1, 233, 1)).should.throw(RangeError, 'Month'); (() => new LocalDate(1, '1', 1)).should.throw(TypeError, 'All arguments must be numbers'); @@ -147,6 +170,8 @@ describe('DateTimeClassesTest', function () { }); it('should convert to string correctly', function () { + new LocalDate(999999999, 12, 31).toString().should.be.eq('999999999-12-31'); + new LocalDate(0, 1, 1).toString().should.be.eq('0000-01-01'); new LocalDate(2000, 2, 29).toString().should.be.eq('2000-02-29'); new LocalDate(2001, 2, 1).toString().should.be.eq('2001-02-01'); new LocalDate(35, 2, 28).toString().should.be.eq('0035-02-28'); @@ -181,6 +206,16 @@ describe('DateTimeClassesTest', function () { localtime5.year.should.be.eq(29999); localtime5.month.should.be.eq(3); localtime5.date.should.be.eq(29); + + const localtime6 = LocalDate.fromString('999999999-12-31'); + localtime6.year.should.be.eq(999999999); + localtime6.month.should.be.eq(12); + localtime6.date.should.be.eq(31); + + const localtime7 = LocalDate.fromString('0000-01-01'); + localtime7.year.should.be.eq(0); + localtime7.month.should.be.eq(1); + localtime7.date.should.be.eq(1); }); it('should throw RangeError on invalid string', function () { @@ -205,6 +240,32 @@ describe('DateTimeClassesTest', function () { (() => LocalDate.fromString(null)).should.throw(TypeError, 'String expected'); (() => LocalDate.fromString()).should.throw(TypeError, 'String expected'); }); + + it('should construct from fromDate correctly', function () { + const date1 = LocalDate.fromDate(new Date(2000, 2, 29, 2, 3, 4, 6)); + date1.toString().should.be.eq('2000-03-29'); + const date2 = LocalDate.fromDate(new Date(2000, 0, 29, 2, 3, 4, 6)); + date2.toString().should.be.eq('2000-01-29'); + const date3 = LocalDate.fromDate(new Date(-2000, 2, 29, 2, 3, 4, 6)); + date3.toString().should.be.eq('-2000-03-29'); + const date4 = LocalDate.fromDate(new Date(-2000, 0, 29, 2, 3, 4, 6)); + date4.toString().should.be.eq('-2000-01-29'); + }); + + it('should throw when constructed from fromDate with a non-date thing', function () { + const nonDateThings = [1, null, '', {}, [], function() {}, class A {}, LocalDateTime.fromDate(new Date())]; + nonDateThings.forEach(nonDateThing => { + (() => LocalDate.fromDate(nonDateThing)).should.throw(TypeError, 'A Date is not passed'); + }); + }); + + it('should throw when constructed from fromDate with an invalid date', function () { + const invalidDates = [new Date('aa'), new Date({}), new Date(undefined)]; + invalidDates.forEach(invalidDate => { + isNaN(invalidDate.getTime).should.be.true; + (() => LocalDate.fromDate(invalidDate)).should.throw(RangeError, 'Invalid Date is passed'); + }); + }); }); describe('LocalDateTimeTest', function () { it('should throw RangeError if local time is not valid', function () { @@ -272,7 +333,6 @@ describe('DateTimeClassesTest', function () { }); it('fromDate should throw RangeError if date is invalid', function () { - (() => LocalDateTime.fromDate(new Date(-1))).should.throw(RangeError, 'Invalid Date'); (() => LocalDateTime.fromDate(new Date('s'))).should.throw(RangeError, 'Invalid Date'); (() => LocalDateTime.fromDate(1, 1)).should.throw(TypeError, 'A Date is not passed'); (() => LocalDateTime.fromDate('s', 1)).should.throw(TypeError, 'A Date is not passed'); @@ -280,13 +340,23 @@ describe('DateTimeClassesTest', function () { }); it('should construct from fromDate correctly', function () { - const dateTime = LocalDateTime.fromDate(new Date(Date.UTC(2000, 2, 29, 2, 3, 4, 6))); - dateTime.toString().should.be.eq('2000-02-29T02:03:04.006000000'); + const dateTime1 = LocalDateTime.fromDate(new Date(2000, 2, 29, 2, 3, 4, 6)); + dateTime1.toString().should.be.eq('2000-03-29T02:03:04.006000000'); + const dateTime2 = LocalDateTime.fromDate(new Date(2000, 0, 29, 2, 3, 4, 6)); + dateTime2.toString().should.be.eq('2000-01-29T02:03:04.006000000'); }); it('should convert to date correctly', function () { const dateTime = new LocalDateTime(new LocalDate(2000, 2, 29), new LocalTime(2, 19, 4, 6000000)); - dateTime.asDate().toISOString().should.be.eq('2000-02-29T02:19:04.006Z'); + const asDate = dateTime.asDate(); + const date = leftZeroPadInteger(asDate.getDate(), 2); + const month = leftZeroPadInteger(asDate.getMonth() + 1, 2); // Date's month is 0-based + const year = leftZeroPadInteger(asDate.getFullYear(), 4); + const hours = leftZeroPadInteger(asDate.getHours(), 2); + const minutes = leftZeroPadInteger(asDate.getMinutes(), 2); + const seconds = leftZeroPadInteger(asDate.getSeconds(), 2); + + `${date}.${month}.${year} ${hours}:${minutes}:${seconds}`.should.be.eq('29.02.2000 02:19:04'); }); }); describe('OffsetDateTimeTest', function () { @@ -304,7 +374,6 @@ describe('DateTimeClassesTest', function () { }); it('fromDate should throw RangeError if date is invalid', function () { - (() => OffsetDateTime.fromDate(new Date(-1), 1)).should.throw(RangeError, 'Invalid Date'); (() => OffsetDateTime.fromDate(new Date('s'), 1)).should.throw(RangeError, 'Invalid Date'); (() => OffsetDateTime.fromDate(1, 1)).should.throw(TypeError, 'A Date is not passed'); (() => OffsetDateTime.fromDate('s', 1)).should.throw(TypeError, 'A Date is not passed'); @@ -320,8 +389,10 @@ describe('DateTimeClassesTest', function () { }); it('should construct from fromDate correctly', function () { - const dateTime3 = OffsetDateTime.fromDate(new Date(Date.UTC(2000, 2, 29, 2, 3, 4, 6)), 1800); - dateTime3.toString().should.be.eq('2000-02-29T02:03:04.006000000+00:30'); + const offsetDateTime1 = OffsetDateTime.fromDate(new Date(2000, 2, 29, 2, 3, 4, 6), 1800); + offsetDateTime1.toString().should.be.eq('2000-03-29T02:03:04.006000000+00:30'); + const offsetDateTime2 = OffsetDateTime.fromDate(new Date(2000, 0, 29, 2, 3, 4, 6), 1800); + offsetDateTime2.toString().should.be.eq('2000-01-29T02:03:04.006000000+00:30'); }); const dateTime1 = new OffsetDateTime( @@ -329,7 +400,18 @@ describe('DateTimeClassesTest', function () { ); it('should convert to date correctly', function () { - dateTime1.asDate().toISOString().should.be.eq('2000-02-29T02:02:24.006Z'); + const asDate = dateTime1.asDate(); + + const date = leftZeroPadInteger(asDate.getDate(), 2); + const month = leftZeroPadInteger(asDate.getMonth() + 1, 2); // Date's month is 0-based + const year = leftZeroPadInteger(asDate.getFullYear(), 4); + const hours = leftZeroPadInteger(asDate.getHours(), 2); + const minutes = leftZeroPadInteger(asDate.getMinutes(), 2); + const seconds = leftZeroPadInteger(asDate.getSeconds(), 2); + + `${date}.${month}.${year} ${hours}:${minutes}:${seconds}`.should.be.eq('29.02.2000 02:02:24'); + + asDate.getMilliseconds().should.be.equal(6); }); it('should convert to string correctly', function () { @@ -390,7 +472,6 @@ describe('DateTimeClassesTest', function () { offsetSeconds3.should.be.eq(0); - // Timezone info omitted, UTC should be assumed const offsetDateTime4 = OffsetDateTime.fromString('2021-04-15T07:33:04.914Z'); const offsetSeconds4 = offsetDateTime4.offsetSeconds; const localDateTime4 = offsetDateTime4.localDateTime;