diff --git a/src/main/scala/com/fortysevendeg/scalacheck/datetime/GenDateTime.scala b/src/main/scala/com/fortysevendeg/scalacheck/datetime/GenDateTime.scala index 10a4deff..f16ae7bc 100644 --- a/src/main/scala/com/fortysevendeg/scalacheck/datetime/GenDateTime.scala +++ b/src/main/scala/com/fortysevendeg/scalacheck/datetime/GenDateTime.scala @@ -16,8 +16,8 @@ object GenDateTime { * @param period An offset from dateTime, serving as an upper bound for generated DateTimes. Can be negative, denoting an offset before the provided DateTime. * @return A DateTime generator for DateTimes within the expected range. */ - def genDateTimeWithinRange[D, R](dateTime: D, range: R)(implicit scDateTime: ScalaCheckDateTimeInfra[D, R]): Gen[D] = { + def genDateTimeWithinRange[D, R](dateTime: D, range: R)(implicit scDateTime: ScalaCheckDateTimeInfra[D, R], granularity: Granularity[D]): Gen[D] = { val diffMillis = scDateTime.getMillis(scDateTime.addRange(dateTime, range)) - scDateTime.getMillis(dateTime) - Gen.choose(0L min diffMillis, 0L max diffMillis).map(millis => scDateTime.addMillis(dateTime, millis)) + Gen.choose(0L min diffMillis, 0L max diffMillis).map(millis => granularity.normalize(scDateTime.addMillis(dateTime, millis))) } } diff --git a/src/main/scala/com/fortysevendeg/scalacheck/datetime/Granularity.scala b/src/main/scala/com/fortysevendeg/scalacheck/datetime/Granularity.scala new file mode 100644 index 00000000..4c9b0942 --- /dev/null +++ b/src/main/scala/com/fortysevendeg/scalacheck/datetime/Granularity.scala @@ -0,0 +1,13 @@ +package com.fortysevendeg.scalacheck.datetime + +trait Granularity[A] { + val normalize: A => A + val description: String +} + +object Granularity { + implicit def identity[A]: Granularity[A] = new Granularity[A] { + val normalize = (a: A) => a + val description = "None" + } +} diff --git a/src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/j8.scala b/src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/jdk8.scala similarity index 76% rename from src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/j8.scala rename to src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/jdk8.scala index 37b08604..7f7ed39d 100644 --- a/src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/j8.scala +++ b/src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/jdk8.scala @@ -4,9 +4,9 @@ import com.fortysevendeg.scalacheck.datetime.typeclasses._ import java.time._ import java.time.temporal.ChronoUnit.MILLIS -trait J8Instances { +trait Jdk8Instances { - implicit val j8ForDuration: ScalaCheckDateTimeInfra[ZonedDateTime, Duration] = new ScalaCheckDateTimeInfra[ZonedDateTime, Duration] { + implicit val jdk8ForDuration: ScalaCheckDateTimeInfra[ZonedDateTime, Duration] = new ScalaCheckDateTimeInfra[ZonedDateTime, Duration] { def addRange(zonedDateTime: ZonedDateTime, duration: Duration): ZonedDateTime = zonedDateTime.plus(duration) def addMillis(zonedDateTime: ZonedDateTime, millis: Long): ZonedDateTime = zonedDateTime.plus(millis, MILLIS) def getMillis(zonedDateTime: ZonedDateTime): Long = zonedDateTime.toInstant.toEpochMilli diff --git a/src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/package.scala b/src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/package.scala index 7795bf6f..244e9f9a 100644 --- a/src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/package.scala +++ b/src/main/scala/com/fortysevendeg/scalacheck/datetime/instances/package.scala @@ -2,5 +2,5 @@ package com.fortysevendeg.scalacheck.datetime package object instances { object joda extends JodaInstances - object j8 extends J8Instances + object jdk8 extends Jdk8Instances } diff --git a/src/main/scala/com/fortysevendeg/scalacheck/datetime/j8/GenJ8.scala b/src/main/scala/com/fortysevendeg/scalacheck/datetime/jdk8/GenJdk8.scala similarity index 65% rename from src/main/scala/com/fortysevendeg/scalacheck/datetime/j8/GenJ8.scala rename to src/main/scala/com/fortysevendeg/scalacheck/datetime/jdk8/GenJdk8.scala index 54919a4a..5f9e8182 100644 --- a/src/main/scala/com/fortysevendeg/scalacheck/datetime/j8/GenJ8.scala +++ b/src/main/scala/com/fortysevendeg/scalacheck/datetime/jdk8/GenJdk8.scala @@ -1,4 +1,4 @@ -package com.fortysevendeg.scalacheck.datetime.j8 +package com.fortysevendeg.scalacheck.datetime.jdk8 import collection.JavaConverters._ @@ -8,9 +8,11 @@ import org.scalacheck.Arbitrary.arbitrary import java.time._ import java.time.temporal.ChronoUnit.MILLIS -object GenJ8 { +import com.fortysevendeg.scalacheck.datetime.Granularity - val genZonedDateTime: Gen[ZonedDateTime] = for { +object GenJdk8 { + + def genZonedDateTime(implicit granularity: Granularity[ZonedDateTime]): Gen[ZonedDateTime] = for { year <- Gen.choose(-292278994, 292278994) month <- Gen.choose(1, 12) maxDaysInMonth = Month.of(month).length(Year.of(year).isLeap) @@ -20,7 +22,7 @@ object GenJ8 { second <- Gen.choose(0, 59) nanoOfSecond <- Gen.choose(0, 999999999) zoneId <- Gen.oneOf(ZoneId.getAvailableZoneIds.asScala.toList) - } yield ZonedDateTime.of(year, month, dayOfMonth, hour, minute, second, nanoOfSecond, ZoneId.of(zoneId)) + } yield granularity.normalize(ZonedDateTime.of(year, month, dayOfMonth, hour, minute, second, nanoOfSecond, ZoneId.of(zoneId))) val genDuration: Gen[Duration] = Gen.choose(Long.MinValue, Long.MaxValue / 1000).map(l => Duration.of(l, MILLIS)) } diff --git a/src/main/scala/com/fortysevendeg/scalacheck/datetime/jdk8/granularity/package.scala b/src/main/scala/com/fortysevendeg/scalacheck/datetime/jdk8/granularity/package.scala new file mode 100644 index 00000000..ea3fcc60 --- /dev/null +++ b/src/main/scala/com/fortysevendeg/scalacheck/datetime/jdk8/granularity/package.scala @@ -0,0 +1,32 @@ +package com.fortysevendeg.scalacheck.datetime.jdk8 + +import com.fortysevendeg.scalacheck.datetime.Granularity +import java.time.ZonedDateTime + +package object granularity { + + implicit val seconds: Granularity[ZonedDateTime] = new Granularity[ZonedDateTime] { + val normalize = (dt: ZonedDateTime) => dt.withNano(0) + val description = "Seconds" + } + + implicit val minutes: Granularity[ZonedDateTime] = new Granularity[ZonedDateTime] { + val normalize = (dt: ZonedDateTime) => dt.withNano(0).withSecond(0) + val description = "Minutes" + } + + implicit val hours: Granularity[ZonedDateTime] = new Granularity[ZonedDateTime] { + val normalize = (dt: ZonedDateTime) => dt.withNano(0).withSecond(0).withMinute(0) + val description = "Hours" + } + + implicit val days: Granularity[ZonedDateTime] = new Granularity[ZonedDateTime] { + val normalize = (dt: ZonedDateTime) => dt.withNano(0).withSecond(0).withMinute(0).withHour(0) + val description = "Days" + } + + implicit val years: Granularity[ZonedDateTime] = new Granularity[ZonedDateTime] { + val normalize = (dt: ZonedDateTime) => dt.withNano(0).withSecond(0).withMinute(0).withHour(0).withDayOfYear(1) + val description = "Years" + } +} diff --git a/src/main/scala/com/fortysevendeg/scalacheck/datetime/joda/GenJoda.scala b/src/main/scala/com/fortysevendeg/scalacheck/datetime/joda/GenJoda.scala index bf408ff6..d86c4e45 100644 --- a/src/main/scala/com/fortysevendeg/scalacheck/datetime/joda/GenJoda.scala +++ b/src/main/scala/com/fortysevendeg/scalacheck/datetime/joda/GenJoda.scala @@ -1,5 +1,7 @@ package com.fortysevendeg.scalacheck.datetime.joda +import com.fortysevendeg.scalacheck.datetime.Granularity + import org.scalacheck.Gen import org.joda.time._ @@ -8,7 +10,7 @@ import org.joda.time._ */ object GenJoda { - /** A Years period generator. */ + /** A Years period generator. */ val genYearsPeriod: Gen[Years] = Gen.choose(-292275054, 292278993).map(Years.ZERO.plus(_)) // Years.MIN_VALUE produces exception-throwing results /** A Months period generator. */ @@ -29,9 +31,7 @@ object GenJoda { /** A Seconds period generator. */ val genSecondsPeriod: Gen[Seconds] = Gen.choose(Seconds.MIN_VALUE.getSeconds, Seconds.MAX_VALUE.getSeconds).map(Seconds.ZERO.plus(_)) - /** - * A Period generator consisting of years, days, hours, minutes, seconds and millis. - */ + /** A Period generator consisting of years, days, hours, minutes, seconds and millis. */ val genPeriod: Gen[Period] = for { years <- genYearsPeriod days <- Gen.choose(1, 365) @@ -41,4 +41,15 @@ object GenJoda { millis <- Gen.choose(0, 999) } yield Period.years(years.getYears).withDays(days).withHours(hours).withMinutes(minutes).withSeconds(seconds).withMillis(millis) + /** A DateTime generator. */ + def genDateTime(implicit granularity: Granularity[DateTime]): Gen[DateTime] = for { + year <- Gen.choose(-292275055,292278994) + month <- Gen.choose(1, 12) + yearAndMonthDt = new DateTime(year, month, 1, 0, 0) + dayOfMonth <- Gen.choose(1, yearAndMonthDt.dayOfMonth.getMaximumValue) + hourOfDay <- Gen.choose(0, 23) + minuteOfHour <- Gen.choose(0, 59) + secondOfMinute <- Gen.choose(0, 59) + millisOfSecond <- Gen.choose(0, 999) + } yield granularity.normalize(new DateTime(year, month, dayOfMonth, hourOfDay, minuteOfHour, secondOfMinute, millisOfSecond)) } diff --git a/src/main/scala/com/fortysevendeg/scalacheck/datetime/joda/granularity/package.scala b/src/main/scala/com/fortysevendeg/scalacheck/datetime/joda/granularity/package.scala new file mode 100644 index 00000000..78ca6628 --- /dev/null +++ b/src/main/scala/com/fortysevendeg/scalacheck/datetime/joda/granularity/package.scala @@ -0,0 +1,32 @@ +package com.fortysevendeg.scalacheck.datetime.joda + +import com.fortysevendeg.scalacheck.datetime.Granularity +import org.joda.time.DateTime + +package object granularity { + + implicit val seconds: Granularity[DateTime] = new Granularity[DateTime] { + val normalize = (dt: DateTime) => dt.withMillisOfSecond(0) + val description = "Seconds" + } + + implicit val minutes: Granularity[DateTime] = new Granularity[DateTime] { + val normalize = (dt: DateTime) => dt.withMillisOfSecond(0).withSecondOfMinute(0) + val description = "Minutes" + } + + implicit val hours: Granularity[DateTime] = new Granularity[DateTime] { + val normalize = (dt: DateTime) => dt.withMillisOfSecond(0).withSecondOfMinute(0).withMinuteOfHour(0) + val description = "Hours" + } + + implicit val days: Granularity[DateTime] = new Granularity[DateTime] { + val normalize = (dt: DateTime) => dt.withMillisOfSecond(0).withSecondOfMinute(0).withMinuteOfHour(0).withHourOfDay(0) + val description = "Days" + } + + implicit val years: Granularity[DateTime] = new Granularity[DateTime] { + val normalize = (dt: DateTime) => dt.withMillisOfSecond(0).withSecondOfMinute(0).withMinuteOfHour(0).withHourOfDay(0).withDayOfYear(1) + val description = "Years" + } +} diff --git a/src/test/scala/com/fortysevendeg/scalacheck/datetime/j8/GenJ8Properties.scala b/src/test/scala/com/fortysevendeg/scalacheck/datetime/j8/GenJ8Properties.scala deleted file mode 100644 index 617fd6ee..00000000 --- a/src/test/scala/com/fortysevendeg/scalacheck/datetime/j8/GenJ8Properties.scala +++ /dev/null @@ -1,52 +0,0 @@ -package com.fortysevendeg.scalacheck.datetime.j8 - -import scala.util.Try - -import org.scalacheck._ -import org.scalacheck.Prop._ - -import java.time._ - -import com.fortysevendeg.scalacheck.datetime.GenDateTime._ -import com.fortysevendeg.scalacheck.datetime.instances.j8._ - -import GenJ8._ - -object GenJ8Properties extends Properties("Java 8 Generators") { - - property("genDuration creates valid durations") = forAll(genDuration) { _ => passed } - - property("genZonedDateTime created valid times") = forAll(genZonedDateTime) { _ => passed } - - // Guards against adding a duration to a datetime which cannot represent millis in a long, causing an exception. - private[this] def tooLargeForAddingRanges(dateTime: ZonedDateTime, d: Duration): Boolean = { - Try(dateTime.plus(d).toInstant().toEpochMilli()).isFailure - } - - property("genDuration can be added to any date") = forAll(genZonedDateTime, genDuration) { (dt, dur) => - !tooLargeForAddingRanges(dt, dur) ==> { - val attempted = Try(dt.plus(dur).toInstant().toEpochMilli()) - attempted.isSuccess :| attempted.toString - } - } - - property("genDateTimeWithinRange for Java 8 should generate ZonedDateTimes between the given date and the end of the specified Duration") = forAll(genZonedDateTime, genDuration) { (now, d) => - !tooLargeForAddingRanges(now, d) ==> { - forAll(genDateTimeWithinRange(now, d)) { generated => - val durationBoundary = now.plus(d) - - val resultText = s"""Duration: $d - |Now: $now - |Generated: $generated - |Period Boundary: $durationBoundary""".stripMargin - - val (lowerBound, upperBound) = if(durationBoundary.isAfter(now)) (now, durationBoundary) else (durationBoundary, now) - - val check = (lowerBound.isBefore(generated) || lowerBound.isEqual(generated)) && - (upperBound.isAfter(generated) || upperBound.isEqual(generated)) - - check :| resultText - } - } - } -} diff --git a/src/test/scala/com/fortysevendeg/scalacheck/datetime/jdk8/GenJdk8Properties.scala b/src/test/scala/com/fortysevendeg/scalacheck/datetime/jdk8/GenJdk8Properties.scala new file mode 100644 index 00000000..3ce418b2 --- /dev/null +++ b/src/test/scala/com/fortysevendeg/scalacheck/datetime/jdk8/GenJdk8Properties.scala @@ -0,0 +1,93 @@ +package com.fortysevendeg.scalacheck.datetime.jdk8 + +import scala.util.Try + +import org.scalacheck._ +import org.scalacheck.Prop._ + +import java.time._ + +import com.fortysevendeg.scalacheck.datetime.GenDateTime._ +import com.fortysevendeg.scalacheck.datetime.instances.jdk8._ + +import GenJdk8._ + +import com.fortysevendeg.scalacheck.datetime.Granularity + +object GenJdk8Properties extends Properties("Java 8 Generators") { + + property("genDuration creates valid durations") = forAll(genDuration) { _ => passed } + + property("genZonedDateTime created valid times (with no granularity)") = forAll(genZonedDateTime) { _ => passed } + + val granularitiesAndPredicates: List[(Granularity[ZonedDateTime], ZonedDateTime => Boolean)] = { + + import java.time.temporal.ChronoField._ + + def zeroNanos(dt: ZonedDateTime) = dt.get(NANO_OF_SECOND) == 0 + def zeroSeconds(dt: ZonedDateTime) = zeroNanos(dt) && dt.get(SECOND_OF_MINUTE) == 0 + def zeroMinutes(dt: ZonedDateTime) = zeroSeconds(dt) && dt.get(MINUTE_OF_HOUR) == 0 + def zeroHours(dt: ZonedDateTime) = zeroMinutes(dt) && dt.get(HOUR_OF_DAY) == 0 + def firstDay(dt: ZonedDateTime) = zeroHours(dt) && dt.get(DAY_OF_YEAR) == 1 + + List( + (granularity.seconds, zeroNanos _), + (granularity.minutes, zeroSeconds _), + (granularity.hours, zeroMinutes _), + (granularity.days, zeroHours _), + (granularity.years, firstDay _) + ) + } + + val granularitiesAndPredicatesWithDefault: List[(Granularity[ZonedDateTime], ZonedDateTime => Boolean)] = (Granularity.identity[ZonedDateTime], (_: ZonedDateTime) => true) :: granularitiesAndPredicates + + property("genZonedDateTime with a granularity generates appropriate ZonedDateTimes") = forAll(Gen.oneOf(granularitiesAndPredicates)) { case (granularity, predicate) => + + implicit val generatedGranularity = granularity + + forAll(genZonedDateTime) { dt => + predicate(dt) :| s"${granularity.description}: $dt" + } + } + + // Guards against adding a duration to a datetime which cannot represent millis in a long, causing an exception. + private[this] def tooLargeForAddingRanges(dateTime: ZonedDateTime, d: Duration): Boolean = { + Try(dateTime.plus(d).toInstant().toEpochMilli()).isFailure + } + + property("genDuration can be added to any date") = forAll(genZonedDateTime, genDuration) { (dt, dur) => + !tooLargeForAddingRanges(dt, dur) ==> { + val attempted = Try(dt.plus(dur).toInstant().toEpochMilli()) + attempted.isSuccess :| attempted.toString + } + } + + property("genDateTimeWithinRange for Java 8 should generate ZonedDateTimes between the given date and the end of the specified Duration") = + forAll(genZonedDateTime, genDuration, Gen.oneOf(granularitiesAndPredicatesWithDefault)) { case (now, d, (granularity, predicate)) => + !tooLargeForAddingRanges(now, d) ==> { + + implicit val generatedGranularity = granularity + + forAll(genDateTimeWithinRange(now, d)) { generated => + val durationBoundary = now.plus(d) + + val resultText = s"""Duration: $d + |Now: $now + |Generated: $generated + |Period Boundary: $durationBoundary + |Granularity ${granularity.description}""".stripMargin + + val (lowerBound, upperBound) = if(durationBoundary.isAfter(now)) (now, durationBoundary) else (durationBoundary, now) + + val rangeCheck = (lowerBound.isBefore(generated) || lowerBound.isEqual(generated)) && + (upperBound.isAfter(generated) || upperBound.isEqual(generated)) + + val granularityCheck = predicate(generated) + + val prop = rangeCheck && granularityCheck + + prop :| resultText + } + } + } +} diff --git a/src/test/scala/com/fortysevendeg/scalacheck/datetime/joda/GenJodaProperties.scala b/src/test/scala/com/fortysevendeg/scalacheck/datetime/joda/GenJodaProperties.scala index b109e046..f7ab42ad 100644 --- a/src/test/scala/com/fortysevendeg/scalacheck/datetime/joda/GenJodaProperties.scala +++ b/src/test/scala/com/fortysevendeg/scalacheck/datetime/joda/GenJodaProperties.scala @@ -11,6 +11,8 @@ import com.fortysevendeg.scalacheck.datetime.instances.joda._ import GenJoda._ +import com.fortysevendeg.scalacheck.datetime.Granularity + object GenJodaProperties extends Properties("Joda Generators") { /* @@ -33,26 +35,63 @@ object GenJodaProperties extends Properties("Joda Generators") { property("genPeriod creates valid periods containing a selection of other periods") = forAll(genPeriod) { _ => passed } - property("genDateTimeWithinRange for Joda should generate DateTimes between the given date and the end of the specified Period") = forAll(genPeriod) { p => + property("genDateTime creates valid DateTime instances (with no granularity)") = forAll(genDateTime) { _ => passed } + + val granularitiesAndPredicates: List[(Granularity[DateTime], DateTime => Boolean)] = { + + def zeroMillis(dt: DateTime) = dt.getMillisOfSecond == 0 + def zeroSeconds(dt: DateTime) = zeroMillis(dt) && dt.getSecondOfMinute == 0 + def zeroMinutes(dt: DateTime) = zeroSeconds(dt) && dt.getMinuteOfHour == 0 + def zeroHours(dt: DateTime) = zeroMinutes(dt) && dt.getHourOfDay == 0 + def firstDay(dt: DateTime) = zeroHours(dt) && dt.getDayOfYear == 1 + + List( + (granularity.seconds, zeroMillis _), + (granularity.minutes, zeroSeconds _), + (granularity.hours, zeroMinutes _), + (granularity.days, zeroHours _), + (granularity.years, firstDay _) + ) + } + + val granularitiesAndPredicatesWithDefault: List[(Granularity[DateTime], DateTime => Boolean)] = (Granularity.identity[DateTime], (_: DateTime) => true) :: granularitiesAndPredicates + + property("genDateTime with a granularity generates appropriate DateTimes") = forAll(Gen.oneOf(granularitiesAndPredicates)) { case (granularity, predicate) => + implicit val generatedGranularity = granularity + + forAll(genDateTime) { dt => + predicate(dt) :| s"${granularity.description}: $dt" + } + } + + property("genDateTimeWithinRange for Joda should generate DateTimes between the given date and the end of the specified Period, with the relevant granularity") = + forAll(genPeriod, Gen.oneOf(granularitiesAndPredicatesWithDefault)) { case (period, (granularity, predicate)) => + + implicit val generatedGranularity = granularity val now = new DateTime() - forAll(genDateTimeWithinRange(now, p)) { generated => + forAll(genDateTimeWithinRange(now, period)) { generated => // if period is negative, then periodBoundary will be before now - val periodBoundary = now.plus(p) + val periodBoundary = now.plus(period) - val resultText = s"""Period: ${PeriodFormat.getDefault().print(p)} + val resultText = s"""Period: ${PeriodFormat.getDefault().print(period)} |Now: $now |Generated: $generated - |Period Boundary: $periodBoundary""".stripMargin + |Period Boundary: $periodBoundary + |Granularity: ${granularity.description}""".stripMargin val (lowerBound, upperBound) = if(periodBoundary.isAfter(now)) (now, periodBoundary) else (periodBoundary, now) - val check = (lowerBound.isBefore(generated) || lowerBound.isEqual(generated)) && - (upperBound.isAfter(generated) || upperBound.isEqual(generated)) + val rangeCheck = (lowerBound.isBefore(generated) || lowerBound.isEqual(generated)) && + (upperBound.isAfter(generated) || upperBound.isEqual(generated)) + + val granularityCheck = predicate(generated) + + val prop = rangeCheck && granularityCheck - check :| resultText + prop :| resultText } } }