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.
- checksums.yaml +4 -4
- data/LICENSE.md +1 -1
- data/README.md +87 -294
- data/lib/matchi/be.rb +11 -24
- data/lib/matchi/be_a_kind_of.rb +21 -63
- data/lib/matchi/be_an_instance_of.rb +24 -101
- data/lib/matchi/be_within/of.rb +39 -33
- data/lib/matchi/be_within.rb +19 -25
- data/lib/matchi/change/delta.rb +56 -0
- data/lib/matchi/change/from.rb +22 -32
- data/lib/matchi/change/from_to.rb +46 -0
- data/lib/matchi/change/to.rb +19 -34
- data/lib/matchi/change.rb +85 -86
- data/lib/matchi/constant.rb +55 -0
- data/lib/matchi/eq.rb +26 -25
- data/lib/matchi/match.rb +30 -26
- data/lib/matchi/number.rb +37 -0
- data/lib/matchi/predicate.rb +46 -56
- data/lib/matchi/raise_exception.rb +75 -40
- data/lib/matchi/satisfy.rb +22 -26
- data/lib/matchi.rb +22 -306
- metadata +18 -11
- data/lib/matchi/change/by.rb +0 -60
- data/lib/matchi/change/by_at_least.rb +0 -61
- data/lib/matchi/change/by_at_most.rb +0 -61
- data/lib/matchi/change/from/to.rb +0 -65
data/lib/matchi/change.rb
CHANGED
|
@@ -1,110 +1,109 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative
|
|
4
|
-
require_relative
|
|
5
|
-
require_relative
|
|
6
|
-
require_relative
|
|
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
|
-
#
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# @
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
|
|
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
|
-
@
|
|
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# @
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
#
|
|
64
|
-
#
|
|
65
|
-
# @
|
|
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
|
-
|
|
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
|
-
#
|
|
81
|
-
#
|
|
82
|
-
# @
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
#
|
|
88
|
-
# @
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# @
|
|
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
|
-
|
|
27
|
+
Eq.eql?(@expected, yield)
|
|
34
28
|
end
|
|
35
29
|
|
|
36
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
21
|
-
#
|
|
22
|
-
# @
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
data/lib/matchi/predicate.rb
CHANGED
|
@@ -1,83 +1,73 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "constant"
|
|
4
|
+
|
|
3
5
|
module Matchi
|
|
4
|
-
#
|
|
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
|
-
#
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# @param
|
|
14
|
-
# @
|
|
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, "
|
|
31
|
+
raise ::ArgumentError, "name must match #{NAME.inspect} (got: #{name.inspect})" unless NAME.match?(@name)
|
|
20
32
|
|
|
21
|
-
@
|
|
33
|
+
@method_name = method_name
|
|
34
|
+
@args = args
|
|
22
35
|
@kwargs = kwargs
|
|
23
|
-
@block
|
|
36
|
+
@block = block
|
|
24
37
|
end
|
|
25
38
|
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
# @
|
|
29
|
-
#
|
|
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
|
-
|
|
47
|
-
return
|
|
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, "
|
|
50
|
+
raise ::TypeError, "#{@method_name} must return true or false (got a #{Constant.label(result_class(result))})"
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# @return [String] a human-readable description of the matcher
|
|
53
|
+
# @return [String]
|
|
55
54
|
def to_s
|
|
56
|
-
(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
return false if @name.end_with?("?", "!")
|
|
66
|
+
"has_#{@name.delete_prefix("have_")}?"
|
|
67
|
+
end
|
|
79
68
|
|
|
80
|
-
|
|
69
|
+
def result_class(result)
|
|
70
|
+
::Kernel.instance_method(:class).bind_call(result)
|
|
81
71
|
end
|
|
82
72
|
end
|
|
83
73
|
end
|