matchi 4.1.1 → 4.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/matchi/change.rb CHANGED
@@ -12,97 +12,171 @@ module Matchi
12
12
  # Initialize a wrapper of the change matcher with an object and the name of
13
13
  # one of its methods.
14
14
  #
15
- # @example
16
- # require "matchi/change"
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
17
31
  #
18
- # Matchi::Change.new("foo", :to_s)
32
+ # @example With positional arguments
33
+ # hash = { key: "value" }
34
+ # Change.new(hash, :fetch, :key) # Track specific key value
19
35
  #
20
- # @param object [#object_id] An object.
21
- # @param method [Symbol] The name of a method.
22
- def initialize(object, method, ...)
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
43
+ def initialize(object, method, *args, **kwargs, &block)
23
44
  raise ::ArgumentError, "method must be a Symbol" unless method.is_a?(::Symbol)
24
45
  raise ::ArgumentError, "object must respond to method" unless object.respond_to?(method)
25
46
 
26
- @state = -> { object.send(method, ...) }
47
+ @state = -> { object.send(method, *args, **kwargs, &block) }
27
48
  end
28
49
 
29
- # Specifies a minimum delta of the expected change.
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)
83
+ def match?
84
+ raise ::ArgumentError, "a block must be provided" unless block_given?
85
+
86
+ value_before = @state.call
87
+ yield
88
+ value_after = @state.call
89
+
90
+ !value_before.eql?(value_after)
91
+ end
92
+
93
+ # Returns a human-readable description of the matcher.
94
+ #
95
+ # @api public
96
+ #
97
+ # @return [String] A string describing what this matcher verifies
30
98
  #
31
99
  # @example
32
- # require "matchi/change"
100
+ # Change.new("test", :upcase).to_s # => 'eq "test"'
101
+ def to_s
102
+ "change #{@state.inspect}"
103
+ end
104
+
105
+ # Specifies a minimum delta of the expected change.
33
106
  #
34
- # object = []
107
+ # @api public
35
108
  #
36
- # change_wrapper = Matchi::Change.new(object, :length)
37
- # change_wrapper.by_at_least(1)
109
+ # @param minimum_delta [#object_id] The minimum expected change amount
38
110
  #
39
- # @param minimum_delta [#object_id] The minimum delta of the expected change.
111
+ # @return [#match?] A matcher that verifies the minimum change
40
112
  #
41
- # @return [#match?] A *change by at least* matcher.
113
+ # @example
114
+ # counter = 0
115
+ # matcher = Change.new(counter, :to_i).by_at_least(5)
116
+ # matcher.match? { counter += 6 } # => true
42
117
  def by_at_least(minimum_delta)
43
118
  ByAtLeast.new(minimum_delta, &@state)
44
119
  end
45
120
 
46
121
  # Specifies a maximum delta of the expected change.
47
122
  #
48
- # @example
49
- # require "matchi/change"
50
- #
51
- # object = []
123
+ # @api public
52
124
  #
53
- # change_wrapper = Matchi::Change.new(object, :length)
54
- # change_wrapper.by_at_most(1)
125
+ # @param maximum_delta [#object_id] The maximum allowed change amount
55
126
  #
56
- # @param maximum_delta [#object_id] The maximum delta of the expected change.
127
+ # @return [#match?] A matcher that verifies the maximum change
57
128
  #
58
- # @return [#match?] A *change by at most* matcher.
129
+ # @example
130
+ # counter = 0
131
+ # matcher = Change.new(counter, :to_i).by_at_most(5)
132
+ # matcher.match? { counter += 3 } # => true
59
133
  def by_at_most(maximum_delta)
60
134
  ByAtMost.new(maximum_delta, &@state)
61
135
  end
62
136
 
63
- # Specifies the delta of the expected change.
64
- #
65
- # @example
66
- # require "matchi/change"
137
+ # Specifies the exact delta of the expected change.
67
138
  #
68
- # object = []
139
+ # @api public
69
140
  #
70
- # change_wrapper = Matchi::Change.new(object, :length)
71
- # change_wrapper.by(1)
141
+ # @param delta [#object_id] The exact expected change amount
72
142
  #
73
- # @param delta [#object_id] The delta of the expected change.
143
+ # @return [#match?] A matcher that verifies the exact change
74
144
  #
75
- # @return [#match?] A *change by* matcher.
145
+ # @example
146
+ # counter = 0
147
+ # matcher = Change.new(counter, :to_i).by(5)
148
+ # matcher.match? { counter += 5 } # => true
76
149
  def by(delta)
77
150
  By.new(delta, &@state)
78
151
  end
79
152
 
80
- # Specifies the original value.
153
+ # Specifies the original value in a value transition check.
81
154
  #
82
- # @example
83
- # require "matchi/change"
155
+ # @api public
84
156
  #
85
- # change_wrapper = Matchi::Change.new("foo", :to_s)
86
- # change_wrapper.from("foo")
157
+ # @param old_value [#object_id] The expected initial value
87
158
  #
88
- # @param old_value [#object_id] The original value.
159
+ # @return [#to] A wrapper for creating a from/to matcher
89
160
  #
90
- # @return [#match?] A *change from* wrapper.
161
+ # @example
162
+ # string = "foo"
163
+ # Change.new(string, :to_s).from("foo").to("FOO")
91
164
  def from(old_value)
92
165
  From.new(old_value, &@state)
93
166
  end
94
167
 
95
- # Specifies the new value to expect.
168
+ # Specifies the final value to expect.
96
169
  #
97
- # @example
98
- # require "matchi/change"
170
+ # @api public
99
171
  #
100
- # change_wrapper = Matchi::Change.new("foo", :to_s)
101
- # change_wrapper.to("FOO")
172
+ # @param new_value [#object_id] The expected final value
102
173
  #
103
- # @param new_value [#object_id] The new value to expect.
174
+ # @return [#match?] A matcher that verifies the final state
104
175
  #
105
- # @return [#match?] A *change to* matcher.
176
+ # @example
177
+ # string = "foo"
178
+ # matcher = Change.new(string, :to_s).to("FOO")
179
+ # matcher.match? { string.upcase! } # => true
106
180
  def to(new_value)
107
181
  To.new(new_value, &@state)
108
182
  end
data/lib/matchi/eq.rb CHANGED
@@ -1,41 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # *Equivalence* matcher.
4
+ # Value equivalence matcher that checks if two objects have identical values.
5
+ #
6
+ # This matcher verifies value equality using Ruby's Object#eql? method, which
7
+ # compares the values of objects rather than their identity. This is different
8
+ # from identity comparison (equal?) which checks if objects are the same instance.
9
+ #
10
+ # @example Basic usage with strings
11
+ # matcher = Matchi::Eq.new("test")
12
+ # matcher.match? { "test" } # => true
13
+ # matcher.match? { "test".dup } # => true
14
+ # matcher.match? { "other" } # => false
15
+ #
16
+ # @example With numbers
17
+ # matcher = Matchi::Eq.new(42)
18
+ # matcher.match? { 42 } # => true
19
+ # matcher.match? { 42.0 } # => false # Different types
20
+ # matcher.match? { 43 } # => false
21
+ #
22
+ # @example With collections
23
+ # array = [1, 2, 3]
24
+ # matcher = Matchi::Eq.new(array)
25
+ # matcher.match? { array.dup } # => true # Same values
26
+ # matcher.match? { array } # => true # Same object
27
+ # matcher.match? { [1, 2, 3] } # => true # Same values
28
+ # matcher.match? { [1, 2] } # => false # Different values
29
+ #
30
+ # @see https://ruby-doc.org/core/Object.html#method-i-eql-3F
31
+ # @see Matchi::Be
5
32
  class Eq
6
- # Initialize the matcher with an object.
33
+ # Initialize the matcher with a reference value.
7
34
  #
8
- # @example
9
- # require "matchi/eq"
35
+ # @api public
36
+ #
37
+ # @param expected [#eql?] The expected equivalent value
10
38
  #
11
- # Matchi::Eq.new("foo")
39
+ # @return [Eq] a new instance of the matcher
12
40
  #
13
- # @param expected [#eql?] An expected equivalent object.
41
+ # @example
42
+ # Eq.new("test") # Match strings with same value
43
+ # Eq.new([1, 2, 3]) # Match arrays with same elements
14
44
  def initialize(expected)
15
45
  @expected = expected
16
46
  end
17
47
 
18
- # Boolean comparison between the actual value and the expected value.
48
+ # Checks if the yielded object has a value equivalent to the expected object.
19
49
  #
20
- # @example
21
- # require "matchi/eq"
50
+ # This method uses Ruby's Object#eql? method, which performs value comparison.
51
+ # Two objects are considered equivalent if they have the same value, even if
52
+ # they are different instances.
53
+ #
54
+ # @api public
22
55
  #
23
- # matcher = Matchi::Eq.new("foo")
24
- # matcher.match? { "foo" } # => true
56
+ # @yield [] Block that returns the object to check
57
+ # @yieldreturn [Object] The object to verify equivalence with
25
58
  #
26
- # @yieldreturn [#object_id] The actual value to compare to the expected
27
- # one.
59
+ # @return [Boolean] true if both objects have equivalent values
28
60
  #
29
- # @return [Boolean] Comparison between actual and expected values.
61
+ # @raise [ArgumentError] if no block is provided
62
+ #
63
+ # @example
64
+ # matcher = Eq.new([1, 2, 3])
65
+ # matcher.match? { [1, 2, 3] } # => true
66
+ # matcher.match? { [1, 2, 3].dup } # => true
30
67
  def match?
31
68
  raise ::ArgumentError, "a block must be provided" unless block_given?
32
69
 
33
70
  @expected.eql?(yield)
34
71
  end
35
72
 
36
- # Returns a string representing the matcher.
73
+ # Returns a human-readable description of the matcher.
74
+ #
75
+ # @api public
37
76
  #
38
- # @return [String] a human-readable description of the matcher
77
+ # @return [String] A string describing what this matcher verifies
78
+ #
79
+ # @example
80
+ # Eq.new("test").to_s # => 'eq "test"'
39
81
  def to_s
40
82
  "eq #{@expected.inspect}"
41
83
  end
data/lib/matchi/match.rb CHANGED
@@ -1,43 +1,83 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # *Regular expressions* matcher.
4
+ # Pattern matching matcher that checks if a value matches a regular expression.
5
+ #
6
+ # This matcher verifies that a value matches a pattern using Ruby's Regexp#match? method.
7
+ # It's particularly useful for string validation, pattern matching, and text analysis.
8
+ # The matcher ensures secure pattern matching by requiring the pattern to respond to match?.
9
+ #
10
+ # @example Basic usage
11
+ # matcher = Matchi::Match.new(/^test/)
12
+ # matcher.match? { "test_string" } # => true
13
+ # matcher.match? { "other_string" } # => false
14
+ #
15
+ # @example Case sensitivity
16
+ # matcher = Matchi::Match.new(/^test$/i)
17
+ # matcher.match? { "TEST" } # => true
18
+ # matcher.match? { "Test" } # => true
19
+ # matcher.match? { "testing" } # => false
20
+ #
21
+ # @example Multiline patterns
22
+ # matcher = Matchi::Match.new(/\A\d+\Z/m)
23
+ # matcher.match? { "123" } # => true
24
+ # matcher.match? { "12.3" } # => false
25
+ #
26
+ # @see https://ruby-doc.org/core/Regexp.html#method-i-match-3F
5
27
  class Match
6
- # Initialize the matcher with an instance of Regexp.
28
+ # Initialize the matcher with a pattern.
7
29
  #
8
- # @example
9
- # require "matchi/match"
30
+ # @api public
31
+ #
32
+ # @param expected [#match?] A pattern that responds to match?
33
+ #
34
+ # @raise [ArgumentError] if the pattern doesn't respond to match?
10
35
  #
11
- # Matchi::Match.new(/^foo$/)
36
+ # @return [Match] a new instance of the matcher
12
37
  #
13
- # @param expected [#match] A regular expression.
38
+ # @example
39
+ # Match.new(/\d+/) # Match digits
40
+ # Match.new(/^test$/i) # Case-insensitive match
41
+ # Match.new(/\A\w+\Z/) # Full string word characters
14
42
  def initialize(expected)
15
43
  raise ::ArgumentError, "expected must respond to match?" unless expected.respond_to?(:match?)
16
44
 
17
45
  @expected = expected
18
46
  end
19
47
 
20
- # Boolean comparison between the actual value and the expected value.
48
+ # Checks if the yielded value matches the expected pattern.
21
49
  #
22
- # @example
23
- # require "matchi/match"
50
+ # This method uses the pattern's match? method to perform the comparison.
51
+ # The match is performed on the entire string unless the pattern specifically
52
+ # allows partial matches.
53
+ #
54
+ # @api public
24
55
  #
25
- # matcher = Matchi::Match.new(/^foo$/)
26
- # matcher.match? { "foo" } # => true
56
+ # @yield [] Block that returns the value to check
57
+ # @yieldreturn [#to_s] The value to match against the pattern
27
58
  #
28
- # @yieldreturn [#object_id] The actual value to compare to the expected
29
- # one.
59
+ # @return [Boolean] true if the value matches the pattern
30
60
  #
31
- # @return [Boolean] Comparison between actual and expected values.
61
+ # @raise [ArgumentError] if no block is provided
62
+ #
63
+ # @example
64
+ # matcher = Match.new(/^\d{3}-\d{2}-\d{4}$/)
65
+ # matcher.match? { "123-45-6789" } # => true
66
+ # matcher.match? { "123456789" } # => false
32
67
  def match?
33
68
  raise ::ArgumentError, "a block must be provided" unless block_given?
34
69
 
35
70
  @expected.match?(yield)
36
71
  end
37
72
 
38
- # Returns a string representing the matcher.
73
+ # Returns a human-readable description of the matcher.
74
+ #
75
+ # @api public
39
76
  #
40
- # @return [String] a human-readable description of the matcher
77
+ # @return [String] A string describing what this matcher verifies
78
+ #
79
+ # @example
80
+ # Match.new(/^test/).to_s # => "match /^test/"
41
81
  def to_s
42
82
  "match #{@expected.inspect}"
43
83
  end
@@ -1,45 +1,109 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # *Predicate* matcher.
4
+ # Predicate matcher that checks if an object responds to a predicate method with a truthy value.
5
+ #
6
+ # This matcher converts a predicate name (starting with 'be_' or 'have_') into a method call
7
+ # ending with '?' and verifies that calling this method returns a boolean value. It's useful
8
+ # for testing state-checking methods and collection properties. The matcher supports two types
9
+ # of predicate formats: 'be_*' which converts to '*?' and 'have_*' which converts to 'has_*?'.
10
+ #
11
+ # @example Basic empty check
12
+ # matcher = Matchi::Predicate.new(:be_empty)
13
+ # matcher.match? { [] } # => true
14
+ # matcher.match? { [1, 2] } # => false
15
+ #
16
+ # @example Object property check with arguments
17
+ # matcher = Matchi::Predicate.new(:have_key, :name)
18
+ # matcher.match? { { name: "Alice" } } # => true
19
+ # matcher.match? { { age: 30 } } # => false
20
+ #
21
+ # @example Using keyword arguments
22
+ # class Record
23
+ # def complete?(status: nil)
24
+ # status.nil? || status == :validated
25
+ # end
26
+ # end
27
+ #
28
+ # matcher = Matchi::Predicate.new(:be_complete, status: :validated)
29
+ # matcher.match? { Record.new } # => true
30
+ #
31
+ # @example With block arguments
32
+ # class List
33
+ # def all?(&block)
34
+ # block ? super : empty?
35
+ # end
36
+ # end
37
+ #
38
+ # matcher = Matchi::Predicate.new(:be_all) { |x| x.positive? }
39
+ # matcher.match? { [1, 2, 3] } # => true
40
+ # matcher.match? { [-1, 2, 3] } # => false
41
+ #
42
+ # @see https://ruby-doc.org/core/Object.html#method-i-respond_to-3F
5
43
  class Predicate
6
- # Initialize the matcher with a name and arguments.
44
+ # Mapping of predicate prefixes to their method name transformations.
45
+ # Each entry defines how a prefix should be converted to its method form.
7
46
  #
8
- # @example
9
- # require "matchi/predicate"
47
+ # @api private
48
+ PREFIXES = {
49
+ "be_" => ->(name) { "#{name.gsub(/\A(?:be_)/, "")}?" },
50
+ "have_" => ->(name) { "#{name.gsub(/\A(?:have_)/, "has_")}?" }
51
+ }.freeze
52
+
53
+ # Initialize the matcher with a predicate name and optional arguments.
54
+ #
55
+ # @api public
56
+ #
57
+ # @param name [#to_s] A matcher name starting with 'be_' or 'have_'
58
+ # @param args [Array] Optional positional arguments to pass to the predicate method
59
+ # @param kwargs [Hash] Optional keyword arguments to pass to the predicate method
60
+ # @param block [Proc] Optional block to pass to the predicate method
61
+ #
62
+ # @raise [ArgumentError] if the predicate name format is invalid
63
+ #
64
+ # @return [Predicate] a new instance of the matcher
10
65
  #
11
- # Matchi::Predicate.new(:be_empty)
66
+ # @example With simple predicate
67
+ # Predicate.new(:be_empty) # Empty check
12
68
  #
13
- # @param name [#to_s] A matcher name.
14
- # @param args [Array] A list of parameters.
15
- # @param kwargs [Hash] A list of keyword parameters.
16
- # @param block [Proc] A block of code.
69
+ # @example With arguments
70
+ # Predicate.new(:have_key, :id) # Key presence check
71
+ #
72
+ # @example With keyword arguments
73
+ # Predicate.new(:be_valid, status: true) # Conditional validation
17
74
  def initialize(name, *args, **kwargs, &block)
18
75
  @name = String(name)
19
76
  raise ::ArgumentError, "invalid predicate name format" unless valid_name?
20
77
 
21
- @args = args
78
+ @args = args
22
79
  @kwargs = kwargs
23
- @block = block
80
+ @block = block
24
81
  end
25
82
 
26
- # Boolean comparison between the actual value and the expected value.
83
+ # Checks if the yielded object responds to and returns true for the predicate.
27
84
  #
28
- # @example
29
- # require "matchi/predicate"
85
+ # This method converts the predicate name into a method name according to the prefix
86
+ # mapping and calls it on the yielded object with any provided arguments. The method
87
+ # must return a boolean value, or a TypeError will be raised.
30
88
  #
31
- # matcher = Matchi::Predicate.new(:be_empty)
32
- # matcher.match? { [] } # => true
89
+ # @api public
33
90
  #
34
- # @example
35
- # require "matchi/predicate"
91
+ # @yield [] Block that returns the object to check
92
+ # @yieldreturn [Object] The object to call the predicate method on
36
93
  #
37
- # matcher = Matchi::Predicate.new(:have_key, :foo)
38
- # matcher.match? { { foo: 42 } } # => true
94
+ # @return [Boolean] true if the predicate method returns true
39
95
  #
40
- # @yieldreturn [#object_id] The actual value to receive the method request.
96
+ # @raise [ArgumentError] if no block is provided
97
+ # @raise [TypeError] if predicate method returns non-boolean value
41
98
  #
42
- # @return [Boolean] A boolean returned by the actual value being tested.
99
+ # @example Basic usage
100
+ # matcher = Predicate.new(:be_empty)
101
+ # matcher.match? { [] } # => true
102
+ # matcher.match? { [1] } # => false
103
+ #
104
+ # @example With arguments
105
+ # matcher = Predicate.new(:have_key, :id)
106
+ # matcher.match? { { id: 1 } } # => true
43
107
  def match?
44
108
  raise ::ArgumentError, "a block must be provided" unless block_given?
45
109
 
@@ -49,9 +113,20 @@ module Matchi
49
113
  raise ::TypeError, "Boolean expected, but #{value.class} instance returned."
50
114
  end
51
115
 
52
- # Returns a string representing the matcher.
116
+ # Returns a human-readable description of the matcher.
117
+ #
118
+ # @api public
53
119
  #
54
- # @return [String] a human-readable description of the matcher
120
+ # @return [String] A string describing what this matcher verifies
121
+ #
122
+ # @example Simple predicate
123
+ # Predicate.new(:be_empty).to_s # => "be empty"
124
+ #
125
+ # @example With arguments
126
+ # Predicate.new(:have_key, :id).to_s # => "have key :id"
127
+ #
128
+ # @example With keyword arguments
129
+ # Predicate.new(:be_valid, active: true).to_s # => "be valid active: true"
55
130
  def to_s
56
131
  (
57
132
  "#{@name.tr("_", " ")} " + [
@@ -64,20 +139,34 @@ module Matchi
64
139
 
65
140
  private
66
141
 
67
- # The name of the method to send to the object.
142
+ # Converts the predicate name into the actual method name to call.
143
+ #
144
+ # @api private
145
+ #
146
+ # @return [Symbol] The method name to call on the object
147
+ # @raise [ArgumentError] if the predicate prefix is unknown
148
+ #
149
+ # @example
150
+ # # With be_ prefix
151
+ # method_name # => :empty? (from be_empty)
152
+ # # With have_ prefix
153
+ # method_name # => :has_key? (from have_key)
68
154
  def method_name
69
- if @name.start_with?("be_")
70
- :"#{@name.gsub("be_", "")}?"
71
- else
72
- :"#{@name.gsub("have_", "has_")}?"
73
- end
155
+ _, transform = PREFIXES.find { |prefix, _| @name.start_with?(prefix) }
156
+ return transform.call(@name) if transform
157
+
158
+ raise ::ArgumentError, "unknown prefix in predicate name: #{@name}"
74
159
  end
75
160
 
76
- # Verify the matcher name structure.
161
+ # Verifies that the predicate name follows the required format.
162
+ #
163
+ # @api private
164
+ #
165
+ # @return [Boolean] true if the name follows the required format
77
166
  def valid_name?
78
- return false if @name.end_with?("?", "!")
167
+ return false if @name.match?(/[?!]\z/)
79
168
 
80
- @name.start_with?("be_", "have_")
169
+ PREFIXES.keys.any? { |prefix| @name.start_with?(prefix) }
81
170
  end
82
171
  end
83
172
  end