Skip to content

Commit 7fd4fc7

Browse files
authored
fix: Number formatting for different locales (#3887)
Signed-off-by: Emre Bogazliyanlioglu <emre@wormholelabs.xyz>
1 parent 9b49fbc commit 7fd4fc7

2 files changed

Lines changed: 247 additions & 3 deletions

File tree

src/utils/formatNumber.test.ts

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2+
import {
3+
formatWithCommas,
4+
removeCommas,
5+
isValidDecimalInput,
6+
formatMinAmount,
7+
} from './formatNumber';
8+
import { amount as sdkAmount } from '@wormhole-foundation/sdk';
9+
10+
// Locale configuration for separators
11+
const localeConfigs = [
12+
{ locale: 'en-US', group: ',', decimal: '.' },
13+
{ locale: 'ja-JP', group: ',', decimal: '.' },
14+
{ locale: 'tr-TR', group: '.', decimal: ',' },
15+
];
16+
17+
describe('formatNumber utilities', () => {
18+
// Store original navigator.language
19+
const originalNavigator = global.navigator;
20+
21+
const mockNavigatorLanguage = (locale: string) => {
22+
Object.defineProperty(global, 'navigator', {
23+
writable: true,
24+
configurable: true,
25+
value: {
26+
...originalNavigator,
27+
language: locale,
28+
},
29+
});
30+
};
31+
32+
afterEach(() => {
33+
// Restore original navigator
34+
Object.defineProperty(global, 'navigator', {
35+
writable: true,
36+
configurable: true,
37+
value: originalNavigator,
38+
});
39+
});
40+
41+
describe.each(localeConfigs)(
42+
'formatWithCommas - $locale',
43+
({ locale, group, decimal }) => {
44+
beforeEach(() => {
45+
mockNavigatorLanguage(locale);
46+
});
47+
48+
it('should format integers with grouping', () => {
49+
expect(formatWithCommas('1234')).toBe(`1${group}234`);
50+
expect(formatWithCommas('1234567')).toBe(`1${group}234${group}567`);
51+
expect(formatWithCommas('1234567890')).toBe(
52+
`1${group}234${group}567${group}890`,
53+
);
54+
});
55+
56+
it('should format decimals with grouping', () => {
57+
expect(formatWithCommas('1234.56')).toBe(`1${group}234${decimal}56`);
58+
expect(formatWithCommas('1234567.89')).toBe(
59+
`1${group}234${group}567${decimal}89`,
60+
);
61+
expect(formatWithCommas('1234.123456')).toBe(
62+
`1${group}234${decimal}123456`,
63+
);
64+
});
65+
66+
it('should preserve trailing decimal point', () => {
67+
expect(formatWithCommas('1234.')).toBe(`1${group}234${decimal}`);
68+
expect(formatWithCommas('123.')).toBe(`123${decimal}`);
69+
});
70+
71+
it('should handle small numbers without grouping', () => {
72+
expect(formatWithCommas('123')).toBe('123');
73+
expect(formatWithCommas('12.34')).toBe(`12${decimal}34`);
74+
expect(formatWithCommas('0.123')).toBe(`0${decimal}123`);
75+
});
76+
77+
it('should handle zero', () => {
78+
expect(formatWithCommas('0')).toBe('0');
79+
expect(formatWithCommas('0.0')).toBe(`0${decimal}0`);
80+
expect(formatWithCommas('0.00')).toBe(`0${decimal}00`);
81+
});
82+
83+
it('should handle empty string', () => {
84+
expect(formatWithCommas('')).toBe('');
85+
});
86+
},
87+
);
88+
89+
describe.each(localeConfigs)(
90+
'removeCommas - $locale',
91+
({ locale, group, decimal }) => {
92+
beforeEach(() => {
93+
mockNavigatorLanguage(locale);
94+
});
95+
96+
it('should remove grouping separators', () => {
97+
expect(removeCommas(`1${group}234`)).toBe('1234');
98+
expect(removeCommas(`1${group}234${group}567`)).toBe('1234567');
99+
expect(removeCommas(`1${group}234${group}567${group}890`)).toBe(
100+
'1234567890',
101+
);
102+
});
103+
104+
it('should preserve/convert decimal separator to dot', () => {
105+
expect(removeCommas(`1${group}234${decimal}56`)).toBe('1234.56');
106+
expect(removeCommas(`1${group}234${group}567${decimal}89`)).toBe(
107+
'1234567.89',
108+
);
109+
});
110+
111+
it('should handle strings without grouping', () => {
112+
expect(removeCommas('123')).toBe('123');
113+
expect(removeCommas(`123${decimal}45`)).toBe('123.45');
114+
});
115+
116+
it('should handle empty string', () => {
117+
expect(removeCommas('')).toBe('');
118+
});
119+
},
120+
);
121+
122+
describe.each(localeConfigs)(
123+
'isValidDecimalInput - $locale',
124+
({ locale, decimal }) => {
125+
beforeEach(() => {
126+
mockNavigatorLanguage(locale);
127+
});
128+
129+
it('should accept valid decimal numbers', () => {
130+
expect(isValidDecimalInput('123')).toBe(true);
131+
expect(isValidDecimalInput(`123${decimal}456`)).toBe(true);
132+
expect(isValidDecimalInput(`0${decimal}123`)).toBe(true);
133+
expect(isValidDecimalInput('0')).toBe(true);
134+
});
135+
136+
it('should accept empty string', () => {
137+
expect(isValidDecimalInput('')).toBe(true);
138+
});
139+
140+
it('should accept just decimal separator', () => {
141+
expect(isValidDecimalInput(decimal)).toBe(true);
142+
});
143+
144+
it('should accept trailing decimal separator', () => {
145+
expect(isValidDecimalInput(`123${decimal}`)).toBe(true);
146+
});
147+
148+
it('should accept leading decimal separator', () => {
149+
expect(isValidDecimalInput(`${decimal}123`)).toBe(true);
150+
});
151+
152+
it('should reject negative numbers', () => {
153+
expect(isValidDecimalInput('-123')).toBe(false);
154+
expect(isValidDecimalInput(`-0${decimal}5`)).toBe(false);
155+
});
156+
157+
it('should reject multiple decimal separators', () => {
158+
expect(isValidDecimalInput(`12${decimal}34${decimal}56`)).toBe(false);
159+
expect(isValidDecimalInput(`1${decimal}${decimal}2`)).toBe(false);
160+
});
161+
162+
it('should reject non-numeric characters', () => {
163+
expect(isValidDecimalInput('12a34')).toBe(false);
164+
expect(isValidDecimalInput('abc')).toBe(false);
165+
});
166+
167+
it('should reject wrong decimal separator for locale', () => {
168+
const wrongDecimal = decimal === '.' ? ',' : '.';
169+
expect(isValidDecimalInput(`12${wrongDecimal}34`)).toBe(false);
170+
});
171+
172+
it('should reject non-string input', () => {
173+
expect(isValidDecimalInput(123 as any)).toBe(false);
174+
expect(isValidDecimalInput(null as any)).toBe(false);
175+
expect(isValidDecimalInput(undefined as any)).toBe(false);
176+
});
177+
},
178+
);
179+
180+
describe.each(localeConfigs)(
181+
'formatWithCommas and removeCommas round-trip - $locale',
182+
({ locale }) => {
183+
beforeEach(() => {
184+
mockNavigatorLanguage(locale);
185+
});
186+
187+
it('should round-trip correctly with various inputs', () => {
188+
const testCases = ['123', '1234', '123456.789', '0.123', '1234567.89'];
189+
testCases.forEach((value) => {
190+
const formatted = formatWithCommas(value);
191+
const restored = removeCommas(formatted);
192+
expect(restored).toBe(value);
193+
});
194+
});
195+
},
196+
);
197+
198+
describe('formatMinAmount', () => {
199+
it('should ceil values greater than 999', () => {
200+
const amount1000 = sdkAmount.fromBaseUnits(1000000000n, 6); // 1000
201+
expect(formatMinAmount(amount1000)).toBe('1000');
202+
203+
const amount1234 = sdkAmount.fromBaseUnits(1234567890n, 6); // 1234.56789
204+
expect(formatMinAmount(amount1234)).toBe('1235');
205+
206+
const amount999_9 = sdkAmount.fromBaseUnits(999900000n, 6); // 999.9
207+
expect(formatMinAmount(amount999_9)).toBe('1000');
208+
});
209+
210+
it('should use toPrecision(3) for values <= 999', () => {
211+
const amount999 = sdkAmount.fromBaseUnits(999000000n, 6); // 999
212+
expect(formatMinAmount(amount999)).toBe('999');
213+
214+
const amount123 = sdkAmount.fromBaseUnits(123456789n, 6); // 123.456789
215+
expect(formatMinAmount(amount123)).toBe('123');
216+
217+
const amount12_3 = sdkAmount.fromBaseUnits(12345678n, 6); // 12.345678
218+
expect(formatMinAmount(amount12_3)).toBe('12.3');
219+
220+
const amount1_23 = sdkAmount.fromBaseUnits(1234567n, 6); // 1.234567
221+
expect(formatMinAmount(amount1_23)).toBe('1.23');
222+
223+
const amount0_123 = sdkAmount.fromBaseUnits(123456n, 6); // 0.123456
224+
expect(formatMinAmount(amount0_123)).toBe('0.123');
225+
226+
const amount0_0123 = sdkAmount.fromBaseUnits(12345n, 6); // 0.012345
227+
expect(formatMinAmount(amount0_0123)).toBe('0.0123');
228+
});
229+
230+
it('should handle zero', () => {
231+
const amountZero = sdkAmount.fromBaseUnits(0n, 6);
232+
expect(formatMinAmount(amountZero)).toBe('0.00');
233+
});
234+
235+
it('should handle amounts with different decimals', () => {
236+
// 18 decimals (like ETH)
237+
const amountEth = sdkAmount.fromBaseUnits(1234567890123456789n, 18); // ~1.234 ETH
238+
expect(formatMinAmount(amountEth)).toBe('1.23');
239+
240+
// 2 decimals
241+
const amount2Dec = sdkAmount.fromBaseUnits(12345n, 2); // 123.45
242+
expect(formatMinAmount(amount2Dec)).toBe('123');
243+
});
244+
});
245+
});

src/views/v3/Bridge/AmountInput.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,11 @@ const DebouncedTextField = memo(
4747

4848
const onInnerChange: ChangeEventHandler<HTMLInputElement> = useCallback(
4949
(e: React.ChangeEvent<HTMLInputElement>) => {
50-
const value = removeCommas(e.target.value);
51-
52-
if (!isValidDecimalInput(value)) {
50+
if (!isValidDecimalInput(e.target.value)) {
5351
return;
5452
}
5553

54+
const value = removeCommas(e.target.value);
5655
const formattedValue = formatWithCommas(value);
5756

5857
setInnerValue(formattedValue);

0 commit comments

Comments
 (0)