@@ -24,14 +24,14 @@ import com.google.firebase.dataconnect.testutil.property.arbitrary.JavaTimeEdgeC
24
24
import com.google.firebase.dataconnect.testutil.property.arbitrary.JavaTimeEdgeCases.MIN_NANO
25
25
import com.google.firebase.dataconnect.testutil.property.arbitrary.JavaTimeEdgeCases.MIN_YEAR
26
26
import com.google.firebase.dataconnect.testutil.toTimestamp
27
+ import io.kotest.common.mapError
27
28
import io.kotest.property.Arb
28
29
import io.kotest.property.arbitrary.arbitrary
29
30
import io.kotest.property.arbitrary.choice
30
31
import io.kotest.property.arbitrary.enum
31
32
import io.kotest.property.arbitrary.int
32
33
import io.kotest.property.arbitrary.of
33
34
import io.kotest.property.arbitrary.orNull
34
- import kotlin.random.nextInt
35
35
import org.threeten.bp.Instant
36
36
import org.threeten.bp.OffsetDateTime
37
37
import org.threeten.bp.ZoneOffset
@@ -153,7 +153,11 @@ private fun Instant.toFdcFieldRegex(): Regex {
153
153
return Regex (pattern)
154
154
}
155
155
156
- data class Nanoseconds (val nanoseconds : Int , val string : String )
156
+ data class Nanoseconds (
157
+ val nanoseconds : Int ,
158
+ val string : String ,
159
+ val digitCounts : JavaTimeArbs .NanosecondComponents
160
+ )
157
161
158
162
sealed interface TimeOffset {
159
163
@@ -177,8 +181,12 @@ sealed interface TimeOffset {
177
181
178
182
data class HhMm (val hours : Int , val minutes : Int , val sign : Sign ) : TimeOffset {
179
183
init {
180
- require(hours in 0 .. 18 ) { " invalid hours: $hours (must be in the closed range 0..23)" }
181
- require(minutes in 0 .. 59 ) { " invalid minutes: $minutes (must be in the closed range 0..59)" }
184
+ require(hours in validHours) {
185
+ " invalid hours: $hours (must be in the closed range $validHours )"
186
+ }
187
+ require(minutes in validMinutes) {
188
+ " invalid minutes: $minutes (must be in the closed range $validMinutes )"
189
+ }
182
190
require(hours != 18 || minutes == 0 ) { " invalid minutes: $minutes (must be 0 when hours=18)" }
183
191
}
184
192
@@ -192,15 +200,44 @@ sealed interface TimeOffset {
192
200
append(" $minutes " .padStart(2 , ' 0' ))
193
201
}
194
202
203
+ fun toSeconds (): Int {
204
+ val absValue = (hours * SECONDS_PER_HOUR ) + (minutes * SECONDS_PER_MINUTE )
205
+ return when (sign) {
206
+ Sign .Positive -> absValue
207
+ Sign .Negative -> - absValue
208
+ }
209
+ }
210
+
195
211
override fun toString () =
196
212
" HhMm(hours=$hours , minutes=$minutes , sign=$sign , " +
197
213
" zoneOffset=$zoneOffset , rfc3339String=$rfc3339String )"
198
214
215
+ operator fun compareTo (other : HhMm ): Int = toSeconds() - other.toSeconds()
216
+
199
217
@Suppress(" unused" )
200
218
enum class Sign (val char : Char , val multiplier : Int ) {
201
219
Positive (' +' , 1 ),
202
220
Negative (' -' , - 1 ),
203
221
}
222
+
223
+ companion object {
224
+ private const val SECONDS_PER_MINUTE : Int = 60
225
+ private const val SECONDS_PER_HOUR : Int = 60 * SECONDS_PER_MINUTE
226
+
227
+ val validHours = 0 .. 18
228
+ val validMinutes = 0 .. 59
229
+
230
+ val maxSeconds: Int = 18 * SECONDS_PER_HOUR
231
+
232
+ fun forSeconds (seconds : Int , sign : Sign ): HhMm {
233
+ require(seconds in 0 .. maxSeconds) {
234
+ " invalid seconds: $seconds (must be between 0 and $maxSeconds , inclusive)"
235
+ }
236
+ val hours = seconds / SECONDS_PER_HOUR
237
+ val minutes = (seconds - (hours * SECONDS_PER_HOUR )) / SECONDS_PER_MINUTE
238
+ return HhMm (hours = hours, minutes = minutes, sign = sign)
239
+ }
240
+ }
204
241
}
205
242
}
206
243
@@ -215,7 +252,6 @@ object JavaTimeArbs {
215
252
val minuteArb = minute()
216
253
val secondArb = second()
217
254
val nanosecondArb = nanosecond().orNull(nullProbability = 0.15 )
218
- val timeOffsetArb = timeOffset()
219
255
220
256
return arbitrary(JavaTimeInstantEdgeCases .all) {
221
257
val year = yearArb.bind()
@@ -226,7 +262,55 @@ object JavaTimeArbs {
226
262
val minute = minuteArb.bind()
227
263
val second = secondArb.bind()
228
264
val nanosecond = nanosecondArb.bind()
229
- val timeOffset = timeOffsetArb.bind()
265
+
266
+ val instantUtc =
267
+ OffsetDateTime .of(
268
+ year,
269
+ month,
270
+ day,
271
+ hour,
272
+ minute,
273
+ second,
274
+ nanosecond?.nanoseconds ? : 0 ,
275
+ ZoneOffset .UTC ,
276
+ )
277
+ .toInstant()
278
+
279
+ // The valid range below was copied from:
280
+ // com.google.firebase.Timestamp.Timestamp.validateRange() 253_402_300_800
281
+ val validEpochSecondRange = - 62_135_596_800 .. 253_402_300_800
282
+
283
+ val numSecondsBelowMaxEpochSecond = validEpochSecondRange.last - instantUtc.epochSecond
284
+ require(numSecondsBelowMaxEpochSecond > 0 ) {
285
+ " internal error gh98nqedss: " +
286
+ " invalid numSecondsBelowMaxEpochSecond: $numSecondsBelowMaxEpochSecond "
287
+ }
288
+ val minTimeZoneOffset =
289
+ if (numSecondsBelowMaxEpochSecond >= TimeOffset .HhMm .maxSeconds) {
290
+ null
291
+ } else {
292
+ TimeOffset .HhMm .forSeconds(
293
+ numSecondsBelowMaxEpochSecond.toInt(),
294
+ TimeOffset .HhMm .Sign .Negative
295
+ )
296
+ }
297
+
298
+ val numSecondsAboveMinEpochSecond = instantUtc.epochSecond - validEpochSecondRange.first
299
+ require(numSecondsAboveMinEpochSecond > 0 ) {
300
+ " internal error mje6a4mrbm: " +
301
+ " invalid numSecondsAboveMinEpochSecond: $numSecondsAboveMinEpochSecond "
302
+ }
303
+ val maxTimeZoneOffset =
304
+ if (numSecondsAboveMinEpochSecond >= TimeOffset .HhMm .maxSeconds) {
305
+ null
306
+ } else {
307
+ TimeOffset .HhMm .forSeconds(
308
+ numSecondsAboveMinEpochSecond.toInt(),
309
+ TimeOffset .HhMm .Sign .Positive
310
+ )
311
+ }
312
+
313
+ val timeOffset = timeOffset(min = minTimeZoneOffset, max = maxTimeZoneOffset).bind()
230
314
231
315
val instant =
232
316
OffsetDateTime .of(
@@ -241,6 +325,27 @@ object JavaTimeArbs {
241
325
)
242
326
.toInstant()
243
327
328
+ require(instant.epochSecond >= validEpochSecondRange.first) {
329
+ " internal error weppxzqj2y: " +
330
+ " instant.epochSecond out of range by " +
331
+ " ${validEpochSecondRange.first - instant.epochSecond} : ${instant.epochSecond} (" +
332
+ " validEpochSecondRange.first=${validEpochSecondRange.first} , " +
333
+ " year=$year , month=$month , day=$day , " +
334
+ " hour=$hour , minute=$minute , second=$second , " +
335
+ " nanosecond=$nanosecond timeOffset=$timeOffset , " +
336
+ " minTimeZoneOffset=$minTimeZoneOffset , maxTimeZoneOffset=$maxTimeZoneOffset )"
337
+ }
338
+ require(instant.epochSecond <= validEpochSecondRange.last) {
339
+ " internal error yxga5xy9bm: " +
340
+ " instant.epochSecond out of range by " +
341
+ " ${instant.epochSecond - validEpochSecondRange.last} : ${instant.epochSecond} (" +
342
+ " validEpochSecondRange.last=${validEpochSecondRange.last} , " +
343
+ " year=$year , month=$month , day=$day , " +
344
+ " hour=$hour , minute=$minute , second=$second , " +
345
+ " nanosecond=$nanosecond timeOffset=$timeOffset , " +
346
+ " minTimeZoneOffset=$minTimeZoneOffset , maxTimeZoneOffset=$maxTimeZoneOffset )"
347
+ }
348
+
244
349
val string = buildString {
245
350
append(year)
246
351
append(' -' )
@@ -268,7 +373,10 @@ object JavaTimeArbs {
268
373
}
269
374
}
270
375
271
- fun timeOffset (): Arb <TimeOffset > = Arb .choice(timeOffsetUtc(), timeOffsetHhMm())
376
+ fun timeOffset (
377
+ min : TimeOffset .HhMm ? ,
378
+ max : TimeOffset .HhMm ? ,
379
+ ): Arb <TimeOffset > = Arb .choice(timeOffsetUtc(), timeOffsetHhMm(min = min, max = max))
272
380
273
381
fun timeOffsetUtc (
274
382
case : Arb <TimeOffset .Utc .Case > = Arb .enum(),
@@ -278,20 +386,45 @@ object JavaTimeArbs {
278
386
sign : Arb <TimeOffset .HhMm .Sign > = Arb .enum(),
279
387
hour : Arb <Int > = Arb .positiveIntWithUniformNumDigitsProbability(0..18),
280
388
minute : Arb <Int > = minute(),
281
- ): Arb <TimeOffset .HhMm > =
282
- arbitrary(
389
+ min : TimeOffset .HhMm ? ,
390
+ max : TimeOffset .HhMm ? ,
391
+ ): Arb <TimeOffset .HhMm > {
392
+ require(min == = null || max == = null || min.toSeconds() < max.toSeconds()) {
393
+ " min must be strictly less than max, but got: " +
394
+ " min=$min (${min!! .toSeconds()} seconds), " +
395
+ " max=$max (${max!! .toSeconds()} seconds), " +
396
+ " a difference of ${min.toSeconds() - max.toSeconds()} seconds"
397
+ }
398
+
399
+ fun isBetweenMinAndMax (other : TimeOffset .HhMm ): Boolean =
400
+ (min == = null || other >= min) && (max == = null || other <= max)
401
+
402
+ return arbitrary(
283
403
edgecases =
284
404
listOf (
285
- TimeOffset .HhMm (hours = 0 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Positive ),
286
- TimeOffset .HhMm (hours = 0 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Negative ),
287
- TimeOffset .HhMm (hours = 17 , minutes = 59 , sign = TimeOffset .HhMm .Sign .Positive ),
288
- TimeOffset .HhMm (hours = 17 , minutes = 59 , sign = TimeOffset .HhMm .Sign .Negative ),
289
- TimeOffset .HhMm (hours = 18 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Positive ),
290
- TimeOffset .HhMm (hours = 18 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Negative ),
291
- )
405
+ TimeOffset .HhMm (hours = 0 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Positive ),
406
+ TimeOffset .HhMm (hours = 0 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Negative ),
407
+ TimeOffset .HhMm (hours = 17 , minutes = 59 , sign = TimeOffset .HhMm .Sign .Positive ),
408
+ TimeOffset .HhMm (hours = 17 , minutes = 59 , sign = TimeOffset .HhMm .Sign .Negative ),
409
+ TimeOffset .HhMm (hours = 18 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Positive ),
410
+ TimeOffset .HhMm (hours = 18 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Negative ),
411
+ )
412
+ .filter(::isBetweenMinAndMax)
292
413
) {
293
- TimeOffset .HhMm (hours = hour.bind(), minutes = minute.bind(), sign = sign.bind())
414
+ var count = 0
415
+ var hhmm: TimeOffset .HhMm
416
+ while (true ) {
417
+ count++
418
+ hhmm = TimeOffset .HhMm (hours = hour.bind(), minutes = minute.bind(), sign = sign.bind())
419
+ if (isBetweenMinAndMax(hhmm)) {
420
+ break
421
+ } else if (count > 1000 ) {
422
+ throw Exception (" internal error j878fp4gmr: exhausted attempts to generate HhMm" )
423
+ }
424
+ }
425
+ hhmm
294
426
}
427
+ }
295
428
296
429
fun year (): Arb <Int > = Arb .int(MIN_YEAR .. MAX_YEAR )
297
430
@@ -316,8 +449,12 @@ object JavaTimeArbs {
316
449
repeat(digitCounts.leadingZeroes) { append(' 0' ) }
317
450
if (digitCounts.proper > 0 ) {
318
451
append(nonZeroDigits.bind())
319
- repeat(digitCounts.proper - 2 ) { append(digits.bind()) }
320
- append(nonZeroDigits.bind())
452
+ if (digitCounts.proper > 1 ) {
453
+ if (digitCounts.proper > 2 ) {
454
+ repeat(digitCounts.proper - 2 ) { append(digits.bind()) }
455
+ }
456
+ append(nonZeroDigits.bind())
457
+ }
321
458
}
322
459
repeat(digitCounts.trailingZeroes) { append(' 0' ) }
323
460
}
@@ -327,18 +464,29 @@ object JavaTimeArbs {
327
464
if (nanosecondsStringTrimmed.isEmpty()) {
328
465
0
329
466
} else {
330
- nanosecondsStringTrimmed.toInt()
467
+ val toIntResult = nanosecondsStringTrimmed.runCatching { toInt() }
468
+ toIntResult.mapError { exception ->
469
+ Exception (
470
+ " internal error qbdgapmye2: " +
471
+ " failed to parse nanosecondsStringTrimmed as an int: " +
472
+ " \" $nanosecondsStringTrimmed \" (digitCounts=$digitCounts )" ,
473
+ exception
474
+ )
475
+ }
476
+ toIntResult.getOrThrow()
331
477
}
332
478
333
- Nanoseconds (nanosecondsInt, nanosecondsString)
479
+ check(nanosecondsInt in 0 .. 999_999_999 ) {
480
+ " internal error c7j2myw6bd: " +
481
+ " nanosecondsStringTrimmed parsed to a value outside the valid range: " +
482
+ " $nanosecondsInt (digitCounts=$digitCounts )"
483
+ }
484
+
485
+ Nanoseconds (nanosecondsInt, nanosecondsString, digitCounts)
334
486
}
335
487
}
336
488
337
- private data class NanosecondComponents (
338
- val leadingZeroes : Int ,
339
- val proper : Int ,
340
- val trailingZeroes : Int
341
- )
489
+ data class NanosecondComponents (val leadingZeroes : Int , val proper : Int , val trailingZeroes : Int )
342
490
343
491
private fun nanosecondComponents (): Arb <NanosecondComponents > =
344
492
arbitrary(
0 commit comments