diff --git a/CLAUDE.md b/CLAUDE.md index 0e7c0ce..eacf1a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,7 +30,7 @@ dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job s - **Generic conversions** (`PreciseNumber/PreciseNumber.Conversions.cs`): The `TryConvertFrom*` and `TryConvertTo*` members behind `CreateChecked`, `CreateSaturating`, and `CreateTruncating`, for every built-in numeric type and `BigInteger`. `To()` uses them too -- **PreciseNumberExtensions** (`PreciseNumber/PreciseNumberExtensions.cs`): Extension methods providing `ToPreciseNumber()` for converting any `INumber` to PreciseNumber +- **PreciseNumberExtensions** (`PreciseNumber/PreciseNumberExtensions.cs`): Extension methods providing `ToPreciseNumber()` for converting any `INumber` to PreciseNumber. Integer and floating-point types convert directly; any other type falls back to `CreateChecked`, so a type that converts itself through `TryConvertToChecked` (such as `ktsu.SignificantNumber`) works, and only a type that can't throws `NotSupportedException` ### Key Design Patterns diff --git a/PreciseNumber.Test/PreciseNumberConversionTests.cs b/PreciseNumber.Test/PreciseNumberConversionTests.cs index d99a6df..541c911 100644 --- a/PreciseNumber.Test/PreciseNumberConversionTests.cs +++ b/PreciseNumber.Test/PreciseNumberConversionTests.cs @@ -438,4 +438,33 @@ public void SmallestExponentConvertsToZero() AssertToInAllModes(value, BigInteger.Zero); } } + + [TestMethod] + public void TestToPreciseNumberAcceptsATypeThatConvertsItself() + { + // SelfConvertingNumber is neither an integer nor a floating-point type, as SignificantNumber + // isn't, but it converts itself through TryConvertToChecked. + SelfConvertingNumber one = new(1.5m, CanConvert: true); + SelfConvertingNumber lo = new(2m, CanConvert: true); + SelfConvertingNumber hi = new(3m, CanConvert: true); + PreciseNumber p = P("1.5"); + + Assert.AreEqual(Checked(one), one.ToPreciseNumber()); + Assert.AreEqual(P("1.5"), one.ToPreciseNumber()); + Assert.AreEqual(0, p.CompareTo(one)); + Assert.AreEqual(-1, p.CompareTo(lo)); + Assert.AreEqual(P("2"), p.Clamp(lo, hi)); + Assert.AreEqual(P("1.5"), p.Clamp(one, hi)); + } + + [TestMethod] + public void TestToPreciseNumberStillRejectsATypeThatCannotConvert() + { + SelfConvertingNumber stuck = new(1.5m, CanConvert: false); + PreciseNumber p = P("1.5"); + + Assert.ThrowsExactly(() => stuck.ToPreciseNumber()); + Assert.ThrowsExactly(() => p.CompareTo(stuck)); + Assert.ThrowsExactly(() => p.Clamp(stuck, stuck)); + } } diff --git a/PreciseNumber.Test/SelfConvertingNumber.cs b/PreciseNumber.Test/SelfConvertingNumber.cs new file mode 100644 index 0000000..9fa2d7f --- /dev/null +++ b/PreciseNumber.Test/SelfConvertingNumber.cs @@ -0,0 +1,122 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Test; + +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Numerics; + +/// +/// A number that is neither an integer nor a floating-point type, as ktsu.SignificantNumber is, +/// and that converts itself to through TryConvertToChecked when +/// is set, and to nothing otherwise. +/// +/// The value it holds. +/// Whether it can convert itself to . +internal readonly record struct SelfConvertingNumber(decimal Value, bool CanConvert) : INumber +{ + public static SelfConvertingNumber One => new(1m, true); + public static int Radix => 10; + public static SelfConvertingNumber Zero => new(0m, true); + public static SelfConvertingNumber AdditiveIdentity => Zero; + public static SelfConvertingNumber MultiplicativeIdentity => One; + + private SelfConvertingNumber With(decimal value) => new(value, CanConvert); + + public static bool TryConvertToChecked(SelfConvertingNumber value, [MaybeNullWhen(false)] out TOther result) + where TOther : INumberBase + { + if (value.CanConvert && typeof(TOther) == typeof(PreciseNumber)) + { + result = (TOther)(object)value.Value.ToPreciseNumber(); + return true; + } + + result = default; + return false; + } + + public static bool TryConvertToSaturating(SelfConvertingNumber value, [MaybeNullWhen(false)] out TOther result) + where TOther : INumberBase => TryConvertToChecked(value, out result); + + public static bool TryConvertToTruncating(SelfConvertingNumber value, [MaybeNullWhen(false)] out TOther result) + where TOther : INumberBase => TryConvertToChecked(value, out result); + + public static bool TryConvertFromChecked(TOther value, out SelfConvertingNumber result) + where TOther : INumberBase + { + result = default; + return false; + } + + public static bool TryConvertFromSaturating(TOther value, out SelfConvertingNumber result) + where TOther : INumberBase => TryConvertFromChecked(value, out result); + + public static bool TryConvertFromTruncating(TOther value, out SelfConvertingNumber result) + where TOther : INumberBase => TryConvertFromChecked(value, out result); + + public static SelfConvertingNumber Abs(SelfConvertingNumber value) => value.With(Math.Abs(value.Value)); + public static bool IsCanonical(SelfConvertingNumber value) => true; + public static bool IsComplexNumber(SelfConvertingNumber value) => false; + public static bool IsEvenInteger(SelfConvertingNumber value) => decimal.IsEvenInteger(value.Value); + public static bool IsFinite(SelfConvertingNumber value) => true; + public static bool IsImaginaryNumber(SelfConvertingNumber value) => false; + public static bool IsInfinity(SelfConvertingNumber value) => false; + public static bool IsInteger(SelfConvertingNumber value) => decimal.IsInteger(value.Value); + public static bool IsNaN(SelfConvertingNumber value) => false; + public static bool IsNegative(SelfConvertingNumber value) => decimal.IsNegative(value.Value); + public static bool IsNegativeInfinity(SelfConvertingNumber value) => false; + public static bool IsNormal(SelfConvertingNumber value) => value.Value != 0m; + public static bool IsOddInteger(SelfConvertingNumber value) => decimal.IsOddInteger(value.Value); + public static bool IsPositive(SelfConvertingNumber value) => decimal.IsPositive(value.Value); + public static bool IsPositiveInfinity(SelfConvertingNumber value) => false; + public static bool IsRealNumber(SelfConvertingNumber value) => true; + public static bool IsSubnormal(SelfConvertingNumber value) => false; + public static bool IsZero(SelfConvertingNumber value) => value.Value == 0m; + public static SelfConvertingNumber MaxMagnitude(SelfConvertingNumber x, SelfConvertingNumber y) => x.With(decimal.MaxMagnitude(x.Value, y.Value)); + public static SelfConvertingNumber MaxMagnitudeNumber(SelfConvertingNumber x, SelfConvertingNumber y) => MaxMagnitude(x, y); + public static SelfConvertingNumber MinMagnitude(SelfConvertingNumber x, SelfConvertingNumber y) => x.With(decimal.MinMagnitude(x.Value, y.Value)); + public static SelfConvertingNumber MinMagnitudeNumber(SelfConvertingNumber x, SelfConvertingNumber y) => MinMagnitude(x, y); + + public static SelfConvertingNumber Parse(ReadOnlySpan s, NumberStyles style, IFormatProvider? provider) => new(decimal.Parse(s, style, provider), true); + public static SelfConvertingNumber Parse(string s, NumberStyles style, IFormatProvider? provider) => new(decimal.Parse(s, style, provider), true); + public static SelfConvertingNumber Parse(ReadOnlySpan s, IFormatProvider? provider) => new(decimal.Parse(s, provider), true); + public static SelfConvertingNumber Parse(string s, IFormatProvider? provider) => new(decimal.Parse(s, provider), true); + + public static bool TryParse(ReadOnlySpan s, NumberStyles style, IFormatProvider? provider, out SelfConvertingNumber result) + { + bool parsed = decimal.TryParse(s, style, provider, out decimal value); + result = new(value, true); + return parsed; + } + + public static bool TryParse([NotNullWhen(true)] string? s, NumberStyles style, IFormatProvider? provider, out SelfConvertingNumber result) => + TryParse(s.AsSpan(), style, provider, out result); + + public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out SelfConvertingNumber result) => + TryParse(s, NumberStyles.Number, provider, out result); + + public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out SelfConvertingNumber result) => + TryParse(s.AsSpan(), NumberStyles.Number, provider, out result); + + public int CompareTo(object? obj) => obj is SelfConvertingNumber other ? CompareTo(other) : 1; + public int CompareTo(SelfConvertingNumber other) => Value.CompareTo(other.Value); + public string ToString(string? format, IFormatProvider? formatProvider) => Value.ToString(format, formatProvider); + + public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) => + Value.TryFormat(destination, out charsWritten, format, provider); + + public static SelfConvertingNumber operator +(SelfConvertingNumber value) => value; + public static SelfConvertingNumber operator -(SelfConvertingNumber value) => value.With(-value.Value); + public static SelfConvertingNumber operator +(SelfConvertingNumber left, SelfConvertingNumber right) => left.With(left.Value + right.Value); + public static SelfConvertingNumber operator -(SelfConvertingNumber left, SelfConvertingNumber right) => left.With(left.Value - right.Value); + public static SelfConvertingNumber operator *(SelfConvertingNumber left, SelfConvertingNumber right) => left.With(left.Value * right.Value); + public static SelfConvertingNumber operator /(SelfConvertingNumber left, SelfConvertingNumber right) => left.With(left.Value / right.Value); + public static SelfConvertingNumber operator %(SelfConvertingNumber left, SelfConvertingNumber right) => left.With(left.Value % right.Value); + public static SelfConvertingNumber operator ++(SelfConvertingNumber value) => value.With(value.Value + 1m); + public static SelfConvertingNumber operator --(SelfConvertingNumber value) => value.With(value.Value - 1m); + public static bool operator <(SelfConvertingNumber left, SelfConvertingNumber right) => left.Value < right.Value; + public static bool operator >(SelfConvertingNumber left, SelfConvertingNumber right) => left.Value > right.Value; + public static bool operator <=(SelfConvertingNumber left, SelfConvertingNumber right) => left.Value <= right.Value; + public static bool operator >=(SelfConvertingNumber left, SelfConvertingNumber right) => left.Value >= right.Value; +} diff --git a/PreciseNumber/PreciseNumberExtensions.cs b/PreciseNumber/PreciseNumberExtensions.cs index 2753353..9351d99 100644 --- a/PreciseNumber/PreciseNumberExtensions.cs +++ b/PreciseNumber/PreciseNumberExtensions.cs @@ -117,8 +117,38 @@ internal static bool TryCreate([NotNullWhen(true)] TInput input, out Pre return true; default: - preciseNumber = default; - return false; + return TryCreateThroughGenericMath(input, out preciseNumber); } } + + /// + /// Converts a type that is neither an integer nor a floating-point type, but may know how to become a + /// itself through TryConvertToChecked, as ktsu.SignificantNumber + /// does. + /// + /// + /// CreateChecked asks first and then , + /// and throws when neither can convert, which is read here as + /// "unsupported". Any other exception, such as an , is the conversion's + /// own answer and is left to propagate, as it would from CreateChecked. + /// + private static bool TryCreateThroughGenericMath(TInput input, out PreciseNumber preciseNumber) + where TInput : INumber + { + try + { + preciseNumber = CreateChecked(input); + return true; + } + catch (NotSupportedException) + { + preciseNumber = default; + return false; + } + } + + private static TSelf CreateChecked(TOther value) + where TSelf : INumberBase + where TOther : INumberBase + => TSelf.CreateChecked(value); }