Repository: takahirom/roborazzi
Pull request #480 (2024-09-11 12:25:04)

Implement diff. percentage
**This PR is my proposed solution for #460.**

Please take a look at the way I implemented the property `diffPercentage` in the `CaptureResult.Changed` data class. Another possibility would be to declare `diffPercentage` directly as a property in the `CaptureResult` interface, and implement them as null-returning-getters in the other data classes, as you have done for other properties. (This would also remove the need for my if-statement in `CaptureResultTest.kt`.)

Also, as far as I can tell, you plan to use the Dropbox Differ in the future for iOS as well (see [this](https://github.com/takahirom/roborazzi/blob/1924d408d2e99e1d9e65a557d7f3711fa9368367/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt#L366) comment). That's why I have implemented the `diffPercentage` to always equal `Float.NaN` (undefined) for iOS (the reason being that your diffing algorithm for iOS would require a lot of changes to be able to deliver the needed data for calculating a diff. percentage).

I hope you like this contribution :)
Don't hesitate to tell me if I need to change something.


Patch:
diff --git a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt
index 4627ddee..f2a089d4 100644
--- a/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt
+++ b/include-build/roborazzi-core/src/commonJvmMain/kotlin/com/github/takahirom/roborazzi/processOutputImageAndReport.kt
@@ -2,6 +2,7 @@ package com.github.takahirom.roborazzi
 
 import com.dropbox.differ.ImageComparator
 import java.io.File
+import kotlin.properties.Delegates
 
 fun interface EmptyCanvasFactory {
   operator fun invoke(
@@ -84,6 +85,10 @@ fun processOutputImageAndReport(
         bufferedImageType = recordOptions.pixelBitConfig.toBufferedImageType()
       )
     }
+
+    // Only used by CaptureResult.Changed
+    var diffPercentage by Delegates.notNull<Float>()
+
     val changed = if (height == goldenRoboCanvas.height && width == goldenRoboCanvas.width) {
       val comparisonResult: ImageComparator.ComparisonResult =
         newRoboCanvas.differ(
@@ -91,10 +96,12 @@ fun processOutputImageAndReport(
           resizeScale = resizeScale,
           imageComparator = roborazziOptions.compareOptions.imageComparator
         )
+      diffPercentage = comparisonResult.pixelDifferences.toFloat() / comparisonResult.pixelCount
       val changed = !roborazziOptions.compareOptions.resultValidator(comparisonResult)
       reportLog("${goldenFile.name} The differ result :$comparisonResult changed:$changed")
       changed
     } else {
+      diffPercentage = Float.NaN // diff. percentage is not defined if new canvas and golden canvas dimensions differ
       reportLog("${goldenFile.name} The image size is changed. actual = (${goldenRoboCanvas.width}, ${goldenRoboCanvas.height}), golden = (${newRoboCanvas.croppedWidth}, ${newRoboCanvas.croppedHeight})")
       true
     }
@@ -147,6 +154,7 @@ fun processOutputImageAndReport(
           actualFile = actualFile.absolutePath,
           goldenFile = goldenFile.absolutePath,
           timestampNs = System.nanoTime(),
+          diffPercentage = diffPercentage,
           contextData = contextData,
         )
       } else {
diff --git a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt
index e9497a32..938f273e 100644
--- a/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt
+++ b/include-build/roborazzi-core/src/commonMain/kotlin/com/github/takahirom/roborazzi/CaptureResult.kt
@@ -74,6 +74,8 @@ sealed interface CaptureResult {
     override val actualFile:@Contextual String,
     @SerialName("timestamp")
     override val timestampNs: Long,
+    @SerialName("diff_percentage")
+    val diffPercentage: Float,
     @SerialName("context_data")
     override val contextData: Map<String,@Contextual Any>
   ) : CaptureResult {
diff --git a/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt b/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt
index be1bfda6..f9054981 100644
--- a/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt
+++ b/roborazzi-compose-ios/src/iosMain/kotlin/io/github/takahirom/roborazzi/RoborazziIos.kt
@@ -539,6 +539,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           actualFile = actualFilePath,
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
+          diffPercentage = Float.NaN,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -581,6 +582,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           actualFile = actualFilePath,
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
+          diffPercentage = Float.NaN,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -623,6 +625,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           actualFile = goldenFilePath,
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
+          diffPercentage = Float.NaN,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)
@@ -665,6 +668,7 @@ fun SemanticsNodeInteraction.captureRoboImage(
           actualFile = goldenFilePath,
           goldenFile = goldenFilePath,
           timestampNs = getNanoTime(),
+          diffPercentage = Float.NaN,
           contextData = emptyMap()
         )
         writeJson(result, resultsDir, nameWithoutExtension)


Test patch:
diff --git a/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt b/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt
index 7e852d5a..efcf8741 100644
--- a/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt
+++ b/include-build/roborazzi-core/src/jvmTest/kotlin/io/github/takahirom/roborazzi/CaptureResultTest.kt
@@ -1,16 +1,15 @@
 package io.github.takahirom.roborazzi
 
 import com.github.takahirom.roborazzi.CaptureResult
-import com.github.takahirom.roborazzi.CaptureResults.Companion.json
 import com.github.takahirom.roborazzi.CaptureResults
+import com.github.takahirom.roborazzi.CaptureResults.Companion.json
 import com.github.takahirom.roborazzi.ResultSummary
-import com.github.takahirom.roborazzi.absolutePath
-import kotlinx.io.files.Path
 import kotlinx.serialization.json.JsonObject
 import kotlinx.serialization.json.boolean
 import kotlinx.serialization.json.double
 import kotlinx.serialization.json.doubleOrNull
 import kotlinx.serialization.json.encodeToJsonElement
+import kotlinx.serialization.json.float
 import kotlinx.serialization.json.int
 import kotlinx.serialization.json.intOrNull
 import kotlinx.serialization.json.jsonArray
@@ -47,6 +46,7 @@ class CaptureResultTest {
         goldenFile = "/golden_file",
         actualFile = "/actual_file",
         timestampNs = 123456789,
+        diffPercentage = 0.123f,
         contextData = mapOf("key" to Long.MAX_VALUE - 100),
       ),
       CaptureResult.Unchanged(
@@ -95,6 +95,12 @@ class CaptureResultTest {
         expectedCaptureResult.timestampNs,
         actualJsonResult["timestamp"]?.jsonPrimitive?.long
       )
+      if (expectedCaptureResult is CaptureResult.Changed) {
+        assertEquals(
+          expectedCaptureResult.diffPercentage,
+          actualJsonResult["diff_percentage"]?.jsonPrimitive?.float
+        )
+      }
       assertEquals(
         expectedCaptureResult.contextData.entries.map { it.key to it.value },
         (actualJsonResult["context_data"]?.jsonObject?.entries
@@ -154,6 +160,7 @@ class CaptureResultTest {
                     "actual_file_path": "actual_file",
                     "golden_file_path": "golden_file",
                     "timestamp": 123456789,
+                    "diff_percentage": 0.123,
                     "context_data": {
                         "key": 9223372036854775707
                     }
@@ -201,6 +208,7 @@ class CaptureResultTest {
     assertEquals("actual_file", actualChangedResult.actualFile)
     assertEquals("golden_file", actualChangedResult.goldenFile)
     assertEquals(123456789, actualChangedResult.timestampNs)
+    assertEquals(0.123f, actualChangedResult.diffPercentage)
     assertEquals(9223372036854775707, actualChangedResult.contextData["key"])
 
     val actualUnchangedResult = actualCaptureResultList[3] as CaptureResult.Unchanged