From 0146cd6c5b4da520d28c6a1678ceface7c9aa633 Mon Sep 17 00:00:00 2001 From: Gregor Heine Date: Wed, 19 Jun 2024 16:31:30 +0100 Subject: [PATCH 1/3] Add duration and byte-count formatters --- build.sbt | 9 +- .../scala/io/flow/util/ByteFormatter.scala | 61 +++++++++++++ .../io/flow/util/DurationFormatter.scala | 27 ++++++ .../io/flow/util/ByteFormatterSpec.scala | 87 +++++++++++++++++++ .../io/flow/util/DurationFormatterSpec.scala | 73 ++++++++++++++++ 5 files changed, 255 insertions(+), 2 deletions(-) create mode 100644 src/main/scala/io/flow/util/ByteFormatter.scala create mode 100644 src/main/scala/io/flow/util/DurationFormatter.scala create mode 100644 src/test/scala/io/flow/util/ByteFormatterSpec.scala create mode 100644 src/test/scala/io/flow/util/DurationFormatterSpec.scala diff --git a/build.sbt b/build.sbt index f1e00f1..003c4b1 100644 --- a/build.sbt +++ b/build.sbt @@ -8,7 +8,7 @@ ThisBuild / javacOptions ++= Seq("-source", "17", "-target", "17") enablePlugins(GitVersioning) git.useGitDescribe := true -lazy val allScalacOptions = Seq( +scalacOptions ++= Seq( "-feature", "-Xfatal-warnings", "-unchecked", @@ -21,6 +21,12 @@ lazy val allScalacOptions = Seq( "-release:17", ) +Test / scalacOptions ++= Seq( + // Allow using -Wnonunit-statement to find bugs in tests without exploding from scalatest assertions + "-Wconf:msg=unused value of type org.scalatest.Assertion:s", + "-Wconf:msg=unused value of type org.scalamock:s" +) + doc / javacOptions := Seq("-encoding", "UTF-8") licenses += ("MIT", url("http://opensource.org/licenses/MIT")) @@ -44,7 +50,6 @@ Test / javaOptions ++= Seq( "--add-opens=java.base/sun.security.ssl=ALL-UNNAMED", "--add-opens=java.base/java.lang=ALL-UNNAMED", ) -scalacOptions ++= allScalacOptions ++ Seq("-release", "17") scalafmtOnCompile := true credentials += Credentials( "Artifactory Realm", diff --git a/src/main/scala/io/flow/util/ByteFormatter.scala b/src/main/scala/io/flow/util/ByteFormatter.scala new file mode 100644 index 0000000..6415dea --- /dev/null +++ b/src/main/scala/io/flow/util/ByteFormatter.scala @@ -0,0 +1,61 @@ +package io.flow.util + +import java.math.RoundingMode +import java.text.DecimalFormat + +object ByteFormatter { + def byteCountSI(bytes: Long): String = { + format(bytes, true) + } + + def byteCountBinary(bytes: Long): String = { + format(bytes, false) + } + + private[util] val (df0, df1, df2) = { + def withDownRound(df: DecimalFormat) = { + df.setRoundingMode(RoundingMode.DOWN) + df + } + ( + withDownRound(new DecimalFormat("0")), + withDownRound(new DecimalFormat("0.#")), + withDownRound(new DecimalFormat("0.##")), + ) + } + + private def format(bytes: Long, si: Boolean) = { + val absBytes = if (bytes == Long.MinValue) Long.MaxValue else Math.abs(bytes) + val (baseValue, unitStrings) = + if (si) + (1000, Vector("k", "M", "G", "T", "P", "E", "Z", "Y")) + else + (1024, Vector("Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi", "Yi")) + + def getExponent(curBytes: Long, baseValue: Int, curExponent: Int = 0): Int = { + if (curBytes < baseValue) { + curExponent + } else { + val newExponent = 1 + curExponent + getExponent(curBytes / baseValue, baseValue, newExponent) + } + } + + if (absBytes < baseValue) { + s"$bytes B" + } else { + val signum = if (bytes < 0) "-" else "" + val exponent = getExponent(absBytes, baseValue) + val divisor = Math.pow(baseValue.toDouble, exponent.toDouble) + val unitString = unitStrings(exponent - 1) + + val res = absBytes / divisor + 3 - res.toLong.toString.length match { + case 2 => f"$signum${df2.format(res)} ${unitString}B" + case 1 => f"$signum${df1.format(res)} ${unitString}B" + case _ => f"$signum${df0.format(res)} ${unitString}B" + } + + } + } +} diff --git a/src/main/scala/io/flow/util/DurationFormatter.scala b/src/main/scala/io/flow/util/DurationFormatter.scala new file mode 100644 index 0000000..0afcdea --- /dev/null +++ b/src/main/scala/io/flow/util/DurationFormatter.scala @@ -0,0 +1,27 @@ +package io.flow.util + +import scala.concurrent.duration.FiniteDuration + +object DurationFormatter { + import ByteFormatter.{df0, df1, df2} + private val units = Seq((1000L, "ns"), (1000L, "us"), (1000L, "ms"), (60L, "sec"), (60L, "min"), (24L, "h")) + + def format(duration: FiniteDuration): String = { + val nanos = duration.toNanos.toDouble + val (t, u) = units.foldLeft((nanos, None: Option[String])) { case ((time, result), (base, unit)) => + result.fold { + if (time < base) { + (time, Some(unit)) + } else { + (time / base, None) + } + }(_ => (time, result)) + } + val unit = u.getOrElse("days") + 3 - t.toLong.toString.length match { + case 2 => f"${df2.format(t)} $unit" + case 1 => f"${df1.format(t)} $unit" + case _ => f"${df0.format(t)} $unit" + } + } +} diff --git a/src/test/scala/io/flow/util/ByteFormatterSpec.scala b/src/test/scala/io/flow/util/ByteFormatterSpec.scala new file mode 100644 index 0000000..84d4ea9 --- /dev/null +++ b/src/test/scala/io/flow/util/ByteFormatterSpec.scala @@ -0,0 +1,87 @@ +package io.flow.util + +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec + +class ByteFormatterSpec extends AnyWordSpec with Matchers { + "SI byte count" should { + "byte values" in { + ByteFormatter.byteCountSI(0L) mustBe "0 B" + ByteFormatter.byteCountSI(1L) mustBe "1 B" + ByteFormatter.byteCountSI(10L) mustBe "10 B" + ByteFormatter.byteCountSI(100L) mustBe "100 B" + ByteFormatter.byteCountSI(999L) mustBe "999 B" + } + + "KB values" in { + ByteFormatter.byteCountSI(1000L) mustBe "1 kB" + ByteFormatter.byteCountSI(1009L) mustBe "1 kB" + ByteFormatter.byteCountSI(1010L) mustBe "1.01 kB" + ByteFormatter.byteCountSI(9999L) mustBe "9.99 kB" + ByteFormatter.byteCountSI(10000L) mustBe "10 kB" + ByteFormatter.byteCountSI(10099L) mustBe "10 kB" + ByteFormatter.byteCountSI(10100L) mustBe "10.1 kB" + ByteFormatter.byteCountSI(99999L) mustBe "99.9 kB" + ByteFormatter.byteCountSI(100000L) mustBe "100 kB" + ByteFormatter.byteCountSI(100999L) mustBe "100 kB" + ByteFormatter.byteCountSI(101000L) mustBe "101 kB" + ByteFormatter.byteCountSI(999999L) mustBe "999 kB" + } + + "MB values" in { + ByteFormatter.byteCountSI(1000000L) mustBe "1 MB" + ByteFormatter.byteCountSI(999999999L) mustBe "999 MB" + } + + "GB values" in { + ByteFormatter.byteCountSI(1000000000L) mustBe "1 GB" + ByteFormatter.byteCountSI(999999999999L) mustBe "999 GB" + } + + "TB values" in { + ByteFormatter.byteCountSI(1000000000000L) mustBe "1 TB" + ByteFormatter.byteCountSI(999999999999999L) mustBe "999 TB" + } + } + + "Binary byte count" should { + "byte values" in { + ByteFormatter.byteCountBinary(0L) mustBe "0 B" + ByteFormatter.byteCountBinary(1L) mustBe "1 B" + ByteFormatter.byteCountBinary(10L) mustBe "10 B" + ByteFormatter.byteCountBinary(100L) mustBe "100 B" + ByteFormatter.byteCountBinary(999L) mustBe "999 B" + ByteFormatter.byteCountBinary(1023L) mustBe "1023 B" + } + + "KiB values" in { + ByteFormatter.byteCountBinary(1024L) mustBe "1 KiB" + ByteFormatter.byteCountBinary(1034L) mustBe "1 KiB" + ByteFormatter.byteCountBinary(1035L) mustBe "1.01 KiB" + ByteFormatter.byteCountBinary(1024L * 10 - 1) mustBe "9.99 KiB" + ByteFormatter.byteCountBinary(1024L * 10) mustBe "10 KiB" + ByteFormatter.byteCountBinary(1024L * 11 - 1) mustBe "10.9 KiB" + ByteFormatter.byteCountBinary(1024L * 11) mustBe "11 KiB" + ByteFormatter.byteCountBinary(1024L * 100 - 1) mustBe "99.9 KiB" + ByteFormatter.byteCountBinary(1024L * 100) mustBe "100 KiB" + ByteFormatter.byteCountBinary(1024L * 128 - 1) mustBe "127 KiB" + ByteFormatter.byteCountBinary(1024L * 128) mustBe "128 KiB" + ByteFormatter.byteCountBinary(1024L * 1024 - 1) mustBe "1023 KiB" + } + + "MiB values" in { + ByteFormatter.byteCountBinary(1024L * 1024) mustBe "1 MiB" + ByteFormatter.byteCountBinary(1024L * 1024 * 1024 - 1) mustBe "1023 MiB" + } + + "GiB values" in { + ByteFormatter.byteCountBinary(1024L * 1024 * 1024) mustBe "1 GiB" + ByteFormatter.byteCountBinary(1024L * 1024 * 1024 * 1024 - 1) mustBe "1023 GiB" + } + + "TiB values" in { + ByteFormatter.byteCountBinary(1024L * 1024 * 1024 * 1024) mustBe "1 TiB" + ByteFormatter.byteCountBinary(1024L * 1024 * 1024 * 1024 * 1024 - 1) mustBe "1023 TiB" + } + } +} diff --git a/src/test/scala/io/flow/util/DurationFormatterSpec.scala b/src/test/scala/io/flow/util/DurationFormatterSpec.scala new file mode 100644 index 0000000..5d02396 --- /dev/null +++ b/src/test/scala/io/flow/util/DurationFormatterSpec.scala @@ -0,0 +1,73 @@ +package io.flow.util + +import org.scalatest.matchers.must.Matchers +import org.scalatest.wordspec.AnyWordSpec +import scala.concurrent.duration._ + +class DurationFormatterSpec extends AnyWordSpec with Matchers { + "nanosecond values" in { + DurationFormatter.format(0.nanos) mustBe "0 ns" + DurationFormatter.format(1.nanos) mustBe "1 ns" + DurationFormatter.format(9.nanos) mustBe "9 ns" + DurationFormatter.format(10.nanos) mustBe "10 ns" + DurationFormatter.format(99.nanos) mustBe "99 ns" + DurationFormatter.format(100.nanos) mustBe "100 ns" + DurationFormatter.format(999.nanos) mustBe "999 ns" + } + + "microsecond values" in { + DurationFormatter.format(1000.nanos) mustBe "1 us" + DurationFormatter.format(1009.nanos) mustBe "1 us" + DurationFormatter.format(1010.nanos) mustBe "1.01 us" + DurationFormatter.format(1099.nanos) mustBe "1.09 us" + DurationFormatter.format(1100.nanos) mustBe "1.1 us" + DurationFormatter.format(9999.nanos) mustBe "9.99 us" + DurationFormatter.format(10000.nanos) mustBe "10 us" + DurationFormatter.format(10099.nanos) mustBe "10 us" + DurationFormatter.format(10100.nanos) mustBe "10.1 us" + DurationFormatter.format(99999.nanos) mustBe "99.9 us" + DurationFormatter.format(100000.nanos) mustBe "100 us" + DurationFormatter.format(999999.nanos) mustBe "999 us" + } + + "millisecond values" in { + DurationFormatter.format(1.milli) mustBe "1 ms" + DurationFormatter.format(1000000.nanos) mustBe "1 ms" + DurationFormatter.format(999.millis) mustBe "999 ms" + DurationFormatter.format(999999999.nanos) mustBe "999 ms" + } + + "second values" in { + DurationFormatter.format(1.second) mustBe "1 sec" + DurationFormatter.format(1000.millis) mustBe "1 sec" + DurationFormatter.format(1000000000.nanos) mustBe "1 sec" + DurationFormatter.format(59.seconds) mustBe "59 sec" + DurationFormatter.format(59999.millis) mustBe "59.9 sec" + DurationFormatter.format(59999999999L.nanos) mustBe "59.9 sec" + } + + "minute values" in { + DurationFormatter.format(1.minute) mustBe "1 min" + DurationFormatter.format(60.seconds) mustBe "1 min" + DurationFormatter.format(60000000000L.nanos) mustBe "1 min" + DurationFormatter.format(59.minutes) mustBe "59 min" + DurationFormatter.format(3599.seconds) mustBe "59.9 min" + DurationFormatter.format(3599999999999L.nanos) mustBe "59.9 min" + } + + "hour values" in { + DurationFormatter.format(1.hour) mustBe "1 h" + DurationFormatter.format(60.minutes) mustBe "1 h" + DurationFormatter.format(3600000000000L.nanos) mustBe "1 h" + DurationFormatter.format(23.hours) mustBe "23 h" + DurationFormatter.format(1439.minutes) mustBe "23.9 h" + DurationFormatter.format(86399999999999L.nanos) mustBe "23.9 h" + } + + "day values" in { + DurationFormatter.format(1.day) mustBe "1 days" + DurationFormatter.format(24.hours) mustBe "1 days" + DurationFormatter.format(86400000000000L.nanos) mustBe "1 days" + DurationFormatter.format(100000.days) mustBe "100000 days" + } +} From bf1c99dea202d11220d8a4cd6a8f9364ab2bf5b5 Mon Sep 17 00:00:00 2001 From: Gregor Heine Date: Wed, 19 Jun 2024 16:41:21 +0100 Subject: [PATCH 2/3] scalafmt --- build.sbt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 003c4b1..9359188 100644 --- a/build.sbt +++ b/build.sbt @@ -24,7 +24,7 @@ scalacOptions ++= Seq( Test / scalacOptions ++= Seq( // Allow using -Wnonunit-statement to find bugs in tests without exploding from scalatest assertions "-Wconf:msg=unused value of type org.scalatest.Assertion:s", - "-Wconf:msg=unused value of type org.scalamock:s" + "-Wconf:msg=unused value of type org.scalamock:s", ) doc / javacOptions := Seq("-encoding", "UTF-8") From 10f35223198a1904d4fc7c07b5ba1ce03293c860 Mon Sep 17 00:00:00 2001 From: Gregor Heine Date: Wed, 19 Jun 2024 17:04:43 +0100 Subject: [PATCH 3/3] update --- src/main/scala/io/flow/util/ByteFormatter.scala | 6 +++--- src/main/scala/io/flow/util/DurationFormatter.scala | 12 +++++++----- src/test/scala/io/flow/util/ByteFormatterSpec.scala | 10 ++++++++++ .../scala/io/flow/util/DurationFormatterSpec.scala | 2 ++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/main/scala/io/flow/util/ByteFormatter.scala b/src/main/scala/io/flow/util/ByteFormatter.scala index 6415dea..eb9760c 100644 --- a/src/main/scala/io/flow/util/ByteFormatter.scala +++ b/src/main/scala/io/flow/util/ByteFormatter.scala @@ -51,9 +51,9 @@ object ByteFormatter { val res = absBytes / divisor 3 - res.toLong.toString.length match { - case 2 => f"$signum${df2.format(res)} ${unitString}B" - case 1 => f"$signum${df1.format(res)} ${unitString}B" - case _ => f"$signum${df0.format(res)} ${unitString}B" + case 2 => s"$signum${df2.format(res)} ${unitString}B" + case 1 => s"$signum${df1.format(res)} ${unitString}B" + case _ => s"$signum${df0.format(res)} ${unitString}B" } } diff --git a/src/main/scala/io/flow/util/DurationFormatter.scala b/src/main/scala/io/flow/util/DurationFormatter.scala index 0afcdea..0ea2c1e 100644 --- a/src/main/scala/io/flow/util/DurationFormatter.scala +++ b/src/main/scala/io/flow/util/DurationFormatter.scala @@ -7,8 +7,9 @@ object DurationFormatter { private val units = Seq((1000L, "ns"), (1000L, "us"), (1000L, "ms"), (60L, "sec"), (60L, "min"), (24L, "h")) def format(duration: FiniteDuration): String = { - val nanos = duration.toNanos.toDouble - val (t, u) = units.foldLeft((nanos, None: Option[String])) { case ((time, result), (base, unit)) => + val nanos = duration.toNanos + val nanosD = (if (nanos == Long.MinValue) Long.MaxValue else Math.abs(nanos)).toDouble + val (t, u) = units.foldLeft((nanosD, None: Option[String])) { case ((time, result), (base, unit)) => result.fold { if (time < base) { (time, Some(unit)) @@ -18,10 +19,11 @@ object DurationFormatter { }(_ => (time, result)) } val unit = u.getOrElse("days") + val signum = if (nanos < 0) "-" else "" 3 - t.toLong.toString.length match { - case 2 => f"${df2.format(t)} $unit" - case 1 => f"${df1.format(t)} $unit" - case _ => f"${df0.format(t)} $unit" + case 2 => s"$signum${df2.format(t)} $unit" + case 1 => s"$signum${df1.format(t)} $unit" + case _ => s"$signum${df0.format(t)} $unit" } } } diff --git a/src/test/scala/io/flow/util/ByteFormatterSpec.scala b/src/test/scala/io/flow/util/ByteFormatterSpec.scala index 84d4ea9..2c58ee1 100644 --- a/src/test/scala/io/flow/util/ByteFormatterSpec.scala +++ b/src/test/scala/io/flow/util/ByteFormatterSpec.scala @@ -42,6 +42,11 @@ class ByteFormatterSpec extends AnyWordSpec with Matchers { ByteFormatter.byteCountSI(1000000000000L) mustBe "1 TB" ByteFormatter.byteCountSI(999999999999999L) mustBe "999 TB" } + + "Extreme values" in { + ByteFormatter.byteCountSI(Long.MinValue) mustBe "-9.22 EB" + ByteFormatter.byteCountSI(Long.MaxValue) mustBe "9.22 EB" + } } "Binary byte count" should { @@ -83,5 +88,10 @@ class ByteFormatterSpec extends AnyWordSpec with Matchers { ByteFormatter.byteCountBinary(1024L * 1024 * 1024 * 1024) mustBe "1 TiB" ByteFormatter.byteCountBinary(1024L * 1024 * 1024 * 1024 * 1024 - 1) mustBe "1023 TiB" } + + "Extreme values" in { + ByteFormatter.byteCountBinary(Long.MinValue) mustBe "-8 EiB" + ByteFormatter.byteCountBinary(Long.MaxValue) mustBe "8 EiB" + } } } diff --git a/src/test/scala/io/flow/util/DurationFormatterSpec.scala b/src/test/scala/io/flow/util/DurationFormatterSpec.scala index 5d02396..3065e4c 100644 --- a/src/test/scala/io/flow/util/DurationFormatterSpec.scala +++ b/src/test/scala/io/flow/util/DurationFormatterSpec.scala @@ -69,5 +69,7 @@ class DurationFormatterSpec extends AnyWordSpec with Matchers { DurationFormatter.format(24.hours) mustBe "1 days" DurationFormatter.format(86400000000000L.nanos) mustBe "1 days" DurationFormatter.format(100000.days) mustBe "100000 days" + DurationFormatter.format(Long.MaxValue.nanos) mustBe "106751 days" + DurationFormatter.format((Long.MinValue + 1L).nanos) mustBe "-106751 days" } }