251 lines
12 KiB
C#
251 lines
12 KiB
C#
using System.Globalization;
|
|
|
|
namespace Just.PreciseMath.Tests;
|
|
|
|
public class DoubleDoubleFormattingTests
|
|
{
|
|
[Theory]
|
|
[InlineData(1.25, 0.0, "1.25")]
|
|
[InlineData(-1.25, 0.0, "-1.25")]
|
|
[InlineData(1000.0, 0.0, "1000")]
|
|
[InlineData(1.0, 5.551115123125783e-17, "1.000000000000000055511151231257827021181583404541015625")]
|
|
[InlineData(9007199254740992.0, 1.0, "9007199254740993")]
|
|
public void ExactFormattingPreservesTheCompleteDyadicValue(double high, double low, string expected)
|
|
{
|
|
// Exact binary fractions: the nonzero fractional low is 2^-54.
|
|
DoubleDouble value = DoubleDouble.FromComponents(high, low);
|
|
value.ToStringExact().ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExactFormattingUsesInvariantSpecialSymbolsAndPreservesZeroSigns()
|
|
{
|
|
CultureInfo original = CultureInfo.CurrentCulture;
|
|
try
|
|
{
|
|
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
|
|
new DoubleDouble(-1.25).ToStringExact().ShouldBe("-1.25");
|
|
DoubleDouble[] values = [DoubleDouble.Zero, DoubleDouble.NegativeZero,
|
|
DoubleDouble.NaN, DoubleDouble.PositiveInfinity, DoubleDouble.NegativeInfinity];
|
|
string[] expected = ["0", "-0", "NaN", "Infinity", "-Infinity"];
|
|
for (int i = 0; i < values.Length; i++)
|
|
{
|
|
string text = values[i].ToStringExact();
|
|
text.ShouldBe(expected[i]);
|
|
DoubleDouble parsed = DoubleDouble.Parse(text, CultureInfo.InvariantCulture);
|
|
BitConverter.DoubleToInt64Bits(parsed.High).ShouldBe(BitConverter.DoubleToInt64Bits(values[i].High));
|
|
BitConverter.DoubleToInt64Bits(parsed.Low).ShouldBe(0L);
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
CultureInfo.CurrentCulture = original;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void ExactFormattingPreservesSparsePairsBeyondTheStandardPrecisionLimit()
|
|
{
|
|
DoubleDouble[] values = [new(double.Epsilon), new(double.MaxValue), DoubleDouble.Pi,
|
|
DoubleDouble.FromComponents(1.0, double.Epsilon),
|
|
DoubleDouble.FromComponents(1.0, -double.Epsilon),
|
|
DoubleDouble.FromComponents(double.MaxValue, double.Epsilon),
|
|
DoubleDouble.FromComponents(double.MaxValue, -double.Epsilon),
|
|
DoubleDouble.FromComponents(double.MaxValue, Math.BitDecrement(Math.ScaleB(1.0, 970)))];
|
|
foreach (DoubleDouble value in values)
|
|
{
|
|
AssertExactText(value);
|
|
AssertExactText(-value);
|
|
}
|
|
DoubleDouble.FromComponents(1.0, double.Epsilon).ToStringExact().Length.ShouldBeGreaterThan(999);
|
|
}
|
|
|
|
[Fact]
|
|
public void ExactFormattingMatchesIndependentIntegerValuesAcrossEveryExponent()
|
|
{
|
|
Random random = new(271828);
|
|
for (int exponent = -1074; exponent <= 1023; exponent++)
|
|
{
|
|
double high = Math.ScaleB(1.0 + random.NextDouble(), exponent);
|
|
double denseLow = Math.ScaleB(random.NextDouble(), exponent - 53);
|
|
foreach (double low in new[] { 0.0, denseLow, -denseLow, double.Epsilon, -double.Epsilon })
|
|
{
|
|
DoubleDouble value = DoubleDouble.FromComponents(high, low);
|
|
AssertExactText(value);
|
|
AssertExactText(-value);
|
|
}
|
|
}
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("G0")]
|
|
[InlineData("g0")]
|
|
public void ZeroGeneralPrecisionUses32SignificantDigits(string format)
|
|
{
|
|
// Exact dyadic 1 + 2^-54, rounded in decimal to 32 significant digits.
|
|
DoubleDouble value = DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54));
|
|
const string expected = "1.0000000000000000555111512312578";
|
|
value.ToString(format, CultureInfo.InvariantCulture).ShouldBe(expected);
|
|
((IFormattable)value).ToString(format, CultureInfo.InvariantCulture).ShouldBe(expected);
|
|
Span<char> destination = stackalloc char[64];
|
|
value.TryFormat(destination, out int written, format, CultureInfo.InvariantCulture).ShouldBeTrue();
|
|
destination[..written].ToString().ShouldBe(expected);
|
|
|
|
// A zero residual lets the independent BCL G32 formatter supply the oracle,
|
|
// including the scientific-notation threshold and exponent letter case.
|
|
string referenceFormat = format[0] == 'G' ? "G32" : "g32";
|
|
foreach (double scalar in new double[] { 1e31, 1e32, 1e-5, -0.0, double.Epsilon, double.MaxValue })
|
|
{
|
|
new DoubleDouble(scalar).ToString(format, CultureInfo.InvariantCulture)
|
|
.ShouldBe(scalar.ToString(referenceFormat, CultureInfo.InvariantCulture));
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void GeneralFormattingTrimsOnlyFractionalZerosAtMaximumPrecision()
|
|
{
|
|
NumberFormatInfo provider = new() { NumberDecimalSeparator = "::" };
|
|
new DoubleDouble(1.25).ToString("G999", provider).ShouldBe("1::25");
|
|
new DoubleDouble(1000.0).ToString("G999", provider).ShouldBe("1000");
|
|
new DoubleDouble(-0.0).ToString("G999", provider).ShouldBe("-0");
|
|
new DoubleDouble(999.5).ToString("G3", provider).ShouldBe("1E+03");
|
|
}
|
|
|
|
[Fact]
|
|
public void GeneralFormattingRetainsLowComponentDigits()
|
|
{
|
|
DoubleDouble value = DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54));
|
|
value.ToString("G32", CultureInfo.InvariantCulture).ShouldBe("1.0000000000000000555111512312578");
|
|
value.ToString("G", CultureInfo.InvariantCulture).ShouldBe("1.0000000000000000555111512312578");
|
|
value.ToString("", CultureInfo.InvariantCulture).ShouldBe("1.0000000000000000555111512312578");
|
|
((IFormattable)value).ToString(null, CultureInfo.InvariantCulture).ShouldBe("1.0000000000000000555111512312578");
|
|
new DoubleDouble(100000.0).ToString("G5", CultureInfo.InvariantCulture).ShouldBe("1E+05");
|
|
new DoubleDouble(0.00001m).ToString("g3", CultureInfo.InvariantCulture).ShouldBe("1e-05");
|
|
new DoubleDouble(0.0001m).ToString("G3", CultureInfo.InvariantCulture).ShouldBe("0.0001");
|
|
new DoubleDouble(999.5).ToString("G3", CultureInfo.InvariantCulture).ShouldBe("1E+03");
|
|
}
|
|
|
|
[Fact]
|
|
public void FormattingDoesNotRouteThroughDecimalOrRestrictExponentRange()
|
|
{
|
|
// Review-1 §6 verified the former decimal-based formatter threw for 1e100,
|
|
// printed "0" for 1e-100, threw for NaN/infinity, and emitted for pi digits
|
|
// already wrong at binary64 precision. Expected digits are exact references:
|
|
// the Pi pair rounded to 32 significant digits, ties to even
|
|
// (Python Fraction/Decimal at precision 120), and the exact binary64 values of
|
|
// the powers of ten.
|
|
DoubleDouble.Pi.ToString("G32", CultureInfo.InvariantCulture).ShouldBe("3.1415926535897932384626433832795");
|
|
new DoubleDouble(1e100).ToString("G", CultureInfo.InvariantCulture).ShouldBe("1.0000000000000000159028911097599E+100");
|
|
new DoubleDouble(1e-100).ToString("G", CultureInfo.InvariantCulture).ShouldBe("1.0000000000000000199918998026029E-100");
|
|
// The binary64 values are exactly representable as sums with a zero residual,
|
|
// so the G32 text must agree with the BCL formatter for the same value.
|
|
new DoubleDouble(1e100).ToString("G32", CultureInfo.InvariantCulture)
|
|
.ShouldBe((1e100).ToString("G32", CultureInfo.InvariantCulture));
|
|
new DoubleDouble(1e-100).ToString("G32", CultureInfo.InvariantCulture)
|
|
.ShouldBe((1e-100).ToString("G32", CultureInfo.InvariantCulture));
|
|
DoubleDouble.NaN.ToString(CultureInfo.InvariantCulture).ShouldBe("NaN");
|
|
new DoubleDouble(double.PositiveInfinity).ToString(CultureInfo.InvariantCulture).ShouldBe("Infinity");
|
|
new DoubleDouble(double.NegativeInfinity).ToString(CultureInfo.InvariantCulture).ShouldBe("-Infinity");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(2.5, "F0", "2")]
|
|
[InlineData(3.5, "F0", "4")]
|
|
[InlineData(-2.5, "F0", "-2")]
|
|
[InlineData(1.25, "F1", "1.2")]
|
|
[InlineData(9.5, "E0", "1E+001")]
|
|
[InlineData(0.125, "e2", "1.25e-001")]
|
|
[InlineData(0.0, "E2", "0.00E+000")]
|
|
[InlineData(-0.0, "F2", "-0.00")]
|
|
public void FixedAndExponentialRoundToEven(double value, string format, string expected)
|
|
{
|
|
new DoubleDouble(value).ToString(format, CultureInfo.InvariantCulture).ShouldBe(expected);
|
|
}
|
|
|
|
[Fact]
|
|
public void FormattingCoversFullBinary64Range()
|
|
{
|
|
new DoubleDouble(double.MaxValue).ToString("E5", CultureInfo.InvariantCulture).ShouldBe("1.79769E+308");
|
|
new DoubleDouble(double.Epsilon).ToString("G6", CultureInfo.InvariantCulture).ShouldBe("4.94066E-324");
|
|
DoubleDouble.FromComponents(1.0, double.Epsilon).ToString("F324", CultureInfo.InvariantCulture)
|
|
.ShouldBe("1." + new string('0', 323) + "5");
|
|
DoubleDouble.FromComponents(1.0, Math.ScaleB(1.0, -54)).ToString("F30", CultureInfo.InvariantCulture)
|
|
.ShouldBe("1.000000000000000055511151231258");
|
|
DoubleDouble.FromComponents(2.5, double.Epsilon).ToString("F0", CultureInfo.InvariantCulture).ShouldBe("3");
|
|
}
|
|
|
|
[Fact]
|
|
[System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1305:Specify IFormatProvider", Justification = "Tests intentionally exercise the current-culture overloads and derive their separator from CurrentCulture.")]
|
|
public void FormattingUsesProviderForSeparatorsSignsAndSpecialValues()
|
|
{
|
|
NumberFormatInfo provider = new()
|
|
{
|
|
NumberDecimalSeparator = ",",
|
|
NegativeSign = "minus",
|
|
PositiveSign = "plus",
|
|
NumberDecimalDigits = 3,
|
|
NaNSymbol = "not-number",
|
|
PositiveInfinitySymbol = "infinite",
|
|
NegativeInfinitySymbol = "minus-infinite"
|
|
};
|
|
new DoubleDouble(-1.25).ToString("F", provider).ShouldBe("minus1,250");
|
|
new DoubleDouble(125.0).ToString("E2", provider).ShouldBe("1,25Eplus002");
|
|
new DoubleDouble(-1.25).ToString(provider).ShouldBe("minus1,25");
|
|
DoubleDouble.NaN.ToString("G", provider).ShouldBe("not-number");
|
|
new DoubleDouble(double.PositiveInfinity).ToString("F2", provider).ShouldBe("infinite");
|
|
new DoubleDouble(double.NegativeInfinity).ToString("E", provider).ShouldBe("minus-infinite");
|
|
new DoubleDouble(1.25).ToString("F2", CultureInfo.GetCultureInfo("fr-FR")).ShouldBe("1,25");
|
|
new DoubleDouble(1.25).ToString().ShouldBe("1" + CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator + "25");
|
|
new DoubleDouble(1.25).ToString("F1").ShouldBe("1" + CultureInfo.CurrentCulture.NumberFormat.NumberDecimalSeparator + "2");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("R")]
|
|
[InlineData("N2")]
|
|
[InlineData("0.00")]
|
|
[InlineData("G1000")]
|
|
[InlineData("F-1")]
|
|
[InlineData("F 2")]
|
|
[InlineData("E999999999999999999999")]
|
|
public void UnsupportedOrUnboundedFormatsThrow(string format)
|
|
{
|
|
Should.Throw<FormatException>(() => DoubleDouble.One.ToString(format, CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
private static void AssertExactText(DoubleDouble value)
|
|
{
|
|
string text = value.ToStringExact();
|
|
text.Length.ShouldBeLessThanOrEqualTo(2048);
|
|
text.ShouldNotContain("E");
|
|
text.ShouldNotContain("e");
|
|
int point = text.IndexOf('.', StringComparison.Ordinal);
|
|
int places = point < 0 ? 0 : text.Length - point - 1;
|
|
if (point >= 0)
|
|
{
|
|
text.ShouldNotEndWith("0");
|
|
text.ShouldNotEndWith(".");
|
|
}
|
|
string digits = point < 0 ? text : text.Remove(point, 1);
|
|
BigInteger coefficient = BigInteger.Parse(digits, CultureInfo.InvariantCulture);
|
|
// Independently decode the input as integer multiples of 2^-1074 and
|
|
// compare with the printed integer coefficient / 10^places exactly.
|
|
BigInteger units = ExactFormattingUnits(value.High) + ExactFormattingUnits(value.Low);
|
|
(coefficient << 1074).ShouldBe(units * BigInteger.Pow(10, places));
|
|
DoubleDouble parsed = DoubleDouble.Parse(text, CultureInfo.InvariantCulture);
|
|
BitConverter.DoubleToInt64Bits(parsed.High).ShouldBe(BitConverter.DoubleToInt64Bits(value.High));
|
|
BitConverter.DoubleToInt64Bits(parsed.Low).ShouldBe(BitConverter.DoubleToInt64Bits(value.Low));
|
|
}
|
|
|
|
private static BigInteger ExactFormattingUnits(double value)
|
|
{
|
|
long bits = BitConverter.DoubleToInt64Bits(value);
|
|
int exponent = (int)((bits >> 52) & 0x7ff);
|
|
BigInteger significand = bits & 0xfffffffffffffL;
|
|
if (exponent != 0)
|
|
{
|
|
significand = (significand + (BigInteger.One << 52)) << (exponent - 1);
|
|
}
|
|
return bits < 0 ? -significand : significand;
|
|
}
|
|
}
|