Skip to content

Commit

Permalink
feat(cr): number formatter
Browse files Browse the repository at this point in the history
  • Loading branch information
jurajpiar committed Dec 4, 2024
1 parent beaa8e7 commit aaeee08
Show file tree
Hide file tree
Showing 2 changed files with 214 additions and 0 deletions.
108 changes: 108 additions & 0 deletions src/app/collective-rewards/utils/formatter.test.ts
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
})
106 changes: 106 additions & 0 deletions src/app/collective-rewards/utils/formatter.ts
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 }

0 comments on commit aaeee08

Please sign in to comment.