diff --git a/lib/data_updater.js b/lib/data_updater.js index 34cbaf2..dfe35f0 100644 --- a/lib/data_updater.js +++ b/lib/data_updater.js @@ -12,12 +12,18 @@ const pidString = `${process.ppid ? `${process.ppid} > ` : ''}PID ${process.pid} export default class DataUpdater extends EventEmitter { /** * Class constructor - * @param {string} dataUrl - the URL from which to fetch the geo.json data + * @param {string} apiRoot - the root URL from which to fetch the geo.json data + * @param {Object} dataPaths - the paths from which to pull data with label as key * @returns {DataUpdater} - the created DataUpdater instance */ - constructor (dataUrl) { + constructor (apiRoot, dataPaths) { + console.log('creating new DataUpdater with args:', 'apiRoot =', apiRoot, ', dataPaths =', dataPaths); super(); - this.dataUrl = dataUrl; + this.apiRoot = apiRoot; + this.dataPaths = {}; + for (let [dataLabel, apiPath] of Object.entries(dataPaths)) { + this.dataPaths[dataLabel] = this.apiRoot + apiPath; + } this.updateInProgress = false; } @@ -28,47 +34,54 @@ export default class DataUpdater extends EventEmitter { * @returns {void} */ performUpdate (childProcess = false) { - let promise; + let promises; this.updateInProgress = true; if (childProcess) { // TODO: Not yet working - promise = new Promise((resolve, reject) => { - console.log(`(${pidString}) executing update on child process...`); - cp = fork(__filename, [], { - stdio: [process.stdin, process.stdout, process.stderr, 'ipc'], - env: { 'DATA_URL': this.dataUrl } - }); - process.on('message', (data) => { - if (data === 'fetchLocationData') { - console.log(`(${pidString}) 'fetchLocationData': starting update.`); - this.fetchLocationData() - .then((cpResult) => { - console.log(`(${pidString}) 'fetchLocationData': update completed.`); - process.send('message', cpResult); - }); - } else { - console.log(`(${pidString}) Unknown message value.`); - } - }); - process.on('error', (error) => { - console.error(`(${pidString}) Error in child process execution: ${error.message}`, error.trace); - reject(error); - }); - cp.on('message', (data) => { - resolve(data); - }); - cp.on('exit', (code, signal) => { - console.log(`(${pidString}) child process exited with code ${code}${signal ? `and signal ${signal}` : ''}.`); + promises = Object.entries(this.dataPaths).map((label_and_uri) => { + const [dataLabel, uri] = label_and_uri; + return new Promise((resolve, reject) => { + console.log(`(${pidString}) executing update on child process...`); + cp = fork(__filename, [], { + stdio: [process.stdin, process.stdout, process.stderr, 'ipc'], + env: { 'DATA_URL': uri, 'DATA_LABEL': dataLabel } + }); + process.on('message', (data) => { + if (data === 'fetchLocationData') { + console.log(`(${pidString}) 'fetchLocationData': starting update.`); + this.fetchLocationData(process.env.DATA_LABEL, process.env.DATA_URL) + .then((cpResult) => { + console.log(`(${pidString}) 'fetchLocationData': update completed for ${process.env.DATA_LABEL} (${process.env.DATA_URL}).`); + process.send('message', cpResult); + }); + } else { + console.log(`(${pidString}) Unknown message value.`); + } + }); + process.on('error', (error) => { + console.error(`(${pidString}) Error in child process execution: ${error.message}`, error.trace); + reject(error); + }); + cp.on('message', (data) => { + resolve(data); + }); + cp.on('exit', (code, signal) => { + console.log(`(${pidString}) child process exited with code ${code}${signal ? `and signal ${signal}` : ''}.`); + }); + cp.send('fetchLocationData'); }); - cp.send('fetchLocationData'); }); } else { console.log(`(${pidString}) starting update.`); - promise = this.fetchLocationData(); + promises = Object.entries(this.dataPaths) + .map(([dataLabel, uri]) => { return this.fetchLocationData(dataLabel, uri); }); } - promise + Promise.all(promises) .then((data) => { - console.log(`(${pidString}) update complete, emitting update...`); - this.emit('update', data); + const updatedDataStore = {}; + console.log(updatedDataStore, data) + for (let [label, geodata] of data) { updatedDataStore[label] = geodata; } + console.log(`(${pidString}) updates complete, emitting update event...`); + this.emit('update', updatedDataStore); this.updateInProgress = false; }) .catch((e) => { @@ -79,13 +92,14 @@ export default class DataUpdater extends EventEmitter { /** * Pulls geo.json data from the external source and emits an update event with the new data - * @emits DataUpdater#update - * @returns {void} + * @param {string} label - the label to use when adding data to the store object + * @param {string} uri - the URI from which to fetch location data + * @returns {Promise} - a Promise object which resolves to an array of [label, dataset] */ - fetchLocationData () { + fetchLocationData (label, uri) { return new Promise((resolve, reject) => { - console.log(`[${new Date()}] Updating location data...`); - https.get(this.dataUrl, (res) => { + console.log(`[${new Date()}] Updating ${label} location data from ${uri}...`); + https.get(uri, (res) => { const { statusCode } = res; const contentType = res.headers['content-type']; const error = this.handleErrorResponse(statusCode, contentType); @@ -99,13 +113,17 @@ export default class DataUpdater extends EventEmitter { const features = []; res.pipe(FeatureParser.parse()) .each((feature) => { - // console.log(`Processing feature: ${feature}`); - features.push(JSON.parse(feature.toString())); + try { + features.push(JSON.parse(feature.toString())); + } catch (error) { + if (error instanceof SyntaxError && error.message === 'Unexpected token ] in JSON at position 0') { + console.log(`Received empty response from API for ${label} (${uri})`); + } else { + console.error(`ERROR while processing ${label} (${uri}) feature: ${feature}`, error); + } + } }); - res.on('end', () => { - const locationData = this.extractGeoJsonData(features); - resolve(locationData); - }); + res.on('end', () => { resolve([label, this.extractGeoJsonData(features)]); }); console.log(`[${new Date()}] Location update complete.`); }).on('error', (e) => { console.error(`Got error: ${e.message}`); diff --git a/lib/pods_finder.js b/lib/pods_finder.js new file mode 100644 index 0000000..4eb6def --- /dev/null +++ b/lib/pods_finder.js @@ -0,0 +1,253 @@ +import zipcodes from 'zipcodes'; +import LatLon from 'geodesy/latlon-ellipsoidal-vincenty'; + +/** + * Class that finds PODs for a given set of zipcodes + */ +export default class PODsFinder { + /** + * Constructor for class + * @param {Map>} locationData - the POD location geodata + * @param {number} mileRadius - the mile radius to use when finding nearby PODs + * @returns {PODsFinder} - the created PODsFinder instance + */ + constructor (locationData, mileRadius) { + this.locationData = locationData; + this.mileRadius = mileRadius; + } + + /** + * Update POD location geodata used in lookups + * @param {Map>} locationData - the new POD location geodata + * @returns {void} + */ + updateLocationData (locationData) { this.locationData = locationData; } + + /** + * Process an incoming message to extract zipcode information + * @param {Array} sentZipCodes - the zipcodes to find PODs for + * @returns {Array} - the array of POD messages + */ + findPODs (sentZipCodes) { + if(sentZipCodes.length == 0) { + return ['Sorry, I couldn\'t find any ZIP codes in your text message. Please try again.',]; + } + const knownZipCodes = this.zipCodesWithPODs(); + const lookupZipCodes = this.augmentLookupZipCodes(sentZipCodes); + let foundPODs = this.computeFoundPODs(lookupZipCodes, knownZipCodes); + if (foundPODs.length == 0) { + return [`Sorry, I don't know about any food and water distribution points near ${sentZipCodes.join(' or ')}. Please try again later!`,]; + } + let podsArray = this.collectPODs(foundPODs, lookupZipCodes); + podsArray = _dedupeArray(podsArray, 'podIndex'); + + const sorts = this.sortedPODListsByLookupZip(podsArray, lookupZipCodes.map((z) => z.zip), 3); + const messages = this.buildMessages(sorts); + return messages; + } + + /** + * Get all zip codes with known PODs in them + * @returns {Array} - the array of zip codes + */ + zipCodesWithPODs () { + return Array.from(this.locationData.keys()); + } + + /** + * Augment the zipcodes with zip information and zips in radius + * @param {Array} zips - the array of zipcodes to augment + * @returns {Array} - the array of augmented zipcodes + */ + augmentLookupZipCodes (zips) { + return zips.map((zip) => { + const zipInfo = zipcodes.lookup(zip); + return { + zip: zipInfo.zip, + info: zipInfo, + latlon: new LatLon(zipInfo.latitude, zipInfo.longitude), + zipsInMileRadius: zipcodes.radius(zipInfo.zip, this.mileRadius) + }; + }); + } + + /** + * Compute the PODs found in the known zip codes from the lookups + * @param {Array} lookupZipCodes - the zipcodes to perform the lookup with + * @param {Array} knownZipCodes - the known zipcodes among the PODs + * @returns {Array} - the array of PODs found from the lookup + */ + computeFoundPODs (lookupZipCodes, knownZipCodes) { + let foundPODs = []; + lookupZipCodes.forEach((z) => { + knownZipCodes.forEach((known) => { + if (z.zipsInMileRadius.indexOf(known.toString()) >= 0) { + foundPODs.push(known.toString()); + } + }); + }); + return foundPODs; + } + + /** + * Augment the POD record with distance from lookups, whether its + * in the radius from the lookups, and with the message segment + * @param {Object} podRecord - the POD record object + * @param {Array} lookupZipCodes - the array of lookups + * @returns {Array} - the augmented POD record + */ + augmentPODRecord (podRecord, lookupZipCodes) { + const { pod, address, phone } = podRecord; + const podLatLon = new LatLon(podRecord.latitude, podRecord.longitude); + const podDistancesFromLookupZips = Array.from( + lookupZipCodes.map((zip) => { + return zip.latlon.distanceTo(podLatLon); + }) + ); + const zipInMileRadiusFromPOD = Array.from( + lookupZipCodes.map((zip) => { + return zip.zipsInMileRadius.includes(podRecord.zip); + }) + ); + const distances = {}; + const inRadius = {}; + for (let idx in lookupZipCodes) { + const zip = lookupZipCodes[idx].zip; + distances[zip] = podDistancesFromLookupZips[idx]; + inRadius[zip] = zipInMileRadiusFromPOD[idx]; + } + return { + ...podRecord, + distances: distances, + inRadius: inRadius, + message: `\n\n${pod}\n${address}${phone ? `\n${phone}` : ''}` + }; + } + + /** + * Collect the PODs for the array of zip codes into an Array + * @param {Array} foundPODs - the PODs to be collected + * @param {Array} lookupZipCodes - the augmented lookup zips + * @returns {Array} - the collected arrray of all relevant PODs + */ + collectPODs (foundPODs, lookupZipCodes) { + let podsArray = []; + for (let zip of foundPODs) { + const loc = this.locationData.get(zip); + if (loc) { + const pods = Array.from(loc); + podsArray = podsArray.concat( + pods.map( + (podRecord) => { + return this.augmentPODRecord(podRecord, lookupZipCodes) + } + ) + ); + } else { + console.warn(`Unexpected missing zip: ${zip}`); + } + } + return podsArray; + } + + /** + * Return the array of PODs in radius, filtering out those not + * @param {Array} pods - the array of PODs + * @param {string} lookupZip - the lookup index for the radius value + * @returns {Array} - the filtered array of PODs + */ + getInRadiusPODs (pods, lookupZip) { + return pods.filter((sh, _i, _a, k = lookupZip) => sh.inRadius[k]); + } + + /** + * Return the array of PODs sorted by distance ascending + * @param {Array} pods - the array of PODs + * @param {string} lookupZip - the lookup zipcode for the distance value + * @returns {Array} - the sorted array of PODs + */ + sortPODsByDistance (pods, lookupZip) { + return pods.sort((a, b, k = lookupZip) => + a.distances[k] - b.distances[k] + ); + } + + /** + * Truncate the array of PODs to the amount specified + * @param {Array} pods - the array of PODs + * @param {number} amount - the number of PODs to return + * @returns {Array} - the truncated array of PODs + */ + truncatePODList (pods, amount) { + return pods.slice(0, amount); + } + + /** + * Filter, sort and truncate the list of PODs per lookup zipcode + * @param {Array} pods - the array of augmented POD records + * @param {Array} lookups - the array of augmented lookup zipcodes + * @param {number} podsPerLookup - the number of PODs to return per lookup zipcode + * @returns {Array} - the filtered, sorted and truncated array + */ + sortedPODListsByLookupZip (pods, lookups, podsPerLookup) { + const results = {}; + const n = podsPerLookup; + lookups.forEach((zip) => { + const filtered = this.getInRadiusPODs(pods, zip); + const sorted = this.sortPODsByDistance(filtered, zip); + const truncated = this.truncatePODList(sorted, n); + results[zip] = truncated; + }); + return results; + } + + /** + * Construct messages from sorted POD lists by lookup zipcode + * @param {Object} sorts - the object of sorted POD lists keyed by lookup zipcode + * @returns {Array} - the array of messages + */ + buildMessages (sorts) { + const messages = []; + const milesToMeters = 1609.344, metersToMiles = 1.0 / milesToMeters; + for (let key in sorts) { + const podsSort = sorts[key]; + const zipcode = key; + let resultString = `Found ${podsSort.length} food/water distribution points near ${zipcode}:`; + for (let pod of podsSort) { + let dist = metersToMiles * pod.distances[key]; + dist = (dist < 1.0 ? Math.ceil(dist * 10) / 10 : Math.ceil(dist)); + const msg = pod.message + + `\n${dist < 1 ? 'Under 1' : `About ${dist}`}mi away`; + if ((resultString + msg).length > 800) { + messages.push(resultString); + resultString = ''; + } + resultString += msg; + } + if (resultString.length > 0) { messages.push(resultString) } + } + return messages; + } +} + +// Helper functions +export const _dedupeArray = function (arr, key) { + let a = _deepCopyArray(arr).reverse(); + a = a.filter(function (e, i, a) { + const testVal = e[key]; + return a.slice(i + 1).findIndex((o) => o[key] === testVal) === -1; + }); + return a.reverse(); +}; + +export const _deepCopyArray = function (o) { + let output, v, key; + output = Array.isArray(o) ? [] : (o == null ? null : {}); + for (key in o) { + if (o.hasOwnProperty(key)) { + v = o[key]; + output[key] = (typeof v === 'object') ? _deepCopyArray(v) : v; + } + } + return output; +}; \ No newline at end of file diff --git a/lib/server.js b/lib/server.js index 716f58b..9c12f4a 100644 --- a/lib/server.js +++ b/lib/server.js @@ -11,23 +11,35 @@ blocked((time, stack) => { }); /* Setup constants from environment variable parameters */ -const dataURL = process.env.DATA_URL; +const apiRoot = process.env.DATA_API_ROOT_URL; +const dataPaths = { + shelters: process.env.SHELTERS_PATH || '/shelters/geo.json', + distribution_points: process.env.PODS_PATH || '/distribution_points/geo.json' +}; const mileRadius = process.env.MILE_RADIUS || 30; /* Setup DataUpdater to periodically retrieve new location data */ -let locationData = new Map(); // actually fetched at server startup +let locationData = { + shelters: new Map(), + distribution_points: new Map() +}; // actually fetched at server startup import DataUpdater from './data_updater'; -const updater = new DataUpdater(dataURL); +const updater = new DataUpdater(apiRoot, dataPaths); /* Setup SheltersFinder with dummy locationData */ import SheltersFinder from './shelters_finder'; const sheltersFinder = new SheltersFinder(locationData, mileRadius); +/* Setup PODsFinder with dummy locationData */ +import PODsFinder from './pods_finder'; +const podsFinder = new PODsFinder(locationData, mileRadius); + /* Handle update event on the DataUpdater */ updater.on('update', (data) => { - console.log(`EVENT DataUpdater#update: Received new data covering ${data.size} zip codes.`); + console.log(`EVENT DataUpdater#update: Received ${Object.keys(data).length} new/refreshed data sets: {${Object.entries(data).map(([label, dataset]) => `${label}: ${dataset.size} zip codes`).join(', ')}}.`); locationData = data; - sheltersFinder.updateLocationData(data); + sheltersFinder.updateLocationData(data.shelters); + podsFinder.updateLocationData(data.distribution_points); console.log('Data update successful.'); }); @@ -69,6 +81,9 @@ app.post('/sms', (req, res) => { for (let msg of sheltersFinder.findShelters(sentZipcodes)) { responseMessages.push(msg); } + for (let msg of podsFinder.findPODs(sentZipcodes)) { + responseMessages.push(msg); + } } const twimlResponse = twilioFormatter.format(responseMessages); diff --git a/test/data_updater.test.js b/test/data_updater.test.js index 71205cc..07d3443 100644 --- a/test/data_updater.test.js +++ b/test/data_updater.test.js @@ -28,7 +28,7 @@ describe('DataUpdater', () => { describe('constructor', () => { it('creates a DataUpdater object', () => { - const d = new DataUpdater('some_url'); + const d = new DataUpdater('some_url', {data: 'some_path'}); expect(d instanceof DataUpdater).to.be.true; }); }); @@ -47,19 +47,21 @@ describe('DataUpdater', () => { }); it.skip('launches a child process for the update', (done) => { - const d = new DataUpdater(data_url); + const d = new DataUpdater(data_url_origin, { my_data: data_url_path }); d.on('update', (data) => { - console.log(`CHILD PROCESS VERSION - test process received 'update' event with data Map of size ${data.size}`); - expect(data).to.be.instanceOf(Map); + expect(data).to.have.own.property('my_data'); + expect(data.my_data).to.be.instanceOf(Map); + console.log(`CHILD PROCESS VERSION - test process received 'update' event with data Map of size ${data.my_data.size}`); done(); }); d.performUpdate(true); }); it('does an update on the main loop when specified', (done) => { - const d = new DataUpdater(data_url); + const d = new DataUpdater(data_url_origin, { my_data: data_url_path }); d.on('update', (data) => { - console.log(`MAIN LOOP VERSION - test process received 'update' event with data Map of size ${data.size}`); - expect(data).to.be.instanceOf(Map); + expect(data).to.have.own.property('my_data'); + expect(data.my_data).to.be.instanceOf(Map); + console.log(`MAIN LOOP VERSION - test process received 'update' event with data Map of size ${data.my_data.size}`); done(); }); d.performUpdate(false); diff --git a/test/pods_finder.test.js b/test/pods_finder.test.js new file mode 100644 index 0000000..732d27a --- /dev/null +++ b/test/pods_finder.test.js @@ -0,0 +1,387 @@ +import PODsFinder, { _dedupeArray, _deepCopyArray } from '../lib/pods_finder'; +import DataUpdater from '../lib/data_updater'; +import zipcodes from 'zipcodes'; +import LatLon from 'geodesy/latlon-ellipsoidal-vincenty'; + +import { expect } from 'chai'; +import fs from 'fs'; + +// Fixtures +const dataFixture = JSON.parse( + fs.readFileSync(`${__dirname}/fixtures/geo.json`).toString() +); + +describe('PODsFinder', () => { + describe('constructor', () => { + it('creates a PODsFinder object', () => { + const p = new PODsFinder(new Map(), 5); + expect(p).to.be.instanceOf(PODsFinder); + }); + }); + + describe('updateLocationData', () => { + it('updates the data with the provided object', () => { + const startData = { 'some': 'data' }; + const updateData = { 'other': 'data' }; + const p = new PODsFinder(startData, 5); + expect(p.locationData).to.deep.eql(startData); + p.updateLocationData(updateData); + expect(p.locationData).to.deep.eql(updateData); + }); + }); + + describe('zipCodesWithPODs()', () => { + it('returns an array of zip codes', () => { + const d = new DataUpdater('some url', { shelters: 'some_path' }); + const shelterLocationData = d.extractGeoJsonData(dataFixture.features); + const p = new PODsFinder(shelterLocationData, 5); + const zips = p.zipCodesWithPODs(); + const expected = ['68850','68883','93555','78570','78559','78580','71301']; + for (let zip of expected) { expect(zips).includes(zip); } + }); + }); + + describe('augmentLookupZipCodes(...)', () => { + it('returns an array of objects with a zip property containing the zip code', () => { + const p = new PODsFinder(new Map(), 5); + const zipcodes = ['70471', '70118']; + const augmentedZips = p.augmentLookupZipCodes(zipcodes); + for (let idx in augmentedZips) { + expect(augmentedZips[idx]).to.have.ownProperty('zip'); + expect(augmentedZips[idx].zip).to.eql(zipcodes[idx]); + } + }); + it('returns an array of objects with an info property matching interface zipcodes.ZipCode', () => { + const p = new PODsFinder(new Map(), 5); + const ZipCodeType = zipcodes.lookup('70003').constructor; + const zips = ['70471', '70118']; + const augmentedZips = p.augmentLookupZipCodes(zips); + for (let idx in augmentedZips) { + expect(augmentedZips[idx]).to.have.ownProperty('info'); + expect(augmentedZips[idx].info).to.be.instanceOf(ZipCodeType); + } + }); + it('returns an array of objects with an latlon property of type LatLon', () => { + const p = new PODsFinder(new Map(), 5); + const zipcodes = ['70471', '70118']; + const augmentedZips = p.augmentLookupZipCodes(zipcodes); + for (let idx in augmentedZips) { + expect(augmentedZips[idx]).to.have.ownProperty('latlon'); + expect(augmentedZips[idx].latlon).to.be.instanceOf(LatLon); + } + }); + it('returns an array of objects with an zipsInMileRadius property with an array of objects matching interface zipcodes.ZipCode', () => { + const p = new PODsFinder(new Map(), 5); + const zips = ['70471', '70118']; + const augmentedZips = p.augmentLookupZipCodes(zips); + for (let idx in augmentedZips) { + expect(augmentedZips[idx]).to.have.ownProperty('zipsInMileRadius'); + expect(augmentedZips[idx].zipsInMileRadius).to.be.an('array'); + augmentedZips[idx].zipsInMileRadius.forEach((z) => { + expect(z).to.be.a('string'); + }); + } + }); + }); + + describe('computeFoundPODs(...)', () => { + it('returns known zipcodes within the mile radius of each of the lookup zipcodes', () => { + const p = new PODsFinder(new Map(), 5); + const lookups = ['70471', '70118'].map((z, _idx, _ary, pf = p) => { + return { + zipsInMileRadius: zipcodes.radius(z, pf.mileRadius) + }; + }); + const knowns = ['70124', '70003', '70116', '70471']; + const found = p.computeFoundPODs(lookups, knowns); + const expected = ['70124', '70116', '70471']; + for (let zip of expected) { expect(found).includes(zip); } + }); + }); + + describe('augmentPODRecord(...)', () => { + it('returns an object with property distances having keys lookups', () => { + const p = new PODsFinder(new Map(), 5); + const shelter = dataFixture.features[0].properties; + const lookups = p.augmentLookupZipCodes(['70471', '70118']); + const augmented = p.augmentPODRecord(shelter, lookups); + expect(augmented).to.have.ownProperty('distances'); + expect(augmented.distances).to.be.an('object'); + for (let l of lookups) { + expect(augmented.distances).to.have.ownProperty(l.zip); + } + }); + it('returns an object with property inRadius having keys lookups', () => { + const p = new PODsFinder(new Map(), 5); + const shelter = dataFixture.features[0].properties; + const lookups = p.augmentLookupZipCodes(['70471', '70118']); + const augmented = p.augmentPODRecord(shelter, lookups); + expect(augmented).to.have.ownProperty('inRadius'); + expect(augmented.inRadius).to.be.an('object'); + for (let l of lookups) { + expect(augmented.inRadius).to.have.ownProperty(l.zip); + } + }); + it('returns an object with array property message type string', () => { + const p = new PODsFinder(new Map(), 5); + const shelter = dataFixture.features[0].properties; + const lookups = p.augmentLookupZipCodes(['70471', '70118']); + const augmented = p.augmentPODRecord(shelter, lookups); + expect(augmented).to.have.ownProperty('message'); + expect(augmented.message).to.be.a('string'); + }); + }); + + describe('collectPODs(...)', () => { + it('returns an array of objects ', () => { + const d = new DataUpdater('some_url', { shelters: 'some_path' }); + const locationData = d.extractGeoJsonData(dataFixture.features); + const p = new PODsFinder(locationData, 5); + const lookups = p.augmentLookupZipCodes(['68850', '71301']); + const knowns = p.zipCodesWithPODs(); + const foundPODs = p.computeFoundPODs(lookups, knowns); + const collected = p.collectPODs(foundPODs, lookups); + const expected = Array.from( + JSON.parse('[{"accepting":"yes","shelter":"Lexington High School","address":"1308 N Adams St, Lexington, NE 68850, USA","city":"LEXINGTON","state":"NE","county":"Dawson County","zip":"68850","phone":null,"updated_by":null,"notes":null,"volunteer_needs":null,"longitude":-99.7487,"latitude":40.7868,"supply_needs":null,"source":"FEMA GeoServer, POD ID: 223259, Org ID: 121390, FORT KEARNEY CHAPTER","google_place_id":null,"special_needs":null,"id":2,"archived":false,"pets":"No","pets_notes":null,"needs":[],"updated_at":"2019-07-11T13:52:43-05:00","updatedAt":"2019-07-11T13:52:43-05:00","last_updated":"2019-07-11T13:52:43-05:00","cleanPhone":"badphone","shelterIndex":1},{"accepting":"yes","shelter":"Bolton Ave. Community Center","address":"226 Bolton Ave, Alexandria, LA 71301, USA","city":"ALEXANDRIA","state":"LA","county":"Rapides Parish","zip":"71301","phone":null,"updated_by":null,"notes":null,"volunteer_needs":null,"longitude":-92.458,"latitude":31.3076,"supply_needs":null,"source":"FEMA GeoServer, POD ID: 328703, Org ID: 121263, Central Louisiana Chapter","google_place_id":null,"special_needs":null,"id":8,"archived":false,"pets":"No","pets_notes":null,"needs":[],"updated_at":"2019-07-11T19:42:07-05:00","updatedAt":"2019-07-11T19:42:07-05:00","last_updated":"2019-07-11T19:42:07-05:00","cleanPhone":"badphone","shelterIndex":8}]') + .map( + (pd, _i, _a, pf = p, l = lookups) => + pf.augmentPODRecord(pd, l) + ) + ); + expect(collected).to.be.an('array'); + console.log(collected) + for (let shelter of expected) { + expect(collected).to.deep.include(shelter); + } + }); + }); + + describe('getInRadiusPODs(...)', () => { + it('returns in-radius shelters for the lookup as an array', () => { + const p = new PODsFinder(new Map(), 5); + const shelters = [ + { inRadius: { '70471': true, '70118': false } }, + { inRadius: { '70471': true, '70118': true } }, + { inRadius: { '70471': false, '70118': false } }, + { inRadius: { '70471': false, '70118': true } } + ]; + const expected_70471 = [ + { inRadius: { '70471': true, '70118': false } }, + { inRadius: { '70471': true, '70118': true } } + ]; + for (let sh of expected_70471) { + expect(p.getInRadiusPODs(shelters, '70471')).to.deep.include(sh); + } + const expected_70118 = [ + { inRadius: { '70471': true, '70118': true } }, + { inRadius: { '70471': false, '70118': true } } + ]; + for (let sh of expected_70118) { + expect(p.getInRadiusPODs(shelters, '70118')).to.deep.include(sh); + } + }); + }); + + describe('sortPODsByDistance(...)', () => { + it('sorts by ascending distance from the given lookup', () => { + const p = new PODsFinder(new Map(), 5); + const shelters = [ + { distances: { '70471': 5.6, '70118': 4.3, '70124': 2.1 } }, + { distances: { '70471': 1.2, '70118': 2.8, '70124': 2.9 } }, + { distances: { '70471': 3.3, '70118': 1.0, '70124': 9.6 } } + ]; + const expected_70471 = [ + { distances: { '70471': 1.2, '70118': 2.8, '70124': 2.9 } }, + { distances: { '70471': 3.3, '70118': 1.0, '70124': 9.6 } }, + { distances: { '70471': 5.6, '70118': 4.3, '70124': 2.1 } } + ]; + for (let sh of expected_70471) { + expect(p.sortPODsByDistance(shelters, '70471')).to.deep.include(sh); + } + const expected_70118 = [ + { distances: { '70471': 3.3, '70118': 1.0, '70124': 9.6 } }, + { distances: { '70471': 1.2, '70118': 2.8, '70124': 2.9 } }, + { distances: { '70471': 5.6, '70118': 4.3, '70124': 2.1 } } + ]; + for (let sh of expected_70118) { + expect(p.sortPODsByDistance(shelters, '70118')) + .to.deep.include(sh); + } + const expected_70124 = [ + { distances: { '70471': 5.6, '70118': 4.3, '70124': 2.1 } }, + { distances: { '70471': 1.2, '70118': 2.8, '70124': 2.9 } }, + { distances: { '70471': 3.3, '70118': 1.0, '70124': 9.6 } } + ]; + for (let sh of expected_70124) { + expect(p.sortPODsByDistance(shelters, '70124')).to.deep.include(sh); + } + }); + }); + + describe('truncatePODList(...)', () => { + it('truncates the array of shelters to the specified length', () => { + const p = new PODsFinder(new Map(), 5); + const shelters = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + let amt, x; + for (amt of [2, 3, 7, 12]) { + const truncated = p.truncatePODList(shelters, amt); + const expected = Math.min(amt, shelters.length); + expect(truncated).to.be.an('array'); + expect(truncated.length).to.eql(expected); + for (x = 0; x < expected; x++) { + expect(truncated[x]).to.eql(shelters[x]); + } + } + }); + }); + + describe('sortedPODListsByLookupZip', () => { + it('returns filtered, sorted and truncated lists of shelters for each lookup zipcode', () => { + const p = new PODsFinder(new Map(), 5); + const shelters = [ + { + distances: { '70471': 5.6, '70118': 4.3, '70124': 2.1 }, + inRadius: { '70471': false, '70118': true, '70124': true } + }, + { + distances: { '70471': 1.2, '70118': 2.8, '70124': 2.9 }, + inRadius: { '70471': true, '70118': true, '70124': true } + }, + { + distances: { '70471': 3.3, '70118': 1.0, '70124': 9.6 }, + inRadius: { '70471': true, '70118': true, '70124': false } + }, + { + distances: { '70471': 5.3, '70118': 7.0, '70124': 8.1 }, + inRadius: { '70471': false, '70118': false, '70124': false } + } + ]; + const lookups = [ + { zip: '70471' }, + { zip: '70118' }, + { zip: '70124' } + ]; + const expected = { + '70471': [ + { + distances: { '70471': 1.2, '70118': 2.8, '70124': 2.9 }, + inRadius: { '70471': true, '70118': true, '70124': true } + }, + { + distances: { '70471': 3.3, '70118': 1.0, '70124': 9.6 }, + inRadius: { '70471': true, '70118': true, '70124': false } + } + ], + '70118': [ + { + distances: { '70471': 3.3, '70118': 1.0, '70124': 9.6 }, + inRadius: { '70471': true, '70118': true, '70124': false } + }, + { + distances: { '70471': 1.2, '70118': 2.8, '70124': 2.9 }, + inRadius: { '70471': true, '70118': true, '70124': true } + } + ], + '70124': [ + { + distances: { '70471': 5.6, '70118': 4.3, '70124': 2.1 }, + inRadius: { '70471': false, '70118': true, '70124': true } + }, + { + distances: { '70471': 1.2, '70118': 2.8, '70124': 2.9 }, + inRadius: { '70471': true, '70118': true, '70124': true } + } + ] + }; + const lists = p.sortPODsByDistance(shelters, lookups, 2); + for (let lookup of lookups) { + expect(lists[lookup]).to.deep.eql(expected[lookup]); + } + }); + }); + + describe('buildMessages(...)', () => { + it('generates an array of messages', () => { + const p = new PODsFinder(new Map(), 5); + const sorts = { + '70471': [ + { + distances: { '70471': 418.4, '70118': 4506.2, '70124': 4667.1 }, + message: '\n\nSHELTER NUMBER ONE\n123 Any Street, Mandeville, LA 70471' + }, + { + distances: { '70471': 5310.8, '70118': 1609.3, '70124': 15449.7 }, + message: '\n\nSHELTER NUMBER TWO\n123 Any Street, New Orleans, LA 70118' + } + ], + '70118': [ + { + distances: { '70471': 5310.8, '70118': 1609.3, '70124': 15449.7 }, + message: '\n\nSHELTER NUMBER TWO\n123 Any Street, New Orleans, LA 70118' + }, + { + distances: { '70471': 418.4, '70118': 4506.2, '70124': 4667.1 }, + message: '\n\nSHELTER NUMBER ONE\n123 Any Street, Mandeville, LA 70471' + } + ], + '70124': [ + { + distances: { '70471': 9012.3, '70118': 6920.2, '70124': 3379.6 }, + message: '\n\nSHELTER NUMBER THREE\n456 Other Street, New Orleans, LA 70123' + }, + { + distances: { '70471': 418.4, '70118': 4506.2, '70124': 4667.1 }, + message: '\n\nSHELTER NUMBER ONE\n123 Any Street, Mandeville, LA 70471' + } + ] + }; + const expected = [ + 'Found 2 food/water distribution points near 70471:\n\nSHELTER NUMBER ONE\n123 Any Street, Mandeville, LA 70471\nUnder 1mi away\n\nSHELTER NUMBER TWO\n123 Any Street, New Orleans, LA 70118\nAbout 4mi away', + 'Found 2 food/water distribution points near 70118:\n\nSHELTER NUMBER TWO\n123 Any Street, New Orleans, LA 70118\nAbout 1mi away\n\nSHELTER NUMBER ONE\n123 Any Street, Mandeville, LA 70471\nAbout 3mi away', + 'Found 2 food/water distribution points near 70124:\n\nSHELTER NUMBER THREE\n456 Other Street, New Orleans, LA 70123\nAbout 3mi away\n\nSHELTER NUMBER ONE\n123 Any Street, Mandeville, LA 70471\nAbout 3mi away' + ]; + for (let message of expected) { + expect(p.buildMessages(sorts)).to.deep.include(message); + } + }); + }); + + describe('helpers', () => { + describe('_dedupeArray(...)', () => { + it('dedupes an array of objects by key', () => { + const arr = [ + { a: 0, b: 1, c: 2 }, + { a: 0, b: 3, c: 2 }, + { a: 0, b: 1, c: 8 }, + { a: 1, b: 1, c: 8 } + ]; + const expected = { + a: [ + { a: 0, b: 1, c: 2 }, + { a: 1, b: 1, c: 8 } + ], + b: [ + { a: 0, b: 1, c: 2 }, + { a: 0, b: 3, c: 2 }, + ], + c: [ + { a: 0, b: 1, c: 2 }, + { a: 0, b: 1, c: 8 } + ] + }; + for (let key in expected) { + expect(_dedupeArray(arr, key)).to.deep.equal(expected[key]); + } + }); + }); + + describe('_deepCopyArray(...)', () => { + it('deep-copies an array of arbitrary data types', () => { + const arr = [0, 'string', false, ['another', 'array', 5], { + some: 'object', of: 'structured', data: [9, 3, 2, 4, 1] + }, 5, [ { a: 0, b: 1, c: 2 }, { a: 0, b: 1, c: 8 } ]]; + expect(_deepCopyArray(arr)).to.deep.equal(arr); + }); + }); + }); +}); diff --git a/test/shelters_finder.test.js b/test/shelters_finder.test.js index bfdbb3d..8ded231 100644 --- a/test/shelters_finder.test.js +++ b/test/shelters_finder.test.js @@ -32,9 +32,9 @@ describe('SheltersFinder', () => { describe('zipCodesWithShelters()', () => { it('returns an array of zip codes', () => { - const d = new DataUpdater('some url'); - const locationData = d.extractGeoJsonData(dataFixture.features); - const s = new SheltersFinder(locationData, 5); + const d = new DataUpdater('some url', { shelters: 'some_path' }); + const shelterLocationData = d.extractGeoJsonData(dataFixture.features); + const s = new SheltersFinder(shelterLocationData, 5); const zips = s.zipCodesWithShelters(); const expected = ['68850','68883','93555','78570','78559','78580','71301']; for (let zip of expected) { expect(zips).includes(zip); } @@ -134,7 +134,7 @@ describe('SheltersFinder', () => { describe('collectShelters(...)', () => { it('returns an array of objects ', () => { - const d = new DataUpdater('some_url'); + const d = new DataUpdater('some_url', { shelters: 'some_path' }); const locationData = d.extractGeoJsonData(dataFixture.features); const s = new SheltersFinder(locationData, 5); const lookups = s.augmentLookupZipCodes(['68850', '71301']);