Skip to content

Commit e269ab6

Browse files
authored
Add property-based testing to temporal-types conversion (#997)
Add this type of testing to `.toStandardDate()` ('should be the reverse operation of fromStandardDate but losing time information') helps to cover corner cases and solve special cases such: * Negative date time not being serialised correctly in the iso standard. Years should always have 6 digits and the signal in front for working correctly with negative years and high numbers. This also avoids the year 2000 problem. See, https://en.wikipedia.org/wiki/ISO_8601 * `Date.fromStandardDate` factory was not taking in consideration the `seconds` contribution in the timezone offset. This is not a quite common scenario, but there are dates with timezone offset of for example `50 minutes` and `20 seconds`. * Fix `Date.toStandardDate` for dates with offsets of seconds. Javascript Date constructor doesn't create dates from iso strings with seconds in the offset. For instance, `new Date("2010-01-12T14:44:53+00:00:10")`. So, the date should be re-created from the UTC timestamp.
1 parent b7e2198 commit e269ab6

File tree

7 files changed

+169
-54
lines changed

7 files changed

+169
-54
lines changed

packages/core/package-lock.json

+42
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"esdoc": "^1.1.0",
3535
"esdoc-importpath-plugin": "^1.0.2",
3636
"esdoc-standard-plugin": "^1.0.0",
37+
"fast-check": "^3.1.3",
3738
"jest": "^27.5.1",
3839
"ts-jest": "^27.1.4",
3940
"ts-node": "^10.3.0",

packages/core/src/internal/temporal-util.ts

+48-14
Original file line numberDiff line numberDiff line change
@@ -288,16 +288,7 @@ export function dateToIsoString (
288288
month: NumberOrInteger | string,
289289
day: NumberOrInteger | string
290290
): string {
291-
year = int(year)
292-
const isNegative = year.isNegative()
293-
if (isNegative) {
294-
year = year.multiply(-1)
295-
}
296-
let yearString = formatNumber(year, 4)
297-
if (isNegative) {
298-
yearString = '-' + yearString
299-
}
300-
291+
const yearString = formatYear(year)
301292
const monthString = formatNumber(month, 2)
302293
const dayString = formatNumber(day, 2)
303294
return `${yearString}-${monthString}-${dayString}`
@@ -313,6 +304,25 @@ export function isoStringToStandardDate (isoString: string): Date {
313304
return new Date(isoString)
314305
}
315306

307+
/**
308+
* Convert the given utc timestamp to a JavaScript Date object
309+
*
310+
* @param {number} utc Timestamp in UTC
311+
* @returns {Date} the date
312+
*/
313+
export function toStandardDate (utc: number): Date {
314+
return new Date(utc)
315+
}
316+
317+
/**
318+
* Shortcut for creating a new StandardDate
319+
* @param date
320+
* @returns {Date} the standard date
321+
*/
322+
export function newDate (date: string | number | Date): Date {
323+
return new Date(date)
324+
}
325+
316326
/**
317327
* Get the total number of nanoseconds from the milliseconds of the given standard JavaScript date and optional nanosecond part.
318328
* @param {global.Date} standardDate the standard JavaScript date.
@@ -341,11 +351,14 @@ export function totalNanoseconds (
341351
* @return {number} the time zone offset in seconds.
342352
*/
343353
export function timeZoneOffsetInSeconds (standardDate: Date): number {
354+
const secondsPortion = standardDate.getSeconds() >= standardDate.getUTCSeconds()
355+
? standardDate.getSeconds() - standardDate.getUTCSeconds()
356+
: standardDate.getSeconds() - standardDate.getUTCSeconds() + 60
344357
const offsetInMinutes = standardDate.getTimezoneOffset()
345358
if (offsetInMinutes === 0) {
346-
return 0
359+
return 0 + secondsPortion
347360
}
348-
return -1 * offsetInMinutes * SECONDS_PER_MINUTE
361+
return -1 * offsetInMinutes * SECONDS_PER_MINUTE + secondsPortion
349362
}
350363

351364
/**
@@ -576,14 +589,30 @@ function formatNanosecond (value: NumberOrInteger | string): string {
576589
return value.equals(0) ? '' : '.' + formatNumber(value, 9)
577590
}
578591

592+
/**
593+
*
594+
* @param {Integer|number|string} year The year to be formatted
595+
* @return {string} formatted year
596+
*/
597+
function formatYear (year: NumberOrInteger | string): string {
598+
const yearInteger = int(year)
599+
if (yearInteger.isNegative() || yearInteger.greaterThan(9999)) {
600+
return formatNumber(yearInteger, 6, { usePositiveSign: true })
601+
}
602+
return formatNumber(yearInteger, 4)
603+
}
604+
579605
/**
580606
* @param {Integer|number|string} num the number to format.
581607
* @param {number} [stringLength=undefined] the string length to left-pad to.
582608
* @return {string} formatted and possibly left-padded number as string.
583609
*/
584610
function formatNumber (
585611
num: NumberOrInteger | string,
586-
stringLength?: number
612+
stringLength?: number,
613+
params?: {
614+
usePositiveSign?: boolean
615+
}
587616
): string {
588617
num = int(num)
589618
const isNegative = num.isNegative()
@@ -598,7 +627,12 @@ function formatNumber (
598627
numString = '0' + numString
599628
}
600629
}
601-
return isNegative ? '-' + numString : numString
630+
if (isNegative) {
631+
return '-' + numString
632+
} else if (params?.usePositiveSign === true) {
633+
return '+' + numString
634+
}
635+
return numString
602636
}
603637

604638
function add (x: NumberOrInteger, y: number): NumberOrInteger {

packages/core/src/temporal-types.ts

+27-10
Original file line numberDiff line numberDiff line change
@@ -667,16 +667,7 @@ export class DateTime<T extends NumberOrInteger = Integer> {
667667
* @throws {Error} If the time zone offset is not defined in the object.
668668
*/
669669
toStandardDate (): StandardDate {
670-
if (this.timeZoneOffsetSeconds === undefined) {
671-
throw new Error('Requires DateTime created with time zone offset')
672-
}
673-
return util.isoStringToStandardDate(
674-
// the timezone name should be removed from the
675-
// string, otherwise the javascript parse doesn't
676-
// read the datetime correctly
677-
this.toString().replace(
678-
this.timeZoneId != null ? `[${this.timeZoneId}]` : '', '')
679-
)
670+
return util.toStandardDate(this._toUTC())
680671
}
681672

682673
/**
@@ -703,6 +694,32 @@ export class DateTime<T extends NumberOrInteger = Integer> {
703694

704695
return localDateTimeStr + timeOffset + timeZoneStr
705696
}
697+
698+
/**
699+
* @private
700+
* @returns {number}
701+
*/
702+
private _toUTC (): number {
703+
if (this.timeZoneOffsetSeconds === undefined) {
704+
throw new Error('Requires DateTime created with time zone offset')
705+
}
706+
const epochSecond = util.localDateTimeToEpochSecond(
707+
this.year,
708+
this.month,
709+
this.day,
710+
this.hour,
711+
this.minute,
712+
this.second,
713+
this.nanosecond
714+
)
715+
716+
const utcSecond = epochSecond.subtract(this.timeZoneOffsetSeconds ?? 0)
717+
718+
return int(utcSecond)
719+
.multiply(1000)
720+
.add(int(this.nanosecond).div(1_000_000))
721+
.toNumber()
722+
}
706723
}
707724

708725
Object.defineProperty(

packages/core/test/temporal-types.test.ts

+42-21
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@
1919

2020
import { StandardDate } from '../src/graph-types'
2121
import { LocalDateTime, Date, DateTime } from '../src/temporal-types'
22+
import { temporalUtil } from '../src/internal'
23+
import fc from 'fast-check'
24+
25+
const MIN_UTC_IN_MS = -8_640_000_000_000_000
26+
const MAX_UTC_IN_MS = 8_640_000_000_000_000
27+
const ONE_DAY_IN_MS = 86_400_000
2228

2329
describe('Date', () => {
2430
describe('.toStandardDate()', () => {
@@ -33,15 +39,26 @@ describe('Date', () => {
3339
})
3440

3541
it('should be the reverse operation of fromStandardDate but losing time information', () => {
36-
const standardDate = new global.Date()
37-
38-
const date = Date.fromStandardDate(standardDate)
39-
const receivedDate = date.toStandardDate()
40-
41-
// Setting 00:00:00:000 UTC
42-
standardDate.setHours(0, -1 * standardDate.getTimezoneOffset(), 0, 0)
43-
44-
expect(receivedDate).toEqual(standardDate)
42+
fc.assert(
43+
fc.property(
44+
fc.date({
45+
max: temporalUtil.newDate(MAX_UTC_IN_MS - ONE_DAY_IN_MS),
46+
min: temporalUtil.newDate(MIN_UTC_IN_MS + ONE_DAY_IN_MS)
47+
}),
48+
standardDate => {
49+
const date = Date.fromStandardDate(standardDate)
50+
const receivedDate = date.toStandardDate()
51+
52+
const adjustedDateTime = temporalUtil.newDate(standardDate)
53+
adjustedDateTime.setHours(0, offset(receivedDate))
54+
55+
expect(receivedDate.getFullYear()).toEqual(adjustedDateTime.getFullYear())
56+
expect(receivedDate.getMonth()).toEqual(adjustedDateTime.getMonth())
57+
expect(receivedDate.getDate()).toEqual(adjustedDateTime.getDate())
58+
expect(receivedDate.getHours()).toEqual(adjustedDateTime.getHours())
59+
expect(receivedDate.getMinutes()).toEqual(adjustedDateTime.getMinutes())
60+
})
61+
)
4562
})
4663
})
4764
})
@@ -63,12 +80,14 @@ describe('LocalDateTime', () => {
6380
})
6481

6582
it('should be the reverse operation of fromStandardDate', () => {
66-
const date = new global.Date()
67-
68-
const localDatetime = LocalDateTime.fromStandardDate(date)
69-
const receivedDate = localDatetime.toStandardDate()
70-
71-
expect(receivedDate).toEqual(date)
83+
fc.assert(
84+
fc.property(fc.date(), (date) => {
85+
const localDatetime = LocalDateTime.fromStandardDate(date)
86+
const receivedDate = localDatetime.toStandardDate()
87+
88+
expect(receivedDate).toEqual(date)
89+
})
90+
)
7291
})
7392
})
7493
})
@@ -135,12 +154,14 @@ describe('DateTime', () => {
135154
})
136155

137156
it('should be the reverse operation of fromStandardDate', () => {
138-
const date = new global.Date()
139-
140-
const datetime = DateTime.fromStandardDate(date)
141-
const receivedDate = datetime.toStandardDate()
142-
143-
expect(receivedDate).toEqual(date)
157+
fc.assert(
158+
fc.property(fc.date(), (date) => {
159+
const datetime = DateTime.fromStandardDate(date)
160+
const receivedDate = datetime.toStandardDate()
161+
162+
expect(receivedDate).toEqual(date)
163+
})
164+
)
144165
})
145166
})
146167
})

packages/neo4j-driver/test/internal/temporal-util.test.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -90,17 +90,17 @@ describe('#unit temporal-util', () => {
9090
it('should convert date to ISO string', () => {
9191
expect(util.dateToIsoString(90, 2, 5)).toEqual('0090-02-05')
9292
expect(util.dateToIsoString(int(1), 1, int(1))).toEqual('0001-01-01')
93-
expect(util.dateToIsoString(-123, int(12), int(23))).toEqual('-0123-12-23')
93+
expect(util.dateToIsoString(-123, int(12), int(23))).toEqual('-000123-12-23')
9494
expect(util.dateToIsoString(int(-999), int(9), int(10))).toEqual(
95-
'-0999-09-10'
95+
'-000999-09-10'
9696
)
9797
expect(util.dateToIsoString(1999, 12, 19)).toEqual('1999-12-19')
9898
expect(util.dateToIsoString(int(2023), int(8), int(16))).toEqual(
9999
'2023-08-16'
100100
)
101-
expect(util.dateToIsoString(12345, 12, 31)).toEqual('12345-12-31')
101+
expect(util.dateToIsoString(12345, 12, 31)).toEqual('+012345-12-31')
102102
expect(util.dateToIsoString(int(19191919), int(11), int(30))).toEqual(
103-
'19191919-11-30'
103+
'+19191919-11-30'
104104
)
105105
expect(util.dateToIsoString(-909090, 9, 9)).toEqual('-909090-09-09')
106106
expect(util.dateToIsoString(int(-888999777), int(7), int(26))).toEqual(

packages/neo4j-driver/test/temporal-types.test.js

+5-5
Original file line numberDiff line numberDiff line change
@@ -590,16 +590,16 @@ describe('#integration temporal-types', () => {
590590
it('should convert Date to ISO string', () => {
591591
expect(date(2015, 10, 12).toString()).toEqual('2015-10-12')
592592
expect(date(881, 1, 1).toString()).toEqual('0881-01-01')
593-
expect(date(-999, 12, 24).toString()).toEqual('-0999-12-24')
594-
expect(date(-9, 1, 1).toString()).toEqual('-0009-01-01')
593+
expect(date(-999, 12, 24).toString()).toEqual('-000999-12-24')
594+
expect(date(-9, 1, 1).toString()).toEqual('-000009-01-01')
595595
}, 60000)
596596

597597
it('should convert LocalDateTime to ISO string', () => {
598598
expect(localDateTime(1992, 11, 8, 9, 42, 17, 22).toString()).toEqual(
599599
'1992-11-08T09:42:17.000000022'
600600
)
601601
expect(localDateTime(-10, 7, 15, 8, 15, 33, 500).toString()).toEqual(
602-
'-0010-07-15T08:15:33.000000500'
602+
'-000010-07-15T08:15:33.000000500'
603603
)
604604
expect(localDateTime(0, 1, 1, 0, 0, 0, 1).toString()).toEqual(
605605
'0000-01-01T00:00:00.000000001'
@@ -615,7 +615,7 @@ describe('#integration temporal-types', () => {
615615
).toEqual('0001-02-03T04:05:06.000000007-13:42:56')
616616
expect(
617617
dateTimeWithZoneOffset(-3, 3, 9, 9, 33, 27, 999000, 15300).toString()
618-
).toEqual('-0003-03-09T09:33:27.000999000+04:15')
618+
).toEqual('-000003-03-09T09:33:27.000999000+04:15')
619619
}, 60000)
620620

621621
it('should convert DateTime with time zone id to ISO-like string', () => {
@@ -642,7 +642,7 @@ describe('#integration temporal-types', () => {
642642
123,
643643
'Asia/Yangon'
644644
).toString()
645-
).toEqual('-30455-05-05T12:24:10.000000123[Asia/Yangon]')
645+
).toEqual('-030455-05-05T12:24:10.000000123[Asia/Yangon]')
646646
}, 60000)
647647

648648
it('should expose local time components in time', () => {

0 commit comments

Comments
 (0)