solrsan 0.0.20

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,149 @@
1
+ module Solrsan
2
+ module Search
3
+ extend ActiveSupport::Concern
4
+ module ClassMethods
5
+ def class_name
6
+ to_s.underscore
7
+ end
8
+
9
+ def perform_solr_command
10
+ @rsolr = Solrsan::Config.instance.rsolr_object
11
+ yield(@rsolr)
12
+ @rsolr.commit
13
+ end
14
+
15
+ def search(search_params={})
16
+ @rsolr ||= Solrsan::Config.instance.rsolr_object
17
+
18
+ start = search_params[:start] || 0
19
+ rows = search_params[:rows] || 20
20
+
21
+ solr_params = parse_params_for_solr(search_params)
22
+
23
+ begin
24
+ solr_response = @rsolr.paginate(start, rows, 'select', :params => solr_params)
25
+ parse_solr_response(solr_response)
26
+ rescue RSolr::Error::Http => e
27
+ {:docs => [],
28
+ :metadata =>
29
+ {:error => {:http_status_code => e.response[:status],
30
+ :http_message_status => RSolr::Error::Http::STATUS_CODES[e.response[:status].to_i],
31
+ :full_message => e.message}}}
32
+ end
33
+ end
34
+
35
+ def parse_params_for_solr(search_params={})
36
+ solr_params = { :echoParams => 'explicit',
37
+ :q => "*:*",
38
+ :facet => "on",
39
+ :'facet.mincount' => 1}.merge(search_params)
40
+ solr_params[:hl] = true unless search_params[:'hl.fl'].blank?
41
+ solr_params[:fq] = ["type:#{class_name}"] + parse_fq(search_params[:fq])
42
+ solr_params
43
+ end
44
+
45
+ def parse_solr_response(solr_response)
46
+ docs = solr_response['response']['docs']
47
+ parsed_facet_counts = parse_facet_counts(solr_response['facet_counts'])
48
+ highlighting = solr_response['highlighting']
49
+
50
+ metadata = {
51
+ :total_count => solr_response['response']['numFound'],
52
+ :start => solr_response['response']['start'],
53
+ :rows => solr_response['responseHeader']['params']['rows'],
54
+ :time => solr_response['responseHeader']['QTime'],
55
+ :status => solr_response['responseHeader']['status']
56
+ }
57
+
58
+ {:docs => docs, :metadata => metadata,
59
+ :facet_counts => parsed_facet_counts, :highlighting => highlighting}
60
+ end
61
+
62
+ def parse_fq(fq)
63
+ return [] if fq.nil?
64
+ if fq.is_a?(Hash)
65
+ fq.map{|k,v| parse_element_in_fq({k => v})}.flatten
66
+ elsif fq.is_a?(Array)
67
+ fq.map{|ele| parse_element_in_fq(ele)}.flatten
68
+ else
69
+ raise "fq must be a hash or array"
70
+ end
71
+ end
72
+
73
+ def parse_element_in_fq(element)
74
+ if element.is_a?(String)
75
+ element
76
+ elsif element.is_a?(Hash)
77
+ element.map do |k,values|
78
+ if values.is_a?(String)
79
+ key_value_query(k,values)
80
+ else
81
+ values.map{|value| key_value_query(k,value) }
82
+ end
83
+ end
84
+ else
85
+ raise "each fq parameter must be a string or hash"
86
+ end
87
+ end
88
+
89
+ def key_value_query(key, value)
90
+ if value.starts_with?("[")
91
+ "#{key}:#{value}"
92
+ else
93
+ "#{key}:\"#{value}\""
94
+ end
95
+ end
96
+
97
+ def parse_facet_counts(facet_counts)
98
+ return {} unless facet_counts
99
+
100
+ if facet_counts['facet_fields']
101
+ facet_counts['facet_fields'] = facet_counts['facet_fields'].reduce({}) do |acc, facet_collection|
102
+ acc[facet_collection[0]] = map_facet_array_to_facet_hash(facet_collection[1])
103
+ acc
104
+ end
105
+ end
106
+
107
+ if facet_counts['facet_queries']
108
+ facet_counts['facet_queries'] = facet_counts['facet_queries'].group_by{|k,v| k.split(":").first}.reduce({}) do |acc, facet_collection|
109
+ facet_name = facet_collection[0]
110
+ values = facet_collection[1]
111
+
112
+ acc[facet_name] = values.reduce({}) do |inner_acc, tuple|
113
+ range = tuple[0].split(":")[1]
114
+ inner_acc[range] = tuple[1]
115
+ inner_acc
116
+ end
117
+ acc
118
+ end
119
+ end
120
+
121
+ facet_counts
122
+ end
123
+
124
+ # solr facet_fields comes in tuple array format []
125
+ def map_facet_array_to_facet_hash(facet_collection)
126
+ if facet_collection.is_a?(Array)
127
+ facet_collection.each_slice(2).reduce({}){|acc, tuple| acc[tuple[0]] = tuple[1]; acc}
128
+ else
129
+ facet_collection
130
+ end
131
+ end
132
+
133
+ end
134
+ end
135
+ end
136
+
137
+ # namespace test documents
138
+ if defined?(Rails) && Rails.env == "test"
139
+ module Solrsan
140
+ module Search
141
+ extend ActiveSupport::Concern
142
+ module ClassMethods
143
+ def class_name
144
+ "#{to_s.underscore}_test"
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,3 @@
1
+ module Solrsan
2
+ VERSION = "0.0.20"
3
+ end
data/lib/solrsan.rb ADDED
@@ -0,0 +1,12 @@
1
+ require 'rsolr'
2
+ require 'active_support/json'
3
+ require 'active_support/concern'
4
+ require 'active_support/core_ext'
5
+
6
+ require 'singleton'
7
+ require 'solrsan/config'
8
+ require 'solrsan/search'
9
+ require 'solrsan/indexer'
10
+
11
+ module Solrsan
12
+ end
@@ -0,0 +1,63 @@
1
+ require 'uri'
2
+ require 'yaml'
3
+
4
+ namespace :solr do
5
+ env = "development"
6
+ base_dir = File.join(File.dirname(__FILE__), "..", "..")
7
+
8
+ if defined? Rails
9
+ env = Rails.env
10
+ base_dir = Rails.root
11
+ end
12
+
13
+ solr_config = YAML::load( File.open( File.join(base_dir, "config", "solr.yml") ) )
14
+ solr_home = File.join(base_dir, "config", "solr")
15
+ solr_data_dir = solr_config[env]['solr_data_dir']
16
+ solr_server_url = solr_config[env]['solr_server_url']
17
+
18
+ jetty_port = URI.parse(solr_server_url).port
19
+ jetty_path = solr_config[env]['jetty_path']
20
+
21
+ solr_server_dir = "cd #{jetty_path};"
22
+ start_solr_cmd = "java -jar start.jar"
23
+ jetty_port_opt = "jetty.port=#{jetty_port}"
24
+ solr_params = "#{jetty_port_opt} -Dsolr.solr.home=#{solr_home} -Dsolr.data.dir=#{solr_data_dir}"
25
+
26
+ desc "Start solr"
27
+ task(:start) do
28
+ # -Dsolr.clustering.enabled=true
29
+ cmd = kill_matching_process_cmd(jetty_port_opt)
30
+ stop_exit_status = run_system_command(cmd)
31
+
32
+ sleep(1)
33
+
34
+ cmd = "#{solr_server_dir} #{start_solr_cmd } #{solr_params} &"
35
+ run_system_command(cmd)
36
+ end
37
+
38
+ desc "Stop solr"
39
+ task(:stop) do
40
+ cmd = kill_matching_process_cmd(jetty_port_opt)
41
+ run_system_command(cmd)
42
+ end
43
+
44
+ def run_system_command(cmd)
45
+ puts cmd
46
+ status = system(cmd)
47
+ $?.exitstatus
48
+ end
49
+
50
+ def kill_matching_process_cmd(process_name)
51
+ cmd = "echo `ps -ef | grep -v grep | grep \"#{process_name.gsub("-", "\\-")}\" | awk '{print $2}'` | xargs kill"
52
+ end
53
+
54
+ # #example of a task to index all items
55
+ # desc "index food"
56
+ # task(:import_foods, :needs => :environment) do
57
+ # FoodDescription.find_in_batches do |batch|
58
+ # FoodIndexer.index(batch)
59
+ # puts "Done with batch of size: #{batch.size}"
60
+ # end
61
+ # end
62
+ end
63
+
data/solrsan.gemspec ADDED
@@ -0,0 +1,25 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "solrsan/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "solrsan"
7
+ s.version = Solrsan::VERSION
8
+ s.platform = Gem::Platform::RUBY
9
+ s.authors = ["Tommy Chheng"]
10
+ s.email = ["tommy.chheng@gmail.com"]
11
+ s.homepage = "http://github.com/tc/solrsan"
12
+ s.summary = %q{Lightweight wrapper for using Apache Solr within a Ruby application including Rails and Sinatra.}
13
+ s.description = %q{solrsan is a lightweight wrapper for using Apache Solr within a Ruby application including Rails and Sinatra.}
14
+
15
+ s.add_dependency('rsolr', '~>1.0.0')
16
+ s.add_dependency('activemodel', '~>3.0.5')
17
+ s.add_dependency('activesupport', '~>3.0.5')
18
+
19
+ s.rubyforge_project = "solrsan"
20
+
21
+ s.files = `git ls-files`.split("\n")
22
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
23
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
24
+ s.require_paths = ["lib"]
25
+ end
@@ -0,0 +1,11 @@
1
+ require 'active_model/attribute_methods'
2
+
3
+ class Document
4
+ include ActiveModel::AttributeMethods
5
+ include Solrsan::Search
6
+ attr_accessor :attributes
7
+
8
+ def initialize(attributes={})
9
+ @attributes = attributes
10
+ end
11
+ end
@@ -0,0 +1,13 @@
1
+ module SearchTestHelper
2
+ def document_mock
3
+ doc = Document.new({:id => 500,
4
+ :title => "Solr test document",
5
+ :author => "Tommy Chheng",
6
+ :content => "ruby scala search",
7
+ :review_count => 4,
8
+ :scores => [1,2,3,4],
9
+ :created_at => Date.parse("Dec 10 2010")})
10
+ doc
11
+ end
12
+ end
13
+
@@ -0,0 +1,24 @@
1
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
2
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
3
+
4
+ MODELS = File.join(File.dirname(__FILE__), "models")
5
+ $LOAD_PATH.unshift(MODELS)
6
+
7
+ #simulate rails env
8
+ class Rails
9
+ def self.env
10
+ "test"
11
+ end
12
+ end
13
+
14
+ require "rubygems"
15
+ require "test/unit"
16
+ require 'solrsan'
17
+
18
+ #test models
19
+ require 'document'
20
+
21
+ solr_config = YAML::load( File.open( File.join(File.dirname(__FILE__), "..", "config", "solr.yml") ) )
22
+ solr_server_url = solr_config["test"]['solr_server_url']
23
+
24
+ Solrsan::Config.instance.solr_server_url = solr_server_url
@@ -0,0 +1,25 @@
1
+ require 'test_helper'
2
+ require 'search_test_helper'
3
+
4
+ class IndexerTest < Test::Unit::TestCase
5
+ include SearchTestHelper
6
+
7
+ def setup
8
+ @document = document_mock
9
+ end
10
+
11
+ def teardown
12
+ end
13
+
14
+ def test_indexed_fields
15
+ created_doc = @document.indexed_fields
16
+ assert_equal @document.attributes[:id], created_doc[:db_id]
17
+ assert_equal @document.attributes[:title], created_doc[:title]
18
+ assert_equal @document.attributes[:author], created_doc[:author]
19
+ assert_equal @document.attributes[:content], created_doc[:content]
20
+ assert_equal @document.attributes[:review_count], created_doc[:review_count]
21
+ assert_equal @document.attributes[:scores], created_doc[:scores]
22
+ assert_equal @document.attributes[:created_at].to_time.utc.xmlschema, created_doc[:created_at]
23
+ end
24
+
25
+ end
@@ -0,0 +1,171 @@
1
+ require 'test_helper'
2
+ require 'search_test_helper'
3
+
4
+ class SearchTest < Test::Unit::TestCase
5
+ include SearchTestHelper
6
+
7
+ def setup
8
+ Document.destroy_all_index_documents!
9
+ @document = document_mock
10
+ end
11
+
12
+ def teardown
13
+ Document.destroy_all_index_documents!
14
+ end
15
+
16
+ def test_simple_query
17
+ Document.index(@document)
18
+ q = @document.attributes[:title]
19
+
20
+ response = Document.search(:q => q)
21
+ metadata = response[:metadata]
22
+ docs = response[:docs]
23
+ assert metadata[:total_count] > 0
24
+ end
25
+
26
+ def test_sort
27
+ Document.index(Document.new(:id => 3, :title => "solar city",:review_count => 10))
28
+ Document.index(Document.new(:id => 4, :title => "city solar", :review_count => 5))
29
+
30
+ q = "solar"
31
+ response = Document.search(:q => q, :sort => "review_count asc")
32
+ docs = response[:docs]
33
+
34
+ assert_not_nil docs[0], "Not enough docs for #{q} to test."
35
+ first_result_name = docs[0][:title]
36
+ assert_not_nil docs[1], "Not enough docs for #{q} to test."
37
+ second_result_name = docs[1][:title]
38
+
39
+ assert_equal [second_result_name, first_result_name].sort, [first_result_name, second_result_name]
40
+
41
+ response = Document.search(:q => q, :sort => "review_count desc")
42
+ docs = response[:docs]
43
+
44
+ assert_not_nil docs[0], "Not enough docs for #{q} to test."
45
+ first_result_name = docs[0][:title]
46
+ assert_not_nil docs[1], "Not enough docs for #{q} to test."
47
+ second_result_name = docs[1][:title]
48
+
49
+ assert_equal [second_result_name, first_result_name].sort.reverse, [first_result_name, second_result_name]
50
+ end
51
+
52
+ def test_invalid_query_should_return_error_message_in_metadata
53
+ response = Document.search(:q => "http://tommy.chheng.com")
54
+ docs = response[:docs]
55
+ metadata = response[:metadata]
56
+
57
+ assert_not_nil response[:metadata][:error]
58
+ assert_equal 400, response[:metadata][:error][:http_status_code]
59
+ end
60
+
61
+ def test_parse_facet_fields
62
+ facet_counts = {'facet_queries'=>{},
63
+ 'facet_fields' => {'language' => ["Scala", 2, "Ruby", 1, "Java", 0]},
64
+ 'facet_dates'=>{}}
65
+
66
+ facet_counts = Document.parse_facet_counts(facet_counts)
67
+
68
+ assert_equal ["Scala", "Ruby", "Java"], facet_counts['facet_fields']['language'].keys
69
+ end
70
+
71
+ def test_parse_facet_queries
72
+ facet_counts = {"facet_queries"=>{"funding:[0 TO 5000000]"=>1, "funding:[10000000 TO 50000000]"=>0}, "facet_fields"=>{}, "facet_dates"=>{}}
73
+
74
+ facet_counts = Document.parse_facet_counts(facet_counts)
75
+
76
+ expected = {"funding"=>{"[0 TO 5000000]"=>1, "[10000000 TO 50000000]"=>0}}
77
+ assert_equal expected, facet_counts['facet_queries']
78
+ end
79
+
80
+ def test_parse_fq_with_hash
81
+ params = {:fq => {:tags => ["ruby", "scala"]}}
82
+ filters = Document.parse_fq(params[:fq])
83
+
84
+ expected = ["tags:\"ruby\"", "tags:\"scala\""]
85
+ assert_equal expected, filters
86
+ end
87
+
88
+ def test_parse_fq_with_hash_array_args
89
+ params = {:fq => [{:tags => ["ruby", "scala"]}]}
90
+ filters = Document.parse_fq(params[:fq])
91
+
92
+ expected = ["tags:\"ruby\"", "tags:\"scala\""]
93
+ assert_equal expected, filters
94
+ end
95
+
96
+ def test_parse_fq_with_hash_string_args
97
+ params = {:fq => [{:tags => "ruby"}]}
98
+ filters = Document.parse_fq(params[:fq])
99
+
100
+ expected = ["tags:\"ruby\""]
101
+ assert_equal expected, filters
102
+ end
103
+
104
+ def test_parse_fq_with_string_args
105
+ params = {:fq => ["tags:ruby"]}
106
+ filters = Document.parse_fq(params[:fq])
107
+
108
+ expected = ["tags:ruby"]
109
+ assert_equal expected, filters
110
+ end
111
+
112
+ def test_parse_fq_with_empty
113
+ filters = Document.parse_fq([])
114
+ expected = []
115
+ assert_equal expected, filters
116
+ end
117
+
118
+ def test_filter_query
119
+ Document.index(Document.new(:id => 3, :author => "Bert", :title => "solr lucene",:review_count => 10, :tags => ['ruby']))
120
+ Document.index(Document.new(:id => 4, :author => "Ernie", :title => "lucene solr", :review_count => 5, :tags => ['ruby', 'scala']))
121
+
122
+ response = Document.search(:q => "solr", :fq => [{:tags => ["scala"]}])
123
+ docs = response[:docs]
124
+ metadata = response[:metadata]
125
+
126
+ assert_equal 1, metadata[:total_count]
127
+
128
+ doc = docs.first
129
+ assert_not_nil doc['tags']
130
+ assert doc['tags'].include?("scala")
131
+ end
132
+
133
+ def test_text_faceting
134
+ Document.index(Document.new(:id => 3, :author => "Bert", :title => "solr lucene",:review_count => 10))
135
+ Document.index(Document.new(:id => 4, :author => "Ernie", :title => "lucene solr", :review_count => 5))
136
+
137
+ response = Document.search(:q => "solr", :'facet.field' => ['author'])
138
+ docs = response[:docs]
139
+ facet_counts = response[:facet_counts]
140
+ assert_not_nil facet_counts["facet_fields"]["author"]
141
+
142
+ author_facet_entries = facet_counts["facet_fields"]["author"]
143
+ assert author_facet_entries.keys.include?("Bert") && author_facet_entries.keys.include?("Ernie")
144
+ end
145
+
146
+ def test_range_faceting
147
+ Document.index(Document.new(:id => 3, :author => "Bert", :title => "solr lucene",:review_count => 10))
148
+ Document.index(Document.new(:id => 4, :author => "Ernie", :title => "lucene solr", :review_count => 5))
149
+
150
+ response = Document.search(:q => "solr", :'facet.field' => ['author'], :'facet.query' => ["review_count:[1 TO 5]", "review_count:[6 TO 10]"])
151
+ docs = response[:docs]
152
+ facet_counts = response[:facet_counts]
153
+
154
+ assert_not_nil facet_counts["facet_fields"]["author"]
155
+ assert_not_nil facet_counts["facet_queries"]["review_count"]
156
+ assert_equal({"[1 TO 5]"=>1, "[6 TO 10]"=>1}, facet_counts["facet_queries"]["review_count"])
157
+ end
158
+
159
+ def test_highlighting_support
160
+ Document.index(Document.new(:id => 3, :author => "Bert", :title => "solr lucene",:review_count => 10, :tags => ["solr"]))
161
+
162
+ response = Document.search(:q => "solr",
163
+ :'hl.fl' => "*")
164
+ docs = response[:docs]
165
+ highlighting = response[:highlighting]
166
+
167
+ first_result = highlighting.first
168
+ assert first_result[1]['tags'].include?("<em>solr</em>")
169
+ end
170
+ end
171
+
metadata ADDED
@@ -0,0 +1,123 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: solrsan
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.20
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Tommy Chheng
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2011-03-18 00:00:00.000000000 -07:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: rsolr
17
+ requirement: &2153183880 !ruby/object:Gem::Requirement
18
+ none: false
19
+ requirements:
20
+ - - ~>
21
+ - !ruby/object:Gem::Version
22
+ version: 1.0.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: *2153183880
26
+ - !ruby/object:Gem::Dependency
27
+ name: activemodel
28
+ requirement: &2153183260 !ruby/object:Gem::Requirement
29
+ none: false
30
+ requirements:
31
+ - - ~>
32
+ - !ruby/object:Gem::Version
33
+ version: 3.0.5
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: *2153183260
37
+ - !ruby/object:Gem::Dependency
38
+ name: activesupport
39
+ requirement: &2153182780 !ruby/object:Gem::Requirement
40
+ none: false
41
+ requirements:
42
+ - - ~>
43
+ - !ruby/object:Gem::Version
44
+ version: 3.0.5
45
+ type: :runtime
46
+ prerelease: false
47
+ version_requirements: *2153182780
48
+ description: solrsan is a lightweight wrapper for using Apache Solr within a Ruby
49
+ application including Rails and Sinatra.
50
+ email:
51
+ - tommy.chheng@gmail.com
52
+ executables: []
53
+ extensions: []
54
+ extra_rdoc_files: []
55
+ files:
56
+ - .gitignore
57
+ - Gemfile
58
+ - Gemfile.lock
59
+ - LICENSE
60
+ - README.markdown
61
+ - Rakefile
62
+ - config/solr.yml
63
+ - config/solr.yml.example
64
+ - config/solr/conf/elevate.xml
65
+ - config/solr/conf/mapping-ISOLatin1Accent.txt
66
+ - config/solr/conf/protwords.txt
67
+ - config/solr/conf/schema.xml
68
+ - config/solr/conf/solrconfig.xml
69
+ - config/solr/conf/spellings.txt
70
+ - config/solr/conf/stopwords.txt
71
+ - config/solr/conf/synonyms.txt
72
+ - config/solr/conf/xslt/example.xsl
73
+ - config/solr/conf/xslt/example_atom.xsl
74
+ - config/solr/conf/xslt/example_rss.xsl
75
+ - config/solr/conf/xslt/luke.xsl
76
+ - lib/rails/generators/solrsan/config/config_generator.rb
77
+ - lib/rails/generators/solrsan/config/templates/solr.yml
78
+ - lib/rails/generators/solrsan/config/templates/solrsan.rb
79
+ - lib/rails/generators/solrsan_generator.rb
80
+ - lib/solrsan.rb
81
+ - lib/solrsan/config.rb
82
+ - lib/solrsan/indexer.rb
83
+ - lib/solrsan/search.rb
84
+ - lib/solrsan/version.rb
85
+ - lib/tasks/solr.rake
86
+ - solrsan.gemspec
87
+ - test/models/document.rb
88
+ - test/search_test_helper.rb
89
+ - test/test_helper.rb
90
+ - test/unit/indexer_test.rb
91
+ - test/unit/search_test.rb
92
+ has_rdoc: true
93
+ homepage: http://github.com/tc/solrsan
94
+ licenses: []
95
+ post_install_message:
96
+ rdoc_options: []
97
+ require_paths:
98
+ - lib
99
+ required_ruby_version: !ruby/object:Gem::Requirement
100
+ none: false
101
+ requirements:
102
+ - - ! '>='
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ required_rubygems_version: !ruby/object:Gem::Requirement
106
+ none: false
107
+ requirements:
108
+ - - ! '>='
109
+ - !ruby/object:Gem::Version
110
+ version: '0'
111
+ requirements: []
112
+ rubyforge_project: solrsan
113
+ rubygems_version: 1.6.2
114
+ signing_key:
115
+ specification_version: 3
116
+ summary: Lightweight wrapper for using Apache Solr within a Ruby application including
117
+ Rails and Sinatra.
118
+ test_files:
119
+ - test/models/document.rb
120
+ - test/search_test_helper.rb
121
+ - test/test_helper.rb
122
+ - test/unit/indexer_test.rb
123
+ - test/unit/search_test.rb