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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e9ef547e116ba3d928630699b7ae95646b0604108499429bc978bf31faa502bf
4
+ data.tar.gz: 01de9448ef8c33a18af96280ba15a993cf08be62292408de956aa8be78572a8c
5
+ SHA512:
6
+ metadata.gz: 15af45c54231f18c6c10b7db2354a644bc206c584c515a169672ad562db43753f5753ececea2c829b04d418b83181c6689c0571be115dea91109425b74bc0342
7
+ data.tar.gz: 15c60ea282171fb9e17427769fd81b5510218590afb0126d51349bf16a73c5b5a58b9657fe0a27e3d1140a3fcfc3e70270c013c02445dbedc71ce0cbb93b2640
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-09-11
4
+
5
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
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,105 @@
1
+ # Syrma
2
+
3
+ Syrma drives [Zaniah](https://github.com/noxdea/zaniah) GUI and TUI applications from Ruby tests. It locates rendered elements, sends input through Zaniah's real event path, waits for redraws, and compares text, trees, terminal output, pixels, and screenshots.
4
+
5
+ ## Installation
6
+
7
+ Add Syrma to the test group in your `Gemfile`:
8
+
9
+ ```ruby
10
+ group :test do
11
+ gem "minitest", "~> 5.0"
12
+ gem "syrma"
13
+ end
14
+ ```
15
+
16
+ Then run `bundle install` and `bundle exec syrma doctor`.
17
+
18
+ ## Minimal test
19
+
20
+ Keep window creation separate from the code that mounts your UI:
21
+
22
+ ```ruby
23
+ module Counter
24
+ def self.mount(window)
25
+ count = 0
26
+ window.draw do
27
+ Zaniah::Div.new
28
+ .child(Zaniah::Text.new("Count: #{count}"))
29
+ .child(Zaniah::Div.new.test_id("increment").on_click { count += 1 })
30
+ end
31
+ end
32
+ end
33
+ ```
34
+
35
+ ```ruby
36
+ require "syrma/minitest"
37
+
38
+ class CounterTest < Minitest::Test
39
+ include Syrma::Minitest
40
+
41
+ def setup
42
+ zaniah_session(width: 320, height: 200) { |window| Counter.mount(window) }
43
+ end
44
+
45
+ def test_increment
46
+ ui.find(test_id: "increment").click
47
+ assert_ui_text "Count: 1"
48
+ end
49
+ end
50
+ ```
51
+
52
+ `test_id` is provided by Zaniah 0.2 and is safe to use in application code.
53
+
54
+ ## API at a glance
55
+
56
+ | Task | API |
57
+ | --- | --- |
58
+ | Locate | `find`, `all`, `test_id`, `text`, `button`, nested `find`, `nth`, `first`, `last` |
59
+ | Pointer | `click`, `double_click`, `right_click`, `hover`, `drag`, `scroll` |
60
+ | Keyboard/text | `press`, `type`, `paste`, `compose`, `commit` |
61
+ | Window/input | `resize`, `close`, `drop_files`, `feed_terminal` |
62
+ | Synchronize | `settle`, `wait_for`, `advance` |
63
+ | Inspect | `tree`, `texts`, `at`, `pixel`, `screenshot`, `terminal_lines`, `menu`, `tooltip` |
64
+ | Assert | text, element, visibility, clickability, background, pixel, tooltip, menu, tree/terminal/image snapshots |
65
+
66
+ Locators are lazy: every operation resolves them against the latest rendered frame. Actions wait for visibility and an unobscured matching event handler, then send events through `Window#input`.
67
+
68
+ ## Snapshots and diagnostics
69
+
70
+ ```ruby
71
+ assert_tree_snapshot "sidebar"
72
+ assert_screenshot "saved", region: ui.test_id("panel"), mask: [ui.test_id("clock")]
73
+ assert_terminal_snapshot "main"
74
+ ```
75
+
76
+ New goldens are created locally and rejected on CI. Set `SYRMA_UPDATE_SNAPSHOTS=1` to update them. A failed UI test writes its screenshot, element tree, hit regions, text runs, recent events, and summary below `tmp/syrma`.
77
+
78
+ ```sh
79
+ bundle exec syrma snapshots update
80
+ bundle exec syrma snapshots prune --dry-run
81
+ bundle exec syrma report
82
+ ```
83
+
84
+ ## Determinism and speed
85
+
86
+ The default text renderer uses only Zaniah's bundled Abel font plus files passed in `fonts:`. Add a repository-owned font for non-Latin screenshot tests. `text: :none` is faster but does not draw glyphs.
87
+
88
+ On Ruby 4.0 arm64 macOS, the included 101-element benchmark measured `event_frames: :gesture` at 3.1 ms per click/check with `text: :none` and 7.9 ms with deterministic text; Syrma's tree build plus locator resolution was about 0.28 ms. Run `bundle exec ruby -Ilib bench/click_bench.rb gesture` on the target CI host for relevant numbers.
89
+
90
+ See [the guide](docs/guide.md) and [recipes](docs/recipes.md) for the full workflow.
91
+
92
+ ## Development
93
+
94
+ ```sh
95
+ bundle exec rake
96
+ bundle exec ruby -Ilib:test script/test_gesture.rb
97
+ bundle exec rbs -I sig validate
98
+ gem build --strict syrma.gemspec
99
+ ```
100
+
101
+ Syrma supports Ruby 3.1+ and Zaniah `~> 0.2.0`. Virtual time is scoped to each session.
102
+
103
+ ## License
104
+
105
+ Syrma is available under the [MIT License](LICENSE.txt).
data/exe/syrma ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "syrma/cli"
5
+
6
+ exit Syrma::CLI.run
data/lib/syrma/cli.rb ADDED
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "optparse"
5
+ require_relative "../syrma"
6
+
7
+ module Syrma
8
+ class CLI
9
+ def self.run(argv = ARGV, out: $stdout, err: $stderr)
10
+ new(out: out, err: err).run(argv.dup)
11
+ end
12
+
13
+ def initialize(out:, err:)
14
+ @out = out
15
+ @err = err
16
+ end
17
+
18
+ def run(arguments)
19
+ command = arguments.shift
20
+ case command
21
+ when "doctor" then doctor
22
+ when "snapshots" then snapshots(arguments)
23
+ when "report" then report(arguments)
24
+ when "record" then record(arguments)
25
+ when "codegen" then codegen(arguments)
26
+ when nil, "help", "--help", "-h" then help
27
+ else
28
+ @err.puts "Unknown command: #{command}"
29
+ help(@err)
30
+ 1
31
+ end
32
+ rescue OptionParser::ParseError, ArgumentError, Error => error
33
+ @err.puts error.message
34
+ 1
35
+ end
36
+
37
+ private
38
+
39
+ def doctor
40
+ checks = {
41
+ "Ruby" => "#{RUBY_VERSION} (#{RUBY_PLATFORM})",
42
+ "zaniah" => Zaniah::VERSION,
43
+ "Bundled font" => File.file?(Internals.bundled_font_path) ? Internals.bundled_font_path : nil,
44
+ "YJIT" => defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? "enabled" : "disabled",
45
+ "External encoding" => Encoding.default_external.name
46
+ }
47
+ checks.each { |name, value| @out.puts format("%-14s %s", name, value || "NG") }
48
+ checks.values.all? ? 0 : 1
49
+ end
50
+
51
+ def snapshots(arguments)
52
+ subcommand = arguments.shift
53
+ case subcommand
54
+ when "update" then update_snapshots(arguments)
55
+ when "prune" then prune_snapshots(arguments)
56
+ else raise ArgumentError, "Usage: syrma snapshots update [test files] | prune [--dry-run]"
57
+ end
58
+ end
59
+
60
+ def update_snapshots(files)
61
+ Snapshots::Store.new.reset_usage!(full: files.empty?)
62
+ env = {"SYRMA_UPDATE_SNAPSHOTS" => "1"}
63
+ command = ["bundle", "exec", "rake", "test"]
64
+ command << "TEST=#{files.join(' ')}" unless files.empty?
65
+ system(env, *command) ? 0 : 1
66
+ end
67
+
68
+ def prune_snapshots(arguments)
69
+ dry_run = arguments.delete("--dry-run")
70
+ raise ArgumentError, "Unknown arguments: #{arguments.join(' ')}" unless arguments.empty?
71
+
72
+ files = Snapshots::Store.new.unused
73
+ files.each { |path| @out.puts(dry_run ? "would remove #{path}" : "removed #{path}") }
74
+ files.each { |path| FileUtils.rm_f(path) } unless dry_run
75
+ 0
76
+ end
77
+
78
+ def report(arguments)
79
+ open_report = arguments.delete("--open")
80
+ raise ArgumentError, "Unknown arguments: #{arguments.join(' ')}" unless arguments.empty?
81
+
82
+ path = Report.generate
83
+ @out.puts path
84
+ open_path(path) if open_report
85
+ 0
86
+ end
87
+
88
+ def record(arguments)
89
+ output = "recording.jsonl"
90
+ parser = OptionParser.new { |options| options.on("--out PATH") { |path| output = path } }
91
+ parser.parse!(arguments)
92
+ script = arguments.shift or raise ArgumentError, "Usage: syrma record SCRIPT [--out PATH]"
93
+ raise ArgumentError, "Unknown arguments: #{arguments.join(' ')}" unless arguments.empty?
94
+
95
+ sessions = []
96
+ recorders = []
97
+ File.open(output, "w", encoding: "UTF-8") do |file|
98
+ Instrumentation.install!
99
+ Instrumentation.capture_windows(backend: nil, on_open: lambda { |window|
100
+ session = Session.attach(window, text: :native, raster: :eager)
101
+ sessions << session
102
+ recorders << Recorder.new(session.driver, file)
103
+ }) { load(File.expand_path(script)) }
104
+ end
105
+ @out.puts output
106
+ 0
107
+ ensure
108
+ recorders&.each(&:close)
109
+ sessions&.each(&:close)
110
+ end
111
+
112
+ def codegen(arguments)
113
+ framework = :minitest
114
+ parser = OptionParser.new { |options| options.on("--framework NAME") { |name| framework = name.to_sym } }
115
+ parser.parse!(arguments)
116
+ raise ArgumentError, "framework must be minitest or rspec" unless %i[minitest rspec].include?(framework)
117
+
118
+ path = arguments.shift or raise ArgumentError, "Usage: syrma codegen RECORDING [--framework minitest|rspec]"
119
+ raise ArgumentError, "Unknown arguments: #{arguments.join(' ')}" unless arguments.empty?
120
+
121
+ @out.write(Codegen.generate(File.readlines(path, encoding: "UTF-8"), framework: framework))
122
+ 0
123
+ end
124
+
125
+ def open_path(path)
126
+ command = if RUBY_PLATFORM.include?("darwin")
127
+ ["open", path]
128
+ elsif RUBY_PLATFORM.match?(/mswin|mingw/)
129
+ ["cmd", "/c", "start", "", path]
130
+ else
131
+ ["xdg-open", path]
132
+ end
133
+ system(*command)
134
+ end
135
+
136
+ def help(io = @out)
137
+ io.puts <<~HELP
138
+ Usage: syrma COMMAND
139
+ doctor
140
+ snapshots update [test files]
141
+ snapshots prune [--dry-run]
142
+ report [--open]
143
+ record SCRIPT [--out recording.jsonl]
144
+ codegen RECORDING [--framework minitest|rspec]
145
+ HELP
146
+ 0
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ class Clock
5
+ def self.real_now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
6
+
7
+ def initialize = @now = self.class.real_now
8
+ def call = @now
9
+
10
+ def advance(seconds)
11
+ raise ArgumentError, "time cannot be negative" unless seconds.is_a?(Numeric) && seconds >= 0
12
+
13
+ @now += seconds.to_f
14
+ end
15
+ end
16
+
17
+ class VirtualKeymap
18
+ def initialize(keymap, clock) = (@keymap, @clock = keymap, clock)
19
+
20
+ def dispatch(key, context: {})
21
+ @keymap.dispatch(key, context: context, now: @clock.call)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ module Codegen
5
+ module_function
6
+
7
+ def generate(lines, framework: :minitest)
8
+ records = lines.filter_map { |line| EventCodec.parse(line) unless line.strip.empty? }
9
+ body = operations(records).map { |line| " #{line}" }.join("\n")
10
+ framework == :rspec ? rspec(body) : minitest(body)
11
+ end
12
+
13
+ def operations(records)
14
+ output = []
15
+ text = +""
16
+ flush = -> { output << "ui.type(#{text.dump})" unless text.empty?; text.clear }
17
+ records.each_with_index do |record, index|
18
+ if record["type"] == "TextInput"
19
+ text << record.fetch("fields").fetch("text")
20
+ next
21
+ end
22
+ next if record["type"] == "KeyUp" || typing_key?(records, index)
23
+
24
+ flush.call
25
+ operation = operation(record)
26
+ output << operation if operation
27
+ end
28
+ flush.call
29
+ output << "# TODO: add assertions"
30
+ end
31
+
32
+ def operation(record)
33
+ fields = record.fetch("fields")
34
+ case record["type"]
35
+ when "MouseDown"
36
+ target = locator(record["target"], fields.fetch("position"))
37
+ return fields["button"].to_s == "right" ? "#{target}.right_click" : "#{target}.click" if target
38
+
39
+ point = fields.fetch("position")
40
+ method = fields["button"].to_s == "right" ? "right_click" : "click"
41
+ "ui.#{method}([#{point.fetch('x')}, #{point.fetch('y')}])"
42
+ when "KeyDown" then "ui.press(#{fields.fetch('keystroke').dump})"
43
+ when "ScrollWheel"
44
+ point = fields.fetch("position")
45
+ delta = fields.fetch("delta")
46
+ "ui.scroll([#{point.fetch('x')}, #{point.fetch('y')}], dx: #{delta.fetch('x')}, dy: #{delta.fetch('y')})"
47
+ when "Composition" then "ui.compose(#{fields.fetch('text').dump}, selection: #{fields['selection'].inspect})"
48
+ when "FileDrop"
49
+ point = fields.fetch("position")
50
+ "ui.drop_files(#{fields.fetch('paths').inspect}, at: [#{point.fetch('x')}, #{point.fetch('y')}])"
51
+ end
52
+ end
53
+
54
+ def locator(target, point)
55
+ return "ui.find(test_id: #{target['test_id'].dump})" if target&.fetch("test_id", nil)
56
+ return "ui.button(#{target['text'].dump})" if target&.fetch("text", nil)&.then { |text| !text.empty? }
57
+
58
+ nil
59
+ end
60
+
61
+ def typing_key?(records, index)
62
+ records[index]["type"] == "KeyDown" && records[index + 1]&.fetch("type") == "TextInput"
63
+ end
64
+
65
+ def minitest(body)
66
+ <<~RUBY
67
+ require "syrma/minitest"
68
+
69
+ class RecordedUiTest < Minitest::Test
70
+ include Syrma::Minitest
71
+
72
+ def test_recorded_flow
73
+ #{body}
74
+ end
75
+ end
76
+ RUBY
77
+ end
78
+
79
+ def rspec(body)
80
+ <<~RUBY
81
+ require "syrma/rspec"
82
+
83
+ RSpec.describe "recorded UI", type: :zaniah do
84
+ it "replays the flow" do
85
+ #{body}
86
+ end
87
+ end
88
+ RUBY
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ class Configuration
5
+ attr_accessor :timeout, :strict, :event_frames, :text, :fonts, :snapshot_dir, :artifacts_dir
6
+
7
+ def initialize
8
+ @timeout = 2.0
9
+ @strict = true
10
+ @event_frames = :each
11
+ @text = :deterministic
12
+ @fonts = []
13
+ @snapshot_dir = "test/syrma_snapshots"
14
+ @artifacts_dir = "tmp/syrma"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Syrma
6
+ module Diagnostics
7
+ module_function
8
+
9
+ def dir_for(test_class, test_name)
10
+ root = ENV["SYRMA_ARTIFACTS"] || Syrma.configuration.artifacts_dir
11
+ File.join(root, sanitize(test_class), sanitize(test_name))
12
+ end
13
+
14
+ def write(session, dir, message: nil)
15
+ FileUtils.mkdir_p(dir)
16
+ tree = session.tree
17
+ size = session.window.content_size
18
+ Zaniah::PNG.write(File.join(dir, "screenshot.png"), size.width.to_i, size.height.to_i, session.screenshot_pixels)
19
+ File.write(File.join(dir, "tree.txt"), tree.root ? TreeFormat.dump(tree.root) : "(nothing rendered)\n", encoding: "UTF-8")
20
+ File.write(File.join(dir, "hits.txt"), hits(tree), encoding: "UTF-8")
21
+ File.write(File.join(dir, "text_runs.txt"), text_runs(tree), encoding: "UTF-8")
22
+ File.write(File.join(dir, "events.log"), events(session), encoding: "UTF-8")
23
+ File.write(File.join(dir, "terminal.txt"), session.terminal_lines.join("\n") + "\n", encoding: "UTF-8") if session.tui?
24
+ File.write(File.join(dir, "summary.md"), summary(session, dir, message, tree, size), encoding: "UTF-8")
25
+ dir
26
+ rescue StandardError => error
27
+ warn "syrma: failed to write diagnostics: #{error.class}: #{error.message}"
28
+ nil
29
+ end
30
+
31
+ def sanitize(value) = value.to_s.gsub(/[^A-Za-z0-9_-]/, "_")
32
+
33
+ def hits(tree)
34
+ tree.hits.each_with_index.map do |(bounds, node), index|
35
+ "#{index}: [#{bounds.x},#{bounds.y} #{bounds.width}x#{bounds.height}] #{node ? node.inspect : '(unknown)'}"
36
+ end.join("\n") + "\n"
37
+ end
38
+
39
+ def text_runs(tree)
40
+ tree.text_runs.map { |x, y, text, color| "#{x},#{y} #{color} #{text.inspect}" }.join("\n") + "\n"
41
+ end
42
+
43
+ def events(session)
44
+ session.event_log.last(100).map do |event|
45
+ "frame=#{event.frame} window=#{event.window.inspect} #{event.input.inspect}"
46
+ end.join("\n") + "\n"
47
+ end
48
+
49
+ def summary(_session, dir, message, tree, size)
50
+ links = %w[screenshot.png tree.txt hits.txt text_runs.txt events.log terminal.txt expected.png actual.png diff.png]
51
+ .select { |name| File.exist?(File.join(dir, name)) }
52
+ .map { |name| "[#{name}](#{name})" }.join(" / ")
53
+ <<~MARKDOWN
54
+ # #{File.basename(dir)}
55
+
56
+ #{message}
57
+
58
+ - zaniah #{Zaniah::VERSION} / Ruby #{RUBY_VERSION} / #{RUBY_PLATFORM}
59
+ - window #{size.width}x#{size.height}, frame #{tree.frame}
60
+ - #{links}
61
+ MARKDOWN
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ class Error < Zaniah::Error; end
5
+ class UnstableUI < Error; end
6
+ class WaitTimeout < Error; end
7
+ class SnapshotMissing < Error; end
8
+ class SnapshotMismatch < Error; end
9
+ class UnsupportedBackend < Error; end
10
+
11
+ class ElementNotFound < Error
12
+ def initialize(locator, tree)
13
+ texts = tree.text_runs.map { |run| run[2] }.uniq.first(10)
14
+ super("Element not found: #{locator}\n Visible text: #{texts.inspect}")
15
+ end
16
+ end
17
+
18
+ class AmbiguousMatch < Error
19
+ def initialize(locator, nodes)
20
+ super("Matched #{nodes.length} elements: #{locator}\n " +
21
+ nodes.first(5).map(&:inspect).join("\n ") + "\n Refine with nth, first, or within")
22
+ end
23
+ end
24
+
25
+ class NotActionable < Error
26
+ attr_reader :reason
27
+
28
+ def initialize(reason, message)
29
+ @reason = reason
30
+ super(message)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Syrma
6
+ module EventCodec
7
+ I = Zaniah::Input
8
+ TYPES = {
9
+ "MouseDown" => I::MouseDown,
10
+ "MouseUp" => I::MouseUp,
11
+ "MouseMove" => I::MouseMove,
12
+ "ScrollWheel" => I::ScrollWheel,
13
+ "KeyDown" => I::KeyDown,
14
+ "KeyUp" => I::KeyUp,
15
+ "TextInput" => I::TextInput,
16
+ "Composition" => I::Composition,
17
+ "FileDrop" => I::FileDrop
18
+ }.freeze
19
+ SYMBOLS = %i[button phase].freeze
20
+
21
+ module_function
22
+
23
+ def dump(event, t:, target: nil)
24
+ type = TYPES.key(event.class) or raise ArgumentError, "Unsupported event: #{event.class}"
25
+ fields = event.to_h.transform_values { |value| encode(value) }
26
+ JSON.generate({"t" => Float(t), "type" => type, "fields" => fields}.tap { |record| record["target"] = target if target })
27
+ end
28
+
29
+ def load(line)
30
+ data = parse(line)
31
+ klass = TYPES.fetch(data["type"]) { raise ArgumentError, "Unknown event type: #{data['type'].inspect}" }
32
+ fields = data.fetch("fields")
33
+ values = klass.members.map do |member|
34
+ value = decode(fields.fetch(member.to_s))
35
+ SYMBOLS.include?(member) && value ? value.to_s.to_sym : value
36
+ end
37
+ klass.new(*values)
38
+ end
39
+
40
+ def parse(line)
41
+ data = JSON.parse(line)
42
+ raise ArgumentError, "Recording must be a JSON object" unless data.is_a?(Hash)
43
+ raise ArgumentError, "Unknown event type: #{data['type'].inspect}" unless TYPES.key?(data["type"])
44
+ raise ArgumentError, "Missing fields" unless data["fields"].is_a?(Hash)
45
+
46
+ data
47
+ rescue JSON::ParserError => error
48
+ raise ArgumentError, "Invalid JSON: #{error.message}"
49
+ end
50
+
51
+ def encode(value)
52
+ return {"x" => value.x, "y" => value.y} if value.is_a?(Zaniah::Point)
53
+
54
+ value
55
+ end
56
+
57
+ def decode(value)
58
+ return Zaniah::Point.new(value.fetch("x"), value.fetch("y")) if value.is_a?(Hash) && value.keys.sort == %w[x y]
59
+
60
+ value
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Syrma
4
+ Event = Data.define(:frame, :window, :input)
5
+
6
+ class EventLog
7
+ include Enumerable
8
+
9
+ def initialize(limit: 100)
10
+ @limit = limit
11
+ @events = []
12
+ end
13
+
14
+ def each(&block) = @events.each(&block)
15
+ def last(count = nil) = count ? @events.last(count) : @events.last
16
+
17
+ def add(frame, window, input)
18
+ @events.shift if @events.length >= @limit
19
+ @events << Event.new(frame: frame, window: window, input: input)
20
+ end
21
+ end
22
+ end