From 795dd1f801e8538000d5024a790a428cd764a41e Mon Sep 17 00:00:00 2001 From: Jos de Jong Date: Thu, 26 Oct 2023 16:07:06 +0200 Subject: [PATCH] fix: #1485 improve conversion of numbers with a round-off errors into a BigNumber --- src/core/function/typed.js | 16 +++---- .../bignumber/convertNumberToBigNumber.js | 43 +++++++++++++++++++ src/utils/number.js | 12 +++--- .../convertNumberToBigNumber.test.js | 35 +++++++++++++++ test/unit-tests/utils/number.test.js | 21 ++++----- 5 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 src/utils/bignumber/convertNumberToBigNumber.js create mode 100644 test/unit-tests/utils/bignumber/convertNumberToBigNumber.test.js diff --git a/src/core/function/typed.js b/src/core/function/typed.js index 8de1b761dd..630a59322a 100644 --- a/src/core/function/typed.js +++ b/src/core/function/typed.js @@ -36,6 +36,9 @@ * @returns {function} The created typed-function. */ +import typedFunction from 'typed-function' +import { convertNumberToBigNumber } from '../../utils/bignumber/convertNumberToBigNumber.js' +import { factory } from '../../utils/factory.js' import { isAccessorNode, isArray, @@ -68,8 +71,8 @@ import { isParenthesisNode, isRange, isRangeNode, - isRelationalNode, isRegExp, + isRelationalNode, isResultSet, isSparseMatrix, isString, @@ -77,9 +80,6 @@ import { isUndefined, isUnit } from '../../utils/is.js' -import typedFunction from 'typed-function' -import { digits } from '../../utils/number.js' -import { factory } from '../../utils/factory.js' import { isMap } from '../../utils/map.js' // returns a new instance of typed-function @@ -173,13 +173,7 @@ export const createTyped = /* #__PURE__ */ factory('typed', dependencies, functi throwNoBignumber(x) } - // note: conversion from number to BigNumber can fail if x has >15 digits - if (digits(x) > 15) { - throw new TypeError('Cannot implicitly convert a number with >15 significant digits to BigNumber ' + - '(value: ' + x + '). ' + - 'Use function bignumber(x) to convert to BigNumber.') - } - return new BigNumber(x) + return convertNumberToBigNumber(x, BigNumber) } }, { from: 'number', diff --git a/src/utils/bignumber/convertNumberToBigNumber.js b/src/utils/bignumber/convertNumberToBigNumber.js new file mode 100644 index 0000000000..fc909d6807 --- /dev/null +++ b/src/utils/bignumber/convertNumberToBigNumber.js @@ -0,0 +1,43 @@ +import { digits } from '../number.js' + +/** + * Convert a number into a BigNumber when it is safe to do so: only when the number + * is has max 15 digits, since a JS number can only represent about ~16 digits. + * This function will correct round-off errors introduced by the JS floating-point + * operations. For example, it will change 0.1+0.2 = 0.30000000000000004 into 0.3. + * + * The function throws an Error when the number cannot be converted safely into a BigNumber. + * + * @param {number} x + * @param {function} BigNumber The bignumber constructor + * @returns {BigNumber} + */ +export function convertNumberToBigNumber (x, BigNumber) { + const d = digits(x) + if (d.length <= 15) { + return new BigNumber(x) + } + + // recognize round-off errors like 0.1 + 0.2 = 0.30000000000000004, which should be 0.3 + // we test whether the first 15 digits end with at least 6 zeros, and a non-zero last digit + // note that a number can optionally end with an exponent + const xStr = x.toString() + const matchTrailingZeros = xStr.match(/(?.+)(?0{6,}[1-9][0-9]*)(?$|[+-eE])/) + if (matchTrailingZeros) { + const { start, end } = matchTrailingZeros.groups + return new BigNumber(start + end) + } + + // recognize round-off errors like 40 - 38.6 = 1.3999999999999986, which should be 1.4 + // we test whether the first 15 digits end with at least 6 nines, and a non-nine and non-zero last digit + // note that a number can optionally end with an exponent + const matchTrailingNines = xStr.match(/(?.+)(?[0-8])(?9{6,}[1-8][0-9]*)(?$|[+-eE])/) + if (matchTrailingNines) { + const { start, digitBeforeNines, end } = matchTrailingNines.groups + return new BigNumber(start + String(parseInt(digitBeforeNines) + 1) + end) + } + + throw new TypeError('Cannot implicitly convert a number with >15 significant digits to BigNumber ' + + '(value: ' + x + '). ' + + 'Use function bignumber(x) to convert to BigNumber.') +} diff --git a/src/utils/number.js b/src/utils/number.js index fd72b57c04..6130392052 100644 --- a/src/utils/number.js +++ b/src/utils/number.js @@ -572,22 +572,22 @@ function zeros (length) { } /** - * Count the number of significant digits of a number. + * Extract all significant digits of a number. * * For example: - * 2.34 returns 3 - * 0.0034 returns 2 - * 120.5e+30 returns 4 + * 2.34 returns '234' + * 0.0034 returns '34' + * 120.5e+30 returns '1205' * * @param {number} value - * @return {number} digits Number of significant digits + * @return {string} Returns a string with all digits */ export function digits (value) { return value .toExponential() + .replace(/^-/, '') // remove sign .replace(/e.*$/, '') // remove exponential notation .replace(/^0\.?0*|\./, '') // remove decimal point and leading zeros - .length } /** diff --git a/test/unit-tests/utils/bignumber/convertNumberToBigNumber.test.js b/test/unit-tests/utils/bignumber/convertNumberToBigNumber.test.js new file mode 100644 index 0000000000..bc67cf5707 --- /dev/null +++ b/test/unit-tests/utils/bignumber/convertNumberToBigNumber.test.js @@ -0,0 +1,35 @@ +import assert from 'assert' +import BigNumber from 'decimal.js' +import { convertNumberToBigNumber } from '../../../../src/utils/bignumber/convertNumberToBigNumber.js' + +describe('convertNumberToBigNumber', function () { + it('should convert numbers to BigNumbers when it is safe to do', function () { + assert.deepStrictEqual(convertNumberToBigNumber(2.4, BigNumber), new BigNumber('2.4')) + assert.deepStrictEqual(convertNumberToBigNumber(2, BigNumber), new BigNumber('2')) + assert.deepStrictEqual(convertNumberToBigNumber(-4, BigNumber), new BigNumber('-4')) + assert.deepStrictEqual(convertNumberToBigNumber(0.1234567, BigNumber), new BigNumber('0.1234567')) + assert.deepStrictEqual(convertNumberToBigNumber(0.12345678901234, BigNumber), new BigNumber('0.12345678901234')) + assert.deepStrictEqual(convertNumberToBigNumber(0.00000000000004, BigNumber), new BigNumber('0.00000000000004')) + assert.deepStrictEqual(convertNumberToBigNumber(1.2e-24, BigNumber), new BigNumber('1.2e-24')) + }) + + it('should convert numbers with round-off errors to BigNumbers when it is safe to do', function () { + // a round-off error above the actual value + assert.deepStrictEqual(convertNumberToBigNumber(0.1 + 0.2, BigNumber).toString(), '0.3') // 0.30000000000000004 + assert.deepStrictEqual(convertNumberToBigNumber(0.1 + 0.24545, BigNumber).toString(), '0.34545') // 0.34545000000000003 + + // a round-off error below the actual value + assert.deepStrictEqual(convertNumberToBigNumber(40 - 38.6, BigNumber).toString(), '1.4') // 1.3999999999999986 + assert.deepStrictEqual(convertNumberToBigNumber(159.119 - 159, BigNumber).toString(), '0.119') // 0.11899999999999977 + assert.deepStrictEqual(convertNumberToBigNumber(159.11934444 - 159, BigNumber).toString(), '0.11934444') // 0.11934443999999189 + }) + + it('should throw an error when converting an number to BigNumber when it is NOT safe to do', function () { + const errorRegex = /Cannot implicitly convert a number with >15 significant digits to BigNumber/ + + assert.throws(() => convertNumberToBigNumber(Math.PI, BigNumber), errorRegex) + assert.throws(() => convertNumberToBigNumber(1 / 3, BigNumber), errorRegex) + assert.throws(() => convertNumberToBigNumber(1 / 7, BigNumber), errorRegex) + assert.throws(() => convertNumberToBigNumber(0.1234567890123456, BigNumber), errorRegex) + }) +}) diff --git a/test/unit-tests/utils/number.test.js b/test/unit-tests/utils/number.test.js index 358e129506..24f9155dfa 100644 --- a/test/unit-tests/utils/number.test.js +++ b/test/unit-tests/utils/number.test.js @@ -44,16 +44,17 @@ describe('number', function () { }) it('should count the number of significant digits of a number', function () { - assert.strictEqual(digits(0), 0) - assert.strictEqual(digits(2), 1) - assert.strictEqual(digits(1234), 4) - assert.strictEqual(digits(2.34), 3) - assert.strictEqual(digits(3000), 1) - assert.strictEqual(digits(0.0034), 2) - assert.strictEqual(digits(120.5e50), 4) - assert.strictEqual(digits(1120.5e+50), 5) - assert.strictEqual(digits(120.52e-50), 5) - assert.strictEqual(digits(Math.PI), 16) + assert.strictEqual(digits(0), '') + assert.strictEqual(digits(2), '2') + assert.strictEqual(digits(1234), '1234') + assert.strictEqual(digits(2.34), '234') + assert.strictEqual(digits(3000), '3') + assert.strictEqual(digits(0.0034), '34') + assert.strictEqual(digits(120.5e50), '1205') + assert.strictEqual(digits(1120.5e+50), '11205') + assert.strictEqual(digits(120.52e-50), '12052') + assert.strictEqual(digits(-1234), '1234') + assert.strictEqual(digits(Math.PI), '3141592653589793') }) it('should format a number using toFixed', function () {