Skip to content

Commit cb2d6fb

Browse files
fix: Count parser is inconsistent with count formatter
1 parent 4a3b5d3 commit cb2d6fb

File tree

2 files changed

+55
-26
lines changed

2 files changed

+55
-26
lines changed

source/format/count.test.ts

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,25 +17,39 @@ it("formats counts as expected", function () {
1717
});
1818

1919
it("parses counts as expected", function () {
20-
const langs =
21-
"af,af-ZA,ar,ar-AE,ar-BH,ar-DZ,ar-EG,ar-IQ,ar-JO,ar-KW,ar-LB,ar-LY,ar-MA,ar-OM,ar-QA,ar-SA,ar-SY,ar-TN,ar-YE,az,az-AZ,az-AZ,be,be-BY,bg,bg-BG,bs-BA,ca,ca-ES,cs,cs-CZ,cy,cy-GB,da,da-DK,de,de-AT,de-CH,de-DE,de-LI,de-LU,dv,dv-MV,el,el-GR,en,en-AU,en-BZ,en-CA,en-CB,en-GB,en-IE,en-JM,en-NZ,en-PH,en-TT,en-US,en-ZA,en-ZW,eo,es,es-AR,es-BO,es-CL,es-CO,es-CR,es-DO,es-EC,es-ES,es-ES,es-GT,es-HN,es-MX,es-NI,es-PA,es-PE,es-PR,es-PY,es-SV,es-UY,es-VE,et,et-EE,eu,eu-ES,fa,fa-IR,fi,fi-FI,fo,fo-FO,fr,fr-BE,fr-CA,fr-CH,fr-FR,fr-LU,fr-MC,gl,gl-ES,gu,gu-IN,he,he-IL,hi,hi-IN,hr,hr-BA,hr-HR,hu,hu-HU,hy,hy-AM,id,id-ID,is,is-IS,it,it-CH,it-IT,ja,ja-JP,ka,ka-GE,kk,kk-KZ,kn,kn-IN,ko,ko-KR,kok,kok-IN,ky,ky-KG,lt,lt-LT,lv,lv-LV,mi,mi-NZ,mk,mk-MK,mn,mn-MN,mr,mr-IN,ms,ms-BN,ms-MY,mt,mt-MT,nb,nb-NO,nl,nl-BE,nl-NL,nn-NO,ns,ns-ZA,pa,pa-IN,pl,pl-PL,ps,ps-AR,pt,pt-BR,pt-PT,qu,qu-BO,qu-EC,qu-PE,ro,ro-RO,ru,ru-RU,sa,sa-IN,se,se-FI,se-FI,se-FI,se-NO,se-NO,se-NO,se-SE,se-SE,se-SE,sk,sk-SK,sl,sl-SI,sq,sq-AL,sr-BA,sr-BA,sr-SP,sr-SP,sv,sv-FI,sv-SE,sw,sw-KE,syr,syr-SY,ta,ta-IN,te,te-IN,th,th-TH,tl,tl-PH,tn,tn-ZA,tr,tr-TR,tt,tt-RU,ts,uk,uk-UA,ur,ur-PK,uz,uz-UZ,uz-UZ,vi,vi-VN,xh,xh-ZA,zh,zh-CN,zh-HK,zh-MO,zh-SG,zh-TW,zu,zu-ZA".split(
22-
",",
23-
);
20+
const testCount = function (count: number, inputLocale?: string, input?: string) {
21+
const locales = inputLocale
22+
? [inputLocale]
23+
: "af,af-ZA,ar,ar-AE,ar-BH,ar-DZ,ar-EG,ar-IQ,ar-JO,ar-KW,ar-LB,ar-LY,ar-MA,ar-OM,ar-QA,ar-SA,ar-SY,ar-TN,ar-YE,az,az-AZ,az-AZ,be,be-BY,bg,bg-BG,bs-BA,ca,ca-ES,cs,cs-CZ,cy,cy-GB,da,da-DK,de,de-AT,de-CH,de-DE,de-LI,de-LU,dv,dv-MV,el,el-GR,en,en-AU,en-BZ,en-CA,en-CB,en-GB,en-IE,en-JM,en-NZ,en-PH,en-TT,en-US,en-ZA,en-ZW,eo,es,es-AR,es-BO,es-CL,es-CO,es-CR,es-DO,es-EC,es-ES,es-ES,es-GT,es-HN,es-MX,es-NI,es-PA,es-PE,es-PR,es-PY,es-SV,es-UY,es-VE,et,et-EE,eu,eu-ES,fa,fa-IR,fi,fi-FI,fo,fo-FO,fr,fr-BE,fr-CA,fr-CH,fr-FR,fr-LU,fr-MC,gl,gl-ES,gu,gu-IN,he,he-IL,hi,hi-IN,hr,hr-BA,hr-HR,hu,hu-HU,hy,hy-AM,id,id-ID,is,is-IS,it,it-CH,it-IT,ja,ja-JP,ka,ka-GE,kk,kk-KZ,kn,kn-IN,ko,ko-KR,kok,kok-IN,ky,ky-KG,lt,lt-LT,lv,lv-LV,mi,mi-NZ,mk,mk-MK,mn,mn-MN,mr,mr-IN,ms,ms-BN,ms-MY,mt,mt-MT,nb,nb-NO,nl,nl-BE,nl-NL,nn-NO,ns,ns-ZA,pa,pa-IN,pl,pl-PL,ps,ps-AR,pt,pt-BR,pt-PT,qu,qu-BO,qu-EC,qu-PE,ro,ro-RO,ru,ru-RU,sa,sa-IN,se,se-FI,se-FI,se-FI,se-NO,se-NO,se-NO,se-SE,se-SE,se-SE,sk,sk-SK,sl,sl-SI,sq,sq-AL,sr-BA,sr-BA,sr-SP,sr-SP,sv,sv-FI,sv-SE,sw,sw-KE,syr,syr-SY,ta,ta-IN,te,te-IN,th,th-TH,tl,tl-PH,tn,tn-ZA,tr,tr-TR,tt,tt-RU,ts,uk,uk-UA,ur,ur-PK,uz,uz-UZ,uz-UZ,vi,vi-VN,xh,xh-ZA,zh,zh-CN,zh-HK,zh-MO,zh-SG,zh-TW,zu,zu-ZA".split(
24+
",",
25+
);
2426

25-
langs.forEach(lang => {
26-
const testVal = -1234567.89;
27-
const formatted = new Intl.NumberFormat(lang).format(testVal);
28-
const parsed = parseCount(formatted, lang);
27+
locales.forEach(locale => {
28+
const formatted = input ?? formatCount(count, locale);
29+
const parsed = parseCount(formatted, locale);
2930

30-
if (parsed !== testVal) {
31-
const parts = new Intl.NumberFormat(lang).formatToParts(testVal);
32-
const literal = parts
33-
.filter(p => p.type === "literal")
34-
.map(part => `U+${part.value.charCodeAt(0).toString(16).padStart(4, "0")}`);
35-
expect(parsed).to.equal(
36-
testVal,
37-
`Failed for locale '${lang}'. Expected '${parsed}' to equal '${testVal}': '${literal.join(", ")}'`,
38-
);
39-
}
40-
});
31+
if (parsed !== count) {
32+
const parts = new Intl.NumberFormat(locale).formatToParts(count);
33+
const literal = parts
34+
.filter(p => p.type !== "literal")
35+
.map(
36+
part =>
37+
`${part.type}:${part.value
38+
.split("")
39+
.map(char => `U+${char.charCodeAt(0).toString(16).padStart(4, "0")}`)
40+
.join(", ")}`,
41+
);
42+
expect(parsed).to.equal(count, `Failed for locale '${locale}': '${literal.join(", ")}'`);
43+
}
44+
});
45+
};
46+
47+
testCount(-1, "en-US", "-1");
48+
testCount(0, "en-US", "0");
49+
testCount(1, "en-US", "1");
50+
testCount(10, "en-US", "10");
51+
testCount(100, "en-US", "100");
52+
testCount(1000, "en-US", "1K");
53+
testCount(1234, "en-US", "1.234K");
54+
testCount(-1234);
4155
});

source/format/count.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mustExist } from "../data/nil.js";
1+
import { coalesceArray, mustExist } from "../data/nil.js";
22

33
/**
44
* Convert counts to a human readable string: `1234` → `1.234K`.
@@ -21,21 +21,36 @@ export const formatCount = (count: number | bigint, locale?: string): string =>
2121
* @returns The number represented by the input string.
2222
*/
2323
export const parseCount = (count: string, locale?: string): number => {
24-
const format = new Intl.NumberFormat(locale);
24+
const format = new Intl.NumberFormat(locale, {
25+
compactDisplay: "short",
26+
maximumFractionDigits: 3,
27+
notation: "compact",
28+
});
2529
const parts = format.formatToParts(-12345.6);
30+
const orders = coalesceArray(
31+
Array.from({ length: 20 }).map(
32+
(_, i) => format.formatToParts(Math.pow(10, i)).find(d => d.type === "compact")?.value ?? "",
33+
),
34+
);
2635
const numerals = Array.from({ length: 10 }).map((_, i) => format.format(i));
2736
const index = new Map(numerals.map((d, i) => [d, i]));
2837
const minusSign = new RegExp(`[${mustExist(parts.find(d => d.type === "minusSign")).value}]`);
29-
const group = new RegExp(`[${mustExist(parts.find(d => d.type === "group")).value}]`, "g");
3038
const decimal = new RegExp(`[${mustExist(parts.find(d => d.type === "decimal")).value}]`);
3139
const numeral = new RegExp(`[${numerals.join("")}]`, "g");
40+
const order = new RegExp(`(${[...new Set(orders.filter(o => o !== ""))].join("|")})`, "g");
3241

33-
const DIRECTION_MARK = /\u061c|\u200e/g;
34-
return +count
42+
const DIRECTION_MARK = /\u061c|\u200e|\u200f/g;
43+
let multiplier = 1;
44+
const value = count
3545
.trim()
3646
.replace(DIRECTION_MARK, "")
37-
.replace(group, "")
3847
.replace(decimal, ".")
3948
.replace(numeral, d => mustExist(index.get(d)).toString())
40-
.replace(minusSign, "-");
49+
.replace(minusSign, "-")
50+
.replace(order, o => {
51+
multiplier = Math.pow(10, orders.indexOf(o));
52+
return "";
53+
})
54+
.replace(/[ \u00a0]/g, "");
55+
return +value * multiplier;
4156
};

0 commit comments

Comments
 (0)