matchi 4.2.0 → 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 +91 -227
- data/lib/matchi/be.rb +10 -65
- data/lib/matchi/be_a_kind_of.rb +21 -116
- data/lib/matchi/be_an_instance_of.rb +24 -113
- data/lib/matchi/be_within/of.rb +57 -0
- data/lib/matchi/be_within.rb +17 -134
- data/lib/matchi/change/delta.rb +56 -0
- data/lib/matchi/change/from.rb +21 -61
- data/lib/matchi/change/from_to.rb +46 -0
- data/lib/matchi/change/to.rb +17 -81
- data/lib/matchi/change.rb +73 -148
- data/lib/matchi/constant.rb +55 -0
- data/lib/matchi/eq.rb +24 -65
- data/lib/matchi/match.rb +28 -64
- data/lib/matchi/number.rb +37 -0
- data/lib/matchi/predicate.rb +40 -139
- data/lib/matchi/raise_exception.rb +72 -104
- data/lib/matchi/satisfy.rb +21 -96
- data/lib/matchi.rb +22 -60
- metadata +17 -17
- data/lib/matchi/change/by.rb +0 -137
- data/lib/matchi/change/by_at_least.rb +0 -158
- data/lib/matchi/change/by_at_most.rb +0 -171
- data/lib/matchi/change/from/to.rb +0 -132
|
@@ -1,131 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "constant"
|
|
4
|
+
|
|
3
5
|
module Matchi
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# This matcher provides a secure way to verify an object's exact type, ensuring it
|
|
7
|
-
# matches a specific class without including subclasses. It uses Ruby's method binding
|
|
8
|
-
# mechanism to bypass potential method overrides, providing better protection against
|
|
9
|
-
# type check spoofing than standard instance_of? checks.
|
|
10
|
-
#
|
|
11
|
-
# @example Basic usage
|
|
12
|
-
# matcher = Matchi::BeAnInstanceOf.new(String)
|
|
13
|
-
# matcher.match? { "test" } # => true
|
|
14
|
-
# matcher.match? { :test } # => false
|
|
15
|
-
#
|
|
16
|
-
# @example Inheritance behavior
|
|
17
|
-
# class Animal; end
|
|
18
|
-
# class Dog < Animal; end
|
|
19
|
-
#
|
|
20
|
-
# matcher = Matchi::BeAnInstanceOf.new(Animal)
|
|
21
|
-
# matcher.match? { Animal.new } # => true
|
|
22
|
-
# matcher.match? { Dog.new } # => false # Subclass doesn't match
|
|
23
|
-
#
|
|
24
|
-
# @example Secure type checking
|
|
25
|
-
# # Consider a class that attempts to masquerade as String:
|
|
26
|
-
# class MaliciousString
|
|
27
|
-
# def class; String; end
|
|
28
|
-
# def instance_of?(klass); true; end
|
|
29
|
-
# end
|
|
30
|
-
#
|
|
31
|
-
# obj = MaliciousString.new
|
|
32
|
-
# obj.instance_of?(String) # => true (spoofed)
|
|
33
|
-
#
|
|
34
|
-
# matcher = Matchi::BeAnInstanceOf.new(String)
|
|
35
|
-
# matcher.match? { obj } # => false (secure)
|
|
6
|
+
# Exact type matcher: passes when the class of the actual value is the
|
|
7
|
+
# expected class itself, not a descendant.
|
|
36
8
|
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
9
|
+
# The class of the actual value is read with `Kernel#class` bound to it, so
|
|
10
|
+
# an object that redefines `class` cannot lie, and a `BasicObject` is
|
|
11
|
+
# handled. The expected class can be given directly, or by name; see
|
|
12
|
+
# {Matchi::Constant}.
|
|
40
13
|
#
|
|
41
|
-
#
|
|
42
|
-
# Matchi::BeAnInstanceOf.new(
|
|
43
|
-
#
|
|
44
|
-
# # Using class name as symbol
|
|
45
|
-
# Matchi::BeAnInstanceOf.new(:String)
|
|
46
|
-
#
|
|
47
|
-
# # Using namespaced class
|
|
48
|
-
# Matchi::BeAnInstanceOf.new("MyModule::MyClass")
|
|
49
|
-
#
|
|
50
|
-
# @see Matchi::BeAKindOf
|
|
51
|
-
# @see https://ruby-doc.org/core/Object.html#method-i-instance_of-3F
|
|
52
|
-
# @see https://ruby-doc.org/core/Module.html#method-i-bind_call
|
|
14
|
+
# @example
|
|
15
|
+
# Matchi::BeAnInstanceOf.new(Integer).match? { 42 } # => true
|
|
16
|
+
# Matchi::BeAnInstanceOf.new(:Numeric).match? { 42 } # => false
|
|
53
17
|
class BeAnInstanceOf
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
# @api public
|
|
57
|
-
#
|
|
58
|
-
# @param expected [Class, #to_s] The expected class or its name
|
|
59
|
-
# Can be provided as a Class object, String, or Symbol
|
|
60
|
-
#
|
|
61
|
-
# @raise [ArgumentError] if the class name doesn't start with an uppercase letter
|
|
62
|
-
#
|
|
63
|
-
# @return [BeAnInstanceOf] a new instance of the matcher
|
|
64
|
-
#
|
|
65
|
-
# @example
|
|
66
|
-
# BeAnInstanceOf.new(String) # Using class
|
|
67
|
-
# BeAnInstanceOf.new("String") # Using string
|
|
68
|
-
# BeAnInstanceOf.new(:String) # Using symbol
|
|
18
|
+
# @param expected [Class, String, Symbol] the expected class, or its name
|
|
19
|
+
# @raise [ArgumentError] when expected is neither a Class nor a constant name
|
|
69
20
|
def initialize(expected)
|
|
70
|
-
@expected =
|
|
71
|
-
return if /\A[A-Z]/.match?(@expected)
|
|
72
|
-
|
|
73
|
-
raise ::ArgumentError,
|
|
74
|
-
"expected must start with an uppercase letter (got: #{@expected})"
|
|
21
|
+
@expected = Constant.normalize(expected, ::Class)
|
|
75
22
|
end
|
|
76
23
|
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
80
|
-
#
|
|
81
|
-
#
|
|
82
|
-
# can be overridden.
|
|
83
|
-
#
|
|
84
|
-
# @api public
|
|
85
|
-
#
|
|
86
|
-
# @yield [] Block that returns the object to check
|
|
87
|
-
# @yieldreturn [Object] The object to verify the type of
|
|
88
|
-
#
|
|
89
|
-
# @return [Boolean] true if the object's actual class is exactly the expected class
|
|
90
|
-
#
|
|
91
|
-
# @raise [ArgumentError] if no block is provided
|
|
92
|
-
# @raise [NameError] if the expected class cannot be found
|
|
93
|
-
#
|
|
94
|
-
# @example Simple type check
|
|
95
|
-
# matcher = BeAnInstanceOf.new(String)
|
|
96
|
-
# matcher.match? { "test" } # => true
|
|
97
|
-
# matcher.match? { StringIO.new } # => false
|
|
24
|
+
# @yieldreturn [Object] the actual value
|
|
25
|
+
# @return [Boolean]
|
|
26
|
+
# @raise [ArgumentError] when no block is given
|
|
27
|
+
# @raise [NameError] when the name is not defined
|
|
28
|
+
# @raise [TypeError] when the name does not resolve to a Class
|
|
98
29
|
def match?
|
|
99
30
|
raise ::ArgumentError, "a block must be provided" unless block_given?
|
|
100
31
|
|
|
101
|
-
actual_class = ::
|
|
102
|
-
expected_class == actual_class
|
|
103
|
-
end
|
|
32
|
+
actual_class = ::Kernel.instance_method(:class).bind_call(yield)
|
|
104
33
|
|
|
105
|
-
|
|
106
|
-
#
|
|
107
|
-
# @api public
|
|
108
|
-
#
|
|
109
|
-
# @return [String] A string describing what this matcher verifies
|
|
110
|
-
#
|
|
111
|
-
# @example
|
|
112
|
-
# BeAnInstanceOf.new(String).to_s # => "be an instance of String"
|
|
113
|
-
def to_s
|
|
114
|
-
"be an instance of #{@expected}"
|
|
34
|
+
Constant.resolve(@expected, ::Class).equal?(actual_class)
|
|
115
35
|
end
|
|
116
36
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
#
|
|
121
|
-
# @api private
|
|
122
|
-
#
|
|
123
|
-
# @return [Class] The resolved class object
|
|
124
|
-
# @raise [NameError] If the class name cannot be resolved to an actual class
|
|
125
|
-
#
|
|
126
|
-
# @note This method handles both string and symbol class names through constant resolution
|
|
127
|
-
def expected_class
|
|
128
|
-
::Object.const_get(@expected)
|
|
37
|
+
# @return [String]
|
|
38
|
+
def to_s
|
|
39
|
+
"be an instance of #{Constant.label(@expected)}"
|
|
129
40
|
end
|
|
130
41
|
end
|
|
131
42
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../number"
|
|
4
|
+
|
|
5
|
+
module Matchi
|
|
6
|
+
class BeWithin
|
|
7
|
+
# Numeric proximity matcher: passes when the actual value is a core number
|
|
8
|
+
# whose distance to the expected value is at most delta.
|
|
9
|
+
#
|
|
10
|
+
# The actual value must be an `Integer`, a `Float`, a `Rational` or a
|
|
11
|
+
# `BigDecimal` (its class is read with `Kernel#class` bound to it). Any
|
|
12
|
+
# other value fails, including a user-defined `Numeric` subclass: such an
|
|
13
|
+
# object would control the subtraction through `coerce`.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# Matchi::BeWithin::Of.new(1, 42).match? { 41 } # => true
|
|
17
|
+
# Matchi::BeWithin::Of.new(1, 42).match? { "42" } # => false
|
|
18
|
+
class Of
|
|
19
|
+
# @param delta [Numeric] the tolerance: a finite, real, non-negative number
|
|
20
|
+
# @param expected [Numeric] the center of the interval: a finite, real number
|
|
21
|
+
# @raise [ArgumentError] when an argument is not such a number
|
|
22
|
+
def initialize(delta, expected)
|
|
23
|
+
@delta = Number.non_negative!(delta, "delta")
|
|
24
|
+
@expected = Number.finite_real!(expected, "expected")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @yieldreturn [Object] the actual value
|
|
28
|
+
# @return [Boolean]
|
|
29
|
+
# @raise [ArgumentError] when no block is given
|
|
30
|
+
def match?
|
|
31
|
+
raise ::ArgumentError, "a block must be provided" unless block_given?
|
|
32
|
+
|
|
33
|
+
actual = yield
|
|
34
|
+
return false unless core_number?(actual)
|
|
35
|
+
|
|
36
|
+
(@expected - actual).abs <= @delta
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [String]
|
|
40
|
+
def to_s
|
|
41
|
+
"be within #{@delta.inspect} of #{@expected.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
CORE = [::Integer, ::Float, ::Rational].freeze
|
|
47
|
+
private_constant :CORE
|
|
48
|
+
|
|
49
|
+
def core_number?(actual)
|
|
50
|
+
klass = ::Kernel.instance_method(:class).bind_call(actual)
|
|
51
|
+
return true if CORE.include?(klass)
|
|
52
|
+
|
|
53
|
+
defined?(::BigDecimal) ? klass.equal?(::BigDecimal) : false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/matchi/be_within.rb
CHANGED
|
@@ -1,149 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "be_within/of"
|
|
4
|
+
require_relative "number"
|
|
5
|
+
|
|
3
6
|
module Matchi
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# of an expected value. It's particularly useful for floating-point comparisons or
|
|
8
|
-
# when checking if a value is approximately equal to another within a given tolerance.
|
|
9
|
-
#
|
|
10
|
-
# @example Basic usage with integers
|
|
11
|
-
# matcher = Matchi::BeWithin.new(1).of(10)
|
|
12
|
-
# matcher.match? { 9 } # => true
|
|
13
|
-
# matcher.match? { 10 } # => true
|
|
14
|
-
# matcher.match? { 11 } # => true
|
|
15
|
-
# matcher.match? { 12 } # => false
|
|
7
|
+
# Builder for the `be within DELTA of EXPECTED` matcher. It is not a matcher
|
|
8
|
+
# itself: it does not respond to `match?`, so a framework that checks the
|
|
9
|
+
# matcher interface rejects a forgotten `of`.
|
|
16
10
|
#
|
|
17
|
-
# @example
|
|
18
|
-
#
|
|
19
|
-
# matcher.match? { 3.1 } # => true
|
|
20
|
-
# matcher.match? { 3.2 } # => true
|
|
21
|
-
# matcher.match? { 3.0 } # => false
|
|
22
|
-
#
|
|
23
|
-
# @example Working with percentages
|
|
24
|
-
# matcher = Matchi::BeWithin.new(5).of(100) # 5% margin
|
|
25
|
-
# matcher.match? { 95 } # => true
|
|
26
|
-
# matcher.match? { 105 } # => true
|
|
27
|
-
# matcher.match? { 110 } # => false
|
|
28
|
-
#
|
|
29
|
-
# @see https://ruby-doc.org/core/Numeric.html#method-i-abs
|
|
11
|
+
# @example
|
|
12
|
+
# Matchi::BeWithin.new(0.5).of(3).match? { 3.2 } # => true
|
|
30
13
|
class BeWithin
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
# @api public
|
|
34
|
-
#
|
|
35
|
-
# @param delta [Numeric] The maximum allowed difference from the expected value
|
|
36
|
-
#
|
|
37
|
-
# @raise [ArgumentError] if delta is not a Numeric
|
|
38
|
-
# @raise [ArgumentError] if delta is negative
|
|
39
|
-
#
|
|
40
|
-
# @return [BeWithin] a new instance of the matcher
|
|
41
|
-
#
|
|
42
|
-
# @example
|
|
43
|
-
# BeWithin.new(0.5) # Using float
|
|
44
|
-
# BeWithin.new(2) # Using integer
|
|
14
|
+
# @param delta [Numeric] the tolerance: a finite, real, non-negative number
|
|
15
|
+
# @raise [ArgumentError] when delta is not such a number
|
|
45
16
|
def initialize(delta)
|
|
46
|
-
|
|
47
|
-
raise ::ArgumentError, "delta must be non-negative" if delta.negative?
|
|
48
|
-
|
|
49
|
-
@delta = delta
|
|
17
|
+
@delta = Number.non_negative!(delta, "delta")
|
|
50
18
|
end
|
|
51
19
|
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# by calling the #of method. Direct use of #match? is not supported.
|
|
56
|
-
#
|
|
57
|
-
# @api public
|
|
58
|
-
#
|
|
59
|
-
# @raise [NotImplementedError] always, as this method should not be used
|
|
60
|
-
#
|
|
61
|
-
# @example
|
|
62
|
-
# # Don't do this:
|
|
63
|
-
# BeWithin.new(0.5).match? { 42 } # Raises NotImplementedError
|
|
64
|
-
#
|
|
65
|
-
# # Do this instead:
|
|
66
|
-
# BeWithin.new(0.5).of(42).match? { 41.8 } # Works correctly
|
|
67
|
-
def match?
|
|
68
|
-
raise ::NotImplementedError, "BeWithin is not a complete matcher. Use BeWithin#of to create a valid matcher."
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Specifies the expected reference value.
|
|
72
|
-
#
|
|
73
|
-
# @api public
|
|
74
|
-
#
|
|
75
|
-
# @param expected [Numeric] The reference value to compare against
|
|
76
|
-
#
|
|
77
|
-
# @raise [ArgumentError] if expected is not a Numeric
|
|
78
|
-
#
|
|
79
|
-
# @return [#match?] A matcher that checks if a value is within the specified range
|
|
80
|
-
#
|
|
81
|
-
# @example
|
|
82
|
-
# be_within_wrapper = BeWithin.new(0.5)
|
|
83
|
-
# be_within_wrapper.of(3.14) # Creates matcher for values in range 2.64..3.64
|
|
20
|
+
# @param expected [Numeric] the center of the interval, a finite real number
|
|
21
|
+
# @return [Matchi::BeWithin::Of] the matcher
|
|
22
|
+
# @raise [ArgumentError] when expected is not a finite real Numeric
|
|
84
23
|
def of(expected)
|
|
85
24
|
Of.new(@delta, expected)
|
|
86
25
|
end
|
|
87
26
|
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
# against the expected value using the specified delta.
|
|
92
|
-
class Of
|
|
93
|
-
# Initialize the matcher with a delta and an expected value.
|
|
94
|
-
#
|
|
95
|
-
# @api private
|
|
96
|
-
#
|
|
97
|
-
# @param delta [Numeric] The maximum allowed difference from the expected value
|
|
98
|
-
# @param expected [Numeric] The reference value to compare against
|
|
99
|
-
#
|
|
100
|
-
# @raise [ArgumentError] if delta is not a Numeric
|
|
101
|
-
# @raise [ArgumentError] if expected is not a Numeric
|
|
102
|
-
# @raise [ArgumentError] if delta is negative
|
|
103
|
-
def initialize(delta, expected)
|
|
104
|
-
raise ::ArgumentError, "delta must be a Numeric" unless delta.is_a?(::Numeric)
|
|
105
|
-
raise ::ArgumentError, "expected must be a Numeric" unless expected.is_a?(::Numeric)
|
|
106
|
-
raise ::ArgumentError, "delta must be non-negative" if delta.negative?
|
|
107
|
-
|
|
108
|
-
@delta = delta
|
|
109
|
-
@expected = expected
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Checks if the yielded value is within the accepted range.
|
|
113
|
-
#
|
|
114
|
-
# The value is considered within range if its absolute difference from the
|
|
115
|
-
# expected value is less than or equal to the specified delta.
|
|
116
|
-
#
|
|
117
|
-
# @api public
|
|
118
|
-
#
|
|
119
|
-
# @yield [] Block that returns the value to check
|
|
120
|
-
# @yieldreturn [Numeric] The value to verify
|
|
121
|
-
#
|
|
122
|
-
# @return [Boolean] true if the value is within the accepted range
|
|
123
|
-
#
|
|
124
|
-
# @raise [ArgumentError] if no block is provided
|
|
125
|
-
#
|
|
126
|
-
# @example
|
|
127
|
-
# matcher = BeWithin.new(0.5).of(3.14)
|
|
128
|
-
# matcher.match? { 3.0 } # => true
|
|
129
|
-
# matcher.match? { 4.0 } # => false
|
|
130
|
-
def match?
|
|
131
|
-
raise ::ArgumentError, "a block must be provided" unless block_given?
|
|
132
|
-
|
|
133
|
-
(@expected - yield).abs <= @delta
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# Returns a human-readable description of the matcher.
|
|
137
|
-
#
|
|
138
|
-
# @api public
|
|
139
|
-
#
|
|
140
|
-
# @return [String] A string describing what this matcher verifies
|
|
141
|
-
#
|
|
142
|
-
# @example
|
|
143
|
-
# BeWithin.new(0.5).of(3.14).to_s # => "be within 0.5 of 3.14"
|
|
144
|
-
def to_s
|
|
145
|
-
"be within #{@delta} of #{@expected}"
|
|
146
|
-
end
|
|
27
|
+
# @return [String]
|
|
28
|
+
def to_s
|
|
29
|
+
"be within #{@delta.inspect}"
|
|
147
30
|
end
|
|
148
31
|
end
|
|
149
32
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../number"
|
|
4
|
+
|
|
5
|
+
module Matchi
|
|
6
|
+
class Change
|
|
7
|
+
# Passes when the difference between the observed value after and before
|
|
8
|
+
# the block, `after - before`, is exactly the expected delta (`:by`), at
|
|
9
|
+
# least it (`:by_at_least`) or at most it (`:by_at_most`). The expected
|
|
10
|
+
# delta receives the comparison; the difference is computed by the
|
|
11
|
+
# observed values, so this matcher depends on their honesty.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# list = []
|
|
15
|
+
# Matchi::Change::Delta.new(:by, 2) { list.length }.match? { list.push(1, 2) } # => true
|
|
16
|
+
class Delta
|
|
17
|
+
# For each kind: the comparison the delta receives, and its description.
|
|
18
|
+
KINDS = {
|
|
19
|
+
by: [:==, "by"],
|
|
20
|
+
by_at_least: [:<=, "by at least"],
|
|
21
|
+
by_at_most: [:>=, "by at most"]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# @param kind [Symbol] `:by`, `:by_at_least` or `:by_at_most`
|
|
25
|
+
# @param delta [Numeric] a finite real number, non-negative unless kind is `:by`
|
|
26
|
+
# @yieldreturn [Object] the observed value
|
|
27
|
+
# @raise [ArgumentError] when the kind is unknown, delta is not such a number, or no block is given
|
|
28
|
+
def initialize(kind, delta, &state)
|
|
29
|
+
raise ::ArgumentError, "unknown kind #{kind.inspect}" unless KINDS.key?(kind)
|
|
30
|
+
raise ::ArgumentError, "a block must be provided" unless block_given?
|
|
31
|
+
|
|
32
|
+
@operator, @label = KINDS.fetch(kind)
|
|
33
|
+
@delta = kind == :by ? Number.finite_real!(delta, "delta") : Number.non_negative!(delta, "delta")
|
|
34
|
+
@state = state
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @yieldreturn [Object] ignored, the block is evaluated for its effect
|
|
38
|
+
# @return [Boolean]
|
|
39
|
+
# @raise [ArgumentError] when no block is given
|
|
40
|
+
def match?
|
|
41
|
+
raise ::ArgumentError, "a block must be provided" unless block_given?
|
|
42
|
+
|
|
43
|
+
value_before = @state.call
|
|
44
|
+
yield
|
|
45
|
+
value_after = @state.call
|
|
46
|
+
|
|
47
|
+
@delta.public_send(@operator, value_after - value_before) ? true : false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [String]
|
|
51
|
+
def to_s
|
|
52
|
+
"change #{@label} #{@delta.inspect}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
data/lib/matchi/change/from.rb
CHANGED
|
@@ -1,75 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative
|
|
3
|
+
require_relative "from_to"
|
|
4
4
|
|
|
5
5
|
module Matchi
|
|
6
6
|
class Change
|
|
7
|
-
#
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
35
|
-
#
|
|
36
|
-
# @
|
|
37
|
-
|
|
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
|
-
@
|
|
50
|
-
@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
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
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
|