diff --git a/src/libs/DistanceRequestUtils.ts b/src/libs/DistanceRequestUtils.ts index db7712eab8909..9216018e1f6a9 100644 --- a/src/libs/DistanceRequestUtils.ts +++ b/src/libs/DistanceRequestUtils.ts @@ -474,12 +474,25 @@ function isDistanceAmountWithinLimit(distance: number, rate: number): boolean { * Normalize odometer text by standardizing locale digits and stripping all * non-numeric characters except the decimal point. fromLocaleDigit converts * each locale character to its standard equivalent (e.g. German ',' → '.' - * for decimal, German '.' → ',' for group separator), then we keep only - * digits and the standard decimal point. + * for decimal, German '.' → ',' for group separator), so after conversion + * dots are always decimals and commas are always group separators. + * We then strip everything except digits and the standard decimal point. */ function normalizeOdometerText(text: string, fromLocaleDigit: (char: string) => string): string { const standardized = replaceAllDigits(text, fromLocaleDigit); - return standardized.replaceAll(/[^0-9.]/g, ''); + const stripped = standardized.replaceAll(/[^0-9.]/g, ''); + // Remove redundant leading zeroes (e.g. "007" → "7", "000" → "0") but + // keep a single zero before a decimal point (e.g. "0.5" stays "0.5"). + return stripped.replace(/^0+(?=\d)/, ''); +} + +/** + * Prepare odometer input text for display by removing non-numeric characters + * (except the decimal point, comma, and space — which serve as group or + * decimal separators depending on locale) and stripping redundant leading zeroes. + */ +function prepareTextForDisplay(text: string): string { + return text.replaceAll(/[^0-9., ]/g, '').replace(/^0+(?=\d)/, ''); } export default { @@ -503,6 +516,7 @@ export default { getRateForExpenseDisplay, isDistanceAmountWithinLimit, normalizeOdometerText, + prepareTextForDisplay, }; export type {MileageRate}; diff --git a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx index 421d7a07cf129..79342e1404f6a 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceOdometer.tsx @@ -308,8 +308,9 @@ function IOURequestStepDistanceOdometer({ if (!isOdometerInputValid(text, startReading)) { return; } - setStartReading(text); - startReadingRef.current = text; + const textForDisplay = DistanceRequestUtils.prepareTextForDisplay(text); + setStartReading(textForDisplay); + startReadingRef.current = textForDisplay; if (formError) { setFormError(''); } @@ -319,8 +320,9 @@ function IOURequestStepDistanceOdometer({ if (!isOdometerInputValid(text, endReading)) { return; } - setEndReading(text); - endReadingRef.current = text; + const textForDisplay = DistanceRequestUtils.prepareTextForDisplay(text); + setEndReading(textForDisplay); + endReadingRef.current = textForDisplay; if (formError) { setFormError(''); } diff --git a/tests/unit/OdometerNormalizationTest.ts b/tests/unit/OdometerNormalizationTest.ts index 85180cedfa076..e558ef5ff5db6 100644 --- a/tests/unit/OdometerNormalizationTest.ts +++ b/tests/unit/OdometerNormalizationTest.ts @@ -9,7 +9,7 @@ describe('normalizeOdometerText', () => { expect(DistanceRequestUtils.normalizeOdometerText('1,5', germanFromLocaleDigit)).toBe('1.5'); }); - it("German '1.5' (fifteen, dot is group separator) normalizes to '15'", () => { + it("German '1.5' (dot is group separator, fifteen) normalizes to '15'", () => { expect(DistanceRequestUtils.normalizeOdometerText('1.5', germanFromLocaleDigit)).toBe('15'); }); @@ -37,4 +37,104 @@ describe('normalizeOdometerText', () => { expect(DistanceRequestUtils.normalizeOdometerText('9999999', englishFromLocaleDigit)).toBe('9999999'); }); }); + + describe('locale-strict decimal handling', () => { + const englishFromLocaleDigit = (char: string) => fromLocaleDigit('en', char); + const germanFromLocaleDigit = (char: string) => fromLocaleDigit('de', char); + + it("English '123,4' (comma is group separator, not decimal) normalizes to '1234'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('123,4', englishFromLocaleDigit)).toBe('1234'); + }); + + it("English '1,234.5' (comma as group with dot decimal) normalizes to '1234.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1,234.5', englishFromLocaleDigit)).toBe('1234.5'); + }); + + it("German '1.234,5' (dot-group, comma-decimal) normalizes to '1234.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('1.234,5', germanFromLocaleDigit)).toBe('1234.5'); + }); + + it("plain comma with identity function: ',5' normalizes to '5' (comma stripped)", () => { + const identity = (char: string) => char; + expect(DistanceRequestUtils.normalizeOdometerText(',5', identity)).toBe('5'); + }); + }); + + describe('leading zeroes', () => { + const identity = (char: string) => char; + + it("strips redundant leading zeroes: '000' → '0'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('000', identity)).toBe('0'); + }); + + it("strips leading zeroes before digits: '007' → '7'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('007', identity)).toBe('7'); + }); + + it("keeps single zero before decimal: '0.5' → '0.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('0.5', identity)).toBe('0.5'); + }); + + it("normalizes multiple zeroes before decimal: '00.5' → '0.5'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('00.5', identity)).toBe('0.5'); + }); + + it("keeps a single zero: '0' → '0'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('0', identity)).toBe('0'); + }); + + it("does not affect numbers without leading zeroes: '123' → '123'", () => { + expect(DistanceRequestUtils.normalizeOdometerText('123', identity)).toBe('123'); + }); + }); +}); + +describe('prepareTextForDisplay', () => { + it("strips non-numeric characters except decimal and comma: 'abc123.4def' → '123.4'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('abc123.4def')).toBe('123.4'); + }); + + it("keeps commas for locale display: '1,234.5' → '1,234.5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('1,234.5')).toBe('1,234.5'); + }); + + it("strips leading zeroes: '007' → '7'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('007')).toBe('7'); + }); + + it("strips redundant leading zeroes: '000' → '0'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('000')).toBe('0'); + }); + + it("keeps single zero before decimal: '0.5' → '0.5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('0.5')).toBe('0.5'); + }); + + it("normalizes multiple leading zeroes before decimal: '00.5' → '0.5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('00.5')).toBe('0.5'); + }); + + it("keeps single zero: '0' → '0'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('0')).toBe('0'); + }); + + it("strips letters and symbols: '@#$%123' → '123'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('@#$%123')).toBe('123'); + }); + + it("handles empty string: '' → ''", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('')).toBe(''); + }); + + it("handles valid number unchanged: '12345.6' → '12345.6'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('12345.6')).toBe('12345.6'); + }); + + it("keeps spaces as group separator (French locale): '1 234,5' → '1 234,5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('1 234,5')).toBe('1 234,5'); + }); + + it("keeps periods as group separator (Italian locale): '1.234,5' → '1.234,5'", () => { + expect(DistanceRequestUtils.prepareTextForDisplay('1.234,5')).toBe('1.234,5'); + }); });