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 +7 -0
- data/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +85 -0
- data/Rakefile +9 -0
- data/exe/lookalike +39 -0
- data/lib/lookalike/assertions.rb +3 -0
- data/lib/lookalike/config.rb +3 -0
- data/lib/lookalike/input.rb +3 -0
- data/lib/lookalike/minitest.rb +6 -0
- data/lib/lookalike/report.rb +3 -0
- data/lib/lookalike/store.rb +3 -0
- data/lib/lookalike/test_unit.rb +6 -0
- data/lib/lookalike/version.rb +5 -0
- data/lib/lookalike.rb +491 -0
- data/sig/lookalike.rbs +4 -0
- metadata +72 -0
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
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
|
+
[](https://rubygems.org/gems/lookalike)
|
|
4
|
+
[](https://rubygems.org/gems/lookalike)
|
|
5
|
+
[](https://github.com/rbgfx/lookalike/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
7
|
+
[](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
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
|
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
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: []
|