@@ -24,14 +24,14 @@ import com.google.firebase.dataconnect.testutil.property.arbitrary.JavaTimeEdgeC
2424import com.google.firebase.dataconnect.testutil.property.arbitrary.JavaTimeEdgeCases.MIN_NANO
2525import com.google.firebase.dataconnect.testutil.property.arbitrary.JavaTimeEdgeCases.MIN_YEAR
2626import com.google.firebase.dataconnect.testutil.toTimestamp
27+ import io.kotest.common.mapError
2728import io.kotest.property.Arb
2829import io.kotest.property.arbitrary.arbitrary
2930import io.kotest.property.arbitrary.choice
3031import io.kotest.property.arbitrary.enum
3132import io.kotest.property.arbitrary.int
3233import io.kotest.property.arbitrary.of
3334import io.kotest.property.arbitrary.orNull
34- import kotlin.random.nextInt
3535import org.threeten.bp.Instant
3636import org.threeten.bp.OffsetDateTime
3737import org.threeten.bp.ZoneOffset
@@ -153,7 +153,11 @@ private fun Instant.toFdcFieldRegex(): Regex {
153153 return Regex (pattern)
154154}
155155
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+ )
157161
158162sealed interface TimeOffset {
159163
@@ -177,8 +181,12 @@ sealed interface TimeOffset {
177181
178182 data class HhMm (val hours : Int , val minutes : Int , val sign : Sign ) : TimeOffset {
179183 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+ }
182190 require(hours != 18 || minutes == 0 ) { " invalid minutes: $minutes (must be 0 when hours=18)" }
183191 }
184192
@@ -192,15 +200,44 @@ sealed interface TimeOffset {
192200 append(" $minutes " .padStart(2 , ' 0' ))
193201 }
194202
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+
195211 override fun toString () =
196212 " HhMm(hours=$hours , minutes=$minutes , sign=$sign , " +
197213 " zoneOffset=$zoneOffset , rfc3339String=$rfc3339String )"
198214
215+ operator fun compareTo (other : HhMm ): Int = toSeconds() - other.toSeconds()
216+
199217 @Suppress(" unused" )
200218 enum class Sign (val char : Char , val multiplier : Int ) {
201219 Positive (' +' , 1 ),
202220 Negative (' -' , - 1 ),
203221 }
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+ }
204241 }
205242}
206243
@@ -215,7 +252,6 @@ object JavaTimeArbs {
215252 val minuteArb = minute()
216253 val secondArb = second()
217254 val nanosecondArb = nanosecond().orNull(nullProbability = 0.15 )
218- val timeOffsetArb = timeOffset()
219255
220256 return arbitrary(JavaTimeInstantEdgeCases .all) {
221257 val year = yearArb.bind()
@@ -226,7 +262,55 @@ object JavaTimeArbs {
226262 val minute = minuteArb.bind()
227263 val second = secondArb.bind()
228264 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()
230314
231315 val instant =
232316 OffsetDateTime .of(
@@ -241,6 +325,27 @@ object JavaTimeArbs {
241325 )
242326 .toInstant()
243327
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+
244349 val string = buildString {
245350 append(year)
246351 append(' -' )
@@ -268,7 +373,10 @@ object JavaTimeArbs {
268373 }
269374 }
270375
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))
272380
273381 fun timeOffsetUtc (
274382 case : Arb <TimeOffset .Utc .Case > = Arb .enum(),
@@ -278,20 +386,45 @@ object JavaTimeArbs {
278386 sign : Arb <TimeOffset .HhMm .Sign > = Arb .enum(),
279387 hour : Arb <Int > = Arb .positiveIntWithUniformNumDigitsProbability(0..18),
280388 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(
283403 edgecases =
284404 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)
292413 ) {
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
294426 }
427+ }
295428
296429 fun year (): Arb <Int > = Arb .int(MIN_YEAR .. MAX_YEAR )
297430
@@ -316,8 +449,12 @@ object JavaTimeArbs {
316449 repeat(digitCounts.leadingZeroes) { append(' 0' ) }
317450 if (digitCounts.proper > 0 ) {
318451 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+ }
321458 }
322459 repeat(digitCounts.trailingZeroes) { append(' 0' ) }
323460 }
@@ -327,18 +464,29 @@ object JavaTimeArbs {
327464 if (nanosecondsStringTrimmed.isEmpty()) {
328465 0
329466 } 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()
331477 }
332478
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)
334486 }
335487 }
336488
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 )
342490
343491 private fun nanosecondComponents (): Arb <NanosecondComponents > =
344492 arbitrary(
0 commit comments