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..7509b7d 100644 --- a/SignificantNumber/SignificantNumber.cs +++ b/SignificantNumber/SignificantNumber.cs @@ -1004,14 +1004,97 @@ 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) + { + 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) => - Value.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 + /// 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.