@@ -181,8 +181,10 @@ sealed interface TimeOffset {
181181
182182 data class HhMm (val hours : Int , val minutes : Int , val sign : Sign ) : TimeOffset {
183183 init {
184- require(hours in 0 .. 18 ) { " invalid hours: $hours (must be in the closed range 0..23)" }
185- require(minutes in 0 .. 59 ) { " invalid minutes: $minutes (must be in the closed range 0..59)" }
184+ require(hours in validHours) { " invalid hours: $hours (must be in the closed range 0..23)" }
185+ require(minutes in validMinutes) {
186+ " invalid minutes: $minutes (must be in the closed range 0..59)"
187+ }
186188 require(hours != 18 || minutes == 0 ) { " invalid minutes: $minutes (must be 0 when hours=18)" }
187189 }
188190
@@ -196,15 +198,40 @@ sealed interface TimeOffset {
196198 append(" $minutes " .padStart(2 , ' 0' ))
197199 }
198200
201+ fun toSeconds (): Int {
202+ val absValue = hours + (minutes * 60 )
203+ return when (sign) {
204+ Sign .Positive -> absValue
205+ Sign .Negative -> - absValue
206+ }
207+ }
208+
199209 override fun toString () =
200210 " HhMm(hours=$hours , minutes=$minutes , sign=$sign , " +
201211 " zoneOffset=$zoneOffset , rfc3339String=$rfc3339String )"
202212
213+ operator fun compareTo (other : HhMm ): Int = toSeconds() - other.toSeconds()
214+
203215 @Suppress(" unused" )
204216 enum class Sign (val char : Char , val multiplier : Int ) {
205217 Positive (' +' , 1 ),
206218 Negative (' -' , - 1 ),
207219 }
220+
221+ companion object {
222+ val validHours = 0 .. 18
223+ val validMinutes = 0 .. 59
224+ val maxSeconds: Int = 18 * 60
225+
226+ fun forSeconds (seconds : Int , sign : Sign ): HhMm {
227+ require(seconds in 0 .. maxSeconds) {
228+ " invalid seconds: $seconds (must be between 0 and $maxSeconds , inclusive)"
229+ }
230+ val hours = seconds / 60
231+ val minutes = seconds - (hours * 60 )
232+ return HhMm (hours = hours, minutes = minutes, sign = sign)
233+ }
234+ }
208235 }
209236}
210237
@@ -219,7 +246,6 @@ object JavaTimeArbs {
219246 val minuteArb = minute()
220247 val secondArb = second()
221248 val nanosecondArb = nanosecond().orNull(nullProbability = 0.15 )
222- val timeOffsetArb = timeOffset()
223249
224250 return arbitrary(JavaTimeInstantEdgeCases .all) {
225251 val year = yearArb.bind()
@@ -230,7 +256,55 @@ object JavaTimeArbs {
230256 val minute = minuteArb.bind()
231257 val second = secondArb.bind()
232258 val nanosecond = nanosecondArb.bind()
233- val timeOffset = timeOffsetArb.bind()
259+
260+ val instantUtc =
261+ OffsetDateTime .of(
262+ year,
263+ month,
264+ day,
265+ hour,
266+ minute,
267+ second,
268+ nanosecond?.nanoseconds ? : 0 ,
269+ ZoneOffset .UTC ,
270+ )
271+ .toInstant()
272+
273+ // The valid range below was copied from:
274+ // com.google.firebase.Timestamp.Timestamp.validateRange() 253_402_300_800
275+ val validEpochSecondRange = - 62_135_596_800 .. 253_402_300_800
276+
277+ val numSecondsBelowMaxEpochSecond = validEpochSecondRange.last - instantUtc.epochSecond
278+ require(numSecondsBelowMaxEpochSecond > 0 ) {
279+ " internal error gh98nqedss: " +
280+ " invalid numSecondsBelowMaxEpochSecond: $numSecondsBelowMaxEpochSecond "
281+ }
282+ val maxTimeZoneOffset =
283+ if (numSecondsBelowMaxEpochSecond >= TimeOffset .HhMm .maxSeconds) {
284+ null
285+ } else {
286+ TimeOffset .HhMm .forSeconds(
287+ numSecondsBelowMaxEpochSecond.toInt(),
288+ TimeOffset .HhMm .Sign .Negative
289+ )
290+ }
291+
292+ val numSecondsAboveMinEpochSecond = instantUtc.epochSecond - validEpochSecondRange.first
293+ require(numSecondsAboveMinEpochSecond > 0 ) {
294+ " internal error mje6a4mrbm: " +
295+ " invalid numSecondsAboveMinEpochSecond: $numSecondsAboveMinEpochSecond "
296+ }
297+ val minTimeZoneOffset =
298+ if (numSecondsAboveMinEpochSecond >= TimeOffset .HhMm .maxSeconds) {
299+ null
300+ } else {
301+ TimeOffset .HhMm .forSeconds(
302+ numSecondsAboveMinEpochSecond.toInt(),
303+ TimeOffset .HhMm .Sign .Positive
304+ )
305+ }
306+
307+ val timeOffset = timeOffset(min = minTimeZoneOffset, max = maxTimeZoneOffset).bind()
234308
235309 val instant =
236310 OffsetDateTime .of(
@@ -245,14 +319,24 @@ object JavaTimeArbs {
245319 )
246320 .toInstant()
247321
248- // The valid range below was copied from:
249- // com.google.firebase.Timestamp.Timestamp.validateRange()
250- require(instant.epochSecond in - 62_135_596_800 until 253_402_300_800 ) {
322+ require(instant.epochSecond >= validEpochSecondRange.first) {
251323 " internal error weppxzqj2y: " +
252- " instant.epochSecond out of range: ${instant.epochSecond} (" +
253- " year=$year , month=$month , day=$day , " +
324+ " instant.epochSecond out of range by " +
325+ " ${validEpochSecondRange.first - instant.epochSecond} : ${instant.epochSecond} (" +
326+ " validEpochSecondRange.first=${validEpochSecondRange.first} , "
327+ " year=$year , month=$month , day=$day , " +
254328 " hour=$hour , minute=$minute , second=$second , " +
255- " nanosecond=$nanosecond timeOffset=$timeOffset )"
329+ " nanosecond=$nanosecond timeOffset=$timeOffset , " +
330+ " minTimeZoneOffset=$minTimeZoneOffset , maxTimeZoneOffset=$maxTimeZoneOffset )"
331+ }
332+ require(instant.epochSecond <= validEpochSecondRange.last) {
333+ " internal error yxga5xy9bm: " +
334+ " instant.epochSecond out of range by " +
335+ " ${instant.epochSecond - validEpochSecondRange.last} : ${instant.epochSecond} (" +
336+ " validEpochSecondRange.last=${validEpochSecondRange.last} , " +
337+ " year=$year , month=$month , day=$day , " +
338+ " nanosecond=$nanosecond timeOffset=$timeOffset , " +
339+ " minTimeZoneOffset=$minTimeZoneOffset , maxTimeZoneOffset=$maxTimeZoneOffset )"
256340 }
257341
258342 val string = buildString {
@@ -282,7 +366,10 @@ object JavaTimeArbs {
282366 }
283367 }
284368
285- fun timeOffset (): Arb <TimeOffset > = Arb .choice(timeOffsetUtc(), timeOffsetHhMm())
369+ fun timeOffset (
370+ min : TimeOffset .HhMm ? ,
371+ max : TimeOffset .HhMm ? ,
372+ ): Arb <TimeOffset > = Arb .choice(timeOffsetUtc(), timeOffsetHhMm(min = min, max = max))
286373
287374 fun timeOffsetUtc (
288375 case : Arb <TimeOffset .Utc .Case > = Arb .enum(),
@@ -292,20 +379,42 @@ object JavaTimeArbs {
292379 sign : Arb <TimeOffset .HhMm .Sign > = Arb .enum(),
293380 hour : Arb <Int > = Arb .positiveIntWithUniformNumDigitsProbability(0..18),
294381 minute : Arb <Int > = minute(),
295- ): Arb <TimeOffset .HhMm > =
296- arbitrary(
382+ min : TimeOffset .HhMm ? ,
383+ max : TimeOffset .HhMm ? ,
384+ ): Arb <TimeOffset .HhMm > {
385+ require(min == = null || max == = null || min.toSeconds() < max.toSeconds()) {
386+ " min must be strictly less than max, but got: " +
387+ " min=$min (${min!! .toSeconds()} seconds), " +
388+ " max=$max (${max!! .toSeconds()} seconds), " +
389+ " a difference of ${min.toSeconds() - max.toSeconds()} seconds"
390+ }
391+
392+ return arbitrary(
297393 edgecases =
298394 listOf (
299- TimeOffset .HhMm (hours = 0 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Positive ),
300- TimeOffset .HhMm (hours = 0 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Negative ),
301- TimeOffset .HhMm (hours = 17 , minutes = 59 , sign = TimeOffset .HhMm .Sign .Positive ),
302- TimeOffset .HhMm (hours = 17 , minutes = 59 , sign = TimeOffset .HhMm .Sign .Negative ),
303- TimeOffset .HhMm (hours = 18 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Positive ),
304- TimeOffset .HhMm (hours = 18 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Negative ),
305- )
395+ TimeOffset .HhMm (hours = 0 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Positive ),
396+ TimeOffset .HhMm (hours = 0 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Negative ),
397+ TimeOffset .HhMm (hours = 17 , minutes = 59 , sign = TimeOffset .HhMm .Sign .Positive ),
398+ TimeOffset .HhMm (hours = 17 , minutes = 59 , sign = TimeOffset .HhMm .Sign .Negative ),
399+ TimeOffset .HhMm (hours = 18 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Positive ),
400+ TimeOffset .HhMm (hours = 18 , minutes = 0 , sign = TimeOffset .HhMm .Sign .Negative ),
401+ )
402+ .filter { (min == = null || it >= min) || (max == = null || it <= max) }
306403 ) {
307- TimeOffset .HhMm (hours = hour.bind(), minutes = minute.bind(), sign = sign.bind())
404+ var count = 0
405+ var hhmm: TimeOffset .HhMm
406+ while (true ) {
407+ count++
408+ hhmm = TimeOffset .HhMm (hours = hour.bind(), minutes = minute.bind(), sign = sign.bind())
409+ if ((min == = null || hhmm >= min) && (max == = null || hhmm <= max)) {
410+ break
411+ } else if (count > 1000 ) {
412+ throw Exception (" internal error j878fp4gmr: exhausted attempts to generate HhMm" )
413+ }
414+ }
415+ hhmm
308416 }
417+ }
309418
310419 fun year (): Arb <Int > = Arb .int(MIN_YEAR .. MAX_YEAR )
311420
0 commit comments