ported legacy DoubleDouble with tests
.NET Test / .NET tests (push) Successful in 1m14s

This commit is contained in:
2026-09-13 22:19:23 +04:00
parent ec609b26f7
commit 082fd84c87
17 changed files with 2891 additions and 26 deletions
@@ -0,0 +1,273 @@
using System.Globalization;
using System.Numerics;
using Shouldly;
using Xunit;
namespace Just.PreciseMath.Tests;
public class DoubleDoubleParsingTests
{
[Theory]
[InlineData("9007199254740993", 9007199254740992.0, 1.0)]
[InlineData("-9007199254740993", -9007199254740992.0, -1.0)]
[InlineData("9.007199254740993e15", 9007199254740992.0, 1.0)]
[InlineData(" +900719925474099300E-2\t", 9007199254740992.0, 1.0)]
[InlineData("1.000000000000000055511151231257827021181583404541015625", 1.0, 5.551115123125783e-17)]
public void ParsingRetainsDecimalInformationBeyondBinary64(string text, double high, double low)
{
// Exact binary cases: 2^53 + 1 and 1 + 2^-54. Neither may be rounded
// through double or decimal before extracting the low component.
AssertParsed(text, CultureInfo.InvariantCulture, high, low);
}
[Theory]
[InlineData("0.1", 0.1, -5.551115123125783e-18)]
[InlineData("-0.1", -0.1, 5.551115123125783e-18)]
[InlineData("1.23456789012345678901234567890123456789", 1.2345678901234567, 9.858021020478981e-17)]
[InlineData("1e-300", 1e-300, -2.5059094e-317)]
[InlineData("2.4703282292062328e-324", double.Epsilon, 0.0)]
public void NonDyadicDecimalInputsRetainTheRoundedResidual(string text, double high, double low)
{
// Reproduce independently with Python fractions: v = Fraction(text),
// h = float(v), l = float(v - Fraction(h)); canonicalize a zero low to +0.
AssertParsed(text, CultureInfo.InvariantCulture, high, low);
}
[Theory]
[InlineData("G99")]
[InlineData("E99")]
[InlineData("F99")]
public void SupportedFormatterOutputCanPreserveAnExactlyRepresentablePair(string format)
{
double low = Math.ScaleB(1.0, -80);
DoubleDouble value = DoubleDouble.FromComponents(1.0, low);
CultureInfo culture = CultureInfo.GetCultureInfo("fr-FR");
// This dyadic has a terminating decimal expansion within the chosen
// precision. No general G32 round-trip guarantee follows from this test.
AssertParsed(value.ToString(format, culture), culture, 1.0, low);
}
[Theory]
[InlineData(".125", 0.125)]
[InlineData("125.e-3", 0.125)]
[InlineData("00000125E-3", 0.125)]
[InlineData("\r\n 1.25 \t", 1.25)]
[InlineData("0", 0.0)]
[InlineData("-0.000e999999999999999999999", -0.0)]
[InlineData("1e999999999999999999999", double.PositiveInfinity)]
[InlineData("-1e999999999999999999999", double.NegativeInfinity)]
[InlineData("1e-999999999999999999999", 0.0)]
[InlineData("-1e-999999999999999999999", -0.0)]
[InlineData("NaN", double.NaN)]
[InlineData("-nan", double.NaN)]
[InlineData("Infinity", double.PositiveInfinity)]
[InlineData("+infinity", double.PositiveInfinity)]
[InlineData("-Infinity", double.NegativeInfinity)]
public void ParsingHandlesGrammarAndSpecialValues(string text, double high)
{
AssertParsed(text, CultureInfo.InvariantCulture, high, 0.0);
}
[Theory]
[InlineData("")]
[InlineData(" \t\r\n")]
[InlineData("+")]
[InlineData(".")]
[InlineData("e1")]
[InlineData("1e")]
[InlineData("1e+")]
[InlineData("1e--1")]
[InlineData("1e999999999999999999x")]
[InlineData("0e999999999999999999x")]
[InlineData("--1")]
[InlineData("+ 1")]
[InlineData("1 2")]
[InlineData("1.2.3")]
[InlineData("1e2e3")]
[InlineData("1,000")]
[InlineData("$1")]
[InlineData("(1)")]
[InlineData("0x10")]
[InlineData("1_000")]
[InlineData("123")]
[InlineData("1\0")]
[InlineData("NaNx")]
public void InvalidInputFailsWithoutLeavingAPartialResult(string text)
{
DoubleDouble.TryParse(text, CultureInfo.InvariantCulture, out DoubleDouble fromString).ShouldBeFalse();
DoubleDouble.TryParse(text.AsSpan(), CultureInfo.InvariantCulture, out DoubleDouble fromSpan).ShouldBeFalse();
AssertPositiveZero(fromString);
AssertPositiveZero(fromSpan);
Should.Throw<FormatException>(() => DoubleDouble.Parse(text, CultureInfo.InvariantCulture));
Should.Throw<FormatException>(() => DoubleDouble.Parse(text.AsSpan(), CultureInfo.InvariantCulture));
}
[Fact]
public void NullStringFailsTryParseAndThrowsArgumentNullFromParse()
{
DoubleDouble.TryParse((string?)null, out DoubleDouble result).ShouldBeFalse();
AssertPositiveZero(result);
DoubleDouble.TryParse((string?)null, CultureInfo.InvariantCulture, out result).ShouldBeFalse();
AssertPositiveZero(result);
Should.Throw<ArgumentNullException>(() => DoubleDouble.Parse((string)null!, CultureInfo.InvariantCulture));
}
[Fact]
public void CultureSuppliesDecimalSeparatorAndBothExponentSigns()
{
NumberFormatInfo info = (NumberFormatInfo)CultureInfo.InvariantCulture.NumberFormat.Clone();
info.NumberDecimalSeparator = "::";
info.PositiveSign = "plus";
info.NegativeSign = "minus";
AssertParsed("minus1::25eplus2", info, -125.0, 0.0);
AssertParsed("plus125eminus2", info, 1.25, 0.0);
AssertParsed("12,5", CultureInfo.GetCultureInfo("fr-FR"), 12.5, 0.0);
DoubleDouble.TryParse("1.25", info, out DoubleDouble result).ShouldBeFalse();
AssertPositiveZero(result);
}
[Fact]
public void CultureSpecialSymbolsAreRecognizedBeforeConsumingTheirSigns()
{
NumberFormatInfo info = (NumberFormatInfo)CultureInfo.InvariantCulture.NumberFormat.Clone();
info.NaNSymbol = "+missing";
info.PositiveInfinitySymbol = "-unbounded";
info.NegativeInfinitySymbol = "negative-limit";
AssertParsed("+MISSING", info, double.NaN, 0.0);
AssertParsed("-UNBOUNDED", info, double.PositiveInfinity, 0.0);
AssertParsed("NEGATIVE-LIMIT", info, double.NegativeInfinity, 0.0);
}
[Fact]
public void DefaultTryParseAndNullProvidersUseCurrentCulture()
{
CultureInfo previous = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
const string Text = "1,25";
DoubleDouble.Parse(Text, null).High.ShouldBe(1.25);
DoubleDouble.Parse(Text.AsSpan(), null).High.ShouldBe(1.25);
DoubleDouble.TryParse(Text, out DoubleDouble fromString).ShouldBeTrue();
DoubleDouble.TryParse(Text.AsSpan(), out DoubleDouble fromSpan).ShouldBeTrue();
fromString.High.ShouldBe(1.25);
fromSpan.High.ShouldBe(1.25);
AssertParsed(Text, null, 1.25, 0.0);
}
finally
{
CultureInfo.CurrentCulture = previous;
}
}
[Fact]
public void GenericStringAndSpanParsingInterfacesAreImplemented()
{
ParseString<DoubleDouble>("9007199254740993").Low.ShouldBe(1.0);
ParseSpan<DoubleDouble>("9007199254740993").Low.ShouldBe(1.0);
}
[Fact]
public void InputLengthIsBoundedBeforeIgnoringWhitespaceOrLeadingZeros()
{
AssertParsed(new string('0', 4095) + "1", CultureInfo.InvariantCulture, 1.0, 0.0);
AssertParsed("1" + new string('0', 4095), CultureInfo.InvariantCulture, double.PositiveInfinity, 0.0);
InvalidInputFailsWithoutLeavingAPartialResult(new string('0', 4096) + "1");
InvalidInputFailsWithoutLeavingAPartialResult(new string(' ', 4096) + "1");
}
[Fact]
public void LongMantissasCanCancelLargeExponentsWithoutOverflowOrUnderflow()
{
AssertParsed("1" + new string('0', 4000) + "e-4000", CultureInfo.InvariantCulture, 1.0, 0.0);
AssertParsed("0." + new string('0', 4000) + "1e4001", CultureInfo.InvariantCulture, 1.0, 0.0);
}
[Fact]
public void ExactDyadicsCoverSubnormalAndOverflowRoundingBoundaries()
{
foreach (int sign in new[] { -1, 1 })
{
// Integer coefficients and powers of two define independent exact inputs.
AssertParsed(DyadicText(sign, -1074), CultureInfo.InvariantCulture, sign * double.Epsilon, 0.0);
AssertParsed(DyadicText(sign, -1075), CultureInfo.InvariantCulture, sign < 0 ? -0.0 : 0.0, 0.0);
AssertParsed(DyadicText(3 * sign, -1076), CultureInfo.InvariantCulture, sign * double.Epsilon, 0.0);
BigInteger maximum = (BigInteger.One << 1024) - (BigInteger.One << 971);
AssertParsed(DyadicText(sign * maximum, 0), CultureInfo.InvariantCulture, sign * double.MaxValue, 0.0);
BigInteger midpoint = maximum + (BigInteger.One << 970);
AssertParsed(DyadicText(sign * midpoint, 0), CultureInfo.InvariantCulture,
sign < 0 ? double.NegativeInfinity : double.PositiveInfinity, 0.0);
// Residual rounding reaches the overflow midpoint although the exact
// input is below it. The adjacent finite pair must be selected.
AssertParsed(DyadicText(sign * (midpoint - (BigInteger.One << 916)), 0), CultureInfo.InvariantCulture,
sign * double.MaxValue, sign * Math.BitDecrement(Math.ScaleB(1.0, 970)));
}
}
[Theory]
[InlineData(2, false)]
[InlineData(3, true)]
public void LowComponentRoundingUsesExactResidualAndTiesToEven(int tail, bool roundUp)
{
// 1 + 2^-54 + tail*2^-108. At tail=2 the low is at its midpoint;
// at tail=3 it lies above the midpoint. The high remains exactly 1.
BigInteger coefficient = (BigInteger.One << 108) + (BigInteger.One << 54) + tail;
double low = Math.ScaleB(1.0, -54);
AssertParsed(DyadicText(coefficient, -108), CultureInfo.InvariantCulture,
1.0, roundUp ? Math.BitIncrement(low) : low);
}
private static T ParseString<T>(string text) where T : IParsable<T>
{
T.TryParse(text, CultureInfo.InvariantCulture, out T? result).ShouldBeTrue();
result.ShouldBe(T.Parse(text, CultureInfo.InvariantCulture));
return T.Parse(text, CultureInfo.InvariantCulture);
}
private static T ParseSpan<T>(ReadOnlySpan<char> text) where T : ISpanParsable<T>
{
T.TryParse(text, CultureInfo.InvariantCulture, out T? result).ShouldBeTrue();
result.ShouldBe(T.Parse(text, CultureInfo.InvariantCulture));
return T.Parse(text, CultureInfo.InvariantCulture);
}
private static string DyadicText(BigInteger coefficient, int exponent)
{
// c*2^-k = (c*5^k)*10^-k. No production conversion or formatting helpers.
return exponent >= 0
? (coefficient << exponent).ToString(CultureInfo.InvariantCulture)
: (coefficient * BigInteger.Pow(5, -exponent)).ToString(CultureInfo.InvariantCulture)
+ "e" + exponent.ToString(CultureInfo.InvariantCulture);
}
private static void AssertPositiveZero(DoubleDouble value)
{
BitConverter.DoubleToInt64Bits(value.High).ShouldBe(0L);
BitConverter.DoubleToInt64Bits(value.Low).ShouldBe(0L);
}
private static void AssertParsed(string text, IFormatProvider? provider, double high, double low)
{
DoubleDouble.TryParse(text, provider, out DoubleDouble fromString).ShouldBeTrue();
DoubleDouble.TryParse(text.AsSpan(), provider, out DoubleDouble fromSpan).ShouldBeTrue();
DoubleDouble[] results = [DoubleDouble.Parse(text, provider), DoubleDouble.Parse(text.AsSpan(), provider),
fromString, fromSpan];
foreach (DoubleDouble result in results)
{
result.High.ShouldBe(high);
result.Low.ShouldBe(low);
if (double.IsFinite(high))
{
(result.High + result.Low).ShouldBe(result.High);
}
if (high == 0.0)
{
BitConverter.DoubleToInt64Bits(result.High).ShouldBe(BitConverter.DoubleToInt64Bits(high));
}
if (low == 0.0)
{
BitConverter.DoubleToInt64Bits(result.Low).ShouldBe(0L);
}
}
}
}