betamax 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: 6f89807eb4aa9a8365294d743efddad3a94a6a6c9078615731bebc2fa1cf93f6
4
+ data.tar.gz: 31a8decf2ca628028820007eff48e83cbe45758fab781bc87503c3c172e61c8a
5
+ SHA512:
6
+ metadata.gz: 92208fdfd5c7bff89094c394827da00a06425f3c0037e9caa498230a1afb3dec467ab5b32325018c0ce2162095ad9ae1c59be4ab36f8943699ffafffdf13407a
7
+ data.tar.gz: cf74600f45a068b9b01b3d268211d3992e6d836d7e80d2f8baafe446a80628588669cb68530e2cb1bea63bd3fca6ea2a5509009562d2bbdb67df538cfdc6650d
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-06-15
4
+
5
+ - Initial release
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # Betamax
2
+
3
+ TODO: Delete this and the text below, and describe your gem
4
+
5
+ Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/betamax`. To experiment with that code, run `bin/console` for an interactive prompt.
6
+
7
+ ## Installation
8
+
9
+ TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
+
11
+ Install the gem and add to the application's Gemfile by executing:
12
+
13
+ ```bash
14
+ bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ```
16
+
17
+ If bundler is not being used to manage dependencies, install the gem by executing:
18
+
19
+ ```bash
20
+ gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
21
+ ```
22
+
23
+ ## Usage
24
+
25
+ TODO: Write usage instructions here
26
+
27
+ ## Development
28
+
29
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
+
31
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
+
33
+ ## Contributing
34
+
35
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Aesthetikx/betamax.
data/Rakefile ADDED
@@ -0,0 +1,10 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new :spec
5
+
6
+ require "rubocop/rake_task"
7
+
8
+ RuboCop::RakeTask.new
9
+
10
+ task default: %i[spec rubocop]
@@ -0,0 +1,59 @@
1
+ module Betamax
2
+ module Errors
3
+ class Error < StandardError; end
4
+
5
+ class NoTapeInserted < Error
6
+ def initialize message = "No tape inserted"
7
+ super
8
+ end
9
+ end
10
+
11
+ class PlaybackError < Error; end
12
+
13
+ class PlaybackFinished < PlaybackError
14
+ def initialize method_name
15
+ super "Received #{method_name} but playback has finished"
16
+ end
17
+ end
18
+
19
+ class MethodMismatch < PlaybackError
20
+ def initialize expected:, actual:
21
+ super "Expected method #{expected} but received #{actual}"
22
+ end
23
+ end
24
+
25
+ class UnexpectedBlock < PlaybackError
26
+ def initialize method_name
27
+ super "Method #{method_name} was called with a block but was recorded without one"
28
+ end
29
+ end
30
+
31
+ class BlockExpected < PlaybackError
32
+ def initialize method_name
33
+ super "Method #{method_name} was called without a block but was recorded with one"
34
+ end
35
+ end
36
+
37
+ class ArgumentMismatch < PlaybackError
38
+ def initialize method_name, expected:, actual:
39
+ super "Method #{method_name} argument mismatch: " \
40
+ "expected #{expected.inspect}, got #{actual.inspect}"
41
+ end
42
+ end
43
+
44
+ class KeywordArgumentMismatch < PlaybackError
45
+ def initialize method_name, expected_key:, expected_value:, actual_key:, actual_value:
46
+ super "Method #{method_name} keyword argument mismatch: " \
47
+ "expected #{expected_key}: #{expected_value.inspect}, " \
48
+ "got #{actual_key}: #{actual_value.inspect}"
49
+ end
50
+ end
51
+
52
+ class UnusedRecordings < PlaybackError
53
+ def initialize unused_methods
54
+ method_names = unused_methods.map(&:method_name).join(", ")
55
+ super "Recording has #{unused_methods.size} unused method(s): #{method_names}"
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,123 @@
1
+ module Betamax
2
+ class MethodPlayer
3
+ attr_reader :recording
4
+
5
+ def initialize recording:
6
+ @recording = recording
7
+ @playback_index = 0
8
+ end
9
+
10
+ def fully_consumed?
11
+ @playback_index == @recording.size &&
12
+ @recording.all? { |record| nested_fully_consumed? record }
13
+ end
14
+
15
+ def unused_recordings
16
+ unused = @recording[@playback_index..] || []
17
+ nested_unused = @recording.first(@playback_index).flat_map do |record|
18
+ unused_from_record record
19
+ end
20
+ unused + nested_unused
21
+ end
22
+
23
+ def call(method_name, *args, **kwargs, &)
24
+ record = advance_playback! method_name
25
+
26
+ validate_method_name! record.method_name, method_name
27
+ validate_args! method_name, args, record.args
28
+ validate_kwargs! method_name, kwargs, record.kwargs
29
+ validate_block! method_name, block_given?, record.block_given
30
+
31
+ replay_yieldings(record.block_yieldings, &)
32
+
33
+ record.result
34
+ end
35
+
36
+ private
37
+
38
+ def nested_fully_consumed? record
39
+ consumed?(record.result) &&
40
+ record.block_yieldings.all? { |yielding| yielding_consumed? yielding }
41
+ end
42
+
43
+ def yielding_consumed? yielding
44
+ yielding.args.all? { |arg| consumed? arg } &&
45
+ yielding.kwargs.values.all? { |value| consumed? value }
46
+ end
47
+
48
+ def consumed? object
49
+ return true unless object.instance_of? RecordedObject
50
+
51
+ object._betamax_recorder.fully_consumed?
52
+ end
53
+
54
+ def unused_from_record record
55
+ result_unused = unused_from_object record.result
56
+ yielding_unused = record.block_yieldings.flat_map do |yielding|
57
+ unused_from_yielding yielding
58
+ end
59
+ result_unused + yielding_unused
60
+ end
61
+
62
+ def unused_from_yielding yielding
63
+ args_unused = yielding.args.flat_map { |arg| unused_from_object arg }
64
+ kwargs_unused = yielding.kwargs.values.flat_map { |value| unused_from_object value }
65
+ args_unused + kwargs_unused
66
+ end
67
+
68
+ def unused_from_object object
69
+ return [] unless object.instance_of? RecordedObject
70
+
71
+ object._betamax_recorder.unused_recordings
72
+ end
73
+
74
+ def replay_yieldings block_yieldings
75
+ block_yieldings.each do |yielding|
76
+ yield(*yielding.args, **yielding.kwargs)
77
+ end
78
+ end
79
+
80
+ def advance_playback! method_name
81
+ @recording[@playback_index].tap do |record|
82
+ raise Errors::PlaybackFinished, method_name if record.nil?
83
+
84
+ @playback_index += 1
85
+ end
86
+ end
87
+
88
+ def validate_method_name! expected, actual
89
+ return if expected == actual
90
+
91
+ raise Errors::MethodMismatch.new expected:, actual:
92
+ end
93
+
94
+ def validate_args! method_name, actual_args, expected_args
95
+ max_size = [actual_args.size, expected_args.size].max
96
+ max_size.times do |i|
97
+ actual = actual_args[i]
98
+ expected = expected_args[i]
99
+ next if actual == expected
100
+
101
+ raise Errors::ArgumentMismatch.new method_name, expected:, actual:
102
+ end
103
+ end
104
+
105
+ def validate_kwargs! method_name, actual_kwargs, expected_kwargs
106
+ all_keys = (actual_kwargs.keys | expected_kwargs.keys)
107
+ all_keys.each do |key|
108
+ actual_value = actual_kwargs[key]
109
+ expected_value = expected_kwargs[key]
110
+ next if actual_value == expected_value
111
+
112
+ raise Errors::KeywordArgumentMismatch.new method_name,
113
+ expected_key: key, expected_value:,
114
+ actual_key: key, actual_value:
115
+ end
116
+ end
117
+
118
+ def validate_block! method_name, actual_block_given, expected_block_given
119
+ raise Errors::BlockExpected, method_name if expected_block_given && !actual_block_given
120
+ raise Errors::UnexpectedBlock, method_name if actual_block_given && !expected_block_given
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,55 @@
1
+ module Betamax
2
+ class MethodRecorder
3
+ PRIMITIVE_TYPES = [Integer, Float, String, Symbol, TrueClass, FalseClass, NilClass].freeze
4
+
5
+ attr_reader :recording
6
+
7
+ def initialize object:, recording: []
8
+ @object = object
9
+ @recording = recording
10
+ end
11
+
12
+ def call(method_name, *args, **kwargs, &) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
13
+ block_yieldings = []
14
+
15
+ result = if block_given?
16
+ @object.send(method_name, *args, **kwargs) do |*block_args, **block_kwargs|
17
+ wrapped_args = block_args.map { |arg| wrap_object arg }
18
+ wrapped_kwargs = block_kwargs.transform_values { |value| wrap_object value }
19
+
20
+ yielding = RecordedYielding.new args: wrapped_args, kwargs: wrapped_kwargs
21
+ block_yieldings << yielding
22
+
23
+ yield(*wrapped_args, **wrapped_kwargs)
24
+ end
25
+ else
26
+ @object.send(method_name, *args, **kwargs, &)
27
+ end
28
+
29
+ wrapped_result = wrap_object result
30
+
31
+ @recording << RecordedMethod.new(
32
+ method_name:,
33
+ args:,
34
+ kwargs:,
35
+ block_given: block_given?,
36
+ block_yieldings:,
37
+ result: wrapped_result
38
+ )
39
+
40
+ wrapped_result
41
+ end
42
+
43
+ private
44
+
45
+ def wrap_object object
46
+ case object
47
+ when *PRIMITIVE_TYPES
48
+ object
49
+ else
50
+ recorder = MethodRecorder.new object:, recording: []
51
+ RecordedObject.new object:, recorder:
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ module Betamax
2
+ class Player
3
+ attr_reader :tape, :root_proxy, :tapes_folder
4
+
5
+ def initialize example, tapes_folder:
6
+ @example = example
7
+ @tapes_folder = tapes_folder
8
+ @tape = Tape.from_rspec_example(example, tapes_folder:)
9
+ @root_proxy = nil
10
+ end
11
+
12
+ def play
13
+ insert_tape
14
+ @example.run
15
+ eject_tape unless @example.exception
16
+ end
17
+
18
+ def record object
19
+ recorder = if @tape.exists?
20
+ MethodPlayer.new recording: @tape.recording
21
+ else
22
+ MethodRecorder.new object:, recording: @tape.recording
23
+ end
24
+
25
+ @root_proxy = RecordedObject.new object:, recorder:
26
+ end
27
+
28
+ private
29
+
30
+ def insert_tape
31
+ @tape.load
32
+ Fiber[:betamax_player] = self
33
+ end
34
+
35
+ def eject_tape
36
+ Fiber[:betamax_player] = nil
37
+
38
+ if @tape.exists?
39
+ verify_fully_consumed!
40
+ elsif @root_proxy
41
+ @tape.save @root_proxy
42
+ end
43
+ end
44
+
45
+ def verify_fully_consumed!
46
+ return unless @root_proxy
47
+
48
+ recorder = @root_proxy._betamax_recorder
49
+ return if recorder.fully_consumed?
50
+
51
+ raise Errors::UnusedRecordings, recorder.unused_recordings
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,14 @@
1
+ module Betamax
2
+ RecordedMethod = Data.define :method_name, :args, :kwargs, :block_given, :block_yieldings, :result
3
+
4
+ # Implement YAML serialization / deserialization
5
+ RecordedMethod.include Module.new {
6
+ def init_with coder
7
+ initialize **coder.map
8
+ end
9
+
10
+ def encode_with coder
11
+ coder.map = to_h
12
+ end
13
+ }
14
+ end
@@ -0,0 +1,34 @@
1
+ module Betamax
2
+ class RecordedObject
3
+ attr_reader :_betamax_recorder
4
+
5
+ def initialize object:, recorder:
6
+ @_betamax_object = object
7
+ @_betamax_recorder = recorder
8
+ end
9
+
10
+ def respond_to_missing? method_name, _include_private = false
11
+ @_betamax_object.respond_to? method_name
12
+ end
13
+
14
+ def method_missing(...)
15
+ @_betamax_recorder.call(...)
16
+ end
17
+ end
18
+
19
+ # Implement YAML serialization / deserialization
20
+ RecordedObject.include Module.new {
21
+ def init_with coder
22
+ recording = coder.map.fetch :recording
23
+ recorder = MethodPlayer.new recording: recording
24
+ initialize object: nil, recorder: recorder
25
+ end
26
+
27
+ def encode_with coder
28
+ coder.map = {
29
+ class_name: @_betamax_object.class.name,
30
+ recording: @_betamax_recorder.recording
31
+ }
32
+ end
33
+ }
34
+ end
@@ -0,0 +1,14 @@
1
+ module Betamax
2
+ RecordedYielding = Data.define :args, :kwargs
3
+
4
+ # Implement YAML serialization / deserialization
5
+ RecordedYielding.include Module.new {
6
+ def init_with coder
7
+ initialize **coder.map
8
+ end
9
+
10
+ def encode_with coder
11
+ coder.map = to_h
12
+ end
13
+ }
14
+ end
@@ -0,0 +1,20 @@
1
+ module Betamax
2
+ Recording = Data.define :version, :objects
3
+
4
+ Recording::VERSION = 1.0
5
+
6
+ # Implement YAML serialization / deserialization
7
+ Recording.include Module.new {
8
+ def init_with coder
9
+ initialize **coder.map
10
+ end
11
+
12
+ def encode_with coder
13
+ coder.map = to_h
14
+ end
15
+
16
+ def default_recording
17
+ objects.fetch(:default)._betamax_recorder.recording
18
+ end
19
+ }
20
+ end
@@ -0,0 +1,17 @@
1
+ module Betamax
2
+ module RSpec
3
+ DEFAULT_TAPES_FOLDER = Pathname.new "spec/betamax_tapes/"
4
+
5
+ def play_rspec example, tapes_folder: DEFAULT_TAPES_FOLDER
6
+ Player.new(example, tapes_folder:).play
7
+ end
8
+
9
+ def install_rspec!
10
+ ::RSpec.configure do |config|
11
+ config.around :each, :betamax do |example|
12
+ Betamax.play_rspec example
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,89 @@
1
+ require "fileutils"
2
+ require "yaml"
3
+
4
+ module Betamax
5
+ class Tape
6
+ PERMITTED_YAML_CLASSES = [
7
+ Betamax::Recording,
8
+ Betamax::RecordedObject,
9
+ Betamax::RecordedMethod,
10
+ Betamax::RecordedYielding,
11
+ Symbol
12
+ ].freeze
13
+
14
+ attr_reader :path, :recording
15
+
16
+ def initialize path
17
+ @path = Pathname.new path
18
+ @recording = nil
19
+ end
20
+
21
+ def self.from_rspec_example example, tapes_folder:
22
+ name = cassette_name_for example.metadata
23
+ name.gsub! "::", "_"
24
+ name.gsub!(/[\s-]+/, "_")
25
+ name.gsub! %r{[^a-zA-Z0-9_/]}, ""
26
+
27
+ filename = Pathname.new(name).sub_ext ".yaml"
28
+ full_path = tapes_folder / filename
29
+
30
+ new full_path
31
+ end
32
+
33
+ def self.cassette_name_for metadata
34
+ # Build name from example group hierarchy + example description
35
+ description_parts = []
36
+
37
+ # Add the example description first
38
+ description_parts << metadata[:description] if metadata[:description]
39
+
40
+ # Walk up the example group hierarchy
41
+ current = metadata[:example_group]
42
+ while current
43
+ description_parts.unshift current[:description] if current[:description]
44
+ current = current[:parent_example_group]
45
+ end
46
+
47
+ description_parts.join "/"
48
+ end
49
+
50
+ def load
51
+ FileUtils.mkdir_p @path.dirname
52
+
53
+ if exists?
54
+ load_existing_recording
55
+ else
56
+ prepare_new_recording
57
+ end
58
+
59
+ self
60
+ end
61
+
62
+ def save proxy
63
+ return if exists? # Don't overwrite existing recordings
64
+
65
+ recording = Betamax::Recording.new \
66
+ version: Recording::VERSION,
67
+ objects: { default: proxy }
68
+
69
+ File.open @path, "w" do |file|
70
+ file.puts recording.to_yaml
71
+ end
72
+ end
73
+
74
+ def exists?
75
+ File.exist? @path
76
+ end
77
+
78
+ private
79
+
80
+ def load_existing_recording
81
+ raw = YAML.safe_load_file @path, permitted_classes: PERMITTED_YAML_CLASSES
82
+ @recording = raw.default_recording
83
+ end
84
+
85
+ def prepare_new_recording
86
+ @recording = []
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,3 @@
1
+ module Betamax
2
+ VERSION = "0.1.0".freeze
3
+ end
data/lib/betamax.rb ADDED
@@ -0,0 +1,33 @@
1
+ require "pathname"
2
+
3
+ require_relative "betamax/errors"
4
+ require_relative "betamax/method_player"
5
+ require_relative "betamax/method_recorder"
6
+ require_relative "betamax/player"
7
+ require_relative "betamax/recorded_method"
8
+ require_relative "betamax/recorded_object"
9
+ require_relative "betamax/recorded_yielding"
10
+ require_relative "betamax/recording"
11
+ require_relative "betamax/rspec"
12
+ require_relative "betamax/tape"
13
+ require_relative "betamax/version"
14
+
15
+ module Betamax
16
+ extend RSpec
17
+
18
+ module_function
19
+
20
+ def record object
21
+ player = current_player
22
+
23
+ raise Errors::NoTapeInserted unless player
24
+
25
+ player.record object
26
+ end
27
+
28
+ def current_player
29
+ Fiber[:betamax_player]
30
+ end
31
+ end
32
+
33
+ Betamax.install_rspec! if defined? RSpec
data/sig/betamax.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Betamax
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,60 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: betamax
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - John DeSilva
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Betamax allows for the recording and playback of arbitrary Ruby objects
13
+ to simplify testing external dependencies
14
+ email:
15
+ - john@aesthetikx.info
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - README.md
22
+ - Rakefile
23
+ - lib/betamax.rb
24
+ - lib/betamax/errors.rb
25
+ - lib/betamax/method_player.rb
26
+ - lib/betamax/method_recorder.rb
27
+ - lib/betamax/player.rb
28
+ - lib/betamax/recorded_method.rb
29
+ - lib/betamax/recorded_object.rb
30
+ - lib/betamax/recorded_yielding.rb
31
+ - lib/betamax/recording.rb
32
+ - lib/betamax/rspec.rb
33
+ - lib/betamax/tape.rb
34
+ - lib/betamax/version.rb
35
+ - sig/betamax.rbs
36
+ homepage: https://github.com/Aesthetikx/betamax
37
+ licenses: []
38
+ metadata:
39
+ homepage_uri: https://github.com/Aesthetikx/betamax
40
+ source_code_uri: https://github.com/Aesthetikx/betamax
41
+ changelog_uri: https://github.com/Aesthetikx/betamax/blob/main/CHANGELOG.md
42
+ rubygems_mfa_required: 'true'
43
+ rdoc_options: []
44
+ require_paths:
45
+ - lib
46
+ required_ruby_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: 3.2.0
51
+ required_rubygems_version: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ requirements: []
57
+ rubygems_version: 4.0.10
58
+ specification_version: 4
59
+ summary: Record and playback of arbitrary Ruby objects
60
+ test_files: []