diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a7f44050..aa16f15c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,8 @@ junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.re junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version = "6.0.2" } +playwright = { module = "com.microsoft.playwright:playwright", version = "1.51.0" } +selenium = { module = "org.seleniumhq.selenium:selenium-java", version = "4.28.1" } spock = { module = "org.spockframework:spock-core", version = "2.4-M7-groovy-5.0" } testng = { module = "org.testng:testng", version = "7.12.0" } wiremock = { module = "org.wiremock:wiremock", version.ref = "wiremock" } diff --git a/manual/build.gradle.kts b/manual/build.gradle.kts index a0140fed..3b9772d9 100644 --- a/manual/build.gradle.kts +++ b/manual/build.gradle.kts @@ -23,10 +23,14 @@ testing { useJUnitJupiter() dependencies { implementation(project(":modules:core")) + implementation(project(":modules:image")) implementation(project(":modules:json-jackson")) implementation(project(":modules:yaml-jackson")) implementation(project(":modules:http")) + implementation(libs.playwright) + implementation(libs.selenium) + implementation(libs.jackson2.databind) implementation(libs.jackson2.dataformat.yaml) implementation(libs.jackson2.jsr310) diff --git a/manual/src/docs/asciidoc/chapters/10-images.adoc b/manual/src/docs/asciidoc/chapters/10-images.adoc new file mode 100644 index 00000000..6090b3cd --- /dev/null +++ b/manual/src/docs/asciidoc/chapters/10-images.adoc @@ -0,0 +1,283 @@ += Image Approval + +ApproveJ supports approval testing of images, which is particularly useful for visual regression testing. +This is commonly used with browser automation tools like Playwright to capture screenshots and ensure UI consistency across changes. + + +[id="image_basics"] +== Basic Image Approval + +To approve an image, use the `approveImage` static method from `ImageApprovalBuilder`. +It accepts either a `BufferedImage` or a `byte[]` (for direct use with screenshot APIs) and provides a fluent API similar to text approval. + +The `byte[]` overload works seamlessly with popular browser automation tools: + +[source,java,indent=0,role="primary"] +.Playwright +---- +include::../../../test/java/examples/java/ImageDocTest.java[tag=approve_screenshot] +---- +<1> Creates an `ImageApprovalBuilder` directly from the screenshot bytes +<2> Compares result to a previously approved image file next to the test + +[source,java,indent=0,role="secondary"] +.Selenium +---- +include::../../../test/java/examples/java/ImageDocTest.java[tag=approve_screenshot_selenium] +---- +<1> Get screenshot as byte array using `OutputType.BYTES` +<2> Pass bytes directly to `approveImage` + +This will create files named `--received.png` and `--approved.png` next to your test. + +When the test runs for the first time, a blank approved file is created. +Copy the received file to the approved file (or use a diff tool) to establish the baseline. +Subsequent runs compare the new screenshot against this approved baseline. + + +[id="image_comparators"] +== Image Comparators + +ApproveJ provides two image comparison strategies, each with different characteristics and use cases. + + +[id="perceptual_hash"] +=== Perceptual Hash (Default) + +Perceptual hashing (pHash) is the default comparison method. +It is robust to minor visual differences that are imperceptible to humans. + +[source,java,indent=0] +---- +include::../../../test/java/examples/java/ImageDocTest.java[tag=approve_screenshot_phash] +---- +<1> Explicitly use perceptual hash comparison (this is the default) + +*How it works:* + +1. Both images are resized to 32×32 pixels +2. Converted to grayscale +3. A Discrete Cosine Transform (DCT) is applied +4. The low-frequency components are extracted +5. A 64-bit hash is generated based on whether each DCT value exceeds the mean +6. The hashes are compared using Hamming distance + +*Strengths:* + +* Robust to antialiasing differences across browsers/platforms +* Tolerant of minor font rendering variations +* Handles slight color shifts from compression +* Ignores subpixel rendering differences +* Fast comparison (comparing two 64-bit numbers) + +*Weaknesses:* + +* May miss small, localized changes (e.g., a single button color change) +* Less precise for pixel-perfect requirements +* Sensitive to significant layout shifts + +*Best for:* + +* Cross-browser visual testing +* CI/CD environments with different rendering engines +* Testing on multiple operating systems +* General UI regression testing + + +[id="pixel_comparison"] +=== Pixel Comparison + +Pixel-by-pixel comparison calculates the exact difference between each pixel. + +[source,java,indent=0] +---- +include::../../../test/java/examples/java/ImageDocTest.java[tag=approve_screenshot_pixel] +---- +<1> Use pixel comparison with 1% threshold (99% similarity required) + +*How it works:* + +1. Each pixel's RGB values are compared +2. Differences are weighted by alpha (transparency) +3. Total difference is calculated as a percentage + +*Strengths:* + +* Precise detection of any visual change +* Good for pixel-perfect design requirements +* Detects small, localized changes + +*Weaknesses:* + +* Sensitive to antialiasing differences +* Fails on subpixel rendering variations +* Different browsers/platforms may produce slightly different results +* Font rendering differences often cause false failures + +*Best for:* + +* Same-browser, same-platform testing +* Pixel-perfect design verification +* Detecting subtle changes like icons or colors + + +[id="thresholds"] +== Configuring Thresholds + +Both comparators accept a threshold to control how much difference is acceptable. + +[source,java,indent=0] +---- +include::../../../test/java/examples/java/ImageDocTest.java[tag=approve_screenshot_threshold] +---- +<1> Require at least 95% similarity + +*Perceptual Hash Threshold:* + +* Default: 0.90 (90% similarity) +* Range: 0.0 to 1.0 +* Higher values = stricter matching +* Recommended: 0.85-0.95 for most use cases + +*Pixel Comparison Threshold:* + +* Default: 0.01 (1% difference allowed) +* Range: 0.0 to 1.0 +* Lower values = stricter matching (threshold represents allowed difference) +* Recommended: 0.01-0.05 depending on stability needs + + +[id="visual_testing_challenges"] +== Common Challenges in Visual Testing + +Visual approval testing can be tricky due to various sources of non-determinism. +Here are common problems and solutions. + + +[id="animations"] +=== Animations and Transitions + +CSS animations and transitions can cause screenshots to capture intermediate states, leading to flaky tests. + +[source,java,indent=0] +---- +include::../../../test/java/examples/java/ImageDocTest.java[tag=approve_screenshot_animations] +---- +<1> Wait for all resources to load +<2> Inject CSS to disable all animations and transitions + + +[id="dynamic_content"] +=== Dynamic Content + +Content that changes between runs (timestamps, ads, user-specific data) will cause failures. + +*Solutions:* + +* Mock or stub dynamic data sources +* Screenshot specific elements instead of full pages +* Use CSS to hide dynamic elements before capturing +* Scrub dynamic content with `display: none` + +[source,java,indent=0] +---- +include::../../../test/java/examples/java/ImageDocTest.java[tag=approve_screenshot_element] +---- +<1> Screenshot only the h1 element + + +[id="font_rendering"] +=== Font Rendering Differences + +Different operating systems render fonts differently, causing pixel-level variations. + +*Solutions:* + +* Use perceptual hash comparison (default) -- it's designed for this +* Run tests in containerized environments with consistent fonts +* Use web fonts that render consistently across platforms +* Lower pixel comparison threshold if you must use pixel comparison + + +[id="viewport_consistency"] +=== Viewport and Resolution + +Different screen sizes and DPI settings affect rendering. + +*Solutions:* + +* Always set explicit viewport size: `page.setViewportSize(1280, 720)` +* Use consistent device scale factor +* Document and enforce the expected resolution in CI + + +[id="network_timing"] +=== Network and Loading Timing + +Images or external resources may not be fully loaded when the screenshot is taken. + +*Solutions:* + +* Use `page.waitForLoadState()` to wait for network idle +* Wait for specific elements: `page.waitForSelector(".hero-image")` +* Add explicit waits for lazy-loaded content +* Consider using `page.waitForTimeout()` as a last resort (not recommended for production) + + +[id="scrolling_and_lazy_loading"] +=== Scrolling and Lazy Loading + +Content below the fold may not be rendered or may trigger lazy loading. + +*Solutions:* + +* Scroll to the element before screenshotting +* Use full-page screenshots: `page.screenshot(new Page.ScreenshotOptions().setFullPage(true))` +* Trigger lazy loading by scrolling, then scroll back + + +[id="cursor_and_focus"] +=== Cursor and Focus States + +The cursor position or focused elements can vary between runs. + +*Solutions:* + +* Click elsewhere before taking screenshot: `page.click("body")` +* Move mouse to consistent position: `page.mouse().move(0, 0)` +* Remove focus rings via CSS injection + + +[id="choosing_comparator"] +== Choosing the Right Comparator + +|=== +| Scenario | Recommended Comparator | Threshold + +| Cross-browser testing +| Perceptual Hash +| 0.85-0.90 + +| Same browser, CI/CD +| Perceptual Hash +| 0.90-0.95 + +| Pixel-perfect design +| Pixel +| 0.01-0.02 + +| Component screenshots +| Perceptual Hash +| 0.90 + +| Icon/logo verification +| Pixel +| 0.005-0.01 + +| Full page screenshots +| Perceptual Hash +| 0.85-0.90 +|=== + +When in doubt, start with perceptual hash comparison. +It provides a good balance between catching real regressions and avoiding false positives from rendering differences. diff --git a/manual/src/docs/asciidoc/chapters/10-configuration.adoc b/manual/src/docs/asciidoc/chapters/11-configuration.adoc similarity index 100% rename from manual/src/docs/asciidoc/chapters/10-configuration.adoc rename to manual/src/docs/asciidoc/chapters/11-configuration.adoc diff --git a/manual/src/docs/asciidoc/index.adoc b/manual/src/docs/asciidoc/index.adoc index 89c45db5..1dc41443 100644 --- a/manual/src/docs/asciidoc/index.adoc +++ b/manual/src/docs/asciidoc/index.adoc @@ -20,4 +20,5 @@ include::chapters/06-reviewing.adoc[leveloffset=1] include::chapters/07-json-jackson.adoc[leveloffset=1] include::chapters/08-yaml-jackson.adoc[leveloffset=1] include::chapters/09-http.adoc[leveloffset=1] -include::chapters/10-configuration.adoc[leveloffset=1] +include::chapters/10-images.adoc[leveloffset=1] +include::chapters/11-configuration.adoc[leveloffset=1] diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot-approved.png new file mode 100644 index 00000000..12e6d5c1 Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_element-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_element-approved.png new file mode 100644 index 00000000..2aecc407 Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_element-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_scrubbed-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_scrubbed-approved.png new file mode 100644 index 00000000..5c9f240a Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_scrubbed-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_scrubbed_element-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_scrubbed_element-approved.png new file mode 100644 index 00000000..0fb3c7b6 Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_scrubbed_element-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_scrubbed_multiple-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_scrubbed_multiple-approved.png new file mode 100644 index 00000000..375fee8d Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_scrubbed_multiple-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_selenium-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_selenium-approved.png new file mode 100644 index 00000000..844d2426 Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_selenium-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_wait_for_animations-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_wait_for_animations-approved.png new file mode 100644 index 00000000..12e6d5c1 Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_wait_for_animations-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_with_custom_threshold-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_with_custom_threshold-approved.png new file mode 100644 index 00000000..12e6d5c1 Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_with_custom_threshold-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_with_perceptual_hash-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_with_perceptual_hash-approved.png new file mode 100644 index 00000000..12e6d5c1 Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_with_perceptual_hash-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_with_pixel_comparison-approved.png b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_with_pixel_comparison-approved.png new file mode 100644 index 00000000..12e6d5c1 Binary files /dev/null and b/manual/src/test/java/examples/java/ImageDocTest-approve_screenshot_with_pixel_comparison-approved.png differ diff --git a/manual/src/test/java/examples/java/ImageDocTest.java b/manual/src/test/java/examples/java/ImageDocTest.java new file mode 100644 index 00000000..26547e2c --- /dev/null +++ b/manual/src/test/java/examples/java/ImageDocTest.java @@ -0,0 +1,233 @@ +package examples.java; + +import static org.approvej.image.ImageApprovalBuilder.approveImage; +import static org.approvej.image.compare.ImageComparators.perceptualHash; +import static org.approvej.image.compare.ImageComparators.pixel; +import static org.approvej.image.scrub.ImageScrubbers.region; +import static org.approvej.image.scrub.ImageScrubbers.regions; + +import com.microsoft.playwright.Browser; +import com.microsoft.playwright.BrowserType; +import com.microsoft.playwright.Page; +import com.microsoft.playwright.Playwright; +import java.awt.Rectangle; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.Dimension; +import org.openqa.selenium.OutputType; +import org.openqa.selenium.TakesScreenshot; +import org.openqa.selenium.WebDriver; +import org.openqa.selenium.chrome.ChromeDriver; +import org.openqa.selenium.chrome.ChromeOptions; + +class ImageDocTest { + + @Test + void approve_screenshot() { + // tag::approve_screenshot[] + try (Playwright playwright = Playwright.create()) { + Browser browser = + playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + Page page = browser.newPage(); + page.setViewportSize(1280, 720); + + page.navigate("https://approvej.org"); + + approveImage(page.screenshot()) // <1> + .byFile(); // <2> + } + // end::approve_screenshot[] + } + + @Test + void approve_screenshot_with_perceptual_hash() { + // tag::approve_screenshot_phash[] + try (Playwright playwright = Playwright.create()) { + Browser browser = + playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + Page page = browser.newPage(); + page.setViewportSize(1280, 720); + + page.navigate("https://approvej.org"); + + approveImage(page.screenshot()) + .comparedBy(perceptualHash()) // <1> + .byFile(); + } + // end::approve_screenshot_phash[] + } + + @Test + void approve_screenshot_with_custom_threshold() { + // tag::approve_screenshot_threshold[] + try (Playwright playwright = Playwright.create()) { + Browser browser = + playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + Page page = browser.newPage(); + page.setViewportSize(1280, 720); + + page.navigate("https://approvej.org"); + + approveImage(page.screenshot()) + .comparedBy(perceptualHash().withThreshold(0.95)) // <1> + .byFile(); + } + // end::approve_screenshot_threshold[] + } + + @Test + void approve_screenshot_with_pixel_comparison() { + // tag::approve_screenshot_pixel[] + try (Playwright playwright = Playwright.create()) { + Browser browser = + playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + Page page = browser.newPage(); + page.setViewportSize(1280, 720); + + page.navigate("https://approvej.org"); + + approveImage(page.screenshot()) + .comparedBy(pixel().withThreshold(0.01)) // <1> + .byFile(); + } + // end::approve_screenshot_pixel[] + } + + @Test + void approve_screenshot_wait_for_animations() { + // tag::approve_screenshot_animations[] + try (Playwright playwright = Playwright.create()) { + Browser browser = + playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + Page page = browser.newPage(); + page.setViewportSize(1280, 720); + + page.navigate("https://approvej.org"); + + // Wait for network to be idle (all resources loaded) + page.waitForLoadState(); // <1> + + // Disable CSS animations and transitions + page.addStyleTag( // <2> + new Page.AddStyleTagOptions() + .setContent( + """ + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + } + """)); + + approveImage(page.screenshot()).byFile(); + } + // end::approve_screenshot_animations[] + } + + @Test + void approve_screenshot_element() { + // tag::approve_screenshot_element[] + try (Playwright playwright = Playwright.create()) { + Browser browser = + playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + Page page = browser.newPage(); + page.setViewportSize(1280, 720); + + page.navigate("https://approvej.org"); + page.waitForLoadState(); + + // Screenshot only a specific element instead of full page + approveImage(page.locator("h1").screenshot()) // <1> + .byFile(); + } + // end::approve_screenshot_element[] + } + + @Test + void approve_screenshot_scrubbed() { + // tag::approve_screenshot_scrubbed[] + try (Playwright playwright = Playwright.create()) { + Browser browser = + playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + Page page = browser.newPage(); + page.setViewportSize(1280, 720); + + page.navigate("https://approvej.org"); + page.waitForLoadState(); + + // Scrub dynamic content like version numbers by masking a region + approveImage(page.screenshot()) + .scrubbedOf(region(10, 50, 100, 20)) // <1> + .byFile(); + } + // end::approve_screenshot_scrubbed[] + } + + @Test + void approve_screenshot_scrubbed_element() { + // tag::approve_screenshot_scrubbed_element[] + try (Playwright playwright = Playwright.create()) { + Browser browser = + playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + Page page = browser.newPage(); + page.setViewportSize(1280, 720); + + page.navigate("https://approvej.org"); + page.waitForLoadState(); + + // Use element bounding box to scrub dynamic content + var versionElement = page.locator("#revnumber"); + var bounds = versionElement.boundingBox(); + + approveImage(page.screenshot()) + .scrubbedOf( + region( // <1> + (int) bounds.x, (int) bounds.y, (int) bounds.width, (int) bounds.height)) + .byFile(); + } + // end::approve_screenshot_scrubbed_element[] + } + + @Test + void approve_screenshot_scrubbed_multiple() { + // tag::approve_screenshot_scrubbed_multiple[] + try (Playwright playwright = Playwright.create()) { + Browser browser = + playwright.chromium().launch(new BrowserType.LaunchOptions().setHeadless(true)); + Page page = browser.newPage(); + page.setViewportSize(1280, 720); + + page.navigate("https://approvej.org"); + page.waitForLoadState(); + + // Scrub multiple regions at once + approveImage(page.screenshot()) + .scrubbedOf( + regions( // <1> + new Rectangle(10, 50, 100, 20), // version number + new Rectangle(200, 100, 80, 15))) // timestamp + .byFile(); + } + // end::approve_screenshot_scrubbed_multiple[] + } + + @Test + void approve_screenshot_selenium() { + // tag::approve_screenshot_selenium[] + ChromeOptions options = new ChromeOptions(); + options.addArguments("--headless"); + WebDriver driver = new ChromeDriver(options); + try { + driver.manage().window().setSize(new Dimension(1280, 720)); + driver.get("https://approvej.org"); + + byte[] screenshot = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES); // <1> + + approveImage(screenshot) // <2> + .byFile(); + } finally { + driver.quit(); + } + // end::approve_screenshot_selenium[] + } +} diff --git a/modules/image/build.gradle.kts b/modules/image/build.gradle.kts new file mode 100644 index 00000000..a07860ea --- /dev/null +++ b/modules/image/build.gradle.kts @@ -0,0 +1,41 @@ +@file:Suppress("UnstableApiUsage", "unused") + +plugins { + `java-library` + jacoco + `jvm-test-suite` + `maven-publish` +} + +java { + withJavadocJar() + withSourcesJar() + toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } +} + +repositories { mavenCentral() } + +dependencies { + api(project(":modules:core")) + api(libs.jspecify) +} + +testing { + suites { + val test by + getting(JvmTestSuite::class) { + useJUnitJupiter() + dependencies { + implementation(platform(libs.junit.bom)) + implementation(libs.junit.jupiter.api) + implementation(libs.junit.jupiter.params) + implementation(libs.assertj.core) + + runtimeOnly(libs.junit.platform.launcher) + runtimeOnly(libs.junit.jupiter.engine) + } + } + } +} + +tasks.jacocoTestReport { reports { xml.required = true } } diff --git a/modules/image/src/main/java/org/approvej/image/ImageApprovalBuilder.java b/modules/image/src/main/java/org/approvej/image/ImageApprovalBuilder.java new file mode 100644 index 00000000..1feeb66e --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/ImageApprovalBuilder.java @@ -0,0 +1,145 @@ +package org.approvej.image; + +import static org.approvej.configuration.Configuration.configuration; +import static org.approvej.image.approve.ImageFileApprover.imageFile; +import static org.approvej.image.compare.ImageComparators.perceptualHash; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.function.UnaryOperator; +import javax.imageio.ImageIO; +import org.approvej.approve.PathProvider; +import org.approvej.approve.PathProviders; +import org.approvej.image.approve.ImageFileApprover; +import org.approvej.image.compare.ImageComparator; +import org.approvej.review.FileReviewer; +import org.approvej.review.ReviewResult; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ImageApprovalBuilder { + + private final BufferedImage value; + private final String name; + private final String filenameExtension; + private final FileReviewer fileReviewer; + private final ImageComparator comparator; + + private ImageApprovalBuilder( + BufferedImage image, + String name, + String filenameExtension, + FileReviewer fileReviewer, + ImageComparator comparator) { + this.value = image; + this.name = name; + this.filenameExtension = filenameExtension; + this.fileReviewer = fileReviewer; + this.comparator = comparator; + } + + /** + * Creates a new builder for the given image. + * + *

By default, uses perceptual hash comparison with 90% similarity threshold. + * + * @param value the image to approve + * @return a new {@link ImageApprovalBuilder} for the given image + */ + public static ImageApprovalBuilder approveImage(BufferedImage value) { + return new ImageApprovalBuilder( + value, "", "png", configuration.defaultFileReviewer(), perceptualHash()); + } + + /** + * Creates a new builder for the given image bytes. + * + *

This is a convenience method for use with screenshot APIs that return byte arrays, such as + * Playwright's {@code page.screenshot()}. + * + *

By default, uses perceptual hash comparison with 90% similarity threshold. + * + * @param imageBytes the image bytes (PNG, JPEG, etc.) to approve + * @return a new {@link ImageApprovalBuilder} for the given image + * @throws UncheckedIOException if the image bytes cannot be read + */ + public static ImageApprovalBuilder approveImage(byte[] imageBytes) { + try { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(imageBytes)); + if (image == null) { + throw new UncheckedIOException( + new IOException("Failed to read image from bytes - unsupported format")); + } + return approveImage(image); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read image from bytes", e); + } + } + + /** + * Specifies the image comparator to use for comparing images. + * + * @param comparator the {@link ImageComparator} to use + * @return a new builder with the specified comparator + * @see org.approvej.image.compare.ImageComparators + */ + public ImageApprovalBuilder comparedBy(ImageComparator comparator) { + return new ImageApprovalBuilder(value, name, filenameExtension, fileReviewer, comparator); + } + + /** + * Applies a scrubber to mask regions of the image before comparison. + * + *

This is useful for hiding dynamic content like version numbers, timestamps, or ads that + * would otherwise cause approval tests to fail. + * + *

Example: + * + *

{@code
+   * approveImage(screenshot)
+   *     .scrubbedOf(region(10, 50, 100, 20))
+   *     .byFile();
+   * }
+ * + * @param scrubber a function that modifies the image to mask dynamic regions + * @return a new builder with the scrubbed image + * @see org.approvej.image.scrub.ImageScrubbers + */ + public ImageApprovalBuilder scrubbedOf(UnaryOperator scrubber) { + return new ImageApprovalBuilder( + scrubber.apply(value), name, filenameExtension, fileReviewer, comparator); + } + + /** + * Approves the image by comparing it to a file next to the test class. + * + *

This is equivalent to calling {@code byFile(PathProviders.nextToTest())}. + * + * @see PathProviders#nextToTest() + */ + public void byFile() { + byFile(PathProviders.nextToTest()); + } + + /** + * Approves the image by comparing it to a file at the specified path. + * + * @param pathProvider the {@link PathProvider} to determine the paths of the approved and + * received files + */ + public void byFile(PathProvider pathProvider) { + PathProvider updatedPathProvider = + pathProvider.filenameAffix(name).filenameExtension(filenameExtension); + ImageFileApprover approver = imageFile(updatedPathProvider, comparator); + ImageApprovalResult approvalResult = approver.apply(value); + if (approvalResult.needsApproval()) { + ReviewResult reviewResult = fileReviewer.apply(updatedPathProvider); + if (reviewResult.needsReapproval()) { + approvalResult = approver.apply(value); + } + } + approvalResult.throwIfNotApproved(); + } +} diff --git a/modules/image/src/main/java/org/approvej/image/ImageApprovalError.java b/modules/image/src/main/java/org/approvej/image/ImageApprovalError.java new file mode 100644 index 00000000..47c55fd6 --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/ImageApprovalError.java @@ -0,0 +1,13 @@ +package org.approvej.image; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** An {@link AssertionError} thrown when an image approval fails. */ +@NullMarked +public class ImageApprovalError extends AssertionError { + + public ImageApprovalError(@Nullable String description) { + super(description == null ? "Missing approval for received image" : description); + } +} diff --git a/modules/image/src/main/java/org/approvej/image/ImageApprovalResult.java b/modules/image/src/main/java/org/approvej/image/ImageApprovalResult.java new file mode 100644 index 00000000..e0196370 --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/ImageApprovalResult.java @@ -0,0 +1,8 @@ +package org.approvej.image; + +public interface ImageApprovalResult { + + boolean needsApproval(); + + void throwIfNotApproved(); +} diff --git a/modules/image/src/main/java/org/approvej/image/approve/AnalysedImage.java b/modules/image/src/main/java/org/approvej/image/approve/AnalysedImage.java new file mode 100644 index 00000000..91f0a7f6 --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/approve/AnalysedImage.java @@ -0,0 +1,119 @@ +package org.approvej.image.approve; + +import java.awt.image.BufferedImage; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record AnalysedImage(BufferedImage image, int width, int height, int size) { + + public static AnalysedImage analyse(BufferedImage image) { + int width = image.getWidth(); + int height = image.getHeight(); + return new AnalysedImage(image, width, height, width * height); + } + + public Pixel pixel(int x, int y) { + if (x >= image.getWidth() || y >= image.getHeight()) return Pixel.missing(); + return Pixel.of(image.getRGB(x, y)); + } + + public double difference(AnalysedImage other) { + double difference = 0.0; + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + Pixel thisPixel = pixel(x, y); + Pixel otherPixel = other.pixel(x, y); + difference += thisPixel.difference(otherPixel); + } + } + return difference / (double) (image.getWidth() * image.getHeight()); + } + + public boolean isMoreDifferentThan(AnalysedImage other, double maxDifference) { + double difference = 0.0; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + Pixel thisPixel = pixel(x, y); + Pixel otherPixel = other.pixel(x, y); + difference += thisPixel.difference(otherPixel) / size; + if (difference > maxDifference) return true; + } + } + return false; + } + + public interface Pixel { + + int MAX_VALUE = 0xff; + Pixel MISSING = new MissingPixel(); + + static Pixel of(int argb) { + return new ArgbPixel( + argb, + (argb >> 24) & MAX_VALUE, + (argb >> 16) & MAX_VALUE, + (argb >> 8) & MAX_VALUE, + argb & MAX_VALUE); + } + + static Pixel missing() { + return MISSING; + } + + int alpha(); + + int red(); + + int green(); + + int blue(); + + double difference(Pixel pixel); + } + + public record ArgbPixel(int argb, int alpha, int red, int green, int blue) implements Pixel { + + @Override + public double difference(Pixel other) { + if (other instanceof MissingPixel) { + return 1.0; + } + double colorDiff = + (Math.abs(this.red - other.red()) + + Math.abs(this.green - other.green()) + + Math.abs(this.blue - other.blue())) + / (double) (MAX_VALUE * 3); + + double alphaWeight = ((this.alpha + other.alpha()) / 2.0) / MAX_VALUE; + return colorDiff * alphaWeight; + } + } + + public record MissingPixel() implements Pixel { + + @Override + public int alpha() { + return -1; + } + + @Override + public int red() { + return -1; + } + + @Override + public int green() { + return -1; + } + + @Override + public int blue() { + return -1; + } + + @Override + public double difference(Pixel pixel) { + return 1; + } + } +} diff --git a/modules/image/src/main/java/org/approvej/image/approve/ImageApprover.java b/modules/image/src/main/java/org/approvej/image/approve/ImageApprover.java new file mode 100644 index 00000000..6d86b89d --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/approve/ImageApprover.java @@ -0,0 +1,7 @@ +package org.approvej.image.approve; + +import java.awt.image.BufferedImage; +import java.util.function.Function; +import org.approvej.image.ImageApprovalResult; + +public interface ImageApprover extends Function {} diff --git a/modules/image/src/main/java/org/approvej/image/approve/ImageFileApprovalResult.java b/modules/image/src/main/java/org/approvej/image/approve/ImageFileApprovalResult.java new file mode 100644 index 00000000..e2f68507 --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/approve/ImageFileApprovalResult.java @@ -0,0 +1,30 @@ +package org.approvej.image.approve; + +import org.approvej.approve.PathProvider; +import org.approvej.image.ImageApprovalError; +import org.approvej.image.ImageApprovalResult; +import org.approvej.image.compare.ImageComparisonResult; + +/** + * {@link ImageApprovalResult} for image files. + * + * @param comparisonResult the result of comparing the images + * @param pathProvider the {@link PathProvider} providing the paths to the received and approved + * files + */ +public record ImageFileApprovalResult( + ImageComparisonResult comparisonResult, PathProvider pathProvider) + implements ImageApprovalResult { + + @Override + public boolean needsApproval() { + return !comparisonResult.isMatch(); + } + + @Override + public void throwIfNotApproved() { + if (needsApproval()) { + throw new ImageApprovalError(comparisonResult.description()); + } + } +} diff --git a/modules/image/src/main/java/org/approvej/image/approve/ImageFileApprover.java b/modules/image/src/main/java/org/approvej/image/approve/ImageFileApprover.java new file mode 100644 index 00000000..140db40d --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/approve/ImageFileApprover.java @@ -0,0 +1,165 @@ +package org.approvej.image.approve; + +import static java.awt.image.BufferedImage.TYPE_INT_ARGB; +import static java.nio.file.Files.createDirectories; +import static java.nio.file.Files.deleteIfExists; +import static java.nio.file.Files.exists; +import static java.nio.file.Files.getLastModifiedTime; +import static java.nio.file.Files.list; +import static java.nio.file.Files.move; +import static java.nio.file.Files.notExists; +import static java.nio.file.StandardOpenOption.CREATE; +import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; +import static java.util.Comparator.comparing; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.imageio.ImageIO; +import org.approvej.approve.PathProvider; +import org.approvej.image.ImageApprovalResult; +import org.approvej.image.compare.ImageComparator; +import org.approvej.image.compare.ImageComparisonResult; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ImageFileApprover implements ImageApprover { + + private final PathProvider pathProvider; + private final ImageComparator comparator; + + /** + * Creates a new image file approver. + * + * @param pathProvider a {@link PathProvider} to determine the paths of the approved and received + * files + * @param comparator the {@link ImageComparator} to use for comparing images + */ + ImageFileApprover(PathProvider pathProvider, ImageComparator comparator) { + this.pathProvider = pathProvider; + this.comparator = comparator; + } + + @Override + public ImageApprovalResult apply(BufferedImage received) { + ensureDirectory(); + handleOldApprovedFiles(); + ensureApprovedFile(received.getWidth(), received.getHeight()); + return check(readApprovedFile(), received); + } + + private void ensureDirectory() { + try { + createDirectories(pathProvider.directory()); + } catch (IOException e) { + throw new ImageFileApproverError( + "Creating directories %s failed".formatted(pathProvider.directory()), e); + } + } + + private void ensureApprovedFile(int width, int height) { + Path approvedPath = pathProvider.approvedPath(); + if (notExists(approvedPath)) { + try { + BufferedImage missingApprovedImage = new BufferedImage(width, height, TYPE_INT_ARGB); + ImageIO.write( + missingApprovedImage, + pathProvider.filenameExtension(), + Files.newOutputStream(approvedPath, CREATE)); + } catch (IOException e) { + throw new ImageFileApproverError( + "Creating approved file %s failed".formatted(approvedPath), e); + } + } + } + + private void handleOldApprovedFiles() { + Path approvedPath = pathProvider.approvedPath(); + String filename = approvedPath.getFileName().toString(); + Pattern filenameExtensionPattern = + Pattern.compile("(?.+?)(?:\\.(?[^.]*))?"); + Matcher matcher = filenameExtensionPattern.matcher(filename); + String baseFilename = matcher.matches() ? matcher.group("baseFilename") : null; + Pattern baseFilenamePattern = Pattern.compile(baseFilename + "(?:\\.(?[^.]*))?"); + try (var paths = list(pathProvider.directory())) { + List oldApprovedFiles = + paths + .filter( + path -> + !path.toAbsolutePath().equals(approvedPath) + && baseFilenamePattern.matcher(path.getFileName().toString()).matches()) + .sorted( + comparing( + path -> { + try { + return getLastModifiedTime(path); + } catch (IOException ignored) { + return FileTime.from(Instant.ofEpochSecond(0)); + } + })) + .toList(); + if (oldApprovedFiles.isEmpty()) { + return; + } + if (!exists(approvedPath)) { + move(oldApprovedFiles.getLast(), approvedPath); + } + oldApprovedFiles.forEach( + path -> { + try { + deleteIfExists(path); + } catch (IOException ignored) { + // this is an optional cleanup + } + }); + } catch (IOException ignored) { + // this is an optional cleanup + } + } + + private BufferedImage readApprovedFile() { + Path approvedPath = pathProvider.approvedPath(); + try { + return ImageIO.read(approvedPath.toFile()); + } catch (IOException e) { + throw new ImageFileApproverError( + "Reading approved file %s failed".formatted(approvedPath), e); + } + } + + private ImageApprovalResult check(BufferedImage previouslyApproved, BufferedImage received) { + ImageComparisonResult comparisonResult = comparator.compare(previouslyApproved, received); + ImageFileApprovalResult result = new ImageFileApprovalResult(comparisonResult, pathProvider); + Path receivedPath = pathProvider.receivedPath(); + if (result.needsApproval()) { + try { + ImageIO.write( + received, + pathProvider.filenameExtension(), + Files.newOutputStream(receivedPath, CREATE, TRUNCATE_EXISTING)); + } catch (IOException e) { + throw new ImageFileApproverError( + "Writing received to %s failed".formatted(receivedPath), e); + } + } else { + try { + deleteIfExists(receivedPath); + } catch (IOException e) { + throw new ImageFileApproverError( + "Deleting received file %s failed".formatted(receivedPath), e); + } + } + return result; + } + + public static ImageFileApprover imageFile(PathProvider pathProvider, ImageComparator comparator) { + return new ImageFileApprover(pathProvider, comparator); + } +} diff --git a/modules/image/src/main/java/org/approvej/image/approve/ImageFileApproverError.java b/modules/image/src/main/java/org/approvej/image/approve/ImageFileApproverError.java new file mode 100644 index 00000000..d379372d --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/approve/ImageFileApproverError.java @@ -0,0 +1,19 @@ +package org.approvej.image.approve; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ImageFileApproverError extends RuntimeException { + + public ImageFileApproverError(String message) { + super(message); + } + + public ImageFileApproverError(String message, Throwable cause) { + super(message, cause); + } + + public ImageFileApproverError(Throwable cause) { + super("Failed to approve file", cause); + } +} diff --git a/modules/image/src/main/java/org/approvej/image/compare/ImageComparator.java b/modules/image/src/main/java/org/approvej/image/compare/ImageComparator.java new file mode 100644 index 00000000..afd5462c --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/compare/ImageComparator.java @@ -0,0 +1,25 @@ +package org.approvej.image.compare; + +import java.awt.image.BufferedImage; +import org.jspecify.annotations.NullMarked; + +/** + * Strategy interface for comparing two images. + * + *

Implementations provide different algorithms for determining image similarity, such as + * pixel-by-pixel comparison or perceptual hashing. + * + * @see ImageComparators + */ +@NullMarked +public interface ImageComparator { + + /** + * Compares two images and returns the comparison result. + * + * @param expected the expected (approved) image + * @param actual the actual (received) image + * @return the result of the comparison + */ + ImageComparisonResult compare(BufferedImage expected, BufferedImage actual); +} diff --git a/modules/image/src/main/java/org/approvej/image/compare/ImageComparators.java b/modules/image/src/main/java/org/approvej/image/compare/ImageComparators.java new file mode 100644 index 00000000..ab08b0ec --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/compare/ImageComparators.java @@ -0,0 +1,44 @@ +package org.approvej.image.compare; + +import org.jspecify.annotations.NullMarked; + +/** + * Factory methods to create {@link ImageComparator} instances. + * + *

Provides access to different image comparison strategies: + * + *

    + *
  • {@link #perceptualHash()} - Perceptual hash comparison, robust to anti-aliasing and minor + * rendering differences + *
  • {@link #pixel()} - Pixel-by-pixel comparison for exact matching + *
+ */ +@NullMarked +public final class ImageComparators { + + private ImageComparators() {} + + /** + * Creates a perceptual hash comparator with 90% similarity threshold. + * + *

Perceptual hashing is robust to antialiasing, font rendering variations, and minor visual + * differences that are imperceptible to humans. + * + * @return a new {@link PerceptualHashComparator} with default threshold + */ + public static PerceptualHashComparator perceptualHash() { + return new PerceptualHashComparator(0.90); + } + + /** + * Creates a pixel-by-pixel comparator with 1% difference threshold. + * + *

Pixel comparison compares each pixel individually and is suitable for exact-match use cases + * where precision is critical. + * + * @return a new {@link PixelComparator} with default threshold + */ + public static PixelComparator pixel() { + return new PixelComparator(0.01); + } +} diff --git a/modules/image/src/main/java/org/approvej/image/compare/ImageComparisonResult.java b/modules/image/src/main/java/org/approvej/image/compare/ImageComparisonResult.java new file mode 100644 index 00000000..e853d3a7 --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/compare/ImageComparisonResult.java @@ -0,0 +1,34 @@ +package org.approvej.image.compare; + +import org.jspecify.annotations.NullMarked; + +/** + * Result of comparing two images using an {@link ImageComparator}. + * + *

Provides information about whether the images match according to the comparator's criteria, + * the similarity score, and a human-readable description of the comparison. + */ +@NullMarked +public interface ImageComparisonResult { + + /** + * Returns whether the images match according to the comparator's threshold. + * + * @return true if the images are considered matching + */ + boolean isMatch(); + + /** + * Returns the similarity score between 0.0 and 1.0, where 1.0 means identical. + * + * @return similarity score from 0.0 (completely different) to 1.0 (identical) + */ + double similarity(); + + /** + * Returns a human-readable description of the comparison result. + * + * @return description of the comparison result + */ + String description(); +} diff --git a/modules/image/src/main/java/org/approvej/image/compare/PerceptualHashComparator.java b/modules/image/src/main/java/org/approvej/image/compare/PerceptualHashComparator.java new file mode 100644 index 00000000..3b3e3ae5 --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/compare/PerceptualHashComparator.java @@ -0,0 +1,150 @@ +package org.approvej.image.compare; + +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import org.jspecify.annotations.NullMarked; + +/** + * Compares images using perceptual hashing (pHash). + * + *

Perceptual hashing is robust to anti-aliasing, font rendering variations, compression + * artifacts, and minor visual differences that are imperceptible to humans. It works by: + * + *

    + *
  1. Resizing the image to 32x32 pixels + *
  2. Converting to grayscale + *
  3. Applying a Discrete Cosine Transform (DCT) + *
  4. Extracting the top-left 8x8 low-frequency components + *
  5. Creating a 64-bit hash based on whether each value is above or below the mean + *
  6. Comparing hashes using Hamming distance + *
+ */ +@NullMarked +public final class PerceptualHashComparator implements ImageComparator { + + private static final int RESIZE_SIZE = 32; + private static final int HASH_SIZE = 8; + private static final int HASH_BITS = HASH_SIZE * HASH_SIZE; + + private final double threshold; + + PerceptualHashComparator(double threshold) { + this.threshold = threshold; + } + + /** + * Returns a new comparator with the specified threshold. + * + * @param threshold the minimum similarity required for a match (0.0 to 1.0), where 0.90 means 90% + * similarity required + * @return a new comparator with the specified threshold + */ + public PerceptualHashComparator withThreshold(double threshold) { + return new PerceptualHashComparator(threshold); + } + + @Override + public ImageComparisonResult compare(BufferedImage expected, BufferedImage actual) { + long expectedHash = computeHash(expected); + long actualHash = computeHash(actual); + + int hammingDistance = Long.bitCount(expectedHash ^ actualHash); + double similarity = 1.0 - ((double) hammingDistance / HASH_BITS); + + return new PerceptualHashComparisonResult(similarity, threshold, hammingDistance); + } + + private long computeHash(BufferedImage image) { + BufferedImage resized = resize(image, RESIZE_SIZE, RESIZE_SIZE); + double[][] grayscale = toGrayscale(resized); + double[][] dct = applyDct(grayscale); + return computeHashFromDct(dct); + } + + private BufferedImage resize(BufferedImage image, int width, int height) { + BufferedImage resized = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics2D g = resized.createGraphics(); + g.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); + g.drawImage(image, 0, 0, width, height, null); + g.dispose(); + return resized; + } + + private double[][] toGrayscale(BufferedImage image) { + int width = image.getWidth(); + int height = image.getHeight(); + double[][] grayscale = new double[height][width]; + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int rgb = image.getRGB(x, y); + int r = (rgb >> 16) & 0xff; + int g = (rgb >> 8) & 0xff; + int b = rgb & 0xff; + // Luminosity formula for grayscale conversion + grayscale[y][x] = 0.299 * r + 0.587 * g + 0.114 * b; + } + } + + return grayscale; + } + + private double[][] applyDct(double[][] input) { + int n = input.length; + double[][] dct = new double[n][n]; + + // Precompute cosine values for efficiency + double[][] cosValues = new double[n][n]; + for (int i = 0; i < n; i++) { + for (int j = 0; j < n; j++) { + cosValues[i][j] = Math.cos((2 * j + 1) * i * Math.PI / (2 * n)); + } + } + + for (int u = 0; u < n; u++) { + for (int v = 0; v < n; v++) { + double sum = 0.0; + for (int y = 0; y < n; y++) { + for (int x = 0; x < n; x++) { + sum += input[y][x] * cosValues[u][y] * cosValues[v][x]; + } + } + + double cu = (u == 0) ? 1.0 / Math.sqrt(2) : 1.0; + double cv = (v == 0) ? 1.0 / Math.sqrt(2) : 1.0; + dct[u][v] = 0.25 * cu * cv * sum; + } + } + + return dct; + } + + private long computeHashFromDct(double[][] dct) { + // Extract top-left 8x8 (excluding DC component at [0][0]) + // Calculate mean of low-frequency components + double sum = 0.0; + for (int y = 0; y < HASH_SIZE; y++) { + for (int x = 0; x < HASH_SIZE; x++) { + if (y == 0 && x == 0) continue; // Skip DC component + sum += dct[y][x]; + } + } + double mean = sum / (HASH_BITS - 1); + + // Build hash: bit is 1 if value > mean + long hash = 0; + for (int y = 0; y < HASH_SIZE; y++) { + for (int x = 0; x < HASH_SIZE; x++) { + if (y == 0 && x == 0) continue; + hash <<= 1; + if (dct[y][x] > mean) { + hash |= 1; + } + } + } + + return hash; + } +} diff --git a/modules/image/src/main/java/org/approvej/image/compare/PerceptualHashComparisonResult.java b/modules/image/src/main/java/org/approvej/image/compare/PerceptualHashComparisonResult.java new file mode 100644 index 00000000..ffec4d60 --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/compare/PerceptualHashComparisonResult.java @@ -0,0 +1,27 @@ +package org.approvej.image.compare; + +import org.jspecify.annotations.NullMarked; + +/** + * Result of a perceptual hash (pHash) image comparison. + * + * @param similarity the similarity score from 0.0 to 1.0 + * @param threshold the minimum similarity required for a match + * @param hammingDistance the Hamming distance between the two hashes (0-64) + */ +@NullMarked +public record PerceptualHashComparisonResult( + double similarity, double threshold, int hammingDistance) implements ImageComparisonResult { + + @Override + public boolean isMatch() { + return similarity >= threshold; + } + + @Override + public String description() { + return "Perceptual hash: %.2f%% similar (threshold: %.2f%%, Hamming distance: %d/64)%s" + .formatted( + similarity * 100, threshold * 100, hammingDistance, isMatch() ? "" : " - MISMATCH"); + } +} diff --git a/modules/image/src/main/java/org/approvej/image/compare/PixelComparator.java b/modules/image/src/main/java/org/approvej/image/compare/PixelComparator.java new file mode 100644 index 00000000..1ea58931 --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/compare/PixelComparator.java @@ -0,0 +1,83 @@ +package org.approvej.image.compare; + +import java.awt.image.BufferedImage; +import org.jspecify.annotations.NullMarked; + +/** + * Compares images pixel-by-pixel. + * + *

This comparator calculates the difference for each pixel based on RGB values and returns the + * overall similarity as a percentage. It is suitable for exact-match use cases but may be sensitive + * to anti-aliasing and rendering differences. + */ +@NullMarked +public final class PixelComparator implements ImageComparator { + + private static final int MAX_VALUE = 0xff; + + private final double threshold; + + PixelComparator(double threshold) { + this.threshold = threshold; + } + + /** + * Returns a new comparator with the specified threshold. + * + * @param threshold the maximum allowed difference (0.0 to 1.0), where 0.01 means 1% difference is + * acceptable + * @return a new comparator with the specified threshold + */ + public PixelComparator withThreshold(double threshold) { + return new PixelComparator(threshold); + } + + @Override + public ImageComparisonResult compare(BufferedImage expected, BufferedImage actual) { + int width = Math.max(expected.getWidth(), actual.getWidth()); + int height = Math.max(expected.getHeight(), actual.getHeight()); + int size = width * height; + + double totalDifference = 0.0; + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + double pixelDiff = pixelDifference(expected, actual, x, y); + totalDifference += pixelDiff / size; + } + } + + double similarity = 1.0 - totalDifference; + return new PixelComparisonResult(similarity, threshold); + } + + private double pixelDifference(BufferedImage expected, BufferedImage actual, int x, int y) { + boolean expectedMissing = x >= expected.getWidth() || y >= expected.getHeight(); + boolean actualMissing = x >= actual.getWidth() || y >= actual.getHeight(); + + if (expectedMissing || actualMissing) { + return 1.0; + } + + int expectedRgb = expected.getRGB(x, y); + int actualRgb = actual.getRGB(x, y); + + int expectedAlpha = (expectedRgb >> 24) & MAX_VALUE; + int expectedRed = (expectedRgb >> 16) & MAX_VALUE; + int expectedGreen = (expectedRgb >> 8) & MAX_VALUE; + int expectedBlue = expectedRgb & MAX_VALUE; + + int actualAlpha = (actualRgb >> 24) & MAX_VALUE; + int actualRed = (actualRgb >> 16) & MAX_VALUE; + int actualGreen = (actualRgb >> 8) & MAX_VALUE; + int actualBlue = actualRgb & MAX_VALUE; + + double colorDiff = + (Math.abs(expectedRed - actualRed) + + Math.abs(expectedGreen - actualGreen) + + Math.abs(expectedBlue - actualBlue)) + / (double) (MAX_VALUE * 3); + + double alphaWeight = ((expectedAlpha + actualAlpha) / 2.0) / MAX_VALUE; + return colorDiff * alphaWeight; + } +} diff --git a/modules/image/src/main/java/org/approvej/image/compare/PixelComparisonResult.java b/modules/image/src/main/java/org/approvej/image/compare/PixelComparisonResult.java new file mode 100644 index 00000000..9c150231 --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/compare/PixelComparisonResult.java @@ -0,0 +1,25 @@ +package org.approvej.image.compare; + +import org.jspecify.annotations.NullMarked; + +/** + * Result of a pixel-by-pixel image comparison. + * + * @param similarity the similarity score from 0.0 to 1.0 + * @param threshold the threshold used for matching + */ +@NullMarked +public record PixelComparisonResult(double similarity, double threshold) + implements ImageComparisonResult { + + @Override + public boolean isMatch() { + return similarity >= (1.0 - threshold); + } + + @Override + public String description() { + return "Pixel comparison: %.2f%% similar (threshold: %.2f%%)%s" + .formatted(similarity * 100, (1.0 - threshold) * 100, isMatch() ? "" : " - MISMATCH"); + } +} diff --git a/modules/image/src/main/java/org/approvej/image/scrub/ImageScrubbers.java b/modules/image/src/main/java/org/approvej/image/scrub/ImageScrubbers.java new file mode 100644 index 00000000..72a68b2c --- /dev/null +++ b/modules/image/src/main/java/org/approvej/image/scrub/ImageScrubbers.java @@ -0,0 +1,111 @@ +package org.approvej.image.scrub; + +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.function.UnaryOperator; +import org.jspecify.annotations.NullMarked; + +/** + * Factory for image scrubbers that mask regions of an image. + * + *

Image scrubbers are useful for hiding dynamic content (like version numbers, timestamps, or + * ads) that would otherwise cause approval tests to fail. + * + *

Example usage: + * + *

{@code
+ * approveImage(screenshot)
+ *     .scrubbedOf(region(10, 50, 100, 20))
+ *     .byFile();
+ * }
+ */ +@NullMarked +public final class ImageScrubbers { + + /** The default color used to mask scrubbed regions (magenta). */ + public static final Color DEFAULT_SCRUB_COLOR = Color.MAGENTA; + + private ImageScrubbers() {} + + /** + * Creates a scrubber that masks a single rectangular region. + * + * @param x the x coordinate of the region's top-left corner + * @param y the y coordinate of the region's top-left corner + * @param width the width of the region + * @param height the height of the region + * @return a scrubber that masks the specified region + */ + public static UnaryOperator region(int x, int y, int width, int height) { + return region(new Rectangle(x, y, width, height)); + } + + /** + * Creates a scrubber that masks a single rectangular region. + * + * @param rectangle the region to mask + * @return a scrubber that masks the specified region + */ + public static UnaryOperator region(Rectangle rectangle) { + return regions(DEFAULT_SCRUB_COLOR, rectangle); + } + + /** + * Creates a scrubber that masks a single rectangular region with a custom color. + * + * @param color the color to use for masking + * @param x the x coordinate of the region's top-left corner + * @param y the y coordinate of the region's top-left corner + * @param width the width of the region + * @param height the height of the region + * @return a scrubber that masks the specified region + */ + public static UnaryOperator region( + Color color, int x, int y, int width, int height) { + return regions(color, new Rectangle(x, y, width, height)); + } + + /** + * Creates a scrubber that masks multiple rectangular regions. + * + * @param rectangles the regions to mask + * @return a scrubber that masks the specified regions + */ + public static UnaryOperator regions(Rectangle... rectangles) { + return regions(DEFAULT_SCRUB_COLOR, rectangles); + } + + /** + * Creates a scrubber that masks multiple rectangular regions with a custom color. + * + * @param color the color to use for masking + * @param rectangles the regions to mask + * @return a scrubber that masks the specified regions + */ + public static UnaryOperator regions(Color color, Rectangle... rectangles) { + return image -> { + BufferedImage copy = copyImage(image); + Graphics2D g = copy.createGraphics(); + try { + g.setColor(color); + for (Rectangle rect : rectangles) { + g.fillRect(rect.x, rect.y, rect.width, rect.height); + } + } finally { + g.dispose(); + } + return copy; + }; + } + + private static BufferedImage copyImage(BufferedImage source) { + BufferedImage copy = new BufferedImage(source.getWidth(), source.getHeight(), source.getType()); + Graphics g = copy.getGraphics(); + try { + g.drawImage(source, 0, 0, null); + } finally { + g.dispose(); + } + return copy; + } +} diff --git a/modules/image/src/test/java/org/approvej/image/ImageApprovalBuilderTest-scrubbedOf-approved.png b/modules/image/src/test/java/org/approvej/image/ImageApprovalBuilderTest-scrubbedOf-approved.png new file mode 100644 index 00000000..d3da2275 Binary files /dev/null and b/modules/image/src/test/java/org/approvej/image/ImageApprovalBuilderTest-scrubbedOf-approved.png differ diff --git a/modules/image/src/test/java/org/approvej/image/ImageApprovalBuilderTest-success-approved.png b/modules/image/src/test/java/org/approvej/image/ImageApprovalBuilderTest-success-approved.png new file mode 100644 index 00000000..39cbc191 Binary files /dev/null and b/modules/image/src/test/java/org/approvej/image/ImageApprovalBuilderTest-success-approved.png differ diff --git a/modules/image/src/test/java/org/approvej/image/ImageApprovalBuilderTest.java b/modules/image/src/test/java/org/approvej/image/ImageApprovalBuilderTest.java new file mode 100644 index 00000000..c3eaeece --- /dev/null +++ b/modules/image/src/test/java/org/approvej/image/ImageApprovalBuilderTest.java @@ -0,0 +1,30 @@ +package org.approvej.image; + +import static java.util.Objects.requireNonNull; +import static org.approvej.approve.PathProviders.nextToTest; +import static org.approvej.image.ImageApprovalBuilder.approveImage; +import static org.approvej.image.scrub.ImageScrubbers.region; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; + +class ImageApprovalBuilderTest { + + @Test + void success() throws IOException { + BufferedImage image = + ImageIO.read(requireNonNull(getClass().getResourceAsStream("/screenshot.png"))); + + approveImage(image).byFile(nextToTest().filenameExtension("png")); + } + + @Test + void scrubbedOf() throws IOException { + BufferedImage image = + ImageIO.read(requireNonNull(getClass().getResourceAsStream("/screenshot.png"))); + + approveImage(image).scrubbedOf(region(10, 10, 50, 20)).byFile(); + } +} diff --git a/modules/image/src/test/java/org/approvej/image/approve/AnalysedImageTest.java b/modules/image/src/test/java/org/approvej/image/approve/AnalysedImageTest.java new file mode 100644 index 00000000..d6bfc500 --- /dev/null +++ b/modules/image/src/test/java/org/approvej/image/approve/AnalysedImageTest.java @@ -0,0 +1,162 @@ +package org.approvej.image.approve; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; + +class AnalysedImageTest { + + private static final AnalysedImage SCREENSHOT; + private static final AnalysedImage BLACK; + private static final AnalysedImage WHITE; + private static final AnalysedImage RED; + private static final AnalysedImage GREEN; + private static final AnalysedImage BLUE; + private static final AnalysedImage CYAN; + private static final AnalysedImage MAGENTA; + private static final AnalysedImage YELLOW; + + static { + try { + SCREENSHOT = + AnalysedImage.analyse( + ImageIO.read( + requireNonNull(AnalysedImageTest.class.getResourceAsStream("/screenshot.png")))); + BLACK = + AnalysedImage.analyse( + ImageIO.read( + requireNonNull(AnalysedImageTest.class.getResourceAsStream("/black.png")))); + WHITE = + AnalysedImage.analyse( + ImageIO.read( + requireNonNull(AnalysedImageTest.class.getResourceAsStream("/white.png")))); + RED = + AnalysedImage.analyse( + ImageIO.read( + requireNonNull(AnalysedImageTest.class.getResourceAsStream("/red.png")))); + GREEN = + AnalysedImage.analyse( + ImageIO.read( + requireNonNull(AnalysedImageTest.class.getResourceAsStream("/green.png")))); + BLUE = + AnalysedImage.analyse( + ImageIO.read( + requireNonNull(AnalysedImageTest.class.getResourceAsStream("/blue.png")))); + CYAN = + AnalysedImage.analyse( + ImageIO.read( + requireNonNull(AnalysedImageTest.class.getResourceAsStream("/cyan.png")))); + MAGENTA = + AnalysedImage.analyse( + ImageIO.read( + requireNonNull(AnalysedImageTest.class.getResourceAsStream("/magenta.png")))); + YELLOW = + AnalysedImage.analyse( + ImageIO.read( + requireNonNull(AnalysedImageTest.class.getResourceAsStream("/yellow.png")))); + } catch (IOException e) { + throw new RuntimeException("Failed to load test images", e); + } + } + + @Test + void difference_screenshot() { + assertThat(SCREENSHOT.difference(SCREENSHOT)).isZero(); + } + + @Test + void difference_black_white() { + assertThat(WHITE.difference(BLACK)).isEqualTo(1.0); + assertThat(BLACK.difference(WHITE)).isEqualTo(1.0); + } + + @Test + void difference_primaries() { + assertThat(RED.difference(RED)).isZero(); + assertThat(RED.difference(GREEN)).isEqualTo(6.0 / 9.0); + assertThat(RED.difference(BLUE)).isEqualTo(6.0 / 9.0); + assertThat(RED.difference(BLACK)).isEqualTo(3.0 / 9.0); + assertThat(RED.difference(WHITE)).isEqualTo(6.0 / 9.0); + assertThat(GREEN.difference(RED)).isEqualTo(6.0 / 9.0); + assertThat(GREEN.difference(GREEN)).isZero(); + assertThat(GREEN.difference(BLUE)).isEqualTo(6.0 / 9.0); + assertThat(GREEN.difference(BLACK)).isEqualTo(3.0 / 9.0); + assertThat(GREEN.difference(WHITE)).isEqualTo(6.0 / 9.0); + assertThat(BLUE.difference(RED)).isEqualTo(6.0 / 9.0); + assertThat(BLUE.difference(GREEN)).isEqualTo(6.0 / 9.0); + assertThat(BLUE.difference(BLUE)).isZero(); + assertThat(BLUE.difference(BLACK)).isEqualTo(3.0 / 9.0); + assertThat(BLUE.difference(WHITE)).isEqualTo(6.0 / 9.0); + } + + @Test + void difference_secondaries() { + assertThat(CYAN.difference(RED)).isOne(); + assertThat(CYAN.difference(GREEN)).isEqualTo(3.0 / 9.0); + assertThat(CYAN.difference(BLUE)).isEqualTo(3.0 / 9.0); + assertThat(CYAN.difference(BLACK)).isEqualTo(6.0 / 9.0); + assertThat(CYAN.difference(WHITE)).isEqualTo(3.0 / 9.0); + assertThat(MAGENTA.difference(RED)).isEqualTo(3.0 / 9.0); + assertThat(MAGENTA.difference(GREEN)).isOne(); + assertThat(MAGENTA.difference(BLUE)).isEqualTo(3.0 / 9.0); + assertThat(MAGENTA.difference(BLACK)).isEqualTo(6.0 / 9.0); + assertThat(MAGENTA.difference(WHITE)).isEqualTo(3.0 / 9.0); + assertThat(YELLOW.difference(RED)).isEqualTo(3.0 / 9.0); + assertThat(YELLOW.difference(GREEN)).isEqualTo(3.0 / 9.0); + assertThat(YELLOW.difference(BLUE)).isOne(); + assertThat(YELLOW.difference(BLACK)).isEqualTo(6.0 / 9.0); + assertThat(YELLOW.difference(WHITE)).isEqualTo(3.0 / 9.0); + } + + @Test + void isMoreDifferentThan_screenshot() { + assertThat(SCREENSHOT.isMoreDifferentThan(SCREENSHOT, 0.99999999)).isFalse(); + } + + @Test + void isMoreDifferentThan_black_white() { + assertThat(WHITE.isMoreDifferentThan(BLACK, 0.99999999)).isTrue(); + assertThat(BLACK.isMoreDifferentThan(WHITE, 0.99999999)).isTrue(); + } + + @Test + void isMoreDifferentThan_primaries() { + assertThat(RED.isMoreDifferentThan(RED, 0.99999999)).isFalse(); + assertThat(RED.isMoreDifferentThan(GREEN, 0.6)).isTrue(); + assertThat(RED.isMoreDifferentThan(BLUE, 0.6)).isTrue(); + assertThat(RED.isMoreDifferentThan(BLACK, 0.3)).isTrue(); + assertThat(RED.isMoreDifferentThan(WHITE, 0.6)).isTrue(); + assertThat(GREEN.isMoreDifferentThan(RED, 0.6)).isTrue(); + assertThat(GREEN.isMoreDifferentThan(GREEN, 0.99999999)).isFalse(); + assertThat(GREEN.isMoreDifferentThan(BLUE, 0.6)).isTrue(); + assertThat(GREEN.isMoreDifferentThan(BLACK, 0.3)).isTrue(); + assertThat(GREEN.isMoreDifferentThan(WHITE, 0.6)).isTrue(); + assertThat(BLUE.isMoreDifferentThan(RED, 0.6)).isTrue(); + assertThat(BLUE.isMoreDifferentThan(GREEN, 0.6)).isTrue(); + assertThat(BLUE.isMoreDifferentThan(BLUE, 0.99999999)).isFalse(); + assertThat(BLUE.isMoreDifferentThan(BLACK, 0.3)).isTrue(); + assertThat(BLUE.isMoreDifferentThan(WHITE, 0.6)).isTrue(); + } + + @Test + void isMoreDifferentThan_secondaries() { + assertThat(CYAN.isMoreDifferentThan(RED, 0.99999999)).isTrue(); + assertThat(CYAN.isMoreDifferentThan(GREEN, 0.3)).isTrue(); + assertThat(CYAN.isMoreDifferentThan(BLUE, 0.3)).isTrue(); + assertThat(CYAN.isMoreDifferentThan(BLACK, 0.6)).isTrue(); + assertThat(CYAN.isMoreDifferentThan(WHITE, 0.3)).isTrue(); + assertThat(MAGENTA.isMoreDifferentThan(RED, 0.3)).isTrue(); + assertThat(MAGENTA.isMoreDifferentThan(GREEN, 0.99999999)).isTrue(); + assertThat(MAGENTA.isMoreDifferentThan(BLUE, 0.3)).isTrue(); + assertThat(MAGENTA.isMoreDifferentThan(BLACK, 0.6)).isTrue(); + assertThat(MAGENTA.isMoreDifferentThan(WHITE, 0.3)).isTrue(); + assertThat(YELLOW.isMoreDifferentThan(RED, 0.3)).isTrue(); + assertThat(YELLOW.isMoreDifferentThan(GREEN, 0.3)).isTrue(); + assertThat(YELLOW.isMoreDifferentThan(BLUE, 0.99999999)).isTrue(); + assertThat(YELLOW.isMoreDifferentThan(BLACK, 0.6)).isTrue(); + assertThat(YELLOW.isMoreDifferentThan(WHITE, 0.3)).isTrue(); + } +} diff --git a/modules/image/src/test/java/org/approvej/image/approve/ImageFileApproverTest.java b/modules/image/src/test/java/org/approvej/image/approve/ImageFileApproverTest.java new file mode 100644 index 00000000..5badd4a0 --- /dev/null +++ b/modules/image/src/test/java/org/approvej/image/approve/ImageFileApproverTest.java @@ -0,0 +1,23 @@ +package org.approvej.image.approve; + +import static java.util.Objects.requireNonNull; +import static org.approvej.approve.PathProviders.nextToTest; +import static org.approvej.image.compare.ImageComparators.perceptualHash; +import static org.junit.jupiter.api.Assertions.*; + +import java.awt.*; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; + +class ImageFileApproverTest { + + @Test + public void apply() throws IOException { + Image image = ImageIO.read(requireNonNull(getClass().getResourceAsStream("/screenshot.png"))); + + ImageFileApprover approver = new ImageFileApprover(nextToTest(), perceptualHash()); + + // approver.apply(image); + } +} diff --git a/modules/image/src/test/java/org/approvej/image/compare/PerceptualHashComparatorTest.java b/modules/image/src/test/java/org/approvej/image/compare/PerceptualHashComparatorTest.java new file mode 100644 index 00000000..12e7b732 --- /dev/null +++ b/modules/image/src/test/java/org/approvej/image/compare/PerceptualHashComparatorTest.java @@ -0,0 +1,100 @@ +package org.approvej.image.compare; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; + +class PerceptualHashComparatorTest { + + private static final BufferedImage SCREENSHOT; + private static final BufferedImage BLACK; + private static final BufferedImage WHITE; + private static final BufferedImage RED; + private static final BufferedImage GREEN; + + static { + try { + SCREENSHOT = + ImageIO.read( + requireNonNull( + PerceptualHashComparatorTest.class.getResourceAsStream("/screenshot.png"))); + BLACK = + ImageIO.read( + requireNonNull(PerceptualHashComparatorTest.class.getResourceAsStream("/black.png"))); + WHITE = + ImageIO.read( + requireNonNull(PerceptualHashComparatorTest.class.getResourceAsStream("/white.png"))); + RED = + ImageIO.read( + requireNonNull(PerceptualHashComparatorTest.class.getResourceAsStream("/red.png"))); + GREEN = + ImageIO.read( + requireNonNull(PerceptualHashComparatorTest.class.getResourceAsStream("/green.png"))); + } catch (IOException e) { + throw new RuntimeException("Failed to load test images", e); + } + } + + @Test + void identicalImages_match() { + PerceptualHashComparator comparator = ImageComparators.perceptualHash(); + + ImageComparisonResult result = comparator.compare(SCREENSHOT, SCREENSHOT); + + assertThat(result.isMatch()).isTrue(); + assertThat(result.similarity()).isEqualTo(1.0); + } + + @Test + void blackAndWhite_noMatch() { + PerceptualHashComparator comparator = ImageComparators.perceptualHash(); + + ImageComparisonResult result = comparator.compare(BLACK, WHITE); + + assertThat(result.isMatch()).isFalse(); + assertThat(result.similarity()).isLessThan(0.9); + } + + @Test + void differentColors_lowSimilarity() { + PerceptualHashComparator comparator = ImageComparators.perceptualHash(); + + ImageComparisonResult result = comparator.compare(RED, GREEN); + + assertThat(result.similarity()).isLessThan(0.9); + } + + @Test + void withThreshold_customThreshold() { + PerceptualHashComparator comparator = ImageComparators.perceptualHash().withThreshold(0.95); + + ImageComparisonResult result = comparator.compare(SCREENSHOT, SCREENSHOT); + + assertThat(result.isMatch()).isTrue(); + } + + @Test + void description_containsInfo() { + PerceptualHashComparator comparator = ImageComparators.perceptualHash(); + + ImageComparisonResult result = comparator.compare(RED, GREEN); + + assertThat(result.description()).contains("Perceptual hash"); + assertThat(result.description()).contains("similar"); + assertThat(result.description()).contains("Hamming distance"); + } + + @Test + void hammingDistance_identicalImages() { + PerceptualHashComparator comparator = ImageComparators.perceptualHash(); + + PerceptualHashComparisonResult result = + (PerceptualHashComparisonResult) comparator.compare(SCREENSHOT, SCREENSHOT); + + assertThat(result.hammingDistance()).isZero(); + } +} diff --git a/modules/image/src/test/java/org/approvej/image/compare/PixelComparatorTest.java b/modules/image/src/test/java/org/approvej/image/compare/PixelComparatorTest.java new file mode 100644 index 00000000..f55e4f7e --- /dev/null +++ b/modules/image/src/test/java/org/approvej/image/compare/PixelComparatorTest.java @@ -0,0 +1,86 @@ +package org.approvej.image.compare; + +import static java.util.Objects.requireNonNull; +import static org.assertj.core.api.Assertions.assertThat; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import javax.imageio.ImageIO; +import org.junit.jupiter.api.Test; + +class PixelComparatorTest { + + private static final BufferedImage SCREENSHOT; + private static final BufferedImage BLACK; + private static final BufferedImage WHITE; + private static final BufferedImage RED; + private static final BufferedImage GREEN; + + static { + try { + SCREENSHOT = + ImageIO.read( + requireNonNull(PixelComparatorTest.class.getResourceAsStream("/screenshot.png"))); + BLACK = + ImageIO.read(requireNonNull(PixelComparatorTest.class.getResourceAsStream("/black.png"))); + WHITE = + ImageIO.read(requireNonNull(PixelComparatorTest.class.getResourceAsStream("/white.png"))); + RED = ImageIO.read(requireNonNull(PixelComparatorTest.class.getResourceAsStream("/red.png"))); + GREEN = + ImageIO.read(requireNonNull(PixelComparatorTest.class.getResourceAsStream("/green.png"))); + } catch (IOException e) { + throw new RuntimeException("Failed to load test images", e); + } + } + + @Test + void identicalImages_match() { + PixelComparator comparator = ImageComparators.pixel(); + + ImageComparisonResult result = comparator.compare(SCREENSHOT, SCREENSHOT); + + assertThat(result.isMatch()).isTrue(); + assertThat(result.similarity()).isEqualTo(1.0); + } + + @Test + void blackAndWhite_noMatch() { + PixelComparator comparator = ImageComparators.pixel(); + + ImageComparisonResult result = comparator.compare(BLACK, WHITE); + + assertThat(result.isMatch()).isFalse(); + assertThat(result.similarity()).isEqualTo(0.0); + } + + @Test + void differentColors_noMatch() { + PixelComparator comparator = ImageComparators.pixel(); + + ImageComparisonResult result = comparator.compare(RED, GREEN); + + assertThat(result.isMatch()).isFalse(); + assertThat(result.similarity()).isLessThan(0.5); + } + + @Test + void withThreshold_customThreshold() { + PixelComparator comparator = ImageComparators.pixel().withThreshold(0.5); + + ImageComparisonResult result = comparator.compare(RED, GREEN); + + // With 50% threshold, images that are less than 50% different should still match + assertThat(result.similarity()).isLessThan(0.5); + } + + @Test + void description_containsInfo() { + PixelComparator comparator = ImageComparators.pixel(); + + ImageComparisonResult result = comparator.compare(RED, GREEN); + + assertThat(result.description()).contains("Pixel comparison"); + assertThat(result.description()).contains("similar"); + assertThat(result.description()).contains("threshold"); + } +} diff --git a/modules/image/src/test/java/org/approvej/image/scrub/ImageScrubbersTest.java b/modules/image/src/test/java/org/approvej/image/scrub/ImageScrubbersTest.java new file mode 100644 index 00000000..4068f70a --- /dev/null +++ b/modules/image/src/test/java/org/approvej/image/scrub/ImageScrubbersTest.java @@ -0,0 +1,83 @@ +package org.approvej.image.scrub; + +import static org.approvej.image.scrub.ImageScrubbers.DEFAULT_SCRUB_COLOR; +import static org.approvej.image.scrub.ImageScrubbers.region; +import static org.approvej.image.scrub.ImageScrubbers.regions; +import static org.assertj.core.api.Assertions.assertThat; + +import java.awt.*; +import java.awt.image.BufferedImage; +import org.junit.jupiter.api.Test; + +class ImageScrubbersTest { + + @Test + void region_masks_rectangular_area() { + BufferedImage image = createWhiteImage(100, 100); + + BufferedImage scrubbed = region(10, 20, 30, 40).apply(image); + + assertThat(scrubbed.getRGB(15, 30)).isEqualTo(DEFAULT_SCRUB_COLOR.getRGB()); + assertThat(scrubbed.getRGB(0, 0)).isEqualTo(Color.WHITE.getRGB()); + } + + @Test + void region_does_not_modify_original_image() { + BufferedImage image = createWhiteImage(100, 100); + + region(10, 20, 30, 40).apply(image); + + assertThat(image.getRGB(15, 30)).isEqualTo(Color.WHITE.getRGB()); + } + + @Test + void region_with_rectangle() { + BufferedImage image = createWhiteImage(100, 100); + + BufferedImage scrubbed = region(new Rectangle(10, 20, 30, 40)).apply(image); + + assertThat(scrubbed.getRGB(15, 30)).isEqualTo(DEFAULT_SCRUB_COLOR.getRGB()); + } + + @Test + void region_with_custom_color() { + BufferedImage image = createWhiteImage(100, 100); + + BufferedImage scrubbed = region(Color.RED, 10, 20, 30, 40).apply(image); + + assertThat(scrubbed.getRGB(15, 30)).isEqualTo(Color.RED.getRGB()); + } + + @Test + void regions_masks_multiple_areas() { + BufferedImage image = createWhiteImage(100, 100); + + BufferedImage scrubbed = + regions(new Rectangle(10, 10, 20, 20), new Rectangle(50, 50, 20, 20)).apply(image); + + assertThat(scrubbed.getRGB(15, 15)).isEqualTo(DEFAULT_SCRUB_COLOR.getRGB()); + assertThat(scrubbed.getRGB(55, 55)).isEqualTo(DEFAULT_SCRUB_COLOR.getRGB()); + assertThat(scrubbed.getRGB(35, 35)).isEqualTo(Color.WHITE.getRGB()); + } + + @Test + void regions_with_custom_color() { + BufferedImage image = createWhiteImage(100, 100); + + BufferedImage scrubbed = + regions(Color.BLUE, new Rectangle(10, 10, 20, 20), new Rectangle(50, 50, 20, 20)) + .apply(image); + + assertThat(scrubbed.getRGB(15, 15)).isEqualTo(Color.BLUE.getRGB()); + assertThat(scrubbed.getRGB(55, 55)).isEqualTo(Color.BLUE.getRGB()); + } + + private static BufferedImage createWhiteImage(int width, int height) { + BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + Graphics g = image.getGraphics(); + g.setColor(Color.WHITE); + g.fillRect(0, 0, width, height); + g.dispose(); + return image; + } +} diff --git a/modules/image/src/test/resources/black.png b/modules/image/src/test/resources/black.png new file mode 100644 index 00000000..704afe43 Binary files /dev/null and b/modules/image/src/test/resources/black.png differ diff --git a/modules/image/src/test/resources/blue.png b/modules/image/src/test/resources/blue.png new file mode 100644 index 00000000..728af8b0 Binary files /dev/null and b/modules/image/src/test/resources/blue.png differ diff --git a/modules/image/src/test/resources/cyan.png b/modules/image/src/test/resources/cyan.png new file mode 100644 index 00000000..bd39e738 Binary files /dev/null and b/modules/image/src/test/resources/cyan.png differ diff --git a/modules/image/src/test/resources/green.png b/modules/image/src/test/resources/green.png new file mode 100644 index 00000000..647a086b Binary files /dev/null and b/modules/image/src/test/resources/green.png differ diff --git a/modules/image/src/test/resources/magenta.png b/modules/image/src/test/resources/magenta.png new file mode 100644 index 00000000..be51293e Binary files /dev/null and b/modules/image/src/test/resources/magenta.png differ diff --git a/modules/image/src/test/resources/red.png b/modules/image/src/test/resources/red.png new file mode 100644 index 00000000..bb3a5759 Binary files /dev/null and b/modules/image/src/test/resources/red.png differ diff --git a/modules/image/src/test/resources/rgb.png b/modules/image/src/test/resources/rgb.png new file mode 100644 index 00000000..4dd53d13 Binary files /dev/null and b/modules/image/src/test/resources/rgb.png differ diff --git a/modules/image/src/test/resources/screenshot.png b/modules/image/src/test/resources/screenshot.png new file mode 100644 index 00000000..39cbc191 Binary files /dev/null and b/modules/image/src/test/resources/screenshot.png differ diff --git a/modules/image/src/test/resources/white.png b/modules/image/src/test/resources/white.png new file mode 100644 index 00000000..8b72a3fe Binary files /dev/null and b/modules/image/src/test/resources/white.png differ diff --git a/modules/image/src/test/resources/yellow.png b/modules/image/src/test/resources/yellow.png new file mode 100644 index 00000000..87bcbaa2 Binary files /dev/null and b/modules/image/src/test/resources/yellow.png differ diff --git a/settings.gradle.kts b/settings.gradle.kts index 321dace2..718c3a64 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,8 @@ include("bom") include("modules:core") +include("modules:image") + include("modules:json-jackson") include("modules:json-jackson3")