mock-suey 0.0.1 → 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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