diff --git a/src/Humanizer/MetricNumeralExtensions.cs b/src/Humanizer/MetricNumeralExtensions.cs index a2f31c58a..1e673fb5b 100644 --- a/src/Humanizer/MetricNumeralExtensions.cs +++ b/src/Humanizer/MetricNumeralExtensions.cs @@ -204,7 +204,7 @@ static string BuildDefaultRepresentation(int input) /// A valid Metric representation public static string ToMetric(this long input, MetricNumeralFormats? formats = null, int? decimals = null) { - if (input.Equals(0) && (!decimals.HasValue || (decimals == 0))) + if (input.Equals(0)) { return input.ToString(); } @@ -333,11 +333,8 @@ static string BuildRepresentation(long input, MetricNumeralFormats? formats, int } var nfi = LocaleNumberFormattingOverrides.GetFormattingNumberFormat(CultureInfo.CurrentCulture); - var representation = decimals > 0 - ? $"{input.ToString(nfi)}{nfi.NumberDecimalSeparator}{new string('0', decimals.Value)}" - : input.ToString(nfi); var space = (formats & MetricNumeralFormats.WithSpace) == MetricNumeralFormats.WithSpace ? " " : string.Empty; - return representation + space; + return input.ToString(nfi) + space; } /// @@ -373,43 +370,101 @@ static string BuildMetricRepresentation(long input, int scale, MetricNumeralForm fractionalPart = Math.Abs(input % divisor); } - if (decimals.HasValue) - { - for (var i = decimals.Value; i < exponent; i++) + if (!decimals.HasValue) + decimals = exponent; + + var unitText = + Math.Sign(scale) switch { - var roundUp = (i + 1 == exponent); + +1 => GetUnitText(Symbols[0][scale - 1], formats), + -1 => GetUnitText(Symbols[1][-scale - 1], formats), + _ => string.Empty + }; - fractionalPart = (fractionalPart + (roundUp ? 5 : 0)) / 10; - } - } - else + var fractionalPartCharacters = Array.Empty(); + + if (decimals > 0) { - decimals = exponent; - } + fractionalPartCharacters = fractionalPart.ToString().PadLeft(exponent, '0').ToCharArray(); + + if (!decimals.HasValue || (decimals >= fractionalPartCharacters.Length)) + decimals = fractionalPartCharacters.Length; + else if (fractionalPartCharacters[decimals.Value] >= '5') + { + var isExactlyAtMidpoint = + (fractionalPartCharacters[decimals.Value] == '5') && + (fractionalPartCharacters.AsSpan().Slice(decimals.Value + 1).IndexOfAnyExcept('0') < 0); + + bool shouldRoundUp; + + if (!isExactlyAtMidpoint) + shouldRoundUp = true; + else + { + var precedingDigit = fractionalPartCharacters[decimals.Value - 1] - '0'; + + var precedingDigitIsOdd = (precedingDigit & 1) != 0; + + // Banker's rounding: 3.5 => 4, 4.5 => 4 + shouldRoundUp = precedingDigitIsOdd; + } + + if (shouldRoundUp) + { + // Apply rounding. Find a digit we can increment, carrying as needed. + for (var i = decimals.Value - 1; i >= 0; i--) + { + if (fractionalPartCharacters[i] < '9') + { + fractionalPartCharacters[i]++; + break; + } + + fractionalPartCharacters[i] = '0'; // loop to carry + } + } + } - var symbol = Math.Sign(scale) == 1 - ? Symbols[0][scale - 1] - : Symbols[1][-scale - 1]; + while ((decimals > 0) && (fractionalPartCharacters[decimals.Value - 1] == '0')) + decimals--; + } var nfi = LocaleNumberFormattingOverrides.GetFormattingNumberFormat(CultureInfo.CurrentCulture); if (decimals == 0) { + var roundingPoint = divisor / 2; + + if (divisor > 1) + { + if (fractionalPart > roundingPoint) + number += Math.Sign(number); + else if (fractionalPart == roundingPoint) + { + // Use banker's rounding for consistency with Math.Round used elsewhere on floats. + number += Math.Sign(number % 2); + } + + if (Math.Abs(number) == 1000) + { + number /= 1000; + scale++; + unitText = GetUnitText(Symbols[0][scale - 1], formats); + } + } + var space = formats.HasValue && formats.Value.HasFlag(MetricNumeralFormats.WithSpace) ? " " : string.Empty; - return number.ToString(nfi) + space + GetUnitText(symbol, formats); + return number.ToString(nfi) + space + unitText; } else { - var decimalPlaces = Math.Min(decimals.Value, exponent); - var extraZeroes = (decimals.Value - decimalPlaces); var space = formats.HasValue && formats.Value.HasFlag(MetricNumeralFormats.WithSpace) ? " " : string.Empty; return number.ToString(nfi) + nfi.NumberDecimalSeparator - + fractionalPart.ToString("d" + decimalPlaces) - + (extraZeroes <= 0 ? string.Empty : new string('0', extraZeroes)) + + new string(fractionalPartCharacters, 0, decimals.Value) + space - + GetUnitText(symbol, formats); + + unitText; } } diff --git a/tests/Humanizer.Tests/CoverageGapTests.cs b/tests/Humanizer.Tests/CoverageGapTests.cs index 6180f4078..865687686 100644 --- a/tests/Humanizer.Tests/CoverageGapTests.cs +++ b/tests/Humanizer.Tests/CoverageGapTests.cs @@ -1612,7 +1612,7 @@ public void ByteSizeAndMetricNumeralCoverRemainingPublicFormattingBranches() Assert.False(string.IsNullOrWhiteSpace(999_999_999_999_999_999L.ToMetric(MetricNumeralFormats.WithSpace | MetricNumeralFormats.UseShortScaleWord, 3))); Assert.False(string.IsNullOrWhiteSpace(999_999_999_999_999_999d.ToMetric(MetricNumeralFormats.WithSpace | MetricNumeralFormats.UseLongScaleWord, 4))); Assert.False(string.IsNullOrWhiteSpace(long.MaxValue.ToMetric())); - Assert.Equal("123.0 ", 123L.ToMetric(MetricNumeralFormats.WithSpace, 1)); + Assert.Equal("123 ", 123L.ToMetric(MetricNumeralFormats.WithSpace, 1)); Assert.Equal("1m", InvokePrivate( typeof(MetricNumeralExtensions), null, diff --git a/tests/Humanizer.Tests/MetricNumeralTests.cs b/tests/Humanizer.Tests/MetricNumeralTests.cs index b92082b6c..70457d50b 100644 --- a/tests/Humanizer.Tests/MetricNumeralTests.cs +++ b/tests/Humanizer.Tests/MetricNumeralTests.cs @@ -75,6 +75,174 @@ public void TestAllSymbolsAsInt(int exponent) Assert.True(isEquals); } + public static TheoryData GenerateLongToMetricTestCases() + { + // Dividing by 1.000...M removes all trailing zeros by changing the scale factor. + static decimal RemoveTrailingZeroes(ref decimal value) + => value /= 1.000000000000000000000000000000000M; + + var data = new TheoryData(); + + // 0-999 + foreach (var value in (long[])[0, 123]) + { + foreach (var decimals in (int?[])[null, 0, 1, 3, 20]) + { + data.Add(value, decimals, value.ToString()); + } + } + + // 1,000-999,999 + foreach (var value in (long[])[1245, 23456, 123456, 123056, 123999]) + { + foreach (var decimals in (int?[])[null, 0, 1, 2, 3, 20]) + { + var scaledValue = value * 0.001M; + + var truncatedValue = + decimals.HasValue + ? Math.Round(scaledValue, decimals.Value) + : scaledValue; + + RemoveTrailingZeroes(ref truncatedValue); + + var expected = truncatedValue + "k"; + + data.Add(value, decimals, expected); + } + } + + // 1,000,000-999,999,999 + foreach (var value in (long[])[23456789, 123456785, 123050709]) + { + foreach (var decimals in (int?[])[null, 0, 1, 2, 3, 4, 5, 6, 20]) + { + var scaledValue = value * 0.000001M; + + var truncatedValue = + decimals.HasValue + ? Math.Round(scaledValue, decimals.Value) + : scaledValue; + + RemoveTrailingZeroes(ref truncatedValue); + + var expected = truncatedValue + "M"; + + data.Add(value, decimals, expected); + } + } + + // 1,000,000,000-999,999,999,999 + foreach (var value in (long[])[23456789165, 123456789123, 123050709020]) + { + foreach (var decimals in (int?[])[null, 0, 1, 2, 3, 7, 8, 9, 20]) + { + var scaledValue = value * 0.000000001M; + + var truncatedValue = + decimals.HasValue + ? Math.Round(scaledValue, decimals.Value) + : scaledValue; + + RemoveTrailingZeroes(ref truncatedValue); + + var expected = truncatedValue + "G"; + + data.Add(value, decimals, expected); + } + } + + // 1,000,000,000,000-999,999,999,999,999 + foreach (var value in (long[])[23456789123456, 123456789123465, 123050709020406]) + { + foreach (var decimals in (int?[])[null, 0, 1, 2, 3, 10, 11, 12, 20]) + { + var scaledValue = value * 0.000000000001M; + + var truncatedValue = + decimals.HasValue + ? Math.Round(scaledValue, decimals.Value) + : scaledValue; + + RemoveTrailingZeroes(ref truncatedValue); + + var expected = truncatedValue + "T"; + + data.Add(value, decimals, expected); + } + } + + // 1,000,000,000,000,000-999,999,999,999,999,999 + foreach (var value in (long[])[23456789123456789, 123456789123456785, 123050709020406080]) + { + foreach (var decimals in (int?[])[null, 0, 1, 2, 3, 13, 14, 15, 20]) + { + var scaledValue = value * 0.000000000000001M; + + var truncatedValue = + decimals.HasValue + ? Math.Round(scaledValue, decimals.Value) + : scaledValue; + + RemoveTrailingZeroes(ref truncatedValue); + + var expected = truncatedValue + "P"; + + data.Add(value, decimals, expected); + } + } + // 1,000,000,000,000,000,000- + foreach (var value in (long[])[1_001_002_003_004_006_005, 2_305_079_902_040_799_020, long.MaxValue]) + { + for (var decimalsValue = -1; decimalsValue <= 20; decimalsValue++) + { + var decimals = decimalsValue < 0 ? null : (int?)decimalsValue; + + var scaledValue = value * 0.000000000000000001M; + + var truncatedValue = + decimals.HasValue + ? Math.Round(scaledValue, decimals.Value) + : scaledValue; + + RemoveTrailingZeroes(ref truncatedValue); + + var expected = truncatedValue + "E"; + + data.Add(value, decimals, expected); + } + } + + // Verify banker's rounding is used + data.Add(2400, 0, "2k"); + data.Add(2500, 0, "2k"); + data.Add(2600, 0, "3k"); + data.Add(3400, 0, "3k"); + data.Add(3500, 0, "4k"); + data.Add(3600, 0, "4k"); + + // Verify scale changes due to rounding are handled. + data.Add(999500, 0, "1M"); + data.Add(999600000, 0, "1G"); + data.Add(999700000000, 0, "1T"); + data.Add(999500000000001, 0, "1P"); + data.Add(999500000000000000, 0, "1E"); + + foreach (var positiveTestCase in data.ToList()) + { + var negativeValue = -positiveTestCase.Data.Item1; + var decimals = positiveTestCase.Data.Item2; + var negativeExpected = + negativeValue != 0 + ? "-" + positiveTestCase.Data.Item3 + : positiveTestCase.Data.Item3; + + data.Add(negativeValue, decimals, negativeExpected); + } + + return data; + } + [Theory] [InlineData("999", 999)] [InlineData("1k", 1000)] @@ -86,45 +254,7 @@ public void ToMetricUsesDefaultIntFormatting(string expected, int subject) => Assert.Equal(expected, subject.ToMetric()); [Theory] - [InlineData(0, 0, "0")] - [InlineData(0, 1, "0.0")] - [InlineData(0, 3, "0.000")] - [InlineData(0, 20, "0.00000000000000000000")] - [InlineData(123, 0, "123")] - [InlineData(123, 1, "123.0")] - [InlineData(123, 3, "123.000")] - [InlineData(123, 20, "123.00000000000000000000")] - [InlineData(123456, null, "123.456k")] - [InlineData(123456, 0, "123k")] - [InlineData(123456, 1, "123.5k")] - [InlineData(123456, 2, "123.46k")] - [InlineData(123456, 3, "123.456k")] - [InlineData(123456, 20, "123.45600000000000000000k")] - [InlineData(123456789, null, "123.456789M")] - [InlineData(123456789, 0, "123M")] - [InlineData(123456789, 1, "123.5M")] - [InlineData(123456789, 2, "123.46M")] - [InlineData(123456789, 3, "123.457M")] - [InlineData(123456789, 5, "123.45679M")] - [InlineData(123456789, 20, "123.45678900000000000000M")] - [InlineData(123456789987, 5, "123.45679G")] - [InlineData(123456789987, 20, "123.45678998700000000000G")] - [InlineData(123456789987654, 5, "123.45679T")] - [InlineData(123456789987654, 20, "123.45678998765400000000T")] - [InlineData(123456789987654321, 5, "123.45679P")] - [InlineData(123456789987654321, 20, "123.45678998765432100000P")] - [InlineData(9223372036854775807, null, "9.223372036854775807E")] - [InlineData(9223372036854775807, 0, "9E")] - [InlineData(9223372036854775807, 3, "9.223E")] - [InlineData(9223372036854775807, 20, "9.22337203685477580700E")] - [InlineData(-1, null, "-1")] - [InlineData(-123, null, "-123")] - [InlineData(-123456, null, "-123.456k")] - [InlineData(-123456789, null, "-123.456789M")] - [InlineData(-9223372036854775808, null, "-9.223372036854775808E")] - [InlineData(-9223372036854775808, 0, "-9E")] - [InlineData(-9223372036854775808, 3, "-9.223E")] - [InlineData(-9223372036854775808, 20, "-9.22337203685477580800E")] + [MemberData(nameof(GenerateLongToMetricTestCases))] public void TestAllSymbolsAsLong(long subject, int? decimals, string expected) => Assert.Equal(expected, subject.ToMetric(decimals: decimals));