Initial Commit

master
Henry Jameson 6 年前
当前提交 94b504b854
  1. 11
      .eslintrc.js
  2. 73
      .gitignore
  3. 25
      index.js
  4. 58
      lib/CustomerFinder.js
  5. 32
      lib/CustomerListFromURL.js
  6. 27
      lib/CustomerListFromURL.test.js
  7. 22
      lib/DistanceResolver.js
  8. 26
      lib/DistanceResolver.test.js
  9. 33
      lib/OpenRouteMapDistanceResolver.js
  10. 28
      lib/SimpleDistanceResolver.js
  11. 28
      lib/SimpleDistanceResolver.test.js
  12. 2051
      package-lock.json
  13. 26
      package.json

@ -0,0 +1,11 @@
module.exports = {
"extends": "standard",
"overrides": [
{
files: "*.test.js",
rules: {
"no-unused-expressions": "off"
}
}
]
};

73
.gitignore vendored

@ -0,0 +1,73 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless

@ -0,0 +1,25 @@
#!/usr/bin/env node
const program = require('commander')
const CustomerFinder = require('./lib/CustomerFinder.js')
const CustomerListFromURL = require('./lib/CustomerListFromURL.js')
program
.version('0.0.1')
.option('--customers [url]', 'URL for customers list [by default uses the URL provided in email ;)].', 'https://s3.amazonaws.com/intercom-take-home-test/customers.txt')
.option('--home [home]', '"Home" location - distances to customers will be calculated against it [Default is [-6.257664, 53.339428]].', [-6.257664, 53.339428])
.option('--no-orm', 'Disables fetching distance via OpenRouteMap.')
.option('--direct-routes', 'Fall back to simple calculation if ORM tells there are no routes.')
.option('-d, --distance [meters]', 'Maximum exclusive distance in meters [100000]', 100000)
.parse(process.argv)
const customerListFromURL = new CustomerListFromURL(program.customers)
const customerFinder = new CustomerFinder(customerListFromURL, program.home, { noORM: !program.orm, fallbackOnInfinity: program.directRoutes })
console.log('Processing...')
customerFinder.findNearestCustomers(program.distance).then(customers => {
console.log('Fitting candidates for invitation:')
customers.forEach(customer => {
console.log('- ' + customer.name)
})
})

@ -0,0 +1,58 @@
const orm = new (require('./OpenRouteMapDistanceResolver.js'))()
const sd = new (require('./SimpleDistanceResolver.js'))()
class CustomerFinder {
/**
* @param {Type of customerListProvider} customerListProvider - provider that would give list of customers
* @param {Type of homeLocation} homeLocation - the location where event would take place
* @param {Object} [options] - optional arguments
* @param {Boolean} [options.noORM] - disable fetching distance via ORM
* @param {Boolean} [options.fallbackOnInfinity] - disable falling back to simple calculation in case ORM couldn't find routes
*/
constructor (customerListProvider, homeLocation, options) {
this._customers = customerListProvider.getList()
this._home = homeLocation
options = options || {}
this._noORM = options.noORM || false
this._fallbackOnInfinity = options.fallbackOnInfinity || true
}
/**
* Asynchronously fetches the distance between "home" and target customer location
* unless noORM is true, it tries to fetch route length from ORM and falls back to
* simple greater-circle length calculation
* @param {Customer} customer - customer from data
* @param {Boolean} noORM - disable fetching from ORM
* @returns {Promise<Number>} Promise of a distance in meters
*/
_getCustomerDistance (customer, noORM) {
const dr = noORM ? sd : orm
return dr.getDistance(this._home, [customer.longitude, customer.latitude])
.then(distance => (distance === Number.POSITIVE_INFINITY && this._fallbackOnInfinity)
? this._getCustomerDistance(customer, true)
: distance)
// probably should fall back for all following queries depending on response
.catch(e => console.error(`Failed fetching length via ${dr.constructor.name}: ${e.message}, falling back... to simple calculation`) ||
this._getCustomerDistance(customer, true))
}
/**
* Returns the list of customers within given limit
* @param {Number} distanceLimit - maximum (exclusive) distance between event location and customer location
* @returns {Promise<Customer[]>} filtered list of customers that match the distance criteria
*/
findNearestCustomers (distanceLimit) {
return this._customers
.then(customers => customers
.map(async customer => ((await this._getCustomerDistance(customer, this._noORM) < distanceLimit)) ? customer : undefined)
)
.then(promises => Promise.all(promises))
.then(customers => customers.filter(_ => _))
}
}
// just a test
// (new CustomerFinder(new (require('./CustomerListFromURL.js'))('https://s3.amazonaws.com/intercom-take-home-test/customers.txt'), [-6.257664, 53.339428], { noORM: false })).findNearestCustomers(100 * 1000).then(_ => console.log(_.length))
// this is what would be happening.
module.exports = CustomerFinder

@ -0,0 +1,32 @@
const rp = require('request-promise-native')
class CustomerListFromURL {
constructor (url) {
this._url = url
}
/**
* internal function that actually fetches data
* @returns {Promise<Response>} request-promise promise
*/
_fetch () {
return rp(this._url)
}
/**
* fetches list and processes it into usable JS array
* @returns {Customer[]} list of customers
*/
getList () {
return this._fetch()
// The data in supplied URL isn't exactly JSON array, instead a list of JSON objects separated by newlines.
.then((response) => response
.split('\n') // splitting by newlines
.map(_ => _.trim()) // trimming strings
.filter(_ => _) // filter out falsy items (undefineds, empty strings)
.map(_ => JSON.parse(_))
)
}
}
module.exports = CustomerListFromURL

@ -0,0 +1,27 @@
require('chai').should()
describe('CustomerListFromURL', function () {
let customerListFromURL
beforeEach(function () {
customerListFromURL = new (require('./CustomerListFromURL.js'))('')
// We mock the actual fetching of data.
customerListFromURL._fetch = () => Promise.resolve(`
{"latitude": "52.986375", "user_id": 12, "name": "Christina McArdle", "longitude": "-6.043701"}
{"latitude": "51.92893", "user_id": 1, "name": "Alice Cahill", "longitude": "-10.27699"}
{"latitude": "51.8856167", "user_id": 2, "name": "Ian McArdle", "longitude": "-10.4240951"}
{"latitude": "52.3191841", "user_id": 3, "name": "Jack Enright", "longitude": "-8.5072391"}
`)
})
it('getList should correctly parse the input', async function () {
const result = await customerListFromURL.getList()
result.should.be.an('array')
result.should.be.have.lengthOf(4)
result.should.have.deep.members([
{latitude: '52.986375', user_id: 12, name: 'Christina McArdle', longitude: '-6.043701'},
{latitude: '51.92893', user_id: 1, name: 'Alice Cahill', longitude: '-10.27699'},
{latitude: '51.8856167', user_id: 2, name: 'Ian McArdle', longitude: '-10.4240951'},
{latitude: '52.3191841', user_id: 3, name: 'Jack Enright', longitude: '-8.5072391'}
])
})
})

@ -0,0 +1,22 @@
class DistanceResolver {
getDistance (A, B) {
A = this._sanitizeCoords(A)
B = this._sanitizeCoords(B)
}
_sanitizeCoords (coords) {
if (!Array.isArray(coords)) throw new Error('Coordinates are not an array!')
if (coords.length !== 2) throw new Error('Coordinates are not in form of [lon, lat] array!')
let lon = coords[0]
let lat = coords[1]
if (typeof lon !== 'number') lon = Number(lon)
if (typeof lat !== 'number') lat = Number(lat)
if (Number.isNaN(lon)) throw new Error('Invalid longitude!')
if (Number.isNaN(lat)) throw new Error('Invalid latitude!')
if (Math.abs(lat) > 90) throw new Error('Latitude must be within [-90;90] degrees')
if (Math.abs(lon) > 180) throw new Error('Longitude must be within [-180;180] degrees')
return [lon, lat]
}
}
module.exports = DistanceResolver

@ -0,0 +1,26 @@
require('chai').should()
describe('DistanceResolver', function () {
const dr = new (require('./DistanceResolver.js'))()
describe('getDistance should throw exception when', function () {
it('longitude is out of bounds', function () {
(() => dr.getDistance([0, 0], [181, 0])).should.throw()
})
it('latitude is out of bounds', function () {
(() => dr.getDistance([0, 0], [180, -91])).should.throw()
})
it('invalid strings', function () {
(() => dr.getDistance(['one hundred', 0], [0, 0])).should.throw()
})
it('invalid arrays', function () {
(() => dr.getDistance([0, 0, 0], [0, 0])).should.throw()
})
it('invalid input', function () {
(() => dr.getDistance('An array', [0, 0])).should.throw()
})
})
it('_sanitizeCoords should convert string numbers to numbers', function () {
(() => dr._sanitizeCoords(['1', '-3'])).should.not.throw()
dr._sanitizeCoords(['1', '-3']).should.have.deep.members([1, -3])
})
})

@ -0,0 +1,33 @@
const DistanceResovler = require('./DistanceResolver.js')
/*
* This is a free key with 2500 requests per day restriction. Ideally this would
* have been stored in a separate location (not stored under VCS) like in CI
* deployment secrets storage. For simplicity's sake I left it here so that
* code will actually work.
*/
const apiKey = '58d904a497c67e00015b45fc711d2a70bece433faf282bd510515061'
const rp = require('request-promise-native')
class OpenRouteMapDistanceResolver extends DistanceResovler {
/**
* Gets (route) distance between point A and point B
* @param {Number[]} A - array [longitude, latitude]
* @param {Number[]} B - array [longitude, latitude]
* @returns {Return Promise<Number>} distance between points in meters
*/
getDistance (A, B) {
// profile profile drive-car gives "can't find point" for some locations, i suppose the ones that are not reachable via car
return rp(`https://api.openrouteservice.org/directions?api_key=${apiKey}&coordinates=${A.join(',')}%7C${B.join(',')}&profile=foot-walking`)
.then(JSON.parse)
// in case there are no routes between points assume there's really no route.
// i'm not sure if that's how it works tho
.then(response => response.routes.length === 0 ? Number.POSITIVE_INFINITY : response.routes[0].summary.distance)
}
}
// Just a test
// (new OpenRouteMapDistanceResolver()).getDistance([8.685755, 49.393505], [8.686141, 49.389643]).then(console.log)
// Should be around 430
module.exports = OpenRouteMapDistanceResolver

@ -0,0 +1,28 @@
const DistanceResovler = require('./DistanceResolver.js')
class SimpleDistanceResolver extends DistanceResovler {
/**
* Gets (great-circle) distance between point A and point B
* @param {Number[]} A - array [longitude, latitude]
* @param {Number[]} B - array [longitude, latitude]
* @returns {Return Promise<Number>} distance between points in meters
*/
getDistance (A, B) {
super.getDistance(A, B)
const radConv = Math.PI / 180
const earthRadius = 6371 * 1000 // kilometers to meters
const lonA = A[0] * radConv
const lonB = B[0] * radConv
const latA = A[1] * radConv
const latB = B[1] * radConv
const lonD = Math.abs(lonA - lonB)
const absAngle = Math.acos(Math.sin(latA) * Math.sin(latB) + Math.cos(latA) * Math.cos(latB) * Math.cos(lonD))
return Promise.resolve(absAngle * earthRadius)
}
}
module.exports = SimpleDistanceResolver

@ -0,0 +1,28 @@
require('chai').should()
describe('SimpleDistanceResolver', function () {
const simpleDistanceResolver = new (require('./SimpleDistanceResolver.js'))()
describe('getDistance', async function () {
it('short distance', async function () {
// Some points on straight road and distance provided by OpenRouteService
// so output of our function should relatively match the expectations
const A = [8.685755, 49.393505]
const B = [8.686141, 49.389643]
const realDist = 432
const distance = await simpleDistanceResolver.getDistance(A, B)
distance.should.be.closeTo(realDist, 5)
})
it('long distance', async function () {
// Some points on some very long straight road in USA and distance provided by OpenRouteService
// so output of our function should relatively match the expectations
const A = [-99.358635, 35.84109]
const B = [-99.35885, 35.725646]
const realDist = 12834
const distance = await simpleDistanceResolver.getDistance(A, B)
distance.should.be.closeTo(realDist, 5)
})
})
})

2051
package-lock.json 自动生成的

文件差异内容过多而无法显示 加载差异

@ -0,0 +1,26 @@
{
"name": "partymaker",
"version": "0.0.1",
"description": "Test exercise for Intercom",
"main": "index.js",
"scripts": {
"test": "mocha --recursive lib/**/*.test.js"
},
"author": "Roman Kostetskiy",
"license": "GPL-3.0-or-later",
"dependencies": {
"commander": "^2.16.0",
"request": "^2.87.0",
"request-promise-native": "^1.0.5"
},
"devDependencies": {
"chai": "^4.1.2",
"eslint": "^5.1.0",
"eslint-config-standard": "^11.0.0",
"eslint-plugin-import": "^2.13.0",
"eslint-plugin-node": "^7.0.1",
"eslint-plugin-promise": "^3.8.0",
"eslint-plugin-standard": "^3.1.0",
"mocha": "^5.2.0"
}
}
正在加载...
取消
保存