diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 881918d2e..86d6b9071 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -15,6 +15,40 @@ read first. --- +## Unreleased — since 2.2.0 + +### At a glance + +| Silent? | What | Was | Is | +|---|---|---|---| +| | `(Entity.Number)someBigInteger` | `FormatException: Illegal character found`, for almost every value | the number | + +### A `BigInteger` converts to a `Number` instead of throwing + +`Entity` and `Entity.Number` both offer an implicit conversion from +`System.Numerics.BigInteger`, and they did not agree. `Entity`'s read the two's-complement bytes; +`Number`'s passed the same bytes to `EInteger.FromString`, whose `byte[]` overload reads them as +**ASCII digits**: + +```csharp +Entity fine = new BigInteger(123456789); // 123456789 +Entity.Number threw = new BigInteger(123456789); // FormatException: Illegal character found +``` + +So the conversion failed for every value whose bytes are not digit characters — which is nearly all +of them, `1` included. The few that worked did so by accident: `12594` is the two bytes `'2'` and +`'1'`, and came out as `21`. + +Nothing in the library reached it, which is why no test caught it; it is only on the public surface. +Both conversions now read the bytes, and a test requires the two to agree rather than merely to +work. + +Found while documenting the members a `#pragma warning disable CS1591` was covering — the two +conversions sit in different files and had to be read side by side to look wrong. +[#585](https://github.com/asc-community/AngouriMath/issues/585). + +--- + ## 2.2.0 — since 2.1.0 ### At a glance diff --git a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs index 6195a01f0..c7d1a8c7b 100644 --- a/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs +++ b/Sources/AngouriMath/Core/Entity/Continuous/Entity.Continuous.Number.Definition.cs @@ -42,26 +42,84 @@ public abstract partial record Number : ContinuousNode /// For example, integers and rationals are such /// public abstract bool IsExact { get; } -#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + // Each of these lands on the narrowest kind that holds the value exactly: the + // integral types on Integer, a ratio on Rational, the rest on Real. Number is + // abstract, so what comes back is always one of its concrete kinds. + + /// The number as an . public static implicit operator Number(sbyte value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(byte value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(short value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(ushort value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(int value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(uint value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(long value) => Integer.Create(value); + + /// The number as an . public static implicit operator Number(ulong value) => Integer.Create(value); + + /// + /// The integer as an , of any size — this is the conversion to + /// reach for where a value will not fit in a . + /// public static implicit operator Number(EInteger value) => Integer.Create(value); + + /// + /// The ratio as a , in lowest terms and with a positive + /// denominator — or an where the denominator reduces to one. + /// public static implicit operator Number(ERational value) => Rational.Create(value); + + /// The decimal as a . public static implicit operator Number(EDecimal value) => Real.Create(value); + + /// + /// The value as a , read as the binary it + /// is — so 0.1f becomes the value that literal holds, not one tenth. + /// public static implicit operator Number(float value) => Real.Create(EDecimal.FromSingle(value)); + + /// + /// The value as a , read as the binary it + /// is rather than as the decimal it was written as. + /// public static implicit operator Number(double value) => Real.Create(EDecimal.FromDouble(value)); + + /// + /// The value as a . A is decimal + /// already, so this one keeps the digits that were written. + /// public static implicit operator Number(decimal value) => Real.Create(EDecimal.FromDecimal(value)); + + /// + /// The .NET complex number as a , through its two + /// parts and their precision. + /// public static implicit operator Number(System.Numerics.Complex value) => Complex.Create(EDecimal.FromDouble(value.Real), EDecimal.FromDouble(value.Imaginary)); + /// The big integer as an , exactly and at any size. + /// + /// Read as the two's-complement bytes it is, which is what + /// BigInteger.ToByteArray returns and what the same conversion on + /// has always done. The overload reached before was + /// EInteger.FromString(byte[]), which reads a byte array as ASCII digits, so + /// every value whose bytes are not digit characters threw a FormatException — + /// which is nearly all of them, 1 included. + /// public static implicit operator Number(System.Numerics.BigInteger bigInt) - => Integer.Create(EInteger.FromString(bigInt.ToByteArray())); -#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member + => Integer.Create(EInteger.FromBytes(bigInt.ToByteArray(), littleEndian: true)); } } } diff --git a/Sources/Tests/UnitTests/Core/BigIntegerConversionTest.cs b/Sources/Tests/UnitTests/Core/BigIntegerConversionTest.cs new file mode 100644 index 000000000..17cfc48b7 --- /dev/null +++ b/Sources/Tests/UnitTests/Core/BigIntegerConversionTest.cs @@ -0,0 +1,75 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using System.Numerics; +using AngouriMath; +using Xunit; + +namespace AngouriMath.Tests.Core +{ + /// + /// The two conversions from , which used to disagree: the one to + /// read the bytes and the one to parsed + /// them as text, so the second threw on nearly every input. + /// + [Trait("Area", "Core")] + public sealed class BigIntegerConversionTest + { + public static readonly TheoryData Values = new() + { + "0", "1", "-1", "2", "-2", "127", "128", "255", "256", "-128", "-129", + "123456789", "-123456789", "9223372036854775807", "9223372036854775808", + "-9223372036854775808", "-9223372036854775809", + "170141183460469231731687303715884105728", + "-170141183460469231731687303715884105728", + }; + + /// To , which is the one that was broken. + [Theory] + [MemberData(nameof(Values))] + public void ToNumber(string text) + { + Entity.Number converted = BigInteger.Parse(text); + Assert.Equal(text, converted.Stringize()); + } + + /// And to , which was not, pinned so it stays that way. + [Theory] + [MemberData(nameof(Values))] + public void ToEntity(string text) + { + Entity converted = BigInteger.Parse(text); + Assert.Equal(text, converted.Stringize()); + } + + /// + /// And the two agree, which is the property whose absence was the defect: one conversion + /// of one value should not depend on which of them the caller reaches. + /// + [Theory] + [MemberData(nameof(Values))] + public void AndTheTwoAgree(string text) + { + var big = BigInteger.Parse(text); + Entity asEntity = big; + Entity.Number asNumber = big; + Assert.Equal(asEntity, (Entity)asNumber); + } + + /// + /// A value whose bytes happen to be ASCII digits was the only kind that used to work, + /// and it worked by accident: 12594 is the two bytes '2' and '1', which the old code + /// read as the number 21. + /// + [Fact] + public void TheValuesThatUsedToWorkWorkedByAccident() + { + Entity.Number converted = new BigInteger(12594); + Assert.Equal("12594", converted.Stringize()); + } + } +}