Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions SignificantNumber.Test/SignificantNumberFormatTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace SignificantNumber.Test;

using System.Globalization;
using ktsu.SignificantNumber;

/// <summary>
/// Covers <see cref="SignificantNumber.ToString(string?, IFormatProvider?)"/> and
/// <see cref="SignificantNumber.TryFormat"/>, which must write every format <see cref="decimal"/> writes.
/// </summary>
[TestClass]
public class SignificantNumberFormatTests
{
private static readonly CultureInfo CommaDecimalCulture = CreateCommaDecimalCulture();

/// <summary>
/// Builds a culture that writes 1.234,5, like de-DE, without depending on the host's culture data.
/// </summary>
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<char> 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<char> 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;
}
}
}
91 changes: 87 additions & 4 deletions SignificantNumber/SignificantNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@
/// <exception cref="DivideByZeroException">Thrown when the current number is zero and <paramref name="power"/> is negative.</exception>
/// <exception cref="ArgumentException">Thrown when the current number is negative and <paramref name="power"/> is not an integer, as the result is not a real number.</exception>
/// <exception cref="OverflowException">Thrown when the result's power of ten is too large or too small to represent.</exception>
public SignificantNumber Pow(PreciseNumber power)

Check warning on line 592 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

Check warning on line 592 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

Check warning on line 592 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

Check warning on line 592 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.

Check warning on line 592 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
{
if (Equal(power, Zero))
{
Expand Down Expand Up @@ -714,7 +714,7 @@
double magnitude = Math.Abs(exponent);
int lostDigits = !double.IsFinite(magnitude)
? DoubleSignificantDigits
: magnitude > 1 ? (int)Math.Ceiling(Math.Log10(magnitude)) : 0;

Check warning on line 717 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 717 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 717 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 717 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

Check warning on line 717 in SignificantNumber/SignificantNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Extract this nested ternary operation into an independent statement.

return int.Max(1, int.Min(significantDigits, DoubleSignificantDigits - lostDigits));
}
Expand Down Expand Up @@ -1004,14 +1004,97 @@
/// </summary>
/// <param name="format">The format to use.</param>
/// <returns>The string representation of the number.</returns>
public string ToString(string format) => PreciseNumber.ToString(Value, format, null);
/// <remarks><inheritdoc cref="ToString(string?, IFormatProvider?)" path="/remarks"/></remarks>
public string ToString(string format) => ToString(format, null);

/// <inheritdoc/>
public string ToString(string? format, IFormatProvider? formatProvider) => PreciseNumber.ToString(Value, format, formatProvider);
/// <remarks>
/// 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 <c>0.00</c>, is written the way <see cref="decimal"/> writes the same value, or the way
/// <see cref="double"/> does when the value is outside the range or precision of <see cref="decimal"/>.
/// </remarks>
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<double>().ToString(format, formatProvider);
}

/// <inheritdoc/>
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider) =>
Value.TryFormat(destination, out charsWritten, format, provider);
/// <remarks><inheritdoc cref="ToString(string?, IFormatProvider?)" path="/remarks"/></remarks>
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> 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<double>().TryFormat(destination, out charsWritten, format, provider);
}

/// <summary>
/// Determines whether a format is one that <see cref="PreciseNumber"/> writes from the exact value: empty, or
/// G, R, F, N or E, in either case, optionally followed by a precision.
/// </summary>
/// <param name="format">The format to check.</param>
/// <returns><see langword="true"/> if <see cref="PreciseNumber"/> handles <paramref name="format"/>; otherwise, <see langword="false"/>.</returns>
private static bool IsExactFormat(ReadOnlySpan<char> 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;
}

/// <summary>
/// Converts the current instance to a <see cref="decimal"/> when that loses nothing.
/// </summary>
/// <param name="exact">The value as a <see cref="decimal"/>, when the method returns <see langword="true"/>.</param>
/// <returns><see langword="true"/> if <see cref="decimal"/> holds the value exactly; otherwise, <see langword="false"/>.</returns>
private bool TryToExactDecimal(out decimal exact)
{
if (PreciseNumber.Abs(Value) > DecimalMaxValue)
{
exact = default;
return false;
}

exact = Value.To<decimal>();
return exact.ToPreciseNumber() == Value;
}

/// <summary>
/// The largest <see cref="decimal"/>, as a <see cref="PreciseNumber"/>.
/// </summary>
private static readonly PreciseNumber DecimalMaxValue = decimal.MaxValue.ToPreciseNumber();

/// <summary>
/// Determines whether the specified <see cref="SignificantNumber"/> is canonical.
Expand Down
Loading