From 131fe0d53739c8a4b7903f16458c51366c0de642 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 17:29:24 +0000 Subject: [PATCH] Convert an INumber type that converts itself to PreciseNumber in ToPreciseNumber [patch] ToPreciseNumber accepted only integer and floating-point types, so a type such as SignificantNumber, which implements TryConvertToChecked, threw NotSupportedException, and p.CompareTo(s) and p.Clamp(s, s) with it. Fall back to CreateChecked for any other type, and throw only when that finds no conversion either. Fixes #188 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3RtuaocwQ2QRF7djh --- CLAUDE.md | 2 +- .../PreciseNumberConversionTests.cs | 29 +++++ PreciseNumber.Test/SelfConvertingNumber.cs | 122 ++++++++++++++++++ PreciseNumber/PreciseNumberExtensions.cs | 34 ++++- 4 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 PreciseNumber.Test/SelfConvertingNumber.cs 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); }