matchi 4.2.0 → 6.0.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.
@@ -1,75 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("from", "to")
3
+ require_relative "from_to"
4
4
 
5
5
  module Matchi
6
6
  class Change
7
- # Initial state wrapper for building a value transition matcher.
7
+ # Builder for the `change from OLD to NEW` matcher. It is not a matcher
8
+ # itself: it does not respond to `match?`, so a forgotten `to` is rejected
9
+ # by any framework that checks the matcher interface.
8
10
  #
9
- # This class acts as a wrapper that captures the expected initial state and
10
- # provides methods to build a complete transition matcher. When combined with
11
- # the 'to' method, it creates a matcher that verifies both the starting and
12
- # ending values of a change operation. This is useful when you need to ensure
13
- # not only the final state but also the initial state of a value.
14
- #
15
- # @example Basic string transformation
16
- # text = "hello"
17
- # Change.new(text, :to_s).from("hello").to("HELLO").match? { text.upcase! } # => true
18
- #
19
- # @example Object state transition
20
- # class User
21
- # attr_accessor :status
22
- # def initialize
23
- # @status = "pending"
24
- # end
25
- # end
26
- #
27
- # user = User.new
28
- # Change.new(user, :status).from("pending").to("active").match? {
29
- # user.status = "active"
30
- # } # => true
31
- #
32
- # @see Matchi::Change::From::To For the complete transition matcher
11
+ # @example
12
+ # Matchi::Change.new(s, :to_s).from("a").to("b")
33
13
  class From
34
- # Initialize the wrapper with an object and a block.
35
- #
36
- # @example
37
- # require "matchi/change/from"
38
- #
39
- # object = "foo"
40
- #
41
- # Matchi::Change::From.new("foo") { object.to_s }
42
- #
43
- # @param expected [#object_id] An expected initial value.
44
- # @param state [Proc] A block of code to execute to get the
45
- # state of the object.
46
- def initialize(expected, &state)
14
+ # @param old_value [#eql?] the expected value before the block
15
+ # @yieldreturn [Object] the observed value
16
+ # @raise [ArgumentError] when no block is given
17
+ def initialize(old_value, &state)
47
18
  raise ::ArgumentError, "a block must be provided" unless block_given?
48
19
 
49
- @expected = expected
50
- @state = state
20
+ @old_value = old_value
21
+ @state = state
22
+ end
23
+
24
+ # @param new_value [#eql?] the expected value after the block
25
+ # @return [Matchi::Change::FromTo]
26
+ def to(new_value)
27
+ FromTo.new(@old_value, new_value, &@state)
51
28
  end
52
29
 
53
- # Specifies the new value to expect.
54
- #
55
- # Creates a complete transition matcher that verifies both the initial
56
- # and final states of a value. The matcher will succeed only if the
57
- # value starts at the expected initial state and changes to the specified
58
- # new value after executing the test block.
59
- #
60
- # @example
61
- # require "matchi/change/from"
62
- #
63
- # object = "foo"
64
- #
65
- # change_from_wrapper = Matchi::Change::From.new("foo") { object.to_s }
66
- # change_from_wrapper.to("FOO")
67
- #
68
- # @param expected_new_value [#object_id] The new value to expect.
69
- #
70
- # @return [#match?] A *change from to* matcher.
71
- def to(expected_new_value)
72
- To.new(@expected, expected_new_value, &@state)
30
+ # @return [String]
31
+ def to_s
32
+ "change from #{@old_value.inspect}"
73
33
  end
74
34
  end
75
35
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../eq"
4
+
5
+ module Matchi
6
+ class Change
7
+ # Passes when the observed value is the old value before the block and
8
+ # the new value after it, both compared like {Matchi::Eq}. The block is
9
+ # always evaluated; a wrong initial value is a failure of the
10
+ # expectation, like a wrong final value.
11
+ #
12
+ # @example
13
+ # s = +"a"
14
+ # Matchi::Change.new(s, :to_s).from("a").to("b").match? { s.replace("b") } # => true
15
+ class FromTo
16
+ # @param old_value [#eql?] the expected value before the block
17
+ # @param new_value [#eql?] the expected value after the block
18
+ # @yieldreturn [Object] the observed value
19
+ # @raise [ArgumentError] when no block is given
20
+ def initialize(old_value, new_value, &state)
21
+ raise ::ArgumentError, "a block must be provided" unless block_given?
22
+
23
+ @old_value = old_value
24
+ @new_value = new_value
25
+ @state = state
26
+ end
27
+
28
+ # @yieldreturn [Object] ignored, the block is evaluated for its effect
29
+ # @return [Boolean]
30
+ # @raise [ArgumentError] when no block is given
31
+ def match?
32
+ raise ::ArgumentError, "a block must be provided" unless block_given?
33
+
34
+ initial = Eq.eql?(@old_value, @state.call)
35
+ yield
36
+
37
+ initial && Eq.eql?(@new_value, @state.call)
38
+ end
39
+
40
+ # @return [String]
41
+ def to_s
42
+ "change from #{@old_value.inspect} to #{@new_value.inspect}"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -1,56 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../eq"
4
+
3
5
  module Matchi
4
6
  class Change
5
- # Final state matcher that verifies if a method returns an expected value after a change.
6
- #
7
- # This matcher focuses on the final state of an object, verifying that a method call
8
- # returns an expected value after executing a block of code. Unlike the full from/to
9
- # matcher, it only cares about the end result, not the initial state.
10
- #
11
- # @example Basic string transformation
12
- # text = "hello"
13
- # matcher = Matchi::Change::To.new("HELLO") { text.to_s }
14
- # matcher.match? { text.upcase! } # => true
15
- # matcher.match? { text.reverse! } # => false
16
- #
17
- # @example Number calculations
18
- # counter = 0
19
- # matcher = Matchi::Change::To.new(5) { counter }
20
- # matcher.match? { counter = 5 } # => true
21
- # matcher.match? { counter += 1 } # => false
22
- #
23
- # @example With object attributes
24
- # class User
25
- # attr_accessor :status
26
- # def initialize(status)
27
- # @status = status
28
- # end
29
- # end
30
- #
31
- # user = User.new(:pending)
32
- # matcher = Matchi::Change::To.new(:active) { user.status }
33
- # matcher.match? { user.status = :active } # => true
7
+ # Passes when the observed value is not the expected value before the
8
+ # block and is the expected value after it: the value must change to it.
9
+ # The block is always evaluated.
34
10
  #
35
- # @see Matchi::Change::From::To For checking both initial and final states
36
- # @see Matchi::Change::By For checking numeric changes
11
+ # @example
12
+ # s = +"a"
13
+ # Matchi::Change.new(s, :to_s).to("b").match? { s.replace("b") } # => true
37
14
  class To
38
- # Initialize the matcher with an expected new value and a state block.
39
- #
40
- # @api public
41
- #
42
- # @param expected [#eql?] The expected final value
43
- # @param state [Proc] Block that retrieves the value to check
44
- #
45
- # @raise [ArgumentError] if no state block is provided
46
- #
47
- # @return [To] a new instance of the matcher
48
- #
49
- # @example With simple value
50
- # To.new("test") { object.value }
51
- #
52
- # @example With complex calculation
53
- # To.new(100) { object.items.count }
15
+ # @param expected [#eql?] the expected value after the block
16
+ # @yieldreturn [Object] the observed value
17
+ # @raise [ArgumentError] when no block is given
54
18
  def initialize(expected, &state)
55
19
  raise ::ArgumentError, "a block must be provided" unless block_given?
56
20
 
@@ -58,47 +22,19 @@ module Matchi
58
22
  @state = state
59
23
  end
60
24
 
61
- # Checks if the state block returns the expected value after executing the provided block.
62
- #
63
- # This method executes the provided block and then checks if the state block
64
- # returns the expected value. It only cares about the final state, not any
65
- # intermediate values or the initial state.
66
- #
67
- # @api public
68
- #
69
- # @yield [] Block that should cause the state change
70
- # @yieldreturn [Object] The result of the block (not used)
71
- #
72
- # @return [Boolean] true if the final state matches the expected value
73
- #
74
- # @raise [ArgumentError] if no block is provided
75
- #
76
- # @example Basic usage
77
- # text = "hello"
78
- # matcher = To.new("HELLO") { text.to_s }
79
- # matcher.match? { text.upcase! } # => true
80
- #
81
- # @example With method chaining
82
- # array = [1, 2, 3]
83
- # matcher = To.new(6) { array.sum }
84
- # matcher.match? { array.map! { |x| x * 2 } } # => false
25
+ # @yieldreturn [Object] ignored, the block is evaluated for its effect
26
+ # @return [Boolean]
27
+ # @raise [ArgumentError] when no block is given
85
28
  def match?
86
29
  raise ::ArgumentError, "a block must be provided" unless block_given?
87
30
 
31
+ already = Eq.eql?(@expected, @state.call)
88
32
  yield
89
- value_after = @state.call
90
33
 
91
- @expected.eql?(value_after)
34
+ !already && Eq.eql?(@expected, @state.call)
92
35
  end
93
36
 
94
- # Returns a human-readable description of the matcher.
95
- #
96
- # @api public
97
- #
98
- # @return [String] A string describing what this matcher verifies
99
- #
100
- # @example
101
- # To.new("test").to_s # => 'change to "test"'
37
+ # @return [String]
102
38
  def to_s
103
39
  "change to #{@expected.inspect}"
104
40
  end
data/lib/matchi/change.rb CHANGED
@@ -1,85 +1,54 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative File.join("change", "by_at_least")
4
- require_relative File.join("change", "by_at_most")
5
- require_relative File.join("change", "by")
6
- require_relative File.join("change", "from")
7
- require_relative File.join("change", "to")
3
+ require_relative "change/delta"
4
+ require_relative "change/from"
5
+ require_relative "change/to"
6
+ require_relative "constant"
8
7
 
9
8
  module Matchi
10
- # Wraps the target of a change matcher.
9
+ # State change matcher: passes when evaluating the block changes the value
10
+ # returned by a public method of an object.
11
+ #
12
+ # The observed method is called with `public_send` before and after the
13
+ # block, and the two values are compared with `eql?`. The observed method
14
+ # must return a value, not the mutated object itself: when it returns the
15
+ # very same unfrozen object twice, the comparison cannot tell whether that
16
+ # object changed, and `match?` raises instead of answering `false`.
17
+ # `attr_reader :items` with `items << x` is that case; observe
18
+ # `items.length` or use {#to}, which compares with the expected value. A
19
+ # frozen object returned twice counts as an unchanged value.
20
+ #
21
+ # The refinements {#by}, {#by_at_least}, {#by_at_most}, {#from} and {#to}
22
+ # build more specific matchers. `Change`, `by`, `by_at_least` and
23
+ # `by_at_most` compare values produced by the observed object, so they
24
+ # depend on its honesty, like {Matchi::Predicate}. `to` and `from … to`
25
+ # compare with the expected value, like {Matchi::Eq}. Whatever the
26
+ # observed method raises propagates.
27
+ #
28
+ # @example
29
+ # list = []
30
+ # Matchi::Change.new(list, :length).match? { list << 1 } # => true
31
+ # Matchi::Change.new(list, :length).by(1).match? { list << 1 } # => true
11
32
  class Change
12
- # Initialize a wrapper of the change matcher with an object and the name of
13
- # one of its methods.
14
- #
15
- # @api public
16
- #
17
- # @param object [#object_id] The object whose state will be monitored
18
- # @param method [Symbol] The name of the method to track
19
- # @param args [Array] Additional positional arguments to pass to the method
20
- # @param kwargs [Hash] Additional keyword arguments to pass to the method
21
- # @param block [Proc] Optional block to pass to the method
22
- #
23
- # @raise [ArgumentError] if method is not a Symbol
24
- # @raise [ArgumentError] if object doesn't respond to method
25
- #
26
- # @return [Change] a new instance of the change wrapper
27
- #
28
- # @example Basic initialization
29
- # array = []
30
- # Change.new(array, :length) # Track array length changes
31
- #
32
- # @example With positional arguments
33
- # hash = { key: "value" }
34
- # Change.new(hash, :fetch, :key) # Track specific key value
35
- #
36
- # @example With keyword arguments
37
- # hash = { a: 1, b: 2 }
38
- # Change.new(hash, :fetch, default: 0) # Track with default value
39
- #
40
- # @example With block
41
- # hash = { a: 1 }
42
- # Change.new(hash, :fetch, :b) { |k| k.to_s } # Track with block default
33
+ # @param object [Object] the object to observe
34
+ # @param method [Symbol, String] a public method of that object
35
+ # @param args [Array] positional arguments for the method
36
+ # @param kwargs [Hash] keyword arguments for the method
37
+ # @param block [Proc, nil] a block for the method
38
+ # @raise [ArgumentError] when method is neither a Symbol nor a String
43
39
  def initialize(object, method, *args, **kwargs, &block)
44
- raise ::ArgumentError, "method must be a Symbol" unless method.is_a?(::Symbol)
45
- raise ::ArgumentError, "object must respond to method" unless object.respond_to?(method)
40
+ raise ::ArgumentError, "method must be a Symbol or a String (got: #{method.inspect})" unless name?(method)
46
41
 
47
- @state = -> { object.send(method, *args, **kwargs, &block) }
42
+ @object = object
43
+ @method = method.to_sym
44
+ @state = -> { ::Kernel.instance_method(:public_send).bind_call(object, @method, *args, **kwargs, &block) }
48
45
  end
49
46
 
50
- # Checks if the tracked method's return value changes when executing the block.
51
- #
52
- # This method verifies that the value changes in any way between the start and end
53
- # of the block execution. It doesn't care about the type or magnitude of the change,
54
- # only that it's different.
55
- #
56
- # @api public
57
- #
58
- # @yield [] Block during which the change should occur
59
- # @yieldreturn [Object] Result of the block execution (not used)
60
- #
61
- # @return [Boolean] true if the value changed, false otherwise
62
- #
63
- # @raise [ArgumentError] if no block is provided
64
- #
65
- # @example Basic usage with array length
66
- # array = []
67
- # matcher = Change.new(array, :length)
68
- # matcher.match? { array << "item" } # => true
69
- # matcher.match? { array.clear } # => true (from 1 to 0)
70
- # matcher.match? { array.dup } # => false (no change)
71
- #
72
- # @example With method parameters
73
- # hash = { key: "old" }
74
- # matcher = Change.new(hash, :fetch, :key)
75
- # matcher.match? { hash[:key] = "new" } # => true
76
- # matcher.match? { hash[:key] = "new" } # => false (same value)
77
- #
78
- # @example With computed values
79
- # text = "hello"
80
- # matcher = Change.new(text, :upcase)
81
- # matcher.match? { text.upcase! } # => true
82
- # matcher.match? { text.upcase! } # => false (already uppercase)
47
+ # @yieldreturn [Object] ignored, the block is evaluated for its effect
48
+ # @return [Boolean]
49
+ # @raise [ArgumentError] when no block is given
50
+ # @raise [NoMethodError] when the object has no such public method
51
+ # @raise [TypeError] when the observed method returns the same unfrozen object twice
83
52
  def match?
84
53
  raise ::ArgumentError, "a block must be provided" unless block_given?
85
54
 
@@ -87,98 +56,54 @@ module Matchi
87
56
  yield
88
57
  value_after = @state.call
89
58
 
90
- !value_before.eql?(value_after)
59
+ return !value_before.eql?(value_after) unless value_before.equal?(value_after)
60
+ return false if ::Kernel.instance_method(:frozen?).bind_call(value_before)
61
+
62
+ raise ::TypeError, "#{self} returned the same unfrozen object twice: observe a value, not the object"
91
63
  end
92
64
 
93
- # Returns a human-readable description of the matcher.
94
- #
95
- # @api public
96
- #
97
- # @return [String] A string describing what this matcher verifies
98
- #
99
- # @example
100
- # Change.new("test", :upcase).to_s # => 'eq "test"'
65
+ # @return [String] the observed class and method, such as `change Array#length`
101
66
  def to_s
102
- "change #{@state.inspect}"
67
+ "change #{Constant.label(::Kernel.instance_method(:class).bind_call(@object))}##{@method}"
103
68
  end
104
69
 
105
- # Specifies a minimum delta of the expected change.
106
- #
107
- # @api public
108
- #
109
- # @param minimum_delta [#object_id] The minimum expected change amount
110
- #
111
- # @return [#match?] A matcher that verifies the minimum change
112
- #
113
- # @example
114
- # counter = 0
115
- # matcher = Change.new(counter, :to_i).by_at_least(5)
116
- # matcher.match? { counter += 6 } # => true
117
- def by_at_least(minimum_delta)
118
- ByAtLeast.new(minimum_delta, &@state)
70
+ # @param delta [Numeric] the exact expected difference, negative allowed
71
+ # @return [Matchi::Change::Delta]
72
+ # @raise [ArgumentError] when delta is not a finite real Numeric
73
+ def by(delta)
74
+ Delta.new(:by, delta, &@state)
119
75
  end
120
76
 
121
- # Specifies a maximum delta of the expected change.
122
- #
123
- # @api public
124
- #
125
- # @param maximum_delta [#object_id] The maximum allowed change amount
126
- #
127
- # @return [#match?] A matcher that verifies the maximum change
128
- #
129
- # @example
130
- # counter = 0
131
- # matcher = Change.new(counter, :to_i).by_at_most(5)
132
- # matcher.match? { counter += 3 } # => true
133
- def by_at_most(maximum_delta)
134
- ByAtMost.new(maximum_delta, &@state)
77
+ # @param delta [Numeric] the smallest acceptable increase, zero or positive
78
+ # @return [Matchi::Change::Delta]
79
+ # @raise [ArgumentError] when delta is not a finite real non-negative Numeric
80
+ def by_at_least(delta)
81
+ Delta.new(:by_at_least, delta, &@state)
135
82
  end
136
83
 
137
- # Specifies the exact delta of the expected change.
138
- #
139
- # @api public
140
- #
141
- # @param delta [#object_id] The exact expected change amount
142
- #
143
- # @return [#match?] A matcher that verifies the exact change
144
- #
145
- # @example
146
- # counter = 0
147
- # matcher = Change.new(counter, :to_i).by(5)
148
- # matcher.match? { counter += 5 } # => true
149
- def by(delta)
150
- By.new(delta, &@state)
84
+ # @param delta [Numeric] the largest acceptable increase, zero or positive
85
+ # @return [Matchi::Change::Delta]
86
+ # @raise [ArgumentError] when delta is not a finite real non-negative Numeric
87
+ def by_at_most(delta)
88
+ Delta.new(:by_at_most, delta, &@state)
151
89
  end
152
90
 
153
- # Specifies the original value in a value transition check.
154
- #
155
- # @api public
156
- #
157
- # @param old_value [#object_id] The expected initial value
158
- #
159
- # @return [#to] A wrapper for creating a from/to matcher
160
- #
161
- # @example
162
- # string = "foo"
163
- # Change.new(string, :to_s).from("foo").to("FOO")
91
+ # @param old_value [#eql?] the expected value before the block
92
+ # @return [Matchi::Change::From] a builder; call `to` on it
164
93
  def from(old_value)
165
94
  From.new(old_value, &@state)
166
95
  end
167
96
 
168
- # Specifies the final value to expect.
169
- #
170
- # @api public
171
- #
172
- # @param new_value [#object_id] The expected final value
173
- #
174
- # @return [#match?] A matcher that verifies the final state
175
- #
176
- # @example
177
- # string = "foo"
178
- # matcher = Change.new(string, :to_s).to("FOO")
179
- # matcher.match? { string.upcase! } # => true
97
+ # @param new_value [#eql?] the expected value after the block
98
+ # @return [Matchi::Change::To]
180
99
  def to(new_value)
181
100
  To.new(new_value, &@state)
182
101
  end
102
+
103
+ private
104
+
105
+ def name?(method)
106
+ ::Symbol === method || ::String === method
107
+ end
183
108
  end
184
109
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matchi
4
+ # Shared handling of an expected module given directly or by name.
5
+ #
6
+ # A name is a String or a Symbol holding a constant path such as
7
+ # `"Comparable"` or `:"Foo::Bar"`. It is validated when the matcher is built
8
+ # and resolved from `Object` when the matcher runs, which lets a
9
+ # specification be written before the class it describes is loaded. Since
10
+ # resolution goes through `Object.const_get`, an `autoload` or an
11
+ # `Object.const_missing` defined by the code under test takes part in it.
12
+ #
13
+ # @api private
14
+ module Constant
15
+ # A constant path: ASCII segments that start with a capital letter,
16
+ # joined by `::`, such as `"Comparable"` or `"File::Stat"`.
17
+ NAME = /\A[A-Z]\w*(::[A-Z]\w*)*\z/
18
+
19
+ # @param expected [Module, String, Symbol] a module, or its name
20
+ # @param kind [Class] `Module` or `Class`, what expected must be
21
+ # @return [Module, String] the module itself, or the validated name
22
+ # @raise [ArgumentError] when expected is neither
23
+ def self.normalize(expected, kind)
24
+ return expected if kind === expected
25
+ return String(expected) if (::String === expected || ::Symbol === expected) && NAME.match?(expected)
26
+
27
+ raise ::ArgumentError, "expected must be a #{kind} or a constant name (got: #{expected.inspect})"
28
+ end
29
+
30
+ # @param expected [Module, String] a module, or a validated name
31
+ # @return [String] the name, or a stable label for an anonymous module
32
+ #
33
+ # The name is read with `Module#name` bound to the module, so a class
34
+ # of the code under test that redefines `name` cannot break a report.
35
+ def self.label(expected)
36
+ return expected unless ::Module === expected
37
+
38
+ ::Module.instance_method(:name).bind_call(expected) || "an anonymous #{::Class === expected ? "class" : "module"}"
39
+ end
40
+
41
+ # @param expected [Module, String] a module, or a validated name
42
+ # @param kind [Class] `Module` or `Class`, what the name must resolve to
43
+ # @return [Module]
44
+ # @raise [NameError] when the name is not defined
45
+ # @raise [TypeError] when the name does not resolve to the expected kind
46
+ def self.resolve(expected, kind)
47
+ return expected if kind === expected
48
+
49
+ value = ::Object.const_get(expected)
50
+ raise ::TypeError, "#{expected} is not a #{kind}" unless kind === value
51
+
52
+ value
53
+ end
54
+ end
55
+ end