mock-suey 0.0.1 → 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,150 @@
1
+ # frozen_string_literal: true
2
+ using RubyNext;
3
+ require "mock_suey/ext/instance_class"
4
+
5
+ module MockSuey
6
+ class MockContract
7
+ using Ext::InstanceClass
8
+
9
+ class Error < StandardError
10
+ attr_reader :contract
11
+
12
+ def initialize(contract, msg)
13
+ @contract = contract
14
+ super(msg)
15
+ end
16
+
17
+ private
18
+
19
+ def captured_calls_message(calls)
20
+ calls.map do |call|
21
+ contract.args_pattern.map.with_index do |arg, i|
22
+ (ANYTHING == arg) ? "_" : call.arguments[i].inspect
23
+ end.join(", ").then do |args_desc|
24
+ " (#{args_desc}) -> #{call.return_value.class}"
25
+ end
26
+ end.uniq.join("\n")
27
+ end
28
+ end
29
+
30
+ class NoMethodCalls < Error
31
+ def initialize(contract)
32
+ super(
33
+ contract,
34
+ "Mock contract verification failed:\n" \
35
+ " No method calls captured for #{contract.method_desc}"
36
+ )
37
+ end
38
+ end
39
+
40
+ class NoMatchingMethodCalls < Error
41
+ def initialize(contract, real_calls)
42
+ @contract = contract
43
+ super(
44
+ contract,
45
+ "Mock contract verification failed:\n" \
46
+ " No matching calls captured for #{contract.inspect}.\n" \
47
+ " Captured call patterns:\n" \
48
+ "#{captured_calls_message(real_calls)}"
49
+ )
50
+ end
51
+ end
52
+
53
+ class NoMatchingReturnType < Error
54
+ def initialize(contract, real_calls)
55
+ @contract = contract
56
+ super(
57
+ contract,
58
+ "Mock contract verification failed:\n" \
59
+ " No calls with the expected return type captured for #{contract.inspect}.\n" \
60
+ " Captured call patterns:\n" \
61
+ "#{captured_calls_message(real_calls)}"
62
+ )
63
+ end
64
+ end
65
+
66
+ ANYTHING = Object.new.freeze
67
+
68
+ def self.from_stub(call_obj)
69
+ (__m__ = call_obj) && ((((receiver_class, method_name, return_value) = nil) || (__m__.respond_to?(:deconstruct_keys) && (((__m_hash__ = __m__.deconstruct_keys([:receiver_class, :method_name, :return_value])) || true) && (Hash === __m_hash__ || Kernel.raise(TypeError, "#deconstruct_keys must return Hash"))) && (((__m_hash__.key?(:receiver_class) && __m_hash__.key?(:method_name)) && __m_hash__.key?(:return_value)) && (((receiver_class = __m_hash__[:receiver_class]) || true) && (((method_name = __m_hash__[:method_name]) || true) && ((return_value = __m_hash__[:return_value]) || true)))))) || Kernel.raise(NoMatchingPatternError, __m__.inspect))
70
+
71
+ args_pattern = call_obj.arguments.map do
72
+ contractable_arg?(_1) ? _1 : ANYTHING
73
+ end
74
+
75
+ new(
76
+ receiver_class: receiver_class,
77
+ method_name: method_name,
78
+ args_pattern: args_pattern,
79
+ return_type: return_value.class
80
+ )
81
+ end
82
+
83
+ def self.contractable_arg?(val)
84
+ case val
85
+ when TrueClass, FalseClass, Numeric, String, Regexp, NilClass
86
+ true
87
+ when Array
88
+ val.all? { |v| contractable_arg?(v) }
89
+ when Hash
90
+ contractable_arg?(val.values)
91
+ else
92
+ false
93
+ end
94
+ end
95
+
96
+ attr_reader :receiver_class, :method_name,
97
+ :args_pattern, :return_type
98
+
99
+ def initialize(receiver_class:, method_name:, args_pattern:, return_type:)
100
+ @receiver_class = receiver_class
101
+ @method_name = method_name
102
+ @args_pattern = args_pattern
103
+ @return_type = return_type
104
+ end
105
+
106
+ def verify!(calls)
107
+ return if noop?
108
+
109
+ raise NoMethodCalls.new(self) if calls.nil? || calls.empty?
110
+
111
+ matching_input_calls = calls.select { matching_args?(_1) }
112
+ raise NoMatchingMethodCalls.new(self, calls) if matching_input_calls.empty?
113
+
114
+ matching_input_calls.each do
115
+ return if _1.return_value.class <= return_type
116
+ end
117
+
118
+ raise NoMatchingReturnType.new(self, matching_input_calls)
119
+ end
120
+
121
+ def noop? ; args_pattern.all? { _1 == ANYTHING }; end
122
+
123
+ def method_desc
124
+ delimeter = receiver_class.singleton_class? ? "." : "#"
125
+
126
+ "#{receiver_class.instance_class_name}#{delimeter}#{method_name}"
127
+ end
128
+
129
+ def inspect
130
+ args_pattern.map do
131
+ (_1 == ANYTHING) ? "_" : _1.inspect
132
+ end.join(", ").then do |args_desc|
133
+ "#{method_desc}: (#{args_desc}) -> #{return_type}"
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def matching_args?(call)
140
+ args_pattern.each.with_index do |arg, i|
141
+ next if arg == ANYTHING
142
+ # Use case-eq here to make it possible to use composed
143
+ # matchers in the future
144
+ return false unless arg === call.arguments[i]
145
+ end
146
+
147
+ true
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+ using RubyNext;
3
+ require "mock_suey/ext/rspec"
4
+ require "mock_suey/ext/instance_class"
5
+
6
+ module MockSuey
7
+ module RSpec
8
+ # Special type of shared context for mocks.
9
+ # The main difference is that it can track mocked classes and methods.
10
+ module MockContext
11
+ NAMESPACE = "mock::"
12
+
13
+ class << self
14
+ attr_writer :context_namespace
15
+
16
+ def context_namespace ; @context_namespace || NAMESPACE; end
17
+
18
+ def collector ; @collector ||= MocksCollector.new; end
19
+
20
+ def registry ; collector.registry; end
21
+ end
22
+
23
+ class MocksCollector
24
+ using Ext::RSpec
25
+ using Ext::InstanceClass
26
+
27
+ # Registry contains all identified mocks/stubs in a form:
28
+ # Hash[Class: Hash[Symbol method_name, Array[MethodCall]]
29
+ attr_reader :registry
30
+
31
+ def initialize
32
+ @registry = Hash.new { |h, k| h[k] = {} }
33
+ @mocks = {}
34
+ end
35
+
36
+ def watch(context_id)
37
+ return if mocks.key?(context_id)
38
+
39
+ mocks[context_id] = true
40
+
41
+ evaluate_context!(context_id)
42
+ end
43
+
44
+ private
45
+
46
+ attr_reader :mocks
47
+
48
+ def evaluate_context!(context_id)
49
+ store = registry
50
+
51
+ Class.new(::RSpec::Core::ExampleGroup) do
52
+ def self.metadata ; {}; end
53
+
54
+ def self.filtered_examples ; examples; end
55
+
56
+ ::RSpec::Core::MemoizedHelpers.define_helpers_on(self)
57
+
58
+ include_context(context_id)
59
+
60
+ specify("true") { expect(true).to be(true) }
61
+
62
+ after do
63
+ ::RSpec::Mocks.space.proxies.values.each do |proxy|
64
+ proxy.method_doubles.values.each do |double|
65
+ method_name = double.method_name
66
+ receiver_class = proxy.target_class
67
+
68
+ # Simple doubles don't have targets
69
+ next unless receiver_class
70
+
71
+ # TODO: Make conversion customizable (see proxy_method_invoked)
72
+ if method_name == :new && receiver_class.singleton_class?
73
+ receiver_class, method_name = receiver_class.instance_class, :initialize
74
+ end
75
+
76
+ expected_calls = store[receiver_class][method_name] = []
77
+
78
+ double.stubs.each do |stub|
79
+ arguments =
80
+ if (__m__ = stub.expected_args) && (__m__.respond_to?(:deconstruct) && (((__m_arr__ = __m__.deconstruct) || true) && (Array === __m_arr__ || Kernel.raise(TypeError, "#deconstruct must return Array"))) && (1 == __m_arr__.size) && (::RSpec::Mocks::ArgumentMatchers::NoArgsMatcher === __m_arr__[0]))
81
+ []
82
+ else
83
+ stub.expected_args
84
+ end
85
+
86
+ return_value = stub.implementation.terminal_action.call
87
+
88
+ expected_calls << MethodCall.new(
89
+ receiver_class: receiver_class,
90
+ method_name: method_name,
91
+ arguments: arguments,
92
+ return_value: return_value
93
+ )
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end.run
99
+ end
100
+ end
101
+
102
+ module DSL
103
+ def mock_context(name, **opts, &block)
104
+ ::RSpec.shared_context("#{MockContext.context_namespace}#{name}", **opts, &block)
105
+ end
106
+ end
107
+
108
+ module ExampleGroup
109
+ def include_mock_context(name)
110
+ context_id = "#{MockContext.context_namespace}#{name}"
111
+
112
+ MockContext.collector.watch(context_id)
113
+ include_context(context_id)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+
120
+ # Extending RSpec
121
+ RSpec.extend(MockSuey::RSpec::MockContext::DSL)
122
+
123
+ if RSpec.configuration.expose_dsl_globally?
124
+ Object.include(MockSuey::RSpec::MockContext::DSL)
125
+ Module.extend(MockSuey::RSpec::MockContext::DSL)
126
+ end
127
+
128
+ RSpec::Core::ExampleGroup.extend(MockSuey::RSpec::MockContext::ExampleGroup)
129
+ RSpec::Core::ExampleGroup.extend(MockSuey::RSpec::MockContext::DSL)
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ gem "rbs", "~> 2.0"
4
+ require "rbs"
5
+ require "rbs/test"
6
+
7
+ require "set"
8
+ require "pathname"
9
+
10
+ require "mock_suey/ext/instance_class"
11
+
12
+ module MockSuey
13
+ module TypeChecks
14
+ using Ext::InstanceClass
15
+
16
+ class Ruby
17
+ class SignatureGenerator
18
+ attr_reader :klass, :method_calls, :constants, :singleton
19
+ alias_method :singleton?, :singleton
20
+
21
+ def initialize(klass, method_calls)
22
+ @klass = klass
23
+ @singleton = klass.singleton_class?
24
+ @method_calls = method_calls
25
+ @constants = Set.new
26
+ end
27
+
28
+ def to_rbs
29
+ [
30
+ header,
31
+ *method_calls.map { |name, calls| method_sig(name, calls) },
32
+ footer
33
+ ].join("\n")
34
+ end
35
+
36
+ private
37
+
38
+ def header
39
+ nesting_parts = klass.instance_class_name.split("::")
40
+
41
+ base = Kernel
42
+ nesting = 0
43
+
44
+ lines = []
45
+
46
+ nesting_parts.map do |const|
47
+ base = base.const_get(const)
48
+ lines << "#{" " * nesting}#{base.is_a?(Class) ? "class" : "module"} #{const}"
49
+ nesting += 1
50
+ end
51
+
52
+ @nesting = nesting_parts.size
53
+
54
+ lines.join("\n")
55
+ end
56
+
57
+ def footer
58
+ @nesting.times.map do |n|
59
+ "#{" " * (@nesting - n - 1)}end"
60
+ end.join("\n")
61
+ end
62
+
63
+ def method_sig(name, calls)
64
+ "#{" " * @nesting}def #{singleton? ? "self." : ""}#{name}: (#{[args_sig(calls.map(&:pos_args)), kwargs_sig(calls.map(&:kwargs))].compact.join(", ")}) -> (#{return_sig(name, calls.map(&:return_value))})"
65
+ end
66
+
67
+ def args_sig(args)
68
+ return if args.all?(&:empty?)
69
+
70
+ args.transpose.map do |arg_values|
71
+ arg_values.map(&:class).uniq.map do
72
+ constants << _1
73
+ "::#{_1.name}"
74
+ end
75
+ end.join(", ")
76
+ end
77
+
78
+ def kwargs_sig(kwargs)
79
+ return if kwargs.all?(&:empty?)
80
+
81
+ key_values = kwargs.each_with_object(Hash.new { |h, k| h[k] = [] }) { |pairs, acc|
82
+ pairs.each { acc[_1] << _2 }
83
+ }
84
+
85
+ key_values.map do |key, values|
86
+ values_sig = values.map(&:class).uniq.map do
87
+ constants << _1
88
+ "::#{_1.name}"
89
+ end.join(" | ")
90
+
91
+ "?#{key}: (#{values_sig})"
92
+ end.join(", ")
93
+ end
94
+
95
+ def return_sig(name, values)
96
+ # Special case
97
+ return "self" if name == :initialize
98
+
99
+ values.map(&:class).uniq.map do
100
+ constants << _1
101
+ "::#{_1.name}"
102
+ end.join(" | ")
103
+ end
104
+ end
105
+
106
+ def initialize(load_dirs: [])
107
+ @load_dirs = Array(load_dirs)
108
+ end
109
+
110
+ def typecheck!(call_obj, raise_on_missing: false)
111
+ method_name = call_obj.method_name
112
+
113
+ method_call = RBS::Test::ArgumentsReturn.return(
114
+ arguments: call_obj.arguments,
115
+ value: call_obj.return_value
116
+ )
117
+
118
+ call_trace = RBS::Test::CallTrace.new(
119
+ method_name: method_name,
120
+ method_call: method_call,
121
+ # TODO: blocks support
122
+ block_calls: [],
123
+ block_given: false
124
+ )
125
+
126
+ method_type = type_for(call_obj.receiver_class, method_name)
127
+
128
+ unless method_type
129
+ raise MissingSignature, "No signature found for #{call_obj.method_desc}" if raise_on_missing
130
+ return
131
+ end
132
+
133
+ self_class = call_obj.receiver_class
134
+ instance_class = call_obj.receiver_class
135
+ class_class = call_obj.receiver_class.singleton_class? ? call_obj.receiver_class : call_obj.receiver_class.singleton_class
136
+
137
+ typecheck = RBS::Test::TypeCheck.new(
138
+ self_class: self_class,
139
+ builder: builder,
140
+ sample_size: 100, # What should be the value here?
141
+ unchecked_classes: [],
142
+ instance_class: instance_class,
143
+ class_class: class_class
144
+ )
145
+
146
+ errors = []
147
+ typecheck.overloaded_call(
148
+ method_type,
149
+ "#{self_class.singleton_class? ? "." : "#"}#{method_name}",
150
+ call_trace,
151
+ errors: errors
152
+ )
153
+
154
+ reject_returned_doubles!(errors)
155
+
156
+ # TODO: Use custom error class
157
+ raise RBS::Test::Tester::TypeError.new(errors) unless errors.empty?
158
+ end
159
+
160
+ def load_signatures_from_calls(calls)
161
+ constants = Set.new
162
+
163
+ calls.group_by(&:receiver_class).each do |klass, klass_calls|
164
+ calls_per_method = klass_calls.group_by(&:method_name)
165
+ generator = SignatureGenerator.new(klass, calls_per_method)
166
+
167
+ generator.to_rbs.then do |rbs|
168
+ MockSuey.logger.debug "Generated RBS for #{klass.instance_class_name}:\n#{rbs}\n"
169
+ load_rbs(rbs)
170
+ end
171
+
172
+ constants |= generator.constants
173
+ end
174
+
175
+ constants.each do |const|
176
+ next if type_defined?(const)
177
+
178
+ SignatureGenerator.new(const, {}).to_rbs.then do |rbs|
179
+ MockSuey.logger.debug "Generated RBS for constant #{const.instance_class_name}:\n#{rbs}\n"
180
+ load_rbs(rbs)
181
+ end
182
+ end
183
+ end
184
+
185
+ private
186
+
187
+ def load_rbs(rbs)
188
+ ::RBS::Parser.parse_signature(rbs).then do |declarations|
189
+ declarations.each do |decl|
190
+ env << decl
191
+ end
192
+ end
193
+ end
194
+
195
+ def env
196
+ return @env if instance_variable_defined?(:@env)
197
+
198
+ loader = RBS::EnvironmentLoader.new
199
+ @load_dirs&.each { loader.add(path: Pathname(_1)) }
200
+ @env = RBS::Environment.from_loader(loader).resolve_type_names
201
+ end
202
+
203
+ def builder ; @builder ||= RBS::DefinitionBuilder.new(env: env); end
204
+
205
+ def type_for(klass, method_name)
206
+ type = type_for_class(klass.instance_class)
207
+ return unless env.class_decls[type]
208
+
209
+ decl = klass.singleton_class? ? builder.build_singleton(type) : builder.build_instance(type)
210
+
211
+ decl.methods[method_name]
212
+ end
213
+
214
+ def type_for_class(klass)
215
+ *path, name = *klass.instance_class_name.split("::").map(&:to_sym)
216
+
217
+ namespace = path.empty? ? RBS::Namespace.root : RBS::Namespace.new(absolute: true, path: path)
218
+
219
+ RBS::TypeName.new(name: name, namespace: namespace)
220
+ end
221
+
222
+ def type_defined?(klass)
223
+ !env.class_decls[type_for_class(klass.instance_class)].nil?
224
+ end
225
+
226
+ def reject_returned_doubles!(errors)
227
+ return unless defined?(::RSpec::Core)
228
+
229
+ errors.reject! do |error|
230
+ case error
231
+ in RBS::Test::Errors::ReturnTypeError[
232
+ type:,
233
+ value: ::RSpec::Mocks::InstanceVerifyingDouble => double
234
+ ]
235
+ return_class = type.instance_of?(RBS::Types::Bases::Self) ? error.klass : type.name
236
+ return_type = return_class.to_s.gsub(/^::/, "")
237
+ double_type = double.instance_variable_get(:@doubled_module).target.to_s
238
+
239
+ double_type == return_type
240
+ in RBS::Test::Errors::ReturnTypeError[
241
+ value: ::RSpec::Mocks::Double
242
+ ]
243
+ true
244
+ else
245
+ false
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end