using System.Diagnostics.CodeAnalysis; using System.Numerics; using Shouldly; using Xunit; namespace Just.PreciseMath.Tests; public sealed class GenericConversionTests { [Theory] [InlineData("byte")] [InlineData("sbyte")] [InlineData("short")] [InlineData("ushort")] [InlineData("int")] [InlineData("uint")] [InlineData("long")] [InlineData("ulong")] [InlineData("nint")] [InlineData("nuint")] [InlineData("Int128")] [InlineData("UInt128")] [InlineData("char")] public void EveryBoundedIntegerCoversBothEndpointsAndAdjacentValues(string type) { switch (type) { case "byte": AssertIntegerBoundaries(byte.MinValue, byte.MaxValue); break; case "sbyte": AssertIntegerBoundaries(sbyte.MinValue, sbyte.MaxValue); break; case "short": AssertIntegerBoundaries(short.MinValue, short.MaxValue); break; case "ushort": AssertIntegerBoundaries(ushort.MinValue, ushort.MaxValue); break; case "int": AssertIntegerBoundaries(int.MinValue, int.MaxValue); break; case "uint": AssertIntegerBoundaries(uint.MinValue, uint.MaxValue); break; case "long": AssertIntegerBoundaries(long.MinValue, long.MaxValue); break; case "ulong": AssertIntegerBoundaries(ulong.MinValue, ulong.MaxValue); break; case "nint": AssertIntegerBoundaries(nint.MinValue, nint.MaxValue); break; case "nuint": AssertIntegerBoundaries(nuint.MinValue, nuint.MaxValue); break; case "Int128": AssertIntegerBoundaries(Int128.MinValue, Int128.MaxValue); break; case "UInt128": AssertIntegerBoundaries(UInt128.MinValue, UInt128.MaxValue); break; case "char": AssertIntegerBoundaries(char.MinValue, char.MaxValue); break; default: throw new ArgumentOutOfRangeException(nameof(type)); } } private static void AssertIntegerBoundaries(T minimum, T maximum) where T : struct, INumberBase { BigInteger min = BigInteger.CreateChecked(minimum); BigInteger max = BigInteger.CreateChecked(maximum); BigInteger width = max - min + 1; // Integer inputs near these power-of-two endpoints have exact two-component // representations, even for Int128/UInt128. No round-trip is used as an oracle. foreach (BigInteger integer in new[] { min, min + 1, max - 1, max }) { T input = T.CreateChecked(integer); foreach (int mode in new[] { 0, 1, 2 }) { DoubleDouble direct = mode switch { 0 => DoubleDouble.CreateChecked(input), 1 => DoubleDouble.CreateSaturating(input), _ => DoubleDouble.CreateTruncating(input), }; AssertExactQuarters(direct, integer * 4); AssertExactQuarters(CreateInMode(input, mode), integer * 4); DoubleDouble hooked; bool supported = mode switch { 0 => ConversionProbe.FromChecked(input, out hooked), 1 => ConversionProbe.FromSaturating(input, out hooked), _ => ConversionProbe.FromTruncating(input, out hooked), }; supported.ShouldBeTrue(); AssertExactQuarters(hooked, integer * 4); } } foreach (BigInteger endpoint in new[] { min, max }) { // Whole-unit neighbors test range transitions; quarter-unit neighbors // prove truncation happens BEFORE range handling, on the exact sum. // Including +/-1.25 crosses the negative endpoint's truncation boundary. foreach (int offset in new[] { -5, -4, -3, -1, 0, 1, 3, 4, 5 }) { BigInteger numerator = (endpoint * 4) + offset; DoubleDouble input = FromExactQuarters(numerator); BigInteger integer = numerator / 4; BigInteger clamped = BigInteger.Min(max, BigInteger.Max(min, integer)); // Finite DD sources use BigInteger-style modular truncation, NOT // binary64's floating-to-integer clamping. Derive modulo independently. BigInteger wrapped = (((integer - min) % width) + width) % width + min; foreach (int mode in new[] { 0, 1, 2 }) { if (mode == 0 && (integer < min || integer > max)) { Should.Throw(() => CreateInMode(input, mode)); Should.Throw(() => ConvertToInMode(input, mode)); } else { BigInteger expected = mode == 0 ? integer : mode == 1 ? clamped : wrapped; BigInteger.CreateChecked(CreateInMode(input, mode)).ShouldBe(expected); BigInteger.CreateChecked(ConvertToInMode(input, mode)).ShouldBe(expected); } } } } AssertNonfiniteIntegerPolicy(); } private static TTarget CreateInMode(TSource value, int mode) where TTarget : INumberBase where TSource : INumberBase { return mode switch { 0 => TTarget.CreateChecked(value), 1 => TTarget.CreateSaturating(value), _ => TTarget.CreateTruncating(value), }; } private static T ConvertToInMode(DoubleDouble value, int mode) where T : struct, INumberBase { T result; bool supported = mode switch { 0 => ConversionProbe.ToChecked(value, out result), 1 => ConversionProbe.ToSaturating(value, out result), _ => ConversionProbe.ToTruncating(value, out result), }; supported.ShouldBeTrue(); return result; } private static DoubleDouble FromExactQuarters(BigInteger numerator) { // Choose the nearest signed power of two using exact integer distances. // A direct BigInteger-to-double cast can truncate rather than round, leaving // an inexact large residual at 128-bit endpoints. Powers of two cast exactly. BigInteger magnitude = BigInteger.Abs(numerator); BigInteger integral = magnitude / 4; BigInteger anchor = BigInteger.Zero; if (!integral.IsZero) { anchor = BigInteger.One << checked((int)integral.GetBitLength() - 1); if (BigInteger.Abs(magnitude - (anchor * 8)) < BigInteger.Abs(magnitude - (anchor * 4))) { anchor *= 2; } } double high = (double)(anchor * numerator.Sign); double low = (double)(numerator - (new BigInteger(high) * 4)) / 4.0; DoubleDouble result = DoubleDouble.FromComponents(high, low); AssertExactQuarters(result, numerator); return result; } private static void AssertExactQuarters(DoubleDouble value, BigInteger numerator) { double high = value.High * 4.0; double low = value.Low * 4.0; double.IsFinite(high).ShouldBeTrue(); double.IsFinite(low).ShouldBeTrue(); Math.Truncate(high).ShouldBe(high); Math.Truncate(low).ShouldBe(low); (new BigInteger(high) + new BigInteger(low)).ShouldBe(numerator); } private static void AssertNonfiniteIntegerPolicy() where T : struct, INumberBase { // Unlike finite DD inputs, NaN/infinities explicitly inherit the destination's // BCL binary64-source policy. Query that independent API, including exceptions; // do not assume every signed/unsigned/native target maps NaN identically. foreach (double special in new[] { double.NaN, double.NegativeInfinity, double.PositiveInfinity }) { foreach (int mode in new[] { 0, 1, 2 }) { T expected; try { expected = CreateInMode(special, mode); } catch (OverflowException) { Should.Throw(() => CreateInMode(new DoubleDouble(special), mode)); Should.Throw(() => ConvertToInMode(new DoubleDouble(special), mode)); continue; } CreateInMode(new DoubleDouble(special), mode).ShouldBe(expected); ConvertToInMode(new DoubleDouble(special), mode).ShouldBe(expected); } } } [Fact] public void UnboundedIntegerUsesFloatingInputOverflowButHasNoFiniteOutputEndpoints() { foreach (int sign in new[] { -1, 1 }) { BigInteger huge = sign * (BigInteger.One << 2000); foreach (int mode in new[] { 0, 1, 2 }) { DoubleDouble created = CreateInMode(huge, mode); created.High.ShouldBe(sign < 0 ? double.NegativeInfinity : double.PositiveInfinity); created.Low.ShouldBe(0.0); DoubleDouble hooked; bool supported = mode switch { 0 => ConversionProbe.FromChecked(huge, out hooked), 1 => ConversionProbe.FromSaturating(huge, out hooked), _ => ConversionProbe.FromTruncating(huge, out hooked), }; supported.ShouldBeTrue(); hooked.High.ShouldBe(created.High); hooked.Low.ShouldBe(0.0); // BigInteger has no endpoints: exact sparse integers beyond UInt128 // still truncate without clamping or wrapping in every output mode. BigInteger integer = sign * ((BigInteger.One << 200) + 1); DoubleDouble finite = FromExactQuarters((integer * 4) + sign); CreateInMode(finite, mode).ShouldBe(integer); ConvertToInMode(finite, mode).ShouldBe(integer); AssertExactQuarters(CreateInMode(integer, mode), integer * 4); } } AssertNonfiniteIntegerPolicy(); } [Fact] public void GenericCreationPreservesComponentsAndExactIntegers() { DoubleDouble value = DoubleDouble.FromComponents(1.0, double.Epsilon); DoubleDouble.CreateChecked(value).Low.ShouldBe(double.Epsilon); DoubleDouble.CreateSaturating(value).Low.ShouldBe(double.Epsilon); DoubleDouble.CreateTruncating(value).Low.ShouldBe(double.Epsilon); DoubleDouble.CreateChecked(ulong.MaxValue).High.ShouldBe(Math.ScaleB(1.0, 64)); DoubleDouble.CreateChecked(ulong.MaxValue).Low.ShouldBe(-1.0); DoubleDouble.CreateChecked(decimal.MaxValue).Low.ShouldBe(-1.0); BigInteger integer = (BigInteger.One << 100) + 1; BigInteger.CreateChecked(DoubleDouble.CreateChecked(integer)).ShouldBe(integer); BitConverter.DoubleToInt64Bits(DoubleDouble.CreateChecked(-0.0).High).ShouldBe(long.MinValue); } [Fact] public void IntegerTargetsTruncateTheExactSumBeforeApplyingRangePolicy() { int.CreateChecked(DoubleDouble.FromComponents(1.0, -double.Epsilon)).ShouldBe(0); int.CreateChecked(DoubleDouble.FromComponents(-1.0, double.Epsilon)).ShouldBe(0); long.CreateChecked(DoubleDouble.FromComponents(Math.ScaleB(1.0, 63), -1.0)).ShouldBe(long.MaxValue); ulong.CreateChecked(DoubleDouble.FromComponents(Math.ScaleB(1.0, 64), -1.0)).ShouldBe(ulong.MaxValue); DoubleDouble overflow = new(256.75); Should.Throw(() => byte.CreateChecked(overflow)); byte.CreateSaturating(overflow).ShouldBe(byte.MaxValue); byte.CreateTruncating(overflow).ShouldBe((byte)0); byte.CreateSaturating(new DoubleDouble(-1.0)).ShouldBe((byte)0); byte.CreateTruncating(new DoubleDouble(-1.0)).ShouldBe(byte.MaxValue); } [Fact] public void HugeIntegerInputsUseFloatingOverflowAndExactRatioRounding() { BigInteger huge = BigInteger.One << 2000; double.IsPositiveInfinity(DoubleDouble.CreateChecked(huge).High).ShouldBeTrue(); double.IsPositiveInfinity(DoubleDouble.CreateSaturating(huge).High).ShouldBeTrue(); double.IsNegativeInfinity(DoubleDouble.CreateTruncating(-huge).High).ShouldBeTrue(); // A sparse exact integer must retain its low component rather than pass through double. DoubleDouble sparse = DoubleDouble.CreateChecked((BigInteger.One << 100) + 1); sparse.High.ShouldBe(Math.ScaleB(1.0, 100)); sparse.Low.ShouldBe(1.0); } [Fact] public void FloatingAndDecimalTargetsRetainResidualRounding() { DoubleDouble aboveFloatMidpoint = DoubleDouble.FromComponents(1.0 + Math.ScaleB(1.0, -24), double.Epsilon); float.CreateChecked(aboveFloatMidpoint).ShouldBe(float.BitIncrement(1.0f)); DoubleDouble aboveHalfMidpoint = DoubleDouble.FromComponents(1.0 + Math.ScaleB(1.0, -11), double.Epsilon); Half.CreateChecked(aboveHalfMidpoint).ShouldBe(Half.BitIncrement((Half)1)); decimal.CreateChecked(DoubleDouble.CreateChecked(0.1m)).ShouldBe(0.1m); Should.Throw(() => decimal.CreateChecked(new DoubleDouble(double.PositiveInfinity))); decimal.CreateSaturating(new DoubleDouble(double.PositiveInfinity)).ShouldBe(decimal.MaxValue); decimal.CreateTruncating(DoubleDouble.NaN).ShouldBe(0m); int.CreateSaturating(DoubleDouble.NaN).ShouldBe(0); int.CreateTruncating(new DoubleDouble(double.PositiveInfinity)).ShouldBe(int.MaxValue); Should.Throw(() => int.CreateChecked(DoubleDouble.NaN)); } [Fact] public void BuiltInNumericFamiliesRoundTripThroughAllCreationModes() { RoundTrip((byte)123); RoundTrip((sbyte)-123); RoundTrip((short)-12345); RoundTrip((ushort)54321); RoundTrip(int.MinValue); RoundTrip(uint.MaxValue); RoundTrip(long.MinValue); RoundTrip(ulong.MaxValue); RoundTrip((nint)(-12345)); RoundTrip((nuint)54321); RoundTrip((Int128.One << 100) + 1); RoundTrip((UInt128.One << 100) + 1); RoundTrip('A'); RoundTrip((Half)1.25); RoundTrip(1.25f); RoundTrip(1.25); RoundTrip(1.25m); } private static void RoundTrip(T value) where T : INumberBase { T.CreateChecked(DoubleDouble.CreateChecked(value)).ShouldBe(value); T.CreateSaturating(DoubleDouble.CreateSaturating(value)).ShouldBe(value); T.CreateTruncating(DoubleDouble.CreateTruncating(value)).ShouldBe(value); } [Fact] public void UnsupportedHooksReturnFalseWithoutTwoSidedRecursion() { ConversionProbe.FromChecked(Complex.One, out DoubleDouble from).ShouldBeFalse(); from.ShouldBe(DoubleDouble.Zero); ConversionProbe.FromSaturating(Complex.One, out _).ShouldBeFalse(); ConversionProbe.FromTruncating(Complex.One, out _).ShouldBeFalse(); ConversionProbe.ToChecked(DoubleDouble.One, out Complex to).ShouldBeFalse(); to.ShouldBe(default); ConversionProbe.ToSaturating(DoubleDouble.One, out Complex _).ShouldBeFalse(); ConversionProbe.ToTruncating(DoubleDouble.One, out Complex _).ShouldBeFalse(); } private interface ConversionProbe : INumberBase where T : INumberBase { public static bool FromChecked(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase { return T.TryConvertFromChecked(value, out result); } public static bool FromSaturating(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase { return T.TryConvertFromSaturating(value, out result); } public static bool FromTruncating(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase { return T.TryConvertFromTruncating(value, out result); } public static bool ToChecked(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase { return T.TryConvertToChecked(value, out result); } public static bool ToSaturating(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase { return T.TryConvertToSaturating(value, out result); } public static bool ToTruncating(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase { return T.TryConvertToTruncating(value, out result); } } [Fact] public void UnsupportedComplexConversionTerminatesWithNotSupported() { Should.Throw(() => DoubleDouble.CreateChecked(Complex.One)); Should.Throw(() => DoubleDouble.CreateSaturating(Complex.One)); Should.Throw(() => DoubleDouble.CreateTruncating(Complex.One)); } }