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));