diff --git a/src/components/__tests__/CurrencyInput-abbreviated.spec.tsx b/src/components/__tests__/CurrencyInput-abbreviated.spec.tsx index 103f060..4fa68ad 100644 --- a/src/components/__tests__/CurrencyInput-abbreviated.spec.tsx +++ b/src/components/__tests__/CurrencyInput-abbreviated.spec.tsx @@ -51,6 +51,40 @@ describe(' abbreviated', () => { expect(screen.getByRole('textbox')).toHaveValue('£1,599,000,000'); }); + it('should handle 4.1m without floating-point precision issues', () => { + render(); + userEvent.type(screen.getByRole('textbox'), '4.1m'); + + expect(onValueChangeSpy).toHaveBeenLastCalledWith('4100000', undefined, { + float: 4100000, + formatted: '£4,100,000', + value: '4100000', + }); + + expect(screen.getByRole('textbox')).toHaveValue('£4,100,000'); + }); + + it('should handle other problematic decimal abbreviations', () => { + render(); + + userEvent.type(screen.getByRole('textbox'), '1.025m'); + expect(onValueChangeSpy).toHaveBeenLastCalledWith('1025000', undefined, { + float: 1025000, + formatted: '$1,025,000', + value: '1025000', + }); + expect(screen.getByRole('textbox')).toHaveValue('$1,025,000'); + + userEvent.clear(screen.getByRole('textbox')); + userEvent.type(screen.getByRole('textbox'), '2.1k'); + expect(onValueChangeSpy).toHaveBeenLastCalledWith('2100', undefined, { + float: 2100, + formatted: '$2,100', + value: '2100', + }); + expect(screen.getByRole('textbox')).toHaveValue('$2,100'); + }); + it('should not abbreviate any other letters', () => { render(); userEvent.type(screen.getByRole('textbox'), '1.5e'); diff --git a/src/components/utils/__tests__/cleanValue.spec.ts b/src/components/utils/__tests__/cleanValue.spec.ts index 067eaed..51281f8 100644 --- a/src/components/utils/__tests__/cleanValue.spec.ts +++ b/src/components/utils/__tests__/cleanValue.spec.ts @@ -267,6 +267,38 @@ describe('cleanValue', () => { ).toEqual(''); }); + it('should handle floating-point precision issues in abbreviations', () => { + expect( + cleanValue({ + value: '4.1m', + }) + ).toEqual('4100000'); + + expect( + cleanValue({ + value: '-4.11B', + }) + ).toEqual('-4110000000'); + + expect( + cleanValue({ + value: '1.025m', + }) + ).toEqual('1025000'); + + expect( + cleanValue({ + value: '2.1k', + }) + ).toEqual('2100'); + + expect( + cleanValue({ + value: '3.1m', + }) + ).toEqual('3100000'); + }); + it('should ignore abbreviations if disableAbbreviations is true', () => { expect( cleanValue({ diff --git a/src/components/utils/__tests__/index.spec.ts b/src/components/utils/__tests__/index.spec.ts new file mode 100644 index 0000000..8795847 --- /dev/null +++ b/src/components/utils/__tests__/index.spec.ts @@ -0,0 +1,105 @@ +import * as utils from '../index'; + +describe('utils index exports', () => { + it('should export cleanValue function', () => { + expect(utils.cleanValue).toBeDefined(); + expect(typeof utils.cleanValue).toBe('function'); + }); + + it('should export fixedDecimalValue function', () => { + expect(utils.fixedDecimalValue).toBeDefined(); + expect(typeof utils.fixedDecimalValue).toBe('function'); + }); + + it('should export formatValue function', () => { + expect(utils.formatValue).toBeDefined(); + expect(typeof utils.formatValue).toBe('function'); + }); + + it('should export getLocaleConfig function', () => { + expect(utils.getLocaleConfig).toBeDefined(); + expect(typeof utils.getLocaleConfig).toBe('function'); + }); + + it('should export getSuffix function', () => { + expect(utils.getSuffix).toBeDefined(); + expect(typeof utils.getSuffix).toBe('function'); + }); + + it('should export isNumber function', () => { + expect(utils.isNumber).toBeDefined(); + expect(typeof utils.isNumber).toBe('function'); + }); + + it('should export padTrimValue function', () => { + expect(utils.padTrimValue).toBeDefined(); + expect(typeof utils.padTrimValue).toBe('function'); + }); + + it('should export repositionCursor function', () => { + expect(utils.repositionCursor).toBeDefined(); + expect(typeof utils.repositionCursor).toBe('function'); + }); + + it('should export safeMultiply function', () => { + expect(utils.safeMultiply).toBeDefined(); + expect(typeof utils.safeMultiply).toBe('function'); + }); + + describe('exported functions should work correctly', () => { + it('cleanValue should clean values', () => { + const result = utils.cleanValue({ value: '1,000' }); + expect(result).toBe('1000'); + }); + + it('fixedDecimalValue should fix decimals', () => { + const result = utils.fixedDecimalValue('123', '.', 2); + expect(result).toBe('1.23'); + }); + + it('formatValue should format values', () => { + const result = utils.formatValue({ value: '1000' }); + expect(result).toBe('1,000'); + }); + + it('getLocaleConfig should return locale config', () => { + const result = utils.getLocaleConfig({ locale: 'en-US', currency: 'USD' }); + expect(result).toHaveProperty('prefix'); + expect(result).toHaveProperty('groupSeparator'); + expect(result).toHaveProperty('decimalSeparator'); + }); + + it('getSuffix should get suffix', () => { + const result = utils.getSuffix('100%', { groupSeparator: ',', decimalSeparator: '.' }); + expect(result).toBe('%'); + }); + + it('isNumber should validate numbers', () => { + expect(utils.isNumber('123')).toBe(true); + expect(utils.isNumber('abc')).toBe(false); + }); + + it('padTrimValue should pad/trim values', () => { + const result = utils.padTrimValue('1.5', '.', 2); + expect(result).toBe('1.50'); + }); + + it('repositionCursor should calculate cursor position', () => { + const result = utils.repositionCursor({ + selectionStart: 1, + value: '1000', + lastKeyStroke: '1', + stateValue: '', + groupSeparator: ',', + }); + expect(result).toHaveProperty('modifiedValue'); + expect(result).toHaveProperty('cursorPosition'); + expect(typeof result.modifiedValue).toBe('string'); + }); + + it('safeMultiply should multiply safely', () => { + const result = utils.safeMultiply(4.1, 1000000); + expect(result).toBe(4100000); + }); + }); +}); diff --git a/src/components/utils/__tests__/parseAbbrValue.spec.ts b/src/components/utils/__tests__/parseAbbrValue.spec.ts index 8d54b5e..be500dd 100644 --- a/src/components/utils/__tests__/parseAbbrValue.spec.ts +++ b/src/components/utils/__tests__/parseAbbrValue.spec.ts @@ -19,6 +19,20 @@ describe('abbrValue', () => { expect(abbrValue(123456, '.')).toEqual('0.123456M'); expect(abbrValue(123456, '.', 2)).toEqual('0.12M'); }); + + describe('floating-point precision in abbreviation', () => { + it('should handle values with potential precision issues', () => { + expect(abbrValue(4100000)).toEqual('4.1M'); + expect(abbrValue(1025000)).toEqual('1.025M'); + expect(abbrValue(3100000)).toEqual('3.1M'); + expect(abbrValue(2100000)).toEqual('2.1M'); + }); + + it('should handle k abbreviations', () => { + expect(abbrValue(1500)).toEqual('1.5k'); + expect(abbrValue(2100)).toEqual('2.1k'); + }); + }); }); describe('parseAbbrValue', () => { @@ -77,4 +91,43 @@ describe('parseAbbrValue', () => { expect(parseAbbrValue('1,2k', ',')).toEqual(1200); expect(parseAbbrValue('2,3m', ',')).toEqual(2300000); }); + + describe('floating-point precision fixes', () => { + it('should handle 4.1M without precision issues', () => { + expect(parseAbbrValue('4.1m')).toBe(4100000); + expect(parseAbbrValue('4.1M')).toBe(4100000); + }); + + it('should handle 1.025M without precision issues', () => { + expect(parseAbbrValue('1.025m')).toBe(1025000); + }); + + it('should handle 4.111M without precision issues', () => { + expect(parseAbbrValue('4.111m')).toBe(4111000); + }); + + it('should handle 3.1M without precision issues', () => { + expect(parseAbbrValue('3.1m')).toBe(3100000); + }); + + it('should handle 2.1M without precision issues', () => { + expect(parseAbbrValue('2.1m')).toBe(2100000); + }); + + it('should handle problematic decimal values with k', () => { + expect(parseAbbrValue('1.5k')).toBe(1500); + expect(parseAbbrValue('2.1k')).toBe(2100); + }); + + it('should handle problematic decimal values with b', () => { + expect(parseAbbrValue('1.1b')).toBe(1100000000); + expect(parseAbbrValue('2.5b')).toBe(2500000000); + }); + + it('should handle negative abbreviated values', () => { + expect(parseAbbrValue('-4.1m')).toBe(-4100000); + expect(parseAbbrValue('-1.5k')).toBe(-1500); + expect(parseAbbrValue('-2.5b')).toBe(-2500000000); + }); + }); }); diff --git a/src/components/utils/__tests__/safeMultiply.spec.ts b/src/components/utils/__tests__/safeMultiply.spec.ts new file mode 100644 index 0000000..3741ab8 --- /dev/null +++ b/src/components/utils/__tests__/safeMultiply.spec.ts @@ -0,0 +1,165 @@ +import { safeMultiply } from '../safeMultiply'; + +describe('safeMultiply', () => { + describe('handles floating-point precision issues', () => { + it('should correctly multiply 4.1 * 1,000,000', () => { + expect(safeMultiply(4.1, 1_000_000)).toBe(4_100_000); + }); + + it('should correctly multiply 1.025 * 1,000,000', () => { + expect(safeMultiply(1.025, 1_000_000)).toBe(1_025_000); + }); + + it('should correctly multiply 4.111 * 1,000,000', () => { + expect(safeMultiply(4.111, 1_000_000)).toBe(4_111_000); + }); + + it('should correctly multiply 3.1 * 1,000,000', () => { + expect(safeMultiply(3.1, 1_000_000)).toBe(3_100_000); + }); + + it('should correctly multiply 2.1 * 1,000,000', () => { + expect(safeMultiply(2.1, 1_000_000)).toBe(2_100_000); + }); + + it('should handle zero values', () => { + expect(safeMultiply(0, 1_000_000)).toBe(0); + expect(safeMultiply(0, 1000)).toBe(0); + expect(safeMultiply(0, 1_000_000_000)).toBe(0); + }); + + it('should handle very small decimal values', () => { + expect(safeMultiply(0.0001233, 1_000_000)).toBe(123.3); + expect(safeMultiply(0.000456, 1_000_000)).toBe(456); + expect(safeMultiply(0.0000001, 1_000_000_000)).toBe(100); + }); + + it('should handle case demcimals with billions', () => { + expect(safeMultiply(0.1, 1_000_000_000)).toBe(100_000_000); + expect(safeMultiply(0.01, 1_000_000_000)).toBe(10_000_000); + expect(safeMultiply(0.001, 1_000_000_000)).toBe(1_000_000); + expect(safeMultiply(0.0001, 1_000_000_000)).toBe(100_000); + }); + + it('should handle case decimals with millions', () => { + expect(safeMultiply(0.1, 1_000_000)).toBe(100_000); + expect(safeMultiply(0.01, 1_000_000)).toBe(10_000); + expect(safeMultiply(0.001, 1_000_000)).toBe(1_000); + expect(safeMultiply(0.0001, 1_000_000)).toBe(100); + expect(safeMultiply(0.333333, 1_000_000)).toBe(333_333); + expect(safeMultiply(0.666666, 1_000_000)).toBe(666_666); + expect(safeMultiply(1.111111, 1_000_000)).toBe(1_111_111); + }); + + it('should handle case decimals with thousands', () => { + expect(safeMultiply(0.1, 1000)).toBe(100); + expect(safeMultiply(0.01, 1000)).toBe(10); + expect(safeMultiply(0.001, 1000)).toBe(1); + expect(safeMultiply(0.0001, 1000)).toBe(0.1); + }); + + it('should handle large numbers with many decimals', () => { + expect(safeMultiply(123.456789, 1_000_000)).toBe(123_456_789); + expect(safeMultiply(9.87654321, 1_000_000)).toBe(9_876_543.21); + expect(safeMultiply(0.123456789, 1_000_000_000)).toBe(123_456_789); + }); + }); + + describe('handles abbreviation multipliers', () => { + it('should handle k (thousands)', () => { + expect(safeMultiply(1.5, 1000)).toBe(1500); + expect(safeMultiply(2.25, 1000)).toBe(2250); + }); + + it('should handle m (millions)', () => { + expect(safeMultiply(5.5, 1_000_000)).toBe(5_500_000); + expect(safeMultiply(10.75, 1_000_000)).toBe(10_750_000); + }); + + it('should handle b (billions)', () => { + expect(safeMultiply(1.1, 1_000_000_000)).toBe(1_100_000_000); + expect(safeMultiply(2.5, 1_000_000_000)).toBe(2_500_000_000); + }); + }); + + describe('handles integer values', () => { + it('should multiply integers correctly', () => { + expect(safeMultiply(4, 1_000_000)).toBe(4_000_000); + expect(safeMultiply(10, 1000)).toBe(10_000); + }); + }); + + describe('handles negative values', () => { + it('should handle negative decimals', () => { + expect(safeMultiply(-4.1, 1_000_000)).toBe(-4_100_000); + expect(safeMultiply(-1.025, 1_000_000)).toBe(-1_025_000); + }); + + it('should handle negative integers', () => { + expect(safeMultiply(-5, 1000)).toBe(-5000); + }); + }); + + describe('handles edge cases', () => { + it('should handle zero', () => { + expect(safeMultiply(0, 1_000_000)).toBe(0); + expect(safeMultiply(0.0, 1000)).toBe(0); + }); + + it('should handle very small decimals', () => { + expect(safeMultiply(0.001, 1000)).toBe(1); + expect(safeMultiply(0.0001, 1_000_000)).toBe(100); + }); + + it('should handle many decimal places', () => { + expect(safeMultiply(1.123456, 1_000_000)).toBe(1_123_456); + expect(safeMultiply(0.999999, 1_000_000)).toBe(999_999); + }); + + it('should handle Infinity', () => { + expect(safeMultiply(Infinity, 1000)).toBe(NaN); + expect(safeMultiply(1.5, Infinity)).toBe(NaN); + }); + + it('should handle NaN', () => { + expect(safeMultiply(NaN, 1000)).toBe(NaN); + expect(safeMultiply(1.5, NaN)).toBe(NaN); + }); + }); + + describe('maintains precision with various decimal lengths', () => { + it('should handle single decimal place', () => { + expect(safeMultiply(1.2, 1000)).toBe(1200); + expect(safeMultiply(9.9, 1000)).toBe(9900); + }); + + it('should handle two decimal places', () => { + expect(safeMultiply(1.25, 1000)).toBe(1250); + expect(safeMultiply(9.99, 1000)).toBe(9990); + }); + + it('should handle three decimal places', () => { + expect(safeMultiply(1.125, 1000)).toBe(1125); + expect(safeMultiply(9.999, 1000)).toBe(9999); + }); + }); + + describe('handles non-power-of-10 multipliers', () => { + it('should handle multiplier that is not a power of 10', () => { + // These multipliers are not powers of 10, so they use regular multiplication + expect(safeMultiply(2, 7)).toBe(14); + expect(safeMultiply(3.5, 4)).toBe(14); + expect(safeMultiply(10, 25)).toBe(250); + }); + + it('should handle decimal multipliers', () => { + expect(safeMultiply(5, 1.5)).toBe(7.5); + expect(safeMultiply(2.5, 2.5)).toBe(6.25); + }); + + it('should handle non-integer multipliers', () => { + expect(safeMultiply(100, 0.5)).toBe(50); + expect(safeMultiply(7.5, 3.2)).toBe(24); + }); + }); +}); diff --git a/src/components/utils/cleanValue.ts b/src/components/utils/cleanValue.ts index 8c1fca9..baa699e 100644 --- a/src/components/utils/cleanValue.ts +++ b/src/components/utils/cleanValue.ts @@ -55,6 +55,7 @@ export const cleanValue = ({ ]); let valueOnly = withoutInvalidChars; + let parsedAbbreviation = false; if (!disableAbbreviations) { // disallow letter without number @@ -66,13 +67,19 @@ export const cleanValue = ({ return ''; } const parsed = parseAbbrValue(withoutInvalidChars, decimalSeparator); - if (parsed) { + if (parsed !== undefined) { valueOnly = String(parsed); + parsedAbbreviation = true; } } const includeNegative = isNegative && allowNegativeValue ? '-' : ''; + // If we parsed an abbreviation, return the expanded value directly + if (parsedAbbreviation) { + return `${includeNegative}${valueOnly}`; + } + if (decimalSeparator && valueOnly.includes(decimalSeparator)) { const [int, decimals] = withoutInvalidChars.split(decimalSeparator); const trimmedDecimals = decimalsLimit && decimals ? decimals.slice(0, decimalsLimit) : decimals; diff --git a/src/components/utils/index.ts b/src/components/utils/index.ts index acfa98c..64980ce 100644 --- a/src/components/utils/index.ts +++ b/src/components/utils/index.ts @@ -8,3 +8,4 @@ export { getSuffix } from './getSuffix'; export { isNumber } from './isNumber'; export { padTrimValue } from './padTrimValue'; export { repositionCursor } from './repositionCursor'; +export { safeMultiply } from './safeMultiply'; diff --git a/src/components/utils/parseAbbrValue.ts b/src/components/utils/parseAbbrValue.ts index 61a2ea5..e726431 100644 --- a/src/components/utils/parseAbbrValue.ts +++ b/src/components/utils/parseAbbrValue.ts @@ -1,4 +1,5 @@ import { escapeRegExp } from './escapeRegExp'; +import { safeMultiply } from './safeMultiply'; /** * Abbreviate number eg. 1000 = 1k @@ -12,7 +13,8 @@ export const abbrValue = (value: number, decimalSeparator = '.', _decimalPlaces const d = p(10, _decimalPlaces); valueLength -= valueLength % 3; - const abbrValue = Math.round((value * d) / p(10, valueLength)) / d + ' kMGTPE'[valueLength / 3]; + const scaledValue = safeMultiply(value, d); + const abbrValue = Math.round(scaledValue / p(10, valueLength)) / d + ' kMGTPE'[valueLength / 3]; return abbrValue.replace('.', decimalSeparator); } @@ -27,14 +29,15 @@ const abbrMap: AbbrMap = { k: 1000, m: 1000000, b: 1000000000 }; * Parse a value with abbreviation e.g 1k = 1000 */ export const parseAbbrValue = (value: string, decimalSeparator = '.'): number | undefined => { - const reg = new RegExp(`(\\d+(${escapeRegExp(decimalSeparator)}\\d*)?)([kmb])$`, 'i'); + const reg = new RegExp(`(-?\\d+(?:${escapeRegExp(decimalSeparator)}\\d*)?)([kmb])$`, 'i'); const match = value.match(reg); if (match) { - const [, digits, , abbr] = match; + const [, digits, abbr] = match; const multiplier = abbrMap[abbr.toLowerCase()]; + const numericValue = Number(digits.replace(decimalSeparator, '.')); - return Number(digits.replace(decimalSeparator, '.')) * multiplier; + return safeMultiply(numericValue, multiplier); } return undefined; diff --git a/src/components/utils/safeMultiply.ts b/src/components/utils/safeMultiply.ts new file mode 100644 index 0000000..bb5cd83 --- /dev/null +++ b/src/components/utils/safeMultiply.ts @@ -0,0 +1,40 @@ +/** + * Safely multiply a decimal number by an integer multiplier to avoid + * floating-point precision issues. + * + * Uses scientific notation (e.g., 4.1e6) which avoids the precision issues + * that occur with direct multiplication (e.g., 4.1 * 1000000 = 4099999.9999999995) + * + * @param value - The decimal number to multiply (e.g., 4.1) + * @param multiplier - The integer multiplier (e.g., 1000000) + * @returns The result as a number + * + * @example + * safeMultiply(4.1, 1_000_000) // Returns 4100000 (using 4.1e6) + * safeMultiply(1.025, 1_000_000) // Returns 1025000 (using 1.025e6) + * safeMultiply(2.1, 1_000) // Returns 2100 (using 2.1e3) + * safeMultiply(-4.1, 1_000_000) // Returns -4100000 (using -4.1e6) + */ +export const safeMultiply = (value: number, multiplier: number): number => { + if (!isFinite(value) || !isFinite(multiplier)) { + return NaN; + } + + // Calculate the exponent (number of zeros in the multiplier) + // e.g., 1000 -> 3, 1000000 -> 6, 1000000000 -> 9 + const exponent = Math.log10(multiplier); + + // If multiplier is a power of 10 (e.g., 1000, 1000000), use scientific notation + // This avoids floating-point precision issues + if (Number.isInteger(exponent)) { + // Convert to exponential notation, extract mantissa and current exponent + // then add the multiplier's exponent + const expString = value.toExponential(); + const [mantissa, currentExp] = expString.split('e'); + const newExp = parseInt(currentExp) + exponent; + return parseFloat(mantissa + 'e' + newExp); + } + + // Fallback to regular multiplication for non-power-of-10 multipliers + return value * multiplier; +}; diff --git a/src/index.ts b/src/index.ts index bb25a17..40bc9a3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,5 +9,6 @@ export type { export { CurrencyInput } from './components/CurrencyInput'; export { formatValue } from './components/utils/formatValue'; export { cleanValue } from './components/utils/cleanValue'; +export { safeMultiply } from './components/utils/safeMultiply'; export default CurrencyInput;