diff --git a/build.gradle.kts b/build.gradle.kts index 0675c2db3..525641e50 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,4 +8,5 @@ plugins { // Set the build directory to not /build to prevent accidental deletion through the clean action // Can be deleted after the migration to Gradle is complete + layout.buildDirectory = file(".build") \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 8f8cb74c7..b0be4a376 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -11,5 +11,7 @@ include( "java:libraries:pdf", "java:libraries:serial", "java:libraries:svg", + ":visual-tests" ) -include("app:utils") \ No newline at end of file +include("app:utils") +include(":visual-tests") \ No newline at end of file diff --git a/visual-tests/__screenshots__/basic-shapes/blue-square-linux.png b/visual-tests/__screenshots__/basic-shapes/blue-square-linux.png new file mode 100644 index 000000000..e2f49aa29 Binary files /dev/null and b/visual-tests/__screenshots__/basic-shapes/blue-square-linux.png differ diff --git a/visual-tests/__screenshots__/basic-shapes/custom-size-rect-linux.png b/visual-tests/__screenshots__/basic-shapes/custom-size-rect-linux.png new file mode 100644 index 000000000..d0eaca541 Binary files /dev/null and b/visual-tests/__screenshots__/basic-shapes/custom-size-rect-linux.png differ diff --git a/visual-tests/__screenshots__/basic-shapes/green-circle-linux.png b/visual-tests/__screenshots__/basic-shapes/green-circle-linux.png new file mode 100644 index 000000000..d4434cc2b Binary files /dev/null and b/visual-tests/__screenshots__/basic-shapes/green-circle-linux.png differ diff --git a/visual-tests/__screenshots__/basic-shapes/red-circle-linux.png b/visual-tests/__screenshots__/basic-shapes/red-circle-linux.png new file mode 100644 index 000000000..7b002aff2 Binary files /dev/null and b/visual-tests/__screenshots__/basic-shapes/red-circle-linux.png differ diff --git a/visual-tests/__screenshots__/rendering/linear-gradient-linux.png b/visual-tests/__screenshots__/rendering/linear-gradient-linux.png new file mode 100644 index 000000000..87bb74dfc Binary files /dev/null and b/visual-tests/__screenshots__/rendering/linear-gradient-linux.png differ diff --git a/visual-tests/build.gradle.kts b/visual-tests/build.gradle.kts new file mode 100644 index 000000000..f5931f0a2 --- /dev/null +++ b/visual-tests/build.gradle.kts @@ -0,0 +1,227 @@ +plugins { + java + application +} + +repositories { + mavenCentral() + maven { url = uri("https://jogamp.org/deployment/maven") } +} + +dependencies { + implementation(project(":core")) + + // JUnit BOM to manage versions + testImplementation(platform("org.junit:junit-bom:5.9.3")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.junit.platform:junit-platform-suite:1.9.3") + + testImplementation("org.assertj:assertj-core:3.24.2") +} + + +application { + mainClass.set("ProcessingVisualTestExamples") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.test { + useJUnitPlatform() + + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + + // Disable parallel execution to avoid Processing window conflicts + maxParallelForks = 1 + + // Add system properties + systemProperty("java.awt.headless", "false") +} + +// Task to update baselines using JUnit +tasks.register("updateBaselines") { + description = "Update visual test baselines" + group = "verification" + + useJUnitPlatform { + includeTags("baseline") + } + + systemProperty("update.baselines", "true") + maxParallelForks = 1 + + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + } +} + +tasks.register("testShapes") { + description = "Run shape-related visual tests" + group = "verification" + + useJUnitPlatform { + includeTags("shapes") + } + + maxParallelForks = 1 +} + +tasks.register("testBasicShapes") { + description = "Run basic shapes visual tests" + group = "verification" + + useJUnitPlatform { + includeTags("basic") + } + + outputs.upToDateWhen { false } + maxParallelForks = 1 + + // Add test logging to see what's happening + testLogging { + events("passed", "skipped", "failed", "started") + showStandardStreams = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } +} + +// Task to run ONLY visual tests (no other test types) +tasks.register("visualTests") { + description = "Run all visual tests" + group = "verification" + + useJUnitPlatform { + // Include all tests in the visual test package + includeEngines("junit-jupiter") + } + + filter { + includeTestsMatching("visual.*") + } + + outputs.upToDateWhen { false } + maxParallelForks = 1 + + testLogging { + events("passed", "skipped", "failed", "started") + showStandardStreams = true + displayGranularity = 2 + } +} + +tasks.register("testRendering") { + description = "Run rendering visual tests" + group = "verification" + + useJUnitPlatform { + includeTags("rendering") + } + + outputs.upToDateWhen { false } + maxParallelForks = 1 + + testLogging { + events("passed", "skipped", "failed", "started") + showStandardStreams = true + } +} + +tasks.register("runSuite") { + description = "Run specific test suite (use -PsuiteClass=SuiteName)" + group = "verification" + + useJUnitPlatform { + val suiteClass = project.findProperty("suiteClass") as String? + ?: "visual.suites.AllVisualTests" + includeTags(suiteClass) + } + + outputs.upToDateWhen { false } + maxParallelForks = 1 +} + +// Update baselines for specific suite +tasks.register("updateBaselinesForSuite") { + description = "Update baselines for specific suite (use -Psuite=tag)" + group = "verification" + + useJUnitPlatform { + val suite = project.findProperty("suite") as String? ?: "baseline" + includeTags(suite, "baseline") + } + + systemProperty("update.baselines", "true") + outputs.upToDateWhen { false } + maxParallelForks = 1 +} + +// CI-specific test task +tasks.register("ciTest") { + description = "Run visual tests in CI mode" + group = "verification" + + useJUnitPlatform() + + outputs.upToDateWhen { false } + + maxParallelForks = 1 + + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + //exceptionFormat = org.gradle.api.tasks.testing.TestExceptionFormat.FULL + } + + // Generate XML reports for CI + reports { + junitXml.required.set(true) + html.required.set(true) + } + + // Fail fast in CI + failFast = true +} + +// Clean task for visual test artifacts +tasks.register("cleanVisualTestFiles") { + description = "Clean visual test artifacts" + group = "build" + + delete(fileTree(".") { + include("diff_*.png") + }) + + // Don't delete baselines by default - be explicit + doLast { + println("✓ Cleaned diff images") + println(" Baselines preserved in __screenshots__/") + println(" To clean baselines: rm -rf __screenshots__/") + } +} + +// Separate task to clean everything including baselines (dangerous!) +tasks.register("cleanAllVisualTestFiles") { + description = "Clean ALL visual test files INCLUDING BASELINES (use with caution!)" + group = "build" + + delete(fileTree(".") { + include("__screenshots__/**") + include("diff_*.png") + }) + + doFirst { + println("⚠️ WARNING: This will delete all baseline images!") + } +} + +tasks.named("clean") { + dependsOn("cleanVisualTestFiles") +} \ No newline at end of file diff --git a/visual-tests/src/main/java/processing/test/visual/BaselineManager.java b/visual-tests/src/main/java/processing/test/visual/BaselineManager.java new file mode 100644 index 000000000..a9d1186dc --- /dev/null +++ b/visual-tests/src/main/java/processing/test/visual/BaselineManager.java @@ -0,0 +1,54 @@ +package processing.test.visual; + +import processing.core.PImage; + +import java.util.List; + +// Baseline manager for updating reference images +public class BaselineManager { + private VisualTestRunner tester; + + public BaselineManager(VisualTestRunner tester) { + this.tester = tester; + } + + public void updateBaseline(String testName, ProcessingSketch sketch) { + updateBaseline(testName, sketch, new TestConfig()); + } + + public void updateBaseline(String testName, ProcessingSketch sketch, TestConfig config) { + System.out.println("Updating baseline for: " + testName); + + // Capture new image + SketchRunner runner = new SketchRunner(sketch, config); + runner.run(); + PImage newImage = runner.getImage(); + + // Save as baseline + String baselinePath = "__screenshots__/" + + testName.replaceAll("[^a-zA-Z0-9-_]", "-") + + "-" + detectPlatform() + ".png"; + newImage.save(baselinePath); + + System.out.println("Baseline updated: " + baselinePath); + } + + public void updateAllBaselines(ProcessingTestSuite suite) { + System.out.println("Updating all baselines..."); + List testNames = suite.getTestNames(); + + for (String testName : testNames) { + // Re-run the test to get the sketch and config + TestResult result = suite.runTest(testName); + // Note: In a real implementation, you'd need to store the sketch reference + // This is a simplified version + } + } + + private String detectPlatform() { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("mac")) return "darwin"; + if (os.contains("win")) return "win32"; + return "linux"; + } +} diff --git a/visual-tests/src/main/java/processing/test/visual/ImageComparator.java b/visual-tests/src/main/java/processing/test/visual/ImageComparator.java new file mode 100644 index 000000000..66bb34b50 --- /dev/null +++ b/visual-tests/src/main/java/processing/test/visual/ImageComparator.java @@ -0,0 +1,417 @@ +package processing.test.visual; + +import processing.core.*; +import java.util.*; + +class ComparisonResult { + public boolean passed; + public double mismatchRatio; + public boolean isFirstRun; + public PImage diffImage; + public ComparisonDetails details; + + public ComparisonResult(boolean passed, double mismatchRatio) { + this.passed = passed; + this.mismatchRatio = mismatchRatio; + this.isFirstRun = false; + } + + public ComparisonResult(boolean passed, PImage diffImage, ComparisonDetails details) { + this.passed = passed; + this.diffImage = diffImage; + this.details = details; + this.mismatchRatio = details != null ? (double) details.significantDiffPixels / (diffImage.width * diffImage.height) : 0.0; + this.isFirstRun = false; + } + + public static ComparisonResult createFirstRun() { + ComparisonResult result = new ComparisonResult(false, 0.0); + result.isFirstRun = true; + return result; + } + + public void saveDiffImage(String filePath) { + if (diffImage != null) { + diffImage.save(filePath); + System.out.println("Diff image saved: " + filePath); + } + } +} + +class ComparisonDetails { + public int totalDiffPixels; + public int significantDiffPixels; + public List clusters; + + public ComparisonDetails(int totalDiffPixels, int significantDiffPixels, List clusters) { + this.totalDiffPixels = totalDiffPixels; + this.significantDiffPixels = significantDiffPixels; + this.clusters = clusters; + } + + public void printDetails() { + System.out.println(" Total diff pixels: " + totalDiffPixels); + System.out.println(" Significant diff pixels: " + significantDiffPixels); + System.out.println(" Clusters found: " + clusters.size()); + + long lineShiftClusters = clusters.stream().filter(c -> c.isLineShift).count(); + if (lineShiftClusters > 0) { + System.out.println(" Line shift clusters (ignored): " + lineShiftClusters); + } + + // Print cluster details + for (int i = 0; i < clusters.size(); i++) { + ClusterInfo cluster = clusters.get(i); + System.out.println(" Cluster " + (i+1) + ": size=" + cluster.size + + ", lineShift=" + cluster.isLineShift); + } + } +} + +// Individual cluster information +class ClusterInfo { + public int size; + public List pixels; + public boolean isLineShift; + + public ClusterInfo(int size, List pixels, boolean isLineShift) { + this.size = size; + this.pixels = pixels; + this.isLineShift = isLineShift; + } +} + +// Simple 2D point +class Point2D { + public int x, y; + + public Point2D(int x, int y) { + this.x = x; + this.y = y; + } +} + +// Interface for pixel matching algorithms +interface PixelMatchingAlgorithm { + ComparisonResult compare(PImage baseline, PImage actual, double threshold); +} + +// Your sophisticated pixel matching algorithm +public class ImageComparator implements PixelMatchingAlgorithm { + + // Algorithm constants + private static final int MAX_SIDE = 400; + private static final int BG_COLOR = 0xFFFFFFFF; // White background + private static final int MIN_CLUSTER_SIZE = 4; + private static final int MAX_TOTAL_DIFF_PIXELS = 40; + private static final double DEFAULT_THRESHOLD = 0.5; + private static final double ALPHA = 0.1; + + private PApplet p; // Reference to PApplet for PImage creation + + public ImageComparator(PApplet p) { + this.p = p; + } + + @Override + public ComparisonResult compare(PImage baseline, PImage actual, double threshold) { + if (baseline == null || actual == null) { + return new ComparisonResult(false, 1.0); + } + + try { + return performComparison(baseline, actual, threshold); + } catch (Exception e) { + System.err.println("Comparison failed: " + e.getMessage()); + return new ComparisonResult(false, 1.0); + } + } + + private ComparisonResult performComparison(PImage baseline, PImage actual, double threshold) { + // Calculate scaling + double scale = Math.min( + (double) MAX_SIDE / baseline.width, + (double) MAX_SIDE / baseline.height + ); + + double ratio = (double) baseline.width / baseline.height; + boolean narrow = ratio != 1.0; + if (narrow) { + scale *= 2; + } + + // Resize images + PImage scaledActual = resizeImage(actual, scale); + PImage scaledBaseline = resizeImage(baseline, scale); + + // Ensure both images have the same dimensions + int width = scaledBaseline.width; + int height = scaledBaseline.height; + + // Create canvases with background color + PImage actualCanvas = createCanvasWithBackground(scaledActual, width, height); + PImage baselineCanvas = createCanvasWithBackground(scaledBaseline, width, height); + + // Create diff output canvas + PImage diffCanvas = p.createImage(width, height, PImage.RGB); + + // Run pixelmatch equivalent + int diffCount = pixelmatch(actualCanvas, baselineCanvas, diffCanvas, width, height, DEFAULT_THRESHOLD); + + // If no differences, return early + if (diffCount == 0) { + return new ComparisonResult(true, diffCanvas, null); + } + + // Post-process to identify and filter out isolated differences + Set visited = new HashSet<>(); + List clusterSizes = new ArrayList<>(); + + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int pos = y * width + x; + + // If this is a diff pixel and not yet visited + if (isDiffPixel(diffCanvas, x, y) && !visited.contains(pos)) { + ClusterInfo clusterInfo = findClusterSize(diffCanvas, x, y, width, height, visited); + clusterSizes.add(clusterInfo); + } + } + } + + // Determine if the differences are significant + List nonLineShiftClusters = clusterSizes.stream() + .filter(cluster -> !cluster.isLineShift && cluster.size >= MIN_CLUSTER_SIZE) + .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + + // Calculate significant differences excluding line shifts + int significantDiffPixels = nonLineShiftClusters.stream() + .mapToInt(cluster -> cluster.size) + .sum(); + + // Determine test result + boolean passed = diffCount == 0 || + significantDiffPixels == 0 || + (significantDiffPixels <= MAX_TOTAL_DIFF_PIXELS && nonLineShiftClusters.size() <= 2); + + ComparisonDetails details = new ComparisonDetails(diffCount, significantDiffPixels, clusterSizes); + + return new ComparisonResult(passed, diffCanvas, details); + } + + private PImage resizeImage(PImage image, double scale) { + int newWidth = (int) Math.ceil(image.width * scale); + int newHeight = (int) Math.ceil(image.height * scale); + + PImage resized = p.createImage(newWidth, newHeight, PImage.RGB); + resized.copy(image, 0, 0, image.width, image.height, 0, 0, newWidth, newHeight); + + return resized; + } + + private PImage createCanvasWithBackground(PImage image, int width, int height) { + PImage canvas = p.createImage(width, height, PImage.RGB); + + // Fill with background color (white) + canvas.loadPixels(); + for (int i = 0; i < canvas.pixels.length; i++) { + canvas.pixels[i] = BG_COLOR; + } + canvas.updatePixels(); + + // Draw the image on top + canvas.copy(image, 0, 0, image.width, image.height, 0, 0, image.width, image.height); + + return canvas; + } + + private int pixelmatch(PImage actual, PImage expected, PImage diff, int width, int height, double threshold) { + int diffCount = 0; + + actual.loadPixels(); + expected.loadPixels(); + diff.loadPixels(); + + for (int i = 0; i < actual.pixels.length; i++) { + int actualColor = actual.pixels[i]; + int expectedColor = expected.pixels[i]; + + double delta = colorDelta(actualColor, expectedColor); + + if (delta > threshold) { + // Mark as different (bright red pixel) + diff.pixels[i] = 0xFFFF0000; // Red + diffCount++; + } else { + // Mark as same (dimmed version of actual image) + int dimColor = dimColor(actualColor, ALPHA); + diff.pixels[i] = dimColor; + } + } + + diff.updatePixels(); + return diffCount; + } + + private double colorDelta(int color1, int color2) { + int r1 = (color1 >> 16) & 0xFF; + int g1 = (color1 >> 8) & 0xFF; + int b1 = color1 & 0xFF; + int a1 = (color1 >> 24) & 0xFF; + + int r2 = (color2 >> 16) & 0xFF; + int g2 = (color2 >> 8) & 0xFF; + int b2 = color2 & 0xFF; + int a2 = (color2 >> 24) & 0xFF; + + int dr = r1 - r2; + int dg = g1 - g2; + int db = b1 - b2; + int da = a1 - a2; + + return Math.sqrt(dr * dr + dg * dg + db * db + da * da) / 255.0; + } + + private int dimColor(int color, double alpha) { + int r = (int) (((color >> 16) & 0xFF) * alpha); + int g = (int) (((color >> 8) & 0xFF) * alpha); + int b = (int) ((color & 0xFF) * alpha); + int a = (int) (255 * alpha); + + r = Math.max(0, Math.min(255, r)); + g = Math.max(0, Math.min(255, g)); + b = Math.max(0, Math.min(255, b)); + a = Math.max(0, Math.min(255, a)); + + return (a << 24) | (r << 16) | (g << 8) | b; + } + + private boolean isDiffPixel(PImage image, int x, int y) { + if (x < 0 || x >= image.width || y < 0 || y >= image.height) return false; + + image.loadPixels(); + int color = image.pixels[y * image.width + x]; + + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + + return r == 255 && g == 0 && b == 0; + } + + private ClusterInfo findClusterSize(PImage diffImage, int startX, int startY, int width, int height, Set visited) { + List queue = new ArrayList<>(); + queue.add(new Point2D(startX, startY)); + + int size = 0; + List clusterPixels = new ArrayList<>(); + + while (!queue.isEmpty()) { + Point2D point = queue.remove(0); + int pos = point.y * width + point.x; + + // Skip if already visited + if (visited.contains(pos)) continue; + + // Skip if not a diff pixel + if (!isDiffPixel(diffImage, point.x, point.y)) continue; + + // Mark as visited + visited.add(pos); + size++; + clusterPixels.add(point); + + // Add neighbors to queue + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; + + int nx = point.x + dx; + int ny = point.y + dy; + + // Skip if out of bounds + if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; + + // Skip if already visited + int npos = ny * width + nx; + if (!visited.contains(npos)) { + queue.add(new Point2D(nx, ny)); + } + } + } + } + + // Determine if this is a line shift + boolean isLineShift = detectLineShift(clusterPixels, diffImage, width, height); + + return new ClusterInfo(size, clusterPixels, isLineShift); + } + + private boolean detectLineShift(List clusterPixels, PImage diffImage, int width, int height) { + if (clusterPixels.isEmpty()) return false; + + int linelikePixels = 0; + + for (Point2D pixel : clusterPixels) { + int neighbors = 0; + for (int dy = -1; dy <= 1; dy++) { + for (int dx = -1; dx <= 1; dx++) { + if (dx == 0 && dy == 0) continue; // Skip self + + int nx = pixel.x + dx; + int ny = pixel.y + dy; + + // Skip if out of bounds + if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; + + // Check if neighbor is a diff pixel + if (isDiffPixel(diffImage, nx, ny)) { + neighbors++; + } + } + } + + // Line-like pixels typically have 1-2 neighbors + if (neighbors <= 2) { + linelikePixels++; + } + } + + // If most pixels (>80%) in the cluster have ≤2 neighbors, it's likely a line shift + return (double) linelikePixels / clusterPixels.size() > 0.8; + } + + // Configuration methods + public ImageComparator setMaxSide(int maxSide) { + // For future configurability + return this; + } + + public ImageComparator setMinClusterSize(int minClusterSize) { + // For future configurability + return this; + } + + public ImageComparator setMaxTotalDiffPixels(int maxTotalDiffPixels) { + // For future configurability + return this; + } +} + +// Utility class for algorithm configuration +class ComparatorConfig { + public int maxSide = 400; + public int minClusterSize = 4; + public int maxTotalDiffPixels = 40; + public double threshold = 0.5; + public double alpha = 0.1; + public int backgroundColor = 0xFFFFFFFF; + + public ComparatorConfig() {} + + public ComparatorConfig(int maxSide, int minClusterSize, int maxTotalDiffPixels) { + this.maxSide = maxSide; + this.minClusterSize = minClusterSize; + this.maxTotalDiffPixels = maxTotalDiffPixels; + } +} \ No newline at end of file diff --git a/visual-tests/src/main/java/processing/test/visual/ProcessingSketch.java b/visual-tests/src/main/java/processing/test/visual/ProcessingSketch.java new file mode 100644 index 000000000..c879ffbb1 --- /dev/null +++ b/visual-tests/src/main/java/processing/test/visual/ProcessingSketch.java @@ -0,0 +1,9 @@ +package processing.test.visual; + +import processing.core.PApplet; + +// Interface for user sketches +public interface ProcessingSketch { + void setup(PApplet p); + void draw(PApplet p); +} diff --git a/visual-tests/src/main/java/processing/test/visual/TestConfig.java b/visual-tests/src/main/java/processing/test/visual/TestConfig.java new file mode 100644 index 000000000..ff69b2e75 --- /dev/null +++ b/visual-tests/src/main/java/processing/test/visual/TestConfig.java @@ -0,0 +1,33 @@ +package processing.test.visual; + +// Test configuration class +public class TestConfig { + public int width = 800; + public int height = 600; + public int[] backgroundColor = {255, 255, 255}; // RGB + public long renderWaitTime = 100; // milliseconds + public double threshold = 0.1; + + public TestConfig() {} + + public TestConfig(int width, int height) { + this.width = width; + this.height = height; + } + + public TestConfig(int width, int height, int[] backgroundColor) { + this.width = width; + this.height = height; + this.backgroundColor = backgroundColor; + } + + public TestConfig setThreshold(double threshold) { + this.threshold = threshold; + return this; + } + + public TestConfig setRenderWaitTime(long waitTime) { + this.renderWaitTime = waitTime; + return this; + } +} diff --git a/visual-tests/src/main/java/processing/test/visual/TestResult.java b/visual-tests/src/main/java/processing/test/visual/TestResult.java new file mode 100644 index 000000000..139e57561 --- /dev/null +++ b/visual-tests/src/main/java/processing/test/visual/TestResult.java @@ -0,0 +1,45 @@ +package processing.test.visual; + +// Enhanced test result with detailed information +public class TestResult { + public String testName; + public boolean passed; + public double mismatchRatio; + public String error; + public boolean isFirstRun; + public ComparisonDetails details; + + public TestResult(String testName, ComparisonResult comparison) { + this.testName = testName; + this.passed = comparison.passed; + this.mismatchRatio = comparison.mismatchRatio; + this.isFirstRun = comparison.isFirstRun; + this.details = comparison.details; + } + + public static TestResult createError(String testName, String error) { + TestResult result = new TestResult(); + result.testName = testName; + result.passed = false; + result.error = error; + return result; + } + + private TestResult() {} // For error constructor + + public void printResult() { + System.out.print(testName + ": "); + if (error != null) { + System.out.println("ERROR - " + error); + } else if (isFirstRun) { + System.out.println("BASELINE CREATED"); + } else if (passed) { + System.out.println("PASSED"); + } else { + System.out.println("FAILED (mismatch: " + String.format("%.4f", mismatchRatio * 100) + "%)"); + if (details != null) { + details.printDetails(); + } + } + } +} diff --git a/visual-tests/src/main/java/processing/test/visual/VisualTestRunner.java b/visual-tests/src/main/java/processing/test/visual/VisualTestRunner.java new file mode 100644 index 000000000..0f0ffe100 --- /dev/null +++ b/visual-tests/src/main/java/processing/test/visual/VisualTestRunner.java @@ -0,0 +1,321 @@ +package processing.test.visual; + +import processing.core.*; +import java.io.*; +import java.nio.file.*; +import java.util.*; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; + +// Core visual tester class +public class VisualTestRunner { + + private String screenshotDir; + private PixelMatchingAlgorithm pixelMatcher; + private String platform; + + public VisualTestRunner(PixelMatchingAlgorithm pixelMatcher) { + this.pixelMatcher = pixelMatcher; + this.screenshotDir = "__screenshots__"; + this.platform = detectPlatform(); + createDirectoryIfNotExists(screenshotDir); + } + + public VisualTestRunner(PixelMatchingAlgorithm pixelMatcher, String screenshotDir) { + this.pixelMatcher = pixelMatcher; + this.screenshotDir = screenshotDir; + this.platform = detectPlatform(); + createDirectoryIfNotExists(screenshotDir); + } + + // Main test execution method + public TestResult runVisualTest(String testName, ProcessingSketch sketch) { + return runVisualTest(testName, sketch, new TestConfig()); + } + + public TestResult runVisualTest(String testName, ProcessingSketch sketch, TestConfig config) { + try { + System.out.println("Running visual test: " + testName); + + // Capture screenshot from sketch + PImage actualImage = captureSketch(sketch, config); + + // Compare with baseline + ComparisonResult comparison = compareWithBaseline(testName, actualImage, config); + + return new TestResult(testName, comparison); + + } catch (Exception e) { + return TestResult.createError(testName, e.getMessage()); + } + } + + // Capture PImage from Processing sketch + private PImage captureSketch(ProcessingSketch sketch, TestConfig config) { + SketchRunner runner = new SketchRunner(sketch, config); + runner.run(); + return runner.getImage(); + } + + // Compare actual image with baseline + private ComparisonResult compareWithBaseline(String testName, PImage actualImage, TestConfig config) { + String baselinePath = getBaselinePath(testName); + + PImage baselineImage = loadBaseline(baselinePath); + + if (baselineImage == null) { + // First run - save as baseline + saveBaseline(testName, actualImage); + return ComparisonResult.createFirstRun(); + } + + // Use your sophisticated pixel matching algorithm + ComparisonResult result = pixelMatcher.compare(baselineImage, actualImage, config.threshold); + + // Save diff images if test failed + if (!result.passed && result.diffImage != null) { + saveDiffImage(testName, result.diffImage); + } + + return result; + } + + // Save diff image for debugging + private void saveDiffImage(String testName, PImage diffImage) { + String sanitizedName = testName.replaceAll("[^a-zA-Z0-9-_]", "-"); + String diffPath; + if (sanitizedName.contains("/")) { + diffPath = "diff_" + sanitizedName.replace("/", "_") + "-" + platform + ".png"; + } else { + diffPath = "diff_" + sanitizedName + "-" + platform + ".png"; + } + + File diffFile = new File(diffPath); + diffFile.getParentFile().mkdirs(); + + diffImage.save(diffPath); + System.out.println("Diff image saved: " + diffPath); + } + + // Utility methods + private String detectPlatform() { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("mac")) return "darwin"; + if (os.contains("win")) return "win32"; + return "linux"; + } + + private void createDirectoryIfNotExists(String dir) { + try { + Files.createDirectories(Paths.get(dir)); + } catch (IOException e) { + System.err.println("Failed to create directory: " + dir); + } + } + + private String getBaselinePath(String testName) { + String sanitizedName = testName.replaceAll("[^a-zA-Z0-9-_/]", "-"); + + return screenshotDir + "/" + sanitizedName + "-" + platform + ".png"; + } + + // Replace loadBaseline method: + private PImage loadBaseline(String path) { + File file = new File(path); + if (!file.exists()) { + System.out.println("loadBaseline: File doesn't exist: " + file.getAbsolutePath()); + return null; + } + + try { + System.out.println("loadBaseline: Loading from " + file.getAbsolutePath()); + + // Use Java ImageIO instead of PApplet + BufferedImage img = ImageIO.read(file); + + if (img == null) { + System.out.println("loadBaseline: ImageIO returned null"); + return null; + } + + // Convert BufferedImage to PImage + PImage pImg = new PImage(img.getWidth(), img.getHeight(), PImage.RGB); + img.getRGB(0, 0, pImg.width, pImg.height, pImg.pixels, 0, pImg.width); + pImg.updatePixels(); + + System.out.println("loadBaseline: ✓ Loaded " + pImg.width + "x" + pImg.height); + return pImg; + + } catch (Exception e) { + System.err.println("loadBaseline: Error loading image: " + e.getMessage()); + e.printStackTrace(); + return null; + } + } + + // Replace saveBaseline method: + private void saveBaseline(String testName, PImage image) { + String path = getBaselinePath(testName); + + if (image == null) { + System.out.println("saveBaseline: ✗ Image is null!"); + return; + } + + try { + // Convert PImage to BufferedImage + BufferedImage bImg = new BufferedImage(image.width, image.height, BufferedImage.TYPE_INT_RGB); + image.loadPixels(); + bImg.setRGB(0, 0, image.width, image.height, image.pixels, 0, image.width); + + // Create File object and ensure parent directories exist + File outputFile = new File(path); + outputFile.getParentFile().mkdirs(); // This creates nested directories + + // Use Java ImageIO to save + ImageIO.write(bImg, "PNG", outputFile); + + System.out.println("Baseline saved: " + path); + + } catch (Exception e) { + System.err.println("Failed to save baseline: " + path); + e.printStackTrace(); + } + } +} +class SketchRunner extends PApplet { + + private ProcessingSketch userSketch; + private TestConfig config; + private PImage capturedImage; + private volatile boolean rendered = false; + + public SketchRunner(ProcessingSketch userSketch, TestConfig config) { + this.userSketch = userSketch; + this.config = config; + } + + public void settings() { + size(config.width, config.height); + } + + public void setup() { + noLoop(); + + // Set background if specified + if (config.backgroundColor != null) { + background(config.backgroundColor[0], config.backgroundColor[1], config.backgroundColor[2]); + } + + // Call user setup + userSketch.setup(this); + } + + public void draw() { + if (!rendered) { + userSketch.draw(this); + capturedImage = get(); + rendered = true; + noLoop(); + } + } + + public void run() { + String[] args = {"SketchRunner"}; + PApplet.runSketch(args, this); + + // Simple polling with timeout + int maxWait = 100; // 10 seconds max + int waited = 0; + + while (!rendered && waited < maxWait) { + try { + Thread.sleep(100); + waited++; + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + // Additional wait time + try { + Thread.sleep(config.renderWaitTime); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + if (surface != null) { + surface.setVisible(false); + } + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public PImage getImage() { + return capturedImage; + } +} + +// Test suite for organizing multiple tests +class ProcessingTestSuite { + private VisualTestRunner tester; + private List tests; + + public ProcessingTestSuite(VisualTestRunner tester) { + this.tester = tester; + this.tests = new ArrayList<>(); + } + + public void addTest(String name, ProcessingSketch sketch) { + addTest(name, sketch, new TestConfig()); + } + + public void addTest(String name, ProcessingSketch sketch, TestConfig config) { + tests.add(new VisualTest(name, sketch, config)); + } + + public List runAll() { + System.out.println("Running " + tests.size() + " visual tests..."); + List results = new ArrayList<>(); + + for (VisualTest test : tests) { + TestResult result = tester.runVisualTest(test.name, test.sketch, test.config); + result.printResult(); + results.add(result); + } + + return results; + } + + public TestResult runTest(String testName) { + VisualTest test = tests.stream() + .filter(t -> t.name.equals(testName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Test not found: " + testName)); + + return tester.runVisualTest(test.name, test.sketch, test.config); + } + + public List getTestNames() { + return tests.stream().map(t -> t.name).collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + } + + // Helper class for internal test storage + private static class VisualTest { + String name; + ProcessingSketch sketch; + TestConfig config; + + VisualTest(String name, ProcessingSketch sketch, TestConfig config) { + this.name = name; + this.sketch = sketch; + this.config = config; + } + } +} diff --git a/visual-tests/src/test/java/visual/base/VisualTest.java b/visual-tests/src/test/java/visual/base/VisualTest.java new file mode 100644 index 000000000..e574a5b6f --- /dev/null +++ b/visual-tests/src/test/java/visual/base/VisualTest.java @@ -0,0 +1,61 @@ +package visual.base; + +import org.junit.jupiter.api.*; +import processing.core.*; +import static org.junit.jupiter.api.Assertions.*; +import processing.test.visual.*; +import java.nio.file.*; +import java.io.File; + +/** + * Base class for Processing visual tests using JUnit 5 + */ +public abstract class VisualTest { + + protected static VisualTestRunner testRunner; + protected static ImageComparator comparator; + + @BeforeAll + public static void setupTestRunner() { + PApplet tempApplet = new PApplet(); + comparator = new ImageComparator(tempApplet); + testRunner = new VisualTestRunner(comparator); + + System.out.println("Visual test runner initialized"); + } + + /** + * Helper method to run a visual test + */ + protected void assertVisualMatch(String testName, ProcessingSketch sketch) { + assertVisualMatch(testName, sketch, new TestConfig()); + } + + protected void assertVisualMatch(String testName, ProcessingSketch sketch, TestConfig config) { + TestResult result = testRunner.runVisualTest(testName, sketch, config); + + // Print result for debugging + result.printResult(); + + // Handle different result types + if (result.isFirstRun) { + // First run - baseline created, mark as skipped + Assumptions.assumeTrue(false, "Baseline created for " + testName + ". Run tests again to verify."); + } else if (result.error != null) { + fail("Test error: " + result.error); + } else { + // Assert that the test passed + Assertions.assertTrue(result.passed, + String.format("Visual test '%s' failed with mismatch ratio: %.4f%%", + testName, result.mismatchRatio * 100)); + } + } + + /** + * Update baseline for a specific test (useful for maintenance) + */ + protected void updateBaseline(String testName, ProcessingSketch sketch, TestConfig config) { + BaselineManager manager = new BaselineManager(testRunner); + manager.updateBaseline(testName, sketch, config); + } +} \ No newline at end of file diff --git a/visual-tests/src/test/java/visual/rendering/GradientTest.java b/visual-tests/src/test/java/visual/rendering/GradientTest.java new file mode 100644 index 000000000..479d0872b --- /dev/null +++ b/visual-tests/src/test/java/visual/rendering/GradientTest.java @@ -0,0 +1,35 @@ +package visual.rendering; + +import org.junit.jupiter.api.*; +import processing.core.*; +import visual.base.VisualTest; +import processing.test.visual.ProcessingSketch; +import processing.test.visual.TestConfig; + +@Tag("rendering") +@Tag("gradients") +public class GradientTest extends VisualTest { + + @Test + @DisplayName("Linear gradient renders correctly") + public void testLinearGradient() { + TestConfig config = new TestConfig(600, 400); + + assertVisualMatch("rendering/linear-gradient", new ProcessingSketch() { + @Override + public void setup(PApplet p) { + p.noStroke(); + } + + @Override + public void draw(PApplet p) { + for (int y = 0; y < p.height; y++) { + float inter = PApplet.map(y, 0, p.height, 0, 1); + int c = p.lerpColor(p.color(255, 0, 0), p.color(0, 0, 255), inter); + p.stroke(c); + p.line(0, y, p.width, y); + } + } + }, config); + } +} \ No newline at end of file diff --git a/visual-tests/src/test/java/visual/shapes/BasicShapeTest.java b/visual-tests/src/test/java/visual/shapes/BasicShapeTest.java new file mode 100644 index 000000000..6756b2557 --- /dev/null +++ b/visual-tests/src/test/java/visual/shapes/BasicShapeTest.java @@ -0,0 +1,92 @@ +package visual.shapes; + + +import org.junit.jupiter.api.*; +import processing.core.*; +import visual.base.*; +import processing.test.visual.*; + +@Tag("basic") +@Tag("shapes") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +public class BasicShapeTest extends VisualTest { + + @Test + @Order(1) + @DisplayName("Red circle renders correctly") + public void testRedCircle() { + assertVisualMatch("basic-shapes/red-circle", new ProcessingSketch() { + @Override + public void setup(PApplet p) { + p.noStroke(); + } + + @Override + public void draw(PApplet p) { + p.background(255, 255, 255); + p.fill(255, 0, 0); + p.ellipse(p.width/2, p.height/2, 100, 100); + } + }); + } + + @Test + @Order(2) + @DisplayName("Blue square renders correctly") + public void testBlueSquare() { + assertVisualMatch("basic-shapes/blue-square", new ProcessingSketch() { + @Override + public void setup(PApplet p) { + p.stroke(0); + p.strokeWeight(2); + } + + @Override + public void draw(PApplet p) { + p.background(255); + p.fill(0, 0, 255); + p.rect(p.width/2 - 50, p.height/2 - 50, 100, 100); + } + }); + } + + @Test + @Order(3) + @DisplayName("Green circle renders correctly") + public void testGreenCircle() { + assertVisualMatch("basic-shapes/green-circle", new ProcessingSketch() { + @Override + public void setup(PApplet p) { + p.noStroke(); + } + + @Override + public void draw(PApplet p) { + p.background(255, 255, 255); + p.fill(0, 255, 0); + p.ellipse(p.width/2, p.height/2, 100, 100); + } + }); + } + + @Test + @Order(4) + @DisplayName("Custom size canvas") + public void testCustomSize() { + TestConfig config = new TestConfig(600, 400); + + assertVisualMatch("basic-shapes/custom-size-rect", new ProcessingSketch() { + @Override + public void setup(PApplet p) { + p.noStroke(); + } + + @Override + public void draw(PApplet p) { + p.background(240, 240, 240); + p.fill(128, 0, 128); + p.rect(50, 50, p.width - 100, p.height - 100); + } + }, config); + } +} \ No newline at end of file diff --git a/visual-tests/src/test/java/visual/suites/BasicShapesSuite.java b/visual-tests/src/test/java/visual/suites/BasicShapesSuite.java new file mode 100644 index 000000000..7145e0415 --- /dev/null +++ b/visual-tests/src/test/java/visual/suites/BasicShapesSuite.java @@ -0,0 +1,11 @@ +package visual.suites; + +import org.junit.platform.suite.api.*; + +@Suite +@SuiteDisplayName("Basic Shapes Visual Tests") +@SelectPackages("visual.shapes") +@IncludeTags("basic") +public class BasicShapesSuite { + // Empty class - just holds annotations +} \ No newline at end of file diff --git a/visual-tests/src/test/java/visual/suites/RenderingSuite.java b/visual-tests/src/test/java/visual/suites/RenderingSuite.java new file mode 100644 index 000000000..6637cd06d --- /dev/null +++ b/visual-tests/src/test/java/visual/suites/RenderingSuite.java @@ -0,0 +1,11 @@ +package visual.suites; + +import org.junit.platform.suite.api.*; + +@Suite +@SuiteDisplayName("Rendering Tests") +@SelectPackages("processing.test.visual.rendering") +@IncludeTags("rendering") +public class RenderingSuite { + // Empty class - just holds annotations +}