From dd3110728cf5a43753de706651fd25da09f4bb0d Mon Sep 17 00:00:00 2001 From: Mircea Hasegan Date: Sat, 20 May 2023 08:44:57 +0200 Subject: [PATCH] feat(util): get percentage from parts --- packages/util/src/Percent.ts | 29 ++++++++++++++++ packages/util/test/Percent.test.ts | 56 ++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 packages/util/test/Percent.test.ts diff --git a/packages/util/src/Percent.ts b/packages/util/src/Percent.ts index c2023265102..5f43c6c6f63 100644 --- a/packages/util/src/Percent.ts +++ b/packages/util/src/Percent.ts @@ -1,4 +1,5 @@ import { OpaqueNumber } from './opaqueTypes'; +import sum from 'lodash/sum'; /** * The Percentage is a relative value that indicates the hundredth parts of any quantity. @@ -8,3 +9,31 @@ import { OpaqueNumber } from './opaqueTypes'; */ export type Percent = OpaqueNumber<'Percent'>; export const Percent = (value: number): Percent => value as unknown as Percent; + +/** + * Calculates the percentages for each part from {@link total}. + * When total is omitted, it is assumed that the total is the sum of the parts. + * + * @param parts array of integer values + * @param total optional param to allow sum(parts) to be smaller than the total + * @returns array of floating point percentages, e.g. [0.1, 0.02, 0.587] is equivalent to 10%, 2%, 58.7% + */ +export const calcPercentages = (parts: number[], total = sum(parts)): Percent[] => { + if (parts.length === 0) { + return []; + } + + let partsSum = sum(parts); + + if (total < partsSum) total = partsSum; + + if (total === 0) { + // it means all parts are 0 + // set everything to 1 and continue with the normal algorithm + parts = parts.map(() => 1); + partsSum = sum(parts); + total = partsSum; + } + + return parts.map((part) => Percent(part / total)); +}; diff --git a/packages/util/test/Percent.test.ts b/packages/util/test/Percent.test.ts new file mode 100644 index 00000000000..138a2a8867a --- /dev/null +++ b/packages/util/test/Percent.test.ts @@ -0,0 +1,56 @@ +import { calcPercentages } from '../src/Percent'; + +describe('Percent', () => { + it('single value is always 100%', () => { + expect(calcPercentages([50])).toEqual([1]); + }); + + it('whole percentages add up to 100%', () => { + expect(calcPercentages([50, 50])).toEqual([0.5, 0.5]); + }); + + it('floating point percentages', () => { + expect(calcPercentages([403, 597])).toEqual([0.403, 0.597]); + expect(calcPercentages([249, 249, 502])).toEqual([0.249, 0.249, 0.502]); + expect(calcPercentages([255, 245, 265, 235])).toEqual([0.255, 0.245, 0.265, 0.235]); + }); + + it('percentages smaller than 1%', () => { + expect(calcPercentages([5, 6, 1000 - 5 - 6])).toEqual([0.005, 0.006, 0.989]); + expect(calcPercentages([0, 6, 1000 - 6])).toEqual([0, 0.006, 0.994]); + }); + + it('one part is zero, total is implicitly zero, so it takes 100% of the total', () => { + expect(calcPercentages([0])).toEqual([1]); + }); + + it('multiple parts are zero, total is implicitly zero, percent is distributed evenly', () => { + expect(calcPercentages([0, 0, 0, 0])).toEqual([0.25, 0.25, 0.25, 0.25]); + }); + + it('total is adjusted to equal at least as much as the sum of the parts', () => { + expect(calcPercentages([80], 0)).toEqual([1]); + }); + + it('returns empty array if no parts are provided', () => { + expect(calcPercentages([])).toEqual([]); + }); + + describe('parts sum less than 100%', () => { + it('part are zero but total > zero translates to 0% for each part', () => { + expect(calcPercentages([0, 0], 100)).toEqual([0, 0]); + }); + + it('single 80% value', () => { + expect(calcPercentages([80], 100)).toEqual([0.8]); + }); + + it('two whole parts adding up to 80%', () => { + expect(calcPercentages([40, 40], 100)).toEqual([0.4, 0.4]); + }); + + it('multiple rounded parts adding up to 80%', () => { + expect(calcPercentages([205, 205, 215, 175], 1000)).toEqual([0.205, 0.205, 0.215, 0.175]); + }); + }); +});