fix 1.0.0.beta11 → 1.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/fix/dsl.rb CHANGED
@@ -1,122 +1,174 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "defi/method"
3
+ require "defi"
4
+ require "spectus"
4
5
 
6
+ require_relative "environment"
7
+ require_relative "level"
5
8
  require_relative "matcher"
6
- require_relative "requirement"
9
+ require_relative "node"
7
10
 
8
11
  module Fix
9
- # Abstract class for handling the domain-specific language.
12
+ # The language of a specification. The block of `Fix :Name do … end` runs
13
+ # on an instance of this class, thrown away afterwards, whose four words
14
+ # build the contexts of the specification: `let`, `with`, `on` and `it`.
15
+ # The matchers ({Fix::Matcher}) and the levels ({Fix::Level}) are words of
16
+ # it too.
10
17
  #
11
- # @api private
18
+ # A gem may add a word that composes these four: fix-its adds `its`.
12
19
  class Dsl
13
- extend Matcher
14
- extend Requirement
20
+ include Level
21
+ include Matcher
15
22
 
16
- # Sets a user-defined property.
17
- #
18
- # @example
19
- # require "fix"
20
- #
21
- # Fix do
22
- # let(:name) { "Bob" }
23
- # end
24
- #
25
- # @param name [String, Symbol] The name of the property.
26
- # @yield The block that defines the property's value
27
- # @yieldreturn [Object] The value to be returned by the property
28
- #
29
- # @return [Symbol] A private method that define the block content.
30
- #
31
- # @api public
32
- def self.let(name, &)
33
- private define_method(name, &)
23
+ # The four words.
24
+ WORDS = %i[let with on it].freeze
25
+ private_constant :WORDS
26
+
27
+ # @yield the document of a specification
28
+ # @return [Array<Fix::Node>] its contexts, each before its children
29
+ def self.build(&)
30
+ dsl = new({}.freeze, [].freeze, "")
31
+ dsl.instance_exec(&)
32
+ dsl.nodes.freeze
34
33
  end
35
34
 
36
- # Defines an example group with user-defined properties that describes a
37
- # unit to be tested.
38
- #
39
- # @example
40
- # require "fix"
41
- #
42
- # Fix do
43
- # with password: "secret" do
44
- # it MUST be true
45
- # end
46
- # end
47
- #
48
- # @param kwargs [Hash] The list of propreties.
49
- # @yield The block that defines the specs for this context
50
- # @yieldreturn [void]
51
- #
52
- # @api public
53
- def self.with(**kwargs, &)
54
- klass = ::Class.new(self)
55
- klass.const_get(:CONTEXTS) << klass
56
- kwargs.each { |name, value| klass.let(name) { value } }
57
- klass.instance_eval(&)
58
- klass
35
+ # @param lets [Hash{Symbol => Proc}] the lets inherited, frozen
36
+ # @param challenges [Array<Defi::Challenge>] the chain inherited, frozen
37
+ # @param chain [String] its text
38
+ # @param own [Array<Symbol>] the lets this context declares already,
39
+ # those a `with` gave it
40
+ # @api private
41
+ def initialize(lets, challenges, chain, own = [])
42
+ @lets = lets
43
+ @challenges = challenges
44
+ @chain = chain
45
+ @own = own
46
+ @expectations = []
47
+ @children = []
59
48
  end
60
49
 
61
- # Defines an example group that describes a unit to be tested.
62
- #
63
- # @example
64
- # require "fix"
65
- #
66
- # Fix do
67
- # on :+, 2 do
68
- # it MUST be 42
69
- # end
70
- # end
71
- #
72
- # @param method_name [String, Symbol] The method to send to the subject.
73
- # @param block [Proc] The block to define the specs.
74
- #
75
- # @api public
76
- def self.on(method_name, *args, **kwargs, &block)
77
- klass = ::Class.new(self)
78
- klass.const_get(:CONTEXTS) << klass
50
+ # @return [Array<Fix::Node>] this context, then its children, as they
51
+ # stand
52
+ def nodes
53
+ node = Node.new(lets: @lets, challenges: @challenges, chain: @chain, expectations: @expectations.dup.freeze)
54
+ [node] + @children
55
+ end
79
56
 
80
- const_set(:"Child#{block.object_id}", klass)
57
+ # A parameter of the subject: its block runs where the subject is built,
58
+ # once per expectation, and may call other lets. A let is not visible
59
+ # from an expectation.
60
+ #
61
+ # @param name [Symbol] its name, which is not a method of the environment
62
+ # nor a word of the language, and not a let of this context already
63
+ # @yieldreturn [Object] its value
64
+ # @return [nil]
65
+ # @raise [ArgumentError] otherwise, or without a block
66
+ def let(name, &block)
67
+ raise ::ArgumentError, "a block must be given" unless block
81
68
 
82
- klass.define_singleton_method(:challenges) do
83
- challenge = ::Defi::Method.new(method_name, *args, **kwargs)
84
- super() + [challenge]
85
- end
69
+ free!(name)
70
+ raise ::ArgumentError, "#{name} is already a let of this context" if @own.include?(name)
86
71
 
87
- klass.instance_eval(&block)
88
- klass
72
+ @own << name
73
+ @lets = @lets.merge(name => block).freeze
74
+ nil
89
75
  end
90
76
 
91
- # Defines a concrete spec definition.
92
- #
93
- # @example
94
- # require "fix"
95
- #
96
- # Fix { it MUST be 42 }
97
- #
98
- # Fix do
99
- # it { MUST be 42 }
100
- # end
101
- #
102
- # @api public
103
- def self.it(requirement = nil, &block)
104
- raise ::ArgumentError, "Must provide either requirement or block, not both" if requirement && block
105
- raise ::ArgumentError, "Must provide either requirement or block" unless requirement || block
77
+ # A context where lets take the values given: the objects themselves,
78
+ # shared by the expectations of the context, which declares these lets.
79
+ #
80
+ # @param values [Hash{Symbol => Object}] the lets, by name
81
+ # @yield the context
82
+ # @return [nil]
83
+ # @raise [ArgumentError] for a name a let could not take, without a value
84
+ # or without a block
85
+ def with(**values, &block)
86
+ raise ::ArgumentError, "a block must be given" unless block
87
+ raise ::ArgumentError, "a with gives a value to a let: with(name: 1)" if values.empty?
106
88
 
107
- location = caller_locations(1, 1).fetch(0)
108
- location = [location.path, location.lineno].join(":")
89
+ values.each_key { |name| free!(name) }
90
+ lets = @lets.merge(values.transform_values { |value| -> { value } }).freeze
91
+ context(lets, @challenges, @chain, values.keys, &block)
92
+ end
93
+
94
+ # A context about what a method of the subject returns, or raises: the
95
+ # method is sent, with its arguments, to the value of the context above.
96
+ # No block can be given to the method: the block is the context.
97
+ #
98
+ # @param name [Symbol] the method, followed by its positional and keyword
99
+ # arguments, as they would be written after it
100
+ # @yield the context
101
+ # @return [nil]
102
+ # @raise [ArgumentError] when the name is not a Symbol, or without a block
103
+ def on(name, *, **, &block)
104
+ raise ::ArgumentError, "a block must be given" unless block
109
105
 
110
- define_method(:"test_#{(requirement || block).object_id}") do
111
- [location, requirement || singleton_class.class_eval(&block), self.class.challenges]
112
- end
106
+ challenge = ::Defi::Challenge.new(name, *, **)
107
+ context(@lets, (@challenges + [challenge]).freeze, text("#{@chain}#{challenge}"), &block)
113
108
  end
114
109
 
115
- # The list of challenges to be addressed to the object to be tested.
116
- #
117
- # @return [Array<Defi::Method>] A list of challenges.
118
- def self.challenges
119
- []
110
+ # An expectation: a requirement, built here and now with a level and a
111
+ # matcher, at the line of the call.
112
+ #
113
+ # @param requirement [Spectus::Requirement] `MUST eq(1)`, say
114
+ # @param location [Thread::Backtrace::Location, nil] where the expectation
115
+ # is written, for a word that composes `it`; the caller of `it` otherwise
116
+ # @return [nil]
117
+ # @raise [ArgumentError] without a requirement, or with a block
118
+ # @raise [TypeError] when the `to_s` of the matcher is not a String
119
+ def it(requirement = nil, location: nil, &block)
120
+ raise ::ArgumentError, "it takes a requirement, not a block: it MUST eq(1)" if block
121
+ raise ::ArgumentError, "it takes a requirement: it MUST eq(1)" unless ::Spectus::Requirement === requirement
122
+ raise ::TypeError, "the to_s of the matcher must be a String" unless ::String === requirement.matcher.to_s
123
+
124
+ location ||= caller_locations(1, 1).fetch(0)
125
+ @expectations << Expectation.new(location: locate(location), requirement:)
126
+ nil
127
+ end
128
+
129
+ private
130
+
131
+ # A let named in an expectation is a mistake with a remedy.
132
+ def method_missing(name, ...)
133
+ return super unless @lets.key?(name)
134
+
135
+ raise ::NameError.new("#{name} is a let, visible in the subject block and in other lets only", name)
136
+ end
137
+
138
+ def respond_to_missing?(_name, _include_private = false)
139
+ false
140
+ end
141
+
142
+ # A child context, whose nodes follow this one's.
143
+ def context(lets, challenges, chain, own = [], &)
144
+ child = Dsl.new(lets, challenges, chain, own)
145
+ child.instance_exec(&)
146
+ @children.concat(child.nodes)
147
+ nil
148
+ end
149
+
150
+ # A name a let can take: a Symbol that is not a method of the environment,
151
+ # public or private, nor a public word of the language, so that nothing
152
+ # is redefined in silence.
153
+ def free!(name)
154
+ raise ::ArgumentError, "the name of a let must be a Symbol" unless ::Symbol === name
155
+ raise ::ArgumentError, "#{name} is a word of the specification language" if WORDS.include?(name)
156
+
157
+ owner = [Environment, Dsl].find { |klass| klass.method_defined?(name) }
158
+ owner ||= Environment if Environment.private_method_defined?(name)
159
+ raise ::ArgumentError, "#{name} is already a method of #{owner.instance_method(name).owner}" if owner
160
+ end
161
+
162
+ # `file:line`, the file relative to the current directory when it is
163
+ # under it.
164
+ def locate(location)
165
+ path = ::File.absolute_path(location.path).delete_prefix(::File.join(::Dir.pwd, ""))
166
+ text("#{path}:#{location.lineno}")
167
+ end
168
+
169
+ # A text of a result, valid UTF-8 and frozen, as those of Spectus.
170
+ def text(string)
171
+ string.encode(::Encoding::UTF_8, invalid: :replace, undef: :replace).freeze
120
172
  end
121
173
  end
122
174
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fix
4
+ # Where a subject is built: an object with a method for each let, whose
5
+ # value is computed once, the first time it is called, by running the
6
+ # block of the let here, so that a let can call another. A new environment
7
+ # is made for each expectation. It is an ordinary object, so that the
8
+ # blocks of a specification can call `Kernel` and the private methods of
9
+ # the top level, as any Ruby code does.
10
+ #
11
+ # @api private
12
+ class Environment
13
+ # @param lets [Hash{Symbol => Proc}] the lets of a context
14
+ def initialize(lets)
15
+ values = {}
16
+ lets.each do |name, block|
17
+ define_singleton_method(name) do
18
+ values.fetch(name) { values[name] = instance_exec(&block) }
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ private_constant :Environment
25
+ end
data/lib/fix/level.rb ADDED
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spectus"
4
+
5
+ module Fix
6
+ # The requirement levels of RFC 2119, as the words of a specification:
7
+ # each one turns a matcher into a `Spectus::Requirement`. A MAY has no
8
+ # negative form.
9
+ module Level
10
+ # @param matcher [#match?] the matcher
11
+ # @return [Spectus::Requirement] an absolute requirement
12
+ def MUST(matcher) # rubocop:disable Naming/MethodName
13
+ ::Spectus.must(matcher)
14
+ end
15
+
16
+ # @param matcher [#match?] the matcher
17
+ # @return [Spectus::Requirement] an absolute prohibition
18
+ def MUST_NOT(matcher) # rubocop:disable Naming/MethodName
19
+ ::Spectus.must_not(matcher)
20
+ end
21
+
22
+ # @param matcher [#match?] the matcher
23
+ # @return [Spectus::Requirement] a recommendation
24
+ def SHOULD(matcher) # rubocop:disable Naming/MethodName
25
+ ::Spectus.should(matcher)
26
+ end
27
+
28
+ # @param matcher [#match?] the matcher
29
+ # @return [Spectus::Requirement] a negated recommendation
30
+ def SHOULD_NOT(matcher) # rubocop:disable Naming/MethodName
31
+ ::Spectus.should_not(matcher)
32
+ end
33
+
34
+ # @param matcher [#match?] the matcher
35
+ # @return [Spectus::Requirement] an option
36
+ def MAY(matcher) # rubocop:disable Naming/MethodName
37
+ ::Spectus.may(matcher)
38
+ end
39
+ end
40
+ end
data/lib/fix/matcher.rb CHANGED
@@ -3,239 +3,98 @@
3
3
  require "matchi"
4
4
 
5
5
  module Fix
6
- # Collection of expectation matchers.
7
- #
8
- # @api private
6
+ # The matchers of a specification, as words: each one builds a matcher of
7
+ # Matchi, or returns a specification, which is a matcher too, and raises
8
+ # `ArgumentError` at load for an argument the matcher refuses. Any object
9
+ # with `match?` and `to_s` is accepted by the levels as well.
9
10
  module Matcher
10
- # Equivalence matcher
11
- #
12
- # @example
13
- # matcher = eq("foo")
14
- # matcher.match? { "foo" } # => true
15
- # matcher.match? { "bar" } # => false
16
- #
17
- # @param expected [#eql?] An expected equivalent object.
18
- #
19
- # @return [#match?] An equivalence matcher.
20
- #
21
- # @api public
11
+ # @param expected [Object] the expected value
12
+ # @return [Matchi::Eq] `eql?` to the expected value, of its class
22
13
  def eq(expected)
23
14
  ::Matchi::Eq.new(expected)
24
15
  end
25
16
 
26
- alias eql eq
27
-
28
- # Identity matcher
29
- #
30
- # @example
31
- # object = "foo"
32
- # matcher = be(object)
33
- # matcher.match? { object } # => true
34
- # matcher.match? { "foo" } # => false
35
- #
36
- # @param expected [#equal?] The expected identical object.
37
- #
38
- # @return [#match?] An identity matcher.
39
- #
40
- # @api public
17
+ # @param expected [Object] the expected object
18
+ # @return [Matchi::Be] the very same object
41
19
  def be(expected)
42
20
  ::Matchi::Be.new(expected)
43
21
  end
44
22
 
45
- alias equal be
46
-
47
- # Comparisons matcher
48
- #
49
- # @example
50
- # matcher = be_within(1).of(41)
51
- # matcher.match? { 42 } # => true
52
- # matcher.match? { 43 } # => false
53
- #
54
- # @param delta [Numeric] A numeric value.
55
- #
56
- # @return [#match?] A comparison matcher.
57
- #
58
- # @api public
23
+ # @param delta [Numeric] the tolerance
24
+ # @return [Matchi::BeWithin] to complete with `of(expected)`
59
25
  def be_within(delta)
60
26
  ::Matchi::BeWithin.new(delta)
61
27
  end
62
28
 
63
- # Regular expressions matcher
64
- #
65
- # @example
66
- # matcher = match(/^foo$/)
67
- # matcher.match? { "foo" } # => true
68
- # matcher.match? { "bar" } # => false
69
- #
70
- # @param expected [#match] A regular expression.
71
- #
72
- # @return [#match?] A regular expression matcher.
73
- #
74
- # @api public
29
+ # @param expected [Regexp] the pattern
30
+ # @return [Matchi::Match] a String or a Symbol matching the pattern
75
31
  def match(expected)
76
32
  ::Matchi::Match.new(expected)
77
33
  end
78
34
 
79
- # Expecting errors matcher
80
- #
81
- # @example
82
- # matcher = raise_exception(NameError)
83
- # matcher.match? { Boom } # => true
84
- # matcher.match? { true } # => false
85
- #
86
- # @param expected [Exception, #to_s] The expected exception name.
87
- #
88
- # @return [#match?] An error matcher.
89
- #
90
- # @api public
91
- def raise_exception(expected)
92
- ::Matchi::RaiseException.new(expected)
35
+ # @param expected [Class, String, Symbol] the exception class, or its name
36
+ # @param message [String, Regexp, nil] the expected message
37
+ # @return [Matchi::RaiseException] raising that exception
38
+ def raise_exception(expected, message = nil)
39
+ ::Matchi::RaiseException.new(expected, message)
93
40
  end
94
41
 
95
- # True matcher
96
- #
97
- # @example
98
- # matcher = be_true
99
- # matcher.match? { true } # => true
100
- # matcher.match? { false } # => false
101
- # matcher.match? { nil } # => false
102
- # matcher.match? { 4 } # => false
103
- #
104
- # @return [#match?] A `true` matcher.
105
- #
106
- # @api public
42
+ # @return [Matchi::Be] `true` itself
107
43
  def be_true
108
- be(true)
44
+ ::Matchi::Be.new(true)
109
45
  end
110
46
 
111
- # False matcher
112
- #
113
- # @example
114
- # matcher = be_false
115
- # matcher.match? { false } # => true
116
- # matcher.match? { true } # => false
117
- # matcher.match? { nil } # => false
118
- # matcher.match? { 4 } # => false
119
- #
120
- # @return [#match?] A `false` matcher.
121
- #
122
- # @api public
47
+ # @return [Matchi::Be] `false` itself
123
48
  def be_false
124
- be(false)
49
+ ::Matchi::Be.new(false)
125
50
  end
126
51
 
127
- # Nil matcher
128
- #
129
- # @example
130
- # matcher = be_nil
131
- # matcher.match? { nil } # => true
132
- # matcher.match? { false } # => false
133
- # matcher.match? { true } # => false
134
- # matcher.match? { 4 } # => false
135
- #
136
- # @return [#match?] A `nil` matcher.
137
- #
138
- # @api public
52
+ # @return [Matchi::Be] `nil` itself
139
53
  def be_nil
140
- be(nil)
54
+ ::Matchi::Be.new(nil)
141
55
  end
142
56
 
143
- # Type/class matcher
144
- #
145
- # @example
146
- # matcher = be_an_instance_of(String)
147
- # matcher.match? { "foo" } # => true
148
- # matcher.match? { 4 } # => false
149
- #
150
- # @param expected [Class, #to_s] The expected class name.
151
- #
152
- # @return [#match?] A type/class matcher.
153
- #
154
- # @api public
57
+ # @param expected [Class, String, Symbol] the class, or its name
58
+ # @return [Matchi::BeAnInstanceOf] an instance of that very class
155
59
  def be_an_instance_of(expected)
156
60
  ::Matchi::BeAnInstanceOf.new(expected)
157
61
  end
158
62
 
159
- # Change matcher
160
- #
161
- # @example
162
- # object = []
163
- # matcher = change(object, :length).by(1)
164
- # matcher.match? { object << 1 } # => true
165
- #
166
- # object = []
167
- # matcher = change(object, :length).by_at_least(1)
168
- # matcher.match? { object << 1 } # => true
169
- #
170
- # object = []
171
- # matcher = change(object, :length).by_at_most(1)
172
- # matcher.match? { object << 1 } # => true
173
- #
174
- # object = "foo"
175
- # matcher = change(object, :to_s).from("foo").to("FOO")
176
- # matcher.match? { object.upcase! } # => true
177
- #
178
- # object = "foo"
179
- # matcher = change(object, :to_s).to("FOO")
180
- # matcher.match? { object.upcase! } # => true
181
- #
182
- # @param object [#object_id] An object.
183
- # @param method [Symbol] The name of a method.
184
- #
185
- # @return [#match?] A change matcher.
63
+ # @param expected [Module, String, Symbol] the module, or its name
64
+ # @return [Matchi::BeAKindOf] an instance of that module or of a descendant
65
+ def be_a_kind_of(expected)
66
+ ::Matchi::BeAKindOf.new(expected)
67
+ end
68
+
69
+ # The object must exist when the specification is loaded: a let, which
70
+ # lives with the subject, cannot be observed.
186
71
  #
187
- # @api public
72
+ # @param object [Object] the object to observe
73
+ # @param method [Symbol, String] the method whose value may change,
74
+ # followed by its arguments and its block
75
+ # @return [Matchi::Change] a change, or one to refine with `by`,
76
+ # `by_at_least`, `by_at_most`, `from … to` or `to`
188
77
  def change(object, method, ...)
189
78
  ::Matchi::Change.new(object, method, ...)
190
79
  end
191
80
 
192
- # Satisfy matcher
193
- #
194
- # @example
195
- # matcher = satisfy { |value| value == 42 }
196
- # matcher.match? { 42 } # => true
197
- #
198
- # @yield [value] A block that defines the satisfaction criteria
199
- # @yieldparam value The value to test
200
- # @yieldreturn [Boolean] true if the value satisfies the criteria
201
- #
202
- # @return [#match?] A satisfy matcher.
203
- #
204
- # @api public
81
+ # @yieldparam value [Object] the actual value
82
+ # @yieldreturn [Boolean] whether it satisfies the block
83
+ # @return [Matchi::Satisfy]
205
84
  def satisfy(&)
206
85
  ::Matchi::Satisfy.new(&)
207
86
  end
208
87
 
209
- private
210
-
211
- # Predicate matcher, or default method missing behavior.
212
- #
213
- # @example Empty predicate matcher
214
- # matcher = be_empty
215
- # matcher.match? { [] } # => true
216
- # matcher.match? { [4] } # => false
217
- def method_missing(name, ...)
218
- return super unless predicate_matcher_name?(name)
219
-
220
- ::Matchi::Predicate.new(name, ...)
221
- end
222
-
223
- # :nocov:
224
-
225
- # Hook method to return whether the obj can respond to id method or not.
226
- def respond_to_missing?(name, include_private = false)
227
- predicate_matcher_name?(name) || super
228
- end
88
+ # @param name [Symbol, nil] the name of a registered specification
89
+ # @yield the document of an anonymous specification, instead of a name
90
+ # @return [Fix::Spec] the specification, tested against the actual value
91
+ # as a matcher
92
+ # @raise [KeyError] when no specification has that name
93
+ # @raise [ArgumentError] with both a name and a block, or neither
94
+ def fix(name = nil, &block)
95
+ raise ::ArgumentError, "a name or a block must be given, not both" if name.nil? == block.nil?
229
96
 
230
- # :nocov:
231
-
232
- # Predicate matcher name detector.
233
- #
234
- # @param name [Array, Symbol] The name of a potential predicate matcher.
235
- #
236
- # @return [Boolean] Indicates if it is a predicate matcher name or not.
237
- def predicate_matcher_name?(name)
238
- name.start_with?("be_", "have_") && !name.end_with?("!", "?")
97
+ name.nil? ? ::Fix.spec(&block) : ::Fix[name]
239
98
  end
240
99
  end
241
100
  end
data/lib/fix/node.rb ADDED
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fix
4
+ # A context of a specification, with everything needed to run its
5
+ # expectations: the lets in force, the chain of methods to send to the
6
+ # subject, and the expectations written in it.
7
+ #
8
+ # @!attribute [r] lets
9
+ # @return [Hash{Symbol => Proc}] the lets, frozen
10
+ # @!attribute [r] challenges
11
+ # @return [Array<Defi::Challenge>] the chain of the `on` above, frozen
12
+ # @!attribute [r] chain
13
+ # @return [String] its text, as `.name(args)` each, empty at the root
14
+ # @!attribute [r] expectations
15
+ # @return [Array<Fix::Expectation>] in the order written
16
+ #
17
+ # @api private
18
+ Node = ::Data.define(:lets, :challenges, :chain, :expectations)
19
+
20
+ # An expectation: a requirement at a line of the specification.
21
+ #
22
+ # @!attribute [r] location
23
+ # @return [String] `file:line`
24
+ # @!attribute [r] requirement
25
+ # @return [Spectus::Requirement]
26
+ #
27
+ # @api private
28
+ Expectation = ::Data.define(:location, :requirement)
29
+
30
+ private_constant :Node
31
+ private_constant :Expectation
32
+ end