Skip to content

Commit bcf5394

Browse files
committed
Finalise implemetation, optimize fo UX, and add full test suite
1 parent 1944a85 commit bcf5394

5 files changed

Lines changed: 463 additions & 84 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { act } from "@testing-library/react";
2+
import { renderHook } from "@testing-library/react-hooks";
3+
4+
import { usePriceField } from "./usePriceField";
5+
6+
describe("usePriceField", () => {
7+
const createEvent = (value: string, name = "price") => ({
8+
target: { name, value },
9+
});
10+
11+
describe("keystroke rejection guard", () => {
12+
it("fires onChange for valid formatted input", () => {
13+
// Arrange
14+
const onChange = jest.fn();
15+
const { result } = renderHook(() => usePriceField("USD", onChange));
16+
17+
// Act
18+
act(() => {
19+
result.current.onChange(createEvent("12.34"));
20+
});
21+
22+
// Assert
23+
expect(onChange).toHaveBeenCalledWith({
24+
target: { name: "price", value: "12.34" },
25+
});
26+
});
27+
28+
it("fires onChange with null when input is cleared", () => {
29+
// Arrange
30+
const onChange = jest.fn();
31+
const { result } = renderHook(() => usePriceField("USD", onChange));
32+
33+
// Act
34+
act(() => {
35+
result.current.onChange(createEvent(""));
36+
});
37+
38+
// Assert
39+
expect(onChange).toHaveBeenCalledWith({
40+
target: { name: "price", value: null },
41+
});
42+
});
43+
44+
it("does not fire onChange when bad keystroke would clear non-empty input", () => {
45+
// Arrange
46+
const onChange = jest.fn();
47+
const { result } = renderHook(() => usePriceField("USD", onChange));
48+
49+
// Act — simulates "12.3" + accidental "," → "12.3," which formatPriceInput rejects
50+
act(() => {
51+
result.current.onChange(createEvent("12.3,"));
52+
});
53+
54+
// Assert
55+
expect(onChange).not.toHaveBeenCalled();
56+
});
57+
58+
it("does not fire onChange for garbage paste over existing value", () => {
59+
// Arrange
60+
const onChange = jest.fn();
61+
const { result } = renderHook(() => usePriceField("USD", onChange));
62+
63+
// Act
64+
act(() => {
65+
result.current.onChange(createEvent("abc!@#"));
66+
});
67+
68+
// Assert
69+
expect(onChange).not.toHaveBeenCalled();
70+
});
71+
72+
it("fires onChange for valid pasted international format", () => {
73+
// Arrange
74+
const onChange = jest.fn();
75+
const { result } = renderHook(() => usePriceField("USD", onChange));
76+
77+
// Act
78+
act(() => {
79+
result.current.onChange(createEvent("1.234,56"));
80+
});
81+
82+
// Assert
83+
expect(onChange).toHaveBeenCalledWith({
84+
target: { name: "price", value: "1234.56" },
85+
});
86+
});
87+
});
88+
});

src/components/PriceField/usePriceField.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export function usePriceField(currency: string | undefined, onChange: FormChange
1616
const rawValue = String(e.target.value ?? "");
1717
const formattedValue = formatPriceInput(rawValue, maxDecimalPlaces);
1818

19+
if (!formattedValue && rawValue) return;
20+
1921
onChange({
2022
target: {
2123
name: e.target.name,

src/components/PriceField/utils.test.ts

Lines changed: 116 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -98,13 +98,20 @@ describe("formatPriceInput", () => {
9898
});
9999

100100
describe("currency-specific decimal places", () => {
101-
it("handles zero decimal places for currencies like JPY", () => {
101+
it("truncates to zero decimal places for currencies like JPY", () => {
102102
expect(formatPriceInput("1000.99", 0)).toBe("1000");
103103
expect(formatPriceInput("1000,99", 0)).toBe("1000");
104+
expect(formatPriceInput("1,234.56", 0)).toBe("1234");
105+
expect(formatPriceInput("1'234.56", 0)).toBe("1234");
104106
});
105107

106-
it("handles three decimal places for currencies like KWD", () => {
108+
it("truncates to three decimal places for currencies like KWD", () => {
107109
expect(formatPriceInput("10.12345", 3)).toBe("10.123");
110+
expect(formatPriceInput("1,234.5678", 3)).toBe("1234.567");
111+
expect(formatPriceInput("1.234,5678", 3)).toBe("1234.567");
112+
expect(formatPriceInput("1'234.5678", 3)).toBe("1234.567");
113+
expect(formatPriceInput("1'234,5678", 3)).toBe("1234.567");
114+
expect(formatPriceInput("1,000", 3)).toBe("1000");
108115
});
109116
});
110117

@@ -126,6 +133,18 @@ describe("formatPriceInput", () => {
126133
expect(formatPriceInput("1.2.3.4", 2)).toBe("1.23");
127134
});
128135

136+
it("handles double-tap comma as typing accident", () => {
137+
expect(formatPriceInput("123,,45", 2)).toBe("123.45");
138+
expect(formatPriceInput("10,,50", 2)).toBe("10.50");
139+
});
140+
141+
it("collapses trailing adjacent separators (double-tap at end)", () => {
142+
expect(formatPriceInput("123.,", 2)).toBe("123.");
143+
expect(formatPriceInput("123,.", 2)).toBe("123.");
144+
expect(formatPriceInput("123,,", 2)).toBe("123.");
145+
expect(formatPriceInput("123..", 2)).toBe("123.");
146+
});
147+
129148
it("returns empty for just separators (no digits)", () => {
130149
expect(formatPriceInput(".", 2)).toBe("");
131150
expect(formatPriceInput(",", 2)).toBe("");
@@ -154,25 +173,113 @@ describe("formatPriceInput", () => {
154173
});
155174
});
156175

176+
describe("comma-only thousands detection", () => {
177+
it("recognizes comma as thousand separator when all groups are exactly 3 digits", () => {
178+
expect(formatPriceInput("1,000", 2)).toBe("1000");
179+
expect(formatPriceInput("1,000,000", 2)).toBe("1000000");
180+
});
181+
182+
it("still treats single comma as decimal when group after is not 3 digits", () => {
183+
expect(formatPriceInput("10,50", 2)).toBe("10.50");
184+
expect(formatPriceInput("10,5", 2)).toBe("10.5");
185+
});
186+
});
187+
188+
describe("apostrophe as thousand separator", () => {
189+
it("strips apostrophes and parses with dot decimal", () => {
190+
expect(formatPriceInput("1'222.33", 2)).toBe("1222.33");
191+
expect(formatPriceInput("1'234'567.89", 2)).toBe("1234567.89");
192+
});
193+
194+
it("handles apostrophe-only without decimal point", () => {
195+
expect(formatPriceInput("1'000", 2)).toBe("1000");
196+
});
197+
198+
it("handles apostrophe with dot decimal and trailing zeros", () => {
199+
expect(formatPriceInput("1'000.00", 2)).toBe("1000.00");
200+
});
201+
202+
it("handles apostrophe thousands with comma decimal (Swiss-EU hybrid)", () => {
203+
expect(formatPriceInput("1'222,33", 2)).toBe("1222.33");
204+
});
205+
206+
it("strips leading zeros from formatted numbers", () => {
207+
expect(formatPriceInput("01'234.56", 2)).toBe("1234.56");
208+
expect(formatPriceInput("001,234.56", 2)).toBe("1234.56");
209+
});
210+
});
211+
157212
describe("edge cases", () => {
213+
it("handles zero", () => {
214+
expect(formatPriceInput("0", 2)).toBe("0");
215+
expect(formatPriceInput("0.00", 2)).toBe("0.00");
216+
});
217+
218+
it("handles large numbers without separators", () => {
219+
expect(formatPriceInput("9999999.99", 2)).toBe("9999999.99");
220+
expect(formatPriceInput("1000000.99", 2)).toBe("1000000.99");
221+
});
222+
158223
it("strips negative sign (prices are positive)", () => {
159224
expect(formatPriceInput("-10.50", 2)).toBe("10.50");
160225
});
161226

162-
it("keeps leading zeros", () => {
163-
expect(formatPriceInput("007.50", 2)).toBe("007.50");
227+
it("strips leading zeros from integer part", () => {
228+
expect(formatPriceInput("007.50", 2)).toBe("7.50");
229+
expect(formatPriceInput("0001.23", 2)).toBe("1.23");
230+
expect(formatPriceInput("00100", 2)).toBe("100");
164231
});
165232

166-
it("handles malformed mixed format (multiple of both separators)", () => {
167-
// "1.222,333.88" has both multiple dots AND a comma - not a clean format
168-
// Falls back to "first separator wins" (typing mode)
169-
expect(formatPriceInput("1.222,333.88", 2)).toBe("1.22");
170-
expect(formatPriceInput("1,222.333,88", 2)).toBe("1.22");
233+
it("rejects malformed mixed format (multiple of both separators)", () => {
234+
expect(formatPriceInput("1.222,333.88", 2)).toBe("");
235+
expect(formatPriceInput("1,222.333,88", 2)).toBe("");
171236
});
172237

173238
it("limits integer part to 15 digits (float64 precision)", () => {
174239
expect(formatPriceInput("123456789012345678", 2)).toBe("123456789012345");
175240
expect(formatPriceInput("12345678901234567.89", 2)).toBe("123456789012345.89");
176241
});
177242
});
243+
244+
describe("invalid formatted inputs", () => {
245+
it("rejects doubled separators in formatted numbers", () => {
246+
expect(formatPriceInput("1,,000.01", 2)).toBe("");
247+
expect(formatPriceInput("1''222.33", 2)).toBe("");
248+
});
249+
250+
it("rejects leading thousand separator", () => {
251+
expect(formatPriceInput(",1000.01", 2)).toBe("");
252+
expect(formatPriceInput("'999.99", 2)).toBe("");
253+
});
254+
255+
it("rejects inconsistent comma grouping", () => {
256+
expect(formatPriceInput("1,00,001.01", 2)).toBe("");
257+
expect(formatPriceInput("1,2345.67", 2)).toBe("");
258+
});
259+
260+
it("rejects inconsistent apostrophe grouping", () => {
261+
expect(formatPriceInput("1'22'2.33", 2)).toBe("");
262+
});
263+
264+
it("rejects adjacent different separators", () => {
265+
expect(formatPriceInput("1,000,.01", 2)).toBe("");
266+
});
267+
268+
it("rejects multiple decimal points in formatted number", () => {
269+
expect(formatPriceInput("1,000,001.0.1", 2)).toBe("");
270+
});
271+
272+
it("rejects ambiguous all-comma with non-3-digit final group", () => {
273+
expect(formatPriceInput("1,234,567,89", 2)).toBe("");
274+
expect(formatPriceInput("1,222,33", 2)).toBe("");
275+
});
276+
277+
it("rejects wrong final apostrophe group", () => {
278+
expect(formatPriceInput("1'234'567'89", 2)).toBe("");
279+
});
280+
281+
it("rejects space as thousand separator", () => {
282+
expect(formatPriceInput("1 222.33", 2)).toBe("");
283+
});
284+
});
178285
});

0 commit comments

Comments
 (0)