snap_diff-capybara 0.0.1 → 2.0.0.alpha1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -0
- data/CODE_OF_CONDUCT.md +129 -0
- data/LICENSE.txt +21 -0
- data/Rakefile +65 -0
- data/capybara-screenshot-diff.gemspec +29 -0
- data/docs/RELEASE_PREP.md +44 -0
- data/docs/UPGRADING.md +475 -0
- data/docs/architecture.md +267 -0
- data/docs/ci-integration.md +238 -0
- data/docs/configuration.md +395 -0
- data/docs/docker-testing.md +24 -0
- data/docs/drivers.md +102 -0
- data/docs/framework-setup.md +87 -0
- data/docs/images/snap_diff_annotated.png +0 -0
- data/docs/images/snap_diff_web_ui.png +0 -0
- data/docs/migration-guide.md +286 -0
- data/docs/organization.md +204 -0
- data/docs/reporters.md +46 -0
- data/docs/thread_safety.md +112 -0
- data/gems.rb +39 -0
- data/lib/capybara/screenshot/diff/annotation_service.rb +7 -0
- data/lib/capybara/screenshot/diff/area_calculator.rb +7 -0
- data/lib/capybara/screenshot/diff/browser_helpers.rb +7 -0
- data/lib/capybara/screenshot/diff/config_legacy.rb +113 -0
- data/lib/capybara/screenshot/diff/cucumber.rb +3 -0
- data/lib/capybara/screenshot/diff/difference.rb +8 -0
- data/lib/capybara/screenshot/diff/drivers/base_driver.rb +9 -0
- data/lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb +7 -0
- data/lib/capybara/screenshot/diff/drivers/vips_driver.rb +7 -0
- data/lib/capybara/screenshot/diff/drivers.rb +9 -0
- data/lib/capybara/screenshot/diff/image_compare.rb +12 -0
- data/lib/capybara/screenshot/diff/image_preprocessor.rb +7 -0
- data/lib/capybara/screenshot/diff/os.rb +14 -0
- data/lib/capybara/screenshot/diff/region.rb +107 -0
- data/lib/capybara/screenshot/diff/reporters/default.rb +109 -0
- data/lib/capybara/screenshot/diff/screenshot_matcher.rb +7 -0
- data/lib/capybara/screenshot/diff/screenshoter.rb +7 -0
- data/lib/capybara/screenshot/diff/stable_screenshoter.rb +7 -0
- data/lib/capybara/screenshot/diff/utils.rb +7 -0
- data/lib/capybara/screenshot/diff/vcs.rb +7 -0
- data/lib/capybara/screenshot/diff/version.rb +15 -0
- data/lib/capybara/screenshot/diff.rb +3 -0
- data/lib/capybara-screenshot-diff.rb +3 -0
- data/lib/capybara_screenshot_diff/attempts_reporter.rb +7 -0
- data/lib/capybara_screenshot_diff/cucumber.rb +8 -0
- data/lib/capybara_screenshot_diff/dsl.rb +12 -0
- data/lib/capybara_screenshot_diff/error_with_filtered_backtrace.rb +8 -0
- data/lib/capybara_screenshot_diff/minitest.rb +14 -0
- data/lib/capybara_screenshot_diff/reporters/html.rb +7 -0
- data/lib/capybara_screenshot_diff/rspec.rb +8 -0
- data/lib/capybara_screenshot_diff/screenshot_assertion.rb +71 -0
- data/lib/capybara_screenshot_diff/screenshot_namer.rb +7 -0
- data/lib/capybara_screenshot_diff/snap.rb +7 -0
- data/lib/capybara_screenshot_diff/snap_manager.rb +7 -0
- data/lib/capybara_screenshot_diff/static.rb +9 -0
- data/lib/capybara_screenshot_diff.rb +50 -0
- data/lib/snap_diff/annotation_service.rb +84 -0
- data/lib/snap_diff/area_calculator.rb +56 -0
- data/lib/snap_diff/attempts_reporter.rb +51 -0
- data/lib/snap_diff/browser_helpers.rb +121 -0
- data/lib/snap_diff/capture/viewport.rb +40 -0
- data/lib/snap_diff/comparison.rb +238 -0
- data/lib/snap_diff/comparison_result.rb +104 -0
- data/lib/snap_diff/config.rb +78 -0
- data/lib/snap_diff/deprecation.rb +84 -0
- data/lib/snap_diff/driver.rb +37 -0
- data/lib/snap_diff/drivers/chunky_png_driver.rb +298 -0
- data/lib/snap_diff/drivers/vips_driver.rb +171 -0
- data/lib/snap_diff/drivers.rb +14 -0
- data/lib/snap_diff/dsl.rb +143 -0
- data/lib/snap_diff/error_with_filtered_backtrace.rb +32 -0
- data/lib/snap_diff/image_preprocessor.rb +68 -0
- data/lib/snap_diff/integrations/cucumber.rb +22 -0
- data/lib/snap_diff/integrations/minitest.rb +70 -0
- data/lib/snap_diff/integrations/rspec.rb +68 -0
- data/lib/snap_diff/legacy_shims.rb +99 -0
- data/lib/snap_diff/os.rb +17 -0
- data/lib/snap_diff/reporters/html.rb +143 -0
- data/lib/snap_diff/reporters/templates/report.html.erb +463 -0
- data/lib/snap_diff/reporting.rb +53 -0
- data/lib/snap_diff/screenshot_assertion.rb +143 -0
- data/lib/snap_diff/screenshot_matcher.rb +113 -0
- data/lib/snap_diff/screenshot_namer.rb +81 -0
- data/lib/snap_diff/screenshoter.rb +129 -0
- data/lib/snap_diff/snap.rb +66 -0
- data/lib/snap_diff/snap_manager.rb +125 -0
- data/lib/snap_diff/stable_screenshoter.rb +103 -0
- data/lib/snap_diff/static.rb +11 -0
- data/lib/snap_diff/utils.rb +38 -0
- data/lib/snap_diff/vcs.rb +35 -0
- data/lib/snap_diff/version.rb +5 -0
- data/lib/snap_diff.rb +65 -0
- metadata +160 -10
- data/README.md +0 -3
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module SnapDiff
|
|
6
|
+
# Represents the result of comparing two images
|
|
7
|
+
#
|
|
8
|
+
# This value object encapsulates the result of an image comparison operation.
|
|
9
|
+
# It follows the Single Responsibility Principle by focusing solely on representing
|
|
10
|
+
# the difference state, including:
|
|
11
|
+
# - Whether images are different or equal
|
|
12
|
+
# - Why they differ (dimensions, pixels, etc.)
|
|
13
|
+
# - The specific region of difference
|
|
14
|
+
# - Whether differences are tolerable based on configured thresholds
|
|
15
|
+
#
|
|
16
|
+
# As part of the layered comparison architecture, this class represents the final
|
|
17
|
+
# output of the comparison process, containing all data needed for reporting.
|
|
18
|
+
class ComparisonResult < Struct.new(:region, :meta, :comparison, :failed_by, :base_image_path, :image_path, keyword_init: nil)
|
|
19
|
+
def self.build_null(comparison, base_image_path, new_image_path, failed_by = nil)
|
|
20
|
+
ComparisonResult.new(
|
|
21
|
+
nil,
|
|
22
|
+
{difference_level: nil, max_color_distance: 0},
|
|
23
|
+
comparison,
|
|
24
|
+
failed_by,
|
|
25
|
+
base_image_path,
|
|
26
|
+
new_image_path
|
|
27
|
+
).freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def different?
|
|
31
|
+
failed? || !(blank? || tolerable?)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def equal?
|
|
35
|
+
!different?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def failed?
|
|
39
|
+
!!failed_by
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def options
|
|
43
|
+
comparison.options
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def tolerance
|
|
47
|
+
options[:tolerance]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def skip_area
|
|
51
|
+
comparison.skip_area
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def area_size_limit
|
|
55
|
+
options[:area_size_limit]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def blank?
|
|
59
|
+
region.nil? || region_area_size.zero?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def region_area_size
|
|
63
|
+
@region_area_size ||= region&.size || 0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ratio
|
|
67
|
+
meta[:difference_level]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_h
|
|
71
|
+
{area_size: region_area_size, region: coordinates}.merge!(meta)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def coordinates
|
|
75
|
+
region&.to_edge_coordinates
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# One-line debugging summary with the difference metrics.
|
|
79
|
+
# (Error messages use #to_h — see Reporters::Default#build_error_message.)
|
|
80
|
+
def inspect
|
|
81
|
+
"#<#{self.class.name} different=#{different?} failed_by=#{failed_by.inspect} " \
|
|
82
|
+
"area_size=#{region_area_size} region=#{coordinates.inspect} " \
|
|
83
|
+
"difference_level=#{ratio.inspect} " \
|
|
84
|
+
"base=#{original_image_path} new=#{new_image_path}>"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def tolerable?
|
|
88
|
+
!!((area_size_limit && area_size_limit >= region_area_size) || (tolerance && tolerance >= ratio))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Path accessors for backward compatibility
|
|
92
|
+
def new_image_path
|
|
93
|
+
image_path || comparison&.new_image_path
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def original_image_path
|
|
97
|
+
base_image_path || comparison&.base_image_path
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def diff_mask
|
|
101
|
+
meta[:diff_mask]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The MAPPING below references the legacy modules, so they must be loaded
|
|
4
|
+
# first. Requires config_legacy directly (a leaf, see its own header
|
|
5
|
+
# comment), not the capybara_screenshot_diff umbrella -- config.rb is
|
|
6
|
+
# required by both snap_diff.rb and (indirectly) capybara_screenshot_diff.rb,
|
|
7
|
+
# and neither of those may lead back here.
|
|
8
|
+
require "capybara/screenshot/diff/config_legacy"
|
|
9
|
+
|
|
10
|
+
module SnapDiff
|
|
11
|
+
# Flat, additive consolidation of every existing
|
|
12
|
+
# +Capybara::Screenshot+ / +Capybara::Screenshot::Diff+ +mattr_accessor+
|
|
13
|
+
# setting behind one object: <tt>SnapDiff.config.<em>attr</em></tt>.
|
|
14
|
+
#
|
|
15
|
+
# Storage ownership: the OLD mattr_accessors remain the single source of
|
|
16
|
+
# truth. Config holds no state of its own -- every reader/writer defined
|
|
17
|
+
# from {MAPPING} simply forwards to the existing accessor. This is
|
|
18
|
+
# deliberate, not just simplest: several of those accessors carry default
|
|
19
|
+
# logic with observable timing (+fail_if_new+ derives its default from
|
|
20
|
+
# +ENV["CI"]+, +root+ falls back to +Rails.root+) that Rails' +mattr_accessor+
|
|
21
|
+
# evaluates once, at class-body-eval time, when +capybara_screenshot_diff.rb+
|
|
22
|
+
# first loads. Re-implementing that logic here -- even faithfully -- would
|
|
23
|
+
# mean evaluating it again, at a different moment (Config#new time), which
|
|
24
|
+
# is exactly the kind of "when defaults are evaluated" divergence the v2
|
|
25
|
+
# consolidation must not introduce. Delegating sidesteps the question
|
|
26
|
+
# entirely: Config never evaluates a default, it only ever forwards to
|
|
27
|
+
# whichever accessor already owns one. Bidirectional consistency (a write
|
|
28
|
+
# through either the old accessor or Config is visible through the other)
|
|
29
|
+
# falls out for free, because both paths read and write the exact same
|
|
30
|
+
# class-variable-backed storage.
|
|
31
|
+
class Config
|
|
32
|
+
# config attr name => [owning module, mattr_accessor name].
|
|
33
|
+
#
|
|
34
|
+
# The two names differ only for +screenshot_enabled+:
|
|
35
|
+
# +Capybara::Screenshot.enabled+ and +Capybara::Screenshot::Diff.enabled+
|
|
36
|
+
# are independent settings (see +Capybara::Screenshot.active?+, which
|
|
37
|
+
# reads both) that happen to share a bare name in their own modules. A
|
|
38
|
+
# flat Config can't expose two attributes both called +enabled+, so the
|
|
39
|
+
# Screenshot-side one is renamed here; Diff's keeps the bare +enabled+
|
|
40
|
+
# name since it's the one most existing configuration touches directly.
|
|
41
|
+
MAPPING = {
|
|
42
|
+
# Capybara::Screenshot
|
|
43
|
+
add_driver_path: [Capybara::Screenshot, :add_driver_path],
|
|
44
|
+
add_os_path: [Capybara::Screenshot, :add_os_path],
|
|
45
|
+
blur_active_element: [Capybara::Screenshot, :blur_active_element],
|
|
46
|
+
screenshot_enabled: [Capybara::Screenshot, :enabled],
|
|
47
|
+
hide_caret: [Capybara::Screenshot, :hide_caret],
|
|
48
|
+
disable_animations: [Capybara::Screenshot, :disable_animations],
|
|
49
|
+
root: [Capybara::Screenshot, :root],
|
|
50
|
+
stability_time_limit: [Capybara::Screenshot, :stability_time_limit],
|
|
51
|
+
window_size: [Capybara::Screenshot, :window_size],
|
|
52
|
+
save_path: [Capybara::Screenshot, :save_path],
|
|
53
|
+
use_lfs: [Capybara::Screenshot, :use_lfs],
|
|
54
|
+
screenshot_format: [Capybara::Screenshot, :screenshot_format],
|
|
55
|
+
capybara_screenshot_options: [Capybara::Screenshot, :capybara_screenshot_options],
|
|
56
|
+
# Capybara::Screenshot::Diff
|
|
57
|
+
delayed: [Capybara::Screenshot::Diff, :delayed],
|
|
58
|
+
area_size_limit: [Capybara::Screenshot::Diff, :area_size_limit],
|
|
59
|
+
fail_if_new: [Capybara::Screenshot::Diff, :fail_if_new],
|
|
60
|
+
pending_if_new: [Capybara::Screenshot::Diff, :pending_if_new],
|
|
61
|
+
fail_on_difference: [Capybara::Screenshot::Diff, :fail_on_difference],
|
|
62
|
+
color_distance_limit: [Capybara::Screenshot::Diff, :color_distance_limit],
|
|
63
|
+
enabled: [Capybara::Screenshot::Diff, :enabled],
|
|
64
|
+
shift_distance_limit: [Capybara::Screenshot::Diff, :shift_distance_limit],
|
|
65
|
+
skip_area: [Capybara::Screenshot::Diff, :skip_area],
|
|
66
|
+
driver: [Capybara::Screenshot::Diff, :driver],
|
|
67
|
+
tolerance: [Capybara::Screenshot::Diff, :tolerance],
|
|
68
|
+
perceptual_threshold: [Capybara::Screenshot::Diff, :perceptual_threshold],
|
|
69
|
+
screenshoter: [Capybara::Screenshot::Diff, :screenshoter],
|
|
70
|
+
manager: [Capybara::Screenshot::Diff, :manager]
|
|
71
|
+
}.freeze
|
|
72
|
+
|
|
73
|
+
MAPPING.each do |name, (mod, mattr)|
|
|
74
|
+
define_method(name) { mod.public_send(mattr) }
|
|
75
|
+
define_method(:"#{name}=") { |value| mod.public_send(:"#{mattr}=", value) }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SnapDiff
|
|
4
|
+
# @api private
|
|
5
|
+
#
|
|
6
|
+
# Internal until the v2 namespace transition; not a public contract.
|
|
7
|
+
#
|
|
8
|
+
# Warn-once-per-subject deprecation helper. Dormant: nothing in the
|
|
9
|
+
# current codebase calls this yet -- it exists so the gated v2 shim layer
|
|
10
|
+
# (ADR-004's +const_missing+-based legacy constant shim and the
|
|
11
|
+
# mattr_accessor-to-Config method shims) has pre-tested warning
|
|
12
|
+
# machinery to call into once it lands.
|
|
13
|
+
module Deprecation
|
|
14
|
+
# Emission channel: Kernel#warn, not a direct +$stderr.puts+.
|
|
15
|
+
#
|
|
16
|
+
# Kernel#warn delegates to +Warning.warn+ (Ruby >= 2.4), so anything
|
|
17
|
+
# that hooks +Warning.warn+ -- a test suite that raises on warnings, a
|
|
18
|
+
# custom log formatter, Ruby's own -W flag -- sees these messages the
|
|
19
|
+
# same way it sees every other Ruby warning. Writing straight to
|
|
20
|
+
# +$stderr+ would bypass that hook entirely and be invisible to any
|
|
21
|
+
# caller who has customized +Warning+ behavior.
|
|
22
|
+
MUTEX = Mutex.new
|
|
23
|
+
@seen = {}
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
# Emit a deprecation warning for +subject+, exactly once per unique
|
|
27
|
+
# +subject+ per process.
|
|
28
|
+
#
|
|
29
|
+
# @param subject [String] the deprecated old-namespace name being
|
|
30
|
+
# referenced, e.g. "Capybara::Screenshot::Diff::ImageCompare"
|
|
31
|
+
# @param replacement [String] the new-namespace name to use instead
|
|
32
|
+
# @param category [Symbol] what kind of thing moved, for the human
|
|
33
|
+
# reading the message (e.g. :constant, :config)
|
|
34
|
+
# @return [void]
|
|
35
|
+
def warn(subject, replacement, category:)
|
|
36
|
+
return if SnapDiff.silence_deprecations?
|
|
37
|
+
|
|
38
|
+
first_time = MUTEX.synchronize do
|
|
39
|
+
@seen.key?(subject) ? false : (@seen[subject] = true)
|
|
40
|
+
end
|
|
41
|
+
return unless first_time
|
|
42
|
+
|
|
43
|
+
Kernel.warn(message_for(subject, replacement, category))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @api private
|
|
47
|
+
#
|
|
48
|
+
# Clears the seen-set. For tests only -- lets each example assert
|
|
49
|
+
# "warns once" from a clean slate instead of leaking state across
|
|
50
|
+
# the suite.
|
|
51
|
+
# @return [void]
|
|
52
|
+
def reset!
|
|
53
|
+
MUTEX.synchronize { @seen.clear }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def message_for(subject, replacement, category)
|
|
59
|
+
"[snap_diff deprecation] `#{subject}` is deprecated (#{category}); " \
|
|
60
|
+
"use `#{replacement}` instead."
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class << self
|
|
66
|
+
# @api private
|
|
67
|
+
attr_accessor :silence_deprecations
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
#
|
|
71
|
+
# @return [Boolean] true if deprecation warnings should be suppressed,
|
|
72
|
+
# either via the {silence_deprecations} accessor or the
|
|
73
|
+
# SNAP_DIFF_SILENCE_DEPRECATIONS env var (truthy = "1"/"true").
|
|
74
|
+
def silence_deprecations?
|
|
75
|
+
!!silence_deprecations || truthy_env?(ENV["SNAP_DIFF_SILENCE_DEPRECATIONS"])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def truthy_env?(value)
|
|
81
|
+
%w[1 true].include?(value.to_s.downcase)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SnapDiff
|
|
4
|
+
# Shared default behavior for image-processing drivers.
|
|
5
|
+
#
|
|
6
|
+
# Replaces the old +Capybara::Screenshot::Diff::Drivers::BaseDriver+
|
|
7
|
+
# superclass (ADR-004 v2 step 4): concrete drivers +include Driver+
|
|
8
|
+
# instead of inheriting. Method names are intentionally unchanged from
|
|
9
|
+
# v1 — see dissent #4 in the v2 architecture design.
|
|
10
|
+
module Driver
|
|
11
|
+
PNG_EXTENSION = ".png"
|
|
12
|
+
|
|
13
|
+
def same_dimension?(comparison)
|
|
14
|
+
dimension(comparison.base_image) == dimension(comparison.new_image)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def height_for(image)
|
|
18
|
+
image.height
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def width_for(image)
|
|
22
|
+
image.width
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def image_area_size(image)
|
|
26
|
+
width_for(image) * height_for(image)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def dimension(image)
|
|
30
|
+
[width_for(image), height_for(image)]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def supports?(feature)
|
|
34
|
+
respond_to?(feature)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require "chunky_png"
|
|
5
|
+
rescue LoadError => e
|
|
6
|
+
raise 'Required chunky_png gem is missing. Add `gem "chunky_png"` to Gemfile' if e.message.match?(/chunky_png/i)
|
|
7
|
+
raise
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require "snap_diff/driver"
|
|
11
|
+
require "snap_diff/drivers"
|
|
12
|
+
require "snap_diff/comparison_result"
|
|
13
|
+
|
|
14
|
+
module SnapDiff
|
|
15
|
+
module Drivers
|
|
16
|
+
class ChunkyPNGDriver
|
|
17
|
+
include SnapDiff::Driver
|
|
18
|
+
include ChunkyPNG::Color
|
|
19
|
+
|
|
20
|
+
def load_images(old_file_name, new_file_name)
|
|
21
|
+
old_bytes, new_bytes = load_image_files(old_file_name, new_file_name)
|
|
22
|
+
|
|
23
|
+
_load_images(old_bytes, new_bytes)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_black_box(image, _region)
|
|
27
|
+
image
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def find_difference_region(comparison)
|
|
31
|
+
DifferenceRegionFinder.new(comparison, self).perform
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def crop(region, i)
|
|
35
|
+
i.crop(*region.to_top_left_corner_coordinates)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def from_file(filename_or_path)
|
|
39
|
+
ChunkyPNG::Image.from_file(filename_or_path.to_s)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def save_image_to(image, filename)
|
|
43
|
+
image.save(filename, :fast_rgba)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def resize_image_to(image, new_width, new_height)
|
|
47
|
+
image.resample_bilinear(new_width, new_height)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def load_image_files(old_file_name, file_name)
|
|
51
|
+
[old_file_name.binread, file_name.binread]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def draw_rectangles(images, region, (r, g, b), offset: 0)
|
|
55
|
+
border_color = ChunkyPNG::Color.rgb(r, g, b)
|
|
56
|
+
border_shadow = ChunkyPNG::Color.rgba(r, g, b, 100)
|
|
57
|
+
|
|
58
|
+
images.map do |image|
|
|
59
|
+
new_img = image.dup
|
|
60
|
+
new_img.rect(region.left - offset, region.top - offset, region.right + offset, region.bottom + offset, border_color)
|
|
61
|
+
new_img.rect(region.left, region.top, region.right, region.bottom, border_shadow)
|
|
62
|
+
new_img
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def same_pixels?(comparison)
|
|
67
|
+
comparison.new_image == comparison.base_image
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def _load_images(old_file, new_file)
|
|
73
|
+
[ChunkyPNG::Image.from_blob(old_file), ChunkyPNG::Image.from_blob(new_file)]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class DifferenceRegionFinder
|
|
77
|
+
attr_accessor :skip_area, :color_distance_limit, :shift_distance_limit
|
|
78
|
+
|
|
79
|
+
def initialize(comparison, driver = nil)
|
|
80
|
+
@comparison = comparison
|
|
81
|
+
@driver = driver
|
|
82
|
+
|
|
83
|
+
@color_distance_limit = comparison.options[:color_distance_limit]
|
|
84
|
+
@shift_distance_limit = comparison.options[:shift_distance_limit]
|
|
85
|
+
@skip_area = comparison.options[:skip_area]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def perform
|
|
89
|
+
find_difference_region(@comparison)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def find_difference_region(comparison)
|
|
93
|
+
new_image, base_image, = comparison.new_image, comparison.base_image
|
|
94
|
+
|
|
95
|
+
meta = {}
|
|
96
|
+
meta[:max_color_distance] = 0
|
|
97
|
+
meta[:max_shift_distance] = 0 if shift_distance_limit
|
|
98
|
+
|
|
99
|
+
region = find_top(base_image, new_image, cache: meta)
|
|
100
|
+
region = if region.nil? || region[1].nil?
|
|
101
|
+
nil
|
|
102
|
+
else
|
|
103
|
+
find_diff_rectangle(base_image, new_image, region, cache: meta)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
result = ComparisonResult.new(region, meta, comparison)
|
|
107
|
+
|
|
108
|
+
unless result.blank?
|
|
109
|
+
meta[:max_color_distance] = meta[:max_color_distance].ceil(1) if meta[:max_color_distance]
|
|
110
|
+
|
|
111
|
+
if comparison.options[:tolerance]
|
|
112
|
+
meta[:difference_level] = difference_level(nil, base_image, region)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
result
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def difference_level(_diff_mask, base_image, region)
|
|
120
|
+
image_area_size = @driver.image_area_size(base_image)
|
|
121
|
+
return nil if image_area_size.zero?
|
|
122
|
+
|
|
123
|
+
region.size.to_f / image_area_size
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def find_diff_rectangle(org_img, new_img, area_coordinates, cache:)
|
|
127
|
+
left, top, right, bottom = find_left_right_and_top(org_img, new_img, area_coordinates, cache: cache)
|
|
128
|
+
bottom = find_bottom(org_img, new_img, left, right, bottom, cache: cache)
|
|
129
|
+
|
|
130
|
+
Region.from_edge_coordinates(left, top, right, bottom)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def find_top(old_img, new_img, cache:)
|
|
134
|
+
old_img.height.times do |y|
|
|
135
|
+
old_img.width.times do |x|
|
|
136
|
+
return [x, y, x, y] unless same_color?(old_img, new_img, x, y, cache: cache)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def find_left_right_and_top(old_img, new_img, region, cache:)
|
|
143
|
+
region = region.is_a?(Region) ? region.to_edge_coordinates : region
|
|
144
|
+
|
|
145
|
+
left = region[0] || old_img.width - 1
|
|
146
|
+
top = region[1]
|
|
147
|
+
right = region[2] || 0
|
|
148
|
+
bottom = region[3]
|
|
149
|
+
|
|
150
|
+
old_img.height.times do |y|
|
|
151
|
+
(0...left).find do |x|
|
|
152
|
+
next if same_color?(old_img, new_img, x, y, cache: cache)
|
|
153
|
+
|
|
154
|
+
top ||= y
|
|
155
|
+
bottom = y
|
|
156
|
+
left = x
|
|
157
|
+
right = x if x > right
|
|
158
|
+
x
|
|
159
|
+
end
|
|
160
|
+
(old_img.width - 1).step(right + 1, -1).find do |x|
|
|
161
|
+
unless same_color?(old_img, new_img, x, y, cache: cache)
|
|
162
|
+
bottom = y
|
|
163
|
+
right = x
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
[left, top, right, bottom]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def find_bottom(old_img, new_img, left, right, bottom, cache:)
|
|
172
|
+
if bottom
|
|
173
|
+
(old_img.height - 1).step(bottom + 1, -1).find do |y|
|
|
174
|
+
(left..right).find do |x|
|
|
175
|
+
bottom = y unless same_color?(old_img, new_img, x, y, cache: cache)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
bottom
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def same_color?(old_img, new_img, x, y, cache:)
|
|
184
|
+
return true if skipped_region?(x, y)
|
|
185
|
+
|
|
186
|
+
color_distance =
|
|
187
|
+
color_distance_at(new_img, old_img, x, y, shift_distance_limit: @shift_distance_limit)
|
|
188
|
+
|
|
189
|
+
if color_distance > cache[:max_color_distance]
|
|
190
|
+
cache[:max_color_distance] = color_distance
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
color_matches = color_distance == 0 ||
|
|
194
|
+
(!!@color_distance_limit && @color_distance_limit > 0 && color_distance <= @color_distance_limit)
|
|
195
|
+
|
|
196
|
+
return color_matches if !@shift_distance_limit || cache[:max_shift_distance] == Float::INFINITY
|
|
197
|
+
|
|
198
|
+
shift_distance = (color_matches && 0) ||
|
|
199
|
+
shift_distance_at(new_img, old_img, x, y, color_distance_limit: @color_distance_limit)
|
|
200
|
+
if shift_distance && (cache[:max_shift_distance].nil? || shift_distance > cache[:max_shift_distance])
|
|
201
|
+
cache[:max_shift_distance] = shift_distance
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
color_matches
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def skipped_region?(x, y)
|
|
208
|
+
return false unless @skip_area
|
|
209
|
+
|
|
210
|
+
@skip_area.any? { |region| region.cover?(x, y) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def color_distance_at(new_img, old_img, x, y, shift_distance_limit:)
|
|
214
|
+
org_color = old_img[x, y]
|
|
215
|
+
unless shift_distance_limit
|
|
216
|
+
return ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[x, y])
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
start_x = [0, x - shift_distance_limit].max
|
|
220
|
+
end_x = [x + shift_distance_limit, new_img.width - 1].min
|
|
221
|
+
start_y = [0, y - shift_distance_limit].max
|
|
222
|
+
end_y = [y + shift_distance_limit, new_img.height - 1].min
|
|
223
|
+
|
|
224
|
+
min_distance = Float::INFINITY
|
|
225
|
+
(start_y..end_y).each do |dy|
|
|
226
|
+
(start_x..end_x).each do |dx|
|
|
227
|
+
distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_img[dx, dy])
|
|
228
|
+
return 0 if distance == 0
|
|
229
|
+
min_distance = distance if distance < min_distance
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
min_distance
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def shift_distance_at(new_img, old_img, x, y, color_distance_limit:)
|
|
236
|
+
org_color = old_img[x, y]
|
|
237
|
+
shift_distance = 0
|
|
238
|
+
loop do
|
|
239
|
+
bounds_breached = 0
|
|
240
|
+
top_row = y - shift_distance
|
|
241
|
+
if top_row >= 0 # top
|
|
242
|
+
([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
|
|
243
|
+
if color_matches(new_img, org_color, dx, top_row, color_distance_limit)
|
|
244
|
+
return shift_distance
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
else
|
|
248
|
+
bounds_breached += 1
|
|
249
|
+
end
|
|
250
|
+
if shift_distance > 0
|
|
251
|
+
if (x - shift_distance) >= 0 # left
|
|
252
|
+
([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
|
|
253
|
+
.each do |dy|
|
|
254
|
+
if color_matches(new_img, org_color, x - shift_distance, dy, color_distance_limit)
|
|
255
|
+
return shift_distance
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
else
|
|
259
|
+
bounds_breached += 1
|
|
260
|
+
end
|
|
261
|
+
if (y + shift_distance) < new_img.height # bottom
|
|
262
|
+
([0, x - shift_distance].max..[x + shift_distance, new_img.width - 1].min).each do |dx|
|
|
263
|
+
if color_matches(new_img, org_color, dx, y + shift_distance, color_distance_limit)
|
|
264
|
+
return shift_distance
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
else
|
|
268
|
+
bounds_breached += 1
|
|
269
|
+
end
|
|
270
|
+
if (x + shift_distance) < new_img.width # right
|
|
271
|
+
([0, top_row + 1].max..[y + shift_distance, new_img.height - 2].min)
|
|
272
|
+
.each do |dy|
|
|
273
|
+
if color_matches(new_img, org_color, x + shift_distance, dy, color_distance_limit)
|
|
274
|
+
return shift_distance
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
else
|
|
278
|
+
bounds_breached += 1
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
break if bounds_breached == 4
|
|
282
|
+
|
|
283
|
+
shift_distance += 1
|
|
284
|
+
end
|
|
285
|
+
Float::INFINITY
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def color_matches(new_img, org_color, x, y, color_distance_limit)
|
|
289
|
+
new_color = new_img[x, y]
|
|
290
|
+
return new_color == org_color unless color_distance_limit
|
|
291
|
+
|
|
292
|
+
color_distance = ChunkyPNG::Color.euclidean_distance_rgba(org_color, new_color)
|
|
293
|
+
color_distance <= color_distance_limit
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|