370 lines
17 KiB
C#
370 lines
17 KiB
C#
using System.Diagnostics.CodeAnalysis;
|
|
|
|
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>(T minimum, T maximum) where T : struct, INumberBase<T>
|
|
{
|
|
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<DoubleDouble, T>(input, mode), integer * 4);
|
|
DoubleDouble hooked;
|
|
bool supported = mode switch
|
|
{
|
|
0 => ConversionProbe<DoubleDouble>.FromChecked(input, out hooked),
|
|
1 => ConversionProbe<DoubleDouble>.FromSaturating(input, out hooked),
|
|
_ => ConversionProbe<DoubleDouble>.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<OverflowException>(() => CreateInMode<T, DoubleDouble>(input, mode));
|
|
Should.Throw<OverflowException>(() => ConvertToInMode<T>(input, mode));
|
|
}
|
|
else
|
|
{
|
|
BigInteger expected = mode == 0 ? integer : mode == 1 ? clamped : wrapped;
|
|
BigInteger.CreateChecked(CreateInMode<T, DoubleDouble>(input, mode)).ShouldBe(expected);
|
|
BigInteger.CreateChecked(ConvertToInMode<T>(input, mode)).ShouldBe(expected);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
AssertNonfiniteIntegerPolicy<T>();
|
|
}
|
|
|
|
private static TTarget CreateInMode<TTarget, TSource>(TSource value, int mode)
|
|
where TTarget : INumberBase<TTarget>
|
|
where TSource : INumberBase<TSource>
|
|
{
|
|
return mode switch
|
|
{
|
|
0 => TTarget.CreateChecked(value),
|
|
1 => TTarget.CreateSaturating(value),
|
|
_ => TTarget.CreateTruncating(value),
|
|
};
|
|
}
|
|
|
|
private static T ConvertToInMode<T>(DoubleDouble value, int mode) where T : struct, INumberBase<T>
|
|
{
|
|
T result;
|
|
bool supported = mode switch
|
|
{
|
|
0 => ConversionProbe<DoubleDouble>.ToChecked(value, out result),
|
|
1 => ConversionProbe<DoubleDouble>.ToSaturating(value, out result),
|
|
_ => ConversionProbe<DoubleDouble>.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<T>() where T : struct, INumberBase<T>
|
|
{
|
|
// 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<T, double>(special, mode);
|
|
}
|
|
catch (OverflowException)
|
|
{
|
|
Should.Throw<OverflowException>(() => CreateInMode<T, DoubleDouble>(new DoubleDouble(special), mode));
|
|
Should.Throw<OverflowException>(() => ConvertToInMode<T>(new DoubleDouble(special), mode));
|
|
continue;
|
|
}
|
|
CreateInMode<T, DoubleDouble>(new DoubleDouble(special), mode).ShouldBe(expected);
|
|
ConvertToInMode<T>(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<DoubleDouble, BigInteger>(huge, mode);
|
|
created.High.ShouldBe(sign < 0 ? double.NegativeInfinity : double.PositiveInfinity);
|
|
created.Low.ShouldBe(0.0);
|
|
DoubleDouble hooked;
|
|
bool supported = mode switch
|
|
{
|
|
0 => ConversionProbe<DoubleDouble>.FromChecked(huge, out hooked),
|
|
1 => ConversionProbe<DoubleDouble>.FromSaturating(huge, out hooked),
|
|
_ => ConversionProbe<DoubleDouble>.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<BigInteger, DoubleDouble>(finite, mode).ShouldBe(integer);
|
|
ConvertToInMode<BigInteger>(finite, mode).ShouldBe(integer);
|
|
AssertExactQuarters(CreateInMode<DoubleDouble, BigInteger>(integer, mode), integer * 4);
|
|
}
|
|
}
|
|
AssertNonfiniteIntegerPolicy<BigInteger>();
|
|
}
|
|
|
|
[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<OverflowException>(() => 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<OverflowException>(() => 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<OverflowException>(() => 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>(T value) where T : INumberBase<T>
|
|
{
|
|
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<DoubleDouble>.FromChecked(Complex.One, out DoubleDouble from).ShouldBeFalse();
|
|
from.ShouldBe(DoubleDouble.Zero);
|
|
ConversionProbe<DoubleDouble>.FromSaturating(Complex.One, out _).ShouldBeFalse();
|
|
ConversionProbe<DoubleDouble>.FromTruncating(Complex.One, out _).ShouldBeFalse();
|
|
ConversionProbe<DoubleDouble>.ToChecked(DoubleDouble.One, out Complex to).ShouldBeFalse();
|
|
to.ShouldBe(default);
|
|
ConversionProbe<DoubleDouble>.ToSaturating(DoubleDouble.One, out Complex _).ShouldBeFalse();
|
|
ConversionProbe<DoubleDouble>.ToTruncating(DoubleDouble.One, out Complex _).ShouldBeFalse();
|
|
}
|
|
|
|
private interface ConversionProbe<T> : INumberBase<T> where T : INumberBase<T>
|
|
{
|
|
public static bool FromChecked<TOther>(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase<TOther>
|
|
{
|
|
return T.TryConvertFromChecked(value, out result);
|
|
}
|
|
|
|
public static bool FromSaturating<TOther>(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase<TOther>
|
|
{
|
|
return T.TryConvertFromSaturating(value, out result);
|
|
}
|
|
|
|
public static bool FromTruncating<TOther>(TOther value, [MaybeNullWhen(false)] out T result) where TOther : INumberBase<TOther>
|
|
{
|
|
return T.TryConvertFromTruncating(value, out result);
|
|
}
|
|
|
|
public static bool ToChecked<TOther>(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase<TOther>
|
|
{
|
|
return T.TryConvertToChecked(value, out result);
|
|
}
|
|
|
|
public static bool ToSaturating<TOther>(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase<TOther>
|
|
{
|
|
return T.TryConvertToSaturating(value, out result);
|
|
}
|
|
|
|
public static bool ToTruncating<TOther>(T value, [MaybeNullWhen(false)] out TOther result) where TOther : INumberBase<TOther>
|
|
{
|
|
return T.TryConvertToTruncating(value, out result);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void UnsupportedComplexConversionTerminatesWithNotSupported()
|
|
{
|
|
Should.Throw<NotSupportedException>(() => DoubleDouble.CreateChecked(Complex.One));
|
|
Should.Throw<NotSupportedException>(() => DoubleDouble.CreateSaturating(Complex.One));
|
|
Should.Throw<NotSupportedException>(() => DoubleDouble.CreateTruncating(Complex.One));
|
|
}
|
|
}
|