Skip to content

Commit e0c4de7

Browse files
committed
adding tests to ensure consistency between duration and instant parsing
1 parent abe03dc commit e0c4de7

File tree

2 files changed

+285
-18
lines changed
  • core/src

2 files changed

+285
-18
lines changed

core/src/main/kotlin/org/jetbrains/kotlinx/dataframe/impl/CanParseUtils.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,8 @@ internal fun javaDurationCanParse(value: String): Boolean = isoDurationRegex.mat
9494
* regex from [java.time.Duration.Lazy.PATTERN], it represents the ISO-8601 duration format.
9595
*/
9696
private val isoDurationRegex = Regex(
97-
pattern = "[-+]?P?:[-+]?[0-9]+D?T?:[-+]?[0-9]+H??:[-+]?[0-9]+M??:[-+]?[0-9]+?:[.,][0-9]{0,9}?S??",
98-
option = RegexOption.IGNORE_CASE,
97+
"""([-+]?)P(?:([-+]?[0-9]+)D)?(T(?:([-+]?[0-9]+)H)?(?:([-+]?[0-9]+)M)?(?:([-+]?[0-9]+)(?:[.,]([0-9]{0,9}))?S)?)?""",
98+
RegexOption.IGNORE_CASE,
9999
)
100100

101101
/**
@@ -121,7 +121,6 @@ private fun durationUnitByIsoCharOrNull(isoChar: Char, isTimeComponent: Boolean)
121121
!isTimeComponent -> {
122122
when (isoChar) {
123123
'D' -> DurationUnit.DAYS
124-
125124
else -> null
126125
}
127126
}

core/src/test/kotlin/org/jetbrains/kotlinx/dataframe/api/parse.kt

Lines changed: 283 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,15 @@ import org.junit.Test
2020
import java.util.Locale
2121
import kotlin.random.Random
2222
import kotlin.reflect.typeOf
23+
import kotlin.time.Duration
2324
import kotlin.time.Duration.Companion.days
2425
import kotlin.time.Duration.Companion.hours
26+
import kotlin.time.Duration.Companion.microseconds
27+
import kotlin.time.Duration.Companion.milliseconds
2528
import kotlin.time.Duration.Companion.minutes
29+
import kotlin.time.Duration.Companion.nanoseconds
2630
import kotlin.time.Duration.Companion.seconds
31+
import java.time.Duration as JavaDuration
2732
import java.time.Instant as JavaInstant
2833

2934
class ParseTests {
@@ -154,22 +159,23 @@ class ParseTests {
154159

155160
@Test
156161
fun `can parse instants`() {
157-
val instantParser = Parsers[typeOf<Instant>()]!!
158-
val javaInstantParser = Parsers[typeOf<JavaInstant>()]!!
162+
val instantParser = Parsers[typeOf<Instant>()]!!.applyOptions(null)
163+
val javaInstantParser = Parsers[typeOf<JavaInstant>()]!!.applyOptions(null)
159164

160165
// from the kotlinx-datetime tests, java instants treat leap seconds etc. like this
161-
fun parseInstantLikeJavaDoesOrNull(input: String): Instant? = catchSilent {
162-
DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET.parseOrNull(input)?.apply {
163-
when {
164-
hour == 24 && minute == 0 && second == 0 && nanosecond == 0 -> {
165-
setDate(toLocalDate().plus(1, DateTimeUnit.DAY))
166-
hour = 0
166+
fun parseInstantLikeJavaDoesOrNull(input: String): Instant? =
167+
catchSilent {
168+
DateTimeComponents.Formats.ISO_DATE_TIME_OFFSET.parseOrNull(input)?.apply {
169+
when {
170+
hour == 24 && minute == 0 && second == 0 && nanosecond == 0 -> {
171+
setDate(toLocalDate().plus(1, DateTimeUnit.DAY))
172+
hour = 0
173+
}
174+
175+
hour == 23 && minute == 59 && second == 60 -> second = 59
167176
}
168-
169-
hour == 23 && minute == 59 && second == 60 -> second = 59
170-
}
171-
}?.toInstantUsingOffset()
172-
}
177+
}?.toInstantUsingOffset()
178+
}
173179

174180
fun formatTwoDigits(i: Int) = if (i < 10) "0$i" else "$i"
175181

@@ -178,8 +184,8 @@ class ParseTests {
178184
for (second in listOf(0..5, 58..62).flatten()) {
179185
val input = "2020-03-16T$hour:${formatTwoDigits(minute)}:${formatTwoDigits(second)}Z"
180186

181-
val myParserRes = instantParser.applyOptions(null)(input) as Instant?
182-
val myJavaParserRes = javaInstantParser.applyOptions(null)(input) as JavaInstant?
187+
val myParserRes = instantParser(input) as Instant?
188+
val myJavaParserRes = javaInstantParser(input) as JavaInstant?
183189
val instantRes = catchSilent { Instant.parse(input) }
184190
val instantLikeJava = parseInstantLikeJavaDoesOrNull(input)
185191
val javaInstantRes = catchSilent { JavaInstant.parse(input) }
@@ -204,6 +210,268 @@ class ParseTests {
204210
columnOf(1.days + 15.minutes, 20.hours + 35.minutes + 11.seconds)
205211
}
206212

213+
@Test
214+
fun `can parse duration isoStrings`() {
215+
val durationParser = Parsers[typeOf<Duration>()]!!.applyOptions(null) as (String) -> Duration?
216+
val javaDurationParser = Parsers[typeOf<JavaDuration>()]!!.applyOptions(null) as (String) -> JavaDuration?
217+
218+
fun testSuccess(duration: Duration, vararg isoStrings: String) {
219+
isoStrings.first() shouldBe duration.toIsoString()
220+
for (isoString in isoStrings) {
221+
Duration.parse(isoString) shouldBe duration
222+
durationParser(isoString) shouldBe duration
223+
224+
javaDurationParser(isoString) shouldBe catchSilent { JavaDuration.parse(isoString) }
225+
}
226+
}
227+
228+
// zero
229+
testSuccess(Duration.ZERO, "PT0S", "P0D", "PT0H", "PT0M", "P0DT0H", "PT0H0M", "PT0H0S")
230+
231+
// single unit
232+
testSuccess(1.days, "PT24H", "P1D", "PT1440M", "PT86400S")
233+
testSuccess(1.hours, "PT1H")
234+
testSuccess(1.minutes, "PT1M")
235+
testSuccess(1.seconds, "PT1S")
236+
testSuccess(1.milliseconds, "PT0.001S")
237+
testSuccess(1.microseconds, "PT0.000001S")
238+
testSuccess(1.nanoseconds, "PT0.000000001S", "PT0.0000000009S")
239+
testSuccess(0.9.nanoseconds, "PT0.000000001S")
240+
241+
// rounded to zero
242+
testSuccess(0.1.nanoseconds, "PT0S")
243+
testSuccess(Duration.ZERO, "PT0S", "PT0.0000000004S")
244+
245+
// several units combined
246+
testSuccess(1.days + 1.minutes, "PT24H1M")
247+
testSuccess(1.days + 1.seconds, "PT24H0M1S")
248+
testSuccess(1.days + 1.milliseconds, "PT24H0M0.001S")
249+
testSuccess(1.hours + 30.minutes, "PT1H30M")
250+
testSuccess(1.hours + 500.milliseconds, "PT1H0M0.500S")
251+
testSuccess(2.minutes + 500.milliseconds, "PT2M0.500S")
252+
testSuccess(90_500.milliseconds, "PT1M30.500S")
253+
254+
// with sign
255+
testSuccess(-1.days + 15.minutes, "-PT23H45M", "PT-23H-45M", "+PT-24H+15M")
256+
testSuccess(-1.days - 15.minutes, "-PT24H15M", "PT-24H-15M", "-PT25H-45M")
257+
testSuccess(Duration.ZERO, "PT0S", "P1DT-24H", "+PT-1H+60M", "-PT1M-60S")
258+
259+
// infinite
260+
testSuccess(
261+
Duration.INFINITE,
262+
"PT9999999999999H",
263+
"PT+10000000000000H",
264+
"-PT-9999999999999H",
265+
"-PT-1234567890123456789012S",
266+
)
267+
testSuccess(-Duration.INFINITE, "-PT9999999999999H", "-PT10000000000000H", "PT-1234567890123456789012S")
268+
269+
fun testFailure(isoString: String) {
270+
catchSilent { Duration.parse(isoString) } shouldBe durationParser(isoString)
271+
catchSilent { JavaDuration.parse(isoString) } shouldBe javaDurationParser(isoString)
272+
}
273+
274+
listOf(
275+
"",
276+
" ",
277+
"P",
278+
"PT",
279+
"P1DT",
280+
"P1",
281+
"PT1",
282+
"0",
283+
"+P",
284+
"+",
285+
"-",
286+
"h",
287+
"H",
288+
"something",
289+
"1m",
290+
"1d",
291+
"2d 11s",
292+
"Infinity",
293+
"-Infinity", // successful in kotlin, not in java
294+
"P+12+34D",
295+
"P12-34D",
296+
"PT1234567890-1234567890S",
297+
" P1D",
298+
"PT1S ",
299+
"P3W",
300+
"P1Y",
301+
"P1M",
302+
"P1S",
303+
"PT1D",
304+
"PT1Y",
305+
"PT1S2S",
306+
"PT1S2H",
307+
"P9999999999999DT-9999999999999H",
308+
"PT1.5H",
309+
"PT0.5D",
310+
"PT.5S",
311+
"PT0.25.25S",
312+
).forEach(::testFailure)
313+
}
314+
315+
@Test
316+
fun `can parse duration default kotlin strings`() {
317+
val durationParser = Parsers[typeOf<Duration>()]!!.applyOptions(null) as (String) -> Duration?
318+
319+
fun testParsing(string: String, expectedDuration: Duration) {
320+
Duration.parse(string) shouldBe expectedDuration
321+
durationParser(string) shouldBe expectedDuration
322+
}
323+
324+
fun testSuccess(duration: Duration, vararg expected: String) {
325+
val actual = duration.toString()
326+
actual shouldBe expected.first()
327+
328+
if (duration.isPositive()) {
329+
if (' ' in actual) {
330+
(-duration).toString() shouldBe "-($actual)"
331+
} else {
332+
(-duration).toString() shouldBe "-$actual"
333+
}
334+
}
335+
336+
for (string in expected) {
337+
testParsing(string, duration)
338+
if (duration.isPositive() && duration.isFinite()) {
339+
testParsing("+($string)", duration)
340+
testParsing("-($string)", -duration)
341+
if (' ' !in string) {
342+
testParsing("+$string", duration)
343+
testParsing("-$string", -duration)
344+
}
345+
}
346+
}
347+
}
348+
349+
testSuccess(101.days, "101d", "2424h")
350+
testSuccess(45.3.days, "45d 7h 12m", "45.3d", "45d 7.2h") // 0.3d == 7.2h
351+
testSuccess(45.days, "45d")
352+
353+
testSuccess(40.5.days, "40d 12h", "40.5d", "40d 720m")
354+
testSuccess(40.days + 20.minutes, "40d 0h 20m", "40d 20m", "40d 1200s")
355+
testSuccess(40.days + 20.seconds, "40d 0h 0m 20s", "40d 20s")
356+
testSuccess(40.days + 100.nanoseconds, "40d 0h 0m 0.000000100s", "40d 100ns")
357+
358+
testSuccess(40.hours + 15.minutes, "1d 16h 15m", "40h 15m")
359+
testSuccess(40.hours, "1d 16h", "40h")
360+
361+
testSuccess(12.5.hours, "12h 30m")
362+
testSuccess(12.hours + 15.seconds, "12h 0m 15s")
363+
testSuccess(12.hours + 1.nanoseconds, "12h 0m 0.000000001s")
364+
testSuccess(30.minutes, "30m")
365+
testSuccess(17.5.minutes, "17m 30s")
366+
367+
testSuccess(16.5.minutes, "16m 30s")
368+
testSuccess(1097.1.seconds, "18m 17.1s")
369+
testSuccess(90.36.seconds, "1m 30.36s")
370+
testSuccess(50.seconds, "50s")
371+
testSuccess(1.3.seconds, "1.3s")
372+
testSuccess(1.seconds, "1s")
373+
374+
testSuccess(0.5.seconds, "500ms")
375+
testSuccess(40.2.milliseconds, "40.2ms")
376+
testSuccess(4.225.milliseconds, "4.225ms")
377+
testSuccess(4.24501.milliseconds, "4.245010ms", "4ms 245us 10ns")
378+
testSuccess(1.milliseconds, "1ms")
379+
380+
testSuccess(0.75.milliseconds, "750us")
381+
testSuccess(75.35.microseconds, "75.35us")
382+
testSuccess(7.25.microseconds, "7.25us")
383+
testSuccess(1.035.microseconds, "1.035us")
384+
testSuccess(1.005.microseconds, "1.005us")
385+
testSuccess(1800.nanoseconds, "1.8us", "1800ns", "0.0000000005h")
386+
387+
testSuccess(950.5.nanoseconds, "951ns")
388+
testSuccess(85.23.nanoseconds, "85ns")
389+
testSuccess(8.235.nanoseconds, "8ns")
390+
testSuccess(1.nanoseconds, "1ns", "0.9ns", "0.001us", "0.0009us")
391+
testSuccess(1.3.nanoseconds, "1ns")
392+
testSuccess(0.75.nanoseconds, "1ns")
393+
testSuccess(0.7512.nanoseconds, "1ns")
394+
395+
// equal to zero
396+
// testSuccess(0.023.nanoseconds, "0.023ns")
397+
// testSuccess(0.0034.nanoseconds, "0.0034ns")
398+
// testSuccess(0.0000035.nanoseconds, "0.0000035ns")
399+
400+
testSuccess(Duration.ZERO, "0s", "0.4ns", "0000.0000ns")
401+
testSuccess(365.days * 10000, "3650000d")
402+
testSuccess(300.days * 100000, "30000000d")
403+
testSuccess(365.days * 100000, "36500000d")
404+
testSuccess(((Long.MAX_VALUE / 2) - 1).milliseconds, "53375995583d 15h 36m 27.902s") // max finite value
405+
406+
// all infinite
407+
// val universeAge = Duration.days(365.25) * 13.799e9
408+
// val planckTime = Duration.seconds(5.4e-44)
409+
410+
// testSuccess(universeAge, "5.04e+12d")
411+
// testSuccess(planckTime, "5.40e-44s")
412+
// testSuccess(Duration.nanoseconds(Double.MAX_VALUE), "2.08e+294d")
413+
testSuccess(Duration.INFINITE, "Infinity", "53375995583d 20h", "+Infinity")
414+
testSuccess(-Duration.INFINITE, "-Infinity", "-(53375995583d 20h)")
415+
416+
fun testFailure(isoString: String) {
417+
catchSilent { Duration.parse(isoString) } shouldBe durationParser(isoString)
418+
}
419+
420+
listOf(
421+
"",
422+
" ",
423+
"P",
424+
"PT",
425+
"P1DT",
426+
"P1",
427+
"PT1",
428+
"0",
429+
"+P",
430+
"+",
431+
"-",
432+
"h",
433+
"H",
434+
"something",
435+
"1234567890123456789012ns",
436+
"Inf",
437+
"-Infinity value",
438+
"1s ",
439+
" 1s",
440+
"1d 1m 1h",
441+
"1s 2s",
442+
"-12m 15s",
443+
"-12m -15s",
444+
"-()",
445+
"-(12m 30s",
446+
"+12m 15s",
447+
"+12m +15s",
448+
"+()",
449+
"+(12m 30s",
450+
"()",
451+
"(12m 30s)",
452+
"12.5m 11.5s",
453+
".2s",
454+
"0.1553.39m",
455+
"P+12+34D",
456+
"P12-34D",
457+
"PT1234567890-1234567890S",
458+
" P1D",
459+
"PT1S ",
460+
"P1Y",
461+
"P1M",
462+
"P1S",
463+
"PT1D",
464+
"PT1Y",
465+
"PT1S2S",
466+
"PT1S2H",
467+
"P9999999999999DT-9999999999999H",
468+
"PT1.5H",
469+
"PT0.5D",
470+
"PT.5S",
471+
"PT0.25.25S",
472+
).forEach(::testFailure)
473+
}
474+
207475
@Test
208476
fun `Parse normal string column`() {
209477
val df = dataFrameOf(List(5_000) { "_$it" }).fill(100) {

0 commit comments

Comments
 (0)