remarkable 3.0.8 → 3.0.9

Sign up to get free protection for your applications and to get access to all the features.
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