lookalike 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c5325c67c97ddff6cc6a8ae7ddbd63542bff28e25e30dc1d23f298b77a4c1ed3
4
+ data.tar.gz: a795c3d3606217019cc73c87d4cb9c5c5e567d6ee539637d59f0973b38264cc9
5
+ SHA512:
6
+ metadata.gz: 3c815fec0f33f5d7eae4ce2e9f347f82dc0c5beea29ece38dbe3dc3b5b5bb6ad7b3dbd3c4b566c7b68d3b9ac0596b9f224fbca73d4c25725adebe6d66de5a3bc
7
+ data.tar.gz: f04613d9fb04478620459412dccdd05d409d4c4cdb78f584216093ecfda67460bf609e18ea39051d93883e0c564dce13375f980e269d332017a93d1629360d6b
data/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.1.0] - 2026-09-23
6
+
7
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 ydah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # Lookalike
2
+
3
+ [![Gem version](https://badge.fury.io/rb/lookalike.svg)](https://rubygems.org/gems/lookalike)
4
+ [![Downloads](https://img.shields.io/gem/dt/lookalike?label=downloads)](https://rubygems.org/gems/lookalike)
5
+ [![CI](https://github.com/rbgfx/lookalike/actions/workflows/ci.yml/badge.svg)](https://github.com/rbgfx/lookalike/actions/workflows/ci.yml)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D3.1-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![License](https://img.shields.io/badge/license-MIT-750014.svg)](LICENSE.txt)
8
+
9
+ > Golden image comparison for Ruby graphics.
10
+
11
+ Lookalike turns rendered images into visual regression tests. It compares
12
+ Tessel images, file paths, and framebuffer-like objects, then writes actionable
13
+ diffs for failed snapshots.
14
+
15
+ **[Features](#features) · [Installation](#installation) · [Quick start](#quick-start) · [Development](#development)**
16
+
17
+ ## Features
18
+
19
+ - Channel and YIQ perceptual comparison modes.
20
+ - Per-pixel deltas, allowed-pixel limits, bounding boxes, and diff images.
21
+ - Optional antialiasing detection for cleaner UI and text snapshots.
22
+ - RSpec, Minitest, and Test::Unit assertion helpers.
23
+ - Snapshot approval flow for local development and CI.
24
+ - HTML reports with an expected/actual overlay slider.
25
+
26
+ ## Installation
27
+
28
+ Add Lookalike to your Gemfile:
29
+
30
+ ~~~ruby
31
+ gem "lookalike"
32
+ ~~~
33
+
34
+ Then run:
35
+
36
+ ~~~sh
37
+ bundle install
38
+ ~~~
39
+
40
+ Or install the released gem:
41
+
42
+ ~~~sh
43
+ gem install lookalike
44
+ ~~~
45
+
46
+ ## Quick start
47
+
48
+ ~~~ruby
49
+ require "lookalike"
50
+
51
+ actual = Tessel.read("actual.png")
52
+ result = Lookalike.compare("expected.png", actual, mode: :channel, max_delta: 1)
53
+ abort result.inspect unless result.match?
54
+ ~~~
55
+
56
+ For snapshot assertions, include <code>Lookalike::Assertions</code> in the test
57
+ context:
58
+
59
+ ~~~ruby
60
+ include Lookalike::Assertions
61
+
62
+ assert_snapshot(render_scene, "home")
63
+ ~~~
64
+
65
+ Missing snapshots are pending locally. Approve them with:
66
+
67
+ ~~~sh
68
+ LOOKALIKE_UPDATE=1 bundle exec rake
69
+ lookalike approve NAME
70
+ lookalike approve --all
71
+ lookalike report tmp/lookalike
72
+ ~~~
73
+
74
+ Set <code>CI</code> to make missing snapshots fail.
75
+
76
+ ## Development
77
+
78
+ ~~~sh
79
+ bundle install
80
+ bundle exec rake verify
81
+ ~~~
82
+
83
+ ## License
84
+
85
+ [MIT](LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec) { |task| task.ruby_opts = ["-I../tessel/lib"] }
7
+
8
+ task default: :spec
9
+ task verify: :spec
data/exe/lookalike ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "lookalike"
6
+
7
+ command = ARGV.shift
8
+ case command
9
+ when "report"
10
+ puts Lookalike::Report.new(ARGV.shift || Lookalike.config.output_dir).write
11
+ when "diff"
12
+ expected, actual = ARGV.shift(2)
13
+ output = nil
14
+ OptionParser.new { |opts| opts.on("-o PATH") { |path| output = path } }.parse!(ARGV)
15
+ result = Lookalike.compare(expected, actual, mode: :channel)
16
+ result.diff_image&.write(output) if output
17
+ puts "#{result.diff_pixels} differing pixels"
18
+ when "approve"
19
+ name = ARGV.shift
20
+ store = Lookalike::Store.new
21
+ names = if name == "--all"
22
+ Dir[File.join(Lookalike.config.output_dir, "**", "*.actual.png")].map do |file|
23
+ file.delete_prefix("#{Lookalike.config.output_dir}/").delete_suffix(".actual.png")
24
+ end
25
+ else
26
+ [name]
27
+ end
28
+ names.each do |entry|
29
+ source = store.output_path(entry, "actual")
30
+ destination = store.path(entry)
31
+ raise ArgumentError, "actual image is missing: #{source}" unless File.file?(source)
32
+ FileUtils.mkdir_p(File.dirname(destination))
33
+ FileUtils.cp(source, destination)
34
+ puts destination
35
+ end
36
+ else
37
+ warn "usage: lookalike report [DIR] | diff EXPECTED ACTUAL [-o PATH] | approve NAME|--all"
38
+ exit 1
39
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lookalike"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lookalike"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lookalike"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "minitest"
4
+ require_relative "../lookalike"
5
+
6
+ Lookalike::Minitest.include(Lookalike::Assertions)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lookalike"
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../lookalike"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test/unit"
4
+ require_relative "../lookalike"
5
+
6
+ Lookalike::TestUnit.include(Lookalike::Assertions)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lookalike
4
+ VERSION = "0.1.0"
5
+ end
data/lib/lookalike.rb ADDED
@@ -0,0 +1,491 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "erb"
5
+ require "fileutils"
6
+ require "tessel"
7
+
8
+ require_relative "lookalike/version"
9
+
10
+ module Lookalike
11
+ class Error < StandardError; end
12
+ class Mismatch < Error
13
+ attr_reader :name, :result, :expected_path, :actual_path, :diff_path
14
+
15
+ def initialize(name, result:, expected_path: nil, actual_path: nil, diff_path: nil)
16
+ @name = name
17
+ @result = result
18
+ @expected_path = expected_path
19
+ @actual_path = actual_path
20
+ @diff_path = diff_path
21
+ super(format_message)
22
+ end
23
+
24
+ def format_message
25
+ "Snapshot mismatch: #{@name}\n" \
26
+ " diff pixels : #{@result.diff_pixels}\n" \
27
+ " max delta : #{@result.max_delta}\n" \
28
+ " diff bbox : #{@result.bounding_box.inspect}\n" \
29
+ " expected : #{@expected_path}\n" \
30
+ " actual : #{@actual_path}\n" \
31
+ " diff : #{@diff_path}"
32
+ end
33
+ end
34
+ class Pending < Error; end
35
+
36
+ class Result
37
+ attr_reader :expected, :actual, :diff_pixels, :max_delta, :bounding_box, :diff_image, :mode
38
+
39
+ def initialize(expected:, actual:, diff_pixels:, max_delta:, bounding_box:, diff_image:, mode:, matched:)
40
+ @expected = expected
41
+ @actual = actual
42
+ @diff_pixels = diff_pixels
43
+ @max_delta = max_delta
44
+ @bounding_box = bounding_box
45
+ @diff_image = diff_image
46
+ @mode = mode
47
+ @matched = matched
48
+ end
49
+
50
+ def match?
51
+ @matched
52
+ end
53
+
54
+ def to_h
55
+ { "match" => match?, "diff_pixels" => diff_pixels, "max_delta" => max_delta,
56
+ "bounding_box" => bounding_box, "mode" => mode.to_s }
57
+ end
58
+ end
59
+
60
+ class Config
61
+ attr_accessor :snapshot_dir, :output_dir, :default_mode, :threshold, :max_diff_pixels
62
+ attr_reader :variants
63
+
64
+ def initialize
65
+ @snapshot_dir = "test/snapshots"
66
+ @output_dir = "tmp/lookalike"
67
+ @default_mode = :perceptual
68
+ @threshold = 0.1
69
+ @max_diff_pixels = 0
70
+ @variants = {}
71
+ end
72
+
73
+ def variant(name, **options)
74
+ @variants[name.to_sym] = options
75
+ end
76
+
77
+ def options_for(name)
78
+ @variants[name.to_sym] || {}
79
+ end
80
+ end
81
+
82
+ module Input
83
+ module_function
84
+
85
+ def normalize(value)
86
+ return value if value.is_a?(Tessel::Image)
87
+ if value.respond_to?(:width) && value.respond_to?(:height) && value.respond_to?(:to_rgba_bytes)
88
+ return Tessel::Image.from_rgba(value.width, value.height, value.to_rgba_bytes)
89
+ end
90
+ return Tessel.read(value) if value.respond_to?(:to_path) || value.is_a?(String)
91
+
92
+ raise TypeError, "unsupported image input: #{value.class}"
93
+ end
94
+ end
95
+
96
+ module Metrics
97
+ module Channel
98
+ module_function
99
+
100
+ def compare(expected, actual, max_delta:, allowed_pixels:)
101
+ diff = 0
102
+ maximum = 0
103
+ box = nil
104
+ each_pixel(expected, actual) do |x, y, a, b|
105
+ delta = a.zip(b).map { |left, right| (left - right).abs }.max
106
+ next unless delta > max_delta
107
+
108
+ diff += 1
109
+ maximum = [maximum, delta].max
110
+ box = expand_box(box, x, y)
111
+ end
112
+ [diff <= allowed_pixels, diff, maximum, box]
113
+ end
114
+
115
+ def each_pixel(expected, actual)
116
+ expected_bytes = expected.bytes
117
+ actual_bytes = actual.bytes
118
+ (0...expected.width).each do |x|
119
+ (0...expected.height).each do |y|
120
+ offset = (y * expected.width + x) * 4
121
+ yield x, y, expected_bytes.byteslice(offset, 4).bytes, actual_bytes.byteslice(offset, 4).bytes
122
+ end
123
+ end
124
+ end
125
+ def expand_box(box, x, y)
126
+ return [x, y, 1, 1] unless box
127
+
128
+ x0 = [box[0], x].min
129
+ y0 = [box[1], y].min
130
+ x1 = [box[0] + box[2] - 1, x].max
131
+ y1 = [box[1] + box[3] - 1, y].max
132
+ [x0, y0, x1 - x0 + 1, y1 - y0 + 1]
133
+ end
134
+ private_class_method :expand_box
135
+ end
136
+
137
+ module YIQ
138
+ module_function
139
+
140
+ def compare(expected, actual, threshold:, allowed_pixels:, ignore_antialiasing: false)
141
+ diff = 0
142
+ maximum = 0
143
+ box = nil
144
+ Channel.each_pixel(expected, actual) do |x, y, left, right|
145
+ delta = distance(composite(left), composite(right))
146
+ maximum = [maximum, (delta * 255).round].max
147
+ next unless delta > threshold
148
+ next if ignore_antialiasing && Antialiasing.detect?(expected, x, y, actual)
149
+
150
+ diff += 1
151
+ box = expand_box(box, x, y)
152
+ end
153
+ [diff <= allowed_pixels, diff, maximum, box]
154
+ end
155
+
156
+ def distance(left, right)
157
+ yiq_left = [0.29889531 * left[0] + 0.58662247 * left[1] + 0.11448223 * left[2], 0.59597799 * left[0] - 0.27417610 * left[1] - 0.32180189 * left[2], 0.21147017 * left[0] - 0.52261711 * left[1] + 0.31114694 * left[2]]
158
+ yiq_right = [0.29889531 * right[0] + 0.58662247 * right[1] + 0.11448223 * right[2], 0.59597799 * right[0] - 0.27417610 * right[1] - 0.32180189 * right[2], 0.21147017 * right[0] - 0.52261711 * right[1] + 0.31114694 * right[2]]
159
+ Math.sqrt(0.5053 * (yiq_left[0] - yiq_right[0])**2 + 0.299 * (yiq_left[1] - yiq_right[1])**2 + 0.1957 * (yiq_left[2] - yiq_right[2])**2)
160
+ end
161
+ private_class_method :distance
162
+
163
+ def composite(pixel)
164
+ alpha = pixel[3] / 255.0
165
+ pixel.first(3).map { |channel| (channel * alpha + 255 * (1 - alpha)) / 255.0 }
166
+ end
167
+ private_class_method :composite
168
+
169
+ def expand_box(box, x, y)
170
+ return [x, y, 1, 1] unless box
171
+
172
+ x0 = [box[0], x].min
173
+ y0 = [box[1], y].min
174
+ x1 = [box[0] + box[2] - 1, x].max
175
+ y1 = [box[1] + box[3] - 1, y].max
176
+ [x0, y0, x1 - x0 + 1, y1 - y0 + 1]
177
+ end
178
+ private_class_method :expand_box
179
+ end
180
+ end
181
+
182
+ module Antialiasing
183
+ module_function
184
+
185
+ def detect?(image, x, y, other)
186
+ intermediate_edge?(image, x, y) && edge?(other, x, y) ||
187
+ intermediate_edge?(other, x, y) && edge?(image, x, y)
188
+ end
189
+
190
+ def edge?(image, x, y)
191
+ values = neighborhood(image, x, y)
192
+ values.max - values.min > 0.1
193
+ end
194
+
195
+ def intermediate_edge?(image, x, y)
196
+ values = neighborhood(image, x, y)
197
+ center = luminance(image[x, y])
198
+ values.max - values.min > 0.1 && center > values.min + 0.02 && center < values.max - 0.02
199
+ end
200
+
201
+ def neighborhood(image, x, y)
202
+ (-1..1).flat_map do |dy|
203
+ (-1..1).filter_map { |dx| pixel = image[x + dx, y + dy]; luminance(pixel) if pixel }
204
+ end
205
+ end
206
+
207
+ def luminance(pixel)
208
+ alpha = pixel[3] / 255.0
209
+ rgb = pixel.first(3).map { |channel| (channel * alpha + 255 * (1 - alpha)) / 255.0 }
210
+ 0.29889531 * rgb[0] + 0.58662247 * rgb[1] + 0.11448223 * rgb[2]
211
+ end
212
+ private_class_method :edge?, :intermediate_edge?, :neighborhood, :luminance
213
+ end
214
+
215
+ module DiffRenderer
216
+ module_function
217
+
218
+ def render(expected, actual, mode: :channel, threshold: 0.1, max_delta: 0, ignore_antialiasing: false)
219
+ result = ImageBuilder.new(expected.width, expected.height)
220
+ (0...expected.height).each do |y|
221
+ (0...expected.width).each do |x|
222
+ left = expected[x, y]
223
+ right = actual[x, y]
224
+ different = if mode == :channel
225
+ left.zip(right).map { |a, b| (a - b).abs }.max > max_delta
226
+ else
227
+ Metrics::YIQ.send(:distance, Metrics::YIQ.send(:composite, left), Metrics::YIQ.send(:composite, right)) > threshold
228
+ end
229
+ antialias = different && ignore_antialiasing && mode.to_sym == :perceptual && Antialiasing.detect?(expected, x, y, actual)
230
+ result[x, y] = if antialias
231
+ [255, 220, 0, 255]
232
+ elsif different
233
+ [255, left[1] / 2, 0, 255]
234
+ else
235
+ [left[0] / 3, left[1] / 3, left[2] / 3, 255]
236
+ end
237
+ end
238
+ end
239
+ result
240
+ end
241
+
242
+ class ImageBuilder < Tessel::Image; end
243
+ end
244
+
245
+ module_function
246
+
247
+ def config
248
+ @config ||= Config.new
249
+ end
250
+
251
+ def configure
252
+ yield config
253
+ end
254
+
255
+ def compare(expected, actual, mode: config.default_mode, max_delta: 0, allowed_pixels: config.max_diff_pixels, max_diff_pixels: nil, threshold: config.threshold, ignore_antialiasing: false)
256
+ mode = mode.to_sym
257
+ raise ArgumentError, "unknown comparison mode: #{mode}" unless %i[channel perceptual].include?(mode)
258
+ allowed_pixels = max_diff_pixels unless max_diff_pixels.nil?
259
+ allowed_pixels = Integer(allowed_pixels)
260
+ raise ArgumentError, "allowed pixels must not be negative" if allowed_pixels.negative?
261
+ max_delta = Integer(max_delta)
262
+ raise ArgumentError, "max delta must be between 0 and 255" unless max_delta.between?(0, 255)
263
+ threshold = Float(threshold)
264
+ raise ArgumentError, "threshold must not be negative" if threshold.negative?
265
+ expected = Input.normalize(expected)
266
+ actual = Input.normalize(actual)
267
+ unless expected.width == actual.width && expected.height == actual.height
268
+ width = [expected.width, actual.width].max
269
+ height = [expected.height, actual.height].max
270
+ padded_expected = Tessel::Image.new(width, height)
271
+ padded_actual = Tessel::Image.new(width, height)
272
+ padded_expected.blit(expected, 0, 0, blend: :copy)
273
+ padded_actual.blit(actual, 0, 0, blend: :copy)
274
+ _matched, diff, maximum, box = if mode == :channel
275
+ Metrics::Channel.compare(padded_expected, padded_actual, max_delta: max_delta, allowed_pixels: allowed_pixels)
276
+ else
277
+ Metrics::YIQ.compare(padded_expected, padded_actual, threshold: threshold, allowed_pixels: allowed_pixels, ignore_antialiasing: ignore_antialiasing)
278
+ end
279
+ dimension_box = nil
280
+ dimension_diff = 0
281
+ (0...height).each do |y|
282
+ (0...width).each do |x|
283
+ next if x < expected.width && y < expected.height && x < actual.width && y < actual.height
284
+
285
+ dimension_diff += 1
286
+ if dimension_box
287
+ x0 = [dimension_box[0], x].min
288
+ y0 = [dimension_box[1], y].min
289
+ x1 = [dimension_box[0] + dimension_box[2] - 1, x].max
290
+ y1 = [dimension_box[1] + dimension_box[3] - 1, y].max
291
+ dimension_box = [x0, y0, x1 - x0 + 1, y1 - y0 + 1]
292
+ else
293
+ dimension_box = [x, y, 1, 1]
294
+ end
295
+ end
296
+ end
297
+ if dimension_diff.positive?
298
+ diff += dimension_diff
299
+ maximum = [maximum, 255].max
300
+ if box
301
+ x0 = [box[0], dimension_box[0]].min
302
+ y0 = [box[1], dimension_box[1]].min
303
+ x1 = [box[0] + box[2] - 1, dimension_box[0] + dimension_box[2] - 1].max
304
+ y1 = [box[1] + box[3] - 1, dimension_box[1] + dimension_box[3] - 1].max
305
+ box = [x0, y0, x1 - x0 + 1, y1 - y0 + 1]
306
+ else
307
+ box = dimension_box
308
+ end
309
+ end
310
+ diff_image = DiffRenderer.render(padded_expected, padded_actual, mode: mode, threshold: threshold, max_delta: max_delta)
311
+ return Result.new(expected: expected, actual: actual, diff_pixels: diff, max_delta: maximum, bounding_box: box, diff_image: diff_image, mode: mode, matched: false)
312
+ end
313
+ return Result.new(expected: expected, actual: actual, diff_pixels: 0, max_delta: 0, bounding_box: nil, diff_image: nil, mode: mode, matched: true) if expected.bytes == actual.bytes
314
+
315
+ matched, diff, maximum, box = if mode == :channel
316
+ Metrics::Channel.compare(expected, actual, max_delta: max_delta, allowed_pixels: allowed_pixels)
317
+ elsif mode.to_sym == :perceptual
318
+ Metrics::YIQ.compare(expected, actual, threshold: threshold, allowed_pixels: allowed_pixels, ignore_antialiasing: ignore_antialiasing)
319
+ else
320
+ Metrics::YIQ.compare(expected, actual, threshold: threshold, allowed_pixels: allowed_pixels, ignore_antialiasing: ignore_antialiasing)
321
+ end
322
+ Result.new(expected: expected, actual: actual, diff_pixels: diff, max_delta: maximum, bounding_box: box, diff_image: DiffRenderer.render(expected, actual, mode: mode, threshold: threshold, max_delta: max_delta, ignore_antialiasing: ignore_antialiasing), mode: mode, matched: matched)
323
+ end
324
+
325
+ def assert_snapshot(actual, name, **options)
326
+ actual = Input.normalize(actual)
327
+ variant = options.delete(:variant)
328
+ variant_options = config.options_for(variant || :default)
329
+ options = variant_options.merge(options)
330
+ store = Store.new(config)
331
+ expected_path = store.path(name, variant: variant)
332
+ actual_path = store.output_path(name, "actual", variant: variant)
333
+ diff_path = store.output_path(name, "diff", variant: variant)
334
+ FileUtils.mkdir_p(File.dirname(actual_path))
335
+ actual.write(actual_path)
336
+ unless File.file?(expected_path)
337
+ if update?(name)
338
+ FileUtils.mkdir_p(File.dirname(expected_path))
339
+ actual.write(expected_path)
340
+ result = compare(actual, actual, **options)
341
+ record_result(name, result, status: "updated", variant: variant, expected_path: expected_path, actual_path: actual_path)
342
+ return result
343
+ end
344
+ unless ENV.key?("CI")
345
+ FileUtils.mkdir_p(File.dirname(expected_path))
346
+ actual.write(expected_path)
347
+ record_result(name, compare(actual, actual, **options), status: "pending", variant: variant, expected_path: expected_path, actual_path: actual_path)
348
+ raise Pending, "created new snapshot: #{expected_path}"
349
+ end
350
+
351
+ result = compare(actual, Tessel::Image.new(actual.width, actual.height))
352
+ record_result(name, result, status: "failed", variant: variant, expected_path: expected_path, actual_path: actual_path, diff_path: diff_path)
353
+ raise Mismatch.new(name, result: result, expected_path: expected_path, actual_path: actual_path, diff_path: diff_path)
354
+ end
355
+
356
+ result = compare(expected_path, actual, **options)
357
+ if result.match?
358
+ record_result(name, result, status: "passed", variant: variant, expected_path: expected_path, actual_path: actual_path)
359
+ return result
360
+ end
361
+ result.diff_image&.write(diff_path)
362
+ if update?(name)
363
+ actual.write(expected_path)
364
+ record_result(name, result, status: "updated", variant: variant, expected_path: expected_path, actual_path: actual_path, diff_path: diff_path)
365
+ return compare(actual, actual, **options)
366
+ end
367
+ FileUtils.cp(expected_path, store.output_path(name, "expected", variant: variant))
368
+ record_result(name, result, status: "failed", variant: variant, expected_path: expected_path, actual_path: actual_path, diff_path: diff_path)
369
+ raise Mismatch.new(name, result: result, expected_path: expected_path, actual_path: actual_path, diff_path: diff_path)
370
+ end
371
+
372
+ def record_result(name, result, status:, variant: nil, expected_path:, actual_path:, diff_path: nil)
373
+ relative = Store.new(config).send(:safe_name, name).tr("/", "__")
374
+ relative = "#{relative}@#{variant}" if variant
375
+ directory = File.join(config.output_dir, "results")
376
+ FileUtils.mkdir_p(directory)
377
+ payload = result.to_h.merge("name" => name.to_s, "variant" => variant&.to_s, "status" => status,
378
+ "expected" => expected_path, "actual" => actual_path, "diff" => diff_path)
379
+ File.write(File.join(directory, "#{relative}.json"), JSON.generate(payload))
380
+ end
381
+ private_class_method :record_result
382
+
383
+ def update?(name)
384
+ value = ENV["LOOKALIKE_UPDATE"]
385
+ return false unless value
386
+ return true if value == "1" || value == "true"
387
+
388
+ value.split(",").any? { |pattern| File.fnmatch?(pattern, name) }
389
+ end
390
+ private_class_method :update?
391
+
392
+ class Store
393
+ def initialize(config = Lookalike.config)
394
+ @config = config
395
+ end
396
+
397
+ def path(name, variant: nil)
398
+ relative = safe_name(name)
399
+ candidate = variant ? File.join(@config.snapshot_dir, "#{relative}@#{safe_variant(variant)}.png") : nil
400
+ candidate && File.file?(candidate) ? candidate : File.join(@config.snapshot_dir, "#{relative}.png")
401
+ end
402
+
403
+ def output_path(name, suffix, variant: nil)
404
+ raise ArgumentError, "invalid output suffix" unless %w[actual expected diff].include?(suffix)
405
+ File.join(@config.output_dir, "#{safe_name(name)}#{variant ? "@#{safe_variant(variant)}" : ""}.#{suffix}.png")
406
+ end
407
+
408
+ private
409
+
410
+ def safe_name(name)
411
+ name = String(name)
412
+ raise ArgumentError, "snapshot name must be relative" if name.empty? || name.start_with?("/", "\\") || name.include?("\\") || name.include?("\0") || name.split("/").include?("..") || name.match?(/\A[A-Za-z]:/)
413
+
414
+ name
415
+ end
416
+
417
+ def safe_variant(variant)
418
+ value = variant.to_s
419
+ raise ArgumentError, "invalid snapshot variant" unless value.match?(/\A[a-zA-Z0-9_-]+\z/)
420
+ value
421
+ end
422
+ end
423
+
424
+ module Assertions
425
+ def assert_snapshot(actual, name, **options)
426
+ Lookalike.assert_snapshot(actual, name, **options)
427
+ rescue Pending => e
428
+ if respond_to?(:pend)
429
+ pend(e.message)
430
+ elsif respond_to?(:skip)
431
+ skip(e.message)
432
+ else
433
+ raise
434
+ end
435
+ rescue Mismatch => e
436
+ respond_to?(:flunk) ? flunk(e.message) : raise
437
+ end
438
+ end
439
+
440
+ module TestUnit
441
+ def assert_snapshot(actual, name, **options)
442
+ super
443
+ rescue Pending => e
444
+ pend(e.message)
445
+ rescue Mismatch => e
446
+ flunk(e.message)
447
+ end
448
+ end
449
+
450
+ module Minitest
451
+ def assert_snapshot(actual, name, **options)
452
+ super
453
+ rescue Pending => e
454
+ skip(e.message)
455
+ rescue Mismatch => e
456
+ flunk(e.message)
457
+ end
458
+ end
459
+
460
+ class Report
461
+ def initialize(output_dir = Lookalike.config.output_dir)
462
+ @output_dir = output_dir
463
+ end
464
+
465
+ def write(entries = Dir[File.join(@output_dir, "**", "*.actual.png")])
466
+ path = File.join(@output_dir, "index.html")
467
+ FileUtils.mkdir_p(@output_dir)
468
+ body = entries.map do |actual|
469
+ base = actual.delete_suffix(".actual.png")
470
+ expected = "#{base}.expected.png"
471
+ diff = "#{base}.diff.png"
472
+ relative = ->(file) { ERB::Util.html_escape(file.delete_prefix("#{@output_dir}/")) }
473
+ "<section><h2>#{ERB::Util.html_escape(File.basename(base))}</h2>" \
474
+ "<div class='compare'><img src='#{relative.call(expected)}' alt='expected'>" \
475
+ "<img class='actual' src='#{relative.call(actual)}' alt='actual'></div>" \
476
+ "<input type='range' min='0' max='100' value='50' aria-label='actual image coverage'>" \
477
+ "<img src='#{relative.call(diff)}' alt='difference'></section>"
478
+ end.join
479
+ File.write(path, "<!doctype html><meta charset='utf-8'><title>Lookalike</title>" \
480
+ "<style>body{font:14px sans-serif;background:#171923;color:#eee}section{margin:2rem 0}.compare{position:relative;display:inline-block}.compare img{display:block;max-width:100%}.compare .actual{position:absolute;inset:0;clip-path:inset(0 50% 0 0)}</style>" \
481
+ "#{body}<script>document.querySelectorAll('section').forEach(s=>{const range=s.querySelector('input');range.oninput=()=>s.querySelector('.actual').style.clipPath=`inset(0 ${100-range.value}% 0 0)`;});</script>")
482
+ results = Dir[File.join(@output_dir, "results", "*.json")].filter_map do |file|
483
+ JSON.parse(File.read(file))
484
+ rescue JSON::ParserError
485
+ nil
486
+ end
487
+ File.write(File.join(@output_dir, "results.json"), JSON.generate(results))
488
+ path
489
+ end
490
+ end
491
+ end
data/sig/lookalike.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Lookalike
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lookalike
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ydah
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: tessel
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 0.1.0
26
+ description: Channel and perceptual visual regression testing with PNG diffs.
27
+ email:
28
+ - t.yudai92@gmail.com
29
+ executables:
30
+ - lookalike
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - CHANGELOG.md
35
+ - LICENSE.txt
36
+ - README.md
37
+ - Rakefile
38
+ - exe/lookalike
39
+ - lib/lookalike.rb
40
+ - lib/lookalike/assertions.rb
41
+ - lib/lookalike/config.rb
42
+ - lib/lookalike/input.rb
43
+ - lib/lookalike/minitest.rb
44
+ - lib/lookalike/report.rb
45
+ - lib/lookalike/store.rb
46
+ - lib/lookalike/test_unit.rb
47
+ - lib/lookalike/version.rb
48
+ - sig/lookalike.rbs
49
+ homepage: https://github.com/rbgfx/lookalike
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/rbgfx/lookalike
54
+ source_code_uri: https://github.com/rbgfx/lookalike/tree/main
55
+ rdoc_options: []
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: 3.1.0
63
+ required_rubygems_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ requirements: []
69
+ rubygems_version: 4.0.16
70
+ specification_version: 4
71
+ summary: Golden image comparison for Ruby graphics
72
+ test_files: []