|
| 1 | +// Copyright (c) 2023-2026 ktsu-dev contributors |
| 2 | + |
| 3 | +namespace ktsu.PreciseNumber.Test; |
| 4 | + |
| 5 | +using System.Globalization; |
| 6 | +using System.Numerics; |
| 7 | + |
| 8 | +/// <summary> |
| 9 | +/// Covers conversion through generic math, which is how code written against <see cref="INumber{TSelf}"/> |
| 10 | +/// reaches <see cref="PreciseNumber"/>. |
| 11 | +/// </summary> |
| 12 | +[TestClass] |
| 13 | +public class PreciseNumberConversionTests |
| 14 | +{ |
| 15 | + private static PreciseNumber P(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture); |
| 16 | + |
| 17 | + private static TTo Checked<TTo, TFrom>(TFrom value) |
| 18 | + where TTo : INumberBase<TTo> |
| 19 | + where TFrom : INumberBase<TFrom> |
| 20 | + => TTo.CreateChecked(value); |
| 21 | + |
| 22 | + private static TTo Saturating<TTo, TFrom>(TFrom value) |
| 23 | + where TTo : INumberBase<TTo> |
| 24 | + where TFrom : INumberBase<TFrom> |
| 25 | + => TTo.CreateSaturating(value); |
| 26 | + |
| 27 | + private static TTo Truncating<TTo, TFrom>(TFrom value) |
| 28 | + where TTo : INumberBase<TTo> |
| 29 | + where TFrom : INumberBase<TFrom> |
| 30 | + => TTo.CreateTruncating(value); |
| 31 | + |
| 32 | + /// <summary>The shape of a unit conversion in generic quantity code.</summary> |
| 33 | + private static T ToBase<T>(T value, double factor) |
| 34 | + where T : INumber<T> |
| 35 | + => value * T.CreateChecked(factor); |
| 36 | + |
| 37 | + private static void AssertFromInAllModes<TFrom>(TFrom value, string expected) |
| 38 | + where TFrom : INumberBase<TFrom> |
| 39 | + { |
| 40 | + PreciseNumber expectedNumber = P(expected); |
| 41 | + Assert.AreEqual(expectedNumber, Checked<PreciseNumber, TFrom>(value), $"Checked from {typeof(TFrom).Name}"); |
| 42 | + Assert.AreEqual(expectedNumber, Saturating<PreciseNumber, TFrom>(value), $"Saturating from {typeof(TFrom).Name}"); |
| 43 | + Assert.AreEqual(expectedNumber, Truncating<PreciseNumber, TFrom>(value), $"Truncating from {typeof(TFrom).Name}"); |
| 44 | + } |
| 45 | + |
| 46 | + private static void AssertToInAllModes<TTo>(string value, TTo expected) |
| 47 | + where TTo : INumberBase<TTo> |
| 48 | + { |
| 49 | + PreciseNumber number = P(value); |
| 50 | + Assert.AreEqual(expected, Checked<TTo, PreciseNumber>(number), $"Checked to {typeof(TTo).Name}"); |
| 51 | + Assert.AreEqual(expected, Saturating<TTo, PreciseNumber>(number), $"Saturating to {typeof(TTo).Name}"); |
| 52 | + Assert.AreEqual(expected, Truncating<TTo, PreciseNumber>(number), $"Truncating to {typeof(TTo).Name}"); |
| 53 | + } |
| 54 | + |
| 55 | + [TestMethod] |
| 56 | + public void FromEveryIntegerTypeIsExact() |
| 57 | + { |
| 58 | + AssertFromInAllModes(sbyte.MinValue, "-128"); |
| 59 | + AssertFromInAllModes(byte.MaxValue, "255"); |
| 60 | + AssertFromInAllModes(short.MinValue, "-32768"); |
| 61 | + AssertFromInAllModes(ushort.MaxValue, "65535"); |
| 62 | + AssertFromInAllModes(int.MinValue, "-2147483648"); |
| 63 | + AssertFromInAllModes(uint.MaxValue, "4294967295"); |
| 64 | + AssertFromInAllModes(long.MinValue, "-9223372036854775808"); |
| 65 | + AssertFromInAllModes(ulong.MaxValue, "18446744073709551615"); |
| 66 | + AssertFromInAllModes(Int128.MinValue, "-170141183460469231731687303715884105728"); |
| 67 | + AssertFromInAllModes(UInt128.MaxValue, "340282366920938463463374607431768211455"); |
| 68 | + AssertFromInAllModes((nint)(-42), "-42"); |
| 69 | + AssertFromInAllModes((nuint)42, "42"); |
| 70 | + AssertFromInAllModes('A', "65"); |
| 71 | + AssertFromInAllModes(BigInteger.Pow(10, 60) + 1, "1" + new string('0', 59) + "1"); |
| 72 | + AssertFromInAllModes(1000, "1000"); |
| 73 | + } |
| 74 | + |
| 75 | + [TestMethod] |
| 76 | + public void FromDecimalIsExact() |
| 77 | + { |
| 78 | + AssertFromInAllModes(1234.5678m, "1234.5678"); |
| 79 | + AssertFromInAllModes(decimal.MaxValue, "79228162514264337593543950335"); |
| 80 | + AssertFromInAllModes(decimal.MinValue, "-79228162514264337593543950335"); |
| 81 | + AssertFromInAllModes(0.0000000000000000000000000001m, "0.0000000000000000000000000001"); |
| 82 | + } |
| 83 | + |
| 84 | + [TestMethod] |
| 85 | + public void FromBinaryFloatingPointUsesDecimalText() |
| 86 | + { |
| 87 | + AssertFromInAllModes(0.3048, "0.3048"); |
| 88 | + AssertFromInAllModes(-1.5e-10, "-0.00000000015"); |
| 89 | + AssertFromInAllModes(1.25f, "1.25"); |
| 90 | + AssertFromInAllModes((Half)1.5, "1.5"); |
| 91 | + AssertFromInAllModes(0.0, "0"); |
| 92 | + } |
| 93 | + |
| 94 | + [TestMethod] |
| 95 | + public void FromPreciseNumberIsIdentity() |
| 96 | + { |
| 97 | + AssertFromInAllModes(P("-12.5"), "-12.5"); |
| 98 | + } |
| 99 | + |
| 100 | + [TestMethod] |
| 101 | + public void GenericUnitConversionIsExact() |
| 102 | + { |
| 103 | + Assert.AreEqual(P("0.3048"), ToBase(PreciseNumber.One, 0.3048)); |
| 104 | + Assert.AreEqual(P("3.048"), ToBase(10.ToPreciseNumber(), 0.3048)); |
| 105 | + } |
| 106 | + |
| 107 | + [TestMethod] |
| 108 | + public void FromNaNMatchesBigInteger() |
| 109 | + { |
| 110 | + // BigInteger is the other built-in numeric type with no NaN and no bound, so it sets the convention. |
| 111 | + Assert.ThrowsExactly<OverflowException>(() => Checked<BigInteger, double>(double.NaN)); |
| 112 | + Assert.AreEqual(BigInteger.Zero, Saturating<BigInteger, double>(double.NaN)); |
| 113 | + Assert.AreEqual(BigInteger.Zero, Truncating<BigInteger, double>(double.NaN)); |
| 114 | + |
| 115 | + Assert.ThrowsExactly<OverflowException>(() => Checked<PreciseNumber, double>(double.NaN)); |
| 116 | + Assert.AreEqual(PreciseNumber.Zero, Saturating<PreciseNumber, double>(double.NaN)); |
| 117 | + Assert.AreEqual(PreciseNumber.Zero, Truncating<PreciseNumber, float>(float.NaN)); |
| 118 | + Assert.AreEqual(PreciseNumber.Zero, Saturating<PreciseNumber, Half>(Half.NaN)); |
| 119 | + } |
| 120 | + |
| 121 | + [TestMethod] |
| 122 | + public void FromInfinityMatchesBigInteger() |
| 123 | + { |
| 124 | + Assert.ThrowsExactly<OverflowException>(() => Checked<BigInteger, double>(double.PositiveInfinity)); |
| 125 | + Assert.ThrowsExactly<OverflowException>(() => Saturating<BigInteger, double>(double.PositiveInfinity)); |
| 126 | + Assert.ThrowsExactly<OverflowException>(() => Truncating<BigInteger, double>(double.NegativeInfinity)); |
| 127 | + |
| 128 | + Assert.ThrowsExactly<OverflowException>(() => Checked<PreciseNumber, double>(double.PositiveInfinity)); |
| 129 | + Assert.ThrowsExactly<OverflowException>(() => Saturating<PreciseNumber, double>(double.PositiveInfinity)); |
| 130 | + Assert.ThrowsExactly<OverflowException>(() => Truncating<PreciseNumber, float>(float.NegativeInfinity)); |
| 131 | + } |
| 132 | + |
| 133 | + [TestMethod] |
| 134 | + public void TryConvertReportsUnsupportedTypes() |
| 135 | + { |
| 136 | + Assert.IsFalse(PreciseNumber.TryConvertFromChecked(new Complex(1, 0), out PreciseNumber _), "Complex is not a supported source"); |
| 137 | + Assert.IsFalse(PreciseNumber.TryConvertToChecked(PreciseNumber.One, out Complex _), "Complex is not a supported destination"); |
| 138 | + } |
| 139 | + |
| 140 | + [TestMethod] |
| 141 | + public void ToIntegerTruncatesTowardZero() |
| 142 | + { |
| 143 | + AssertToInAllModes("12.9", 12); |
| 144 | + AssertToInAllModes("-12.9", -12); |
| 145 | + AssertToInAllModes("0.999", 0L); |
| 146 | + AssertToInAllModes("-0.5", (short)0); |
| 147 | + AssertToInAllModes("12345e3", 12345000); |
| 148 | + AssertToInAllModes("65.7", 'A'); |
| 149 | + AssertToInAllModes("-170141183460469231731687303715884105728", Int128.MinValue); |
| 150 | + AssertToInAllModes("340282366920938463463374607431768211455", UInt128.MaxValue); |
| 151 | + AssertToInAllModes("-42.1", (nint)(-42)); |
| 152 | + AssertToInAllModes("42.1", (nuint)42); |
| 153 | + AssertToInAllModes("255", byte.MaxValue); |
| 154 | + AssertToInAllModes("-128", sbyte.MinValue); |
| 155 | + AssertToInAllModes("65535", ushort.MaxValue); |
| 156 | + AssertToInAllModes("4294967295", uint.MaxValue); |
| 157 | + AssertToInAllModes("18446744073709551615.5", ulong.MaxValue); |
| 158 | + } |
| 159 | + |
| 160 | + [TestMethod] |
| 161 | + public void ToIntegerOutOfRange() |
| 162 | + { |
| 163 | + PreciseNumber tooLargeForByte = P("300"); |
| 164 | + Assert.ThrowsExactly<OverflowException>(() => Checked<byte, PreciseNumber>(tooLargeForByte)); |
| 165 | + Assert.AreEqual(byte.MaxValue, Saturating<byte, PreciseNumber>(tooLargeForByte)); |
| 166 | + Assert.AreEqual((byte)44, Truncating<byte, PreciseNumber>(tooLargeForByte)); |
| 167 | + |
| 168 | + PreciseNumber negative = P("-1"); |
| 169 | + Assert.ThrowsExactly<OverflowException>(() => Checked<uint, PreciseNumber>(negative)); |
| 170 | + Assert.AreEqual(uint.MinValue, Saturating<uint, PreciseNumber>(negative)); |
| 171 | + Assert.AreEqual(uint.MaxValue, Truncating<uint, PreciseNumber>(negative)); |
| 172 | + |
| 173 | + PreciseNumber pastInt = P("2147483648"); |
| 174 | + Assert.ThrowsExactly<OverflowException>(() => Checked<int, PreciseNumber>(pastInt)); |
| 175 | + Assert.AreEqual(int.MaxValue, Saturating<int, PreciseNumber>(pastInt)); |
| 176 | + Assert.AreEqual(int.MinValue, Truncating<int, PreciseNumber>(pastInt)); |
| 177 | + |
| 178 | + Assert.ThrowsExactly<OverflowException>(() => Checked<char, PreciseNumber>(P("70000"))); |
| 179 | + } |
| 180 | + |
| 181 | + [TestMethod] |
| 182 | + public void ToIntegerFromHugeValuesMatchesBigInteger() |
| 183 | + { |
| 184 | + foreach (string text in new[] { "7e5000", "-7e5000", "123456789e50", "-98765e45", "3e39", "1e38" }) |
| 185 | + { |
| 186 | + PreciseNumber number = P(text); |
| 187 | + BigInteger integer = number.To<BigInteger>(); |
| 188 | + |
| 189 | + Assert.AreEqual(Truncating<int, BigInteger>(integer), Truncating<int, PreciseNumber>(number), $"int truncating {text}"); |
| 190 | + Assert.AreEqual(Truncating<ulong, BigInteger>(integer), Truncating<ulong, PreciseNumber>(number), $"ulong truncating {text}"); |
| 191 | + Assert.AreEqual(Truncating<Int128, BigInteger>(integer), Truncating<Int128, PreciseNumber>(number), $"Int128 truncating {text}"); |
| 192 | + Assert.AreEqual(Truncating<UInt128, BigInteger>(integer), Truncating<UInt128, PreciseNumber>(number), $"UInt128 truncating {text}"); |
| 193 | + Assert.AreEqual(Saturating<long, BigInteger>(integer), Saturating<long, PreciseNumber>(number), $"long saturating {text}"); |
| 194 | + Assert.AreEqual(Saturating<UInt128, BigInteger>(integer), Saturating<UInt128, PreciseNumber>(number), $"UInt128 saturating {text}"); |
| 195 | + } |
| 196 | + |
| 197 | + Assert.ThrowsExactly<OverflowException>(() => Checked<Int128, PreciseNumber>(P("7e5000"))); |
| 198 | + } |
| 199 | + |
| 200 | + [TestMethod] |
| 201 | + public void ToBigIntegerTruncatesTowardZero() |
| 202 | + { |
| 203 | + AssertToInAllModes("12345e5", BigInteger.Parse("1234500000", CultureInfo.InvariantCulture)); |
| 204 | + AssertToInAllModes("-12.9", new BigInteger(-12)); |
| 205 | + AssertToInAllModes("0.0001", BigInteger.Zero); |
| 206 | + } |
| 207 | + |
| 208 | + [TestMethod] |
| 209 | + public void ToDoubleIsCorrectlyRounded() |
| 210 | + { |
| 211 | + string[] texts = |
| 212 | + [ |
| 213 | + "0.1", |
| 214 | + "0.3048", |
| 215 | + "-123.456", |
| 216 | + "3.14159265358979323846264338327950288419716939937510582097494", |
| 217 | + "9007199254740993", |
| 218 | + "9007199254740995", |
| 219 | + "2.2250738585072011e-308", |
| 220 | + "4.9406564584124654e-324", |
| 221 | + "1.7976931348623157e308", |
| 222 | + "123456789012345678901234567890e-45", |
| 223 | + "0.000000000000000000000000000000000000000001", |
| 224 | + ]; |
| 225 | + |
| 226 | + foreach (string text in texts) |
| 227 | + { |
| 228 | + double expected = double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture); |
| 229 | + AssertToInAllModes(text, expected); |
| 230 | + } |
| 231 | + } |
| 232 | + |
| 233 | + [TestMethod] |
| 234 | + public void ToDoubleOverflowsToInfinity() |
| 235 | + { |
| 236 | + AssertToInAllModes("1e400", double.PositiveInfinity); |
| 237 | + AssertToInAllModes("-1e400", double.NegativeInfinity); |
| 238 | + AssertToInAllModes("1e-400", 0.0); |
| 239 | + } |
| 240 | + |
| 241 | + [TestMethod] |
| 242 | + public void ToSingleAndHalfAreCorrectlyRounded() |
| 243 | + { |
| 244 | + foreach (string text in new[] { "0.1", "16777217", "3.4028235e38", "1.401298464324817e-45", "2.718281828459045235360287471352662497757" }) |
| 245 | + { |
| 246 | + AssertToInAllModes(text, float.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); |
| 247 | + } |
| 248 | + |
| 249 | + foreach (string text in new[] { "0.1", "65504", "65520", "1.5", "-3.14159" }) |
| 250 | + { |
| 251 | + AssertToInAllModes(text, Half.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); |
| 252 | + } |
| 253 | + } |
| 254 | + |
| 255 | + [TestMethod] |
| 256 | + public void ToDecimalRoundsExcessDigits() |
| 257 | + { |
| 258 | + AssertToInAllModes("1234.5678", 1234.5678m); |
| 259 | + AssertToInAllModes("79228162514264337593543950335", decimal.MaxValue); |
| 260 | + |
| 261 | + const string longFraction = "0.12345678901234567890123456789012345678901234"; |
| 262 | + AssertToInAllModes(longFraction, decimal.Parse(longFraction, NumberStyles.Float, CultureInfo.InvariantCulture)); |
| 263 | + } |
| 264 | + |
| 265 | + [TestMethod] |
| 266 | + public void ToDecimalOutOfRange() |
| 267 | + { |
| 268 | + foreach (string text in new[] { "1e30", "79228162514264337593543950336" }) |
| 269 | + { |
| 270 | + PreciseNumber number = P(text); |
| 271 | + Assert.ThrowsExactly<OverflowException>(() => Checked<decimal, PreciseNumber>(number), text); |
| 272 | + Assert.AreEqual(decimal.MaxValue, Saturating<decimal, PreciseNumber>(number), text); |
| 273 | + Assert.AreEqual(decimal.MaxValue, Truncating<decimal, PreciseNumber>(number), text); |
| 274 | + Assert.AreEqual(decimal.MinValue, Saturating<decimal, PreciseNumber>(-number), text); |
| 275 | + } |
| 276 | + |
| 277 | + // BigInteger clamps rather than wraps when truncating to decimal, and so does PreciseNumber. |
| 278 | + Assert.AreEqual(decimal.MaxValue, Truncating<decimal, BigInteger>(BigInteger.Pow(10, 30))); |
| 279 | + } |
| 280 | + |
| 281 | + [TestMethod] |
| 282 | + public void BuiltInCreateCheckedReachesPreciseNumber() |
| 283 | + { |
| 284 | + Assert.AreEqual(1.5, double.CreateChecked(P("1.5"))); |
| 285 | + Assert.AreEqual(12, int.CreateChecked(P("12.75"))); |
| 286 | + Assert.AreEqual(1234.5678m, decimal.CreateSaturating(P("1234.5678"))); |
| 287 | + Assert.AreEqual(BigInteger.One, BigInteger.CreateTruncating(P("1.9"))); |
| 288 | + } |
| 289 | + |
| 290 | + [TestMethod] |
| 291 | + public void ToUsesTheSameConversions() |
| 292 | + { |
| 293 | + Assert.AreEqual(12, P("12.9").To<int>()); |
| 294 | + Assert.AreEqual( |
| 295 | + double.Parse("3.14159265358979323846264338327950288419716939937510582097494", NumberStyles.Float, CultureInfo.InvariantCulture), |
| 296 | + P("3.14159265358979323846264338327950288419716939937510582097494").To<double>()); |
| 297 | + } |
| 298 | +} |
0 commit comments