-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
214 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
import { describe, it, expect, test } from 'vitest' | ||
import { formatNumber, RoundingMode } from './formatter' | ||
|
||
describe('formatNumber', () => { | ||
it('should return a string', () => { | ||
expect(formatNumber(1n)).toBeTypeOf('string') | ||
}) | ||
|
||
it('should contain "." for decimals > 0', () => { | ||
const wholePart = '1234567' | ||
const decimalPart = '111222333444555666' | ||
const value = `${wholePart}${decimalPart}` | ||
const output = formatNumber(value, { | ||
decimals: 1, | ||
}) | ||
expect(output.indexOf('.') > 0).toBeTruthy() | ||
}) | ||
|
||
it('should contain no "." for decimals == 0', () => { | ||
const wholePart = '1234567' | ||
const decimalPart = '111222333444555666' | ||
const value = `${wholePart}${decimalPart}` | ||
const output = formatNumber(value, { | ||
decimals: 0, | ||
}) | ||
expect(output.indexOf('.') == -1).toBeTruthy() | ||
}) | ||
|
||
it('should separate thousands with given separator', () => { | ||
const value = '1234567111222333444555666' | ||
const output = formatNumber(value, { | ||
thousandsSeparator: '∂', | ||
decimals: 12, | ||
}) | ||
|
||
expect(output).to.eq('1∂234∂567∂111∂222.333∂444∂555∂666') | ||
}) | ||
|
||
test.each( | ||
Array(19) | ||
.fill(0) | ||
.map((_, i) => i), | ||
)('should have number of decimals as defined in options', expectedDecimals => { | ||
const value = '1234567111222333444555666' | ||
const output = formatNumber(value, { | ||
decimals: expectedDecimals, | ||
}) | ||
|
||
const [_, actualDecimalPart] = output.split('.') | ||
|
||
expect(actualDecimalPart?.length ?? 0, `when options.decimals == ${expectedDecimals}`).to.eq( | ||
expectedDecimals, | ||
) | ||
}) | ||
|
||
const roundingModes: RoundingMode[] = ['ceil', 'floor', 'round'] | ||
test.each( | ||
Array(19 * roundingModes.length) | ||
.fill(0) | ||
.map((_, i) => [i % 19, roundingModes[i % roundingModes.length]]), | ||
)( | ||
'should contain number of decimals according to the decimalPlaces rounding options', | ||
(decimalPlaces, mode) => { | ||
const wholePart = '1234567' | ||
const decimalPart = '000111222333444555' | ||
const value = `${wholePart}${decimalPart}` | ||
const expectedNumberOfDecimals = decimalPlaces | ||
const output = formatNumber(value, { | ||
round: { | ||
decimalPlaces, | ||
mode, | ||
}, | ||
}) | ||
const [_, actualDecimalPart] = output.split('.') | ||
|
||
const actualNumberOfDecimals = actualDecimalPart?.length ?? 0 | ||
|
||
expect( | ||
actualNumberOfDecimals, | ||
`when decimalPlaces == ${decimalPlaces} and rounding mode == ${mode}`, | ||
).to.eq(expectedNumberOfDecimals) | ||
}, | ||
) | ||
|
||
// TODO: test w/ carryovers eg 39999.999999 | ||
it('should carryover correctly when ceiled', () => { | ||
const wholePart = '1234567' | ||
const decimalPart = '999999999999999999' | ||
const value = `${wholePart}${decimalPart}` | ||
const output = formatNumber(value, { | ||
round: { | ||
decimalPlaces: 0, | ||
mode: 'ceil', | ||
}, | ||
}) | ||
|
||
expect(output).to.eq('1234568') | ||
}) | ||
|
||
// TODO: test w/ less decimals | ||
// TODO: test defaults | ||
// TODO: test mismatched options | ||
// TODO: test disappearance of . | ||
// TODO: test w/o decimals | ||
// TODO: test w/ 0.000001 | ||
// TODO: test w/ unknown rounding mode | ||
// TODO: test that the least significant digit is not rounded | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import { formatUnits } from 'viem' | ||
export type RoundingMode = 'floor' | 'ceil' | 'round' | ||
type RoundingOptions = { | ||
mode: RoundingMode | ||
decimalPlaces: number | ||
} | ||
type NumberFormatOptions = { | ||
decimals: number | ||
thousandsSeparator: string | ||
round: Partial<RoundingOptions> | ||
} | ||
|
||
export const DEFAULT_NUMBER_FORMAT_OPTIONS: NumberFormatOptions = { | ||
decimals: 18, | ||
round: { | ||
mode: 'round', | ||
decimalPlaces: 18, | ||
}, | ||
thousandsSeparator: '', | ||
} | ||
|
||
const mergeDeep = <T extends Object>(a: T, b: T, maxDepth = 3): T => { | ||
if (!maxDepth) { | ||
return { ...a, ...b } | ||
} | ||
|
||
return { ...mergeDeep(a, b, maxDepth - 1), ...mergeDeep(a, b, maxDepth - 1) } | ||
} | ||
|
||
type FormatNumber = (value: bigint | string | number, options?: Partial<NumberFormatOptions>) => string | ||
const normaliseValue: FormatNumber = (value, options) => { | ||
const valueAsBigInt = BigInt(value) | ||
const { decimals } = options as NumberFormatOptions | ||
const { decimalPlaces, mode } = options?.round as RoundingOptions | ||
const inUnits = formatUnits(valueAsBigInt, decimals) | ||
|
||
if (decimals === decimalPlaces) { | ||
return inUnits | ||
} | ||
|
||
const fn = roundingModes[mode] | ||
|
||
return fn(inUnits, decimalPlaces) | ||
} | ||
|
||
export const formatNumber: FormatNumber = (value, options) => { | ||
const mergedOptions = !!options | ||
? (mergeDeep(DEFAULT_NUMBER_FORMAT_OPTIONS, options) as NumberFormatOptions) | ||
: DEFAULT_NUMBER_FORMAT_OPTIONS | ||
const normalisedValue = normaliseValue(value, mergedOptions) | ||
|
||
const { thousandsSeparator } = mergedOptions | ||
const [wholePart, decimalPart] = normalisedValue.split('.') | ||
const wholePartWSeparator = wholePart | ||
.split('') | ||
.reduceRight( | ||
(acc: string, digit: string, index: number, arr) => | ||
`${digit}${index === arr.length - 1 || index % 3 ? '' : thousandsSeparator}${acc}`, | ||
'', | ||
) | ||
|
||
const decimalPartWSeparator = decimalPart | ||
? decimalPart | ||
.split('') | ||
.reduce( | ||
(acc: string, digit: string, index: number) => | ||
`${acc}${!index || index % 3 ? '' : thousandsSeparator}${digit}`, | ||
'', | ||
) | ||
: '' | ||
|
||
return `${wholePartWSeparator}${decimalPartWSeparator ? '.' : ''}${decimalPartWSeparator}` | ||
} | ||
|
||
const floor = (value: string, toDecimals: number) => { | ||
const indexOfDecimalPoint = value.indexOf('.') | ||
|
||
return value.slice(0, indexOfDecimalPoint + 1 + toDecimals) | ||
} | ||
|
||
const ceil = (value: string, toDecimals: number) => { | ||
const [wholePart, decimalPart] = value.split('.') | ||
if (decimalPart.length < toDecimals) { | ||
return value | ||
} | ||
|
||
const croppedDecimals = decimalPart.slice(0, toDecimals) | ||
const retainingPrefix = '10' | ||
const ceiledPrefixedDecimals = (BigInt(retainingPrefix + croppedDecimals) + 1n).toString() | ||
const [_, carryover, ...ceiledDecimals] = ceiledPrefixedDecimals | ||
|
||
return `${BigInt(wholePart) + BigInt(carryover)}${toDecimals ? '.' : ''}${ceiledDecimals.join('')}` | ||
} | ||
|
||
const round = (value: string, toDecimals: number) => { | ||
const leastSignificantDigit = value.indexOf('.') | ||
if (Number(leastSignificantDigit) >= 5) { | ||
return ceil(value, toDecimals) | ||
} | ||
|
||
return floor(value, toDecimals) | ||
} | ||
|
||
type RoundingFunction = (value: string, toDecimals: number) => string | ||
type RoundingModes = Record<RoundingOptions['mode'], RoundingFunction> | ||
export const roundingModes: RoundingModes = { floor, ceil, round } |