remarkable 3.0.8 → 3.0.9

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/CHANGELOG CHANGED
@@ -1,3 +1,25 @@
1
+ * Added support to blocks configuration. All Remarkable matcher and macros can
2
+ now be configured using a block:
3
+
4
+ should_accept_nested_attributes_for :tasks do |m|
5
+ m.allow_destroy
6
+ m.accept(:name => 'cool')
7
+ m.reject(:name => '')
8
+ end
9
+
10
+ * Added support to {{sentence}} as interpolation option in optionals.
11
+ Previously we had:
12
+
13
+ validate_uniqueness_of :id, :scope => [:project_id, :company_id]
14
+ # Description: "should require unique attributes for id scoped to [:project_id, :company_id]"
15
+
16
+ Now with the new sentence option, we can have:
17
+
18
+ validate_uniqueness_of :id, :scope => [:project_id, :company_id]
19
+ # Description: "should require unique attributes for id scoped to project_id and company_id"
20
+
21
+ * Added support to splat and block to optionals
22
+
1
23
  * Added namespace lookup to optionals and expectations. For example, in ActiveRecord
2
24
  several matchers have :allow_nil and :allow_blank as options. So you can store
3
25
  the translation at:
data/README CHANGED
@@ -1,18 +1,31 @@
1
1
  = Remarkable
2
2
 
3
3
  This is the core package of Remarkable. It provides a DSL for creating matchers
4
- with I18n support, decoupling messages from matcher's logic and adding rspec
5
- extra features.
4
+ with I18n support, decouples messages from matchers logic, add rspec extra features,
5
+ create macros automatically and allow those macros to be configurable wth blocks.
6
6
 
7
7
  == Macros
8
8
 
9
9
  Each matcher in Remarkable is also available as a macro. So this matcher:
10
-
11
- it { should validate_presence_of(:name) }
10
+
11
+ it { should validate_numericality_of(:age, :greater_than => 18, :only_integer => true) }
12
+ it { should validate_numericality_of(:age).greater_than(18).only_integer }
12
13
 
13
14
  Can also be written as:
14
15
 
15
- should_validate_presence_of :name
16
+ should_validate_numericality_of :age, :greater_than => 18, :only_integer => true
17
+
18
+ Which can be also written as:
19
+
20
+ should_validate_numericality_of :age do |m|
21
+ m.only_integer
22
+ m.greater_than 18
23
+ # Or: m.greater_than = 18
24
+ end
25
+
26
+ Choose your style!
27
+
28
+ == Disabled Macros
16
29
 
17
30
  Remarkable adds the possibility to disable macros. So as you could do:
18
31
 
@@ -1,4 +1,6 @@
1
- module Remarkable
1
+ module Remarkable
2
+ # This class holds the basic structure for Remarkable matchers. All matchers
3
+ # must inherit from it.
2
4
  class Base
3
5
  include Remarkable::Messages
4
6
  extend Remarkable::DSL
@@ -1,19 +1,36 @@
1
1
  dir = File.dirname(__FILE__)
2
2
  require File.join(dir, 'dsl', 'assertions')
3
3
  require File.join(dir, 'dsl', 'optionals')
4
- require File.join(dir, 'dsl', 'matches')
5
4
  require File.join(dir, 'dsl', 'callbacks')
6
5
 
7
- module Remarkable
6
+ module Remarkable
7
+ # The DSL module is responsable for all Remarkable convenience methods.
8
+ # It has three main submodules:
9
+ #
10
+ # * <tt>Assertions</tt> - adds a class methods to define matcher initialization and assertions,
11
+ # allowing matches? to be hidden from the matcher developer and dealing
12
+ # with I18n in the expectations messages;
13
+ #
14
+ # * <tt>Callbacks</tt> - provides API for after_initialize and before_assert callbacks;
15
+ #
16
+ # * <tt>Optionals</tt> - add an optionals DSL, which is also used for the auto configuring blocks
17
+ # and dynamic descriptions.
18
+ #
8
19
  module DSL
9
- ATTR_READERS = [ :matcher_arguments, :matcher_optionals, :matcher_single_assertions,
10
- :matcher_collection_assertions, :before_assert_callbacks, :after_initialize_callbacks
20
+ ATTR_READERS = [
21
+ :matcher_arguments,
22
+ :matcher_optionals,
23
+ :matcher_optionals_splat,
24
+ :matcher_optionals_block,
25
+ :matcher_single_assertions,
26
+ :matcher_collection_assertions,
27
+ :before_assert_callbacks,
28
+ :after_initialize_callbacks
11
29
  ] unless self.const_defined?(:ATTR_READERS)
12
30
 
13
31
  def self.extended(base) #:nodoc:
14
- base.extend Assertions
32
+ base.send :include, Assertions
15
33
  base.send :include, Callbacks
16
- base.send :include, Matches
17
34
  base.send :include, Optionals
18
35
 
19
36
  # Initialize matcher_arguments hash with names as an empty array
@@ -1,182 +1,388 @@
1
1
  module Remarkable
2
- module DSL
3
- module Assertions
2
+ module DSL
3
+ # This module is responsable to create a basic matcher structure using a DSL.
4
+ #
5
+ # A matcher that checks if an element is included in an array can be done
6
+ # just with:
7
+ #
8
+ # class IncludedMatcher < Remarkable::Base
9
+ # arguments :value
10
+ # assertion :is_included?
11
+ #
12
+ # protected
13
+ # def is_included?
14
+ # @subject.include?(@value)
15
+ # end
16
+ # end
17
+ #
18
+ # As you have noticed, the DSL also allows you to remove the messages from
19
+ # matcher. Since it will look for it on I18n yml file.
20
+ #
21
+ # If you want to create a matcher that accepts multile values to be tested,
22
+ # you just need to do:
23
+ #
24
+ # class IncludedMatcher < Remarkable::Base
25
+ # arguments :collection => :values, :as => :value
26
+ # collection_assertion :is_included?
27
+ #
28
+ # protected
29
+ # def is_included?
30
+ # @subject.include?(@value)
31
+ # end
32
+ # end
33
+ #
34
+ # Notice that the :is_included? logic didn't have to change, because Remarkable
35
+ # handle this automatically for you.
36
+ #
37
+ module Assertions
38
+
39
+ def self.included(base) # :nodoc:
40
+ base.extend ClassMethods
41
+ end
42
+
43
+ module ClassMethods
4
44
 
5
- protected
45
+ protected
6
46
 
7
- # It sets the arguments your matcher receives on initialization.
8
- #
9
- # arguments :name, :range
10
- #
11
- # Which is roughly the same as:
12
- #
13
- # def initialize(name, range, options = {})
14
- # @name = name
15
- # @range = range
16
- # @options = options
17
- # end
18
- #
19
- # But most of the time your matchers iterates through a collection,
20
- # such as a collection of attributes in the case below:
21
- #
22
- # @product.should validate_presence_of(:title, :name)
23
- #
24
- # validate_presence_of is a matcher declared as:
25
- #
26
- # class ValidatePresenceOfMatcher < Remarkable::Base
27
- # arguments :collection => :attributes, :as => :attribute
28
- # end
29
- #
30
- # In this case, Remarkable provides an API that enables you to easily
31
- # assert each item of the collection. Let's check more examples:
32
- #
33
- # should allow_values_for(:email, "jose@valim.com", "carlos@brando.com")
34
- #
35
- # Is declared as:
36
- #
37
- # arguments :attribute, :collection => :good_values, :as => :good_value
38
- #
39
- # And this is the same as:
40
- #
41
- # class AllowValuesForMatcher < Remarkable::Base
42
- # def initialize(attribute, *good_values)
43
- # @attribute = attribute
44
- # @options = default_options.merge(good_values.extract_options!)
45
- # @good_values = good_values
46
- # end
47
- # end
48
- #
49
- # Now, the collection is @good_values. In each assertion method we will
50
- # have a @good_value variable (in singular) instantiated with the value
51
- # to assert.
52
- #
53
- # Finally, if your matcher deals with blocks, you can also set them as
54
- # option:
55
- #
56
- # arguments :name, :block => :builder
57
- #
58
- # It will be available under the instance variable @builder.
59
- #
60
- def arguments(*names)
61
- options = names.extract_options!
62
- args = names.dup
47
+ # It sets the arguments your matcher receives on initialization.
48
+ #
49
+ # == Options
50
+ #
51
+ # * <tt>:collection</tt> - if a collection is expected.
52
+ # * <tt>:as</tt> - how each item of the collection will be available.
53
+ # * <tt>:block</tt> - tell the matcher can receive blocks as argument and store
54
+ # them under the variable given.
55
+ #
56
+ # Note: the expected block cannot have arity 1. This is already reserved
57
+ # for macro configuration.
58
+ #
59
+ # == Examples
60
+ #
61
+ # Let's see for each example how the arguments declarion reflects on
62
+ # the matcher API:
63
+ #
64
+ # arguments :assign
65
+ # # Can be called as:
66
+ # #=> should_assign :task
67
+ # #=> should_assign :task, :with => Task.new
68
+ #
69
+ # This is roughly the same as:
70
+ #
71
+ # def initialize(assign, options = {})
72
+ # @assign = name
73
+ # @options = options
74
+ # end
75
+ #
76
+ # As you noticed, a matcher can always receive options on initialization.
77
+ # If you have a matcher that accepts only options, for example,
78
+ # have_default_scope you just need to call <tt>arguments</tt>:
79
+ #
80
+ # arguments
81
+ # # Can be called as:
82
+ # #=> should_have_default_scope :limit => 10
83
+ #
84
+ # arguments :collection => :assigns, :as => :assign
85
+ # # Can be called as:
86
+ # #=> should_assign :task1, :task2
87
+ # #=> should_assign :task1, :task2, :with => Task.new
88
+ #
89
+ # arguments :collection => :assigns, :as => :assign, :block => :buildeer
90
+ # # Can be called as:
91
+ # #=> should_assign :task1, :task2
92
+ # #=> should_assign(:task1, :task2){ Task.new }
93
+ #
94
+ # The block will be available under the instance variable @builder.
95
+ #
96
+ # == I18n
97
+ #
98
+ # All the parameters given to arguments are available for interpolation
99
+ # in I18n. So if you have the following declarion:
100
+ #
101
+ # class InRange < Remarkable::Base
102
+ # arguments :range, :collection => :names, :as => :name
103
+ #
104
+ # You will have {{range}}, {{names}} and {{name}} available for I18n
105
+ # messages:
106
+ #
107
+ # in_range:
108
+ # description: "have {{names}} to be on range {{range}}"
109
+ #
110
+ # Before a collection is sent to I18n, it's transformed to a sentence.
111
+ # So if the following matcher:
112
+ #
113
+ # in_range(2..20, :username, :password)
114
+ #
115
+ # Has the following description:
116
+ #
117
+ # "should have username and password in range 2..20"
118
+ #
119
+ def arguments(*names)
120
+ options = names.extract_options!
121
+ args = names.dup
122
+
123
+ @matcher_arguments[:names] = names
63
124
 
64
- @matcher_arguments[:names] = names
125
+ if collection = options.delete(:collection)
126
+ @matcher_arguments[:collection] = collection
65
127
 
66
- if collection = options.delete(:collection)
67
- @matcher_arguments[:collection] = collection
128
+ if options[:as]
129
+ @matcher_arguments[:as] = options.delete(:as)
130
+ else
131
+ raise ArgumentError, 'You gave me :collection as option but have not give me :as as well'
132
+ end
68
133
 
69
- if options[:as]
70
- @matcher_arguments[:as] = options.delete(:as)
134
+ args << "*#{collection}"
135
+ get_options = "#{collection}.extract_options!"
136
+ set_collection = "@#{collection} = #{collection}"
71
137
  else
72
- raise ArgumentError, 'You gave me :collection as option but have not give me :as as well'
138
+ args << 'options={}'
139
+ get_options = 'options'
140
+ set_collection = ''
73
141
  end
74
142
 
75
- args << "*#{collection}"
76
- get_options = "#{collection}.extract_options!"
77
- set_collection = "@#{collection} = #{collection}"
78
- else
79
- args << 'options={}'
80
- get_options = 'options'
81
- set_collection = ''
143
+ if block = options.delete(:block)
144
+ block = :block unless block.is_a?(Symbol)
145
+ @matcher_arguments[:block] = block
146
+ end
147
+
148
+ # Blocks are always appended. If they have arity 1, they are used for
149
+ # macro configuration, otherwise, they are stored in the :block variable.
150
+ #
151
+ args << "&block"
152
+
153
+ assignments = names.map do |name|
154
+ "@#{name} = #{name}"
155
+ end.join("\n ")
156
+
157
+ class_eval <<-END, __FILE__, __LINE__
158
+ def initialize(#{args.join(',')})
159
+ _builder, block = block, nil if block && block.arity == 1
160
+ #{assignments}
161
+ #{"@#{block} = block" if block}
162
+ @options = default_options.merge(#{get_options})
163
+ #{set_collection}
164
+ run_after_initialize_callbacks
165
+ _builder.call(self) if _builder
166
+ end
167
+ END
82
168
  end
83
169
 
84
- if block = options.delete(:block)
85
- @matcher_arguments[:block] = block
86
- args << "&#{block}"
87
- names << block
170
+ # Declare the assertions that are runned for each element in the collection.
171
+ # It must be used with <tt>arguments</tt> methods in order to work properly.
172
+ #
173
+ # == Examples
174
+ #
175
+ # The example given in <tt>assertions</tt> can be transformed to
176
+ # accept a collection just doing:
177
+ #
178
+ # class IncludedMatcher < Remarkable::Base
179
+ # arguments :collection => :values, :as => :value
180
+ # collection_assertion :is_included?
181
+ #
182
+ # protected
183
+ # def is_included?
184
+ # @subject.include?(@value)
185
+ # end
186
+ # end
187
+ #
188
+ # All further consideration done in <tt>assertions</tt> are also valid here.
189
+ #
190
+ def collection_assertions(*methods, &block)
191
+ define_method methods.last, &block if block_given?
192
+ @matcher_collection_assertions += methods
88
193
  end
194
+ alias :collection_assertion :collection_assertions
89
195
 
90
- assignments = names.map do |name|
91
- "@#{name} = #{name}"
92
- end.join("\n ")
196
+ # Declares the assertions that are run once per matcher.
197
+ #
198
+ # == Examples
199
+ #
200
+ # A matcher that checks if an element is included in an array can be done
201
+ # just with:
202
+ #
203
+ # class IncludedMatcher < Remarkable::Base
204
+ # arguments :value
205
+ # assertion :is_included?
206
+ #
207
+ # protected
208
+ # def is_included?
209
+ # @subject.include?(@value)
210
+ # end
211
+ # end
212
+ #
213
+ # Whenever the matcher is called, the :is_included? action is automatically
214
+ # triggered. Each assertion must return true or false. In case it's false
215
+ # it will seach for an expectation message on the I18n file. In this
216
+ # case, the error message would be on:
217
+ #
218
+ # included:
219
+ # description: "check {{value}} is included in the array"
220
+ # expectations:
221
+ # is_included: "{{value}} is included in the array"
222
+ #
223
+ # In case of failure, it will output:
224
+ #
225
+ # "Expected {{value}} is included in the array"
226
+ #
227
+ # Notice that on the yml file the question mark is removed for readability.
228
+ #
229
+ # == Shortcut declaration
230
+ #
231
+ # You can shortcut declaration by giving a name and block to assertion
232
+ # method:
233
+ #
234
+ # class IncludedMatcher < Remarkable::Base
235
+ # arguments :value
236
+ #
237
+ # assertion :is_included? do
238
+ # @subject.include?(@value)
239
+ # end
240
+ # end
241
+ #
242
+ def assertions(*methods, &block)
243
+ if block_given?
244
+ define_method methods.last, &block
245
+ protected methods.last
246
+ end
93
247
 
94
- class_eval <<-END, __FILE__, __LINE__
95
- def initialize(#{args.join(',')})
96
- #{assignments}
97
- @options = default_options.merge(#{get_options})
98
- #{set_collection}
99
- run_after_initialize_callbacks
100
- end
101
- END
248
+ @matcher_single_assertions += methods
249
+ end
250
+ alias :assertion :assertions
251
+
252
+ # Class method that accepts a block or a hash to set matcher's default
253
+ # options. It's called on matcher initialization and stores the default
254
+ # value in the @options instance variable.
255
+ #
256
+ # == Examples
257
+ #
258
+ # default_options do
259
+ # { :name => @subject.name }
260
+ # end
261
+ #
262
+ # default_options :message => :invalid
263
+ #
264
+ def default_options(hash = {}, &block)
265
+ if block_given?
266
+ define_method :default_options, &block
267
+ else
268
+ class_eval "def default_options; #{hash.inspect}; end"
269
+ end
270
+ end
271
+ end
272
+
273
+ # This method is responsable for connecting <tt>arguments</tt>, <tt>assertions</tt>
274
+ # and <tt>collection_assertions</tt>.
275
+ #
276
+ # It's the one that executes the assertions once, executes the collection
277
+ # assertions for each element in the collection and also responsable to set
278
+ # the I18n messages.
279
+ #
280
+ def matches?(subject)
281
+ @subject = subject
282
+
283
+ run_before_assert_callbacks
284
+
285
+ send_methods_and_generate_message(self.class.matcher_single_assertions) &&
286
+ assert_matcher_for(instance_variable_get("@#{self.class.matcher_arguments[:collection]}") || []) do |value|
287
+ instance_variable_set("@#{self.class.matcher_arguments[:as]}", value)
288
+ send_methods_and_generate_message(self.class.matcher_collection_assertions)
102
289
  end
290
+ end
103
291
 
104
- # Call it to declare your collection assertions. Every method given will
105
- # iterate through the whole collection given in <tt>:arguments</tt>.
106
- #
107
- # For example, validate_presence_of can be written as:
108
- #
109
- # class ValidatePresenceOfMatcher < Remarkable::Base
110
- # arguments :collection => :attributes, :as => :attribute
111
- # collection_assertions :allow_nil?
112
- #
113
- # protected
114
- # def allow_nil?
115
- # # matcher logic
116
- # end
117
- # end
118
- #
119
- # Then we call it as:
120
- #
121
- # should validate_presence_of(:email, :password)
292
+ protected
293
+
294
+ # You can overwrite this instance method to provide default options on
295
+ # initialization.
122
296
  #
123
- # For each attribute given, it will call the method :allow_nil which
124
- # contains the matcher logic. As stated in <tt>arguments</tt>, those
125
- # attributes will be available under the instance variable @argument
126
- # and the matcher subject is available under the instance variable
127
- # @subject.
297
+ def default_options
298
+ {}
299
+ end
300
+
301
+ # Overwrites default_i18n_options to provide arguments and optionals
302
+ # to interpolation options.
128
303
  #
129
- # If a block is given, it will create a method with the name given.
130
- # So we could write the same class as above just as:
304
+ # If you still need to provide more other interpolation options, you can
305
+ # do that in two ways:
131
306
  #
132
- # class ValidatePresenceOfMatcher < Remarkable::Base
133
- # arguments :collection => :attributes
307
+ # 1. Overwrite interpolation_options:
134
308
  #
135
- # collection_assertion :allow_nil? do
136
- # # matcher logic
137
- # end
309
+ # def interpolation_options
310
+ # { :real_value => real_value }
138
311
  # end
139
312
  #
140
- # Those methods should return true if it pass or false if it fails. When
141
- # it fails, it will use I18n API to find the proper failure message:
313
+ # 2. Return a hash from your assertion method:
142
314
  #
143
- # expectations:
144
- # allow_nil: allowed the value to be nil
145
- # allow_blank: allowed the value to be blank
146
- #
147
- # Or you can set the message in the instance variable @expectation in the
148
- # assertion method if you don't want to rely on I18n API.
315
+ # def my_assertion
316
+ # return true if real_value == expected_value
317
+ # return false, :real_value => real_value
318
+ # end
149
319
  #
150
- # As you might have noticed from the examples above, this method is also
151
- # aliased as <tt>collection_assertion</tt>.
320
+ # In both cases, :real_value will be available as interpolation option.
152
321
  #
153
- def collection_assertions(*methods, &block)
154
- define_method methods.last, &block if block_given?
155
- @matcher_collection_assertions += methods
322
+ def default_i18n_options #:nodoc:
323
+ i18n_options = {}
324
+
325
+ @options.each do |key, value|
326
+ i18n_options[key] = value.inspect
327
+ end if @options
328
+
329
+ # Also add arguments as interpolation options.
330
+ self.class.matcher_arguments[:names].each do |name|
331
+ i18n_options[name] = instance_variable_get("@#{name}").inspect
332
+ end
333
+
334
+ # Add collection interpolation options.
335
+ i18n_options.update(collection_interpolation)
336
+
337
+ # Add default options (highest priority). They should not be overwritten.
338
+ i18n_options.update(super)
156
339
  end
157
- alias :collection_assertion :collection_assertions
158
340
 
159
- # In contrast to <tt>collection_assertions</tt>, the methods given here
160
- # are called just once. In other words, it does not iterate through the
161
- # collection given in arguments.
341
+ # Method responsible to add collection as interpolation.
162
342
  #
163
- # It also accepts blocks and is aliased as assertion.
164
- #
165
- def assertions(*methods, &block)
166
- define_method methods.last, &block if block_given?
167
- @matcher_single_assertions += methods
343
+ def collection_interpolation #:nodoc:
344
+ options = {}
345
+
346
+ if collection_name = self.class.matcher_arguments[:collection]
347
+ collection_name = collection_name.to_sym
348
+ collection = instance_variable_get("@#{collection_name}")
349
+ options[collection_name] = array_to_sentence(collection) if collection
350
+
351
+ object_name = self.class.matcher_arguments[:as].to_sym
352
+ object = instance_variable_get("@#{object_name}")
353
+ options[object_name] = object if object
354
+ end
355
+
356
+ options
168
357
  end
169
- alias :assertion :assertions
170
358
 
171
- # Class method that accepts a block or a hash to set matcher's default options.
359
+ # Send the assertion methods given and create a expectation message
360
+ # if any of those methods returns false.
361
+ #
362
+ # Since most assertion methods ends with an question mark and it's not
363
+ # readable in yml files, we remove question and exclation marks at the
364
+ # end of the method name before translating it. So if you have a method
365
+ # called is_valid? on I18n yml file we will check for a key :is_valid.
172
366
  #
173
- def default_options(hash = {}, &block)
174
- if block_given?
175
- define_method :default_options, &block
176
- else
177
- class_eval "def default_options; #{hash.inspect}; end"
367
+ def send_methods_and_generate_message(methods) #:nodoc:
368
+ methods.each do |method|
369
+ bool, hash = send(method)
370
+
371
+ unless bool
372
+ parent_scope = matcher_i18n_scope.split('.')
373
+ matcher_name = parent_scope.pop
374
+ lookup = :"expectations.#{method.to_s.gsub(/(\?|\!)$/, '')}"
375
+
376
+ hash = { :scope => parent_scope, :default => lookup }.merge(hash || {})
377
+ @expectation ||= Remarkable.t "#{matcher_name}.#{lookup}", default_i18n_options.merge(hash)
378
+
379
+ return false
380
+ end
178
381
  end
179
- end
382
+
383
+ return true
384
+ end
385
+
180
386
 
181
387
  end
182
388
  end