From 358cad8107867a30a1009c6f9a4dd8bce6e7d714 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:33:32 +0000 Subject: [PATCH 1/2] Write C, P and custom format strings the way decimal does [patch] The ktsu.PreciseNumber 2.6.11 bump made G, R, F, N and E work, but C, P and custom patterns such as "0.00" and "#,##0.0" still threw FormatException from ToString, TryFormat and string interpolation. Keep G, R, F, N and E on PreciseNumber, which writes them from the exact value. Write any other format through decimal when it holds the value exactly, and through double otherwise, so a format is refused only when decimal or double would refuse it too. TryFormat returns false when the destination is too small. Fixes ktsu-dev/SignificantNumber#126 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW6Hi85XX4Q7wr44mWe --- .../SignificantNumberFormatTests.cs | 138 ++++++++++++++++++ SignificantNumber/SignificantNumber.cs | 73 ++++++++- 2 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 SignificantNumber.Test/SignificantNumberFormatTests.cs diff --git a/SignificantNumber.Test/SignificantNumberFormatTests.cs b/SignificantNumber.Test/SignificantNumberFormatTests.cs new file mode 100644 index 0000000..b2896c4 --- /dev/null +++ b/SignificantNumber.Test/SignificantNumberFormatTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace SignificantNumber.Test; + +using System.Globalization; +using ktsu.SignificantNumber; + +/// +/// Covers and +/// , which must write every format writes. +/// +[TestClass] +public class SignificantNumberFormatTests +{ + private static readonly CultureInfo CommaDecimalCulture = CreateCommaDecimalCulture(); + + /// + /// Builds a culture that writes 1.234,5, like de-DE, without depending on the host's culture data. + /// + private static CultureInfo CreateCommaDecimalCulture() + { + CultureInfo culture = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + NumberFormatInfo format = culture.NumberFormat; + format.NumberDecimalSeparator = ","; + format.NumberGroupSeparator = "."; + format.CurrencyDecimalSeparator = ","; + format.CurrencyGroupSeparator = "."; + format.PercentDecimalSeparator = ","; + format.PercentGroupSeparator = "."; + return culture; + } + + private static CultureInfo Culture(bool commaDecimal) => commaDecimal ? CommaDecimalCulture : CultureInfo.InvariantCulture; + + private static SignificantNumber Parse(string text) => + SignificantNumber.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture); + + [TestMethod] + [DataRow("1234.5", "F2", false)] + [DataRow("1234.5", "N2", false)] + [DataRow("1234.5", "E2", false)] + [DataRow("1234.5", "C", false)] + [DataRow("1234.5", "C0", false)] + [DataRow("1234.5", "P", false)] + [DataRow("1234.5", "P1", false)] + [DataRow("1234.5", "0.00", false)] + [DataRow("1234.5", "#,##0.0", false)] + [DataRow("1234.5", "F2", true)] + [DataRow("1234.5", "N2", true)] + [DataRow("1234.5", "E2", true)] + [DataRow("1234.5", "C", true)] + [DataRow("1234.5", "P1", true)] + [DataRow("1234.5", "0.00", true)] + [DataRow("1234.5", "#,##0.0", true)] + [DataRow("-0.00125", "C", false)] + [DataRow("-0.00125", "P", false)] + [DataRow("-0.00125", "0.0000", true)] + [DataRow("0", "C", false)] + [DataRow("0", "P", true)] + [DataRow("100000000000000000000", "C", false)] + [DataRow("100000000000000000000", "#,##0.0", true)] + public void ToString_Format_MatchesDecimal(string value, string format, bool commaDecimal) + { + CultureInfo culture = Culture(commaDecimal); + string expected = decimal.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture).ToString(format, culture); + + Assert.AreEqual(expected, Parse(value).ToString(format, culture)); + } + + [TestMethod] + [DataRow("1234.5", "C", false)] + [DataRow("1234.5", "P1", true)] + [DataRow("1234.5", "0.00", true)] + [DataRow("1234.5", "N2", false)] + public void TryFormat_Format_MatchesDecimal(string value, string format, bool commaDecimal) + { + CultureInfo culture = Culture(commaDecimal); + string expected = decimal.Parse(value, NumberStyles.Float, CultureInfo.InvariantCulture).ToString(format, culture); + Span destination = stackalloc char[64]; + + Assert.IsTrue(Parse(value).TryFormat(destination, out int charsWritten, format, culture)); + Assert.AreEqual(expected, destination[..charsWritten].ToString()); + } + + [TestMethod] + [DataRow("F2")] + [DataRow("N2")] + [DataRow("C")] + [DataRow("0.00")] + public void TryFormat_DestinationTooSmall_ReturnsFalse(string format) + { + Span destination = stackalloc char[2]; + + Assert.IsFalse(Parse("1234.5").TryFormat(destination, out _, format, CultureInfo.InvariantCulture)); + } + + [TestMethod] + public void Interpolation_WithFormatSpecifier_UsesTheFormat() + { + SignificantNumber value = Parse("1234.5"); + + Assert.AreEqual("1234.5", string.Create(CultureInfo.InvariantCulture, $"{value:F1}")); + Assert.AreEqual("1,234.50", string.Format(CultureInfo.InvariantCulture, "{0:N2}", value)); + Assert.AreEqual("1234.50", string.Create(CultureInfo.InvariantCulture, $"{value:0.00}")); + Assert.AreEqual("ยค1,234.50", string.Format(CultureInfo.InvariantCulture, "{0:C}", value)); + } + + [TestMethod] + public void ToString_CustomFormat_OutsideDecimalRange_FormatsLikeDouble() + { + SignificantNumber value = Parse("1E+30"); + + Assert.AreEqual(1e30.ToString("0.###E+0", CultureInfo.InvariantCulture), value.ToString("0.###E+0", CultureInfo.InvariantCulture)); + } + + [TestMethod] + public void ToString_CustomFormat_BeyondDecimalPrecision_FormatsLikeDouble() + { + SignificantNumber value = Parse("1E-40"); + + Assert.AreEqual(1e-40.ToString("0.##E+0", CultureInfo.InvariantCulture), value.ToString("0.##E+0", CultureInfo.InvariantCulture)); + } + + [TestMethod] + public void ToString_FormatWithoutProvider_UsesCurrentCulture() + { + CultureInfo original = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = CommaDecimalCulture; + Assert.AreEqual("1234,50", Parse("1234.5").ToString("0.00")); + } + finally + { + CultureInfo.CurrentCulture = original; + } + } +} diff --git a/SignificantNumber/SignificantNumber.cs b/SignificantNumber/SignificantNumber.cs index 9179e20..cf1db31 100644 --- a/SignificantNumber/SignificantNumber.cs +++ b/SignificantNumber/SignificantNumber.cs @@ -1004,14 +1004,81 @@ public TOutput To() /// /// The format to use. /// The string representation of the number. - public string ToString(string format) => PreciseNumber.ToString(Value, format, null); + /// + public string ToString(string format) => ToString(format, null); /// - public string ToString(string? format, IFormatProvider? formatProvider) => PreciseNumber.ToString(Value, format, formatProvider); + /// + /// The standard formats G, R, F, N and E are written from the exact value. Any other format, such as C, P or a + /// custom pattern like 0.00, is written the way writes the same value, or the way + /// does when the value is outside the range or precision of . + /// + public string ToString(string? format, IFormatProvider? formatProvider) => + IsExactFormat(format) + ? PreciseNumber.ToString(Value, format, formatProvider) + : TryToExactDecimal(out decimal exact) + ? exact.ToString(format, formatProvider) + : Value.To().ToString(format, formatProvider); /// + /// public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => - Value.TryFormat(destination, out charsWritten, format, provider); + IsExactFormat(format) + ? Value.TryFormat(destination, out charsWritten, format, provider) + : TryToExactDecimal(out decimal exact) + ? exact.TryFormat(destination, out charsWritten, format, provider) + : Value.To().TryFormat(destination, out charsWritten, format, provider); + + /// + /// Determines whether a format is one that writes from the exact value: empty, or + /// G, R, F, N or E, in either case, optionally followed by a precision. + /// + /// The format to check. + /// if handles ; otherwise, . + private static bool IsExactFormat(ReadOnlySpan format) + { + if (format.IsEmpty) + { + return true; + } + + if (char.ToUpperInvariant(format[0]) is not ('G' or 'R' or 'F' or 'N' or 'E')) + { + return false; + } + + foreach (char digit in format[1..]) + { + if (!char.IsAsciiDigit(digit)) + { + return false; + } + } + + return true; + } + + /// + /// Converts the current instance to a when that loses nothing. + /// + /// The value as a , when the method returns . + /// if holds the value exactly; otherwise, . + private bool TryToExactDecimal(out decimal exact) + { + if (PreciseNumber.Abs(Value) > DecimalMaxValue) + { + exact = default; + return false; + } + + exact = Value.To(); + return exact.ToPreciseNumber() == Value; + } + + /// + /// The largest , as a . + /// + private static readonly PreciseNumber DecimalMaxValue = decimal.MaxValue.ToPreciseNumber(); /// /// Determines whether the specified is canonical. From 304ca844e5806e80cd12e4ce7bdeec5e4a39746b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 13:41:38 +0000 Subject: [PATCH 2/2] Replace the nested ternaries in ToString and TryFormat with if statements, per SonarCloud Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW6Hi85XX4Q7wr44mWe --- SignificantNumber/SignificantNumber.cs | 40 ++++++++++++++++++-------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/SignificantNumber/SignificantNumber.cs b/SignificantNumber/SignificantNumber.cs index cf1db31..7509b7d 100644 --- a/SignificantNumber/SignificantNumber.cs +++ b/SignificantNumber/SignificantNumber.cs @@ -1013,21 +1013,37 @@ public TOutput To() /// custom pattern like 0.00, is written the way writes the same value, or the way /// does when the value is outside the range or precision of . /// - public string ToString(string? format, IFormatProvider? formatProvider) => - IsExactFormat(format) - ? PreciseNumber.ToString(Value, format, formatProvider) - : TryToExactDecimal(out decimal exact) - ? exact.ToString(format, formatProvider) - : Value.To().ToString(format, formatProvider); + public string ToString(string? format, IFormatProvider? formatProvider) + { + if (IsExactFormat(format)) + { + return PreciseNumber.ToString(Value, format, formatProvider); + } + + if (TryToExactDecimal(out decimal exact)) + { + return exact.ToString(format, formatProvider); + } + + return Value.To().ToString(format, formatProvider); + } /// /// - public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => - IsExactFormat(format) - ? Value.TryFormat(destination, out charsWritten, format, provider) - : TryToExactDecimal(out decimal exact) - ? exact.TryFormat(destination, out charsWritten, format, provider) - : Value.To().TryFormat(destination, out charsWritten, format, provider); + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) + { + if (IsExactFormat(format)) + { + return Value.TryFormat(destination, out charsWritten, format, provider); + } + + if (TryToExactDecimal(out decimal exact)) + { + return exact.TryFormat(destination, out charsWritten, format, provider); + } + + return Value.To().TryFormat(destination, out charsWritten, format, provider); + } /// /// Determines whether a format is one that writes from the exact value: empty, or