From 0a58535a3966ba79070d1e8d6c0e89afc23038ca Mon Sep 17 00:00:00 2001 From: LuisMerinoP <49386405+LuisMerinoP@users.noreply.github.com> Date: Fri, 24 Nov 2023 20:20:40 +0100 Subject: [PATCH] Use arguments in Number.toLocaleString (#1619) * Matching most widely use cases for internationalization and tests. * Commenting U+2009 test that fails. --- Jint.Tests/Runtime/NumberTests.cs | 81 ++++++++++++++++++++++++++ Jint/Native/Number/NumberIntlHelper.cs | 42 +++++++++++++ Jint/Native/Number/NumberPrototype.cs | 20 ++++++- 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 Jint/Native/Number/NumberIntlHelper.cs diff --git a/Jint.Tests/Runtime/NumberTests.cs b/Jint.Tests/Runtime/NumberTests.cs index 992a59843a..5b46fb6393 100644 --- a/Jint.Tests/Runtime/NumberTests.cs +++ b/Jint.Tests/Runtime/NumberTests.cs @@ -63,5 +63,86 @@ public void ParseFloat(string input, double result) var value = _engine.Evaluate($"parseFloat('{input}')").AsNumber(); Assert.Equal(result, value); } + + // Results from node -v v18.18.0. + [Theory] + // Thousand separators. + [InlineData("1000000", "en-US", "1,000,000")] + [InlineData("1000000", "en-GB", "1,000,000")] + [InlineData("1000000", "de-DE", "1.000.000")] + // TODO. Fails in Win CI due to U+2009 + // Check https://learn.microsoft.com/en-us/dotnet/core/extensions/globalization-icu + // [InlineData("1000000", "fr-FR", "1 000 000")] + [InlineData("1000000", "es-ES", "1.000.000")] + [InlineData("1000000", "es-LA", "1.000.000")] + [InlineData("1000000", "es-MX", "1,000,000")] + [InlineData("1000000", "es-AR", "1.000.000")] + [InlineData("1000000", "es-CL", "1.000.000")] + // Comma separator. + [InlineData("1,23", "en-US", "23")] + [InlineData("1,23", "en-GB", "23")] + [InlineData("1,23", "de-DE", "23")] + [InlineData("1,23", "fr-FR", "23")] + [InlineData("1,23", "es-ES", "23")] + [InlineData("1,23", "es-LA", "23")] + [InlineData("1,23", "es-MX", "23")] + [InlineData("1,23", "es-AR", "23")] + [InlineData("1,23", "es-CL", "23")] + // Dot deicimal separator. + [InlineData("1.23", "en-US", "1.23")] + [InlineData("1.23", "en-GB", "1.23")] + [InlineData("1.23", "de-DE", "1,23")] + [InlineData("1.23", "fr-FR", "1,23")] + [InlineData("1.23", "es-ES", "1,23")] + [InlineData("1.23", "es-LA", "1,23")] + [InlineData("1.23", "es-MX", "1.23")] + [InlineData("1.23", "es-AR", "1,23")] + [InlineData("1.23", "es-CL", "1,23")] + // Scientific notation. + [InlineData("1e6", "en-US", "1,000,000")] + [InlineData("1e6", "en-GB", "1,000,000")] + [InlineData("1e6", "de-DE", "1.000.000")] + // TODO. Fails in Win CI due to U+2009 + // Check https://learn.microsoft.com/en-us/dotnet/core/extensions/globalization-icu + // [InlineData("1000000", "fr-FR", "1 000 000")] + [InlineData("1e6", "es-ES", "1.000.000")] + [InlineData("1e6", "es-LA", "1.000.000")] + [InlineData("1e6", "es-MX", "1,000,000")] + [InlineData("1e6", "es-AR", "1.000.000")] + [InlineData("1e6", "es-CL", "1.000.000")] + // Returns the correct max decimal degits for the respective cultures, rounded down. + [InlineData("1.234444449", "en-US", "1.234")] + [InlineData("1.234444449", "en-GB", "1.234")] + [InlineData("1.234444449", "de-DE", "1,234")] + [InlineData("1.234444449", "fr-FR", "1,234")] + [InlineData("1.234444449", "es-ES", "1,234")] + [InlineData("1.234444449", "es-LA", "1,234")] + [InlineData("1.234444449", "es-MX", "1.234")] + [InlineData("1.234444449", "es-AR", "1,234")] + [InlineData("1.234444449", "es-CL", "1,234")] + // Returns the correct max decimal degits for the respective cultures, rounded up. + [InlineData("1.234500001", "en-US", "1.235")] + [InlineData("1.234500001", "en-GB", "1.235")] + [InlineData("1.234500001", "de-DE", "1,235")] + [InlineData("1.234500001", "fr-FR", "1,235")] + [InlineData("1.234500001", "es-ES", "1,235")] + [InlineData("1.234500001", "es-LA", "1,235")] + [InlineData("1.234500001", "es-MX", "1.235")] + [InlineData("1.234500001", "es-AR", "1,235")] + [InlineData("1.234500001", "es-CL", "1,235")] + public void ToLocaleString(string parseNumber, string culture, string result) + { + var value = _engine.Evaluate($"({parseNumber}).toLocaleString('{culture}')").AsString(); + Assert.Equal(result, value); + } + + [Theory] + // Does not add extra zeros of there is no cuture argument. + [InlineData("123456")] + public void ToLocaleStringNoArg(string parseNumber) + { + var value = _engine.Evaluate($"({parseNumber}).toLocaleString()").AsString(); + Assert.DoesNotContain(".0", value); + } } } diff --git a/Jint/Native/Number/NumberIntlHelper.cs b/Jint/Native/Number/NumberIntlHelper.cs new file mode 100644 index 0000000000..4e17d82cab --- /dev/null +++ b/Jint/Native/Number/NumberIntlHelper.cs @@ -0,0 +1,42 @@ +// Ideally, internacionalization formats implemented through the ECMAScript standards would follow this: +// https://tc39.es/ecma402/#sec-initializedatetimeformat +// https://tc39.es/ecma402/#sec-canonicalizelocalelist +// Along with the implementations of whatever is subsequenlty called. + +// As this is not in place (See TODOS in NumberFormatConstructor and DateTimeFormatConstructor) we can arrange +// values that will match the JS behavior using the host logic. This bypasses the ECMAScript standards but can +// do the job for the most common use cases and cultures meanwhile. + +namespace Jint.Native.Number +{ + internal class NumberIntlHelper + { + // Obtined empirically. For all cultures tested, we get a maximum of 3 decimal digits. + private const int JS_MAX_DECIMAL_DIGIT_COUNT = 3; + + /// + /// Checks the powers of 10 of number to count the number of decimal digits. + /// Returns a clamped JS_MAX_DECIMAL_DIGIT_COUNT count. + /// JavaScript will use the shortest representation that accurately represents the value + /// and clamp the decimal digits to JS_MAX_DECIMAL_DIGIT_COUNT. + /// C# fills the digits with zeros up to the culture's numberFormat.NumberDecimalDigits + /// and does not provide the same max (numberFormat.NumberDecimalDigits != JS_MAX_DECIMAL_DIGIT_COUNT). + /// This function matches the JS behaviour for the decimal digits returned, this is the actual decimal + /// digits for a number (with no zeros fill) clamped to JS_MAX_DECIMAL_DIGIT_COUNT. + /// + public static int GetDecimalDigitCount(double number) + { + for (int i = 0; i < JS_MAX_DECIMAL_DIGIT_COUNT; i++) + { + var powOf10 = number * System.Math.Pow(10, i); + bool isInteger = powOf10 == ((int) powOf10); + if (isInteger) + { + return i; + } + } + + return JS_MAX_DECIMAL_DIGIT_COUNT; + } + } +} diff --git a/Jint/Native/Number/NumberPrototype.cs b/Jint/Native/Number/NumberPrototype.cs index 333e5a80d0..186be11282 100644 --- a/Jint/Native/Number/NumberPrototype.cs +++ b/Jint/Native/Number/NumberPrototype.cs @@ -80,7 +80,25 @@ private JsValue ToLocaleString(JsValue thisObject, JsValue[] arguments) return "-Infinity"; } - return m.ToString("n", Engine.Options.Culture); + var numberFormat = (NumberFormatInfo) Engine.Options.Culture.NumberFormat.Clone(); + + try + { + if (arguments.Length > 0 && arguments[0].IsString()) + { + var cultureArgument = arguments[0].ToString(); + numberFormat = (NumberFormatInfo) CultureInfo.GetCultureInfo(cultureArgument).NumberFormat.Clone(); + } + + int decDigitCount = NumberIntlHelper.GetDecimalDigitCount(m); + numberFormat.NumberDecimalDigits = decDigitCount; + } + catch (CultureNotFoundException) + { + ExceptionHelper.ThrowRangeError(_realm, "Incorrect locale information provided"); + } + + return m.ToString("n", numberFormat); } private JsValue ValueOf(JsValue thisObject, JsValue[] arguments)