From 3104599b0bb4b086f98a8f3a14b2435385120c29 Mon Sep 17 00:00:00 2001 From: John Rodriguez Date: Tue, 18 Jun 2024 17:15:24 -0400 Subject: [PATCH] Repro test --- .github/workflows/build.yml | 15 +- sample-cli/build.gradle | 1 + .../cash/paparazzi/sample/ImageComparer.kt | 160 ++++++++++++++++++ .../paparazzi/sample/RenderingIssuesTest.kt | 53 ++++++ settings.gradle | 1 + 5 files changed, 222 insertions(+), 8 deletions(-) create mode 100644 sample-cli/build.gradle create mode 100644 sample-cli/src/main/java/app/cash/paparazzi/sample/ImageComparer.kt create mode 100644 sample/src/test/java/app/cash/paparazzi/sample/RenderingIssuesTest.kt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 51e06d99f1..f2a83c9cd9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,6 +31,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 5 - name: Configure JDK uses: actions/setup-java@v4 @@ -41,18 +43,15 @@ jobs: - name: Setup Gradle uses: gradle/gradle-build-action@v3 - - name: Run All Tests - run: ./gradlew check + - name: Record Sample Snapshots + run: ./gradlew sample:recordPaparazziDebug --tests=RenderingIssuesTest - - name: Upload Test Failures - if: failure() + - name: Upload Sample Snapshots uses: actions/upload-artifact@v4 with: - name: test-failures-${{ matrix.os }}-${{ matrix.java-version }} + name: sample-snapshots-${{ matrix.os }}-${{ matrix.java-version }} path: | - **/build/reports/tests/*/ - **/build/paparazzi/failures/ - paparazzi-gradle-plugin/src/test/projects/**/build/reports/paparazzi/**/images/ + sample/src/test/snapshots/** publish: runs-on: ubuntu-latest diff --git a/sample-cli/build.gradle b/sample-cli/build.gradle new file mode 100644 index 0000000000..f70506f67f --- /dev/null +++ b/sample-cli/build.gradle @@ -0,0 +1 @@ +apply plugin: 'org.jetbrains.kotlin.jvm' diff --git a/sample-cli/src/main/java/app/cash/paparazzi/sample/ImageComparer.kt b/sample-cli/src/main/java/app/cash/paparazzi/sample/ImageComparer.kt new file mode 100644 index 0000000000..ae529f0eb2 --- /dev/null +++ b/sample-cli/src/main/java/app/cash/paparazzi/sample/ImageComparer.kt @@ -0,0 +1,160 @@ +package app.cash.paparazzi.sample + +import java.awt.Color +import java.awt.image.BufferedImage +import java.awt.image.BufferedImage.TYPE_INT_ARGB +import java.io.File +import java.io.File.separatorChar +import javax.imageio.ImageIO + +public fun main() { + val image1 = "sample-cli/images1/app.cash.paparazzi.sample_RenderingIssuesTest_example_mac-arm-7ca1390_.png" + val image2 = "sample-cli/images2/app.cash.paparazzi.sample_RenderingIssuesTest_example_mac-arm-7ca1390_.png" + + compareImages(image1, image2) +} + +private fun readImage(image: String): BufferedImage { + val file = File(image) + println("Reading: ${file.absolutePath}") + return ImageIO.read(file) +} + +public fun compareImages(image1: String, image2: String) { + val goldenImage: BufferedImage = readImage(image1) + val image: BufferedImage = readImage(image2) + + val goldenImageWidth = goldenImage.width + val goldenImageHeight = goldenImage.height + + val imageWidth = image.width + val imageHeight = image.height + + val deltaWidth = Math.max(goldenImageWidth, imageWidth) + val deltaHeight = Math.max(goldenImageHeight, imageHeight) + + // Blur the images to account for the scenarios where there are pixel + // differences + // in where a sharp edge occurs + // goldenImage = blur(goldenImage, 6); + // image = blur(image, 6); + val width = goldenImageWidth + deltaWidth + imageWidth + val deltaImage = BufferedImage(width, deltaHeight, TYPE_INT_ARGB) + val g = deltaImage.graphics + + // Compute delta map + var delta: Long = 0 + for (y in 0 until deltaHeight) { + for (x in 0 until deltaWidth) { + val goldenRgb = if (x >= goldenImageWidth || y >= goldenImageHeight) { + 0x00808080 + } else { + goldenImage.getRGB(x, y) + } + + val rgb = if (x >= imageWidth || y >= imageHeight) { + 0x00808080 + } else { + image.getRGB(x, y) + } + + if (goldenRgb == rgb) { + deltaImage.setRGB(goldenImageWidth + x, y, 0x00808080) + continue + } + + // If the pixels have no opacity, don't delta colors at all + if (goldenRgb and -0x1000000 == 0 && rgb and -0x1000000 == 0) { + deltaImage.setRGB(goldenImageWidth + x, y, 0x00808080) + continue + } + + val deltaR = (rgb and 0xFF0000).ushr(16) - (goldenRgb and 0xFF0000).ushr(16) + val newR = 128 + deltaR and 0xFF + val deltaG = (rgb and 0x00FF00).ushr(8) - (goldenRgb and 0x00FF00).ushr(8) + val newG = 128 + deltaG and 0xFF + val deltaB = (rgb and 0x0000FF) - (goldenRgb and 0x0000FF) + val newB = 128 + deltaB and 0xFF + + val avgAlpha = + ((goldenRgb and -0x1000000).ushr(24) + (rgb and -0x1000000).ushr(24)) / 2 shl 24 + + val newRGB = avgAlpha or (newR shl 16) or (newG shl 8) or newB + deltaImage.setRGB(goldenImageWidth + x, y, newRGB) + + delta += Math.abs(deltaR) + .toLong() + delta += Math.abs(deltaG) + .toLong() + delta += Math.abs(deltaB) + .toLong() + } + } + + // 3 different colors, 256 color levels + val total = deltaHeight.toLong() * deltaWidth.toLong() * 3L * 256L + val percentDifference = (delta * 100 / total.toDouble()).toFloat() + + var error: String? = null + val imageName = getName(image2) + if (percentDifference > 0.0) { + error = String.format("Images differ (by %f%%)", percentDifference) + } else if (Math.abs(goldenImageWidth - imageWidth) >= 2) { + error = "Widths differ too much for " + imageName + ": " + + goldenImageWidth + "x" + goldenImageHeight + + "vs" + imageWidth + "x" + imageHeight + } else if (Math.abs(goldenImageHeight - imageHeight) >= 2) { + error = "Heights differ too much for " + imageName + ": " + + goldenImageWidth + "x" + goldenImageHeight + + "vs" + imageWidth + "x" + imageHeight + } + + if (error != null) { + // Expected on the left + // Golden on the right + g.drawImage(goldenImage, 0, 0, null) + g.drawImage(image, goldenImageWidth + deltaWidth, 0, null) + + // Labels + if (deltaWidth > 80) { + g.color = Color.RED + g.drawString("Expected", 10, 20) + g.drawString("Actual", goldenImageWidth + deltaWidth + 10, 20) + } + + val output = File(".", "delta-$imageName") + if (output.exists()) { + val deleted = output.delete() + println("existing output deleted?: $deleted") + } + ImageIO.write(deltaImage, "PNG", output) + error += " - see details in file://" + output.path + "\n" + error = run { + var initialMessage = error + val output1 = File(".", getName(image2)) + if (output1.exists()) { + val deleted = output1.delete() + println("existing output deleted?: $deleted") + } + ImageIO.write(image, "PNG", output1) + initialMessage += "Thumbnail for current rendering stored at file://" + output1.path + // initialMessage += "\nRun the following command to accept the changes:\n"; + // initialMessage += String.format("mv %1$s %2$s", output.getPath(), + // ImageUtils.class.getResource(relativePath).getPath()); + // The above has been commented out, since the destination path returned is in out dir + // and it makes the tests pass without the code being actually checked in. + initialMessage + } + println(error) + throw AssertionError(error) + } else { + println("Images are identical!") + } + + g.dispose() +} + +private fun getName(relativePath: String): String { + return relativePath.substring(relativePath.lastIndexOf(separatorChar) + 1) +} + diff --git a/sample/src/test/java/app/cash/paparazzi/sample/RenderingIssuesTest.kt b/sample/src/test/java/app/cash/paparazzi/sample/RenderingIssuesTest.kt new file mode 100644 index 0000000000..c1a5649a83 --- /dev/null +++ b/sample/src/test/java/app/cash/paparazzi/sample/RenderingIssuesTest.kt @@ -0,0 +1,53 @@ +package app.cash.paparazzi.sample + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import app.cash.paparazzi.Paparazzi +import com.android.ide.common.rendering.api.SessionParams +import org.junit.Rule +import org.junit.Test + +class RenderingIssuesTest { + @get:Rule + val paparazzi = Paparazzi( + renderingMode = SessionParams.RenderingMode.SHRINK, + ) + + @Test + fun example() { + paparazzi.snapshot("${osName()}-${gitShortSha()}") { + Box( + modifier = Modifier.background(Color(0xFF000033)) + ) { + Text("ExampleText", color = Color.White) + } + } + } + + fun osName(): String { + val osName = System.getProperty("os.name")!!.lowercase() + return when { + osName.startsWith("windows") -> "win" + osName.startsWith("mac") -> { + val osArch = System.getProperty("os.arch")!!.lowercase() + if (osArch.startsWith("x86")) "mac-x86" else "mac-arm" + } + + else -> "linux" + } + } + + fun gitShortSha(): String { + val command = "git rev-parse --short HEAD~" + val p = ProcessBuilder().command(*command.split(" ").toTypedArray()).start() + val exit = p.waitFor() + if (exit == 0) { + return p.inputStream.bufferedReader().readText() + } else { + throw Exception(p.errorStream.bufferedReader().readText()) + } + } +} diff --git a/settings.gradle b/settings.gradle index 662c95b9f6..703240a291 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,7 @@ include ':paparazzi-preview-processor' include ':paparazzi-gradle-plugin' include ':sample' +include ':sample-cli' enableFeaturePreview('TYPESAFE_PROJECT_ACCESSORS')