From f7178442143142558f57267c6df18079f6166436 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Tue, 30 Jun 2026 23:30:00 +0200 Subject: [PATCH 1/8] chore: start new development version --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index f09c72c..12f6f25 100644 --- a/build.sbt +++ b/build.sbt @@ -190,7 +190,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-SNAPSHOT" lazy val metadataSettings = Def.settings( name := projectName, From 71a82d320c87497245ea80b843900ae4095e1aa1 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Wed, 1 Jul 2026 01:41:58 +0200 Subject: [PATCH 2/8] chore: update plugins to latest versions, and CI configs accordingly --- .github/workflows/ci.yml | 18 +++++++++--------- .github/workflows/clean.yml | 3 +++ project/plugins.sbt | 6 +++--- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72b510f..b235bfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,13 +31,13 @@ jobs: 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 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 11 @@ -45,7 +45,7 @@ jobs: - 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 @@ -53,7 +53,7 @@ jobs: - name: Setup Java (temurin@19) if: matrix.java == 'temurin@19' - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: distribution: temurin java-version: 19 @@ -61,7 +61,7 @@ jobs: - 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 @@ -75,14 +75,14 @@ jobs: - name: Check formatting with scalafmt if: runner.os == 'ubuntu-latest' && runner.java-version == '11' - run: sbt '++ ${{ matrix.scala }}' scalafmtCheckAll + run: sbt '++ ${{ matrix.scala }}; scalafmtCheckAll' - name: Lint with scalafix if: runner.os == 'ubuntu-latest' && runner.java-version == '11' - run: sbt '++ ${{ matrix.scala }}' 'scalafixAll --check' + 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/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 From 13b9eabe619961ee8597f70e23528d90d749c256 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Tue, 30 Jun 2026 23:30:57 +0200 Subject: [PATCH 3/8] feat: add .unsafe(Length) to the API of EuclideanDistance --- CHANGELOG.md | 9 +++++++ build.sbt | 3 ++- .../geometry/src/main/scala/Distance.scala | 6 +++++ modules/geometry/src/main/scala/package.scala | 2 +- .../src/test/scala/TestDistance.scala | 26 +++++++++++++++++-- .../test/scala/TestDistanceJsonCodec.scala | 2 +- 6 files changed, 43 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5036dcf..6bfd17f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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). +## Unreleased + +### Added +* `EuclideanDistance.unsafe` method, wrapping a `squants.space.Length` value as a `EuclideanDistance`, +performing the necessary validation as nonnegative and erroring otherwise. + +### 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." + ## [v0.5.2] - 2026-03-18 ### Fixed diff --git a/build.sbt b/build.sbt index 12f6f25..5287430 100644 --- a/build.sbt +++ b/build.sbt @@ -182,7 +182,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", diff --git a/modules/geometry/src/main/scala/Distance.scala b/modules/geometry/src/main/scala/Distance.scala index adbd2a3..a1a5ec4 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,10 @@ 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) + + /** Try to interpret the given length value as a distance in Euclidean space. */ + def unsafe: Length => EuclideanDistance = Distance.applyUnsafe `andThen` EuclideanDistance.apply end EuclideanDistance 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..cf6aaa3 100644 --- a/modules/geometry/src/test/scala/TestDistance.scala +++ b/modules/geometry/src/test/scala/TestDistance.scala @@ -1,12 +1,16 @@ package at.ac.oeaw.imba.gerlich.gerlib.geometry -import squants.space.* - 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} +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.refinement.IllegalRefinement /** Tests for the refinement of a [[squants.space.Length]] value as a distance */ @@ -57,4 +61,22 @@ 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." + } + end TestDistance 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( From 38cfc6fc88f2c5fe57ace4e1ad2801df4dd04e62 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Thu, 2 Jul 2026 02:17:23 +0200 Subject: [PATCH 4/8] feat: add configuration module, for scopt and pureconfig with Pixels3D and PixelDefinition --- CHANGELOG.md | 1 + build.sbt | 11 +- .../PureConfigImagingInstances.scala | 23 +++ .../PureConfigSquantsInstances.scala | 23 +++ .../src/main/scala/instances/package.scala | 10 ++ .../src/main/scala/package.scala | 4 + .../TestPixelDefinitionConfiguration.scala | 131 ++++++++++++++++++ .../src/test/scala/TestPixelDefinition.scala | 58 ++++++++ project/Dependencies.scala | 10 +- 9 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala create mode 100644 modules/configuration/src/main/scala/instances/PureConfigSquantsInstances.scala create mode 100644 modules/configuration/src/main/scala/instances/package.scala create mode 100644 modules/configuration/src/main/scala/package.scala create mode 100644 modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala create mode 100644 modules/imaging/src/test/scala/TestPixelDefinition.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bfd17f..c2bc11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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`. ### 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." diff --git a/build.sbt b/build.sbt index 5287430..bbff376 100644 --- a/build.sbt +++ b/build.sbt @@ -52,7 +52,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 +65,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( 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..3559854 --- /dev/null +++ b/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.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 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..b7ee344 --- /dev/null +++ b/modules/configuration/src/main/scala/instances/package.scala @@ -0,0 +1,10 @@ +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..ad6fc1e --- /dev/null +++ b/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala @@ -0,0 +1,131 @@ +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/imaging/src/test/scala/TestPixelDefinition.scala b/modules/imaging/src/test/scala/TestPixelDefinition.scala new file mode 100644 index 0000000..9a88bf4 --- /dev/null +++ b/modules/imaging/src/test/scala/TestPixelDefinition.scala @@ -0,0 +1,58 @@ +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/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" From 557162e133128f36232c817b3e752a653c2b94db Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Sun, 5 Jul 2026 21:29:44 +0200 Subject: [PATCH 5/8] feat: add Show and SimpleShow instances for Distance --- CHANGELOG.md | 2 ++ .../scala/instances/DistanceInstances.scala | 16 +++++++++ .../src/main/scala/instances/package.scala | 2 +- .../src/test/scala/TestDistance.scala | 35 +++++++++++++++---- 4 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 modules/geometry/src/main/scala/instances/DistanceInstances.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index c2bc11e..80504e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `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` ### 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." + ## [v0.5.2] - 2026-03-18 ### Fixed 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..8467bb1 --- /dev/null +++ b/modules/geometry/src/main/scala/instances/DistanceInstances.scala @@ -0,0 +1,16 @@ +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 */ + 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/test/scala/TestDistance.scala b/modules/geometry/src/test/scala/TestDistance.scala index cf6aaa3..f3c931c 100644 --- a/modules/geometry/src/test/scala/TestDistance.scala +++ b/modules/geometry/src/test/scala/TestDistance.scala @@ -1,16 +1,19 @@ package at.ac.oeaw.imba.gerlich.gerlib.geometry +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} +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 */ @@ -64,19 +67,39 @@ class TestDistance extends AnyFunSuite, should.Matchers, ScalaCheckPropertyCheck 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 + 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) + 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) } + 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 From c0aea0dd5cbf782b977d12ca662ebd0d82934c69 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Sun, 5 Jul 2026 21:31:03 +0200 Subject: [PATCH 6/8] bump sbt to latest 1.x release version --- CHANGELOG.md | 2 +- project/build.properties | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80504e7..79c8103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ performing the necessary validation as nonnegative and erroring otherwise. ### 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 ## [v0.5.2] - 2026-03-18 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 From d980ae4b4680c8e6e405a2319f3c52d2eb081d4c Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Sun, 5 Jul 2026 21:32:17 +0200 Subject: [PATCH 7/8] chore: apply formatter --- .../PureConfigImagingInstances.scala | 12 ++-- .../src/main/scala/instances/package.scala | 4 +- .../TestPixelDefinitionConfiguration.scala | 70 +++++++++---------- .../src/test/scala/TestPixelDefinition.scala | 54 +++++++------- 4 files changed, 67 insertions(+), 73 deletions(-) diff --git a/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala b/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala index 3559854..9536bcb 100644 --- a/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala +++ b/modules/configuration/src/main/scala/instances/PureConfigImagingInstances.scala @@ -14,10 +14,12 @@ 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) - ) + 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/package.scala b/modules/configuration/src/main/scala/instances/package.scala index b7ee344..972787e 100644 --- a/modules/configuration/src/main/scala/instances/package.scala +++ b/modules/configuration/src/main/scala/instances/package.scala @@ -4,7 +4,5 @@ package at.ac.oeaw.imba.gerlich.gerlib.configuration package object instances: object all extends AllConfigurationInstances - trait AllConfigurationInstances - extends PureConfigImagingInstances, - PureConfigSquantsInstances + trait AllConfigurationInstances extends PureConfigImagingInstances, PureConfigSquantsInstances end instances diff --git a/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala b/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala index ad6fc1e..cb2c1b6 100644 --- a/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala +++ b/modules/configuration/src/test/scala/TestPixelDefinitionConfiguration.scala @@ -47,20 +47,19 @@ class TestPixelDefinitionConfiguration 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 - } + 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."): @@ -90,19 +89,18 @@ class TestPixelDefinitionConfiguration 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") - } + 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( @@ -116,15 +114,15 @@ class TestPixelDefinitionConfiguration ) 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") + 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") } } diff --git a/modules/imaging/src/test/scala/TestPixelDefinition.scala b/modules/imaging/src/test/scala/TestPixelDefinition.scala index 9a88bf4..79f83fd 100644 --- a/modules/imaging/src/test/scala/TestPixelDefinition.scala +++ b/modules/imaging/src/test/scala/TestPixelDefinition.scala @@ -10,10 +10,7 @@ import org.scalatest.matchers.should import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks /** Tests for the parsing of pixel definitions */ -class TestPixelDefinition - extends AnyFunSuite, - ScalaCheckPropertyChecks, - should.Matchers: +class TestPixelDefinition extends AnyFunSuite, ScalaCheckPropertyChecks, should.Matchers: private type MicroOrNano = Microns.type | Nanometers.type @@ -29,30 +26,29 @@ class TestPixelDefinition } 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 - } + 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 From dc1597cdf59cba58775b0ca0d6768f133e19d6c4 Mon Sep 17 00:00:00 2001 From: Vince Reuter Date: Sun, 5 Jul 2026 23:22:50 +0200 Subject: [PATCH 8/8] chore: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c8103..b6ebc4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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."