Skip to content

Commit b5bc3a7

Browse files
authored
Puf strengthen types (#215)
* Add Geopoint, Geohash, and GeohashRange types. * Move all common tests into GeoFireUtils. * Move geohashQuery tests to GeoFireUtils. * Improve coverage of GeoQuery constructor * Move query validation tests into GeoQuery.test
1 parent 8d13bcd commit b5bc3a7

File tree

11 files changed

+441
-343
lines changed

11 files changed

+441
-343
lines changed

packages/geofire-common/src/index.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ export const E2 = 0.00669447819799;
2929
// Cutoff for rounding errors on double calculations
3030
export const EPSILON = 1e-12;
3131

32+
export type Geopoint = [number, number];
33+
export type Geohash = string;
34+
export type GeohashRange = [Geohash, Geohash];
35+
3236
function log2(x: number): number {
3337
return Math.log(x) / Math.log(2);
3438
}
@@ -64,7 +68,7 @@ export function validateKey(key: string): void {
6468
*
6569
* @param location The [latitude, longitude] pair to be verified.
6670
*/
67-
export function validateLocation(location: number[]): void {
71+
export function validateLocation(location: Geopoint): void {
6872
let error: string;
6973

7074
if (!Array.isArray(location)) {
@@ -96,7 +100,7 @@ export function validateLocation(location: number[]): void {
96100
*
97101
* @param geohash The geohash to be validated.
98102
*/
99-
export function validateGeohash(geohash: string): void {
103+
export function validateGeohash(geohash: Geohash): void {
100104
let error;
101105

102106
if (typeof geohash !== 'string') {
@@ -139,7 +143,7 @@ export function degreesToRadians(degrees: number): number {
139143
* global default is used.
140144
* @returns The geohash of the inputted location.
141145
*/
142-
export function geohashForLocation(location: number[], precision: number = GEOHASH_PRECISION): string {
146+
export function geohashForLocation(location: Geopoint, precision: number = GEOHASH_PRECISION): Geohash {
143147
validateLocation(location);
144148
if (typeof precision !== 'undefined') {
145149
if (typeof precision !== 'number' || isNaN(precision)) {
@@ -262,7 +266,7 @@ export function wrapLongitude(longitude: number): number {
262266
* @param size The size of the bounding box.
263267
* @returns The number of bits necessary for the geohash.
264268
*/
265-
export function boundingBoxBits(coordinate: number[], size: number): number {
269+
export function boundingBoxBits(coordinate: Geopoint, size: number): number {
266270
const latDeltaDegrees = size / METERS_PER_DEGREE_LATITUDE;
267271
const latitudeNorth = Math.min(90, coordinate[0] + latDeltaDegrees);
268272
const latitudeSouth = Math.max(-90, coordinate[0] - latDeltaDegrees);
@@ -278,10 +282,11 @@ export function boundingBoxBits(coordinate: number[], size: number): number {
278282
* to be prefixes of any geohash that lies within the circle.
279283
*
280284
* @param center The center given as [latitude, longitude].
281-
* @param radius The radius of the circle.
282-
* @returns The eight bounding box points.
285+
* @param radius The radius of the circle in meters.
286+
* @returns The center of the box, and the eight bounding box points.
283287
*/
284-
export function boundingBoxCoordinates(center: number[], radius: number): number[][] {
288+
export function boundingBoxCoordinates(center: Geopoint, radius: number):
289+
[Geopoint, Geopoint,Geopoint,Geopoint,Geopoint,Geopoint,Geopoint,Geopoint,Geopoint] {
285290
const latDegrees = radius / METERS_PER_DEGREE_LATITUDE;
286291
const latitudeNorth = Math.min(90, center[0] + latDegrees);
287292
const latitudeSouth = Math.max(-90, center[0] - latDegrees);
@@ -308,7 +313,7 @@ export function boundingBoxCoordinates(center: number[], radius: number): number
308313
* @param bits The number of bits of precision.
309314
* @returns A [start, end] pair of geohashes.
310315
*/
311-
export function geohashQuery(geohash: string, bits: number): string[] {
316+
export function geohashQuery(geohash: Geohash, bits: number): GeohashRange {
312317
validateGeohash(geohash);
313318
const precision = Math.ceil(bits / BITS_PER_CHAR);
314319
if (geohash.length < precision) {
@@ -337,7 +342,7 @@ export function geohashQuery(geohash: string, bits: number): string[] {
337342
* @param radius The radius of the circle.
338343
* @return An array of geohash query bounds, each containing a [start, end] pair.
339344
*/
340-
export function geohashQueryBounds(center: number[], radius: number): string[][] {
345+
export function geohashQueryBounds(center: Geopoint, radius: number): GeohashRange[] {
341346
validateLocation(center);
342347
const queryBits = Math.max(1, boundingBoxBits(center, radius));
343348
const geohashPrecision = Math.ceil(queryBits / BITS_PER_CHAR);
@@ -362,7 +367,7 @@ export function geohashQueryBounds(center: number[], radius: number): string[][]
362367
* @param location2 The [latitude, longitude] pair of the second location.
363368
* @returns The distance, in kilometers, between the inputted locations.
364369
*/
365-
export function distanceBetween(location1: number[], location2: number[]): number {
370+
export function distanceBetween(location1: Geopoint, location2: Geopoint): number {
366371
validateLocation(location1);
367372
validateLocation(location2);
368373

Lines changed: 262 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,268 @@
11
import * as chai from 'chai';
22

3+
import {
4+
boundingBoxBits, degreesToRadians, distanceBetween, geohashForLocation, geohashQuery,
5+
geohashQueryBounds, GEOHASH_PRECISION, metersToLongitudeDegrees, validateGeohash, validateKey,
6+
validateLocation, wrapLongitude, Geopoint
7+
} from '../src/index';
8+
import {
9+
invalidGeohashes, invalidKeys, invalidLocations, invalidQueryCriterias,
10+
validGeohashes, validKeys, validLocations, validQueryCriterias
11+
} from './common';
12+
313
const expect = chai.expect;
414

5-
// TODO: move relevant tests from the `geofire` package to `geofire-common`.
6-
describe('Dummy test to ensure the run succeeds:', () => {
7-
it("hello world", () => {
8-
expect(true).to.be.true;
15+
describe('geoFireUtils Tests:', () => {
16+
describe('Parameter validation:', () => {
17+
it('validateKey() does not throw errors given valid keys', () => {
18+
validKeys.forEach((validKey) => {
19+
expect(() => validateKey(validKey)).not.to.throw();
20+
});
21+
});
22+
23+
it('validateKey() throws errors given invalid keys', () => {
24+
invalidKeys.forEach((invalidKey) => {
25+
// @ts-ignore
26+
expect(() => validateKey(invalidKey)).to.throw();
27+
});
28+
});
29+
30+
it('validateLocation() does not throw errors given valid locations', () => {
31+
validLocations.forEach((validLocation, i) => {
32+
expect(() => validateLocation(validLocation as Geopoint)).not.to.throw();
33+
});
34+
});
35+
36+
it('validateLocation() throws errors given invalid locations', () => {
37+
invalidLocations.forEach((invalidLocation, i) => {
38+
// @ts-ignore
39+
expect(() => validateLocation(invalidLocation)).to.throw();
40+
});
41+
});
42+
43+
it('validateGeohash() does not throw errors given valid geohashes', () => {
44+
validGeohashes.forEach((validGeohash, i) => {
45+
expect(() => validateGeohash(validGeohash)).not.to.throw();
46+
});
47+
});
48+
49+
it('validateGeohash() throws errors given invalid geohashes', () => {
50+
invalidGeohashes.forEach((invalidGeohash, i) => {
51+
// @ts-ignore
52+
expect(() => validateGeohash(invalidGeohash)).to.throw();
53+
});
54+
});
55+
});
56+
57+
describe('Distance calculations:', () => {
58+
it('degreesToRadians() converts degrees to radians', () => {
59+
expect(degreesToRadians(0)).to.be.closeTo(0, 0);
60+
expect(degreesToRadians(45)).to.be.closeTo(0.7854, 4);
61+
expect(degreesToRadians(90)).to.be.closeTo(1.5708, 4);
62+
expect(degreesToRadians(135)).to.be.closeTo(2.3562, 4);
63+
expect(degreesToRadians(180)).to.be.closeTo(3.1416, 4);
64+
expect(degreesToRadians(225)).to.be.closeTo(3.9270, 4);
65+
expect(degreesToRadians(270)).to.be.closeTo(4.7124, 4);
66+
expect(degreesToRadians(315)).to.be.closeTo(5.4978, 4);
67+
expect(degreesToRadians(360)).to.be.closeTo(6.2832, 4);
68+
expect(degreesToRadians(-45)).to.be.closeTo(-0.7854, 4);
69+
expect(degreesToRadians(-90)).to.be.closeTo(-1.5708, 4);
70+
});
71+
72+
it('degreesToRadians() throws errors given invalid inputs', () => {
73+
// @ts-ignore
74+
expect(() => degreesToRadians('')).to.throw();
75+
// @ts-ignore
76+
expect(() => degreesToRadians('a')).to.throw();
77+
// @ts-ignore
78+
expect(() => degreesToRadians(true)).to.throw();
79+
// @ts-ignore
80+
expect(() => degreesToRadians(false)).to.throw();
81+
// @ts-ignore
82+
expect(() => degreesToRadians([1])).to.throw();
83+
// @ts-ignore
84+
expect(() => degreesToRadians({})).to.throw();
85+
expect(() => degreesToRadians(null)).to.throw();
86+
expect(() => degreesToRadians(undefined)).to.throw();
87+
});
88+
89+
it('dist() calculates the distance between locations', () => {
90+
expect(distanceBetween([90, 180], [90, 180])).to.be.closeTo(0, 0);
91+
expect(distanceBetween([-90, -180], [90, 180])).to.be.closeTo(20015, 1);
92+
expect(distanceBetween([-90, -180], [-90, 180])).to.be.closeTo(0, 1);
93+
expect(distanceBetween([-90, -180], [90, -180])).to.be.closeTo(20015, 1);
94+
expect(distanceBetween([37.7853074, -122.4054274], [78.216667, 15.55])).to.be.closeTo(6818, 1);
95+
expect(distanceBetween([38.98719, -77.250783], [29.3760648, 47.9818853])).to.be.closeTo(10531, 1);
96+
expect(distanceBetween([38.98719, -77.250783], [-54.933333, -67.616667])).to.be.closeTo(10484, 1);
97+
expect(distanceBetween([29.3760648, 47.9818853], [-54.933333, -67.616667])).to.be.closeTo(14250, 1);
98+
expect(distanceBetween([-54.933333, -67.616667], [-54, -67])).to.be.closeTo(111, 1);
99+
});
100+
101+
it('dist() does not throw errors given valid locations', () => {
102+
validLocations.forEach((validLocation, i) => {
103+
expect(() => distanceBetween(validLocation as Geopoint, [0, 0])).not.to.throw();
104+
expect(() => distanceBetween([0, 0], validLocation as Geopoint)).not.to.throw();
105+
});
106+
});
107+
108+
it('dist() throws errors given invalid locations', () => {
109+
invalidLocations.forEach((invalidLocation, i) => {
110+
// @ts-ignore
111+
expect(() => distanceBetween(invalidLocation, [0, 0])).to.throw();
112+
// @ts-ignore
113+
expect(() => distanceBetween([0, 0], invalidLocation)).to.throw();
114+
});
115+
});
116+
});
117+
118+
describe('Geohashing:', () => {
119+
it('geohashForLocation() encodes locations to geohashes given no precision', () => {
120+
expect(geohashForLocation([-90, -180])).to.be.equal('000000000000'.slice(0, GEOHASH_PRECISION));
121+
expect(geohashForLocation([90, 180])).to.be.equal('zzzzzzzzzzzz'.slice(0, GEOHASH_PRECISION));
122+
expect(geohashForLocation([-90, 180])).to.be.equal('pbpbpbpbpbpb'.slice(0, GEOHASH_PRECISION));
123+
expect(geohashForLocation([90, -180])).to.be.equal('bpbpbpbpbpbp'.slice(0, GEOHASH_PRECISION));
124+
expect(geohashForLocation([37.7853074, -122.4054274])).to.be.equal('9q8yywe56gcf'.slice(0, GEOHASH_PRECISION));
125+
expect(geohashForLocation([38.98719, -77.250783])).to.be.equal('dqcjf17sy6cp'.slice(0, GEOHASH_PRECISION));
126+
expect(geohashForLocation([29.3760648, 47.9818853])).to.be.equal('tj4p5gerfzqu'.slice(0, GEOHASH_PRECISION));
127+
expect(geohashForLocation([78.216667, 15.55])).to.be.equal('umghcygjj782'.slice(0, GEOHASH_PRECISION));
128+
expect(geohashForLocation([-54.933333, -67.616667])).to.be.equal('4qpzmren1kwb'.slice(0, GEOHASH_PRECISION));
129+
expect(geohashForLocation([-54, -67])).to.be.equal('4w2kg3s54y7h'.slice(0, GEOHASH_PRECISION));
130+
});
131+
132+
it('geohashForLocation() encodes locations to geohashes given a custom precision', () => {
133+
expect(geohashForLocation([-90, -180], 6)).to.be.equal('000000');
134+
expect(geohashForLocation([90, 180], 20)).to.be.equal('zzzzzzzzzzzzzzzzzzzz');
135+
expect(geohashForLocation([-90, 180], 1)).to.be.equal('p');
136+
expect(geohashForLocation([90, -180], 5)).to.be.equal('bpbpb');
137+
expect(geohashForLocation([37.7853074, -122.4054274], 8)).to.be.equal('9q8yywe5');
138+
expect(geohashForLocation([38.98719, -77.250783], 18)).to.be.equal('dqcjf17sy6cppp8vfn');
139+
expect(geohashForLocation([29.3760648, 47.9818853], 12)).to.be.equal('tj4p5gerfzqu');
140+
expect(geohashForLocation([78.216667, 15.55], 1)).to.be.equal('u');
141+
expect(geohashForLocation([-54.933333, -67.616667], 7)).to.be.equal('4qpzmre');
142+
expect(geohashForLocation([-54, -67], 9)).to.be.equal('4w2kg3s54');
143+
});
144+
145+
it('geohashForLocation() does not throw errors given valid locations', () => {
146+
validLocations.forEach((validLocation, i) => {
147+
expect(() => geohashForLocation(validLocation as Geopoint)).not.to.throw();
148+
});
149+
});
150+
151+
it('geohashForLocation() throws errors given invalid locations', () => {
152+
invalidLocations.forEach((invalidLocation, i) => {
153+
// @ts-ignore
154+
expect(() => geohashForLocation(invalidLocation)).to.throw();
155+
});
156+
});
157+
158+
it('geohashForLocation() does not throw errors given valid precision', () => {
159+
const validPrecisions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, undefined];
160+
161+
validPrecisions.forEach((validPrecision, i) => {
162+
expect(() => geohashForLocation([0, 0], validPrecision)).not.to.throw();
163+
});
164+
});
165+
166+
it('geohashForLocation() throws errors given invalid precision', () => {
167+
const invalidPrecisions = [0, -1, 1.5, 23, '', 'a', true, false, [], {}, [1], { a: 1 }, null];
168+
169+
invalidPrecisions.forEach((invalidPrecision, i) => {
170+
// @ts-ignore
171+
expect(() => geohashForLocation([0, 0], invalidPrecision)).to.throw();
172+
});
173+
});
174+
});
175+
176+
describe('Coordinate calculations:', () => {
177+
it('metersToLongtitudeDegrees calculates correctly', () => {
178+
expect(metersToLongitudeDegrees(1000, 0)).to.be.closeTo(0.008983, 5);
179+
expect(metersToLongitudeDegrees(111320, 0)).to.be.closeTo(1, 5);
180+
expect(metersToLongitudeDegrees(107550, 15)).to.be.closeTo(1, 5);
181+
expect(metersToLongitudeDegrees(96486, 30)).to.be.closeTo(1, 5);
182+
expect(metersToLongitudeDegrees(78847, 45)).to.be.closeTo(1, 5);
183+
expect(metersToLongitudeDegrees(55800, 60)).to.be.closeTo(1, 5);
184+
expect(metersToLongitudeDegrees(28902, 75)).to.be.closeTo(1, 5);
185+
expect(metersToLongitudeDegrees(0, 90)).to.be.closeTo(0, 5);
186+
expect(metersToLongitudeDegrees(1000, 90)).to.be.closeTo(360, 5);
187+
expect(metersToLongitudeDegrees(1000, 89.9999)).to.be.closeTo(360, 5);
188+
expect(metersToLongitudeDegrees(1000, 89.995)).to.be.closeTo(102.594208, 5);
189+
});
190+
191+
it('wrapLongitude wraps correctly', () => {
192+
expect(wrapLongitude(0)).to.be.closeTo(0, 6);
193+
expect(wrapLongitude(180)).to.be.closeTo(180, 6);
194+
expect(wrapLongitude(-180)).to.be.closeTo(-180, 6);
195+
expect(wrapLongitude(182)).to.be.closeTo(-178, 6);
196+
expect(wrapLongitude(270)).to.be.closeTo(-90, 6);
197+
expect(wrapLongitude(360)).to.be.closeTo(0, 6);
198+
expect(wrapLongitude(540)).to.be.closeTo(-180, 6);
199+
expect(wrapLongitude(630)).to.be.closeTo(-90, 6);
200+
expect(wrapLongitude(720)).to.be.closeTo(0, 6);
201+
expect(wrapLongitude(810)).to.be.closeTo(90, 6);
202+
expect(wrapLongitude(-360)).to.be.closeTo(0, 6);
203+
expect(wrapLongitude(-182)).to.be.closeTo(178, 6);
204+
expect(wrapLongitude(-270)).to.be.closeTo(90, 6);
205+
expect(wrapLongitude(-360)).to.be.closeTo(0, 6);
206+
expect(wrapLongitude(-450)).to.be.closeTo(-90, 6);
207+
expect(wrapLongitude(-540)).to.be.closeTo(180, 6);
208+
expect(wrapLongitude(-630)).to.be.closeTo(90, 6);
209+
expect(wrapLongitude(1080)).to.be.closeTo(0, 6);
210+
expect(wrapLongitude(-1080)).to.be.closeTo(0, 6);
211+
});
212+
});
213+
214+
describe('Bounding box bits:', () => {
215+
it('boundingBoxBits must return correct number of bits', () => {
216+
expect(boundingBoxBits([35, 0], 1000)).to.be.equal(28);
217+
expect(boundingBoxBits([35.645, 0], 1000)).to.be.equal(27);
218+
expect(boundingBoxBits([36, 0], 1000)).to.be.equal(27);
219+
expect(boundingBoxBits([0, 0], 1000)).to.be.equal(28);
220+
expect(boundingBoxBits([0, -180], 1000)).to.be.equal(28);
221+
expect(boundingBoxBits([0, 180], 1000)).to.be.equal(28);
222+
expect(boundingBoxBits([0, 0], 8000)).to.be.equal(22);
223+
expect(boundingBoxBits([45, 0], 1000)).to.be.equal(27);
224+
expect(boundingBoxBits([75, 0], 1000)).to.be.equal(25);
225+
expect(boundingBoxBits([75, 0], 2000)).to.be.equal(23);
226+
expect(boundingBoxBits([90, 0], 1000)).to.be.equal(1);
227+
expect(boundingBoxBits([90, 0], 2000)).to.be.equal(1);
228+
});
9229
});
10230
});
231+
232+
describe('Geohash queries:', () => {
233+
it('Geohash queries must be of the right size', () => {
234+
expect(geohashQuery('64m9yn96mx', 6)).to.be.deep.equal(['60', '6h']);
235+
expect(geohashQuery('64m9yn96mx', 1)).to.be.deep.equal(['0', 'h']);
236+
expect(geohashQuery('64m9yn96mx', 10)).to.be.deep.equal(['64', '65']);
237+
expect(geohashQuery('6409yn96mx', 11)).to.be.deep.equal(['640', '64h']);
238+
expect(geohashQuery('64m9yn96mx', 11)).to.be.deep.equal(['64h', '64~']);
239+
expect(geohashQuery('6', 10)).to.be.deep.equal(['6', '6~']);
240+
expect(geohashQuery('64z178', 12)).to.be.deep.equal(['64s', '64~']);
241+
expect(geohashQuery('64z178', 15)).to.be.deep.equal(['64z', '64~']);
242+
});
243+
244+
it('Query bounds from geohashQueryBounds must contain points in circle', () => {
245+
function inQuery(queries, hash) {
246+
for (let i = 0; i < queries.length; i++) {
247+
if (hash >= queries[i][0] && hash < queries[i][1]) {
248+
return true;
249+
}
250+
}
251+
return false;
252+
}
253+
for (let i = 0; i < 200; i++) {
254+
const centerLat = Math.pow(Math.random(), 5) * 160 - 80;
255+
const centerLong = Math.pow(Math.random(), 5) * 360 - 180;
256+
const radius = Math.random() * Math.random() * 100000;
257+
const degreeRadius = metersToLongitudeDegrees(radius, centerLat);
258+
const queries = geohashQueryBounds([centerLat, centerLong], radius);
259+
for (let j = 0; j < 1000; j++) {
260+
const pointLat = Math.max(-89.9, Math.min(89.9, centerLat + Math.random() * degreeRadius));
261+
const pointLong = wrapLongitude(centerLong + Math.random() * degreeRadius);
262+
if (distanceBetween([centerLat, centerLong], [pointLat, pointLong]) < radius / 1000) {
263+
expect(inQuery(queries, geohashForLocation([pointLat, pointLong]))).to.be.equal(true);
264+
}
265+
}
266+
}
267+
});
268+
});

0 commit comments

Comments
 (0)