diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72b510f..9b98333 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,45 +23,28 @@ jobs: matrix: os: [ubuntu-latest, macos-latest] scala: [3.6.4] - java: - - temurin@11 - - temurin@17 - - temurin@19 - - temurin@21 + java: [temurin@17, temurin@21] + exclude: + - os: macos-latest + java: temurin@17 runs-on: ${{ matrix.os }} steps: - name: Checkout current branch (full) - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Setup Java (temurin@11) - if: matrix.java == 'temurin@11' - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 11 - cache: sbt - - name: Setup Java (temurin@17) if: matrix.java == 'temurin@17' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 cache: sbt - - name: Setup Java (temurin@19) - if: matrix.java == 'temurin@19' - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 19 - cache: sbt - - name: Setup Java (temurin@21) if: matrix.java == 'temurin@21' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 21 @@ -74,15 +57,15 @@ jobs: run: brew install sbt - name: Check formatting with scalafmt - if: runner.os == 'ubuntu-latest' && runner.java-version == '11' - run: sbt '++ ${{ matrix.scala }}' scalafmtCheckAll + if: runner.os == 'ubuntu-latest' && runner.java-version == '21' + run: sbt '++ ${{ matrix.scala }}; scalafmtCheckAll' - name: Lint with scalafix - if: runner.os == 'ubuntu-latest' && runner.java-version == '11' - run: sbt '++ ${{ matrix.scala }}' 'scalafixAll --check' + if: runner.os == 'ubuntu-latest' && runner.java-version == '21' + run: sbt '++ ${{ matrix.scala }}; scalafixAll --check' - name: Check that workflows are up to date - run: sbt '++ ${{ matrix.scala }}' githubWorkflowCheck + run: sbt '++ ${{ matrix.scala }}; githubWorkflowCheck' - name: Build project - run: sbt '++ ${{ matrix.scala }}' test + run: sbt '++ ${{ matrix.scala }}; test' diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml index bfc865d..4bb28c8 100644 --- a/.github/workflows/clean.yml +++ b/.github/workflows/clean.yml @@ -9,6 +9,9 @@ name: Clean on: push +permissions: + actions: write + jobs: delete-artifacts: name: Delete Artifacts diff --git a/.gitignore b/.gitignore index 58948c5..3c39dde 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ project/* !project/Dependencies.scala !project/build.properties !project/plugins.sbt +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 5036dcf..9825a84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v0.6.0] - 2026-07-07 + +### Added +* `EuclideanDistance.unsafe` method, wrapping a `squants.space.Length` value as a `EuclideanDistance`, +performing the necessary validation as nonnegative and erroring otherwise. +* `configuration` module, with support for parsing definition of pixel lengths for `scopt` or `pureconfig`. +* Instances of `Show` and `SimpleShow` for `geometry.Distance`, with an implementation which can be guaranteed to roundtrip through `Distance.parse` +* Tests for `PixelDefinition` and `Pixels3D` + +### Changed +* Changed the error message for refinement of `squants.space.Length` as a `Distance` value from "Allegedly nonnegative length must actually be nonnegative." to "Allegedly nonnegative length is actually negative." +* Updated `sbt` to version 1.12.13 +* Restricted the CI Java version matrix to the LTS releases Temurin 17 and 21 (dropping 11 and 19). + ## [v0.5.2] - 2026-03-18 ### Fixed diff --git a/build.sbt b/build.sbt index f09c72c..7e00a0d 100644 --- a/build.sbt +++ b/build.sbt @@ -7,7 +7,7 @@ val projectName = "gerlib" val rootPkg = s"at.ac.oeaw.imba.gerlich.$projectName" val gitHubOwner = "gerlichlab" val gitPubUrl = s"https://github.com/$gitHubOwner/$projectName.git" -val primaryJavaVersion = "11" +val primaryJavaVersion = "21" val primaryOs = "ubuntu-latest" val isPrimaryOsAndPrimaryJavaTest = s"runner.os == '$primaryOs' && runner.java-version == '$primaryJavaVersion'" @@ -24,10 +24,11 @@ ThisBuild / scalafixDependencies ++= Seq( ThisBuild / githubWorkflowOSes := Set(primaryOs, "macos-latest", "ubuntu-latest").toSeq ThisBuild / githubWorkflowTargetBranches := Seq("main") ThisBuild / githubWorkflowPublishTargetBranches := Seq() -ThisBuild / githubWorkflowJavaVersions := Seq(primaryJavaVersion, "17", "19", "21").map(JavaSpec.temurin) +ThisBuild / githubWorkflowJavaVersions := Seq("17", primaryJavaVersion).map(JavaSpec.temurin) +ThisBuild / githubWorkflowBuildMatrixExclusions += MatrixExclude(Map("os" -> "macos-latest", "java" -> "temurin@17")) ThisBuild / githubWorkflowBuildPreamble ++= Seq( // Account for the absence of sbt in newer versions of the setup-java GitHub Action. - WorkflowStep.Run(commands = List("brew install sbt"), cond = Some("contains(runner.os, 'macos')")), + WorkflowStep.Run(commands = List("brew install sbt"), cond = Some("contains(runner.os, 'macos')")), /* Add linting and formatting checks, but only limit to a single platform + Java combo. */ WorkflowStep.Sbt( List("scalafmtCheckAll"), @@ -52,7 +53,7 @@ ThisBuild / testOptions += Tests.Argument("-oF") // full stack traces lazy val root = project .in(file(".")) - .aggregate(cell, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing, zarr) + .aggregate(cell, configuration, geometry, graph, imaging, io, json, numeric, pan, refinement, roi, testing, zarr) .enablePlugins(BuildInfoPlugin) .settings(commonSettings) .settings(noPublishSettings) @@ -65,6 +66,15 @@ lazy val root = project lazy val cell = defineModule("cell")(project) .dependsOn(numeric) +lazy val configuration = defineModule("configuration")(project) + .dependsOn(imaging) + .settings( + libraryDependencies ++= Seq( + pureconfigCore, + pureconfigGeneric, + ) + ) + lazy val geometry = defineModule("geometry")(project) .dependsOn(numeric, refinement) .settings( @@ -182,7 +192,8 @@ lazy val compileSettings = Def.settings( "-rewrite", // for scalafix RemoveUnused: https://scalacenter.github.io/scalafix/docs/rules/RemoveUnused.html "-Wunused:all", - // Warnings about an unused symbol are expected in some source files testing whether code compiles or not. + /* Warnings about an unused symbol are expected in some source files testing whether code compiles or not. */ + "-Wconf:msg=unused import&src=./modules/geometry/src/test/scala/TestDistance.scala:silent", "-Wconf:msg=unused import&src=./modules/imaging/src/test/scala/TestImagingInstances.scala:silent", "-Wconf:msg=unused import&src=./modules/testing/src/test/scala/TestInstanceAvailability.scala:silent", "-Werror", @@ -190,7 +201,7 @@ lazy val compileSettings = Def.settings( Test / console / scalacOptions := (Compile / console / scalacOptions).value, ) -lazy val versionNumber = "0.5.2" +lazy val versionNumber = "0.6.0" lazy val metadataSettings = Def.settings( name := projectName, diff --git a/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala b/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala new file mode 100644 index 0000000..9536bcb --- /dev/null +++ b/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala @@ -0,0 +1,25 @@ +package at.ac.oeaw.imba.gerlich.gerlib.configuration +package instances + +import cats.syntax.all.* +import pureconfig.ConfigReader +import pureconfig.error.CannotConvert +import pureconfig.generic.semiauto.deriveReader +import squants.space.Length + +import at.ac.oeaw.imba.gerlich.gerlib.imaging.{Pixels3D, PixelDefinition} + +/** PureConfig typeclasses instances for imaging-related domain-specific types */ +trait PureConfigImagingInstances: + given (ConfigReader[PixelDefinition]) => ConfigReader[Pixels3D] = + deriveReader[Pixels3D] + + given (readLength: ConfigReader[Length]) => ConfigReader[PixelDefinition] = + readLength.emap(l => + PixelDefinition + .tryToDefine(l) + .leftMap(msg => + CannotConvert(value = l.toString, toType = "PixelDefinition", because = msg) + ) + ) +end PureConfigImagingInstances diff --git a/modules/configuration/src/main/scala/instances/PureConfigSquantsInstances.scala b/modules/configuration/src/main/scala/instances/PureConfigSquantsInstances.scala new file mode 100644 index 0000000..5d1caa5 --- /dev/null +++ b/modules/configuration/src/main/scala/instances/PureConfigSquantsInstances.scala @@ -0,0 +1,23 @@ +package at.ac.oeaw.imba.gerlich.gerlib.configuration +package instances + +import cats.syntax.all.* +import pureconfig.ConfigReader +import pureconfig.error.CannotConvert +import squants.space.Length + +/** PureConfig typeclass instances for squants */ +trait PureConfigSquantsInstances: + given (readString: ConfigReader[String]) => ConfigReader[Length] = + readString.emap: s => + Length + .parseString(s) + .toEither + .leftMap { e => + CannotConvert( + value = s, + toType = "squants.space.Length", + because = e.getMessage + ) + } +end PureConfigSquantsInstances diff --git a/modules/configuration/src/main/scala/instances/package.scala b/modules/configuration/src/main/scala/instances/package.scala new file mode 100644 index 0000000..972787e --- /dev/null +++ b/modules/configuration/src/main/scala/instances/package.scala @@ -0,0 +1,8 @@ +package at.ac.oeaw.imba.gerlich.gerlib.configuration + +/** Configuration-related typeclass instances */ +package object instances: + object all extends AllConfigurationInstances + + trait AllConfigurationInstances extends PureConfigImagingInstances, PureConfigSquantsInstances +end instances diff --git a/modules/configuration/src/main/scala/package.scala b/modules/configuration/src/main/scala/package.scala new file mode 100644 index 0000000..880cc59 --- /dev/null +++ b/modules/configuration/src/main/scala/package.scala @@ -0,0 +1,4 @@ +package at.ac.oeaw.imba.gerlich.gerlib + +/** Functionality related to configuration (e.g., from file or CLI) */ +package object configuration diff --git a/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala b/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala new file mode 100644 index 0000000..cb2c1b6 --- /dev/null +++ b/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala @@ -0,0 +1,129 @@ +package at.ac.oeaw.imba.gerlich.gerlib.configuration + +import cats.* +import cats.syntax.all.* +import pureconfig.* +import squants.space.{LengthUnit, Microns, Nanometers} +import org.scalacheck.* +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks + +import at.ac.oeaw.imba.gerlich.gerlib.configuration.instances.all.given // Bring in the pureconfig.ConfigReader instances. +import at.ac.oeaw.imba.gerlich.gerlib.imaging.Pixels3D + +/** Tests for the parsing of pixel definitions */ +class TestPixelDefinitionConfiguration + extends AnyFunSuite, + ScalaCheckPropertyChecks, + should.Matchers: + + private type MicroOrNano = Microns.type | Nanometers.type + + private def maxSize: Double = 1e6 + + private def genMicroOrNano: Gen[MicroOrNano] = Gen.oneOf(Microns, Nanometers) + + private def genPlusMinusMillion: Gen[Double] = Gen.choose(-maxSize, maxSize) + + private type LengthParseInputs[A] = (A, LengthUnit) + + private def buildRawConfig[A: Show]( + x: LengthParseInputs[A], + y: LengthParseInputs[A], + z: LengthParseInputs[A] + ): String = + given Show[LengthParseInputs[A]] = Show.show { case (num, unit) => + s"${num.show} ${unit.symbol}" + } + s"{ x: ${x.show}, y: ${y.show}, z: ${z.show} }" + + test("Basic examples parse as expected."): + given Arbitrary[Double] = Arbitrary(genPlusMinusMillion) + given Shrink[Double] = Shrink.shrinkAny + given Equality[Double] = + TolerantNumerics.tolerantDoubleEquality(1 / maxSize) + + given Arbitrary[LengthUnit] = Arbitrary { genMicroOrNano } + + forAll(minSuccessful(10000)) { (x: Double, y: Double, z: Double, unit: LengthUnit) => + val perX = 100 + val perY = 200 + val perZ = 400 + val rawConfigData = + buildRawConfig(perX -> unit, perY -> unit, perZ -> unit) + ConfigSource.string(rawConfigData).load[Pixels3D] match { + case Left(errors) => fail(s"Errors: $errors") + case Right(scaling) => + (scaling.liftX(x) in unit).value shouldEqual x * perX + (scaling.liftY(y) in unit).value shouldEqual y * perY + (scaling.liftZ(z) in unit).value shouldEqual z * perZ + } + } + + test("Pixels3D parse requires the correct combination (x, y, z) of keys."): + val legitKeys = Set("x", "y", "z") + + def genSubstitutions: Gen[List[(String, String)]] = + Gen + .containerOf[Set, String](Gen.oneOf(legitKeys)) + .map(_.toList) + .flatMap { toSwapOut => + Gen.listOfN(toSwapOut.length, Gen.alphaNumStr).map(toSwapOut.zip) + } + + val MyUnit = Nanometers + def genInputAndExpectation: Gen[(String, Boolean)] = + val base = buildRawConfig(100 -> MyUnit, 200 -> MyUnit, 300 -> MyUnit) + genSubstitutions.map { subs => + val updated = subs.foldLeft(base) { case (acc, (oldKey, newKey)) => + acc.replace(oldKey ++ ":", newKey ++ ":") + } + val expSuccess = + val newKeys = + legitKeys -- subs.map(_._1).toSet ++ subs.map(_._2).toSet + subs.isEmpty || legitKeys === newKeys + updated -> expSuccess + } + + given [A] => Shrink[A] = Shrink.shrinkAny // no shrinking whatsoever + + forAll(genInputAndExpectation, minSuccessful(1000)) { (rawConfigData, shouldSucceed) => + ConfigSource.string(rawConfigData).load[Pixels3D] match { + case Left(errors) => + if !shouldSucceed then succeed + else + fail( + s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}" + ) + case Right(_) => + if shouldSucceed then succeed + else fail(s"Expected failure with $rawConfigData but succeeded") + } + } + + test( + "Pixels3D parse requires proper length units with strictly positive values." + ): + val inputsAndExpectations = Table( + ("rawConfigData", "shouldSucceed"), + ("{ x: 100 nm, y: 200 nm, z: 300 nm }", true), // good example + ("{ x: 100 nm, y: 200, z: 300 nm }", false), // missing a units on y + ("{ x: 100 nm, y: 200 nm, z: -300 nm }", false) // negative z + ) + forAll(inputsAndExpectations) { (rawConfigData, shouldSucceed) => + ConfigSource.string(rawConfigData).load[Pixels3D] match { + case Left(errors) => + if !shouldSucceed then succeed + else + fail( + s"Expected succeess with $rawConfigData but failed: ${errors.prettyPrint}" + ) + case Right(_) => + if shouldSucceed then succeed + else fail(s"Expected failure with $rawConfigData but succeeded") + } + + } +end TestPixelDefinitionConfiguration diff --git a/modules/geometry/src/main/scala/Distance.scala b/modules/geometry/src/main/scala/Distance.scala index adbd2a3..e117499 100644 --- a/modules/geometry/src/main/scala/Distance.scala +++ b/modules/geometry/src/main/scala/Distance.scala @@ -6,6 +6,8 @@ import cats.* import cats.data.Validated import cats.syntax.all.* +import squants.space.Length + import at.ac.oeaw.imba.gerlich.gerlib.numeric.* /** Piecewise / by-component distance, as absolute differences @@ -91,6 +93,14 @@ object EuclideanDistance: import Distance.given_Order_Distance Order.by(_.get) // use the Double backing the squants.space.Length. + /** Try to read the given text (e.g., a configuration value) as a distance in Euclidean space. */ def parse: String => Either[String, EuclideanDistance] = s => Distance.parse(s).map(EuclideanDistance.apply) + + /** Interpret the given length as a distance in Euclidean space. + * + * @throws java.lang.IllegalArgumentException + * if the given length is negative + */ + def unsafe: Length => EuclideanDistance = Distance.applyUnsafe `andThen` EuclideanDistance.apply end EuclideanDistance diff --git a/modules/geometry/src/main/scala/instances/DistanceInstances.scala b/modules/geometry/src/main/scala/instances/DistanceInstances.scala new file mode 100644 index 0000000..cd550b5 --- /dev/null +++ b/modules/geometry/src/main/scala/instances/DistanceInstances.scala @@ -0,0 +1,20 @@ +package at.ac.oeaw.imba.gerlich.gerlib.geometry +package instances + +import cats.Show +import at.ac.oeaw.imba.gerlich.gerlib.SimpleShow + +/** Typeclass instances related to distance values */ +trait DistanceInstances: + /** Show the quantity and units with a space in between. + * + * This format is chosen so that the rendered value roundtrips through `Distance.parse`, which + * delegates to squants' `Length` parser. + */ + given Show[Distance] = Show.show { d => + s"${d.value} ${d.unit.symbol}" + } + + /** Use .show to derive .show_ */ + given Show[Distance] => SimpleShow[Distance] = SimpleShow.fromShow +end DistanceInstances diff --git a/modules/geometry/src/main/scala/instances/package.scala b/modules/geometry/src/main/scala/instances/package.scala index 0871549..865fa1b 100644 --- a/modules/geometry/src/main/scala/instances/package.scala +++ b/modules/geometry/src/main/scala/instances/package.scala @@ -8,5 +8,5 @@ package object instances: object all extends AllGeometryInstances /** Aggregation of all the geometry-related data types' typeclass instances */ - trait AllGeometryInstances extends CoordinateInstances, PointInstances + trait AllGeometryInstances extends CoordinateInstances, DistanceInstances, PointInstances end instances diff --git a/modules/geometry/src/main/scala/package.scala b/modules/geometry/src/main/scala/package.scala index 59b832b..7c7ed76 100644 --- a/modules/geometry/src/main/scala/package.scala +++ b/modules/geometry/src/main/scala/package.scala @@ -38,7 +38,7 @@ package object geometry: given RuntimeConstraint[Length, Not[Negative]] = new RuntimeConstraint( _.value >= 0, - "Allegedly nonnegative length must actually be nonnegative." + "Allegedly nonnegative length is actually negative." ) /** Leave this alias transparent, since we just want the typelevel 'check' that the length is diff --git a/modules/geometry/src/test/scala/TestDistance.scala b/modules/geometry/src/test/scala/TestDistance.scala index 9b75014..f3c931c 100644 --- a/modules/geometry/src/test/scala/TestDistance.scala +++ b/modules/geometry/src/test/scala/TestDistance.scala @@ -1,12 +1,19 @@ package at.ac.oeaw.imba.gerlich.gerlib.geometry -import squants.space.* +import cats.syntax.all.* import org.scalacheck.{Arbitrary, Gen} import org.scalacheck.Arbitrary.arbitrary import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks + +import io.github.iltotore.iron.{:|, autoRefine, refineUnsafe} +import io.github.iltotore.iron.constraint.any.Not +import io.github.iltotore.iron.constraint.numeric.Negative +import squants.space.* + +import at.ac.oeaw.imba.gerlich.gerlib.geometry.instances.all.given import at.ac.oeaw.imba.gerlich.gerlib.refinement.IllegalRefinement /** Tests for the refinement of a [[squants.space.Length]] value as a distance */ @@ -57,4 +64,42 @@ class TestDistance extends AnyFunSuite, should.Matchers, ScalaCheckPropertyCheck l < d shouldBe l < dAsL } + test("EuclideanDistance.unsafe requires a length, not just int or nonnegative int."): + assertTypeError("EuclideanDistance.unsafe(2)") // plain Int prohibited + assertCompiles("2: Int :| Not[Negative]") // building nonnegative int works + assertTypeError( + "EuclideanDistance.unsafe(2: Int :| Not[Negative])" + ) // nonnegative Int prohibited + assertCompiles("Nanometers(2)") // building Length works + assertCompiles("EuclideanDistance.unsafe(Nanometers(2))") // Length argument works + + test("EuclideanDistance.unsafe is correct for a nonnegative length."): + forAll(Gen.choose(0, Int.MaxValue).map(Nanometers.apply)) { l => + EuclideanDistance.unsafe(l).get shouldEqual Distance.applyUnsafe(l) + } + + test("EuclideanDistance.unsafe fails properly for a negative length."): + forAll(Gen.choose(Int.MinValue, -1).map(Nanometers.apply)) { l => + val obsErr = intercept[IllegalArgumentException] { EuclideanDistance.unsafe(l) } + obsErr.getMessage shouldEqual "Allegedly nonnegative length is actually negative." + } + + test("Distance roundtrips through .show"): + import ArbitraryDistance.given + given Arbitrary[Double] = Arbitrary(Gen.choose(0, Double.MaxValue)) + forAll { (d: Distance) => Distance.parse(d.show) shouldEqual d.asRight } + + test("Distance roundtrips through .simpleShow"): + import at.ac.oeaw.imba.gerlich.gerlib.syntax.all.* // for .show_ + import ArbitraryDistance.given + given Arbitrary[Double] = Arbitrary(Gen.choose(0, Double.MaxValue)) + forAll { (d: Distance) => Distance.parse(d.show_) shouldEqual d.asRight } + + object ArbitraryDistance: + given Arbitrary[Length] => Arbitrary[Distance] = + Arbitrary( + arbitrary[Length] + .map(l => Distance.either(l).fold(msg => throw IllegalRefinement(l, msg), identity)) + ) + end ArbitraryDistance end TestDistance diff --git a/modules/imaging/src/test/scala/TestPixelDefinition.scala b/modules/imaging/src/test/scala/TestPixelDefinition.scala new file mode 100644 index 0000000..79f83fd --- /dev/null +++ b/modules/imaging/src/test/scala/TestPixelDefinition.scala @@ -0,0 +1,54 @@ +package at.ac.oeaw.imba.gerlich.gerlib.imaging + +import cats.* +import cats.syntax.all.* +import squants.space.{Length, LengthUnit, Microns, Nanometers} +import org.scalacheck.* +import org.scalactic.{Equality, TolerantNumerics} +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should +import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks + +/** Tests for the parsing of pixel definitions */ +class TestPixelDefinition extends AnyFunSuite, ScalaCheckPropertyChecks, should.Matchers: + + private type MicroOrNano = Microns.type | Nanometers.type + + private def maxSize: Double = 1e6 + + private def genMicroOrNano: Gen[MicroOrNano] = Gen.oneOf(Microns, Nanometers) + + test( + "With squants input, PixelDefinition.tryToDefine works exactly when the value is (strictly) positive." + ): + def genLength = genMicroOrNano.flatMap { buildLength => + Gen.choose(-1e3, 1e3).map(buildLength.apply) + } + def genPixelCount: Gen[Int] = Gen.choose(0, 1e6.toInt) + + forAll(genLength, genPixelCount, minSuccessful(10000)) { (length, numPixels) => + PixelDefinition.tryToDefine(length) match { + case Left(_) if length.value <= 0 => succeed + case Left(msg) => + fail(s"Pixel definition with length ${length} failed: $msg") + case Right(_) if length.value <= 0 => + fail(s"Pixel definition with length ${length} succeded") + case Right(pxDef) => + import PixelDefinition.syntax.lift + given Equality[Double] = + TolerantNumerics.tolerantDoubleEquality(1 / maxSize) + + val obs: Length = (pxDef.lift(numPixels) in length.unit) + val exp: Length = + val x = numPixels * length.value + val u = length.unit.symbol + Length(x, u).toEither + .leftMap { e => + s"Error bulding length from ($x, $u: ${e.getMessage})" + } + .fold(msg => throw new Exception(msg), identity) + obs.value shouldEqual exp.value + obs.unit shouldEqual exp.unit + } + } +end TestPixelDefinition diff --git a/modules/json/src/test/scala/TestDistanceJsonCodec.scala b/modules/json/src/test/scala/TestDistanceJsonCodec.scala index dd33239..c775bf1 100644 --- a/modules/json/src/test/scala/TestDistanceJsonCodec.scala +++ b/modules/json/src/test/scala/TestDistanceJsonCodec.scala @@ -27,7 +27,7 @@ class TestDistanceJsonCodec extends AnyFunSuite, should.Matchers, ScalaCheckProp import geometry.given // for the ReadWriter instance val err1 = QuantityParseException("Unable to parse Length", "70") - val msg2Suffix = "Allegedly nonnegative length must actually be nonnegative." + val msg2Suffix = "Allegedly nonnegative length is actually negative." forAll( Table( diff --git a/project/Dependencies.scala b/project/Dependencies.scala index ed12aa0..4b29fb5 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -21,10 +21,18 @@ object Dependencies { "org.typelevel" %% s"cats-$name" % "2.13.0" } + object PureConfig { + def getModuleId(name: String): ModuleID = "com.github.pureconfig" %% s"pureconfig-$name" % "0.17.10" + } + /* Versions */ lazy val scalatestVersion = "3.2.19" - /* Core dependencies */ + /* config dependencies */ + lazy val pureconfigCore = PureConfig.getModuleId("core") + lazy val pureconfigGeneric = PureConfig.getModuleId("generic-scala3") + + /* core dependencies */ lazy val catsCore = Cats.getModuleId("core") lazy val kittens = "org.typelevel" %% "kittens" % "3.5.0" lazy val mouse = "org.typelevel" %% "mouse" % "1.3.2" diff --git a/project/build.properties b/project/build.properties index e97b272..7c95fc1 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=1.10.10 +sbt.version=1.12.13 diff --git a/project/plugins.sbt b/project/plugins.sbt index 5b4a982..42cdb8a 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.1") -addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.25.0") -addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.4") +addSbtPlugin("com.github.sbt" % "sbt-github-actions" % "0.31.0") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.6.1") addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.13.1") -addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.2") \ No newline at end of file +addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.14.7") \ No newline at end of file