Skip to content
82 changes: 58 additions & 24 deletions src/Humanizer/MetricNumeralExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ static string BuildDefaultRepresentation(int input)
/// <returns>A valid Metric representation</returns>
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();
}
Expand Down Expand Up @@ -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;
}

/// <summary>
Expand Down Expand Up @@ -373,43 +370,80 @@ 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<char>();

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')
{
// 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
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
}
Comment on lines +440 to +446

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Promote rounded 1000 values to next metric prefix

After the decimals == 0 rounding step, number can become 1000 (or -1000) but the code immediately returns with the current unitText, producing outputs like 1000k for inputs such as 999500. This is inconsistent with the double/int path (which re-normalizes after rounding) and yields an incorrect metric unit at scale boundaries; a post-rounding normalization check is needed before formatting.

Useful? React with 👍 / 👎.


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

Expand Down
208 changes: 169 additions & 39 deletions tests/Humanizer.Tests/MetricNumeralTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,174 @@ public void TestAllSymbolsAsInt(int exponent)
Assert.True(isEquals);
}

public static TheoryData<long, int?, string> 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<long, int?, string>();

// 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[])[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, 123456789, 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[])[23456789123, 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, 123456789123456, 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, 123456789123456789, 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_005_006, 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)]
Expand All @@ -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));

Expand Down
Loading