/******************************************************************************* * Copyright 2017 Ivan Shubin http://galenframework.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. ******************************************************************************/ package com.galenframework.validation.specs; import java.awt.Rectangle; import java.awt.image.BufferedImage; import java.io.InputStream; import java.text.DecimalFormat; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import com.galenframework.page.Rect; import com.galenframework.specs.SpecImage; import com.galenframework.validation.*; import com.galenframework.config.GalenConfig; import com.galenframework.page.PageElement; import com.galenframework.utils.GalenUtils; import com.galenframework.rainbow4j.ComparisonOptions; import com.galenframework.rainbow4j.ImageCompareResult; import com.galenframework.rainbow4j.Rainbow4J; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import static java.util.Arrays.asList; public class SpecValidationImage extends SpecValidation<SpecImage> { private final static Logger LOG = LoggerFactory.getLogger(SpecValidationImage.class); private static final String NO_ERROR_MESSAGE = null; private static final ImageCompareResult NO_RESULT = null; private static class ImageCheck { private final String imagePath; private final double difference; private final ImageCompareResult result; private final String errorMessage; public ImageCheck(String imagePath, double difference, ImageCompareResult result, String errorMessage) { this.imagePath = imagePath; this.difference = difference; this.result = result; this.errorMessage = errorMessage; } } @Override public ValidationResult check(PageValidation pageValidation, String objectName, SpecImage spec) throws ValidationErrorException { PageElement pageElement = pageValidation.findPageElement(objectName); checkAvailability(pageElement, objectName); final BufferedImage pageImage = pageValidation.getPage().getScreenshotImage(); int tolerance = GalenConfig.getConfig().getImageSpecDefaultTolerance(); if (spec.getTolerance() != null && spec.getTolerance() >= 0) { tolerance = spec.getTolerance(); } ComparisonOptions options = new ComparisonOptions(); options.setIgnoreRegions(convertIgnoreObjectsToRegions(pageValidation, spec)); options.setStretchToFit(spec.isStretch()); options.setOriginalFilters(spec.getOriginalFilters()); options.setSampleFilters(spec.getSampleFilters()); options.setMapFilters(spec.getMapFilters()); options.setTolerance(tolerance); options.setAnalyzeOffset(spec.getAnalyzeOffset()); Rect elementArea = pageElement.getArea(); List<String> realPaths = new LinkedList<>(); for (String imagePossiblePath : spec.getImagePaths()) { if (imagePossiblePath.contains("*") || imagePossiblePath.contains("#")) { realPaths.addAll(GalenUtils.findFilesOrResourcesMatchingSearchExpression(imagePossiblePath)); } else { realPaths.add(imagePossiblePath); } } if (realPaths.isEmpty()) { throw new ValidationErrorException("There are no images found").withValidationObject(new ValidationObject(pageElement.getArea(), objectName)); } int largestPossibleDifference = elementArea.getHeight() * elementArea.getWidth() * 2; ImageCheck minCheck = new ImageCheck(realPaths.get(0), largestPossibleDifference, NO_RESULT, NO_ERROR_MESSAGE); Iterator<String> it = realPaths.iterator(); try { while (minCheck.difference > 0 && it.hasNext()) { String imagePath = it.next(); ImageCheck imageCheck = checkImages(spec, pageImage, options, elementArea, imagePath); if (imageCheck.difference <= minCheck.difference) { minCheck = imageCheck; } } } catch (ValidationErrorException ex) { LOG.trace("Validation errors during image compare.", ex); ex.withValidationObject(new ValidationObject(pageElement.getArea(), objectName)); throw ex; } catch (Exception ex) { LOG.trace("Unknown errors during image compare", ex); throw new ValidationErrorException(ex).withValidationObject(new ValidationObject(pageElement.getArea(), objectName)); } List<ValidationObject> objects = asList(new ValidationObject(pageElement.getArea(), objectName)); if (minCheck.difference > 0) { throw new ValidationErrorException(minCheck.errorMessage) .withValidationObjects(objects) .withImageComparison(new ImageComparison( minCheck.result.getOriginalFilteredImage(), minCheck.result.getSampleFilteredImage(), minCheck.result.getComparisonMap())); } return new ValidationResult(spec, objects); } private List<Rectangle> convertIgnoreObjectsToRegions(PageValidation pageValidation, SpecImage spec) { List<Rectangle> ignoreRegions = new LinkedList<>(); if (spec.getIgnoredObjectExpressions() != null) { for (String objectSearchExpression : spec.getIgnoredObjectExpressions()) { List<String> ignoreObjects = pageValidation.getPageSpec().findAllObjectsMatchingStrictStatements(objectSearchExpression); if (ignoreObjects != null) { for (String objectName: ignoreObjects) { PageElement pageElement = pageValidation.findPageElement(objectName); if (pageElement.isPresent() && pageElement.isVisible()) { ignoreRegions.add(pageElement.getArea().toAwtRectangle()); } } } } } return ignoreRegions; } private ImageCheck checkImages(SpecImage spec, BufferedImage pageImage, ComparisonOptions options, Rect elementArea, String imagePath) throws ValidationErrorException { BufferedImage sampleImage; try { InputStream stream = GalenUtils.findFileOrResourceAsStream(imagePath); sampleImage = Rainbow4J.loadImage(stream); } catch (Exception ex) { LOG.error("Unknown errors during image check.", ex); throw new ValidationErrorException("Couldn't load image: " + spec.getImagePaths().get(0)); } Rectangle sampleArea = spec.getSelectedArea() != null ? toRectangle(spec.getSelectedArea()) : new Rectangle(0, 0, sampleImage.getWidth(), sampleImage.getHeight()); if (elementArea.getLeft() >= pageImage.getWidth() || elementArea.getTop() >= pageImage.getHeight()) { throw new RuntimeException(String.format( "The page element is located outside of the screenshot. (Element {x: %d, y: %d, w: %d, h: %d}, Screenshot {w: %d, h: %d})", elementArea.getLeft(), elementArea.getTop(), elementArea.getWidth(), elementArea.getHeight(), pageImage.getWidth(), pageImage.getHeight())); } if (spec.isCropIfOutside() || isOnlyOnePixelOutsideScreenshot(elementArea, pageImage)) { elementArea = cropElementAreaIfOutside(elementArea, pageImage.getWidth(), pageImage.getHeight()); } ImageCompareResult result = Rainbow4J.compare(pageImage, sampleImage, toRectangle(elementArea), sampleArea, options); double difference = 0.0; String errorMessage = null; SpecImage.ErrorRate errorRate = spec.getErrorRate(); if (errorRate == null) { errorRate = GalenConfig.getConfig().getImageSpecDefaultErrorRate(); } if (errorRate.getType() == SpecImage.ErrorRateType.PERCENT) { difference = result.getPercentage() - errorRate.getValue(); if (difference > 0) { errorMessage = createErrorMessageForPercentage(msgErrorPrefix(spec.getImagePaths().get(0)), errorRate.getValue(), result.getPercentage()); } } else { difference = result.getTotalPixels() - errorRate.getValue(); if (difference > 0) { errorMessage = createErrorMessageForPixels(msgErrorPrefix(spec.getImagePaths().get(0)), errorRate.getValue().intValue(), result.getTotalPixels()); } } return new ImageCheck(imagePath, difference, result, errorMessage); } private boolean isOnlyOnePixelOutsideScreenshot(Rect elementArea, BufferedImage pageImage) { int dx = elementArea.getLeft() + elementArea.getWidth() - pageImage.getWidth(); int dy = elementArea.getTop() + elementArea.getHeight() - pageImage.getHeight(); return Math.max(dx, dy) == 1; } private Rect cropElementAreaIfOutside(Rect elementArea, int width, int height) { int x2 = elementArea.getLeft() + elementArea.getWidth(); int y2 = elementArea.getTop() + elementArea.getHeight(); int originalWidth = elementArea.getWidth(); int originalHeight = elementArea.getHeight(); if (originalWidth > 0 && originalHeight > 0) { int newWidth = originalWidth; int newHeight = originalHeight; if (x2 >= width) { newWidth -= x2 - width + 1; } if (y2 >= height) { newHeight -= y2 - height + 1; } if ((double) (newWidth * newHeight) / (double) (originalWidth * originalHeight) < 0.5) { throw new RuntimeException(String.format( "The cropped area is less than a half of element area (Element {x: %d, y: %d, w: %d, h: %d}, Screenshot {w: %d, h: %d})", elementArea.getLeft(), elementArea.getTop(), newWidth, newHeight, width, height)); } return new Rect(elementArea.getLeft(), elementArea.getTop(), newWidth, newHeight); } return elementArea; } private String msgErrorPrefix(String imagePath) { return String.format("Element does not look like \"%s\". ", imagePath); } private String createErrorMessageForPixels(String msgPrefix, Integer maxPixels, long totalPixels) throws ValidationErrorException { return String.format("%sThere are %d mismatching pixels but max allowed is %d", msgPrefix, totalPixels, maxPixels); } private String createErrorMessageForPercentage(String msgPrefix, Double maxPercentage, double percentage) throws ValidationErrorException { return String.format("%sThere are %s%% mismatching pixels but max allowed is %s%%", msgPrefix, formatDouble(percentage), formatDouble(maxPercentage)); } private static final DecimalFormat _doubleFormat = new DecimalFormat("#.##"); private String formatDouble(Double value) { return _doubleFormat.format(value); } private Rectangle toRectangle(Rect area) { return new Rectangle(area.getLeft(), area.getTop(), area.getWidth(), area.getHeight()); } }