syrma 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.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ module ScriptLauncher
5
+ Launch = Struct.new(:fiber, :status, keyword_init: true)
6
+
7
+ module WindowRun
8
+ def run = Fiber.yield(self)
9
+ end
10
+
11
+ module AppRun
12
+ def run
13
+ return super unless Thread.current[:syrma_script_launch]
14
+
15
+ Fiber.yield(self)
16
+ end
17
+ end
18
+
19
+ module_function
20
+
21
+ def launch(path, argv: [], backend: :headless, **options)
22
+ install!
23
+ Instrumentation.install!
24
+ opened = []
25
+ launch = Launch.new
26
+ launch.fiber = Fiber.new do
27
+ original_argv = ARGV.dup
28
+ Thread.current[:syrma_script_launch] = true
29
+ ARGV.replace(argv)
30
+ Instrumentation.capture_windows(backend: backend, on_open: lambda { |window|
31
+ opened << window
32
+ window.extend(WindowRun)
33
+ }) { load(File.expand_path(path)) }
34
+ launch.status = 0
35
+ rescue SystemExit => error
36
+ launch.status = error.status
37
+ ensure
38
+ ARGV.replace(original_argv)
39
+ Thread.current[:syrma_script_launch] = nil
40
+ end
41
+ launch.fiber.resume
42
+ raise Error, "Script did not open a window: #{path}" if opened.empty?
43
+
44
+ session = Session.new(**options, backend: backend, attached_windows: opened, owns_windows: true)
45
+ session.launcher = launch
46
+ session
47
+ end
48
+
49
+ def finish(launch)
50
+ return unless launch&.fiber&.alive?
51
+
52
+ launch.fiber.resume
53
+ end
54
+
55
+ def install!
56
+ return if @installed
57
+
58
+ Zaniah::App.prepend(AppRun)
59
+ @installed = true
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Syrma
6
+ class Session
7
+ attr_reader :raster, :strict, :timeout, :event_log, :app, :event_frames, :font_paths, :text_mode, :windows, :drivers
8
+
9
+ class << self
10
+ def attach(window, **options) = new(**options, attached_windows: [window], owns_windows: false)
11
+ def launch_script(path, argv: [], **options) = ScriptLauncher.launch(path, argv: argv, **options)
12
+
13
+ def for_app(app, backend: :headless, **options)
14
+ Instrumentation.install!
15
+ windows = Instrumentation.capture_windows(backend: backend) { yield(app) }
16
+ raise Error, "No window was opened inside the block" if windows.empty?
17
+
18
+ new(**options, backend: backend, app: app, attached_windows: windows, owns_windows: true)
19
+ end
20
+ end
21
+
22
+ def initialize(width: 800, height: 600, backend: :headless, text: nil, fonts: nil,
23
+ raster: nil, clock: :virtual, strict: nil, timeout: nil, keymap: nil,
24
+ app: nil, max_frames: 20, event_frames: nil, attached_windows: nil,
25
+ owns_windows: true, **window_options, &mount)
26
+ config = Syrma.configuration
27
+ text ||= config.text
28
+ fonts ||= config.fonts
29
+ strict = config.strict if strict.nil?
30
+ timeout ||= ENV["SYRMA_TIMEOUT"] || config.timeout
31
+ event_frames ||= config.event_frames
32
+ raster ||= ENV["SYRMA_RASTER"] == "eager" ? :eager : :lazy
33
+ validate_options!(backend, event_frames, raster, clock)
34
+ Instrumentation.install!
35
+
36
+ @raster = raster
37
+ @strict = strict
38
+ @app = app
39
+ @max_frames = max_frames
40
+ @clock = clock == :virtual ? Clock.new : (clock == :real ? Zaniah::MONOTONIC_CLOCK : clock)
41
+ @event_frames = event_frames
42
+ @timeout = Float(timeout)
43
+ @event_log = EventLog.new
44
+ @text_mode = text
45
+ @owns_windows = owns_windows
46
+ @backend = backend
47
+ @text = text
48
+ @fonts = fonts
49
+ keymap = VirtualKeymap.new(keymap, @clock) if keymap
50
+ @outputs = {}
51
+ @windows = attached_windows || [open_window(backend, width, height, window_options, keymap)]
52
+ @drivers = @windows.map { |window| prepare(window, backend, text, fonts) }
53
+ @font_paths = text == :deterministic ? [Internals.bundled_font_path, *fonts.map { |font| File.expand_path(font) }] : []
54
+ @current = 0
55
+ mount&.call(window)
56
+ settle
57
+ end
58
+
59
+ def window(title: nil, index: nil)
60
+ return @windows[@current] unless title || index
61
+
62
+ selected = index ? @windows[index] : @windows.find { |candidate| candidate.title == title }
63
+ raise Error, "Window not found: #{title ? "title: #{title.inspect}" : "index: #{index}"}" unless selected
64
+
65
+ @current = @windows.index(selected)
66
+ driver
67
+ end
68
+
69
+ def driver = @drivers[@current]
70
+ def session = self
71
+ def method_missing(name, ...) = driver.respond_to?(name) ? driver.public_send(name, ...) : super
72
+ def respond_to_missing?(name, include_all = false) = driver.respond_to?(name, include_all) || super
73
+
74
+ def settle
75
+ @max_frames.times do
76
+ @app&.executor&.drain
77
+ sync_app_windows
78
+ busy = false
79
+ @windows.each do |candidate|
80
+ next if candidate.closed? || !candidate.dirty?
81
+
82
+ candidate.tick
83
+ busy = true
84
+ end
85
+ return self unless busy || (@app && !Internals.executor_idle?(@app.executor))
86
+ end
87
+ raise UnstableUI, "UI did not stabilize within #{@max_frames} frames (continuous animation?)"
88
+ end
89
+
90
+ def advance(seconds)
91
+ raise Error, "advance is unavailable for this clock" unless @clock.respond_to?(:advance)
92
+
93
+ @clock.advance(seconds)
94
+ @windows.reject(&:closed?).each(&:tick)
95
+ settle
96
+ end
97
+
98
+ def wait_for(message = "Condition was not met", timeout: @timeout)
99
+ deadline = Clock.real_now + timeout
100
+ loop do
101
+ settle
102
+ result = yield
103
+ return result if result
104
+
105
+ if Clock.real_now >= deadline
106
+ detail = message.respond_to?(:call) ? message.call : message
107
+ raise WaitTimeout, "Timed out after #{timeout} seconds: #{detail}"
108
+ end
109
+ sleep 0.01
110
+ end
111
+ end
112
+
113
+ def screenshot_pixels(target = driver)
114
+ settle
115
+ clear = target.window.testing_clear || Zaniah::Platform::Headless::Window::DEFAULT_CLEAR
116
+ target.window.device.render(target.window.scene, clear: clear)
117
+ end
118
+
119
+ def tui?(target = driver) = @outputs.key?(target.window)
120
+ def output_for(target = driver) = @outputs.fetch(target.window)
121
+ def exit_status = @launcher&.status
122
+
123
+ def launcher=(launcher)
124
+ @launcher = launcher
125
+ end
126
+
127
+ def close
128
+ closed = !@owns_windows || @windows.map { |candidate| candidate.closed? || candidate.close }.all?
129
+ ScriptLauncher.finish(@launcher) if @launcher
130
+ closed
131
+ end
132
+
133
+ private
134
+
135
+ def validate_options!(backend, event_frames, raster, clock)
136
+ raise ArgumentError, "backend must be :headless or :tui" unless %i[headless tui].include?(backend)
137
+ raise ArgumentError, "event_frames must be :each or :gesture" unless %i[each gesture].include?(event_frames)
138
+ raise ArgumentError, "raster must be :lazy or :eager" unless %i[lazy eager].include?(raster)
139
+ raise ArgumentError, "clock must be :virtual, :real, or callable" unless %i[virtual real].include?(clock) || clock.respond_to?(:call)
140
+ end
141
+
142
+ def open_window(backend, width, height, options, keymap)
143
+ options = options.merge(clock: @clock)
144
+ options[:keymap] = keymap if keymap
145
+ if backend == :tui
146
+ output = options[:output] || StringIO.new
147
+ window = Zaniah::Platform.open_window(backend: backend, width: width, height: height,
148
+ input: options[:input] || StringIO.new, output: output,
149
+ **options.except(:input, :output))
150
+ @outputs[window] = output
151
+ window
152
+ else
153
+ Zaniah::Platform.open_window(backend: backend, width: width, height: height, **options)
154
+ end
155
+ end
156
+
157
+ def prepare(candidate, backend, text, fonts)
158
+ actual_backend = candidate.is_a?(Zaniah::Platform::TUI::Window) ? :tui : backend
159
+ candidate.text_system = TextSystems.build(text, fonts: fonts) unless actual_backend == :tui
160
+ WindowDriver.new(candidate, self)
161
+ end
162
+
163
+ def sync_app_windows
164
+ return unless @app
165
+
166
+ (@app.windows - @windows).each do |candidate|
167
+ @windows << candidate
168
+ @drivers << prepare(candidate, @backend, @text, @fonts)
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ Node = Data.define(:path, :element, :type, :test_id, :key, :text, :color, :font_size,
5
+ :bounds, :visible_bounds, :background, :border_color, :radius,
6
+ :handlers, :tooltip, :children) do
7
+ def visible? = visible_bounds.width.positive? && visible_bounds.height.positive?
8
+ def clickable? = handlers.include?(:click) || handlers.include?(:mouse_down)
9
+ def center = Zaniah::Point.new(visible_bounds.x + visible_bounds.width / 2.0,
10
+ visible_bounds.y + visible_bounds.height / 2.0)
11
+ def descendants = children.flat_map { |child| [child, *child.descendants] }
12
+ def content_text = [text, *descendants.map(&:text)].compact.join(" ")
13
+ def ancestor_of?(other) = other.path.length > path.length && other.path.first(path.length) == path
14
+ def inspect = "#<#{type}#{test_id && " @#{test_id}"}#{text && " #{text.inspect}"} #{path.join('.')}>"
15
+ end
16
+
17
+ Tree = Data.define(:frame, :size, :root, :nodes, :hits, :text_runs, :menu, :menu_index, :tooltip) do
18
+ def hit_at(point, event: :mouse_down, button: :left)
19
+ hits.reverse_each do |bounds, node|
20
+ next unless bounds.contains?(point)
21
+
22
+ return node if node.nil? || Tree.handles?(node, event, button)
23
+ end
24
+ nil
25
+ end
26
+
27
+ def self.handles?(node, event, button)
28
+ case event
29
+ when :mouse_down
30
+ (button == :right && Internals.context_menu(node.element)) ||
31
+ (node.handlers & %i[mouse_down click drag]).any?
32
+ when :mouse_move then node.handlers.include?(:hover) || node.tooltip
33
+ when :mouse_up then node.handlers.include?(:mouse_up)
34
+ when :scroll then node.handlers.include?(:scroll_wheel)
35
+ end
36
+ end
37
+ end
38
+
39
+ class SnapshotBuilder
40
+ def build(window)
41
+ by_element = {}.compare_by_identity
42
+ nodes = []
43
+ viewport = Zaniah::Bounds.new(0, 0, window.content_size.width, window.content_size.height)
44
+ root = window.testing_root && visit(window.testing_root, viewport, [0], nodes, by_element)
45
+ hits = window.dispatcher.hits.map { |hit| [hit.bounds, hit.owner && by_element[hit.owner]] }
46
+ popup = window.popup
47
+ Tree.new(frame: window.testing_frame, size: window.content_size, root: root,
48
+ nodes: nodes.freeze, hits: hits.freeze, text_runs: window.text_runs.map(&:dup).freeze,
49
+ menu: popup&.labels, menu_index: popup&.selected_index,
50
+ tooltip: Internals.shown_tooltip(window))
51
+ end
52
+
53
+ private
54
+
55
+ def visit(element, clip, path, nodes, by_element)
56
+ bounds = element.layout_node&.bounds or return nil
57
+ style = Internals.style(element)
58
+ return nil if style[:display] == :none
59
+
60
+ visible = bounds.intersect(clip)
61
+ child_clip = style[:overflow] == :visible ? clip : visible
62
+ index = nodes.length
63
+ nodes << nil
64
+ children = element.children.each_with_index.filter_map do |child, child_index|
65
+ visit(child, child_clip, path + [child_index], nodes, by_element)
66
+ end
67
+ node = Node.new(
68
+ path: path.freeze, element: element, type: element.class.name.split("::").last.downcase.to_sym,
69
+ test_id: element.test_id, key: Internals.key(element),
70
+ text: text_attr(element, :text), color: text_attr(element, :text_color),
71
+ font_size: text_attr(element, :font_size), bounds: bounds, visible_bounds: visible,
72
+ background: Internals.background(element), border_color: Internals.border_color(element),
73
+ radius: Internals.radius(element), handlers: element.handlers,
74
+ tooltip: Internals.tooltip(element), children: children.freeze
75
+ )
76
+ nodes[index] = node
77
+ by_element[element] = node
78
+ end
79
+
80
+ def text_attr(element, name) = element.is_a?(Zaniah::Text) ? element.public_send(name) : nil
81
+ end
82
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+
7
+ module Syrma
8
+ module Snapshots
9
+ class Store
10
+ NAME = /\A[A-Za-z0-9_-]+\z/
11
+
12
+ attr_reader :root
13
+
14
+ def initialize(root: nil, env: ENV)
15
+ @root = File.expand_path(root || env["SYRMA_SNAPSHOT_DIR"] || Syrma.configuration.snapshot_dir)
16
+ @env = env
17
+ end
18
+
19
+ def update? = @env["SYRMA_UPDATE_SNAPSHOTS"] == "1"
20
+ def ci? = !@env["CI"].to_s.empty?
21
+
22
+ def path(test_file:, test_class:, name:, ext:)
23
+ unless name.to_s.match?(NAME)
24
+ raise ArgumentError, "Snapshot names may contain only letters, digits, hyphens, and underscores: #{name.inspect}"
25
+ end
26
+
27
+ dir = File.join(root, File.basename(test_file.to_s, ".rb"), test_class.to_s.gsub(/[^A-Za-z0-9_-]/, "_"))
28
+ File.join(dir, "#{name}.#{ext}").tap do |full|
29
+ raise ArgumentError, "Snapshot path is outside the configured directory" unless full.start_with?(root + File::SEPARATOR)
30
+
31
+ record(full)
32
+ end
33
+ end
34
+
35
+ def write(path, bytes)
36
+ FileUtils.mkdir_p(File.dirname(path))
37
+ File.binwrite(path, bytes)
38
+ end
39
+
40
+ def usage_path
41
+ File.join(ENV["SYRMA_ARTIFACTS"] || Syrma.configuration.artifacts_dir, "used-snapshots.txt")
42
+ end
43
+
44
+ def reset_usage!(full: false)
45
+ FileUtils.mkdir_p(File.dirname(usage_path))
46
+ File.write(usage_path, full ? "# full\n" : "# partial\n", encoding: "UTF-8")
47
+ end
48
+
49
+ def unused
50
+ return [] unless File.exist?(usage_path)
51
+
52
+ lines = File.readlines(usage_path, chomp: true, encoding: "UTF-8")
53
+ raise Error, "Cannot prune snapshots after a partial test run" unless lines.first == "# full"
54
+
55
+ used = lines.drop(1).to_h { |path| [File.expand_path(path), true] }
56
+ Dir[File.join(root, "**", "*")].select { |path| File.file?(path) && !used[File.expand_path(path)] }
57
+ end
58
+
59
+ private
60
+
61
+ def record(path)
62
+ FileUtils.mkdir_p(File.dirname(usage_path))
63
+ paths = [path]
64
+ paths << path.sub(/\.png\z/, ".meta.json") if path.end_with?(".png")
65
+ File.open(usage_path, "a", encoding: "UTF-8") { |file| paths.each { |used| file.puts(used) } }
66
+ end
67
+ end
68
+ end
69
+
70
+ module Visual
71
+ module_function
72
+
73
+ def meta(session)
74
+ window = session.window
75
+ text = window.text_system
76
+ {
77
+ zaniah: Zaniah::VERSION,
78
+ size: [window.content_size.width, window.content_size.height],
79
+ scale_factor: window.scale_factor,
80
+ text: text&.class&.name,
81
+ fonts: session.font_paths.map { |path| Digest::SHA256.file(path).hexdigest[0, 16] }
82
+ }
83
+ end
84
+
85
+ def check_screenshot(session, store:, path:, threshold: 2, max_diff_pixels: 0,
86
+ max_diff_ratio: nil, region: nil, mask: [], artifacts: nil)
87
+ validate_limits!(max_diff_pixels, max_diff_ratio)
88
+ width = session.window.content_size.width.to_i
89
+ height = session.window.content_size.height.to_i
90
+ actual = session.screenshot_pixels.dup
91
+ Array(mask).each { |target| Image.mask!(actual, width, height, bounds_for(target)) }
92
+ width, height, actual = Image.crop(actual, width, height, bounds_for(region)) if region
93
+ png = Zaniah::PNG.encode(width, height, actual)
94
+ meta_path = path.sub(/\.png\z/, ".meta.json")
95
+
96
+ unless File.exist?(path)
97
+ if store.ci? && !store.update?
98
+ raise SnapshotMissing, "Snapshot not found: #{path} (set SYRMA_UPDATE_SNAPSHOTS=1 to create it)"
99
+ end
100
+ write_golden(store, path, meta_path, png, session)
101
+ return :created
102
+ end
103
+
104
+ expected_width, expected_height, expected = Zaniah::PNG.decode(File.binread(path))
105
+ result = if [expected_width, expected_height] == [width, height]
106
+ Comparator.new(threshold: threshold).compare(width, height, expected, actual)
107
+ end
108
+ valid = result && result.diff_pixels <= max_diff_pixels &&
109
+ (max_diff_ratio.nil? || result.ratio <= max_diff_ratio)
110
+ return :matched if valid
111
+
112
+ if store.update?
113
+ write_golden(store, path, meta_path, png, session)
114
+ return :updated
115
+ end
116
+
117
+ write_diff_artifacts(artifacts, path, png, width, height, result) if artifacts
118
+ old_meta = File.exist?(meta_path) ? JSON.parse(File.read(meta_path, encoding: "UTF-8")) : {}
119
+ font_hint = old_meta["fonts"] && old_meta["fonts"] != meta(session)[:fonts] ? "\n Font configuration differs from the snapshot" : ""
120
+ detail = if result
121
+ "#{result.diff_pixels} differing pixels (#{(result.ratio * 100).round(3)}%)"
122
+ else
123
+ "size mismatch: expected #{expected_width}x#{expected_height}, got #{width}x#{height}"
124
+ end
125
+ raise SnapshotMismatch, "Image snapshot mismatch: #{File.basename(path)} #{detail}#{font_hint}" +
126
+ (artifacts ? "\n Artifacts: #{artifacts}" : "")
127
+ end
128
+
129
+ def bounds_for(target)
130
+ node = target.is_a?(Locator) ? target.resolve : target
131
+ node.respond_to?(:bounds) ? node.bounds : node
132
+ end
133
+
134
+ def validate_limits!(pixels, ratio)
135
+ raise ArgumentError, "max_diff_pixels must be a non-negative integer" unless pixels.is_a?(Integer) && pixels >= 0
136
+ return if ratio.nil? || (ratio.is_a?(Numeric) && ratio.between?(0, 1))
137
+
138
+ raise ArgumentError, "max_diff_ratio must be between 0.0 and 1.0"
139
+ end
140
+
141
+ def write_golden(store, path, meta_path, png, session)
142
+ store.write(path, png)
143
+ store.write(meta_path, JSON.pretty_generate(meta(session)))
144
+ end
145
+
146
+ def write_diff_artifacts(dir, expected_path, actual_png, width, height, result)
147
+ FileUtils.mkdir_p(dir)
148
+ File.binwrite(File.join(dir, "expected.png"), File.binread(expected_path))
149
+ File.binwrite(File.join(dir, "actual.png"), actual_png)
150
+ Zaniah::PNG.write(File.join(dir, "diff.png"), width, height, result.diff_image) if result
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ module TerminalFormat
5
+ ROW = 20
6
+ COL = 8
7
+ ANSI = /\e\[[0-9;?]*[A-Za-z]/
8
+ RGB = /\e\[38;2;(\d+);(\d+);(\d+)m/
9
+
10
+ module_function
11
+
12
+ def lines(output, colors: false)
13
+ frame = output.split("\e[H").last.to_s
14
+ frame = frame.gsub(RGB) { format("{#%02x%02x%02x}", $1.to_i, $2.to_i, $3.to_i) } if colors
15
+ frame.gsub(ANSI, "").split("\r\n").map(&:rstrip)
16
+ end
17
+
18
+ def overlaps(text_runs)
19
+ cells = text_runs.map do |x, y, text, _color|
20
+ column = (x / COL).floor
21
+ [(y / ROW).floor, column, column + Zaniah::Unicode.width(text.to_s), text]
22
+ end
23
+ cells.combination(2).filter_map do |first, second|
24
+ [first[3], second[3], first[0]] if first[0] == second[0] && first[1] < second[2] && second[1] < first[2]
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ module TreeFormat
5
+ DEFAULT = %i[test_id text bounds bg handlers tooltip].freeze
6
+
7
+ module_function
8
+
9
+ def dump(node, attributes: DEFAULT, depth: 0, out: +"")
10
+ parts = [node.type.to_s]
11
+ parts << "@#{node.test_id}" if attributes.include?(:test_id) && node.test_id
12
+ parts << node.text.inspect if attributes.include?(:text) && node.text
13
+ if attributes.include?(:bounds)
14
+ bounds = node.bounds
15
+ parts << "[#{num(bounds.x)},#{num(bounds.y)} #{num(bounds.width)}x#{num(bounds.height)}]"
16
+ end
17
+ parts << "bg=#{node.background}" if attributes.include?(:bg) && node.background && node.background != "#0000"
18
+ parts << "on=#{node.handlers.join(',')}" if attributes.include?(:handlers) && node.handlers.any?
19
+ parts << "tooltip=#{node.tooltip.inspect}" if attributes.include?(:tooltip) && node.tooltip
20
+ out << (" " * depth) << parts.join(" ") << "\n"
21
+ node.children.each { |child| dump(child, attributes: attributes, depth: depth + 1, out: out) }
22
+ out
23
+ end
24
+
25
+ def num(value)
26
+ rounded = value.to_f.round(2)
27
+ rounded == rounded.to_i ? rounded.to_i.to_s : rounded.to_s
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ module TextSystems
5
+ module_function
6
+
7
+ def build(mode, fonts: [])
8
+ case mode
9
+ when :none, nil then nil
10
+ when :native then Zaniah::TextSystem::Renderer.new
11
+ when :deterministic
12
+ paths = [Internals.bundled_font_path, *fonts.map { |font| File.expand_path(font) }]
13
+ missing = paths.reject { |path| File.file?(path) }
14
+ raise ArgumentError, "Fonts not found: #{missing.join(', ')}" unless missing.empty?
15
+
16
+ db = Zaniah::TextSystem::FontDB.new(paths: paths)
17
+ primary = fonts.empty? ? paths.first : paths[1]
18
+ Zaniah::TextSystem::Renderer.new(font: db.open(primary), font_db: db)
19
+ else
20
+ raise ArgumentError, "Unsupported text mode: #{mode.inspect}" unless mode.respond_to?(:layout_line)
21
+
22
+ mode
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ class Comparator
5
+ Result = Data.define(:width, :height, :diff_pixels, :diff_image) do
6
+ def ratio = diff_pixels.fdiv(width * height)
7
+ end
8
+
9
+ def initialize(threshold: 0)
10
+ raise ArgumentError, "threshold must be between 0 and 255" unless threshold.is_a?(Numeric) && threshold.between?(0, 255)
11
+
12
+ @threshold = threshold
13
+ end
14
+
15
+ def compare(width, height, expected, actual)
16
+ expected_size = width * height * 4
17
+ unless expected.bytesize == actual.bytesize && expected.bytesize == expected_size
18
+ raise ArgumentError, "Image dimensions do not match"
19
+ end
20
+
21
+ diff = String.new(capacity: expected_size, encoding: Encoding::BINARY)
22
+ count = 0
23
+ expected_bytes = expected.unpack("C*")
24
+ actual_bytes = actual.unpack("C*")
25
+ (width * height).times do |index|
26
+ offset = index * 4
27
+ different = 4.times.any? { |channel| (expected_bytes[offset + channel] - actual_bytes[offset + channel]).abs > @threshold }
28
+ if different
29
+ count += 1
30
+ diff << [255, 0, 0, 255].pack("C4")
31
+ else
32
+ gray = ((expected_bytes[offset] * 30 + expected_bytes[offset + 1] * 59 + expected_bytes[offset + 2] * 11) / 300 + 170).clamp(0, 255)
33
+ diff << [gray, gray, gray, 255].pack("C4")
34
+ end
35
+ end
36
+ Result.new(width: width, height: height, diff_pixels: count, diff_image: diff)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ module Visual
5
+ module Image
6
+ module_function
7
+
8
+ def crop(rgba, width, height, bounds)
9
+ x = bounds.x.floor.clamp(0, width)
10
+ y = bounds.y.floor.clamp(0, height)
11
+ cropped_width = bounds.right.ceil.clamp(0, width) - x
12
+ cropped_height = bounds.bottom.ceil.clamp(0, height) - y
13
+ out = String.new(capacity: cropped_width * cropped_height * 4, encoding: Encoding::BINARY)
14
+ cropped_height.times do |row|
15
+ out << rgba.byteslice(((y + row) * width + x) * 4, cropped_width * 4)
16
+ end
17
+ [cropped_width, cropped_height, out]
18
+ end
19
+
20
+ def mask!(rgba, width, height, bounds, color: [255, 0, 255, 255])
21
+ pixel = color.pack("C4")
22
+ (bounds.y.floor.clamp(0, height)...bounds.bottom.ceil.clamp(0, height)).each do |row|
23
+ (bounds.x.floor.clamp(0, width)...bounds.right.ceil.clamp(0, width)).each do |column|
24
+ rgba[(row * width + column) * 4, 4] = pixel
25
+ end
26
+ end
27
+ rgba
28
+ end
29
+ end
30
+ end
31
+ end