matchi 4.1.1 → 5.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.
data/lib/matchi/change.rb CHANGED
@@ -1,110 +1,109 @@
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
- # @example
16
- # require "matchi/change"
17
- #
18
- # Matchi::Change.new("foo", :to_s)
19
- #
20
- # @param object [#object_id] An object.
21
- # @param method [Symbol] The name of a method.
22
- def initialize(object, method, ...)
23
- raise ::ArgumentError, "method must be a Symbol" unless method.is_a?(::Symbol)
24
- raise ::ArgumentError, "object must respond to method" unless object.respond_to?(method)
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
39
+ def initialize(object, method, *args, **kwargs, &block)
40
+ raise ::ArgumentError, "method must be a Symbol or a String (got: #{method.inspect})" unless name?(method)
25
41
 
26
- @state = -> { object.send(method, ...) }
42
+ @object = object
43
+ @method = method.to_sym
44
+ @state = -> { ::Kernel.instance_method(:public_send).bind_call(object, @method, *args, **kwargs, &block) }
27
45
  end
28
46
 
29
- # Specifies a minimum delta of the expected change.
30
- #
31
- # @example
32
- # require "matchi/change"
33
- #
34
- # object = []
35
- #
36
- # change_wrapper = Matchi::Change.new(object, :length)
37
- # change_wrapper.by_at_least(1)
38
- #
39
- # @param minimum_delta [#object_id] The minimum delta of the expected change.
40
- #
41
- # @return [#match?] A *change by at least* matcher.
42
- def by_at_least(minimum_delta)
43
- ByAtLeast.new(minimum_delta, &@state)
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
52
+ def match?
53
+ raise ::ArgumentError, "a block must be provided" unless block_given?
54
+
55
+ value_before = @state.call
56
+ yield
57
+ value_after = @state.call
58
+
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"
44
63
  end
45
64
 
46
- # Specifies a maximum delta of the expected change.
47
- #
48
- # @example
49
- # require "matchi/change"
50
- #
51
- # object = []
52
- #
53
- # change_wrapper = Matchi::Change.new(object, :length)
54
- # change_wrapper.by_at_most(1)
55
- #
56
- # @param maximum_delta [#object_id] The maximum delta of the expected change.
57
- #
58
- # @return [#match?] A *change by at most* matcher.
59
- def by_at_most(maximum_delta)
60
- ByAtMost.new(maximum_delta, &@state)
65
+ # @return [String] the observed class and method, such as `change Array#length`
66
+ def to_s
67
+ "change #{Constant.label(::Kernel.instance_method(:class).bind_call(@object))}##{@method}"
61
68
  end
62
69
 
63
- # Specifies the delta of the expected change.
64
- #
65
- # @example
66
- # require "matchi/change"
67
- #
68
- # object = []
69
- #
70
- # change_wrapper = Matchi::Change.new(object, :length)
71
- # change_wrapper.by(1)
72
- #
73
- # @param delta [#object_id] The delta of the expected change.
74
- #
75
- # @return [#match?] A *change by* matcher.
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
76
73
  def by(delta)
77
- By.new(delta, &@state)
74
+ Delta.new(:by, delta, &@state)
75
+ end
76
+
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)
78
82
  end
79
83
 
80
- # Specifies the original value.
81
- #
82
- # @example
83
- # require "matchi/change"
84
- #
85
- # change_wrapper = Matchi::Change.new("foo", :to_s)
86
- # change_wrapper.from("foo")
87
- #
88
- # @param old_value [#object_id] The original value.
89
- #
90
- # @return [#match?] A *change from* wrapper.
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)
89
+ end
90
+
91
+ # @param old_value [#eql?] the expected value before the block
92
+ # @return [Matchi::Change::From] a builder; call `to` on it
91
93
  def from(old_value)
92
94
  From.new(old_value, &@state)
93
95
  end
94
96
 
95
- # Specifies the new value to expect.
96
- #
97
- # @example
98
- # require "matchi/change"
99
- #
100
- # change_wrapper = Matchi::Change.new("foo", :to_s)
101
- # change_wrapper.to("FOO")
102
- #
103
- # @param new_value [#object_id] The new value to expect.
104
- #
105
- # @return [#match?] A *change to* matcher.
97
+ # @param new_value [#eql?] the expected value after the block
98
+ # @return [Matchi::Change::To]
106
99
  def to(new_value)
107
100
  To.new(new_value, &@state)
108
101
  end
102
+
103
+ private
104
+
105
+ def name?(method)
106
+ ::Symbol === method || ::String === method
107
+ end
109
108
  end
110
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
data/lib/matchi/eq.rb CHANGED
@@ -1,43 +1,44 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # *Equivalence* matcher.
4
+ # Equivalence matcher: passes when the actual value is of the same class as
5
+ # the expected value and `eql?` to it.
6
+ #
7
+ # The expected value receives the `eql?` call, so the actual value cannot
8
+ # lie. The class check comes first because a few core `eql?` methods, such
9
+ # as `Hash#eql?`, hand the comparison back to an operand of another class;
10
+ # it costs nothing, since `eql?` never holds across classes for core values.
11
+ #
12
+ # @example
13
+ # Matchi::Eq.new("foo").match? { "foo" } # => true
14
+ # Matchi::Eq.new(1).match? { 1.0 } # => false (eql? is strict on type)
5
15
  class Eq
6
- # Initialize the matcher with an object.
7
- #
8
- # @example
9
- # require "matchi/eq"
10
- #
11
- # Matchi::Eq.new("foo")
12
- #
13
- # @param expected [#eql?] An expected equivalent object.
16
+ # @param expected [#eql?] the expected value
14
17
  def initialize(expected)
15
18
  @expected = expected
16
19
  end
17
20
 
18
- # Boolean comparison between the actual value and the expected value.
19
- #
20
- # @example
21
- # require "matchi/eq"
22
- #
23
- # matcher = Matchi::Eq.new("foo")
24
- # matcher.match? { "foo" } # => true
25
- #
26
- # @yieldreturn [#object_id] The actual value to compare to the expected
27
- # one.
28
- #
29
- # @return [Boolean] Comparison between actual and expected values.
21
+ # @yieldreturn [Object] the actual value
22
+ # @return [Boolean]
23
+ # @raise [ArgumentError] when no block is given
30
24
  def match?
31
25
  raise ::ArgumentError, "a block must be provided" unless block_given?
32
26
 
33
- @expected.eql?(yield)
27
+ Eq.eql?(@expected, yield)
34
28
  end
35
29
 
36
- # Returns a string representing the matcher.
37
- #
38
- # @return [String] a human-readable description of the matcher
30
+ # @return [String]
39
31
  def to_s
40
32
  "eq #{@expected.inspect}"
41
33
  end
34
+
35
+ # @param expected [#eql?] the value that receives the comparison
36
+ # @param actual [Object] a value from the code under test
37
+ # @return [Boolean] whether actual is of the class of expected and `eql?` to it
38
+ def self.eql?(expected, actual)
39
+ return false unless ::Kernel.instance_method(:class).bind_call(actual).equal?(expected.class)
40
+
41
+ expected.eql?(actual) ? true : false
42
+ end
42
43
  end
43
44
  end
data/lib/matchi/match.rb CHANGED
@@ -1,43 +1,47 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Matchi
4
- # *Regular expressions* matcher.
4
+ # Regular expression matcher: passes when the actual value is a String or a
5
+ # Symbol that the expected Regexp matches. Any other actual value fails, as
6
+ # does a string with an invalid or incompatible encoding.
7
+ #
8
+ # Only a `Regexp` is accepted as pattern: with a `String` pattern,
9
+ # `String#match?` would turn the actual value into the regular expression,
10
+ # handing the comparison to the code under test. Use {Matchi::Satisfy} for
11
+ # other predicates.
12
+ #
13
+ # @example
14
+ # Matchi::Match.new(/^foo/).match? { "foobar" } # => true
15
+ # Matchi::Match.new(/^foo/).match? { 42 } # => false
5
16
  class Match
6
- # Initialize the matcher with an instance of Regexp.
7
- #
8
- # @example
9
- # require "matchi/match"
10
- #
11
- # Matchi::Match.new(/^foo$/)
12
- #
13
- # @param expected [#match] A regular expression.
17
+ # @param expected [Regexp] the pattern
18
+ # @raise [ArgumentError] when expected is not a Regexp
14
19
  def initialize(expected)
15
- raise ::ArgumentError, "expected must respond to match?" unless expected.respond_to?(:match?)
20
+ raise ::ArgumentError, "expected must be a Regexp (got: #{expected.inspect})" unless ::Regexp === expected
16
21
 
17
22
  @expected = expected
18
23
  end
19
24
 
20
- # Boolean comparison between the actual value and the expected value.
21
- #
22
- # @example
23
- # require "matchi/match"
24
- #
25
- # matcher = Matchi::Match.new(/^foo$/)
26
- # matcher.match? { "foo" } # => true
27
- #
28
- # @yieldreturn [#object_id] The actual value to compare to the expected
29
- # one.
30
- #
31
- # @return [Boolean] Comparison between actual and expected values.
25
+ # @yieldreturn [Object] the actual value
26
+ # @return [Boolean]
27
+ # @raise [ArgumentError] when no block is given
32
28
  def match?
33
29
  raise ::ArgumentError, "a block must be provided" unless block_given?
34
30
 
35
- @expected.match?(yield)
31
+ actual = yield
32
+ return false unless ::String === actual || ::Symbol === actual
33
+
34
+ string = ::Symbol === actual ? actual.name : actual
35
+ return false unless string.valid_encoding?
36
+
37
+ begin
38
+ @expected.match?(string)
39
+ rescue ::Encoding::CompatibilityError
40
+ false
41
+ end
36
42
  end
37
43
 
38
- # Returns a string representing the matcher.
39
- #
40
- # @return [String] a human-readable description of the matcher
44
+ # @return [String]
41
45
  def to_s
42
46
  "match #{@expected.inspect}"
43
47
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Matchi
4
+ # Validation of the numbers given to matchers: deltas and expected values
5
+ # must be finite and real, so that a `NaN`, an infinity or a `Complex`
6
+ # cannot build a matcher that never, or always, passes.
7
+ #
8
+ # @api private
9
+ module Number
10
+ # @param value [Object]
11
+ # @return [Boolean] whether value is a finite real Numeric
12
+ def self.finite_real?(value)
13
+ ::Numeric === value && value.real? && value.finite?
14
+ end
15
+
16
+ # @param value [Object]
17
+ # @param role [String] the argument name, for the error message
18
+ # @return [Numeric] value
19
+ # @raise [ArgumentError] when value is not a finite real Numeric
20
+ def self.finite_real!(value, role)
21
+ raise ::ArgumentError, "#{role} must be a finite real Numeric (got: #{value.inspect})" unless finite_real?(value)
22
+
23
+ value
24
+ end
25
+
26
+ # @param value [Object]
27
+ # @param role [String] the argument name, for the error message
28
+ # @return [Numeric] value
29
+ # @raise [ArgumentError] when value is not a finite real non-negative Numeric
30
+ def self.non_negative!(value, role)
31
+ finite_real!(value, role)
32
+ raise ::ArgumentError, "#{role} must be non-negative (got: #{value.inspect})" if value.negative?
33
+
34
+ value
35
+ end
36
+ end
37
+ end
@@ -1,83 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "constant"
4
+
3
5
  module Matchi
4
- # *Predicate* matcher.
6
+ # Predicate matcher: passes when a predicate method of the actual value
7
+ # returns `true`. The matcher name `be_xxx` calls `xxx?`, and `have_xxx`
8
+ # calls `has_xxx?`, with `Kernel#public_send` bound to the actual value.
9
+ # The predicate must return `true` or `false`; any other value is an error,
10
+ # not a truthy pass.
11
+ #
12
+ # Since the actual value answers the call, this matcher depends on the
13
+ # honesty of the object under test. Use it for the object's own documented
14
+ # predicates.
15
+ #
16
+ # @example
17
+ # Matchi::Predicate.new(:be_empty).match? { [] } # => true
18
+ # Matchi::Predicate.new(:have_key, :a).match? { { a: 1 } } # => true
5
19
  class Predicate
6
- # Initialize the matcher with a name and arguments.
7
- #
8
- # @example
9
- # require "matchi/predicate"
10
- #
11
- # Matchi::Predicate.new(:be_empty)
12
- #
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.
20
+ # A matcher name: `be_` or `have_`, then a letter, then letters, digits
21
+ # or underscores, in any script. It never ends with `?` or `!`.
22
+ NAME = /\A(be|have)_\p{Alpha}[\p{Alnum}_]*\z/
23
+
24
+ # @param name [Symbol, String] `be_xxx` or `have_xxx`
25
+ # @param args [Array] positional arguments for the predicate
26
+ # @param kwargs [Hash] keyword arguments for the predicate
27
+ # @param block [Proc, nil] a block for the predicate
28
+ # @raise [ArgumentError] when the name does not start with be_ or have_
17
29
  def initialize(name, *args, **kwargs, &block)
18
30
  @name = String(name)
19
- raise ::ArgumentError, "invalid predicate name format" unless valid_name?
31
+ raise ::ArgumentError, "name must match #{NAME.inspect} (got: #{name.inspect})" unless NAME.match?(@name)
20
32
 
21
- @args = args
33
+ @method_name = method_name
34
+ @args = args
22
35
  @kwargs = kwargs
23
- @block = block
36
+ @block = block
24
37
  end
25
38
 
26
- # Boolean comparison between the actual value and the expected value.
27
- #
28
- # @example
29
- # require "matchi/predicate"
30
- #
31
- # matcher = Matchi::Predicate.new(:be_empty)
32
- # matcher.match? { [] } # => true
33
- #
34
- # @example
35
- # require "matchi/predicate"
36
- #
37
- # matcher = Matchi::Predicate.new(:have_key, :foo)
38
- # matcher.match? { { foo: 42 } } # => true
39
- #
40
- # @yieldreturn [#object_id] The actual value to receive the method request.
41
- #
42
- # @return [Boolean] A boolean returned by the actual value being tested.
39
+ # @yieldreturn [Object] the actual value
40
+ # @return [Boolean]
41
+ # @raise [ArgumentError] when no block is given
42
+ # @raise [TypeError] when the predicate returns something other than true or false
43
+ # @raise [NoMethodError] when the actual value has no such public predicate
43
44
  def match?
44
45
  raise ::ArgumentError, "a block must be provided" unless block_given?
45
46
 
46
- value = yield.send(method_name, *@args, **@kwargs, &@block)
47
- return value if [false, true].include?(value)
47
+ result = ::Kernel.instance_method(:public_send).bind_call(yield, @method_name, *@args, **@kwargs, &@block)
48
+ return result if true.equal?(result) || false.equal?(result)
48
49
 
49
- raise ::TypeError, "Boolean expected, but #{value.class} instance returned."
50
+ raise ::TypeError, "#{@method_name} must return true or false (got a #{Constant.label(result_class(result))})"
50
51
  end
51
52
 
52
- # Returns a string representing the matcher.
53
- #
54
- # @return [String] a human-readable description of the matcher
53
+ # @return [String]
55
54
  def to_s
56
- (
57
- "#{@name.tr("_", " ")} " + [
58
- @args.map(&:inspect).join(", "),
59
- @kwargs.map { |k, v| "#{k}: #{v.inspect}" }.join(", "),
60
- (@block.nil? ? "" : "&block")
61
- ].reject { |i| i.eql?("") }.join(", ")
62
- ).strip
55
+ arguments = @args.map(&:inspect) + @kwargs.map { |key, value| "#{key}: #{value.inspect}" }
56
+ arguments << "&block" if @block
57
+
58
+ [@name.tr("_", " "), arguments.join(", ")].reject(&:empty?).join(" ")
63
59
  end
64
60
 
65
61
  private
66
62
 
67
- # The name of the method to send to the object.
68
63
  def method_name
69
- if @name.start_with?("be_")
70
- :"#{@name.gsub("be_", "")}?"
71
- else
72
- :"#{@name.gsub("have_", "has_")}?"
73
- end
74
- end
64
+ return "#{@name.delete_prefix("be_")}?" if @name.start_with?("be_")
75
65
 
76
- # Verify the matcher name structure.
77
- def valid_name?
78
- return false if @name.end_with?("?", "!")
66
+ "has_#{@name.delete_prefix("have_")}?"
67
+ end
79
68
 
80
- @name.start_with?("be_", "have_")
69
+ def result_class(result)
70
+ ::Kernel.instance_method(:class).bind_call(result)
81
71
  end
82
72
  end
83
73
  end