buscar 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,18 @@
1
+ module Buscar
2
+ if defined? Rails::Railtie
3
+ require 'rails'
4
+ class Railtie < Rails::Railtie
5
+ initializer 'buscar.insert_into_action_view' do
6
+ ActiveSupport.on_load :action_view do
7
+ Buscar::Railtie.insert
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ class Railtie
14
+ def self.insert
15
+ ActionView::Base.send(:include, Buscar::Helpers)
16
+ end
17
+ end
18
+ end
data/spec/blueprint.rb ADDED
@@ -0,0 +1,34 @@
1
+ require 'machinist/active_record'
2
+ require 'sham'
3
+ require 'faker'
4
+
5
+ Sham.define do
6
+ city { Faker::Address.city }
7
+ business { Faker::Company.name }
8
+ tag { Faker::Lorem.words(1) }
9
+ end
10
+
11
+ City.blueprint do
12
+ name { Sham.city }
13
+ end
14
+
15
+ Business.blueprint do
16
+ city { City.make }
17
+ name { Sham.business }
18
+ active true
19
+ end
20
+
21
+ Tag.blueprint do
22
+ name { Sham.tag }
23
+ published true
24
+ end
25
+
26
+ Tagging.blueprint do
27
+ business { Business.make }
28
+ tag { Tag.make }
29
+ end
30
+
31
+ def tag_business(business, tag_name)
32
+ tag = Tag.find_by_name(tag_name) || Tag.make(:name => tag_name)
33
+ Tagging.make(:tag => tag, :business => business)
34
+ end
@@ -0,0 +1,82 @@
1
+ require 'spec_helper'
2
+ require 'active_support'
3
+ require 'action_view'
4
+ require 'action_view/base' # For the NonConcattingString class
5
+ require 'action_view/template/handlers/erb' # For the OutputBuffer class
6
+ require 'webrat'
7
+
8
+ describe Buscar::Helpers do
9
+ include Webrat::Matchers
10
+ include ActionView::Helpers
11
+ include Buscar::Helpers
12
+
13
+ # This magic code allows certain Rails helpers to work without loading the whole Rails environment.
14
+ # I'm assuming that CaptureHelper uses it to store the captured output.
15
+ attr_accessor :output_buffer
16
+
17
+ describe '#filter_menu' do
18
+ before :each do
19
+ @index = mock(:filter_param_options => [['breakfast', 'Breakfast Time'], ['lunch'], ['dinner']], :filter_param => 'lunch')
20
+ end
21
+
22
+ it 'yields each possible filter param' do
23
+ yielded = []
24
+ filter_menu(@index) do |filter_param|
25
+ yielded << filter_param
26
+ ''
27
+ end
28
+ yielded.should == ['breakfast', 'lunch', 'dinner']
29
+ end
30
+
31
+ it 'prints a link for each option, using the URL returned by the block and the humanized param or the overridden label as the text' do
32
+ html = filter_menu(@index) do |filter_param|
33
+ "http://test.host/#{filter_param}"
34
+ end
35
+ html.should include('<a href="http://test.host/breakfast">Breakfast Time</a>')
36
+ html.should include('<a href="http://test.host/lunch">Lunch</a>')
37
+ html.should include('<a href="http://test.host/dinner">Dinner</a>')
38
+ end
39
+ end
40
+
41
+ describe '#sort_menu' do
42
+ before :each do
43
+ @index = mock(:sort_param_options => [['name'], ['dishes', 'Number of Dishes'], ['reviews']], :sort_param => 'dishes')
44
+ end
45
+
46
+ it 'yields each possible sort param' do
47
+ yielded = []
48
+ sort_menu(@index) do |sort_param|
49
+ yielded << sort_param
50
+ ''
51
+ end
52
+ yielded.should == ['name', 'dishes', 'reviews']
53
+ end
54
+
55
+ it 'prints a link for each option, using the URL returned by the block and the humanized param or the overridden label as the text' do
56
+ html = sort_menu(@index) do |sort_param, filter_param|
57
+ "http://test.host/#{sort_param}"
58
+ end
59
+ html.should include('<a href="http://test.host/name">Name</a>')
60
+ html.should include('<a href="http://test.host/dishes">Number of Dishes</a>')
61
+ html.should include('<a href="http://test.host/reviews">Reviews</a>')
62
+ end
63
+ end
64
+
65
+ describe '#page_links' do
66
+ before :each do
67
+ @index = mock(:page_count => 3, :page => 1) # Index#page returns a zero-based offset. The helper must convert to one-based.
68
+ end
69
+
70
+ it 'determines the correct, 1-based current page' do
71
+ page_links(@index) { |page| "/pages/#{page}" }.should have_selector('ul') do |ul|
72
+ ul.should have_selector('li') do |one|
73
+ one.should have_selector('a', 'href' => '/pages/1', :content => '1')
74
+ end
75
+ ul.should have_selector('li', :content => '2')
76
+ ul.should have_selector('li') do |three|
77
+ three.should have_selector('a', 'href' => '/pages/3', :content => '3')
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,442 @@
1
+ require 'spec_helper'
2
+ require 'test_db'
3
+ require 'blueprint'
4
+ require 'matchers'
5
+
6
+ # This class demontrates the bare minimum required of an Index subclass
7
+ class TagIndex < Buscar::Index
8
+ def finder
9
+ Tag
10
+ end
11
+
12
+ # This method is already defined in the superclass. By default, it looks at params[:records_per_page].
13
+ # If that is undefined, it returns 50. One reason to override the method would be to return a different number
14
+ # when params[:records_per_page] is undefined, as illustrated below. Or, you could make the method
15
+ # ignore params[:records_per_page], thus preventing the user from choosing a value.
16
+ def records_per_page
17
+ @params[:records_per_page] || 25
18
+ end
19
+ end
20
+
21
+ class TagIndexWithRecordsPerPage
22
+ # This could, if necessary, look at @params and intelligently decide what relationships to include.
23
+ # In this case, though, we just return the same thing no matter what.
24
+ def includes_clause
25
+ :businesses
26
+ end
27
+ end
28
+
29
+ module CityTagIndex
30
+ # You can give this any arity you want, but you must at some point initialize @params as a hash.
31
+ # The superclass implementation takes one argument: the params hash.
32
+ def initialize(city, params = {})
33
+ @city = city
34
+ @params = params
35
+ end
36
+ end
37
+
38
+ class TagIndexWithSingleFilter < TagIndex
39
+ # See comment for TagIndexWithChainedFilter#filter below.
40
+ def filter
41
+ {:published => true}
42
+ end
43
+ end
44
+
45
+ class TagIndexWithFilterOptions < TagIndex
46
+ include CityTagIndex
47
+
48
+ # Must return a hash. Keys correspond to possible values of params[:filter].
49
+ # Values can be a Proc or anything accepted by ActiveRecord's #where clause.
50
+ # If a Proc, it will be passed to #select (the Enumerable method, not the Relation method.)
51
+ # Otherwise, it will be passed to #where.
52
+ #
53
+ # The third element of each array is optional. If it's not specified, the helper will humanize
54
+ # the first param.
55
+ def filter_options
56
+ [
57
+ ['short_name', 'LENGTH(name) < 4', 'Short Name'],
58
+ ['medium_name', ['LENGTH(name) >= ? AND LENGTH(name) <= ?', 5, 10], 'Medium Name'],
59
+ ['long_name', lambda { |tag| tag.name.length > 10 }, 'Long Name']
60
+ ]
61
+ end
62
+
63
+ # Optional to define. If not defined, the default filter option will be 'none', which will, of course,
64
+ # mean no filtering. The string 'none' will in fact be passed to the #filter_menu helper and will appear in URLs
65
+ # (assuming you use the helper).
66
+ def default_filter_option
67
+ 'short_name'
68
+ end
69
+ end
70
+
71
+ class TagIndexWithChainedFilter < TagIndexWithFilterOptions
72
+ include CityTagIndex
73
+
74
+ # Must return one of the following:
75
+ # - Something that can be passed to #order.
76
+ # - A proc for #select.
77
+ # - A Chain where each element is one of the above. This will cause the filters to be chained, effectively ANDing them. Call #chain to build the Chain object, as shown below.
78
+ #
79
+ # If you're using automatic filter switching, you can implement this method to add
80
+ # on some filtering that will always be applied, regardless of which option is selected.
81
+ # To do that, return an array where one element is #super.
82
+ # The implementation below illustrates just that:
83
+ def filter
84
+ chain(
85
+ super, # Use automatic filter switching, i.e. use params[:filter] and #filter_options
86
+ {:published => true}, # Only find published tags
87
+ lambda { |tag| !tag.active_businesses_in_city(@city).empty? } # Only find tags with at least one active business
88
+ )
89
+ end
90
+ end
91
+
92
+ class TagIndexWithStringSort < TagIndex
93
+ # Must return one of the following:
94
+ # - A string or symbol for #order
95
+ # - A proc for #sort_by
96
+ #
97
+ # Unlike #filter, defining this method is NOT compatible with auto-switching.
98
+ # This is because you cannot chain sorting--there can only be zero or one sort
99
+ # orders in use for a given result set. So, only define this method if you
100
+ # don't intend to use auto-switching for sorting. In this example, we always
101
+ # sort the same way, but you could implement something dynamic.
102
+ def sort
103
+ 'name'
104
+ end
105
+ end
106
+
107
+ class TagIndexWithSymbolSort < TagIndex
108
+ # See comment for TagIndexWithStringSort#sort above.
109
+ def sort
110
+ :name
111
+ end
112
+ end
113
+
114
+ class TagIndexWithProcSort < TagIndex
115
+ def sort
116
+ lambda { |tag| tag.name }
117
+ end
118
+ end
119
+
120
+ class TagIndexWithSortOptions < TagIndex
121
+ include CityTagIndex
122
+
123
+ # Must return a hash. Keys correspond to possible values of params[:sort].
124
+ # Values can be a string, a symbol, or a Proc. If a Proc, it will be passed to #sort_by.
125
+ # If a string or symbol, it will be passed to #order
126
+ #
127
+ # The third element of each array is optional. If it's not specified, the helper will humanize
128
+ # the first param.
129
+ def sort_options
130
+ [
131
+ ['name', 'name', 'Name'], # Will be passed to #order
132
+ ['businesses', lambda { |tag| -1 * tag.active_businesses_in_city(@city).length }, 'Number of Businesses']
133
+ ]
134
+ end
135
+
136
+ # Optional to define. If not defined, the default sort order will be 'none',
137
+ # which will probably mean that the records will be returned in order of creation.
138
+ # (Unless some default ordering has been previously defined for the object returned by #finder.)
139
+ # The string 'none' will in fact be passed to the #sort_menu helper and will appear in URLs
140
+ # (assuming you use the helper).
141
+ def default_sort_option
142
+ 'name'
143
+ end
144
+ end
145
+
146
+ describe Buscar::Index do
147
+ include Buscar::IndexMatchers
148
+
149
+ def setup_paginated_records
150
+ @burgers = Tag.make(:name => 'Burgers')
151
+ @ethiopian = Tag.make(:name => 'Ethiopian')
152
+ @french = Tag.make(:name => 'French')
153
+ @indian = Tag.make(:name => 'Indian')
154
+ @mexican = Tag.make(:name => 'Mexican')
155
+ @pizza = Tag.make(:name => 'Pizza')
156
+ @thai = Tag.make(:name => 'Thai')
157
+ end
158
+
159
+ before :all do
160
+ @chicago = City.make(:name => 'Chicago')
161
+ @dc = City.make(:name => 'Washington')
162
+ end
163
+
164
+ before :each do
165
+ Business.delete_all
166
+ Tag.delete_all
167
+ Tagging.delete_all
168
+ end
169
+
170
+ describe '#each' do
171
+ it 'iterates over the results on the current page' do
172
+ setup_paginated_records
173
+
174
+ # Index mixes in enumerable, so calling to_a is an easy way
175
+ # to test what's yielded by #each
176
+ {
177
+ 1 => [@burgers, @ethiopian],
178
+ 2 => [@french, @indian],
179
+ 3 => [@mexican, @pizza],
180
+ 4 => [@thai]
181
+ }.each do |page, tags|
182
+ index = TagIndex.new(:page => page)
183
+ index.stub(:records_per_page => 2)
184
+ index.stub(:order_clause => :name)
185
+ index.generate!
186
+ index.to_a.should == tags
187
+ end
188
+ end
189
+
190
+ it 'iterates over all records if paginate? returns false' do
191
+ setup_paginated_records
192
+
193
+ index = TagIndex.new
194
+ index.stub(:records_per_page => 2)
195
+ index.stub(:paginate? => false)
196
+ index.generate!
197
+
198
+ index.to_a.length.should == 7
199
+ end
200
+ end
201
+
202
+ describe '#empty?' do
203
+ it 'returns true if no records are found' do
204
+ TagIndex.generate.empty?.should be_true
205
+ end
206
+
207
+ it 'returns false if records are found' do
208
+ Tag.make
209
+ TagIndex.generate.empty?.should be_false
210
+ end
211
+ end
212
+
213
+ describe '#filter_param' do
214
+ it 'returns whatever was given in the params' do
215
+ TagIndex.new(:filter => 'long_names').filter_param.should == 'long_names'
216
+ end
217
+
218
+ it 'returns "none" if nothing was given and default_filter_option is undefined' do
219
+ TagIndex.new.filter_param.should == 'none'
220
+ end
221
+
222
+ it 'returns the default if nothing was given and default_filter_option is defined' do
223
+ index = TagIndex.new
224
+ index.stub(:default_filter_option => 'long_names')
225
+ index.filter_param.should == 'long_names'
226
+ end
227
+ end
228
+
229
+ describe '#filter_param_options' do
230
+ it 'returns a nested array of all the possible filter_options' do
231
+ TagIndexWithFilterOptions.new(@chicago).filter_param_options.should == [['short_name', 'Short Name'], ['medium_name', 'Medium Name'], ['long_name', 'Long Name']]
232
+ end
233
+
234
+ it 'raises if filter_options is not defined' do
235
+ lambda { TagIndex.new.filter_param_options }.should raise_error
236
+ end
237
+ end
238
+
239
+ describe '.generate' do
240
+ it 'does not require subclasses to define anything other than #finder' do
241
+ TagIndex.generate
242
+ end
243
+
244
+ it 'returns an instance of Index' do
245
+ TagIndex.generate.should be_a(Buscar::Index)
246
+ end
247
+
248
+ it 'uses the return value of #sort in an SQL ORDER clause when #sort returns a string' do
249
+ italian = Tag.make(:name => 'Italian')
250
+ pizza = Tag.make(:name => 'Pizza')
251
+ burgers = Tag.make(:name => 'Burgers')
252
+ TagIndexWithStringSort.generate.records.should == [burgers, italian, pizza]
253
+ end
254
+
255
+ it 'uses the return value of #sort in an SQL order clause when #sort returns a symbol' do
256
+ italian = Tag.make(:name => 'Italian')
257
+ pizza = Tag.make(:name => 'Pizza')
258
+ burgers = Tag.make(:name => 'Burgers')
259
+ TagIndexWithSymbolSort.generate.records.should == [burgers, italian, pizza]
260
+ end
261
+
262
+ it 'uses the return value of #sort in #sort_by when #sort returns a Proc' do
263
+ italian = Tag.make(:name => 'Italian')
264
+ pizza = Tag.make(:name => 'Pizza')
265
+ burgers = Tag.make(:name => 'Burgers')
266
+ TagIndexWithProcSort.generate.records.should == [burgers, italian, pizza]
267
+ end
268
+
269
+ it 'auto-switches the sorting when #sort_options is defined' do
270
+ italian = Tag.make(:name => 'Italian')
271
+ Tagging.make(:tag => italian, :business => Business.make(:city => @chicago))
272
+ Tagging.make(:tag => italian, :business => Business.make(:city => @chicago))
273
+
274
+ pizza = Tag.make(:name => 'Pizza')
275
+
276
+ burgers = Tag.make(:name => 'Burgers')
277
+ Tagging.make(:tag => burgers, :business => Business.make(:city => @chicago))
278
+
279
+ TagIndexWithSortOptions.generate(@chicago, :sort => 'name').records.should == [burgers, italian, pizza]
280
+ TagIndexWithSortOptions.generate(@chicago, :sort => 'businesses').records.should == [italian, burgers, pizza]
281
+ end
282
+
283
+ it 'uses the return value of #filter in #where when a non-array is returned' do
284
+ italian = Tag.make(:name => 'Italian')
285
+ pizza = Tag.make(:name => 'Pizza', :published => false)
286
+ burgers = Tag.make(:name => 'Burgers')
287
+ TagIndexWithSingleFilter.generate.records.should == [italian, burgers]
288
+ end
289
+
290
+ it 'chains the filters when #filter returns an array' do
291
+ # This one should be included.
292
+ italian = Tag.make(:name => 'Italian')
293
+ Tagging.make(:tag => italian, :business => Business.make(:city => @chicago))
294
+
295
+ # This one should be excluded because we're using auto-switching, and the name is too long.
296
+ mexican = Tag.make(:name => 'Mexican-American')
297
+ Tagging.make(:tag => mexican, :business => Business.make(:city => @chicago))
298
+
299
+ # This one should be excluded because it's not published
300
+ pizza = Tag.make(:name => 'Pizza', :published => false)
301
+ Tagging.make(:tag => pizza, :business => Business.make(:city => @chicago))
302
+
303
+ # This one should be excluded because it has no active businesses
304
+ burgers = Tag.make(:name => 'Burgers')
305
+ Tagging.make(:tag => burgers, :business => Business.make(:city => @chicago, :active => false))
306
+
307
+ TagIndexWithChainedFilter.generate(@chicago, :filter => 'medium_name').records.should == [italian]
308
+ end
309
+
310
+ it 'auto-switches the filter when #filter_options is defined' do
311
+ short = Tag.make(:name => 'foo')
312
+ medium = Tag.make(:name => 'foobar')
313
+ long = Tag.make(:name => 'foobarfoobar')
314
+ TagIndexWithFilterOptions.generate(@chicago, :filter => 'short_name').records.should == [short]
315
+ TagIndexWithFilterOptions.generate(@chicago, :filter => 'medium_name').records.should == [medium]
316
+ TagIndexWithFilterOptions.generate(@chicago, :filter => 'long_name').records.should == [long]
317
+ end
318
+ end
319
+
320
+ describe '#length' do
321
+ it 'returns the number of records' do
322
+ 3.times { Tag.make }
323
+ TagIndex.generate.length.should == 3
324
+ end
325
+ end
326
+
327
+ describe '#optional_params' do
328
+ it 'accepts param keys and returns a hash of all the matching params that are defined' do
329
+ TagIndex.new('name' => 'Jarrett', 'age' => '23', 'location' => 'chicago').optional_params('name', 'location').should == {'name' => 'Jarrett', 'location' => 'chicago'}
330
+ end
331
+ end
332
+
333
+ describe '#page' do
334
+ it 'returns the current zero-based page number, as determined by params, or defaults to 0' do
335
+ TagIndex.new(:page => '5').page.should == 4
336
+ end
337
+ end
338
+
339
+ describe '#page_count' do
340
+ it 'returns the number of pages, taking into account records_per_page and the total number of records' do
341
+ setup_paginated_records
342
+
343
+ index = TagIndex.new
344
+ index.stub(:records_per_page => 2)
345
+ index.generate!
346
+
347
+ index.page_count.should == 4
348
+ end
349
+ end
350
+
351
+ describe '#params' do
352
+ it 'returns @params' do
353
+ index = TagIndex.new('foo' => 'bar')
354
+ index.params.should == {'foo' => 'bar'}
355
+ end
356
+ end
357
+
358
+ describe '#records_on_page' do
359
+ it 'paginates the results using records_per_page' do
360
+ setup_paginated_records
361
+
362
+ index = TagIndex.new
363
+ index.stub(:records_per_page => 2)
364
+ index.stub(:order_clause => :name)
365
+ index.generate!
366
+
367
+ index.records_on_page(0).should == [@burgers, @ethiopian]
368
+ index.records_on_page(1).should == [@french, @indian]
369
+ index.records_on_page(2).should == [@mexican, @pizza]
370
+ index.records_on_page(3).should == [@thai]
371
+ end
372
+
373
+ it 'does not paginate if paginate? returns false' do
374
+ setup_paginated_records
375
+
376
+ index = TagIndex.new
377
+ index.stub(:records_per_page => 2)
378
+ index.stub(:paginate? => false)
379
+ index.generate!
380
+
381
+ index.records_on_page(0).length.should == 7
382
+ end
383
+ end
384
+
385
+ describe '#sort_param' do
386
+ it 'returns whatever was given in the params' do
387
+ TagIndex.new(:sort => 'name').sort_param.should == 'name'
388
+ end
389
+
390
+ it 'returns "none" if nothing was given and default_sort_option is undefined' do
391
+ TagIndex.new.sort_param.should == 'none'
392
+ end
393
+
394
+ it 'returns the default if nothing was given and default_sort_option is defined' do
395
+ TagIndexWithSortOptions.new(@chicago).sort_param.should == 'name'
396
+ end
397
+ end
398
+
399
+ describe '#sort_param_options' do
400
+ it 'returns a nested array of all the possible sort_options' do
401
+ TagIndexWithSortOptions.new(@chicago).sort_param_options.should == [['name', 'Name'], ['businesses', 'Number of Businesses']]
402
+ end
403
+
404
+ it 'raises if sort_options is not defined' do
405
+ lambda { TagIndex.new.sort_param_options }.should raise_error
406
+ end
407
+ end
408
+ end
409
+
410
+ # Just to make sure our example models are working properly. We could just mock this stuff out, but I
411
+ # feel much more comfortable using real models when the subject of the test is the model layer. I've
412
+ # seen some weird bugs related to the interaction between plugins and ActiveRecord which would NOT
413
+ # have been caught had AR been mocked.
414
+ describe Tag do
415
+ before :each do
416
+ City.delete_all
417
+ Business.delete_all
418
+ Tag.delete_all
419
+ Tagging.delete_all
420
+ end
421
+
422
+ describe '#active_businesses_in_city' do
423
+ it 'filters inactive businesses' do
424
+ chicago = City.make
425
+ active = Business.make(:city => chicago)
426
+ inactive = Business.make(:city => chicago, :active => false)
427
+ tag_business(active, 'Pizza')
428
+ tag_business(inactive, 'Pizza')
429
+ Tag.find_by_name('Pizza').active_businesses_in_city(chicago).should == [active]
430
+ end
431
+
432
+ it 'filters businesses in other cities' do
433
+ chicago = City.make
434
+ dc = City.make
435
+ chi_pizza = Business.make(:city => chicago)
436
+ dc_pizza = Business.make(:city => dc)
437
+ tag_business(chi_pizza, 'Pizza')
438
+ tag_business(dc_pizza, 'Pizza')
439
+ Tag.find_by_name('Pizza').active_businesses_in_city(chicago).should == [chi_pizza]
440
+ end
441
+ end
442
+ end