Skip to content

Commit f63a621

Browse files
authored
Improve the available precision for NSDate to Instant conversion (#448)
Turns out, a roundtrip in the near times only loses 0.5 microseconds of precision, whereas at the far ends of the supported range, we lose 10 microseconds. It is a lot, but much less than the microsecond precision loss that happens when an NSDate is formatted (which led us to this implementation in the first place). Fixes #427
1 parent 670a914 commit f63a621

File tree

2 files changed

+70
-35
lines changed

2 files changed

+70
-35
lines changed

core/darwin/src/Converters.kt

+8-5
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
package kotlinx.datetime
99

1010
import kotlinx.cinterop.*
11+
import kotlinx.datetime.internal.NANOS_PER_ONE
1112
import platform.Foundation.*
1213

1314
/**
@@ -27,14 +28,16 @@ public fun Instant.toNSDate(): NSDate {
2728
/**
2829
* Converts the [NSDate] to the corresponding [Instant].
2930
*
30-
* Even though Darwin only uses millisecond precision, it is possible that [date] uses larger resolution, storing
31-
* microseconds or even nanoseconds. In this case, the sub-millisecond parts of [date] are rounded to the nearest
32-
* millisecond, given that they are likely to be conversion artifacts.
31+
* Note that the [NSDate] stores a [Double] value.
32+
* This means that the results of this conversion may be imprecise.
33+
* For example, if the [NSDate] only has millisecond or microsecond precision logically,
34+
* due to conversion artifacts in [Double] values, the result may include non-zero nanoseconds.
3335
*/
3436
public fun NSDate.toKotlinInstant(): Instant {
3537
val secs = timeIntervalSince1970()
36-
val millis = secs * 1000 + if (secs > 0) 0.5 else -0.5
37-
return Instant.fromEpochMilliseconds(millis.toLong())
38+
val fullSeconds = secs.toLong()
39+
val nanos = (secs - fullSeconds) * NANOS_PER_ONE
40+
return Instant.fromEpochSeconds(fullSeconds, nanos.toLong())
3841
}
3942

4043
/**

core/darwin/test/ConvertersTest.kt

+62-30
Original file line numberDiff line numberDiff line change
@@ -6,40 +6,44 @@ package kotlinx.datetime.test
66

77
import kotlinx.datetime.*
88
import kotlinx.cinterop.*
9+
import kotlinx.datetime.internal.NANOS_PER_ONE
910
import platform.Foundation.*
1011
import kotlin.math.*
1112
import kotlin.random.*
1213
import kotlin.test.*
1314

1415
class ConvertersTest {
1516

16-
private val dateFormatter = NSDateFormatter()
17-
private val locale = NSLocale.localeWithLocaleIdentifier("en_US_POSIX")
18-
private val gregorian = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierGregorian)!!
17+
private val isoCalendar = NSCalendar.calendarWithIdentifier(NSCalendarIdentifierISO8601)!!
18+
@OptIn(UnsafeNumber::class)
1919
private val utc = NSTimeZone.timeZoneForSecondsFromGMT(0)
2020

21-
init {
22-
dateFormatter.locale = locale
23-
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ"
24-
dateFormatter.calendar = gregorian
25-
dateFormatter.timeZone = utc
21+
@Test
22+
fun testToFromNSDateNow() {
23+
// as of writing, the max difference in such round-trips across 10^8 iterations was 120 nanoseconds,
24+
// so we allow for four times that
25+
repeat(10) {
26+
val now = Clock.System.now()
27+
assertEqualUpToHalfMicrosecond(now, now.toNSDate().toKotlinInstant())
28+
}
29+
repeat(10) {
30+
val nowInSwift = NSDate()
31+
assertEqualUpToHalfMicrosecond(nowInSwift, nowInSwift.toKotlinInstant().toNSDate())
32+
}
2633
}
2734

2835
@Test
2936
fun testToFromNSDate() {
30-
// The first day on the Gregorian calendar. The day before it is 1582-10-04 in the Julian calendar.
31-
val gregorianCalendarStart = Instant.parse("1582-10-15T00:00:00Z").toEpochMilliseconds()
32-
val minBoundMillis = (NSDate.distantPast.timeIntervalSince1970 * 1000 + 0.5).toLong()
33-
val maxBoundMillis = (NSDate.distantFuture.timeIntervalSince1970 * 1000 - 0.5).toLong()
37+
val secondsBound = NSDate.distantPast.timeIntervalSince1970.toLong() until
38+
NSDate.distantFuture.timeIntervalSince1970.toLong()
3439
repeat(STRESS_TEST_ITERATIONS) {
35-
val millis = Random.nextLong(minBoundMillis, maxBoundMillis)
36-
val instant = Instant.fromEpochMilliseconds(millis)
37-
val date = instant.toNSDate()
38-
// Darwin's date printer dynamically adjusts to switching between calendars, while our Instant does not.
39-
if (millis >= gregorianCalendarStart) {
40-
assertEquals(instant, Instant.parse(dateFormatter.stringFromDate(date)))
41-
}
42-
assertEquals(instant, date.toKotlinInstant())
40+
val seconds = Random.nextLong(secondsBound)
41+
val nanos = Random.nextInt(0, NANOS_PER_ONE)
42+
val instant = Instant.fromEpochSeconds(seconds, nanos)
43+
// at most 6 microseconds difference was observed in 10^8 iterations
44+
assertEqualUpToTenMicroseconds(instant, instant.toNSDate().toKotlinInstant())
45+
// while here, no difference at all was observed
46+
assertEqualUpToOneNanosecond(instant.toNSDate(), instant.toNSDate().toKotlinInstant().toNSDate())
4347
}
4448
}
4549

@@ -79,32 +83,60 @@ class ConvertersTest {
7983

8084
@Test
8185
fun localDateToNSDateComponentsTest() {
82-
val date = LocalDate.parse("2019-02-04")
86+
val date = LocalDate(2019, 2, 4)
8387
val components = date.toNSDateComponents()
8488
components.timeZone = utc
85-
val nsDate = gregorian.dateFromComponents(components)!!
86-
val formatter = NSDateFormatter()
87-
formatter.timeZone = utc
88-
formatter.dateFormat = "yyyy-MM-dd"
89+
val nsDate = isoCalendar.dateFromComponents(components)!!
90+
val formatter = NSDateFormatter().apply {
91+
timeZone = utc
92+
dateFormat = "yyyy-MM-dd"
93+
}
8994
assertEquals("2019-02-04", formatter.stringFromDate(nsDate))
9095
}
9196

9297
@Test
9398
fun localDateTimeToNSDateComponentsTest() {
94-
val str = "2019-02-04T23:59:30.543"
95-
val dateTime = LocalDateTime.parse(str)
99+
val dateTime = LocalDate(2019, 2, 4).atTime(23, 59, 30, 123456000)
96100
val components = dateTime.toNSDateComponents()
97101
components.timeZone = utc
98-
val nsDate = gregorian.dateFromComponents(components)!!
99-
assertEquals(str + "Z", dateFormatter.stringFromDate(nsDate))
102+
val nsDate = isoCalendar.dateFromComponents(components)!!
103+
assertEqualUpToHalfMicrosecond(dateTime.toInstant(TimeZone.UTC), nsDate.toKotlinInstant())
100104
}
101105

102-
@OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
106+
@OptIn(ExperimentalForeignApi::class, UnsafeNumber::class)
103107
private fun zoneOffsetCheck(timeZone: FixedOffsetTimeZone, hours: Int, minutes: Int) {
104108
val nsTimeZone = timeZone.toNSTimeZone()
105109
val kotlinTimeZone = nsTimeZone.toKotlinTimeZone()
106110
assertEquals(hours * 3600 + minutes * 60, nsTimeZone.secondsFromGMT.convert())
107111
assertIs<FixedOffsetTimeZone>(kotlinTimeZone)
108112
assertEquals(timeZone.offset, kotlinTimeZone.offset)
109113
}
114+
115+
private fun assertEqualUpToTenMicroseconds(instant1: Instant, instant2: Instant) {
116+
if ((instant1 - instant2).inWholeMicroseconds.absoluteValue > 10) {
117+
throw AssertionError("Expected $instant1 to be equal to $instant2 up to 10 microseconds")
118+
}
119+
}
120+
121+
private fun assertEqualUpToHalfMicrosecond(instant1: Instant, instant2: Instant) {
122+
if ((instant1 - instant2).inWholeNanoseconds.absoluteValue > 500) {
123+
throw AssertionError("Expected $instant1 to be equal to $instant2 up to 0.5 microseconds")
124+
}
125+
}
126+
127+
private fun assertEqualUpToHalfMicrosecond(date1: NSDate, date2: NSDate) {
128+
val difference = abs(date1.timeIntervalSinceDate(date2) * 1000000)
129+
if (difference > 0.5) {
130+
throw AssertionError("Expected $date1 to be equal to $date2 up to 0.5 microseconds, " +
131+
"but the difference was $difference microseconds")
132+
}
133+
}
134+
135+
private fun assertEqualUpToOneNanosecond(date1: NSDate, date2: NSDate) {
136+
val difference = abs(date1.timeIntervalSinceDate(date2) * 1000000000)
137+
if (difference > 1) {
138+
throw AssertionError("Expected $date1 to be equal to $date2 up to 1 nanosecond, " +
139+
"but the difference was $difference microseconds")
140+
}
141+
}
110142
}

0 commit comments

Comments
 (0)