capybara-screenshot-diff 1.12.0 → 1.14.0

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.
@@ -4,8 +4,9 @@ require "pathname"
4
4
  require "fileutils"
5
5
 
6
6
  require "capybara/screenshot/diff/comparison"
7
+ require "capybara/screenshot/diff/difference"
8
+ require "capybara/screenshot/diff/drivers"
7
9
  require "capybara/screenshot/diff/image_preprocessor"
8
- require "capybara/screenshot/diff/difference_finder"
9
10
  require "capybara/screenshot/diff/reporters/default"
10
11
 
11
12
  module Capybara
@@ -36,6 +37,8 @@ module Capybara
36
37
  # - Only performing expensive operations when absolutely necessary
37
38
  # - Maintaining high accuracy for complex comparisons
38
39
  class ImageCompare
40
+ TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
41
+
39
42
  attr_reader :driver, :driver_options
40
43
  attr_reader :image_path, :base_image_path
41
44
  attr_reader :difference, :error_message
@@ -48,6 +51,7 @@ module Capybara
48
51
 
49
52
  @driver_options = options.freeze
50
53
  @driver = Drivers.for(@driver_options)
54
+ @without_tolerable_options = (driver_options.keys & TOLERABLE_OPTIONS).empty?
51
55
  end
52
56
 
53
57
  # Performs a quick comparison of two image files.
@@ -85,7 +89,7 @@ module Capybara
85
89
  # - `false` if the images are considered identical
86
90
  #
87
91
  # @see #processed
88
- # @see DifferenceFinder
92
+ # @see #analyze_difference
89
93
  def different?
90
94
  processed.difference.different?
91
95
  end
@@ -110,8 +114,8 @@ module Capybara
110
114
 
111
115
  private
112
116
 
113
- def difference_finder
114
- @difference_finder ||= DifferenceFinder.new(driver, driver_options)
117
+ def without_tolerable_options?
118
+ @without_tolerable_options
115
119
  end
116
120
 
117
121
  def load_images_and_build_comparison(base_path, new_path, options)
@@ -130,8 +134,41 @@ module Capybara
130
134
  # Create comparison with preprocessed images
131
135
  comparison = load_comparison(base_image_path, image_path, driver_options)
132
136
 
133
- # Use difference finder to analyze the comparison
134
- difference_finder.call(comparison, quick_mode: quick_mode)
137
+ analyze_difference(comparison, quick_mode: quick_mode)
138
+ end
139
+
140
+ # Analyzes the comparison and determines if images are different.
141
+ #
142
+ # @param comparison [Comparison] The comparison object containing images to analyze.
143
+ # @param quick_mode [Boolean] When true, performs minimal checks and returns early.
144
+ # In quick mode, returns [is_equal, difference] where:
145
+ # - is_equal is true if images are considered equal
146
+ # - difference is a Difference object or nil
147
+ # When false, returns a Difference object directly.
148
+ # @return [Array, Difference] Result format depends on quick_mode parameter.
149
+ def analyze_difference(comparison, quick_mode: true)
150
+ # Handle dimension differences
151
+ unless driver.same_dimension?(comparison)
152
+ result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
153
+ return quick_mode ? [false, result] : result
154
+ end
155
+
156
+ # Handle identical pixels
157
+ if driver.same_pixels?(comparison)
158
+ result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
159
+ return quick_mode ? [true, result] : result
160
+ end
161
+
162
+ # Handle early return for non-tolerable options
163
+ if quick_mode && without_tolerable_options?
164
+ return [false, nil]
165
+ end
166
+
167
+ # Process difference region
168
+ region = driver.find_difference_region(comparison)
169
+
170
+ # Only create a proper difference object if we've completed the comparison
171
+ quick_mode ? [!region.different?, region] : region
135
172
  end
136
173
 
137
174
  def difference=(new_difference)
@@ -1,18 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "capybara/screenshot/diff/annotation_service"
4
+
3
5
  module Capybara::Screenshot::Diff
4
6
  module Reporters
5
7
  class Default
6
- attr_reader :annotated_image_path, :annotated_base_image_path, :heatmap_diff_path, :difference
8
+ attr_reader :difference
7
9
 
8
10
  def initialize(difference)
9
11
  @difference = difference
12
+ @annotation_service = AnnotationService.new(difference)
13
+ end
14
+
15
+ def annotated_image_path
16
+ annotation_service.annotated_image_path
17
+ end
10
18
 
11
- ext = comparison.new_image_path.extname.delete_prefix(".")
12
- screenshot_format = difference.comparison.options[:screenshot_format] || (ext unless ext.empty?) || "png"
13
- @annotated_image_path = comparison.new_image_path.sub_ext(".diff.#{screenshot_format}")
14
- @annotated_base_image_path = comparison.base_image_path.sub_ext(".diff.#{screenshot_format}")
15
- @heatmap_diff_path = comparison.new_image_path.sub_ext(".heatmap.diff.#{screenshot_format}")
19
+ def annotated_base_image_path
20
+ annotation_service.annotated_base_image_path
21
+ end
22
+
23
+ def heatmap_diff_path
24
+ annotation_service.heatmap_diff_path
16
25
  end
17
26
 
18
27
  def generate
@@ -31,44 +40,35 @@ module Capybara::Screenshot::Diff
31
40
  end
32
41
 
33
42
  def clean_tmp_files
34
- annotated_base_image_path.unlink if annotated_base_image_path.exist?
35
- annotated_image_path.unlink if annotated_image_path.exist?
36
- heatmap_diff_path.unlink if heatmap_diff_path.exist?
37
- end
38
-
39
- def build_error_for_different_dimensions
40
- change_msg = [comparison.base_image, comparison.new_image]
41
- .map { |image| driver.dimension(image).join("x") }
42
- .join(" => ")
43
-
44
- "Dimensions have changed: #{change_msg}\n#{base_image_path.to_path}\n#{image_path.to_path}"
43
+ annotation_service.clean_tmp_files
45
44
  end
46
45
 
47
46
  def annotate_and_save_images
48
- save_annotation_for(new_image, annotated_image_path)
49
- save_annotation_for(base_image, annotated_base_image_path)
50
- save_heatmap_diff if difference.diff_mask
47
+ annotation_service.annotate_and_save_images
51
48
  end
52
49
 
53
50
  def save_annotation_for(image, image_path)
54
- image = annotate_difference(image, difference.region)
55
- image = annotate_skip_areas(image, difference.comparison.skip_area) if difference.comparison.skip_area
56
-
57
- save(image, image_path.to_path)
51
+ annotation_service.save_annotation_for(image, image_path)
58
52
  end
59
53
 
60
54
  def annotate_difference(image, region)
61
- driver.draw_rectangles([image], region, CapybaraScreenshotDiff::RED_RGBA, offset: 1).first
55
+ annotation_service.annotate_difference(image, region)
62
56
  end
63
57
 
64
58
  def annotate_skip_areas(image, skip_areas)
65
- skip_areas.reduce(image) do |memo, region|
66
- driver.draw_rectangles([memo], region, CapybaraScreenshotDiff::ORANGE_RGBA).first
67
- end
59
+ annotation_service.annotate_skip_areas(image, skip_areas)
68
60
  end
69
61
 
70
62
  def save(image, image_path)
71
- driver.save_image_to(image, image_path.to_s)
63
+ annotation_service.save(image, image_path)
64
+ end
65
+
66
+ def build_error_for_different_dimensions
67
+ change_msg = [comparison.base_image, comparison.new_image]
68
+ .map { |image| driver.dimension(image).join("x") }
69
+ .join(" => ")
70
+
71
+ "Dimensions have changed: #{change_msg}\n#{base_image_path.to_path}\n#{image_path.to_path}"
72
72
  end
73
73
 
74
74
  NEW_LINE = "\n"
@@ -85,20 +85,7 @@ module Capybara::Screenshot::Diff
85
85
 
86
86
  private
87
87
 
88
- def save_heatmap_diff
89
- merged_image = driver.merge(new_image, base_image)
90
- highlighted_mask = driver.highlight_mask(difference.diff_mask, merged_image, color: CapybaraScreenshotDiff::RED_RGBA)
91
-
92
- save(highlighted_mask, heatmap_diff_path.to_path)
93
- end
94
-
95
- def base_image
96
- difference.comparison.base_image
97
- end
98
-
99
- def new_image
100
- difference.comparison.new_image
101
- end
88
+ attr_reader :annotation_service
102
89
 
103
90
  def base_image_path
104
91
  comparison.base_image_path
@@ -32,11 +32,25 @@ module Capybara
32
32
 
33
33
  # Pre-computation: No need to compare without base screenshot
34
34
  # NOTE: Consider to return PreValid Assertion Value Object with hard coded valid result
35
- return unless need_to_compare?
35
+ unless need_to_compare?
36
+ CapybaraScreenshotDiff.record_new_screenshot(screenshot_full_name)
37
+ return
38
+ end
36
39
 
37
40
  create_screenshot_assertion(skip_stack_frames + 1, comparison_options)
38
41
  end
39
42
 
43
+ # Captures a screenshot without comparing it to a baseline.
44
+ def capture
45
+ check_window_size!
46
+ prepare_screenshot_options
47
+
48
+ capture_options, comparison_options = extract_capture_and_comparison_options!(driver_options)
49
+
50
+ @snapshot.manager.create_output_directory_for(@snapshot.path)
51
+ capture_screenshot(capture_options, comparison_options)
52
+ end
53
+
40
54
  private
41
55
 
42
56
  def need_to_compare?
@@ -14,7 +14,7 @@ module Capybara
14
14
  # `:stability_time_limit` and `:wait` in capture options and ensures that `:stability_time_limit` is less than or equal to `:wait`.
15
15
  #
16
16
  # @param capture_options [Hash] The options for capturing screenshots, must include `:stability_time_limit` and `:wait`.
17
- # @param comparison_options [Hash, nil] The options for comparing screenshots, defaults to `nil` which uses `Diff.default_options`.
17
+ # @param comparison_options [Hash] The options for comparing screenshots, defaults to `{}`. Same signature as {Capybara::Screenshot::Screenshoter#initialize}.
18
18
  # @raise [ArgumentError] If `:wait` or `:stability_time_limit` are not provided, or if `:stability_time_limit` is greater than `:wait`.
19
19
  def initialize(capture_options, comparison_options = {})
20
20
  @stability_time_limit, @wait = capture_options.fetch_values(*STABILITY_OPTIONS)
@@ -3,7 +3,7 @@
3
3
  module Capybara
4
4
  module Screenshot
5
5
  module Diff
6
- VERSION = "1.12.0"
6
+ VERSION = "1.14.0"
7
7
  end
8
8
  end
9
9
  end
@@ -9,4 +9,12 @@ Before do
9
9
  Capybara::Screenshot::BrowserHelpers.resize_window_if_needed
10
10
  end
11
11
 
12
+ After do |scenario|
13
+ if !scenario.failed? && (msg = CapybaraScreenshotDiff.pending_screenshots_message)
14
+ skip_this_scenario(msg)
15
+ end
16
+ ensure
17
+ CapybaraScreenshotDiff.reset
18
+ end
19
+
12
20
  AfterAll { CapybaraScreenshotDiff.finalize_reporters! }
@@ -25,7 +25,7 @@ module CapybaraScreenshotDiff
25
25
  screenshot_namer.group = name
26
26
  end
27
27
 
28
- # Takes a screenshot and optionally compares it against a baseline image.
28
+ # Takes a screenshot and compares it against a baseline image.
29
29
  #
30
30
  # The method follows a layered optimization strategy for comparison:
31
31
  # 1. First checks if screenshot functionality is active
@@ -53,7 +53,7 @@ module CapybaraScreenshotDiff
53
53
  # @raise [CapybaraScreenshotDiff::ExpectationNotMet] If comparison fails and immediate validation is enabled.
54
54
  # @raise [CapybaraScreenshotDiff::UnstableImage] If the image comparison is unstable.
55
55
  # @raise [CapybaraScreenshotDiff::WindowSizeMismatchError] If the window size doesn't match expectations.
56
- def screenshot(name, skip_stack_frames: 0, **options)
56
+ def assert_matches_screenshot(name, skip_stack_frames: 0, **options)
57
57
  return false unless Capybara::Screenshot.active?
58
58
 
59
59
  # Get the full name with section and group information
@@ -76,15 +76,36 @@ module CapybaraScreenshotDiff
76
76
  true
77
77
  end
78
78
 
79
- # Alias for backward compatibility with older test suites.
80
- # @see #screenshot
81
- alias_method :assert_matches_screenshot, :screenshot
79
+ # Convenience wrapper around {#assert_matches_screenshot} and {#capture_screenshot}.
80
+ # @param compare [Boolean] When false, only captures the screenshot without comparing it to a baseline.
81
+ # @see #assert_matches_screenshot
82
+ # @see #capture_screenshot
83
+ def screenshot(name, skip_stack_frames: 0, compare: true, **options)
84
+ if compare
85
+ assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **options)
86
+ else
87
+ capture_screenshot(name, **options)
88
+ end
89
+ end
90
+
91
+ # Captures a screenshot without comparing it to a baseline.
92
+ # @param name [String] The base name of the screenshot, used to generate the filename.
93
+ # @param options [Hash] Additional options for taking the screenshot. See {#assert_matches_screenshot}.
94
+ # @return [Boolean] True if the screenshot was successfully captured.
95
+ def capture_screenshot(name, **options)
96
+ return false unless Capybara::Screenshot.active?
97
+
98
+ full_name = CapybaraScreenshotDiff.screenshot_namer.full_name(name)
99
+ Capybara::Screenshot::Diff::ScreenshotMatcher.new(full_name, options).capture
100
+
101
+ true
102
+ end
82
103
 
83
104
  # Asserts the current page has no visual changes from the baseline.
84
105
  # Override in your base test class to add project-specific behavior
85
106
  # (e.g., waiting for Turbo, default skip areas).
86
107
  def assert_no_screenshot_changes(name, skip_stack_frames: 0, **opts)
87
- screenshot(name, skip_stack_frames: skip_stack_frames + 1, **opts)
108
+ assert_matches_screenshot(name, skip_stack_frames: skip_stack_frames + 1, **opts)
88
109
  end
89
110
 
90
111
  private
@@ -96,7 +117,7 @@ module CapybaraScreenshotDiff
96
117
  #
97
118
  # @param name [String] The full name of the screenshot, including any section/group context.
98
119
  # @param options [Hash] Options for screenshot taking and comparison.
99
- # See {#screenshot} for available options.
120
+ # See {#assert_matches_screenshot} for available options.
100
121
  # @param skip_stack_frames [Integer] Number of stack frames to skip for error reporting.
101
122
  # @return [ScreenshotAssertion, nil] The assertion object or nil if no assertion is needed.
102
123
  # @see ScreenshotAssertion
@@ -19,7 +19,7 @@ module CapybaraScreenshotDiff
19
19
  module Assertions
20
20
  include ::CapybaraScreenshotDiff::DSL
21
21
 
22
- def screenshot(*args, skip_stack_frames: 0, **opts)
22
+ def assert_matches_screenshot(*args, skip_stack_frames: 0, **opts)
23
23
  self.assertions += 1
24
24
 
25
25
  super(*args, skip_stack_frames: skip_stack_frames + 1, **opts)
@@ -27,8 +27,6 @@ module CapybaraScreenshotDiff
27
27
  raise ::Minitest::Assertion, e.message
28
28
  end
29
29
 
30
- alias_method :assert_matches_screenshot, :screenshot
31
-
32
30
  def setup
33
31
  super
34
32
  ::Capybara::Screenshot::BrowserHelpers.resize_window_if_needed
@@ -37,6 +35,10 @@ module CapybaraScreenshotDiff
37
35
  def before_teardown
38
36
  super
39
37
  CapybaraScreenshotDiff.verify
38
+
39
+ if (msg = CapybaraScreenshotDiff.pending_screenshots_message)
40
+ skip(msg)
41
+ end
40
42
  rescue CapybaraScreenshotDiff::ExpectationNotMet => e
41
43
  assertion = ::Minitest::Assertion.new(e)
42
44
  assertion.set_backtrace(e.backtrace)
@@ -7,7 +7,7 @@ RSpec::Matchers.define :match_screenshot do |name, **options|
7
7
  description { "match screenshot '#{name}'" }
8
8
 
9
9
  match do |_page|
10
- screenshot(name, **options)
10
+ assert_matches_screenshot(name, **options)
11
11
  true
12
12
  end
13
13
 
@@ -30,10 +30,15 @@ RSpec.configure do |config|
30
30
  end
31
31
  end
32
32
 
33
- config.after do
33
+ config.after do |example|
34
34
  if self.class.include?(CapybaraScreenshotDiff::DSL)
35
35
  begin
36
36
  CapybaraScreenshotDiff.verify
37
+
38
+ # Never mask a real failure with a pending marker.
39
+ if example.exception.nil? && (msg = CapybaraScreenshotDiff.pending_screenshots_message)
40
+ skip(msg)
41
+ end
37
42
  rescue CapybaraScreenshotDiff::ExpectationNotMet => e
38
43
  raise RSpec::Expectations::ExpectationNotMetError.new(e.message).tap { |ex| ex.set_backtrace(e.backtrace) }
39
44
  ensure
@@ -65,10 +65,11 @@ module CapybaraScreenshotDiff
65
65
  end
66
66
 
67
67
  class AssertionRegistry
68
- attr_reader :assertions, :screenshot_namer
68
+ attr_reader :assertions, :screenshot_namer, :new_screenshots
69
69
 
70
70
  def initialize
71
71
  @assertions = []
72
+ @new_screenshots = []
72
73
  @screenshot_namer = CapybaraScreenshotDiff::ScreenshotNamer.new
73
74
  end
74
75
 
@@ -84,6 +85,14 @@ module CapybaraScreenshotDiff
84
85
  !@assertions.empty?
85
86
  end
86
87
 
88
+ def record_new_screenshot(name)
89
+ @new_screenshots.push(name)
90
+ end
91
+
92
+ def new_screenshots_present?
93
+ !@new_screenshots.empty?
94
+ end
95
+
87
96
  def verify(screenshots = CapybaraScreenshotDiff.assertions)
88
97
  return unless ::Capybara::Screenshot.active? && ::Capybara::Screenshot::Diff.fail_on_difference
89
98
 
@@ -102,6 +111,7 @@ module CapybaraScreenshotDiff
102
111
 
103
112
  def reset
104
113
  @assertions.clear
114
+ @new_screenshots.clear
105
115
  @screenshot_namer = CapybaraScreenshotDiff::ScreenshotNamer.new
106
116
  end
107
117
  end
@@ -120,6 +130,9 @@ module CapybaraScreenshotDiff
120
130
  def_delegator :registry, :assertions
121
131
  def_delegator :registry, :assertions_present?
122
132
  def_delegator :registry, :failed_assertions
133
+ def_delegator :registry, :record_new_screenshot
134
+ def_delegator :registry, :new_screenshots
135
+ def_delegator :registry, :new_screenshots_present?
123
136
  def reset
124
137
  notify_reporters(registry.assertions)
125
138
  registry.reset
@@ -145,6 +158,17 @@ module CapybaraScreenshotDiff
145
158
  def_delegator :registry, :screenshot_namer
146
159
  def_delegator :registry, :verify
147
160
 
161
+ # Message to skip the test with when a new screenshot has no baseline yet
162
+ # and `pending_if_new` is enabled. Adapters call this after verifying
163
+ # screenshots, and skip the test with the returned message when present.
164
+ #
165
+ # @return [String, nil] the pending message, or nil when there is nothing to report
166
+ def pending_screenshots_message
167
+ return unless ::Capybara::Screenshot::Diff.pending_if_new && new_screenshots_present?
168
+
169
+ "No baseline for: #{new_screenshots.join(", ")}. Commit the captured screenshots to record them."
170
+ end
171
+
148
172
  private
149
173
 
150
174
  def notify_reporters(assertions)
@@ -1,5 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # Lazy load: snap_diff.rb requires this file, so eagerly `require`-ing it
4
+ # here would be circular. `autoload` defers the load until SnapDiff is
5
+ # first referenced, by which point this file has already finished loading.
6
+ autoload :SnapDiff, "snap_diff"
7
+
3
8
  require "capybara/dsl"
4
9
  require "capybara/screenshot/diff/version"
5
10
  require "capybara/screenshot/diff/utils"
@@ -65,6 +70,7 @@ module Capybara
65
70
  mattr_accessor(:delayed) { true }
66
71
  mattr_accessor :area_size_limit
67
72
  mattr_accessor(:fail_if_new) { !ENV["CI"].nil? && !ENV["CI"].empty? }
73
+ mattr_accessor(:pending_if_new) { false }
68
74
  mattr_accessor(:fail_on_difference) { true }
69
75
  mattr_accessor :color_distance_limit
70
76
  mattr_accessor(:enabled) { true }
data/lib/snap_diff.rb ADDED
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara_screenshot_diff"
4
+
5
+ # Forward-looking namespace for the gem, per ADR-004.
6
+ #
7
+ # These are pure additive aliases onto the existing
8
+ # +Capybara::Screenshot::Diff+ API — no behavior changes, no deprecation
9
+ # warnings. See ADR-004 for the full migration plan.
10
+ module SnapDiff
11
+ Comparison = Capybara::Screenshot::Diff::ImageCompare
12
+
13
+ def self.compare(...)
14
+ Capybara::Screenshot::Diff.compare(...)
15
+ end
16
+
17
+ def self.start(&block)
18
+ Capybara::Screenshot::Diff.configure(&block)
19
+ end
20
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: capybara-screenshot-diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.12.0
4
+ version: 1.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Uwe Kubosch
@@ -77,29 +77,33 @@ extensions: []
77
77
  extra_rdoc_files: []
78
78
  files:
79
79
  - CHANGELOG.md
80
+ - CODE_OF_CONDUCT.md
80
81
  - LICENSE.txt
81
82
  - Rakefile
82
83
  - capybara-screenshot-diff.gemspec
83
84
  - docs/RELEASE_PREP.md
84
85
  - docs/UPGRADING.md
86
+ - docs/architecture.md
85
87
  - docs/ci-integration.md
86
88
  - docs/configuration.md
87
89
  - docs/docker-testing.md
88
90
  - docs/drivers.md
89
91
  - docs/framework-setup.md
92
+ - docs/images/snap_diff_annotated.png
90
93
  - docs/images/snap_diff_web_ui.png
94
+ - docs/migration-guide.md
91
95
  - docs/organization.md
92
96
  - docs/reporters.md
93
97
  - docs/thread_safety.md
94
98
  - gems.rb
95
99
  - lib/capybara-screenshot-diff.rb
96
100
  - lib/capybara/screenshot/diff.rb
101
+ - lib/capybara/screenshot/diff/annotation_service.rb
97
102
  - lib/capybara/screenshot/diff/area_calculator.rb
98
103
  - lib/capybara/screenshot/diff/browser_helpers.rb
99
104
  - lib/capybara/screenshot/diff/comparison.rb
100
105
  - lib/capybara/screenshot/diff/cucumber.rb
101
106
  - lib/capybara/screenshot/diff/difference.rb
102
- - lib/capybara/screenshot/diff/difference_finder.rb
103
107
  - lib/capybara/screenshot/diff/drivers.rb
104
108
  - lib/capybara/screenshot/diff/drivers/base_driver.rb
105
109
  - lib/capybara/screenshot/diff/drivers/chunky_png_driver.rb
@@ -130,6 +134,7 @@ files:
130
134
  - lib/capybara_screenshot_diff/snap.rb
131
135
  - lib/capybara_screenshot_diff/snap_manager.rb
132
136
  - lib/capybara_screenshot_diff/static.rb
137
+ - lib/snap_diff.rb
133
138
  homepage: https://github.com/snap-diff/snap_diff-capybara
134
139
  licenses:
135
140
  - MIT
@@ -149,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
149
154
  - !ruby/object:Gem::Version
150
155
  version: '0'
151
156
  requirements: []
152
- rubygems_version: 4.0.6
157
+ rubygems_version: 4.0.16
153
158
  specification_version: 4
154
159
  summary: Track your GUI changes with diff assertions
155
160
  test_files: []
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "capybara/screenshot/diff/comparison"
4
- require "capybara/screenshot/diff/difference"
5
-
6
- module Capybara
7
- module Screenshot
8
- module Diff
9
- # Analyzes image differences with configurable tolerance levels.
10
- #
11
- # This class implements the core comparison logic for detecting visual differences
12
- # between images while accounting for various tolerances and optimizations.
13
- #
14
- # The comparison process follows these steps:
15
- # 1. Dimension Check (Fastest)
16
- # - Compares image dimensions first for quick rejection
17
- # - Different dimensions always indicate a difference
18
- #
19
- # 2. Pixel Equality Check (Fast)
20
- # - Performs bitwise comparison if dimensions match
21
- # - Returns immediately if images are exactly identical
22
- #
23
- # 3. Tolerant Comparison (Slower)
24
- # - Only runs if quick checks don't determine equality
25
- # - Respects configured tolerances for color and shift differences
26
- # - Can ignore specific regions (skip_area)
27
- # - Considers anti-aliasing and sub-pixel rendering differences
28
- #
29
- # The class is designed to be stateless and thread-safe, with all configuration
30
- # passed in through the constructor.
31
- class DifferenceFinder
32
- TOLERABLE_OPTIONS = [:tolerance, :color_distance_limit, :shift_distance_limit, :area_size_limit].freeze
33
-
34
- attr_reader :driver, :options
35
-
36
- # Creates a new DifferenceFinder instance.
37
- #
38
- # @param driver [Drivers::Base] The image processing driver to use.
39
- # Must implement the driver interface expected by DifferenceFinder.
40
- # @param options [Hash] Configuration options for the comparison:
41
- # @option options [Numeric] :tolerance (0.001) Color tolerance threshold (0.0-1.0).
42
- # @option options [Numeric] :color_distance_limit Maximum allowed color distance.
43
- # @option options [Numeric] :shift_distance_limit Maximum allowed shift distance.
44
- # @option options [Numeric] :area_size_limit Maximum allowed difference area size.
45
- # @option options [Array<Array>] :skip_area Regions to exclude from comparison.
46
- def initialize(driver, options)
47
- @driver = driver
48
- @options = options
49
- @without_tolerable_options = (options.keys & TOLERABLE_OPTIONS).empty?
50
- end
51
-
52
- # Analyzes the comparison and determines if images are different.
53
- #
54
- # @param comparison [Comparison] The comparison object containing images to analyze.
55
- # @param quick_mode [Boolean] When true, performs minimal checks and returns early.
56
- # In quick mode, returns [is_equal, difference] where:
57
- # - is_equal is true if images are considered equal
58
- # - difference is a Difference object or nil
59
- # When false, returns a Difference object directly.
60
- # @return [Array, Difference] Result format depends on quick_mode parameter.
61
- # @raise [ArgumentError] If the comparison object is invalid.
62
- def call(comparison, quick_mode: true)
63
- # Process the comparison and return result
64
-
65
- # Handle dimension differences
66
- unless driver.same_dimension?(comparison)
67
- result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path, {different_dimensions: true})
68
- return quick_mode ? [false, result] : result
69
- end
70
-
71
- # Handle identical pixels
72
- if driver.same_pixels?(comparison)
73
- result = Difference.build_null(comparison, comparison.base_image_path, comparison.new_image_path)
74
- return quick_mode ? [true, result] : result
75
- end
76
-
77
- # Handle early return for non-tolerable options
78
- if quick_mode && without_tolerable_options?
79
- return [false, nil]
80
- end
81
-
82
- # Process difference region
83
- region = driver.find_difference_region(comparison)
84
-
85
- # Only create a proper difference object if we've completed the comparison
86
- quick_mode ? [!region.different?, region] : region
87
- end
88
-
89
- private
90
-
91
- def without_tolerable_options?
92
- @without_tolerable_options
93
- end
94
- end
95
- end
96
- end
97
- end