buscar 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.
@@ -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