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 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(() => 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; } }