diff --git a/LICENSE b/LICENSE index 0ebeaf84e27..b0ade127db6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Faker - Copyright (c) 2022 +Faker - Copyright (c) 2022-2023 This software consists of voluntary contributions made by many individuals. For exact contribution history, see the revision history diff --git a/src/internal/mersenne/twister.ts b/src/internal/mersenne/twister.ts index 15189e6775c..d25e3acf583 100644 --- a/src/internal/mersenne/twister.ts +++ b/src/internal/mersenne/twister.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2022 Faker + * Copyright (c) 2022-2023 Faker * * This is a version of the original source code migrated to TypeScript and * modified by the Faker team. diff --git a/src/modules/helpers/index.ts b/src/modules/helpers/index.ts index 3bdc2f68362..7c5ee9972cd 100644 --- a/src/modules/helpers/index.ts +++ b/src/modules/helpers/index.ts @@ -459,6 +459,55 @@ export class HelpersModule { return array[index]; } + /** + * Returns a weighted random element from the given array. Each element of the array should be an object with two keys `weight` and `value`. + * + * - Each `weight` key should be a number representing the probability of selecting the value, relative to the sum of the weights. Weights can be any positive float or integer. + * - Each `value` key should be the corresponding value. + * + * For example, if there are two values A and B, with weights 1 and 2 respectively, then the probability of picking A is 1/3 and the probability of picking B is 2/3. + * + * @template T The type of the entries to pick from. + * @param array Array to pick the value from. + * + * @example + * faker.helpers.weightedArrayElement([{ weight: 5, value: 'sunny' }, { weight: 4, value: 'rainy' }, { weight: 1, value: 'snowy' }]) // 'sunny', 50% of the time, 'rainy' 40% of the time, 'snowy' 10% of the time + * + * @since 8.0.0 + */ + weightedArrayElement( + array: ReadonlyArray<{ weight: number; value: T }> + ): T { + if (array.length === 0) { + throw new FakerError( + 'weightedArrayElement expects an array with at least one element' + ); + } + + if (!array.every((elt) => elt.weight > 0)) { + throw new FakerError( + 'weightedArrayElement expects an array of { weight, value } objects where weight is a positive number' + ); + } + + const total = array.reduce((acc, { weight }) => acc + weight, 0); + const random = this.faker.number.float({ + min: 0, + max: total, + precision: 1e-9, + }); + let current = 0; + for (const { weight, value } of array) { + current += weight; + if (random < current) { + return value; + } + } + + // In case of rounding errors, return the last element + return array[array.length - 1].value; + } + /** * Returns a subset with random elements of the given array in random order. * diff --git a/src/modules/internet/user-agent.ts b/src/modules/internet/user-agent.ts index 40bc025f12d..58ef441bec7 100644 --- a/src/modules/internet/user-agent.ts +++ b/src/modules/internet/user-agent.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2022 Faker + * Copyright (c) 2022-2023 Faker * * This is a version of the original code migrated to TypeScript and modified * by the Faker team. diff --git a/src/modules/location/index.ts b/src/modules/location/index.ts index 4e25cbb955c..ab472658b3d 100644 --- a/src/modules/location/index.ts +++ b/src/modules/location/index.ts @@ -1,4 +1,5 @@ import type { Faker } from '../..'; +import { deprecated } from '../../internal/deprecated'; /** * Module to generate addresses and locations. @@ -368,6 +369,27 @@ export class LocationModule { ); } + /** + * Generates a random GPS coordinate within the specified radius from the given coordinate. + * + * @param options The options for generating a GPS coordinate. + * @param options.origin The original coordinate to get a new coordinate close to. + * If no coordinate is given, a random one will be chosen. + * @param options.radius The maximum distance from the given coordinate to the new coordinate. Defaults to `10`. + * @param options.isMetric If `true` assume the radius to be in kilometers. If `false` for miles. Defaults to `false`. + * + * @example + * faker.location.nearbyGPSCoordinate() // [ 33.8475, -170.5953 ] + * faker.location.nearbyGPSCoordinate({ origin: [33, -170] }) // [ 33.0165, -170.0636 ] + * faker.location.nearbyGPSCoordinate({ origin: [33, -170], radius: 1000, isMetric: true }) // [ 37.9163, -179.2408 ] + * + * @since 8.0.0 + */ + nearbyGPSCoordinate(options?: { + origin?: [latitude: number, longitude: number]; + radius?: number; + isMetric?: boolean; + }): [latitude: number, longitude: number]; /** * Generates a random GPS coordinate within the specified radius from the given coordinate. * @@ -382,14 +404,74 @@ export class LocationModule { * faker.location.nearbyGPSCoordinate([33, -170], 1000, true) // [ 37.9163, -179.2408 ] * * @since 8.0.0 + * + * @deprecated Use `faker.location.nearbyGPSCoordinate({ origin, radius, isMetric })` instead. */ nearbyGPSCoordinate( coordinate?: [latitude: number, longitude: number], - radius: number = 10, - isMetric: boolean = false + radius?: number, + isMetric?: boolean + ): [latitude: number, longitude: number]; + /** + * Generates a random GPS coordinate within the specified radius from the given coordinate. + * + * @param options The options for generating a GPS coordinate. + * @param options.origin The original coordinate to get a new coordinate close to. + * If no coordinate is given, a random one will be chosen. + * @param options.radius The maximum distance from the given coordinate to the new coordinate. Defaults to `10`. + * @param options.isMetric If `true` assume the radius to be in kilometers. If `false` for miles. Defaults to `false`. + * @param legacyRadius Deprecated, use `options.radius` instead. + * @param legacyIsMetric Deprecated, use `options.isMetric` instead. + * + * @example + * faker.location.nearbyGPSCoordinate() // [ 33.8475, -170.5953 ] + * faker.location.nearbyGPSCoordinate({ origin: [33, -170] }) // [ 33.0165, -170.0636 ] + * faker.location.nearbyGPSCoordinate({ origin: [33, -170], radius: 1000, isMetric: true }) // [ 37.9163, -179.2408 ] + * + * @since 8.0.0 + */ + nearbyGPSCoordinate( + options?: + | [latitude: number, longitude: number] + | { + origin?: [latitude: number, longitude: number]; + radius?: number; + isMetric?: boolean; + }, + legacyRadius?: number, + legacyIsMetric?: boolean + ): [latitude: number, longitude: number]; + nearbyGPSCoordinate( + options: + | [latitude: number, longitude: number] + | { + origin?: [latitude: number, longitude: number]; + radius?: number; + isMetric?: boolean; + } = {}, + legacyRadius: number = 10, + legacyIsMetric: boolean = false ): [latitude: number, longitude: number] { - // If there is no coordinate, the best we can do is return a random GPS coordinate. - if (coordinate === undefined) { + if (Array.isArray(options)) { + deprecated({ + deprecated: + 'faker.location.nearbyGPSCoordinate(coordinate, radius, isMetric)', + proposed: + 'faker.location.nearbyGPSCoordinate({ origin, radius, isMetric })', + since: '8.0', + until: '9.0', + }); + options = { origin: options }; + } + + const { + origin, + radius = legacyRadius, + isMetric = legacyIsMetric, + } = options; + + // If there is no origin, the best we can do is return a random GPS coordinate. + if (origin == null) { return [this.latitude(), this.longitude()]; } @@ -414,22 +496,22 @@ export class LocationModule { const distanceInDegree = distanceInKm / kmPerDegree; // in ° - const newCoordinate: [latitude: number, longitude: number] = [ - coordinate[0] + Math.sin(angleRadians) * distanceInDegree, - coordinate[1] + Math.cos(angleRadians) * distanceInDegree, + const coordinate: [latitude: number, longitude: number] = [ + origin[0] + Math.sin(angleRadians) * distanceInDegree, + origin[1] + Math.cos(angleRadians) * distanceInDegree, ]; // Box latitude [-90°, 90°] - newCoordinate[0] = newCoordinate[0] % 180; - if (newCoordinate[0] < -90 || newCoordinate[0] > 90) { - newCoordinate[0] = Math.sign(newCoordinate[0]) * 180 - newCoordinate[0]; - newCoordinate[1] += 180; + coordinate[0] = coordinate[0] % 180; + if (coordinate[0] < -90 || coordinate[0] > 90) { + coordinate[0] = Math.sign(coordinate[0]) * 180 - coordinate[0]; + coordinate[1] += 180; } // Box longitude [-180°, 180°] - newCoordinate[1] = (((newCoordinate[1] % 360) + 540) % 360) - 180; + coordinate[1] = (((coordinate[1] % 360) + 540) % 360) - 180; - return [newCoordinate[0], newCoordinate[1]]; + return [coordinate[0], coordinate[1]]; } /** diff --git a/test/__snapshots__/helpers.spec.ts.snap b/test/__snapshots__/helpers.spec.ts.snap index 95ac4a03aa8..c5714ea91a3 100644 --- a/test/__snapshots__/helpers.spec.ts.snap +++ b/test/__snapshots__/helpers.spec.ts.snap @@ -177,6 +177,10 @@ exports[`helpers > 42 > uniqueArray > with array 1`] = ` ] `; +exports[`helpers > 42 > weightedArrayElement > with array 1`] = `"sunny"`; + +exports[`helpers > 42 > weightedArrayElement > with array with percentages 1`] = `"sunny"`; + exports[`helpers > 1211 > arrayElement > noArgs 1`] = `"c"`; exports[`helpers > 1211 > arrayElement > with array 1`] = `"!"`; @@ -368,6 +372,10 @@ exports[`helpers > 1211 > uniqueArray > with array 1`] = ` ] `; +exports[`helpers > 1211 > weightedArrayElement > with array 1`] = `"snowy"`; + +exports[`helpers > 1211 > weightedArrayElement > with array with percentages 1`] = `"snowy"`; + exports[`helpers > 1337 > arrayElement > noArgs 1`] = `"a"`; exports[`helpers > 1337 > arrayElement > with array 1`] = `"l"`; @@ -541,3 +549,7 @@ exports[`helpers > 1337 > uniqueArray > with array 1`] = ` "d", ] `; + +exports[`helpers > 1337 > weightedArrayElement > with array 1`] = `"sunny"`; + +exports[`helpers > 1337 > weightedArrayElement > with array with percentages 1`] = `"sunny"`; diff --git a/test/__snapshots__/location.spec.ts.snap b/test/__snapshots__/location.spec.ts.snap index ffe73ffd3f4..5f67809a36c 100644 --- a/test/__snapshots__/location.spec.ts.snap +++ b/test/__snapshots__/location.spec.ts.snap @@ -46,6 +46,48 @@ exports[`location > 42 > nearbyGPSCoordinate > noArgs 1`] = ` ] `; +exports[`location > 42 > nearbyGPSCoordinate > only isMetric 1`] = ` +[ + -22.5828, + 106.7555, +] +`; + +exports[`location > 42 > nearbyGPSCoordinate > only radius 1`] = ` +[ + -22.5828, + 106.7555, +] +`; + +exports[`location > 42 > nearbyGPSCoordinate > with origin and isMetric 1`] = ` +[ + 37.05058762889859, + -13.05029562250138, +] +`; + +exports[`location > 42 > nearbyGPSCoordinate > with origin and radius 1`] = ` +[ + 37.122112668351875, + -13.121407798779614, +] +`; + +exports[`location > 42 > nearbyGPSCoordinate > with origin, radius and isMetric 1`] = ` +[ + 37.075875092904894, + -13.075437119965613, +] +`; + +exports[`location > 42 > nearbyGPSCoordinate > with radius and isMetric 1`] = ` +[ + -22.5828, + 106.7555, +] +`; + exports[`location > 42 > ordinalDirection > noArgs 1`] = `"Northwest"`; exports[`location > 42 > ordinalDirection > with abbr = false 1`] = `"Northwest"`; @@ -124,6 +166,48 @@ exports[`location > 1211 > nearbyGPSCoordinate > noArgs 1`] = ` ] `; +exports[`location > 1211 > nearbyGPSCoordinate > only isMetric 1`] = ` +[ + 77.1337, + -14.7545, +] +`; + +exports[`location > 1211 > nearbyGPSCoordinate > only radius 1`] = ` +[ + 77.1337, + -14.7545, +] +`; + +exports[`location > 1211 > nearbyGPSCoordinate > with origin and isMetric 1`] = ` +[ + 36.98215379643012, + -12.962972893442156, +] +`; + +exports[`location > 1211 > nearbyGPSCoordinate > with origin and radius 1`] = ` +[ + 36.95691638741659, + -12.910610595257708, +] +`; + +exports[`location > 1211 > nearbyGPSCoordinate > with origin, radius and isMetric 1`] = ` +[ + 36.97323069464518, + -12.944459340163235, +] +`; + +exports[`location > 1211 > nearbyGPSCoordinate > with radius and isMetric 1`] = ` +[ + 77.1337, + -14.7545, +] +`; + exports[`location > 1211 > ordinalDirection > noArgs 1`] = `"Southwest"`; exports[`location > 1211 > ordinalDirection > with abbr = false 1`] = `"Southwest"`; @@ -202,6 +286,48 @@ exports[`location > 1337 > nearbyGPSCoordinate > noArgs 1`] = ` ] `; +exports[`location > 1337 > nearbyGPSCoordinate > only isMetric 1`] = ` +[ + -42.8356, + 21.7907, +] +`; + +exports[`location > 1337 > nearbyGPSCoordinate > only radius 1`] = ` +[ + -42.8356, + 21.7907, +] +`; + +exports[`location > 1337 > nearbyGPSCoordinate > with origin and isMetric 1`] = ` +[ + 37.05004958398222, + -13.003788641630877, +] +`; + +exports[`location > 1337 > nearbyGPSCoordinate > with origin and radius 1`] = ` +[ + 37.12082442834317, + -13.009146139144832, +] +`; + +exports[`location > 1337 > nearbyGPSCoordinate > with origin, radius and isMetric 1`] = ` +[ + 37.07507884069983, + -13.00568330041608, +] +`; + +exports[`location > 1337 > nearbyGPSCoordinate > with radius and isMetric 1`] = ` +[ + -42.8356, + 21.7907, +] +`; + exports[`location > 1337 > ordinalDirection > noArgs 1`] = `"Northwest"`; exports[`location > 1337 > ordinalDirection > with abbr = false 1`] = `"Northwest"`; diff --git a/test/helpers.spec.ts b/test/helpers.spec.ts index 80c79e2f15b..6ef7c374eff 100644 --- a/test/helpers.spec.ts +++ b/test/helpers.spec.ts @@ -59,6 +59,20 @@ describe('helpers', () => { t.it('noArgs').it('with array', 'Hello World!'.split('')); }); + t.describe('weightedArrayElement', (t) => { + t.it('with array', [ + { weight: 5, value: 'sunny' }, + { weight: 4, value: 'rainy' }, + { weight: 1, value: 'snowy' }, + ]); + + t.it('with array with percentages', [ + { weight: 0.5, value: 'sunny' }, + { weight: 0.4, value: 'rainy' }, + { weight: 0.1, value: 'snowy' }, + ]); + }); + t.describe('arrayElements', (t) => { t.it('noArgs') .it('with array', 'Hello World!'.split('')) @@ -145,6 +159,94 @@ describe('helpers', () => { }); }); + describe('weightedArrayElement', () => { + it('should return a weighted random element in the array', () => { + const testArray = [ + { weight: 10, value: 'hello' }, + { weight: 5, value: 'to' }, + { weight: 3, value: 'you' }, + { weight: 2, value: 'my' }, + { weight: 1, value: 'friend' }, + ]; + const actual = faker.helpers.weightedArrayElement(testArray); + + expect(testArray.map((a) => a.value)).toContain(actual); + }); + + it('should return a weighted random element in the array using floats', () => { + const testArray = [ + { weight: 0.1, value: 'hello' }, + { weight: 0.05, value: 'to' }, + { weight: 0.03, value: 'you' }, + { weight: 0.02, value: 'my' }, + { weight: 0.01, value: 'friend' }, + ]; + const actual = faker.helpers.weightedArrayElement(testArray); + + expect(testArray.map((a) => a.value)).toContain(actual); + }); + + it('should return the only element in the array when there is only 1', () => { + const testArray = [{ weight: 10, value: 'hello' }]; + const actual = faker.helpers.weightedArrayElement(testArray); + + expect(actual).toBe('hello'); + }); + + it('should throw if the array is empty', () => { + expect(() => faker.helpers.weightedArrayElement([])).toThrowError( + new FakerError( + 'weightedArrayElement expects an array with at least one element' + ) + ); + }); + + it('should allow falsey values', () => { + const testArray = [{ weight: 1, value: false }]; + const actual = faker.helpers.weightedArrayElement(testArray); + expect(actual).toBe(false); + }); + + it('should throw if any weight is zero', () => { + const testArray = [ + { weight: 0, value: 'hello' }, + { weight: 5, value: 'to' }, + ]; + expect(() => + faker.helpers.weightedArrayElement(testArray) + ).toThrowError( + new FakerError( + 'weightedArrayElement expects an array of { weight, value } objects where weight is a positive number' + ) + ); + }); + + it('should throw if any weight is negative', () => { + const testArray = [ + { weight: -1, value: 'hello' }, + { weight: 5, value: 'to' }, + ]; + expect(() => + faker.helpers.weightedArrayElement(testArray) + ).toThrowError( + new FakerError( + 'weightedArrayElement expects an array of { weight, value } objects where weight is a positive number' + ) + ); + }); + + it('should not throw with a frozen array', () => { + const testArray = [ + { weight: 7, value: 'ice' }, + { weight: 3, value: 'snow' }, + ]; + const frozenArray = Object.freeze(testArray); + expect(() => + faker.helpers.weightedArrayElement(frozenArray) + ).to.not.throw(); + }); + }); + describe('arrayElements', () => { it('should return a subset with random elements in the array', () => { const testArray = ['hello', 'to', 'you', 'my', 'friend']; diff --git a/test/location.spec.ts b/test/location.spec.ts index 567ec148c8d..3a7d958d0ea 100644 --- a/test/location.spec.ts +++ b/test/location.spec.ts @@ -72,7 +72,18 @@ describe('location', () => { }); t.describe('nearbyGPSCoordinate', (t) => { - t.it('noArgs').it('near origin', [0, 0]); + t.it('noArgs') + .it('near origin', { origin: [0, 0] }) + .it('with origin and radius', { origin: [37, -13], radius: 15 }) + .it('with origin, radius and isMetric', { + origin: [37, -13], + radius: 15, + isMetric: true, + }) + .it('with origin and isMetric', { origin: [37, -13], isMetric: true }) + .it('with radius and isMetric', { radius: 15, isMetric: true }) + .it('only radius', { radius: 12 }) + .it('only isMetric', { isMetric: true }); }); t.it('state').it('stateAbbr'); @@ -286,11 +297,11 @@ describe('location', () => { const latitude1 = +faker.location.latitude(); const longitude1 = +faker.location.longitude(); - const coordinate = faker.location.nearbyGPSCoordinate( - [latitude1, longitude1], + const coordinate = faker.location.nearbyGPSCoordinate({ + origin: [latitude1, longitude1], radius, - isMetric - ); + isMetric, + }); expect(coordinate.length).toBe(2); expect(coordinate[0]).toBeTypeOf('number');