lib/capybara/screenshot/diff/image_compare.rb in capybara-screenshot-diff-1.7.1 vs lib/capybara/screenshot/diff/image_compare.rb in capybara-screenshot-diff-1.8.0

- old
+ new

@@ -3,255 +3,226 @@ module Capybara module Screenshot module Diff LOADED_DRIVERS = {} - # Compare two images and determine if they are equal, different, or within some comparison + # Compare two image and determine if they are equal, different, or within some comparison # range considering color values and difference area size. - class ImageCompare < SimpleDelegator - TMP_FILE_SUFFIX = "~" + class ImageCompare + TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze attr_reader :driver, :driver_options - attr_reader :annotated_new_file_name, :annotated_old_file_name, :new_file_name, :old_file_name, :skip_area - attr_accessor :shift_distance_limit, :area_size_limit, :color_distance_limit + attr_reader :annotated_image_path, :annotated_base_image_path, + :image_path, :base_image_path, + :new_file_name, :old_file_name - def initialize(new_file_name, old_file_name = nil, options = {}) - options = old_file_name if old_file_name.is_a?(Hash) + def initialize(image_path, base_image_path, options = {}) + @image_path = Pathname.new(image_path) - @new_file_name = new_file_name - @old_file_name = old_file_name || "#{new_file_name}#{ImageCompare::TMP_FILE_SUFFIX}" - @annotated_old_file_name = "#{new_file_name.chomp(".png")}.committed.png" - @annotated_new_file_name = "#{new_file_name.chomp(".png")}.latest.png" + @new_file_name = @image_path.to_s + @annotated_image_path = @image_path.sub_ext(".diff.png") - @driver_options = options + @base_image_path = Pathname.new(base_image_path) - @color_distance_limit = options[:color_distance_limit] || 0 - @area_size_limit = options[:area_size_limit] - @shift_distance_limit = options[:shift_distance_limit] - @dimensions = options[:dimensions] - @skip_area = options[:skip_area] - @tolerance = options[:tolerance] - @median_filter_window_size = options[:median_filter_window_size] + @old_file_name = @base_image_path.to_s + @annotated_base_image_path = @base_image_path.sub_ext(".diff.png") - driver_klass = find_driver_class_for(@driver_options.fetch(:driver, :chunky_png)) - @driver = driver_klass.new(@new_file_name, @old_file_name, **@driver_options) + @driver_options = options.dup - super(@driver) + @driver = Drivers.for(@driver_options) end - def skip_area=(new_skip_area) - @skip_area = new_skip_area - driver.skip_area = @skip_area - end - # Compare the two image files and return `true` or `false` as quickly as possible. # Return falsely if the old file does not exist or the image dimensions do not match. def quick_equal? - return false unless old_file_exists? + @error_message = nil + return false unless image_files_exist? + # TODO: Confirm this change. There are screenshots with the same size, but there is a big difference return true if new_file_size == old_file_size - images = driver.load_images(@old_file_name, @new_file_name) - old_image, new_image = preprocess_images(images, driver) + comparison = load_and_process_images - return false if driver.dimension_changed?(old_image, new_image) + unless driver.same_dimension?(comparison) + @error_message = build_error_for_different_dimensions(comparison) + return false + end - self.difference_region, meta = driver.find_difference_region( - new_image, - old_image, - @color_distance_limit, - @shift_distance_limit, - @area_size_limit, - fast_fail: true - ) + return true if driver.same_pixels?(comparison) - return true if difference_region_area_size.zero? || difference_region_empty?(new_image, difference_region) - return true if @area_size_limit && difference_region_area_size <= @area_size_limit - return true if @tolerance && @tolerance >= driver.difference_level(meta, old_image, difference_region) - # TODO: Remove this or find similar solution for vips - return true if @shift_distance_limit && driver.shift_distance_equal? + # Could not make any difference to be tolerable, so skip and return as not equal + return false if without_tolerable_options? + @difference = driver.find_difference_region(comparison) + return true unless @difference.different? + + @error_message = @difference.inspect false end - # Compare the two images referenced by this object, and return `true` if they are different, + # Compare the two image referenced by this object, and return `true` if they are different, # and `false` if they are the same. def different? - return false unless old_file_exists? + @error_message = nil - images = driver.load_images(@old_file_name, @new_file_name) - old_image, new_image = preprocess_images(images, driver) + @error_message = _different? - if driver.dimension_changed?(old_image, new_image) - self.difference_region = Region.from_edge_coordinates( - 0, - 0, - [driver.width_for(old_image), driver.width_for(new_image)].min, - [driver.height_for(old_image), driver.height_for(new_image)].min - ) + clean_tmp_files unless @error_message - return different(*images) - end + !@error_message.nil? + end - self.difference_region, meta = driver.find_difference_region( - new_image, - old_image, - @color_distance_limit, - @shift_distance_limit, - @area_size_limit - ) + def build_error_for_different_dimensions(comparison) + change_msg = [comparison.base_image, comparison.new_image] + .map { |i| driver.dimension(i).join("x") } + .join(" => ") - return not_different if difference_region_area_size.zero? || difference_region_empty?(old_image, difference_region) - return not_different if @area_size_limit && difference_region_area_size <= @area_size_limit - return not_different if @tolerance && @tolerance > driver.difference_level(meta, old_image, difference_region) - # TODO: Remove this or find similar solution for vips - return not_different if @shift_distance_limit && !driver.shift_distance_different? - - different(*images) + "Screenshot dimension has been changed for #{@new_file_name}: #{change_msg}" end def clean_tmp_files - FileUtils.cp @old_file_name, @new_file_name if old_file_exists? - File.delete(@old_file_name) if old_file_exists? - File.delete(@annotated_old_file_name) if File.exist?(@annotated_old_file_name) - File.delete(@annotated_new_file_name) if File.exist?(@annotated_new_file_name) + @annotated_base_image_path.unlink if @annotated_base_image_path.exist? + @annotated_image_path.unlink if @annotated_image_path.exist? end - def save(old_img, new_img, annotated_old_file_name, annotated_new_file_name) - driver.save_image_to(old_img, annotated_old_file_name) - driver.save_image_to(new_img, annotated_new_file_name) + def save(image, image_path) + driver.save_image_to(image, image_path.to_s) end - def old_file_exists? - @old_file_name && File.exist?(@old_file_name) + def image_files_exist? + @base_image_path.exist? && @image_path.exist? end - def reset - self.difference_region = nil - driver.reset + NEW_LINE = "\n" + + attr_reader :error_message + + private + + def without_tolerable_options? + (@driver_options.keys & TOLERABLE_OPTIONS).empty? end - NEW_LINE = "\n" + def _different? + raise "There is no original (base) screenshot version to compare, located: #{@base_image_path}" unless @base_image_path.exist? + raise "There is no new screenshot version to compare, located: #{@image_path}" unless @image_path.exist? - def error_message - result = { - area_size: difference_region_area_size, - region: difference_coordinates - } + comparison = load_and_process_images - driver.adds_error_details_to(result) + unless driver.same_dimension?(comparison) + return build_error_for_different_dimensions(comparison) + end + return not_different if driver.same_pixels?(comparison) + + @difference = driver.find_difference_region(comparison) + return not_different unless @difference.different? + + different(@difference) + end + + def load_and_process_images + images = driver.load_images(old_file_name, new_file_name) + base_image, new_image = preprocess_images(images) + Comparison.new(new_image, base_image, @driver_options) + end + + def build_error_message(difference) [ - "(#{result.to_json})", + "(#{difference.inspect})", new_file_name, - annotated_old_file_name, - annotated_new_file_name + annotated_base_image_path.to_path, + annotated_image_path.to_path ].join(NEW_LINE) end - def difference_coordinates - difference_region&.to_edge_coordinates + def skip_area + @driver_options[:skip_area] end - def difference_region_area_size - return 0 unless difference_region - - difference_region.size + def median_filter_window_size + @driver_options[:median_filter_window_size] end - private - - attr_accessor :difference_region - - def different(old_image, new_image) - annotate_and_save([old_image, new_image], difference_region) - true + def dimensions + @driver_options[:dimensions] end - def find_driver_class_for(driver) - driver = AVAILABLE_DRIVERS.first if driver == :auto - - LOADED_DRIVERS[driver] ||= - case driver - when :chunky_png - require "capybara/screenshot/diff/drivers/chunky_png_driver" - Drivers::ChunkyPNGDriver - when :vips - require "capybara/screenshot/diff/drivers/vips_driver" - Drivers::VipsDriver - else - fail "Wrong adapter #{driver.inspect}. Available adapters: #{AVAILABLE_DRIVERS.inspect}" - end + def different(difference) + annotate_and_save_images(difference) + build_error_message(difference) end - def preprocess_images(images, driver = self) - old_img = preprocess_image(images.first, driver) - new_img = preprocess_image(images.last, driver) - - [old_img, new_img] + def preprocess_images(images) + images.map { |image| preprocess_image(image) } end - def preprocess_image(image, driver = self) + def preprocess_image(image) result = image - if @dimensions && driver.inscribed?(@dimensions, result) - result = driver.crop(@dimensions, result) + # FIXME: How can we access to this method from public interface? Is this not documented feature? + if dimensions && driver.inscribed?(dimensions, result) + result = driver.crop(dimensions, result) end - if @median_filter_window_size - result = driver.filter_image_with_median(image, @median_filter_window_size) + if skip_area + result = ignore_skipped_area(result) end - if @skip_area - result = @skip_area.reduce(result) { |image, region| driver.add_black_box(image, region) } + if median_filter_window_size + result = blur_image_by(image, median_filter_window_size) end result end + def blur_image_by(image, size) + driver.filter_image_with_median(image, size) + end + + def ignore_skipped_area(image) + skip_area.reduce(image) { |memo, region| driver.add_black_box(memo, region) } + end + def old_file_size - @old_file_size ||= old_file_exists? && File.size(@old_file_name) + @old_file_size ||= image_files_exist? && File.size(@old_file_name) end def new_file_size File.size(@new_file_name) end def not_different - clean_tmp_files - false + nil end - def difference_region_empty?(new_image, region) - region.nil? || - ( - region.height == height_for(new_image) && - region.width == width_for(new_image) && - region.x.zero? && - region.y.zero? - ) + def annotate_and_save_images(difference) + annotate_and_save_image(difference, difference.comparison.new_image, @annotated_image_path) + annotate_and_save_image(difference, difference.comparison.base_image, @annotated_base_image_path) end - def annotate_and_save(images, region) - annotated_images = annotate_difference(images, region) - annotated_images = annotate_skip_areas(annotated_images, @skip_area) if @skip_area - - save(*annotated_images, @annotated_old_file_name, @annotated_new_file_name) + def annotate_and_save_image(difference, image, image_path) + image = annotate_difference(image, difference.region) + image = annotate_skip_areas(image, difference.skip_area) if difference.skip_area + save(image, image_path.to_path) end DIFF_COLOR = [255, 0, 0, 255].freeze - def annotate_difference(images, region) - driver.draw_rectangles(images, region, DIFF_COLOR) + def annotate_difference(image, region) + driver.draw_rectangles(Array[image], region, DIFF_COLOR, offset: 1).first end SKIP_COLOR = [255, 192, 0, 255].freeze - def annotate_skip_areas(annotated_images, skip_areas) - skip_areas.reduce(annotated_images) do |annotated_images, region| - driver.draw_rectangles(annotated_images, region, SKIP_COLOR) + def annotate_skip_areas(image, skip_areas) + skip_areas.reduce(image) do |memo, region| + driver.draw_rectangles(Array[memo], region, SKIP_COLOR).first end end + end + + class Comparison < Struct.new(:new_image, :base_image, :options) end end end end