From 8331e34d8981a2db9d5217fb847c80485206fc55 Mon Sep 17 00:00:00 2001 From: Matthew Layton Date: Wed, 23 Apr 2025 10:27:47 +0100 Subject: [PATCH] Fixed a bug where `SetScale` was producing invalid results. --- Directory.Build.props | 6 +- .../NumericsExtensionsTests.cs | 8 +-- OnixLabs.Numerics/NumericsExtensions.cs | 69 +++++++++++++++---- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 62f19cd..8e66a1f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,8 +1,8 @@ - 12.1.0 - 12.1.0 - 12.1.0 + 12.1.1 + 12.1.1 + 12.1.1 net8.0;net9.0 13 enable diff --git a/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs b/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs index 3d408c0..27854ed 100644 --- a/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs +++ b/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs @@ -169,16 +169,16 @@ public void DecimalSetScaleWithRoundingShouldTruncateWhenNoPrecisionLoss(string public void DecimalSetScaleShouldThrowWhenScaleIsNegative() { // Given / When / Then - Exception exception = Assert.Throws(() => 123.45m.SetScale(-1)); - Assert.Contains("Scale must be greater than, or equal to zero.", exception.Message); + Exception exception = Assert.Throws(() => 123.45m.SetScale(-1)); + Assert.Contains("Scale must be within the inclusive range of 0 to 28.", exception.Message); } [Fact(DisplayName = "Decimal.SetScale(rounding) should throw when scale is negative")] public void DecimalSetScaleWithRoundingShouldThrowWhenScaleIsNegative() { // Given / When / Then - Exception exception = Assert.Throws(() => 123.45m.SetScale(-1, MidpointRounding.AwayFromZero)); - Assert.Contains("Scale must be greater than, or equal to zero.", exception.Message); + Exception exception = Assert.Throws(() => 123.45m.SetScale(-1, MidpointRounding.AwayFromZero)); + Assert.Contains("Scale must be within the inclusive range of 0 to 28.", exception.Message); } [Theory(DisplayName = "INumber.IsBetween should produce the expected result")] diff --git a/OnixLabs.Numerics/NumericsExtensions.cs b/OnixLabs.Numerics/NumericsExtensions.cs index 46435c0..ccb55b0 100644 --- a/OnixLabs.Numerics/NumericsExtensions.cs +++ b/OnixLabs.Numerics/NumericsExtensions.cs @@ -24,6 +24,11 @@ namespace OnixLabs.Numerics; [EditorBrowsable(EditorBrowsableState.Never)] public static class NumericsExtensions { + /// + /// The maximum scale of a value. + /// + private const int MaxScale = 28; + /// /// Gets the minimum value of a value as a . /// @@ -66,24 +71,39 @@ public static BigInteger GetUnscaledValue(this decimal value) /// if is negative. public static decimal SetScale(this decimal value, int scale) { - Require(scale >= 0, "Scale must be greater than, or equal to zero.", nameof(scale)); + RequireWithinRangeInclusive(scale, 0, MaxScale, "Scale must be within the inclusive range of 0 to 28.", nameof(scale)); + + // Determine maximum representable scale given the integer part length + BigInteger unscaledValue = value.GetUnscaledValue(); + BigInteger absUnscaled = BigInteger.Abs(unscaledValue); + BigInteger factorCurrent = BigInteger.Pow(10, value.Scale); + BigInteger integerPart = BigInteger.DivRem(absUnscaled, factorCurrent, out _); + int integerDigits = integerPart.IsZero ? 1 : integerPart.ToString().Length; + int maxPossibleScale = MaxScale - integerDigits; + Require(scale <= maxPossibleScale, $"Maximum possible scale for the specified value is {maxPossibleScale}.", nameof(scale)); + + // No change needed if (value.Scale == scale) return value; + // Increase scale: pad with zeros if (value.Scale < scale) { - decimal factor = GenericMath.Pow10(scale - value.Scale); - return value * factor / factor; + int diff = scale - value.Scale; + BigInteger padded = unscaledValue * BigInteger.Pow(10, diff); + return padded.ToDecimal(scale); } - decimal pow10 = GenericMath.Pow10(scale); - decimal truncated = Math.Truncate(value * pow10) / pow10; + // Decrease scale: drop extra digits + int drop = value.Scale - scale; + BigInteger divisor = BigInteger.Pow(10, drop); + BigInteger quotient = BigInteger.DivRem(unscaledValue, divisor, out BigInteger remainder); - if (value == truncated) - return truncated; + // If there is any remainder, dropping would lose precision + Check(remainder == 0, $"Cannot set scale to {scale} due to a loss of precision."); - throw new InvalidOperationException($"Cannot reduce scale without losing precision: {value}"); + return quotient.ToDecimal(scale); } /// @@ -101,21 +121,42 @@ public static decimal SetScale(this decimal value, int scale) /// if is negative. public static decimal SetScale(this decimal value, int scale, MidpointRounding mode) { - Require(scale >= 0, "Scale must be greater than, or equal to zero.", nameof(scale)); + RequireWithinRangeInclusive(scale, 0, MaxScale, "Scale must be within the inclusive range of 0 to 28.", nameof(scale)); + + // Determine maximum representable scale + BigInteger unscaledValue = value.GetUnscaledValue(); + BigInteger absUnscaled = BigInteger.Abs(unscaledValue); + BigInteger factorCurrent = BigInteger.Pow(10, value.Scale); + BigInteger integerPart = BigInteger.DivRem(absUnscaled, factorCurrent, out _); + int integerDigits = integerPart.IsZero ? 1 : integerPart.ToString().Length; + int maxPossibleScale = MaxScale - integerDigits; + Require(scale <= maxPossibleScale, $"Maximum possible scale for the specified value is {maxPossibleScale}.", nameof(scale)); + + // No change needed if (value.Scale == scale) return value; + // Increase scale: pad with zeros if (value.Scale < scale) { - decimal factor = GenericMath.Pow10(scale - value.Scale); - return value * factor / factor; + int diff = scale - value.Scale; + BigInteger padded = unscaledValue * BigInteger.Pow(10, diff); + return padded.ToDecimal(scale); } - decimal pow10 = GenericMath.Pow10(scale); - decimal truncated = Math.Truncate(value * pow10) / pow10; + // Decrease scale: drop or round extra digits + int drop = value.Scale - scale; + BigInteger divisor = BigInteger.Pow(10, drop); + BigInteger.DivRem(unscaledValue, divisor, out BigInteger remainder); + + // If fractional remainder, then rounding required + if (remainder != 0) + return decimal.Round(value, scale, mode); - return value == truncated ? truncated : Math.Round(value, scale, mode); + // No rounding required + BigInteger quotient = unscaledValue / divisor; + return quotient.ToDecimal(scale); } ///