Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Implement solution #9

Closed
wants to merge 8 commits into from

2 participants

@ElijahCA

Hi there! Implementation details have been added to the bottom of the readme. The app is deployed here. Please let me know if you have any questions!

@cdnbacon
Owner

@ElijahCA I like what I see, but there's a pretty serious bug, repro is curl "http://127.0.0.1:3000/suggestions?q=M" or http://powerful-sierra-1219.herokuapp.com/suggestions?q=M

Can you fix it and then I'll start my review?

@ElijahCA

@cdnbacon sorry about that. The bug should be fixed now. Let me know if you have any other problems!

@ElijahCA

Sorry to change the code under your feet, if you had started reviewing. I found a better solution to the bug you encountered, and have implemented it in 2fcc979.

@ElijahCA

Sorry for another update; I thought of a better solution to the bug that you encountered today, so I've implemented it here.

If I think of anything else that I want to improve before you take a look, I'll add it to the pull request. You're free to take a look whenever you get a chance -- I'll always leave the pull request in a reviewable state, and keep the deployed code on Heroku up to date.

@cdnbacon cdnbacon closed this
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
View
7 Gemfile
@@ -1,9 +1,14 @@
source 'https://rubygems.org'
+ruby '2.0.0'
+gem 'dalli', '~>2.7.0'
+gem 'fast_trie', '~>0.5.0'
+gem 'haversine', '~>0.3.0'
+gem 'rack-cache', '~>1.2'
gem 'sinatra', '~>1.4.4'
gem 'thin', '~>1.6.1'
group 'development' do
gem 'rspec', '~>2.14.1'
gem 'rack-test', '~>0.6.2'
-end
+end
View
9 Gemfile.lock
@@ -2,9 +2,14 @@ GEM
remote: https://rubygems.org/
specs:
daemons (1.1.9)
+ dalli (2.7.0)
diff-lcs (1.2.4)
eventmachine (1.0.3)
+ fast_trie (0.5.0)
+ haversine (0.3.0)
rack (1.5.2)
+ rack-cache (1.2)
+ rack (>= 0.4)
rack-protection (1.5.1)
rack
rack-test (0.6.2)
@@ -31,6 +36,10 @@ PLATFORMS
ruby
DEPENDENCIES
+ dalli (~> 2.7.0)
+ fast_trie (~> 0.5.0)
+ haversine (~> 0.3.0)
+ rack-cache (~> 1.2)
rack-test (~> 0.6.2)
rspec (~> 2.14.1)
sinatra (~> 1.4.4)
View
73 README.md
@@ -142,3 +142,76 @@ Thin web server (v1.6.1 codename Death Proof)
Maximum connections set to 1024
Listening on 0.0.0.0:3000, CTRL+C to stop
```
+
+## Implementation
+
+Implemented on Heroku here: http://powerful-sierra-1219.herokuapp.com/
+
+My solution consists of two main components:
+- `CityMatcher`, a class responsible for parsing the city data file, and then
+ returning cities that match a partial city name. Partial city names provided
+ for the lookup are case insensitive, and do not treat accented characters
+ specially (é is considered the same as e). Lookups are done using a trie,
+ a space-efficient data structure that allows for fast partial string match
+ lookups.
+- `CityScorer`, a class which is responsble for assigning a confidence scores to
+ a set of cities.
+
+### Scoring
+
+Scoring is based on three criteria:
+
+ 1. Name completeness: Potentially matching cities for the partial match
+ 'Plymouth' are 'Plymouth, PA, USA' and 'Plymouth Meeting, PA, USA'. The
+ closer the partial match is to the full city name, the more likely it is that
+ the person is attempting to match that city, so 'Plymouth, PA, USA' will have a
+ higher confidence score.
+ 2. Population: Buses are more likely to depart from cities with higher
+ populations, and an individual is more likely to be living in a city with
+ more people. Therefore, cities with higher populations are assinged a higher
+ confidence score.
+ 3. Distance: If the latitude and longitude of the user are given, a closer city
+ will be assigned a higher confidence score.
+
+The highest score that can be assigned to an attribute is 1.0, while the lowest
+that can be assigned is 0.0. A higher score represents a more confident
+suggestion. The total confidence score is a weighted average of the individual
+attributes, again from 0.0 to 1.0, with the following weighting schemes used:
+
+ 1. Latitude and longitude provided:
+
+ `0.3 * name completeness score + 0.2 * population score + 0.5 * distance core`
+
+ 2. Latitude and longitude not provided:
+
+ `0.6 * name completeness score + 0.4 * population score`
+
+### Mitigations to handle high levels of traffic
+
+In order to handle potentially high amounts of traffic, a Memcache instance
+has been created on Heroku (using MemCachier). This cache is used in two ways:
+
+ 1. HTTP requests are cached using `Rack::Cache` for 30 seconds
+ 2. The lookup for cities that match a partial city name is cached for 60
+ seconds. (This is the output of `CityMatcher`, which used by `CityScorer` to
+ compute the scores).
+
+### Future Improvements
+
+Several improvements could be made to this implementation. Some of them are:
+- Take city names in different languages into account. Right now, only the
+ primary name of the city is used (the English name for non-Quebec cities, and
+ the French name for Quebec cities). However, the city data file has city
+ names in several different languages for many Canadian cities, which could be
+ used for better matching.
+ - The Trie class that is used does not support every unicode/utf-8 character
+ (`‘` is an example of an unsupported character). We would need to
+ implement our own trie in order to make this improvement.
+- Add the ability to limit the number of cities returned. This would be passed
+ in the query string as `&limit=n`, where only the top `n` cities would be
+ returned in the json response.
+- Update the scoring algorithm to figure out if the user is in Canada or the
+ United States by latitude/longitude or IP address, and then provide higher
+ scores to cities in the same country.
+- Use a more persistent key-value store (Redis) instead of Memcache, so that if
+ there is an outage our caches do not go cold.
View
52 app.rb
@@ -1,11 +1,53 @@
require 'sinatra/base'
require 'json'
+require_relative 'lib/city_matcher'
+require_relative 'lib/city_scorer'
+require_relative 'lib/common'
+
+DATA_FILE_LOCATION = './data/cities_canada-usa.tsv'
-# http://www.sinatrarb.com/
class App < Sinatra::Base
- # Endpoints
+ set :city_matcher, CityMatcher.new(DATA_FILE_LOCATION)
+
+ helpers do
+ def get_possible_cities(query)
+ unless $cache
+ return settings.city_matcher.possible_cities(query)
+ end
+
+ cached_possible_cities = $cache.get(query)
+ if cached_possible_cities
+ # cache hit
+ return Marshal.load(cached_possible_cities)
+ end
+
+ # cache miss
+ possible_cities = settings.city_matcher.possible_cities(query)
+ $cache.set(query, Marshal.dump(possible_cities), 60)
+ possible_cities
+ end
+ end
+
get '/suggestions' do
- status 404
- {:suggestions => []}.to_json
+ cache_control :public, :max_age => 30
+ content_type :json
+
+ query = request[:q]
+ if query.nil? || query.empty?
+ halt 400
+ end
+ lat = request[:latitude] ? request[:latitude].to_f : nil
+ long = request[:longitude] ? request[:longitude].to_f : nil
+
+ possible_cities = get_possible_cities(query)
+
+ if possible_cities.empty?
+ status 404
+ {:suggestions => []}.to_json
+ else
+ city_scorer = CityScorer.new(possible_cities, query, lat, long)
+ city_scorer.score_cities
+ JSON.pretty_generate({:suggestions => city_scorer.cities_to_hash})
+ end
end
-end
+end
View
12 config.ru
@@ -1,3 +1,13 @@
require File.join(File.dirname(__FILE__), 'app')
+require 'dalli'
+require 'rack-cache'
-run App
+if memcachier_servers = ENV['MEMCACHIER_SERVERS']
+ $cache = Dalli::Client.new memcachier_servers.split(','), {
+ username: ENV['MEMCACHIER_USERNAME'],
+ password: ENV['MEMCACHIER_PASSWORD']
+ }
+ use Rack::Cache, verbose: true, metastore: $cache, entitystore: $cache
+end
+
+run App
View
90 lib/city_matcher.rb
@@ -0,0 +1,90 @@
+require 'trie'
+
+require_relative 'common'
+require_relative 'strict_tsv'
+
+# This class is responsible for parsing the city data file, and returning
+# cities whose names match a given partial name.
+# Usage should be as follows:
+#
+# city_matcher = CityMatcher.new(data_file)
+# city_matcher.possible_cities('partial city name')
+# => Array of matching cities, where each city is of type CityMatcher::City
+class CityMatcher
+ # Canadian provinces are encoded as numbers in the data file.
+ # This maps the numbers to the corresponding provinces
+ PROV_MAPPINGS = {
+ '01' => 'AB',
+ '02' => 'BC',
+ '03' => 'MB',
+ '04' => 'NB',
+ '05' => 'NL',
+ '07' => 'NS',
+ '08' => 'ON',
+ '09' => 'PE',
+ '10' => 'QC',
+ '11' => 'SK',
+ '12' => 'YT',
+ '13' => 'NT',
+ '14' => 'NU'
+ }
+
+ COUNTRY_MAPPINGS = {
+ 'CA' => 'Canada',
+ 'US' => 'USA'
+ }
+
+ City = Struct.new(:name, :lat, :long, :population)
+
+ # Load a city data file in tsv format
+ def initialize(city_file)
+ # A trie allows us to efficiently find every city that matches a pattern.
+ # http://en.wikipedia.org/wiki/Trie
+ @city_trie = Trie.new
+
+ # @city_trie will return to us the names of cities that match a certain
+ # pattern as strings. We use @cities to look up the corresponding City
+ # structs (this way, our trie is smaller)
+ @cities = {}
+
+ tsv = StrictTsv.new(city_file)
+ tsv.parse do |row|
+ population = row['population'].to_i
+ next if population <= 5000 # reject if population is 5000 or lower
+ name = row['name']
+ prov = PROV_MAPPINGS[row['admin1']] || row['admin1']
+ country = COUNTRY_MAPPINGS[row['country']]
+ full_name = [name, prov, country].join(', ')
+ city = City.new(
+ full_name,
+ row['lat'].to_f,
+ row['long'].to_f,
+ population
+ )
+
+ lookup_name = normalize_name(full_name)
+ @cities[lookup_name] = city
+ @city_trie.add(lookup_name)
+ end
+ end
+
+ # Takes a partial city name, and returns an Array of City structs that match
+ # the string. The input city name is downcased, and accented characters are
+ # replaced with their non-accented counterparts, so that the lookup is case
+ # and language insensitive.
+ def possible_cities(partial_name)
+ return nil unless partial_name
+ city_names = @city_trie.children(normalize_name(partial_name))
+ return city_names.map { |c| @cities[c] }
+ end
+
+ private
+ def normalize_name(name)
+ # In order for our lookups to be case-insensitive, we use a city's downcased
+ # name as keys in @citiess and @city_trie.
+ # We also remove any accented characters, so that English users can still find
+ # cities with French accents.
+ # (For example, 'Montré' and 'Montre' will both match 'Montréal')
+ remove_accented_characters(name.downcase)
+ end
+end
View
116 lib/city_scorer.rb
@@ -0,0 +1,116 @@
+require 'haversine'
+require 'json'
+
+# This class takes as input an Array of cities, a string that partially matches
+# the names of these cities, and optionally the latitude and longitude of the
+# user. It is responsible for assigning a score to each city, which represets
+# how confident it is that the user is attempting to select each of the cities. The array
+# of cities is then returned in order of confidence, with the most confident
+# match at the top.
+#
+# Scoring is based on three criteria:
+# 1. Name completeness:
+# Potentially matching cities for the partial match 'Plymouth' are
+# 'Plymouth, PA, USA' and 'Plymouth Meeting, PA, USA'. The closer the
+# partial match is to the full city name, the more likely it is that the
+# person is attempting to match that city, so 'Plymouth, PA, USA' will have
+# a higher confidence score.
+# 2. Population:
+# Buses are more likely to depart from cities with higher populations, and an
+# individual is more likely to be living in a city with more people.
+# Therefore, cities with higher populations are assinged a higher confidence score.
+# 3. Distance:
+# If the latitude and longitude of the user are given, a closer city will be
+# assigned a higher confidence score.
+#
+# The highest score that can be assigned to an attribute is 1.0, while the lowest that
+# can be assigned is 0.0. A higher score represents a more confident suggestion.
+# The total confidence score is a weighted average of the individual attributes, again
+# from 0.0 to 1.0, with the following weighting schemes used:
+#
+# 1. Latitude and longitude provided:
+# 0.3 * name completeness score + 0.2 * population score + 0.5 * distance score
+# 2. Latitude and longitude not provided:
+# 0.6 * name completeness score + 0.4 * population score
+#
+#
+# Expceted usage of the class is as follows:
+#
+# city_scorer = CityScorer.new(city_list)
+# city_scorer.score_cities
+# city_scorer.cities_to_hash
+# => An array of the cities, sorted descending by score, where each city is of type Hash
+class CityScorer
+
+ City = Struct.new(:name, :lat, :long, :population, :name_completeness_score,
+ :population_score, :distance, :distance_score, :score)
+
+ def initialize(cities, partial_match, lat = nil, long = nil)
+ @cities = cities.map { |c| City.new(c.name, c.lat, c.long, c.population) }
+ @partial_match_size = partial_match.size.to_f
+ @lat = lat
+ @long = long
+ end
+
+ def score_cities
+ score_population
+ score_name_completeness
+ score_distance
+
+ @cities.each do |city|
+ if should_score_distance?
+ city.score = 0.3 * city.name_completeness_score +
+ 0.2 * city.population_score +
+ 0.5 * city.distance_score
+ else
+ city.score = 0.6 * city.name_completeness_score +
+ 0.4 * city.population_score
+ end
+ end
+ end
+
+ def cities_to_hash
+ sorted_cities = @cities.sort_by(&:score).reverse
+ cities_hashed = sorted_cities.map do |c|
+ {'name' => c.name,
+ 'latitude' => c.lat,
+ 'longitude' => c.long,
+ 'score' => c.score}
+ end
+ end
+
+ private
+
+ def score_population
+ max_pop = @cities.max_by(&:population).population.to_f
+
+ @cities.each do |city|
+ city.population_score = city.population / max_pop
+ end
+ end
+
+ def score_name_completeness
+ @cities.each do |city|
+ city.name_completeness_score = @partial_match_size / city.name.size
+ end
+ end
+
+ def should_score_distance?
+ return !@lat.nil? && !@long.nil?
+ end
+
+ def score_distance
+ return unless should_score_distance?
+
+ @cities.each do |city|
+ city.distance = Haversine.distance(@lat, @long, city.lat, city.long).to_m
+ end
+
+ max_distance = @cities.max_by(&:distance).distance
+
+ @cities.each do |city|
+ city.distance_score = 1 - city.distance / max_distance
+ end
+ end
+
+end
View
9 lib/common.rb
@@ -0,0 +1,9 @@
+# from http://blog.slashpoundbang.com/post/12938588984/remove-all-accents-and-diacritics-from-string-in-ruby
+# Since we only do lookups on lowercase strings, we only need to do the
+# conversion for lowercase characters
+def remove_accented_characters(string)
+ string.tr(
+ "‘àáâãäåāăąçćĉċčðďđèéêëēĕėęěĝğġģĥħìíîïĩīĭįıĵķĸĺļľŀłñńņňʼnŋòóôõöøōŏőŕŗřśŝşšſţťŧùúûüũūŭůűųŵýÿŷźżž",
+ "'aaaaaaaaacccccdddeeeeeeeeegggghhiiiiiiiiijkklllllnnnnnnooooooooorrrssssstttuuuuuuuuuuwyyyzzz"
+ )
+end
View
17 lib/strict_tsv.rb
@@ -0,0 +1,17 @@
+# From https://gist.github.com/hqmq/5460684
+class StrictTsv
+ attr_reader :filepath
+ def initialize(filepath)
+ @filepath = filepath
+ end
+
+ def parse
+ open(filepath) do |f|
+ headers = f.gets.strip.split("\t")
+ f.each do |line|
+ fields = Hash[headers.zip(line.split("\t"))]
+ yield fields
+ end
+ end
+ end
+end
View
68 spec/city_matcher_spec.rb
@@ -0,0 +1,68 @@
+require_relative 'spec_helper'
+require_relative '../lib/city_matcher.rb'
+
+describe CityMatcher do
+ let(:test_data_file) { File.join(__dir__, 'data/cities_canada-usa.tsv') }
+
+ context 'initialization' do
+ it 'loads a .tsv file upon initialization' do
+ city_matcher = CityMatcher.new(test_data_file)
+ cities = city_matcher.instance_variable_get(:@cities)
+
+ abbotsford = cities['abbotsford, bc, canada']
+ expect(abbotsford.name).to eq('Abbotsford, BC, Canada')
+ expect(abbotsford.lat).to eq(49.05798)
+ expect(abbotsford.long).to eq(-122.25257)
+ expect(abbotsford.population).to eq(151683)
+ end
+ end
+
+ context '#possible_cities' do
+ subject(:city_matcher) do
+ CityMatcher.new(test_data_file)
+ end
+
+ it 'returns an empty list if no matching city is found' do
+ expect(city_matcher.possible_cities('somecitythatdoesntexist')).to be_empty
+ end
+
+ it 'matches partial names' do
+ possible_cities = city_matcher.possible_cities('A')
+ expect(possible_cities.count).to eql(3)
+ expect(possible_cities[0].name).to eql('Abbotsford, BC, Canada')
+ expect(possible_cities[1].name).to eql('Acton Vale, QC, Canada')
+ expect(possible_cities[2].name).to eql('Airdrie, AB, Canada')
+ end
+
+ it 'matches full names' do
+ possible_cities = city_matcher.possible_cities('acton vale, qc, canada')
+ expect(possible_cities.count).to eql(1)
+ expect(possible_cities.first.name).to eql('Acton Vale, QC, Canada')
+ end
+
+ it 'is case insensitive' do
+ possible_cities = city_matcher.possible_cities('aBBotsFoRd')
+ expect(possible_cities.count).to eql(1)
+ expect(possible_cities.first.name).to eql('Abbotsford, BC, Canada')
+ end
+
+ it 'matches multiple cities with the same name' do
+ possible_cities = city_matcher.possible_cities('Kirkland')
+ expect(possible_cities.count).to eql(2)
+ expect(possible_cities.first.name).to eql('Kirkland, ON, Canada')
+ expect(possible_cities.last.name).to eql('Kirkland, QC, Canada')
+ end
+
+ it 'matches cities with accents without passing accents' do
+ possible_cities = city_matcher.possible_cities('Montreal')
+ expect(possible_cities.count).to eql(1)
+ expect(possible_cities.first.name).to eql('Montréal, QC, Canada')
+ end
+
+ it 'matches cities with accents when passing accents' do
+ possible_cities = city_matcher.possible_cities('Montréal')
+ expect(possible_cities.count).to eql(1)
+ expect(possible_cities.first.name).to eql('Montréal, QC, Canada')
+ end
+ end
+end
View
105 spec/city_scorer_spec.rb
@@ -0,0 +1,105 @@
+require 'json'
+
+require_relative 'spec_helper'
+require_relative '../lib/city_scorer.rb'
+require_relative '../lib/city_matcher.rb'
+
+describe CityScorer do
+ let(:toronto) { CityMatcher::City.new('Toronto, ON, Canada', 43.70011, -79.4163, 4612191) }
+ let(:toronto_oh) { CityMatcher::City.new('Toronto, OH, USA', 40.46423, -80.60091, 5091) }
+ let(:montreal) { CityMatcher::City.new('Montreal, QC, Canada', 45.50884, -73.58781, 3268513) }
+
+ context '#score_population' do
+ subject(:cities) do
+ city_scorer = CityScorer.new([toronto, montreal], 'match')
+ city_scorer.send(:score_population)
+ city_scorer.instance_variable_get(:@cities)
+ end
+
+ it 'gives the highest population city the highest score' do
+ expect(cities.first.name).to eql(toronto.name)
+ expect(cities.first.population_score).to eql(1.0)
+ end
+
+ it 'gives a smaller city a lower score' do
+ expect(cities.last.name).to eql(montreal.name)
+ expect(cities.last.population_score).to eql(montreal.population.to_f / toronto.population)
+ end
+ end
+
+ context '#score_distance' do
+ context 'with lat and long' do
+ subject(:cities) do
+ city_scorer = CityScorer.new([toronto, montreal], 'match', toronto.lat, toronto.long)
+ city_scorer.send(:score_distance)
+ city_scorer.instance_variable_get(:@cities)
+ end
+
+ it 'gives the closest city the highest score' do
+ expect(cities.first.name).to eql(toronto.name)
+ expect(cities.first.distance_score).to eql(1.0)
+ end
+
+ it 'gives a farther city a lower score' do
+ expect(cities.last.name).to eql(montreal.name)
+ expect(cities.last.distance_score).to be < 1.0
+ end
+ end
+
+ context 'without lat and long' do
+ it 'does not score distance when lat and long are not provided' do
+ city_scorer = CityScorer.new([toronto], 'match')
+ city_scorer.send(:score_distance)
+ cities = city_scorer.instance_variable_get(:@cities)
+
+ expect(cities.first.distance_score).to be_nil
+ end
+
+ end
+ end
+
+ context '#score_name_completeness' do
+ it 'gives a full match a perfect score' do
+ city_scorer = CityScorer.new([toronto], toronto.name)
+ city_scorer.send(:score_name_completeness)
+ cities = city_scorer.instance_variable_get(:@cities)
+
+ expect(cities.first.name_completeness_score).to be(1.0)
+ end
+
+ it 'gives a partial match a non-perfect score' do
+ partial_name = 'Tor'
+ city_scorer = CityScorer.new([toronto], 'Tor')
+ city_scorer.send(:score_name_completeness)
+ cities = city_scorer.instance_variable_get(:@cities)
+ city_name = cities.first.name
+
+ expect(cities.first.name_completeness_score).to be(partial_name.size / city_name.size.to_f)
+ end
+ end
+
+ context '#score' do
+ it 'scores closer city with larger population higher' do
+ city_scorer = CityScorer.new([toronto, toronto_oh], 'To', toronto.lat - 1, toronto.long + 1)
+ scored_cities = city_scorer.score_cities
+
+ expect(scored_cities.size).to eql(2)
+ expect(scored_cities[0].name).to eql('Toronto, ON, Canada')
+ expect(scored_cities[0].score).to be > scored_cities[1].score
+ end
+ end
+
+ context '#cities_to_hash' do
+ it 'converts Citys to hashes' do
+ city_scorer = CityScorer.new([toronto], toronto.name)
+ city_scorer.score_cities
+
+ response = city_scorer.cities_to_hash
+ expect(response.count).to eql(1)
+ expect(response[0]['name']).to eql(toronto.name)
+ expect(response[0]['score']).to eql(1.0)
+ expect(response[0]['latitude']).to eql(toronto.lat)
+ expect(response[0]['longitude']).to eql(toronto.long)
+ end
+ end
+end
View
7 spec/data/cities_canada-usa.tsv
@@ -0,0 +1,7 @@
+id name ascii alt_name lat long feat_class feat_code country cc2 admin1 admin2 admin3 admin4 population elevation dem tz modified_at
+5881791 Abbotsford Abbotsford Abbotsford,YXX,Абботсфорд 49.05798 -122.25257 P PPL CA 02 5957659 151683 114 America/Vancouver 2013-04-22
+5882142 Acton Vale Acton Vale 45.65007 -72.56582 P PPL CA 10 16 5135 90 America/Montreal 2008-04-11
+5882799 Airdrie Airdrie Erdri,ai er de li,ayrdray,ayrdry,eeodeuli,Ердри,إيردراي,ائرڈرائ,ایردری,艾尔德里,에어드리 51.30011 -114.03528 P PPL CA 01 24673 1086 America/Edmonton 2008-04-11
+5992830 Kirkland Kirkland 45.45008 -73.86586 P PPL CA 10 20491 36 America/Montreal 2010-09-23
+5992836 Kirkland Kirkland Lake Kirkland Lejk,YKX,keokeullaendeuleikeu,kyrkland lyk, awntaryw,Киркланд Лејк,كيركلاند ليك، أونتاريو,커클랜드레이크 48.14461 -80.03767 P PPL CA 08 7775 321 America/Toronto 2012-01-07
+6077243 Montréal Montreal Lungsod ng Montreal,Lungsod ng Montréal,Monreal,Monreal',Monreala,Monrealis,Monreyal,Monreāla,Mons Regius,Mont-real,Montreal,Montreal - Montreal,Montreal - Montréal,Montreal City,Montreali,Montrealo,Montréal,YMQ,meng te li er,monreali,monteuliol,montorioru,mwntral,mwntryal,Μοντρεαλ,Μόντρεαλ,Монреал,Монреаль,Монтреал,מונטריאול,مونترآل,مونتریال,مونترېئال,მონრეალი,ᒧᕆᐊᓪ,モントリオール,蒙特利尔,몬트리올 45.50884 -73.58781 P PPLA2 CA 10 06 3268513 216 America/Montreal 2012-08-02
View
88 spec/functional_spec.rb
@@ -1,6 +1,45 @@
require 'spec_helper'
+shared_examples_for 'a valid response' do |city_regexp|
+ it 'returns a 200' do
+ expect(response.status).to eq(200)
+ end
+
+ it 'returns an array of suggestions' do
+ expect(response.json_body['suggestions']).to be_a(Array)
+ expect(response.json_body['suggestions']).to_not be_empty
+ end
+
+ it 'contains a match' do
+ names = response.json_body['suggestions'].map { |r| r['name'] }
+ expect(names.grep(city_regexp)).to_not be_empty
+ end
+
+ it 'contains latitudes and longitudes' do
+ response.json_body['suggestions'].each do |result|
+ expect(result['latitude']).to_not be_nil
+ expect(result['longitude']).to_not be_nil
+ end
+ end
+
+ it 'contains scores' do
+ response.json_body['suggestions'].each do |result|
+ expect(result['score']).to_not be_nil
+ end
+ end
+end
+
describe 'GET /suggestions' do
+ describe 'without a query' do
+ subject(:response) do
+ get '/suggestions'
+ end
+
+ it 'returns a 400' do
+ expect(response.status).to eq(400)
+ end
+ end
+
describe 'with a non-existent city' do
subject(:response) do
get '/suggestions', {:q => 'SomeRandomCityInTheMiddleOfNowhere'}
@@ -16,36 +55,43 @@
end
end
- describe 'with a valid city' do
+ describe 'with a parital match' do
subject(:response) do
- get '/suggestions', {:q => 'Montreal'}
+ get '/suggestions', {:q => 'M'}
end
- it 'returns a 200' do
- expect(response.status).to eq(200)
- end
+ it_should_behave_like 'a valid response', /montréal/i
+ end
- it 'returns an array of suggestions' do
- expect(response.json_body['suggestions']).to be_a(Array)
- expect(response.json_body['suggestions']).to_not be_empty
+ describe 'with accented characters in query' do
+ subject(:response) do
+ get '/suggestions', {:q => 'Mā‘'}
end
- it 'contains a match' do
- names = response.json_body['suggestions'].map { |r| r['name'] }
- expect(names.grep(/montreal/i)).to_not be_empty
+ it_should_behave_like 'a valid response', /Mā‘ili/
+ end
+
+ describe 'with a valid city' do
+ subject(:response) do
+ get '/suggestions', {:q => 'Montreal'}
end
- it 'contains latitudes and longitudes' do
- response.json_body['suggestions'].each do |result|
- expect(result['latitude']).to_not be_nil
- expect(result['longitude']).to_not be_nil
- end
+ it_should_behave_like 'a valid response', /montréal/i
+ end
+
+ describe 'with a valid city, and valid longitude and latitude' do
+ subject(:response) do
+ get '/suggestions', {:q => 'Montreal', :longitude => '45.28378', :latitude => '-87.3828'}
end
- it 'contains scores' do
- response.json_body['suggestions'].each do |result|
- expect(result['score']).to_not be_nil
- end
+ it_should_behave_like 'a valid response', /montréal/i
+ end
+
+ describe 'with a valid city, and invalid longitude and latitude' do
+ subject(:response) do
+ get '/suggestions', {:q => 'Montreal', :longitude => 'hi', :latitude => 'mom'}
end
+
+ it_should_behave_like 'a valid response', /montréal/i
end
-end
+end
View
2  spec/spec_helper.rb
@@ -31,4 +31,4 @@ def json_body
config.include TestHelpers
config.include Rack::Test::Methods
-end
+end
Something went wrong with that request. Please try again.