From b4b86bdb353cebdaa313930a6e8cb594c90b019d Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 27 Jul 2026 08:01:07 -0700 Subject: [PATCH 1/7] Ensure that Decimal32/64/128 ToString roundtrips and preserves the cohort The general and roundtrip specifiers unconditionally suppressed scientific notation, so a positive quantum exponent had no correct spelling and reparsed as a different cohort member. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/System/Number.Formatting.cs | 89 +++++++++++++++++- .../System/Decimal128Tests.cs | 88 ++++++++++++++---- .../System/Decimal32Tests.cs | 90 +++++++++++++++---- .../System/Decimal64Tests.cs | 88 ++++++++++++++---- 4 files changed, 297 insertions(+), 58 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs b/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs index 6213688da85fcf..c526632026a6b6 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs @@ -384,7 +384,14 @@ internal static string FormatDecimalIeee754(TValue value, stri { if (fmt is 'G' or 'R' or 'g' or 'r') { - FormatGeneralAndRoundTripDecimalIeee754(ref vlb, ref number, fmt, digits, info); + if (fmt is 'R' or 'r') + { + // The roundtrip specifier ignores any precision specifier and is otherwise identical to the general specifier + fmt = (char)(fmt - ('R' - 'G')); + digits = -1; + } + + FormatGeneralAndRoundTripDecimalIeee754(ref vlb, ref number, (char)(fmt - ('G' - 'E')), digits, info); } else { @@ -418,14 +425,86 @@ internal static bool TryFormatDecimalIeee754(TValue val return success; } - private static void FormatGeneralAndRoundTripDecimalIeee754(ref ValueListBuilder vlb, ref NumberBuffer number, char fmt, int digits, NumberFormatInfo info) + /// + /// Formats using the general format, preserving the quantum exponent so that + /// reparsing the result recovers the same member of the cohort. + /// + /// + /// Fixed-point notation can only spell a quantum exponent that is at or below zero, since a positive + /// quantum would require trailing zeros that reparse as a larger coefficient. Scientific notation is + /// therefore required whenever the quantum exponent is positive, and is otherwise picked using the same + /// compactness heuristic as the binary floating-point types. + /// + private static unsafe void FormatGeneralAndRoundTripDecimalIeee754(ref ValueListBuilder vlb, ref NumberBuffer number, char expChar, int nMaxDigits, NumberFormatInfo info) where TChar : unmanaged, IUtfChar { + Debug.Assert(number.Kind == NumberBufferKind.Decimal); + + if ((nMaxDigits > 0) && (nMaxDigits < number.DigitsCount)) + { + RoundNumber(ref number, nMaxDigits, isCorrectlyRounded: false); + } + if (number.IsNegative) { vlb.Append(info.NegativeSignTChar()); } - FormatGeneral(ref vlb, ref number, digits, info, (char)(fmt - ('G' - 'E')), suppressScientific: true); + + byte* dig = number.DigitsPtr; + int digitCount = number.DigitsCount; + + // `Scale` is the coefficient digit count plus the quantum exponent. A zero coefficient has no + // stored digits but still participates as the single digit `0` when computing the adjusted exponent. + int exponent = number.Scale - digitCount; + int adjustedExponent = (digitCount != 0) ? (number.Scale - 1) : exponent; + + if ((exponent > 0) || (adjustedExponent < -4)) + { + vlb.Append(TChar.CastFrom((digitCount != 0) ? (char)dig[0] : '0')); + + if (digitCount > 1) + { + vlb.Append(info.NumberDecimalSeparatorTChar()); + + for (int i = 1; i < digitCount; i++) + { + vlb.Append(TChar.CastFrom((char)dig[i])); + } + } + + FormatExponent(ref vlb, info, adjustedExponent, expChar, minDigits: 2, positiveSign: true); + return; + } + + int integerDigits = number.Scale; + Debug.Assert(integerDigits <= digitCount); + + if (integerDigits > 0) + { + for (int i = 0; i < integerDigits; i++) + { + vlb.Append(TChar.CastFrom((char)dig[i])); + } + } + else + { + vlb.Append(TChar.CastFrom('0')); + } + + if (exponent < 0) + { + vlb.Append(info.NumberDecimalSeparatorTChar()); + + for (int i = integerDigits; i < 0; i++) + { + vlb.Append(TChar.CastFrom('0')); + } + + for (int i = Math.Max(integerDigits, 0); i < digitCount; i++) + { + vlb.Append(TChar.CastFrom((char)dig[i])); + } + } } public static unsafe string FormatDecimal(decimal value, ReadOnlySpan format, NumberFormatInfo info) @@ -491,7 +570,9 @@ internal static void DecimalIeee754ToNumber(TValue value, ref if (TValue.IsZero(unpackDecimal.Significand)) { - number.Scale = unpackDecimal.UnbiasedExponent < 0 ? unpackDecimal.UnbiasedExponent : 0; + // A zero coefficient has no stored digits, so `Scale` carries the quantum exponent directly. + // Every other format specifier calls `RoundNumber` (or resets `Scale` itself) before reading it. + number.Scale = unpackDecimal.UnbiasedExponent; number.DigitsCount = 0; number.Digits[0] = (byte)'\0'; number.CheckConsistency(); diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs index c6fde9d09b71b5..3d7c3ef698a1a1 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs @@ -116,13 +116,13 @@ public static void Parse_Preserve_TrailingZero(string value, string expected) public static IEnumerable Parse_Preserve_TrailingZero_TestData() { yield return new object[] { "0.00", "0.00" }; - yield return new object[] { "0." + new string('0', 6176), "0." + new string('0', 6176) }; - yield return new object[] { "0." + new string('0', 10000), "0." + new string('0', 6176) }; - yield return new object[] { "0." + new string('0', 10000) + "1234567", "0." + new string('0', 6176) }; + yield return new object[] { "0." + new string('0', 6176), "0E-6176" }; + yield return new object[] { "0." + new string('0', 10000), "0E-6176" }; + yield return new object[] { "0." + new string('0', 10000) + "1234567", "0E-6176" }; yield return new object[] { "0e-2", "0.00" }; - yield return new object[] { "0e-6176", "0." + new string('0', 6176) }; - yield return new object[] { "0e-10000", "0." + new string('0', 6176) }; - yield return new object[] { "0.123e-10000", "0." + new string('0', 6176) }; + yield return new object[] { "0e-6176", "0E-6176" }; + yield return new object[] { "0e-10000", "0E-6176" }; + yield return new object[] { "0.123e-10000", "0E-6176" }; } public static IEnumerable Parse_Invalid_TestData() @@ -396,28 +396,40 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal128.Parse("0"), "G", defaultFormat, "0" }; yield return new object[] { Decimal128.Zero, "G", defaultFormat, "0" }; yield return new object[] { Decimal128.Parse("0.0000"), "G", defaultFormat, "0.0000" }; - yield return new object[] { Decimal128.Parse($"{Int128.MinValue}"), "G", defaultFormat, "-170141183460469231731687303715884100000" }; - yield return new object[] { Decimal128.Parse($"{Int128.MaxValue}"), "G", defaultFormat, "170141183460469231731687303715884100000" }; - yield return new object[] { Decimal128.Parse("3e6144"), "G", defaultFormat, "3" + new string('0', 6144) }; - yield return new object[] { Decimal128.Parse("-3e6144"), "G", defaultFormat, "-3" + new string('0', 6144) }; + yield return new object[] { Decimal128.Parse($"{Int128.MinValue}"), "G", defaultFormat, "-1.701411834604692317316873037158841E+38" }; + yield return new object[] { Decimal128.Parse($"{Int128.MaxValue}"), "G", defaultFormat, "1.701411834604692317316873037158841E+38" }; + yield return new object[] { Decimal128.Parse("3e6144"), "G", defaultFormat, "3." + new string('0', 33) + "E+6144" }; + yield return new object[] { Decimal128.Parse("-3e6144"), "G", defaultFormat, "-3." + new string('0', 33) + "E+6144" }; yield return new object[] { Decimal128.Parse("-4567"), "G", defaultFormat, "-4567" }; yield return new object[] { Decimal128.Parse("-4567.891"), "G", defaultFormat, "-4567.891" }; yield return new object[] { Decimal128.Parse("0"), "G", defaultFormat, "0" }; yield return new object[] { Decimal128.Parse("4567"), "G", defaultFormat, "4567" }; yield return new object[] { Decimal128.Parse("4567.891"), "G", defaultFormat, "4567.891" }; + // A positive quantum exponent has no fixed-point spelling, so scientific notation is required + yield return new object[] { Decimal128.Parse("1e7"), "G", defaultFormat, "1E+07" }; + yield return new object[] { Decimal128.Parse("10e6"), "G", defaultFormat, "1.0E+07" }; + yield return new object[] { Decimal128.Parse("0e2"), "G", defaultFormat, "0E+02" }; + yield return new object[] { Decimal128.Parse("-0e2"), "G", defaultFormat, "-0E+02" }; + yield return new object[] { Decimal128.Parse("0.0001"), "G", defaultFormat, "0.0001" }; + yield return new object[] { Decimal128.Parse("0.00001"), "G", defaultFormat, "1E-05" }; + yield return new object[] { Decimal128.Parse("0.000100"), "G", defaultFormat, "0.000100" }; + yield return new object[] { Decimal128.MaxValue, "G", defaultFormat, "9." + new string('9', 33) + "E+6144" }; + yield return new object[] { Decimal128.MinValue, "G", defaultFormat, "-9." + new string('9', 33) + "E+6144" }; + yield return new object[] { Decimal128.Epsilon, "G", defaultFormat, "1E-6176" }; + yield return new object[] { Decimal128.Parse("2468"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal128.Parse("2467"), "[#-##-#]", defaultFormat, "[2-46-7]" }; - yield return new object[] { Decimal128.Parse("4e-6177"), "G", defaultFormat, "0." + new string('0', 6176) }; - yield return new object[] { Decimal128.Parse("5e-6177"), "G", defaultFormat, "0." + new string('0', 6176) }; - yield return new object[] { Decimal128.Parse("5.00000000000000000000000000000000000000001e-6177"), "G", defaultFormat, "0." + new string('0', 6175) + "1" }; - yield return new object[] { Decimal128.Parse("6e-6177"), "G", defaultFormat, "0." + new string('0', 6175) + "1" }; - yield return new object[] { Decimal128.Parse("-4e-6177"), "G", defaultFormat, "-0." + new string('0', 6176) }; - yield return new object[] { Decimal128.Parse("-5e-6177"), "G", defaultFormat, "-0." + new string('0', 6176) }; - yield return new object[] { Decimal128.Parse("-5.00000000000000000000000000000000000000001e-6177"), "G", defaultFormat, "-0." + new string('0', 6175) + "1" }; - yield return new object[] { Decimal128.Parse("-6e-6177"), "G", defaultFormat, "-0." + new string('0', 6175) + "1" }; + yield return new object[] { Decimal128.Parse("4e-6177"), "G", defaultFormat, "0E-6176" }; + yield return new object[] { Decimal128.Parse("5e-6177"), "G", defaultFormat, "0E-6176" }; + yield return new object[] { Decimal128.Parse("5.00000000000000000000000000000000000000001e-6177"), "G", defaultFormat, "1E-6176" }; + yield return new object[] { Decimal128.Parse("6e-6177"), "G", defaultFormat, "1E-6176" }; + yield return new object[] { Decimal128.Parse("-4e-6177"), "G", defaultFormat, "-0E-6176" }; + yield return new object[] { Decimal128.Parse("-5e-6177"), "G", defaultFormat, "-0E-6176" }; + yield return new object[] { Decimal128.Parse("-5.00000000000000000000000000000000000000001e-6177"), "G", defaultFormat, "-1E-6176" }; + yield return new object[] { Decimal128.Parse("-6e-6177"), "G", defaultFormat, "-1E-6176" }; } } @@ -457,6 +469,46 @@ private static void ToString(Decimal128 f, string format, IFormatProvider provid Assert.Equal(expected.Replace('E', 'e'), f.ToString(format.ToLowerInvariant(), provider)); } + public static IEnumerable ToString_Roundtrip_TestData() + { + yield return new object[] { "0" }; + yield return new object[] { "-0" }; + yield return new object[] { "0.00" }; + yield return new object[] { "0e2" }; + yield return new object[] { "0e6111" }; + yield return new object[] { "0e-6176" }; + yield return new object[] { "-0e-6176" }; + yield return new object[] { "1" }; + yield return new object[] { "1.0" }; + yield return new object[] { "1." + new string('0', 33) }; + yield return new object[] { "1e7" }; + yield return new object[] { "10e6" }; + yield return new object[] { "1" + new string('0', 33) + "e1" }; + yield return new object[] { "-4567.891" }; + yield return new object[] { "0.0001" }; + yield return new object[] { "0.00001" }; + yield return new object[] { "0.000100" }; + yield return new object[] { "170141183460469231731687303715884105728" }; + yield return new object[] { new string('9', 34) + "e6111" }; + yield return new object[] { "-" + new string('9', 34) + "e6111" }; + yield return new object[] { "1e-6176" }; + yield return new object[] { "1234567890123456789012345678901234e-6176" }; + } + + [Theory] + [MemberData(nameof(ToString_Roundtrip_TestData))] + public static void ToString_Roundtrips_And_PreservesQuantum(string value) + { + Decimal128 expected = Decimal128.Parse(value, CultureInfo.InvariantCulture); + + foreach (string format in new[] { null, "G", "g", "R", "r" }) + { + string formatted = expected.ToString(format, CultureInfo.InvariantCulture); + Decimal128 actual = Decimal128.Parse(formatted, CultureInfo.InvariantCulture); + Assert.Equal(Decimal128.EncodeDecimal(expected), Decimal128.EncodeDecimal(actual)); + } + } + [Theory] [MemberData(nameof(PositiveInfinity_NonCanonicalEncodings128_TestData))] [MemberData(nameof(NegativeInfinity_NonCanonicalEncodings128_TestData))] diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs index fc27494c438f79..8503f4b1b23066 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs @@ -116,13 +116,13 @@ public static void Parse_Preserve_TrailingZero(string value, string expected) public static IEnumerable Parse_Preserve_TrailingZero_TestData() { yield return new object[] { "0.00", "0.00" }; - yield return new object[] { "0." + new string('0', 101), "0." + new string('0', 101) }; - yield return new object[] { "0." + new string('0', 1000), "0." + new string('0', 101) }; - yield return new object[] { "0." + new string('0', 1000) + "1234567", "0." + new string('0', 101) }; + yield return new object[] { "0." + new string('0', 101), "0E-101" }; + yield return new object[] { "0." + new string('0', 1000), "0E-101" }; + yield return new object[] { "0." + new string('0', 1000) + "1234567", "0E-101" }; yield return new object[] { "0e-2", "0.00" }; - yield return new object[] { "0e-101", "0." + new string('0', 101) }; - yield return new object[] { "0e-1000", "0." + new string('0', 101) }; - yield return new object[] { "0.123e-1000", "0." + new string('0', 101) }; + yield return new object[] { "0e-101", "0E-101" }; + yield return new object[] { "0e-1000", "0E-101" }; + yield return new object[] { "0.123e-1000", "0E-101" }; } public static IEnumerable Parse_Invalid_TestData() @@ -393,27 +393,41 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal32.Parse("0"), "G", defaultFormat, "0" }; yield return new object[] { Decimal32.Zero, "G", defaultFormat, "0" }; yield return new object[] { Decimal32.Parse("0.0000"), "G", defaultFormat, "0.0000" }; - yield return new object[] { Decimal32.Parse($"{int.MinValue}"), "G", defaultFormat, "-2147484000" }; - yield return new object[] { Decimal32.Parse($"{int.MaxValue}"), "G", defaultFormat, "2147484000" }; - yield return new object[] { Decimal32.Parse("3" + new string('0', 96)), "G", defaultFormat, "3" + new string('0', 96) }; - yield return new object[] { Decimal32.Parse("-3" + new string('0', 96)), "G", defaultFormat, "-3" + new string('0', 96) }; + yield return new object[] { Decimal32.Parse($"{int.MinValue}"), "G", defaultFormat, "-2.147484E+09" }; + yield return new object[] { Decimal32.Parse($"{int.MaxValue}"), "G", defaultFormat, "2.147484E+09" }; + yield return new object[] { Decimal32.Parse("3" + new string('0', 96)), "G", defaultFormat, "3.000000E+96" }; + yield return new object[] { Decimal32.Parse("-3" + new string('0', 96)), "G", defaultFormat, "-3.000000E+96" }; yield return new object[] { Decimal32.Parse("-4567"), "G", defaultFormat, "-4567" }; yield return new object[] { Decimal32.Parse("-4567.891"), "G", defaultFormat, "-4567.891" }; yield return new object[] { Decimal32.Parse("0"), "G", defaultFormat, "0" }; yield return new object[] { Decimal32.Parse("4567"), "G", defaultFormat, "4567" }; yield return new object[] { Decimal32.Parse("4567.891"), "G", defaultFormat, "4567.891" }; + // A positive quantum exponent has no fixed-point spelling, so scientific notation is required + yield return new object[] { Decimal32.Parse("1e7"), "G", defaultFormat, "1E+07" }; + yield return new object[] { Decimal32.Parse("10e6"), "G", defaultFormat, "1.0E+07" }; + yield return new object[] { Decimal32.Parse("1000000e1"), "G", defaultFormat, "1.000000E+07" }; + yield return new object[] { Decimal32.Parse("10000000"), "G", defaultFormat, "1.000000E+07" }; + yield return new object[] { Decimal32.Parse("0e2"), "G", defaultFormat, "0E+02" }; + yield return new object[] { Decimal32.Parse("-0e2"), "G", defaultFormat, "-0E+02" }; + yield return new object[] { Decimal32.Parse("0.0001"), "G", defaultFormat, "0.0001" }; + yield return new object[] { Decimal32.Parse("0.00001"), "G", defaultFormat, "1E-05" }; + yield return new object[] { Decimal32.Parse("0.000100"), "G", defaultFormat, "0.000100" }; + yield return new object[] { Decimal32.MaxValue, "G", defaultFormat, "9.999999E+96" }; + yield return new object[] { Decimal32.MinValue, "G", defaultFormat, "-9.999999E+96" }; + yield return new object[] { Decimal32.Epsilon, "G", defaultFormat, "1E-101" }; + yield return new object[] { Decimal32.Parse("2468"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal32.Parse("2467"), "[#-##-#]", defaultFormat, "[2-46-7]" }; - yield return new object[] { Decimal32.Parse("4e-102"), "G", defaultFormat, "0." + new string('0', 101) }; - yield return new object[] { Decimal32.Parse("5e-102"), "G", defaultFormat, "0." + new string('0', 101) }; - yield return new object[] { Decimal32.Parse("5.000000000000001e-102"), "G", defaultFormat, "0." + new string('0', 100) + "1" }; - yield return new object[] { Decimal32.Parse("6e-102"), "G", defaultFormat, "0." + new string('0', 100) + "1" }; - yield return new object[] { Decimal32.Parse("-4e-102"), "G", defaultFormat, "-0." + new string('0', 101) }; - yield return new object[] { Decimal32.Parse("-5e-102"), "G", defaultFormat, "-0." + new string('0', 101) }; - yield return new object[] { Decimal32.Parse("-5.000000000000001e-102"), "G", defaultFormat, "-0." + new string('0', 100) + "1" }; - yield return new object[] { Decimal32.Parse("-6e-102"), "G", defaultFormat, "-0." + new string('0', 100) + "1" }; + yield return new object[] { Decimal32.Parse("4e-102"), "G", defaultFormat, "0E-101" }; + yield return new object[] { Decimal32.Parse("5e-102"), "G", defaultFormat, "0E-101" }; + yield return new object[] { Decimal32.Parse("5.000000000000001e-102"), "G", defaultFormat, "1E-101" }; + yield return new object[] { Decimal32.Parse("6e-102"), "G", defaultFormat, "1E-101" }; + yield return new object[] { Decimal32.Parse("-4e-102"), "G", defaultFormat, "-0E-101" }; + yield return new object[] { Decimal32.Parse("-5e-102"), "G", defaultFormat, "-0E-101" }; + yield return new object[] { Decimal32.Parse("-5.000000000000001e-102"), "G", defaultFormat, "-1E-101" }; + yield return new object[] { Decimal32.Parse("-6e-102"), "G", defaultFormat, "-1E-101" }; } } @@ -453,6 +467,46 @@ private static void ToString(Decimal32 f, string format, IFormatProvider provide Assert.Equal(expected.Replace('E', 'e'), f.ToString(format.ToLowerInvariant(), provider)); } + public static IEnumerable ToString_Roundtrip_TestData() + { + yield return new object[] { "0" }; + yield return new object[] { "-0" }; + yield return new object[] { "0.00" }; + yield return new object[] { "0e2" }; + yield return new object[] { "0e90" }; + yield return new object[] { "0e-101" }; + yield return new object[] { "-0e-101" }; + yield return new object[] { "1" }; + yield return new object[] { "1.0" }; + yield return new object[] { "1.000000" }; + yield return new object[] { "1e7" }; + yield return new object[] { "10e6" }; + yield return new object[] { "1000000e1" }; + yield return new object[] { "-4567.891" }; + yield return new object[] { "0.0001" }; + yield return new object[] { "0.00001" }; + yield return new object[] { "0.000100" }; + yield return new object[] { "2147483648" }; + yield return new object[] { "9999999e90" }; + yield return new object[] { "-9999999e90" }; + yield return new object[] { "1e-101" }; + yield return new object[] { "1234567e-101" }; + } + + [Theory] + [MemberData(nameof(ToString_Roundtrip_TestData))] + public static void ToString_Roundtrips_And_PreservesQuantum(string value) + { + Decimal32 expected = Decimal32.Parse(value, CultureInfo.InvariantCulture); + + foreach (string format in new[] { null, "G", "g", "R", "r" }) + { + string formatted = expected.ToString(format, CultureInfo.InvariantCulture); + Decimal32 actual = Decimal32.Parse(formatted, CultureInfo.InvariantCulture); + Assert.Equal(Decimal32.EncodeDecimal(expected), Decimal32.EncodeDecimal(actual)); + } + } + [Theory] [MemberData(nameof(PositiveInfinity_NonCanonicalEncodings_TestData))] [MemberData(nameof(NegativeInfinity_NonCanonicalEncodings_TestData))] diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs index 3c8c1485db5042..c0ac74acb9ba1e 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs @@ -117,13 +117,13 @@ public static void Parse_Preserve_TrailingZero(string value, string expected) public static IEnumerable Parse_Preserve_TrailingZero_TestData() { yield return new object[] { "0.00", "0.00" }; - yield return new object[] { "0." + new string('0', 398), "0." + new string('0', 398) }; - yield return new object[] { "0." + new string('0', 1000), "0." + new string('0', 398) }; - yield return new object[] { "0." + new string('0', 1000) + "1234567", "0." + new string('0', 398) }; + yield return new object[] { "0." + new string('0', 398), "0E-398" }; + yield return new object[] { "0." + new string('0', 1000), "0E-398" }; + yield return new object[] { "0." + new string('0', 1000) + "1234567", "0E-398" }; yield return new object[] { "0e-2", "0.00" }; - yield return new object[] { "0e-398", "0." + new string('0', 398) }; - yield return new object[] { "0e-10000", "0." + new string('0', 398) }; - yield return new object[] { "0.123e-10000", "0." + new string('0', 398) }; + yield return new object[] { "0e-398", "0E-398" }; + yield return new object[] { "0e-10000", "0E-398" }; + yield return new object[] { "0.123e-10000", "0E-398" }; } public static IEnumerable Parse_Invalid_TestData() @@ -399,27 +399,39 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal64.Parse("0"), "G", defaultFormat, "0" }; yield return new object[] { Decimal64.Zero, "G", defaultFormat, "0" }; yield return new object[] { Decimal64.Parse("0.0000"), "G", defaultFormat, "0.0000" }; - yield return new object[] { Decimal64.Parse($"{long.MinValue}"), "G", defaultFormat, "-9223372036854776000" }; - yield return new object[] { Decimal64.Parse($"{long.MaxValue}"), "G", defaultFormat, "9223372036854776000" }; - yield return new object[] { Decimal64.Parse("3e384"), "G", defaultFormat, "3" + new string('0', 384) }; - yield return new object[] { Decimal64.Parse("-3e384"), "G", defaultFormat, "-3" + new string('0', 384) }; + yield return new object[] { Decimal64.Parse($"{long.MinValue}"), "G", defaultFormat, "-9.223372036854776E+18" }; + yield return new object[] { Decimal64.Parse($"{long.MaxValue}"), "G", defaultFormat, "9.223372036854776E+18" }; + yield return new object[] { Decimal64.Parse("3e384"), "G", defaultFormat, "3." + new string('0', 15) + "E+384" }; + yield return new object[] { Decimal64.Parse("-3e384"), "G", defaultFormat, "-3." + new string('0', 15) + "E+384" }; yield return new object[] { Decimal64.Parse("-4567e0"), "G", defaultFormat, "-4567" }; yield return new object[] { Decimal64.Parse("-4567891e-3"), "G", defaultFormat, "-4567.891" }; yield return new object[] { Decimal64.Parse("0e0"), "G", defaultFormat, "0" }; yield return new object[] { Decimal64.Parse("4567e0"), "G", defaultFormat, "4567" }; yield return new object[] { Decimal64.Parse("4567891e-3"), "G", defaultFormat, "4567.891" }; + // A positive quantum exponent has no fixed-point spelling, so scientific notation is required + yield return new object[] { Decimal64.Parse("1e7"), "G", defaultFormat, "1E+07" }; + yield return new object[] { Decimal64.Parse("10e6"), "G", defaultFormat, "1.0E+07" }; + yield return new object[] { Decimal64.Parse("0e2"), "G", defaultFormat, "0E+02" }; + yield return new object[] { Decimal64.Parse("-0e2"), "G", defaultFormat, "-0E+02" }; + yield return new object[] { Decimal64.Parse("0.0001"), "G", defaultFormat, "0.0001" }; + yield return new object[] { Decimal64.Parse("0.00001"), "G", defaultFormat, "1E-05" }; + yield return new object[] { Decimal64.Parse("0.000100"), "G", defaultFormat, "0.000100" }; + yield return new object[] { Decimal64.MaxValue, "G", defaultFormat, "9.999999999999999E+384" }; + yield return new object[] { Decimal64.MinValue, "G", defaultFormat, "-9.999999999999999E+384" }; + yield return new object[] { Decimal64.Epsilon, "G", defaultFormat, "1E-398" }; + yield return new object[] { Decimal64.Parse("2468e0"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal64.Parse("2467e0"), "[#-##-#]", defaultFormat, "[2-46-7]" }; - yield return new object[] { Decimal64.Parse("4e-399"), "G", defaultFormat, "0." + new string('0', 398) }; - yield return new object[] { Decimal64.Parse("5e-399"), "G", defaultFormat, "0." + new string('0', 398) }; - yield return new object[] { Decimal64.Parse("5.00000000000000000000000001e-399"), "G", defaultFormat, "0." + new string('0', 397) + "1" }; - yield return new object[] { Decimal64.Parse("6e-399"), "G", defaultFormat, "0." + new string('0', 397) + "1" }; - yield return new object[] { Decimal64.Parse("-4e-399"), "G", defaultFormat, "-0." + new string('0', 398) }; - yield return new object[] { Decimal64.Parse("-5e-399"), "G", defaultFormat, "-0." + new string('0', 398) }; - yield return new object[] { Decimal64.Parse("-5.00000000000000000000000001e-399"), "G", defaultFormat, "-0." + new string('0', 397) + "1" }; - yield return new object[] { Decimal64.Parse("-6e-399"), "G", defaultFormat, "-0." + new string('0', 397) + "1" }; + yield return new object[] { Decimal64.Parse("4e-399"), "G", defaultFormat, "0E-398" }; + yield return new object[] { Decimal64.Parse("5e-399"), "G", defaultFormat, "0E-398" }; + yield return new object[] { Decimal64.Parse("5.00000000000000000000000001e-399"), "G", defaultFormat, "1E-398" }; + yield return new object[] { Decimal64.Parse("6e-399"), "G", defaultFormat, "1E-398" }; + yield return new object[] { Decimal64.Parse("-4e-399"), "G", defaultFormat, "-0E-398" }; + yield return new object[] { Decimal64.Parse("-5e-399"), "G", defaultFormat, "-0E-398" }; + yield return new object[] { Decimal64.Parse("-5.00000000000000000000000001e-399"), "G", defaultFormat, "-1E-398" }; + yield return new object[] { Decimal64.Parse("-6e-399"), "G", defaultFormat, "-1E-398" }; } } @@ -459,6 +471,46 @@ private static void ToString(Decimal64 f, string format, IFormatProvider provide Assert.Equal(expected.Replace('E', 'e'), f.ToString(format.ToLowerInvariant(), provider)); } + public static IEnumerable ToString_Roundtrip_TestData() + { + yield return new object[] { "0" }; + yield return new object[] { "-0" }; + yield return new object[] { "0.00" }; + yield return new object[] { "0e2" }; + yield return new object[] { "0e369" }; + yield return new object[] { "0e-398" }; + yield return new object[] { "-0e-398" }; + yield return new object[] { "1" }; + yield return new object[] { "1.0" }; + yield return new object[] { "1.000000000000000" }; + yield return new object[] { "1e7" }; + yield return new object[] { "10e6" }; + yield return new object[] { "1000000000000000e1" }; + yield return new object[] { "-4567.891" }; + yield return new object[] { "0.0001" }; + yield return new object[] { "0.00001" }; + yield return new object[] { "0.000100" }; + yield return new object[] { "9223372036854775808" }; + yield return new object[] { "9999999999999999e369" }; + yield return new object[] { "-9999999999999999e369" }; + yield return new object[] { "1e-398" }; + yield return new object[] { "1234567890123456e-398" }; + } + + [Theory] + [MemberData(nameof(ToString_Roundtrip_TestData))] + public static void ToString_Roundtrips_And_PreservesQuantum(string value) + { + Decimal64 expected = Decimal64.Parse(value, CultureInfo.InvariantCulture); + + foreach (string format in new[] { null, "G", "g", "R", "r" }) + { + string formatted = expected.ToString(format, CultureInfo.InvariantCulture); + Decimal64 actual = Decimal64.Parse(formatted, CultureInfo.InvariantCulture); + Assert.Equal(Decimal64.EncodeDecimal(expected), Decimal64.EncodeDecimal(actual)); + } + } + [Theory] [MemberData(nameof(PositiveInfinity_NonCanonicalEncodings64_TestData))] [MemberData(nameof(NegativeInfinity_NonCanonicalEncodings64_TestData))] From 15b55a39e177b3f30def82e6ff40e4c7d3f24cdd Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 27 Jul 2026 08:19:00 -0700 Subject: [PATCH 2/7] Add test coverage for the general and roundtrip precision specifiers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../System.Runtime.Tests/System/Decimal128Tests.cs | 12 ++++++++++++ .../System.Runtime.Tests/System/Decimal32Tests.cs | 13 +++++++++++++ .../System.Runtime.Tests/System/Decimal64Tests.cs | 12 ++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs index 3d7c3ef698a1a1..9fac8e3bb0c623 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs @@ -418,6 +418,14 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal128.MinValue, "G", defaultFormat, "-9." + new string('9', 33) + "E+6144" }; yield return new object[] { Decimal128.Epsilon, "G", defaultFormat, "1E-6176" }; + // The general specifier honors a precision specifier, where-as the roundtrip specifier ignores it + yield return new object[] { Decimal128.Parse("1234"), "G3", defaultFormat, "1.23E+03" }; + yield return new object[] { Decimal128.Parse("999"), "G2", defaultFormat, "1E+03" }; + yield return new object[] { Decimal128.Parse("1." + new string('0', 33)), "G1", defaultFormat, "1" }; + yield return new object[] { Decimal128.MaxValue, "G1", defaultFormat, "1E+6145" }; + yield return new object[] { Decimal128.Parse("1234567890123456789012345678901234"), "R5", defaultFormat, "1234567890123456789012345678901234" }; + yield return new object[] { Decimal128.Parse("1234567890123456789012345678901234"), "G5", defaultFormat, "1.2346E+33" }; + yield return new object[] { Decimal128.Parse("2468"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal128.Parse("2467"), "[#-##-#]", defaultFormat, "[2-46-7]" }; @@ -507,6 +515,10 @@ public static void ToString_Roundtrips_And_PreservesQuantum(string value) Decimal128 actual = Decimal128.Parse(formatted, CultureInfo.InvariantCulture); Assert.Equal(Decimal128.EncodeDecimal(expected), Decimal128.EncodeDecimal(actual)); } + + // The roundtrip specifier ignores any precision specifier + Assert.Equal(expected.ToString("R", CultureInfo.InvariantCulture), expected.ToString("R1", CultureInfo.InvariantCulture)); + Assert.Equal(expected.ToString("r", CultureInfo.InvariantCulture), expected.ToString("r5", CultureInfo.InvariantCulture)); } [Theory] diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs index 8503f4b1b23066..efa654cea7b69c 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs @@ -417,6 +417,15 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal32.MinValue, "G", defaultFormat, "-9.999999E+96" }; yield return new object[] { Decimal32.Epsilon, "G", defaultFormat, "1E-101" }; + // The general specifier honors a precision specifier, where-as the roundtrip specifier ignores it + yield return new object[] { Decimal32.Parse("1234"), "G3", defaultFormat, "1.23E+03" }; + yield return new object[] { Decimal32.Parse("999"), "G2", defaultFormat, "1E+03" }; + yield return new object[] { Decimal32.Parse("1.000000"), "G1", defaultFormat, "1" }; + yield return new object[] { Decimal32.Parse("1000000e1"), "G3", defaultFormat, "1E+07" }; + yield return new object[] { Decimal32.MaxValue, "G1", defaultFormat, "1E+97" }; + yield return new object[] { Decimal32.Parse("1234567"), "R5", defaultFormat, "1234567" }; + yield return new object[] { Decimal32.Parse("1234567"), "G5", defaultFormat, "1.2346E+06" }; + yield return new object[] { Decimal32.Parse("2468"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal32.Parse("2467"), "[#-##-#]", defaultFormat, "[2-46-7]" }; @@ -505,6 +514,10 @@ public static void ToString_Roundtrips_And_PreservesQuantum(string value) Decimal32 actual = Decimal32.Parse(formatted, CultureInfo.InvariantCulture); Assert.Equal(Decimal32.EncodeDecimal(expected), Decimal32.EncodeDecimal(actual)); } + + // The roundtrip specifier ignores any precision specifier + Assert.Equal(expected.ToString("R", CultureInfo.InvariantCulture), expected.ToString("R1", CultureInfo.InvariantCulture)); + Assert.Equal(expected.ToString("r", CultureInfo.InvariantCulture), expected.ToString("r5", CultureInfo.InvariantCulture)); } [Theory] diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs index c0ac74acb9ba1e..ea79855e066e04 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs @@ -421,6 +421,14 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal64.MinValue, "G", defaultFormat, "-9.999999999999999E+384" }; yield return new object[] { Decimal64.Epsilon, "G", defaultFormat, "1E-398" }; + // The general specifier honors a precision specifier, where-as the roundtrip specifier ignores it + yield return new object[] { Decimal64.Parse("1234"), "G3", defaultFormat, "1.23E+03" }; + yield return new object[] { Decimal64.Parse("999"), "G2", defaultFormat, "1E+03" }; + yield return new object[] { Decimal64.Parse("1.000000000000000"), "G1", defaultFormat, "1" }; + yield return new object[] { Decimal64.MaxValue, "G1", defaultFormat, "1E+385" }; + yield return new object[] { Decimal64.Parse("1234567890123456"), "R5", defaultFormat, "1234567890123456" }; + yield return new object[] { Decimal64.Parse("1234567890123456"), "G5", defaultFormat, "1.2346E+15" }; + yield return new object[] { Decimal64.Parse("2468e0"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal64.Parse("2467e0"), "[#-##-#]", defaultFormat, "[2-46-7]" }; @@ -509,6 +517,10 @@ public static void ToString_Roundtrips_And_PreservesQuantum(string value) Decimal64 actual = Decimal64.Parse(formatted, CultureInfo.InvariantCulture); Assert.Equal(Decimal64.EncodeDecimal(expected), Decimal64.EncodeDecimal(actual)); } + + // The roundtrip specifier ignores any precision specifier + Assert.Equal(expected.ToString("R", CultureInfo.InvariantCulture), expected.ToString("R1", CultureInfo.InvariantCulture)); + Assert.Equal(expected.ToString("r", CultureInfo.InvariantCulture), expected.ToString("r5", CultureInfo.InvariantCulture)); } [Theory] From e864c38ea114033279bebd1d22dd538b3733b486 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 27 Jul 2026 13:11:53 -0700 Subject: [PATCH 3/7] Don't treat rounded-away trailing zeros as a positive quantum exponent RoundNumber strips trailing coefficient digits without adjusting Scale, so Scale - DigitsCount no longer reports the quantum exponent once a precision specifier has rounded the value. Compare against the requested precision in that case and recover the dropped digits as trailing zeros. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/System/Number.Formatting.cs | 26 ++++++++++++------- .../System/Decimal128Tests.cs | 10 ++++++- .../System/Decimal32Tests.cs | 10 ++++++- .../System/Decimal64Tests.cs | 10 ++++++- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs b/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs index c526632026a6b6..afd3069926c765 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs @@ -440,7 +440,9 @@ private static unsafe void FormatGeneralAndRoundTripDecimalIeee754(ref Va { Debug.Assert(number.Kind == NumberBufferKind.Decimal); - if ((nMaxDigits > 0) && (nMaxDigits < number.DigitsCount)) + bool rounded = (nMaxDigits > 0) && (nMaxDigits < number.DigitsCount); + + if (rounded) { RoundNumber(ref number, nMaxDigits, isCorrectlyRounded: false); } @@ -453,12 +455,17 @@ private static unsafe void FormatGeneralAndRoundTripDecimalIeee754(ref Va byte* dig = number.DigitsPtr; int digitCount = number.DigitsCount; - // `Scale` is the coefficient digit count plus the quantum exponent. A zero coefficient has no - // stored digits but still participates as the single digit `0` when computing the adjusted exponent. - int exponent = number.Scale - digitCount; - int adjustedExponent = (digitCount != 0) ? (number.Scale - 1) : exponent; + // `Scale` is the coefficient digit count plus the quantum exponent, so `Scale` exceeding the number + // of significant digits means the quantum exponent is positive. Rounding drops trailing coefficient + // digits without touching `Scale`, so the requested precision is what remains significant in that + // case; the dropped digits are recovered as trailing zeros below. + int significantDigits = rounded ? nMaxDigits : digitCount; + + // A zero coefficient has no stored digits but still participates as the single digit `0` when + // computing the adjusted exponent. + int adjustedExponent = (digitCount != 0) ? (number.Scale - 1) : number.Scale; - if ((exponent > 0) || (adjustedExponent < -4)) + if ((number.Scale > significantDigits) || (adjustedExponent < -4)) { vlb.Append(TChar.CastFrom((digitCount != 0) ? (char)dig[0] : '0')); @@ -477,13 +484,14 @@ private static unsafe void FormatGeneralAndRoundTripDecimalIeee754(ref Va } int integerDigits = number.Scale; - Debug.Assert(integerDigits <= digitCount); if (integerDigits > 0) { for (int i = 0; i < integerDigits; i++) { - vlb.Append(TChar.CastFrom((char)dig[i])); + // Rounding can leave fewer digits than the scale requires, in which case the remaining + // integer positions are trailing zeros of the rounded coefficient. + vlb.Append(TChar.CastFrom((i < digitCount) ? (char)dig[i] : '0')); } } else @@ -491,7 +499,7 @@ private static unsafe void FormatGeneralAndRoundTripDecimalIeee754(ref Va vlb.Append(TChar.CastFrom('0')); } - if (exponent < 0) + if (integerDigits < digitCount) { vlb.Append(info.NumberDecimalSeparatorTChar()); diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs index 9fac8e3bb0c623..31c4306405e3cb 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs @@ -418,7 +418,7 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal128.MinValue, "G", defaultFormat, "-9." + new string('9', 33) + "E+6144" }; yield return new object[] { Decimal128.Epsilon, "G", defaultFormat, "1E-6176" }; - // The general specifier honors a precision specifier, where-as the roundtrip specifier ignores it + // The general specifier honors a precision specifier, whereas the roundtrip specifier ignores it yield return new object[] { Decimal128.Parse("1234"), "G3", defaultFormat, "1.23E+03" }; yield return new object[] { Decimal128.Parse("999"), "G2", defaultFormat, "1E+03" }; yield return new object[] { Decimal128.Parse("1." + new string('0', 33)), "G1", defaultFormat, "1" }; @@ -426,6 +426,14 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal128.Parse("1234567890123456789012345678901234"), "R5", defaultFormat, "1234567890123456789012345678901234" }; yield return new object[] { Decimal128.Parse("1234567890123456789012345678901234"), "G5", defaultFormat, "1.2346E+33" }; + // Rounding drops trailing coefficient digits without changing the quantum exponent + yield return new object[] { Decimal128.Parse("10.00000"), "G2", defaultFormat, "10" }; + yield return new object[] { Decimal128.Parse("10.00000"), "G3", defaultFormat, "10" }; + yield return new object[] { Decimal128.Parse("1000.400"), "G4", defaultFormat, "1000" }; + yield return new object[] { Decimal128.Parse("1000.500"), "G3", defaultFormat, "1E+03" }; + yield return new object[] { Decimal128.Parse("100.0"), "G2", defaultFormat, "1E+02" }; + yield return new object[] { Decimal128.Parse("0.00012345"), "G2", defaultFormat, "0.00012" }; + yield return new object[] { Decimal128.Parse("2468"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal128.Parse("2467"), "[#-##-#]", defaultFormat, "[2-46-7]" }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs index efa654cea7b69c..bee8b3149fecfd 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs @@ -417,7 +417,7 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal32.MinValue, "G", defaultFormat, "-9.999999E+96" }; yield return new object[] { Decimal32.Epsilon, "G", defaultFormat, "1E-101" }; - // The general specifier honors a precision specifier, where-as the roundtrip specifier ignores it + // The general specifier honors a precision specifier, whereas the roundtrip specifier ignores it yield return new object[] { Decimal32.Parse("1234"), "G3", defaultFormat, "1.23E+03" }; yield return new object[] { Decimal32.Parse("999"), "G2", defaultFormat, "1E+03" }; yield return new object[] { Decimal32.Parse("1.000000"), "G1", defaultFormat, "1" }; @@ -426,6 +426,14 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal32.Parse("1234567"), "R5", defaultFormat, "1234567" }; yield return new object[] { Decimal32.Parse("1234567"), "G5", defaultFormat, "1.2346E+06" }; + // Rounding drops trailing coefficient digits without changing the quantum exponent + yield return new object[] { Decimal32.Parse("10.00000"), "G2", defaultFormat, "10" }; + yield return new object[] { Decimal32.Parse("10.00000"), "G3", defaultFormat, "10" }; + yield return new object[] { Decimal32.Parse("1000.400"), "G4", defaultFormat, "1000" }; + yield return new object[] { Decimal32.Parse("1000.500"), "G3", defaultFormat, "1E+03" }; + yield return new object[] { Decimal32.Parse("100.0"), "G2", defaultFormat, "1E+02" }; + yield return new object[] { Decimal32.Parse("0.00012345"), "G2", defaultFormat, "0.00012" }; + yield return new object[] { Decimal32.Parse("2468"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal32.Parse("2467"), "[#-##-#]", defaultFormat, "[2-46-7]" }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs index ea79855e066e04..2f7a0c630d649a 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs @@ -421,7 +421,7 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal64.MinValue, "G", defaultFormat, "-9.999999999999999E+384" }; yield return new object[] { Decimal64.Epsilon, "G", defaultFormat, "1E-398" }; - // The general specifier honors a precision specifier, where-as the roundtrip specifier ignores it + // The general specifier honors a precision specifier, whereas the roundtrip specifier ignores it yield return new object[] { Decimal64.Parse("1234"), "G3", defaultFormat, "1.23E+03" }; yield return new object[] { Decimal64.Parse("999"), "G2", defaultFormat, "1E+03" }; yield return new object[] { Decimal64.Parse("1.000000000000000"), "G1", defaultFormat, "1" }; @@ -429,6 +429,14 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal64.Parse("1234567890123456"), "R5", defaultFormat, "1234567890123456" }; yield return new object[] { Decimal64.Parse("1234567890123456"), "G5", defaultFormat, "1.2346E+15" }; + // Rounding drops trailing coefficient digits without changing the quantum exponent + yield return new object[] { Decimal64.Parse("10.00000"), "G2", defaultFormat, "10" }; + yield return new object[] { Decimal64.Parse("10.00000"), "G3", defaultFormat, "10" }; + yield return new object[] { Decimal64.Parse("1000.400"), "G4", defaultFormat, "1000" }; + yield return new object[] { Decimal64.Parse("1000.500"), "G3", defaultFormat, "1E+03" }; + yield return new object[] { Decimal64.Parse("100.0"), "G2", defaultFormat, "1E+02" }; + yield return new object[] { Decimal64.Parse("0.00012345"), "G2", defaultFormat, "0.00012" }; + yield return new object[] { Decimal64.Parse("2468e0"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal64.Parse("2467e0"), "[#-##-#]", defaultFormat, "[2-46-7]" }; From fce672cb18d92713bcadeed0b95ce723e847dd27 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 27 Jul 2026 13:38:20 -0700 Subject: [PATCH 4/7] Preserve the sign of zero when formatting Decimal32/64/128 The IEEE decimal types shared NumberBufferKind.Decimal with System.Decimal, which has no concept of negative zero, so RoundNumber and NumberToStringFormat cleared the sign for every specifier other than G and R. A signed zero is a distinct IEEE value, so give these types their own kind and keep the sign the way the binary floating-point types do. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Common/src/System/Number.Formatting.Common.cs | 4 ++-- src/libraries/Common/src/System/Number.NumberBuffer.cs | 8 +++++++- .../Common/src/System/Number.Parsing.Common.cs | 2 +- .../src/System/Number.Formatting.cs | 4 ++-- .../src/System/Number.Parsing.cs | 2 +- .../System.Runtime.Tests/System/Decimal128Tests.cs | 10 ++++++++++ .../System.Runtime.Tests/System/Decimal32Tests.cs | 10 ++++++++++ .../System.Runtime.Tests/System/Decimal64Tests.cs | 10 ++++++++++ 8 files changed, 43 insertions(+), 7 deletions(-) diff --git a/src/libraries/Common/src/System/Number.Formatting.Common.cs b/src/libraries/Common/src/System/Number.Formatting.Common.cs index b0556537ad62fd..caf9ed627acc2d 100644 --- a/src/libraries/Common/src/System/Number.Formatting.Common.cs +++ b/src/libraries/Common/src/System/Number.Formatting.Common.cs @@ -435,7 +435,7 @@ internal static unsafe void NumberToStringFormat(ref ValueListBuilder digits) public void CheckConsistency() { #if DEBUG - Debug.Assert(Kind is NumberBufferKind.Integer or NumberBufferKind.Decimal or NumberBufferKind.FloatingPoint); + Debug.Assert(Kind is NumberBufferKind.Integer or NumberBufferKind.Decimal or NumberBufferKind.FloatingPoint or NumberBufferKind.DecimalIeee754); Debug.Assert(Digits[0] != '0', "Leading zeros should never be stored in a Number"); int numDigits; @@ -127,6 +127,12 @@ internal enum NumberBufferKind : byte Integer = 1, Decimal = 2, FloatingPoint = 3, + + /// + /// An IEEE 754 decimal interchange format. This rounds like but, like + /// , has a signed zero that must survive formatting. + /// + DecimalIeee754 = 4, } } } diff --git a/src/libraries/Common/src/System/Number.Parsing.Common.cs b/src/libraries/Common/src/System/Number.Parsing.Common.cs index 585122e3eb876c..f3d71cb0c3bcd8 100644 --- a/src/libraries/Common/src/System/Number.Parsing.Common.cs +++ b/src/libraries/Common/src/System/Number.Parsing.Common.cs @@ -268,7 +268,7 @@ private static unsafe bool TryParseNumber(TChar* str, TChar* strEnd, Numb { if ((state & StateNonZero) == 0) { - if (number.Kind != NumberBufferKind.Decimal) + if (number.Kind is not (NumberBufferKind.Decimal or NumberBufferKind.DecimalIeee754)) { number.Scale = 0; } diff --git a/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs b/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs index afd3069926c765..2541286461ee5c 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs @@ -376,7 +376,7 @@ internal static string FormatDecimalIeee754(TValue value, stri char fmt = ParseFormatSpecifier(format, out int digits); byte* pDigits = stackalloc byte[TDecimal.BufferLength]; - NumberBuffer number = new NumberBuffer(NumberBufferKind.Decimal, pDigits, TDecimal.BufferLength); + NumberBuffer number = new NumberBuffer(NumberBufferKind.DecimalIeee754, pDigits, TDecimal.BufferLength); DecimalIeee754ToNumber(value, ref number); @@ -438,7 +438,7 @@ internal static bool TryFormatDecimalIeee754(TValue val private static unsafe void FormatGeneralAndRoundTripDecimalIeee754(ref ValueListBuilder vlb, ref NumberBuffer number, char expChar, int nMaxDigits, NumberFormatInfo info) where TChar : unmanaged, IUtfChar { - Debug.Assert(number.Kind == NumberBufferKind.Decimal); + Debug.Assert(number.Kind == NumberBufferKind.DecimalIeee754); bool rounded = (nMaxDigits > 0) && (nMaxDigits < number.DigitsCount); diff --git a/src/libraries/System.Private.CoreLib/src/System/Number.Parsing.cs b/src/libraries/System.Private.CoreLib/src/System/Number.Parsing.cs index a797d5f90ce2ad..5ecc71f0a76a49 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Number.Parsing.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Number.Parsing.cs @@ -994,7 +994,7 @@ internal static ParsingStatus TryParseDecimalIeee754(Re where TDecimal : unmanaged, IDecimalIeee754ParseAndFormatInfo where TValue : unmanaged, IBinaryInteger { - NumberBuffer number = new NumberBuffer(NumberBufferKind.Decimal, stackalloc byte[TDecimal.BufferLength]); + NumberBuffer number = new NumberBuffer(NumberBufferKind.DecimalIeee754, stackalloc byte[TDecimal.BufferLength]); result = default; if (!TryStringToNumber(value, styles, ref number, info, out elementsConsumed)) diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs index 31c4306405e3cb..c3f18c00dc973c 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs @@ -392,6 +392,16 @@ public static IEnumerable ToString_TestData() { yield return new object[] { Decimal128.Parse("-0"), "G", defaultFormat, "-0" }; yield return new object[] { Decimal128.NegativeZero, "G", defaultFormat, "-0" }; + + // A signed zero is a distinct IEEE value, so the sign survives every specifier, as it does for double + yield return new object[] { Decimal128.NegativeZero, "E2", defaultFormat, "-0.00E+000" }; + yield return new object[] { Decimal128.NegativeZero, "F2", defaultFormat, "-0.00" }; + yield return new object[] { Decimal128.NegativeZero, "N2", defaultFormat, "-0.00" }; + yield return new object[] { Decimal128.NegativeZero, "P0", defaultFormat, "-0 %" }; + yield return new object[] { Decimal128.NegativeZero, "C2", defaultFormat, "(\u00A40.00)" }; + yield return new object[] { Decimal128.NegativeZero, "0.00", defaultFormat, "-0.00" }; + yield return new object[] { Decimal128.Parse("-0e2"), "F2", defaultFormat, "-0.00" }; + yield return new object[] { Decimal128.Parse("-0.001"), "F2", defaultFormat, "-0.00" }; yield return new object[] { Decimal128.Parse("-0.0000"), "G", defaultFormat, "-0.0000" }; yield return new object[] { Decimal128.Parse("0"), "G", defaultFormat, "0" }; yield return new object[] { Decimal128.Zero, "G", defaultFormat, "0" }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs index bee8b3149fecfd..215b0da51b054f 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs @@ -389,6 +389,16 @@ public static IEnumerable ToString_TestData() { yield return new object[] { Decimal32.Parse("-0"), "G", defaultFormat, "-0" }; yield return new object[] { Decimal32.NegativeZero, "G", defaultFormat, "-0" }; + + // A signed zero is a distinct IEEE value, so the sign survives every specifier, as it does for double + yield return new object[] { Decimal32.NegativeZero, "E2", defaultFormat, "-0.00E+000" }; + yield return new object[] { Decimal32.NegativeZero, "F2", defaultFormat, "-0.00" }; + yield return new object[] { Decimal32.NegativeZero, "N2", defaultFormat, "-0.00" }; + yield return new object[] { Decimal32.NegativeZero, "P0", defaultFormat, "-0 %" }; + yield return new object[] { Decimal32.NegativeZero, "C2", defaultFormat, "(\u00A40.00)" }; + yield return new object[] { Decimal32.NegativeZero, "0.00", defaultFormat, "-0.00" }; + yield return new object[] { Decimal32.Parse("-0e2"), "F2", defaultFormat, "-0.00" }; + yield return new object[] { Decimal32.Parse("-0.001"), "F2", defaultFormat, "-0.00" }; yield return new object[] { Decimal32.Parse("-0.0000"), "G", defaultFormat, "-0.0000" }; yield return new object[] { Decimal32.Parse("0"), "G", defaultFormat, "0" }; yield return new object[] { Decimal32.Zero, "G", defaultFormat, "0" }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs index 2f7a0c630d649a..32a8afd4601246 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs @@ -395,6 +395,16 @@ public static IEnumerable ToString_TestData() { yield return new object[] { Decimal64.Parse("-0"), "G", defaultFormat, "-0" }; yield return new object[] { Decimal64.NegativeZero, "G", defaultFormat, "-0" }; + + // A signed zero is a distinct IEEE value, so the sign survives every specifier, as it does for double + yield return new object[] { Decimal64.NegativeZero, "E2", defaultFormat, "-0.00E+000" }; + yield return new object[] { Decimal64.NegativeZero, "F2", defaultFormat, "-0.00" }; + yield return new object[] { Decimal64.NegativeZero, "N2", defaultFormat, "-0.00" }; + yield return new object[] { Decimal64.NegativeZero, "P0", defaultFormat, "-0 %" }; + yield return new object[] { Decimal64.NegativeZero, "C2", defaultFormat, "(\u00A40.00)" }; + yield return new object[] { Decimal64.NegativeZero, "0.00", defaultFormat, "-0.00" }; + yield return new object[] { Decimal64.Parse("-0e2"), "F2", defaultFormat, "-0.00" }; + yield return new object[] { Decimal64.Parse("-0.001"), "F2", defaultFormat, "-0.00" }; yield return new object[] { Decimal64.Parse("-0.0000"), "G", defaultFormat, "-0.0000" }; yield return new object[] { Decimal64.Parse("0"), "G", defaultFormat, "0" }; yield return new object[] { Decimal64.Zero, "G", defaultFormat, "0" }; From 1e63dcee41afa1cd6349062ef3c54eee1a4dc00d Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 27 Jul 2026 14:17:34 -0700 Subject: [PATCH 5/7] Round ties to even when formatting Decimal32/64/128 IEEE 754 requires conversions to a decimal character sequence to be correctly rounded under the applicable rounding-direction attribute, which defaults to roundTiesToEven. These types were inheriting the half-away-from-zero rounding that System.Decimal uses for back-compat, which also made ToString disagree with Round, quantize, and every arithmetic operation on the same type. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/System/Number.Formatting.Common.cs | 24 +++++++++++++++++++ .../System/Decimal128Tests.cs | 20 ++++++++++++++++ .../System/Decimal32Tests.cs | 20 ++++++++++++++++ .../System/Decimal64Tests.cs | 20 ++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/src/libraries/Common/src/System/Number.Formatting.Common.cs b/src/libraries/Common/src/System/Number.Formatting.Common.cs index caf9ed627acc2d..e24c971efc1c93 100644 --- a/src/libraries/Common/src/System/Number.Formatting.Common.cs +++ b/src/libraries/Common/src/System/Number.Formatting.Common.cs @@ -1120,6 +1120,30 @@ static bool ShouldRoundUp(byte* dig, int i, NumberBufferKind numberKind, bool is return false; } + if (numberKind == NumberBufferKind.DecimalIeee754) + { + // The buffer holds the exact coefficient, so a '5' followed by nothing but zeros is a + // true tie rather than an artifact of a truncated expansion. IEEE 754 §5.12.1 requires + // the conversion to be correctly rounded under the applicable rounding-direction + // attribute, which is roundTiesToEven. + + if (digit != '5') + { + return digit > '5'; + } + + for (int j = i + 1; dig[j] != '\0'; j++) + { + if (dig[j] != '0') + { + return true; + } + } + + // A tie with no preceding digit rounds toward the implicit leading zero, which is even. + return (i > 0) && (((dig[i - 1] - '0') & 1) != 0); + } + // Values greater than or equal to 5 should round up, otherwise we round down. The IEEE // 754 spec actually dictates that ties (exactly 5) should round to the nearest even number // but that can have undesired behavior for custom numeric format strings. This probably diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs index c3f18c00dc973c..d3d6dd3287e19b 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs @@ -444,6 +444,26 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal128.Parse("100.0"), "G2", defaultFormat, "1E+02" }; yield return new object[] { Decimal128.Parse("0.00012345"), "G2", defaultFormat, "0.00012" }; + // Ties round to even, as IEEE 754 §5.12.1 requires of every conversion to a character + // sequence, including the custom formats where the binary types round away from zero + yield return new object[] { Decimal128.Parse("0.5"), "F0", defaultFormat, "0" }; + yield return new object[] { Decimal128.Parse("1.5"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal128.Parse("2.5"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal128.Parse("3.5"), "F0", defaultFormat, "4" }; + yield return new object[] { Decimal128.Parse("-0.5"), "F0", defaultFormat, "-0" }; + yield return new object[] { Decimal128.Parse("-2.5"), "F0", defaultFormat, "-2" }; + yield return new object[] { Decimal128.Parse("2.500"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal128.Parse("2.5001"), "F0", defaultFormat, "3" }; + yield return new object[] { Decimal128.Parse("2.4999"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal128.Parse("0.25"), "F1", defaultFormat, "0.2" }; + yield return new object[] { Decimal128.Parse("0.35"), "F1", defaultFormat, "0.4" }; + yield return new object[] { Decimal128.Parse("1.25"), "E1", defaultFormat, "1.2E+000" }; + yield return new object[] { Decimal128.Parse("12.5"), "N0", defaultFormat, "12" }; + yield return new object[] { Decimal128.Parse("12.5"), "G2", defaultFormat, "12" }; + yield return new object[] { Decimal128.Parse("1000.500"), "G4", defaultFormat, "1000" }; + yield return new object[] { Decimal128.Parse("0.25"), "0.0", defaultFormat, "0.2" }; + yield return new object[] { Decimal128.Parse("1.25"), "#.#", defaultFormat, "1.2" }; + yield return new object[] { Decimal128.Parse("2468"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal128.Parse("2467"), "[#-##-#]", defaultFormat, "[2-46-7]" }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs index 215b0da51b054f..71ba09ff753ab0 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs @@ -444,6 +444,26 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal32.Parse("100.0"), "G2", defaultFormat, "1E+02" }; yield return new object[] { Decimal32.Parse("0.00012345"), "G2", defaultFormat, "0.00012" }; + // Ties round to even, as IEEE 754 §5.12.1 requires of every conversion to a character + // sequence, including the custom formats where the binary types round away from zero + yield return new object[] { Decimal32.Parse("0.5"), "F0", defaultFormat, "0" }; + yield return new object[] { Decimal32.Parse("1.5"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal32.Parse("2.5"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal32.Parse("3.5"), "F0", defaultFormat, "4" }; + yield return new object[] { Decimal32.Parse("-0.5"), "F0", defaultFormat, "-0" }; + yield return new object[] { Decimal32.Parse("-2.5"), "F0", defaultFormat, "-2" }; + yield return new object[] { Decimal32.Parse("2.500"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal32.Parse("2.5001"), "F0", defaultFormat, "3" }; + yield return new object[] { Decimal32.Parse("2.4999"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal32.Parse("0.25"), "F1", defaultFormat, "0.2" }; + yield return new object[] { Decimal32.Parse("0.35"), "F1", defaultFormat, "0.4" }; + yield return new object[] { Decimal32.Parse("1.25"), "E1", defaultFormat, "1.2E+000" }; + yield return new object[] { Decimal32.Parse("12.5"), "N0", defaultFormat, "12" }; + yield return new object[] { Decimal32.Parse("12.5"), "G2", defaultFormat, "12" }; + yield return new object[] { Decimal32.Parse("1000.500"), "G4", defaultFormat, "1000" }; + yield return new object[] { Decimal32.Parse("0.25"), "0.0", defaultFormat, "0.2" }; + yield return new object[] { Decimal32.Parse("1.25"), "#.#", defaultFormat, "1.2" }; + yield return new object[] { Decimal32.Parse("2468"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal32.Parse("2467"), "[#-##-#]", defaultFormat, "[2-46-7]" }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs index 32a8afd4601246..5decb39021f848 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs @@ -447,6 +447,26 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal64.Parse("100.0"), "G2", defaultFormat, "1E+02" }; yield return new object[] { Decimal64.Parse("0.00012345"), "G2", defaultFormat, "0.00012" }; + // Ties round to even, as IEEE 754 §5.12.1 requires of every conversion to a character + // sequence, including the custom formats where the binary types round away from zero + yield return new object[] { Decimal64.Parse("0.5"), "F0", defaultFormat, "0" }; + yield return new object[] { Decimal64.Parse("1.5"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal64.Parse("2.5"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal64.Parse("3.5"), "F0", defaultFormat, "4" }; + yield return new object[] { Decimal64.Parse("-0.5"), "F0", defaultFormat, "-0" }; + yield return new object[] { Decimal64.Parse("-2.5"), "F0", defaultFormat, "-2" }; + yield return new object[] { Decimal64.Parse("2.500"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal64.Parse("2.5001"), "F0", defaultFormat, "3" }; + yield return new object[] { Decimal64.Parse("2.4999"), "F0", defaultFormat, "2" }; + yield return new object[] { Decimal64.Parse("0.25"), "F1", defaultFormat, "0.2" }; + yield return new object[] { Decimal64.Parse("0.35"), "F1", defaultFormat, "0.4" }; + yield return new object[] { Decimal64.Parse("1.25"), "E1", defaultFormat, "1.2E+000" }; + yield return new object[] { Decimal64.Parse("12.5"), "N0", defaultFormat, "12" }; + yield return new object[] { Decimal64.Parse("12.5"), "G2", defaultFormat, "12" }; + yield return new object[] { Decimal64.Parse("1000.500"), "G4", defaultFormat, "1000" }; + yield return new object[] { Decimal64.Parse("0.25"), "0.0", defaultFormat, "0.2" }; + yield return new object[] { Decimal64.Parse("1.25"), "#.#", defaultFormat, "1.2" }; + yield return new object[] { Decimal64.Parse("2468e0"), "N", defaultFormat, "2,468.00" }; yield return new object[] { Decimal64.Parse("2467e0"), "[#-##-#]", defaultFormat, "[2-46-7]" }; From 567d1ccb4712b0d8bb003e44fee99d4f7349fcea Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 27 Jul 2026 14:32:02 -0700 Subject: [PATCH 6/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/libraries/Common/src/System/Number.NumberBuffer.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/Common/src/System/Number.NumberBuffer.cs b/src/libraries/Common/src/System/Number.NumberBuffer.cs index 588e84e9ba0f33..c358721d943c85 100644 --- a/src/libraries/Common/src/System/Number.NumberBuffer.cs +++ b/src/libraries/Common/src/System/Number.NumberBuffer.cs @@ -129,8 +129,8 @@ internal enum NumberBufferKind : byte FloatingPoint = 3, /// - /// An IEEE 754 decimal interchange format. This rounds like but, like - /// , has a signed zero that must survive formatting. + /// An IEEE 754 decimal interchange format. This rounds like but, like + /// , has a signed zero that must survive formatting. /// DecimalIeee754 = 4, } From 3842267aae47de2c782bcd2c35239c11c37a9ff5 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 27 Jul 2026 17:04:01 -0700 Subject: [PATCH 7/7] Add upward tie coverage for the custom format path Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/libraries/Common/src/System/Number.NumberBuffer.cs | 6 ++++-- .../tests/System.Runtime.Tests/System/Decimal128Tests.cs | 2 ++ .../tests/System.Runtime.Tests/System/Decimal32Tests.cs | 2 ++ .../tests/System.Runtime.Tests/System/Decimal64Tests.cs | 2 ++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/libraries/Common/src/System/Number.NumberBuffer.cs b/src/libraries/Common/src/System/Number.NumberBuffer.cs index c358721d943c85..ffae72efa64aaa 100644 --- a/src/libraries/Common/src/System/Number.NumberBuffer.cs +++ b/src/libraries/Common/src/System/Number.NumberBuffer.cs @@ -129,8 +129,10 @@ internal enum NumberBufferKind : byte FloatingPoint = 3, /// - /// An IEEE 754 decimal interchange format. This rounds like but, like - /// , has a signed zero that must survive formatting. + /// An IEEE 754 decimal interchange format. Unlike the buffer + /// holds the exact coefficient rather than a pre-rounded shortest representation, so formatting must round + /// it; unlike that rounding is ties-to-even and a signed zero must + /// survive it. /// DecimalIeee754 = 4, } diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs index d3d6dd3287e19b..83441db6a4d1d1 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal128Tests.cs @@ -462,7 +462,9 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal128.Parse("12.5"), "G2", defaultFormat, "12" }; yield return new object[] { Decimal128.Parse("1000.500"), "G4", defaultFormat, "1000" }; yield return new object[] { Decimal128.Parse("0.25"), "0.0", defaultFormat, "0.2" }; + yield return new object[] { Decimal128.Parse("0.75"), "0.0", defaultFormat, "0.8" }; yield return new object[] { Decimal128.Parse("1.25"), "#.#", defaultFormat, "1.2" }; + yield return new object[] { Decimal128.Parse("1.75"), "#.#", defaultFormat, "1.8" }; yield return new object[] { Decimal128.Parse("2468"), "N", defaultFormat, "2,468.00" }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs index 71ba09ff753ab0..595ff39be865c1 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal32Tests.cs @@ -462,7 +462,9 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal32.Parse("12.5"), "G2", defaultFormat, "12" }; yield return new object[] { Decimal32.Parse("1000.500"), "G4", defaultFormat, "1000" }; yield return new object[] { Decimal32.Parse("0.25"), "0.0", defaultFormat, "0.2" }; + yield return new object[] { Decimal32.Parse("0.75"), "0.0", defaultFormat, "0.8" }; yield return new object[] { Decimal32.Parse("1.25"), "#.#", defaultFormat, "1.2" }; + yield return new object[] { Decimal32.Parse("1.75"), "#.#", defaultFormat, "1.8" }; yield return new object[] { Decimal32.Parse("2468"), "N", defaultFormat, "2,468.00" }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs index 5decb39021f848..be4774fd00dff4 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Decimal64Tests.cs @@ -465,7 +465,9 @@ public static IEnumerable ToString_TestData() yield return new object[] { Decimal64.Parse("12.5"), "G2", defaultFormat, "12" }; yield return new object[] { Decimal64.Parse("1000.500"), "G4", defaultFormat, "1000" }; yield return new object[] { Decimal64.Parse("0.25"), "0.0", defaultFormat, "0.2" }; + yield return new object[] { Decimal64.Parse("0.75"), "0.0", defaultFormat, "0.8" }; yield return new object[] { Decimal64.Parse("1.25"), "#.#", defaultFormat, "1.2" }; + yield return new object[] { Decimal64.Parse("1.75"), "#.#", defaultFormat, "1.8" }; yield return new object[] { Decimal64.Parse("2468e0"), "N", defaultFormat, "2,468.00" };