crspec 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
+ require "securerandom"
4
+ require "monitor"
5
+
6
+ module Crspec
7
+ class ExecutionContext
8
+ STORAGE_KEY = :crspec_execution_context
9
+
10
+ attr_reader :example_id, :metadata, :parent
11
+
12
+ def self.current
13
+ Fiber[STORAGE_KEY] ||= new(SecureRandom.uuid)
14
+ end
15
+
16
+ def self.isolate(example_id, metadata = {})
17
+ parent_context = Fiber[STORAGE_KEY]
18
+ new_context = new(example_id, metadata, parent_context)
19
+
20
+ # Fiber storage inheritance preserves context while isolating mutations
21
+ Fiber.new(storage: { STORAGE_KEY => new_context }) do
22
+ yield new_context
23
+ end.resume
24
+ end
25
+
26
+ def initialize(example_id, metadata = {}, parent = nil)
27
+ @example_id = example_id
28
+ @metadata = metadata.freeze
29
+ @parent = parent
30
+ @memoized_values = {}
31
+ @monitor = Monitor.new
32
+ end
33
+
34
+ def fetch_memoized(key, &block)
35
+ return @memoized_values[key] if @memoized_values.key?(key)
36
+
37
+ @monitor.synchronize do
38
+ return @memoized_values[key] if @memoized_values.key?(key)
39
+
40
+ @memoized_values[key] = block.call
41
+ end
42
+ end
43
+
44
+ def [](key)
45
+ @monitor.synchronize do
46
+ @memoized_values[key]
47
+ end
48
+ end
49
+
50
+ def []=(key, value)
51
+ @monitor.synchronize do
52
+ @memoized_values[key] = value
53
+ end
54
+ end
55
+
56
+ def reset!
57
+ @monitor.synchronize do
58
+ @memoized_values.clear
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "matchers"
4
+
5
+ module Crspec
6
+ class ExpectationNotMetError < StandardError; end
7
+
8
+ class ExpectationTarget
9
+ def initialize(actual)
10
+ @actual = actual
11
+ end
12
+
13
+ def to(matcher = nil, &block)
14
+ matcher ||= block
15
+ return matcher.setup_expect(@actual) if matcher.respond_to?(:setup_expect)
16
+
17
+ raise ExpectationNotMetError, matcher.failure_message unless matcher.matches?(@actual)
18
+
19
+ true
20
+ end
21
+
22
+ def not_to(matcher = nil, &block)
23
+ matcher ||= block
24
+ if matcher.matches?(@actual)
25
+ msg = matcher.respond_to?(:failure_message_when_negated) ? matcher.failure_message_when_negated : "Expected matcher not to match"
26
+ raise ExpectationNotMetError, msg
27
+ end
28
+ true
29
+ end
30
+
31
+ alias to_not not_to
32
+ end
33
+
34
+ module Expectations
35
+ include Matchers
36
+
37
+ UNDEFINED = Object.new.freeze
38
+
39
+ def expect(value = UNDEFINED, &block)
40
+ actual = value.equal?(UNDEFINED) ? block : value
41
+ ExpectationTarget.new(actual)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crspec
4
+ module Formatters
5
+ class ProgressFormatter
6
+ def initialize(output = $stdout, color: output.respond_to?(:tty?) && output.tty?)
7
+ @output = output
8
+ @color = color
9
+ @mutex = Mutex.new
10
+ end
11
+
12
+ def example_passed(_example)
13
+ @mutex.synchronize do
14
+ @output.print @color ? "\e[32m.\e[0m" : "."
15
+ @output.flush
16
+ end
17
+ end
18
+
19
+ def example_failed(_example)
20
+ @mutex.synchronize do
21
+ @output.print @color ? "\e[31mF\e[0m" : "F"
22
+ @output.flush
23
+ end
24
+ end
25
+
26
+ def example_pending(_example)
27
+ @mutex.synchronize do
28
+ @output.print @color ? "\e[33m*\e[0m" : "*"
29
+ @output.flush
30
+ end
31
+ end
32
+
33
+ def start; end
34
+
35
+ def finish
36
+ @mutex.synchronize do
37
+ @output.puts
38
+ end
39
+ end
40
+ end
41
+
42
+ class NullFormatter
43
+ def example_passed(_example); end
44
+ def example_failed(_example); end
45
+ def example_pending(_example); end
46
+ def start; end
47
+ def finish; end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crspec
4
+ module Matchers
5
+ class BaseMatcher
6
+ attr_reader :actual, :expected
7
+
8
+ def initialize(expected = nil)
9
+ @expected = expected
10
+ end
11
+
12
+ def failure_message
13
+ "Expected #{@actual.inspect} to match #{@expected.inspect}"
14
+ end
15
+
16
+ def failure_message_when_negated
17
+ "Expected #{@actual.inspect} not to match #{@expected.inspect}"
18
+ end
19
+ end
20
+
21
+ class EqMatcher < BaseMatcher
22
+ def matches?(actual)
23
+ @actual = actual
24
+ @actual == @expected
25
+ end
26
+
27
+ def failure_message
28
+ "Expected #{@expected.inspect}, got #{@actual.inspect}"
29
+ end
30
+
31
+ def failure_message_when_negated
32
+ "Expected value not to equal #{@expected.inspect}"
33
+ end
34
+ end
35
+
36
+ class BeMatcher < BaseMatcher
37
+ def matches?(actual)
38
+ @actual = actual
39
+ if @expected.nil? && !instance_variable_defined?(:@expected)
40
+ true
41
+ elsif @expected.equal?(true) || @expected.equal?(false)
42
+ @actual == @expected
43
+ else
44
+ @actual.equal?(@expected)
45
+ end
46
+ end
47
+
48
+ def failure_message
49
+ "Expected #{@expected.inspect} (object_id: #{@expected.object_id}), got #{@actual.inspect} (object_id: #{@actual.object_id})"
50
+ end
51
+ end
52
+
53
+ class BeNilMatcher < BaseMatcher
54
+ def matches?(actual)
55
+ @actual = actual
56
+ @actual.nil?
57
+ end
58
+
59
+ def failure_message
60
+ "Expected #{@actual.inspect} to be nil"
61
+ end
62
+
63
+ def failure_message_when_negated
64
+ "Expected #{@actual.inspect} not to be nil"
65
+ end
66
+ end
67
+
68
+ class BeTruthyMatcher < BaseMatcher
69
+ def matches?(actual)
70
+ @actual = actual
71
+ !!@actual
72
+ end
73
+
74
+ def failure_message
75
+ "Expected #{@actual.inspect} to be truthy"
76
+ end
77
+ end
78
+
79
+ class BeFalsyMatcher < BaseMatcher
80
+ def matches?(actual)
81
+ @actual = actual
82
+ !@actual
83
+ end
84
+
85
+ def failure_message
86
+ "Expected #{@actual.inspect} to be falsy"
87
+ end
88
+ end
89
+
90
+ class IncludeMatcher < BaseMatcher
91
+ def matches?(actual)
92
+ @actual = actual
93
+ if @expected.is_a?(Array)
94
+ @expected.all? { |e| @actual.include?(e) }
95
+ else
96
+ @actual.include?(@expected)
97
+ end
98
+ end
99
+
100
+ def failure_message
101
+ "Expected #{@actual.inspect} to include #{@expected.inspect}"
102
+ end
103
+
104
+ def failure_message_when_negated
105
+ "Expected #{@actual.inspect} not to include #{@expected.inspect}"
106
+ end
107
+ end
108
+
109
+ class RaiseErrorMatcher < BaseMatcher
110
+ def initialize(expected_exception = StandardError, expected_message = nil)
111
+ super(expected_exception)
112
+ @expected_exception = expected_exception
113
+ @expected_message = expected_message
114
+ @actual_exception = nil
115
+ end
116
+
117
+ def matches?(block)
118
+ raise ArgumentError, "raise_error matcher requires a block" unless block.respond_to?(:call)
119
+
120
+ begin
121
+ block.call
122
+ false
123
+ rescue Exception => e
124
+ @actual_exception = e
125
+ matches_exception?(e)
126
+ end
127
+ end
128
+
129
+ def failure_message
130
+ if @actual_exception
131
+ "Expected #{@expected_exception}#{@expected_message ? " with message #{@expected_message.inspect}" : ""}, but got #{@actual_exception.class}: #{@actual_exception.message.inspect}"
132
+ else
133
+ "Expected #{@expected_exception} to be raised, but nothing was raised"
134
+ end
135
+ end
136
+
137
+ def failure_message_when_negated
138
+ "Expected no exception, but #{@actual_exception.class} was raised"
139
+ end
140
+
141
+ private
142
+
143
+ def matches_exception?(e)
144
+ return false unless e.is_a?(@expected_exception)
145
+ return true unless @expected_message
146
+
147
+ if @expected_message.is_a?(Regexp)
148
+ @expected_message.match?(e.message)
149
+ else
150
+ e.message.include?(@expected_message.to_s)
151
+ end
152
+ end
153
+ end
154
+
155
+ class RespondToMatcher < BaseMatcher
156
+ def initialize(*methods)
157
+ super(methods)
158
+ @methods = methods
159
+ end
160
+
161
+ def matches?(actual)
162
+ @actual = actual
163
+ @methods.all? { |m| @actual.respond_to?(m) }
164
+ end
165
+
166
+ def failure_message
167
+ "Expected #{@actual.inspect} to respond to #{@methods.map(&:inspect).join(", ")}"
168
+ end
169
+ end
170
+
171
+ def eq(expected)
172
+ EqMatcher.new(expected)
173
+ end
174
+
175
+ def be(expected = nil)
176
+ BeMatcher.new(expected)
177
+ end
178
+
179
+ def be_nil
180
+ BeNilMatcher.new
181
+ end
182
+
183
+ def be_truthy
184
+ BeTruthyMatcher.new
185
+ end
186
+
187
+ def be_falsy
188
+ BeFalsyMatcher.new
189
+ end
190
+
191
+ def include(*expected)
192
+ IncludeMatcher.new(expected.size == 1 ? expected.first : expected)
193
+ end
194
+
195
+ def raise_error(expected_exception = StandardError, message = nil)
196
+ RaiseErrorMatcher.new(expected_exception, message)
197
+ end
198
+
199
+ def respond_to(*methods)
200
+ RespondToMatcher.new(*methods)
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "space"
4
+
5
+ module Crspec
6
+ module Mock
7
+ class MockError < StandardError; end
8
+
9
+ class Double
10
+ attr_reader :name
11
+
12
+ def initialize(name = nil, stubs = {})
13
+ @name = name || "Double"
14
+ Space.current.register_double(self)
15
+ stubs.each do |method_name, return_value|
16
+ Space.current.register_stub(self, method_name, proc { return_value })
17
+ end
18
+ end
19
+
20
+ def verify_expectations!
21
+ # Verified via Space
22
+ end
23
+
24
+ def inspect
25
+ "#<Double #{@name.inspect}>"
26
+ end
27
+ end
28
+
29
+ class StubChain
30
+ attr_reader :target, :method_name
31
+
32
+ def initialize(target, method_name)
33
+ @target = target
34
+ @method_name = method_name.to_sym
35
+ @expected_args = nil
36
+ @return_proc = proc {}
37
+ register_default_stub
38
+ end
39
+
40
+ def with(*args)
41
+ @expected_args = args
42
+ update_stub
43
+ self
44
+ end
45
+
46
+ def and_return(*values)
47
+ if values.size == 1
48
+ val = values.first
49
+ @return_proc = proc { val }
50
+ else
51
+ idx = 0
52
+ @return_proc = proc do
53
+ res = values[idx] || values.last
54
+ idx += 1
55
+ res
56
+ end
57
+ end
58
+ update_stub
59
+ self
60
+ end
61
+
62
+ def and_raise(exception = StandardError, message = nil)
63
+ @return_proc = proc do
64
+ err = exception.is_a?(Class) ? exception.new(message) : exception
65
+ raise err
66
+ end
67
+ update_stub
68
+ self
69
+ end
70
+
71
+ def and_yield(*yield_args)
72
+ old_proc = @return_proc
73
+ @return_proc = proc do |*args, &block|
74
+ block&.call(*yield_args)
75
+ old_proc.call(*args)
76
+ end
77
+ update_stub
78
+ self
79
+ end
80
+
81
+ private
82
+
83
+ def register_default_stub
84
+ update_stub
85
+ end
86
+
87
+ def update_stub
88
+ target = @target
89
+ method_name = @method_name
90
+ expected_args = @expected_args
91
+ return_proc = @return_proc
92
+
93
+ implementation = proc do |*args, **kwargs, &block|
94
+ if expected_args && args != expected_args
95
+ raise MockError, "Expected #{method_name} with #{expected_args.inspect}, got #{args.inspect}"
96
+ end
97
+
98
+ return_proc.call(*args, **kwargs, &block)
99
+ end
100
+
101
+ Space.current.register_stub(target, method_name, implementation)
102
+ end
103
+ end
104
+
105
+ class ExpectationChain < StubChain
106
+ def initialize(target, method_name)
107
+ @call_count = 0
108
+ @expected_count = 1
109
+ @count_constraint = :exact
110
+ super(target, method_name)
111
+ Space.current.register_expectation(self)
112
+ end
113
+
114
+ def once
115
+ @expected_count = 1
116
+ @count_constraint = :exact
117
+ self
118
+ end
119
+
120
+ def twice
121
+ @expected_count = 2
122
+ @count_constraint = :exact
123
+ self
124
+ end
125
+
126
+ def exactly(n)
127
+ @expected_count = n
128
+ @count_constraint = :exact
129
+ self
130
+ end
131
+
132
+ def at_least(n)
133
+ @expected_count = n
134
+ @count_constraint = :at_least
135
+ self
136
+ end
137
+
138
+ def verify_expectations!
139
+ valid = case @count_constraint
140
+ when :exact then @call_count == @expected_count
141
+ when :at_least then @call_count >= @expected_count
142
+ end
143
+
144
+ return if valid
145
+
146
+ raise MockError,
147
+ "Expected #{@target.inspect} to receive #{@method_name.inspect} #{@count_constraint} #{@expected_count} times, but received it #{@call_count} times"
148
+ end
149
+
150
+ private
151
+
152
+ def update_stub
153
+ target = @target
154
+ method_name = @method_name
155
+ expected_args = @expected_args
156
+ return_proc = @return_proc
157
+
158
+ implementation = proc do |*args, **kwargs, &block|
159
+ @call_count += 1
160
+ if expected_args && args != expected_args
161
+ raise MockError, "Expected #{method_name} with #{expected_args.inspect}, got #{args.inspect}"
162
+ end
163
+
164
+ return_proc.call(*args, **kwargs, &block)
165
+ end
166
+
167
+ Space.current.register_stub(target, method_name, implementation)
168
+ end
169
+ end
170
+
171
+ class ReceiveMatcher
172
+ attr_reader :method_name
173
+
174
+ def initialize(method_name)
175
+ @method_name = method_name.to_sym
176
+ @expected_args = nil
177
+ @return_values = nil
178
+ @raise_exception = nil
179
+ @yield_args = nil
180
+ @expected_count = 1
181
+ @count_constraint = :exact
182
+ end
183
+
184
+ def with(*args)
185
+ @expected_args = args
186
+ self
187
+ end
188
+
189
+ def and_return(*values)
190
+ @return_values = values
191
+ self
192
+ end
193
+
194
+ def and_raise(exception = StandardError, message = nil)
195
+ @raise_exception = exception
196
+ @raise_message = message
197
+ self
198
+ end
199
+
200
+ def and_yield(*args)
201
+ @yield_args = args
202
+ self
203
+ end
204
+
205
+ def once
206
+ @expected_count = 1
207
+ @count_constraint = :exact
208
+ self
209
+ end
210
+
211
+ def twice
212
+ @expected_count = 2
213
+ @count_constraint = :exact
214
+ self
215
+ end
216
+
217
+ def exactly(n)
218
+ @expected_count = n
219
+ @count_constraint = :exact
220
+ self
221
+ end
222
+
223
+ def at_least(n)
224
+ @expected_count = n
225
+ @count_constraint = :at_least
226
+ self
227
+ end
228
+
229
+ def setup_allow(target)
230
+ chain = StubChain.new(target, @method_name)
231
+ apply_chain_options(chain)
232
+ chain
233
+ end
234
+
235
+ def setup_expect(target)
236
+ chain = ExpectationChain.new(target, @method_name)
237
+ chain.instance_variable_set(:@expected_count, @expected_count)
238
+ chain.instance_variable_set(:@count_constraint, @count_constraint)
239
+ apply_chain_options(chain)
240
+ chain
241
+ end
242
+
243
+ private
244
+
245
+ def apply_chain_options(chain)
246
+ chain.with(*@expected_args) if @expected_args
247
+ chain.and_return(*@return_values) if @return_values
248
+ chain.and_raise(@raise_exception, @raise_message) if @raise_exception
249
+ chain.and_yield(*@yield_args) if @yield_args
250
+ end
251
+ end
252
+
253
+ class AllowTarget
254
+ def initialize(target)
255
+ @target = target
256
+ end
257
+
258
+ def to(matcher)
259
+ if matcher.respond_to?(:setup_allow)
260
+ matcher.setup_allow(@target)
261
+ else
262
+ matcher.call(@target)
263
+ end
264
+ end
265
+ end
266
+
267
+ module DSL
268
+ def double(name = nil, stubs = {})
269
+ Double.new(name, stubs)
270
+ end
271
+
272
+ def instance_double(target_class, name = nil, stubs = {})
273
+ Double.new(name || target_class.name, stubs)
274
+ end
275
+
276
+ def allow(target)
277
+ AllowTarget.new(target)
278
+ end
279
+
280
+ def receive(method_name)
281
+ ReceiveMatcher.new(method_name)
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Crspec
4
+ module Mock
5
+ module Interceptor
6
+ def method_missing(method_name, *args, **kwargs, &block)
7
+ space = Fiber[Space::STORAGE_KEY]
8
+ if space && (stub = space.fetch_stub(self, method_name))
9
+ stub.call(*args, **kwargs, &block)
10
+ else
11
+ super
12
+ end
13
+ end
14
+
15
+ def respond_to_missing?(method_name, include_private = false)
16
+ space = Fiber[Space::STORAGE_KEY]
17
+ space&.fetch_stub(self, method_name) || super
18
+ end
19
+
20
+ def self.add_intercept_method(method_name)
21
+ method_sym = method_name.to_sym
22
+ return if instance_methods(false).include?(method_sym) || private_instance_methods(false).include?(method_sym)
23
+
24
+ define_method(method_sym) do |*args, **kwargs, &block|
25
+ space = Fiber[Space::STORAGE_KEY]
26
+ if space && (stub = space.fetch_stub(self, method_sym))
27
+ stub.call(*args, **kwargs, &block)
28
+ else
29
+ super(*args, **kwargs, &block)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end