diff --git a/Directory.Build.props b/Directory.Build.props
index 9ca331a..62f19cd 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -1,5 +1,8 @@
+ 12.1.0
+ 12.1.0
+ 12.1.0
net8.0;net9.0
13
enable
@@ -8,8 +11,5 @@
en
Copyright © ONIXLabs 2020
https://github.com/onix-labs/onixlabs-dotnet
- 12.0.0
- 12.0.0
- 12.0.0
diff --git a/OnixLabs.Numerics.UnitTests/GenericMathPow10Tests.cs b/OnixLabs.Numerics.UnitTests/GenericMathPow10Tests.cs
new file mode 100644
index 0000000..09cb894
--- /dev/null
+++ b/OnixLabs.Numerics.UnitTests/GenericMathPow10Tests.cs
@@ -0,0 +1,94 @@
+// Copyright 2020-2025 ONIXLabs
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+using System;
+using Xunit;
+
+namespace OnixLabs.Numerics.UnitTests;
+
+public sealed class GenericMathPow10Tests
+{
+ [Theory(DisplayName = "GenericMath.Pow10 should produce the expected result (Int32)")]
+ [InlineData(0, 1)]
+ [InlineData(1, 10)]
+ [InlineData(2, 100)]
+ [InlineData(3, 1000)]
+ [InlineData(4, 10000)]
+ [InlineData(5, 100000)]
+ [InlineData(6, 1000000)]
+ [InlineData(9, 1000000000)]
+ public void GenericMathPow10ShouldProduceExpectedResultInt32(int exponent, int expected)
+ {
+ // Given / When
+ int result = GenericMath.Pow10(exponent);
+
+ // Then
+ Assert.Equal(expected, result);
+ }
+
+ [Theory(DisplayName = "GenericMath.Pow10 should produce the expected result (Int64)")]
+ [InlineData(0, 1L)]
+ [InlineData(1, 10L)]
+ [InlineData(2, 100L)]
+ [InlineData(10, 10000000000L)]
+ [InlineData(15, 1000000000000000L)]
+ public void GenericMathPow10ShouldProduceExpectedResultInt64(int exponent, long expected)
+ {
+ // Given / When
+ long result = GenericMath.Pow10(exponent);
+
+ // Then
+ Assert.Equal(expected, result);
+ }
+
+ [Theory(DisplayName = "GenericMath.Pow10 should produce the expected result (Double)")]
+ [InlineData(0, 1.0)]
+ [InlineData(3, 1000.0)]
+ [InlineData(6, 1e6)]
+ [InlineData(9, 1e9)]
+ [InlineData(15, 1e15)]
+ public void GenericMathPow10ShouldProduceExpectedResultDouble(int exponent, double expected)
+ {
+ // Given / When
+ double result = GenericMath.Pow10(exponent);
+
+ // Then
+ Assert.Equal(expected, result, precision: 10);
+ }
+
+ [Theory(DisplayName = "GenericMath.Pow10 should produce the expected result (Decimal)")]
+ [InlineData(0, "1")]
+ [InlineData(1, "10")]
+ [InlineData(5, "100000")]
+ [InlineData(10, "10000000000")]
+ public void GenericMathPow10ShouldProduceExpectedResultDecimal(int exponent, string expectedStr)
+ {
+ // Given / When
+ decimal expected = decimal.Parse(expectedStr);
+ decimal result = GenericMath.Pow10(exponent);
+
+ // Then
+ Assert.Equal(expected, result);
+ }
+
+ [Fact(DisplayName = "GenericMath.Pow10 should throw ArgumentException when the exponent is negative.")]
+ public void GenericMathPow10ShouldThrowArgumentExceptionWhenExponentNegative()
+ {
+ // Given / When
+ Exception exception = Assert.Throws(() => GenericMath.Pow10(-1));
+
+ // Then
+ Assert.Contains("Exponent must be greater", exception.Message);
+ }
+}
diff --git a/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs b/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs
index 4fc4ee4..3d408c0 100644
--- a/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs
+++ b/OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs
@@ -80,6 +80,107 @@ public void DecimalGetUnscaledValueShouldProduceExpectedResultValueAndScale(int
Assert.Equal(expected, actual);
}
+ [Theory(DisplayName = "Decimal.SetScale should preserve or pad when scale is less or equal")]
+ [InlineData("123.0", 2, "123.00")]
+ [InlineData("123.00", 2, "123.00")]
+ [InlineData("123.000", 2, "123.00")]
+ [InlineData("0.0", 2, "0.00")]
+ [InlineData("123.12", 2, "123.12")]
+ public void DecimalSetScaleShouldPreserveOrPadWhenScaleIsLessOrEqual(string inputStr, int scale, string expectedStr)
+ {
+ // Given
+ decimal input = decimal.Parse(inputStr);
+ decimal expected = decimal.Parse(expectedStr);
+
+ // When
+ decimal result = input.SetScale(scale);
+
+ // Then
+ Assert.Equal(expected, result);
+ }
+
+ [Theory(DisplayName = "Decimal.SetScale should truncate when no precision loss")]
+ [InlineData("123.1200", 2, "123.12")]
+ [InlineData("0.1000", 1, "0.1")]
+ [InlineData("999.0000", 3, "999.000")]
+ public void DecimalSetScaleShouldTruncateWhenNoPrecisionLoss(string inputStr, int scale, string expectedStr)
+ {
+ // Given
+ decimal input = decimal.Parse(inputStr);
+ decimal expected = decimal.Parse(expectedStr);
+
+ // When
+ decimal result = input.SetScale(scale);
+
+ // Then
+ Assert.Equal(expected, result);
+ }
+
+ [Theory(DisplayName = "Decimal.SetScale should throw when truncation would lose precision")]
+ [InlineData("123.456", 2)]
+ [InlineData("1.001", 2)]
+ [InlineData("0.123456789", 5)]
+ public void DecimalSetScaleShouldThrowWhenTruncationWouldLosePrecision(string inputStr, int scale)
+ {
+ // Given
+ decimal input = decimal.Parse(inputStr);
+
+ // When / Then
+ Assert.Throws(() => input.SetScale(scale));
+ }
+
+ [Theory(DisplayName = "Decimal.SetScale(rounding) should apply correct rounding")]
+ [InlineData("123.456", 2, MidpointRounding.AwayFromZero, "123.46")]
+ [InlineData("123.454", 2, MidpointRounding.AwayFromZero, "123.45")]
+ [InlineData("123.455", 2, MidpointRounding.ToZero, "123.45")]
+ [InlineData("123.455", 2, MidpointRounding.ToEven, "123.46")]
+ [InlineData("0.125", 2, MidpointRounding.ToEven, "0.12")]
+ [InlineData("0.135", 2, MidpointRounding.ToEven, "0.14")]
+ public void DecimalSetScaleWithRoundingShouldApplyCorrectRounding(string inputStr, int scale, MidpointRounding mode, string expectedStr)
+ {
+ // Given
+ decimal input = decimal.Parse(inputStr);
+ decimal expected = decimal.Parse(expectedStr);
+
+ // When
+ decimal result = input.SetScale(scale, mode);
+
+ // Then
+ Assert.Equal(expected, result);
+ }
+
+ [Theory(DisplayName = "Decimal.SetScale(rounding) should truncate when no precision loss")]
+ [InlineData("123.0000", 2, MidpointRounding.AwayFromZero, "123.00")]
+ [InlineData("1.000", 1, MidpointRounding.ToEven, "1.0")]
+ public void DecimalSetScaleWithRoundingShouldTruncateWhenNoPrecisionLoss(string inputStr, int scale, MidpointRounding mode, string expectedStr)
+ {
+ // Given
+ decimal input = decimal.Parse(inputStr);
+ decimal expected = decimal.Parse(expectedStr);
+
+ // When
+ decimal result = input.SetScale(scale, mode);
+
+ // Then
+ Assert.Equal(expected, result);
+ }
+
+ [Fact(DisplayName = "Decimal.SetScale should throw when scale is negative")]
+ 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);
+ }
+
+ [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);
+ }
+
[Theory(DisplayName = "INumber.IsBetween should produce the expected result")]
[InlineData(0, 0, 0, true)]
[InlineData(0, 0, 1, true)]
diff --git a/OnixLabs.Numerics/GenericMath.cs b/OnixLabs.Numerics/GenericMath.cs
index cd25140..25971b4 100644
--- a/OnixLabs.Numerics/GenericMath.cs
+++ b/OnixLabs.Numerics/GenericMath.cs
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+using System;
using System.Globalization;
using System.Numerics;
@@ -41,7 +42,7 @@ public static BigInteger Factorial(T value) where T : IBinaryInteger
{
Require(value >= T.Zero, "Value must be greater than or equal to zero.");
- if(value <= T.One) return BigInteger.One;
+ if (value <= T.One) return BigInteger.One;
BigInteger result = BigInteger.One;
@@ -71,4 +72,33 @@ public static int IntegerLength(T value) where T : INumberBase
/// The underlying type.
/// Returns the minimum and maximum values from the specified left-hand and right-hand values.
public static (T Min, T Max) MinMax(T left, T right) where T : INumber => (T.Min(left, right), T.Max(left, right));
+
+ ///
+ /// Computes 10 raised to the power of a non-negative integer exponent using exponentiation by squaring, for any numeric type implementing .
+ ///
+ /// The numeric type. Must implement .
+ /// The exponent to raise 10 to. Must be greater than or equal to zero.
+ /// A value of type equal to 10 raised to the power of .
+ /// if is less than zero.
+ public static T Pow10(int exponent) where T : INumber
+ {
+ Require(exponent >= 0, "Exponent must be greater than, or equal to zero.", nameof(exponent));
+
+ if (exponent == 0)
+ return T.One;
+
+ T result = T.One;
+ T baseValue = T.CreateChecked(10);
+
+ while (exponent > 0)
+ {
+ if ((exponent & 1) == 1)
+ result *= baseValue;
+
+ baseValue *= baseValue;
+ exponent >>= 1;
+ }
+
+ return result;
+ }
}
diff --git a/OnixLabs.Numerics/NumericsExtensions.cs b/OnixLabs.Numerics/NumericsExtensions.cs
index 3b9f94f..46435c0 100644
--- a/OnixLabs.Numerics/NumericsExtensions.cs
+++ b/OnixLabs.Numerics/NumericsExtensions.cs
@@ -51,6 +51,73 @@ public static BigInteger GetUnscaledValue(this decimal value)
return decimal.IsPositive(value) ? result : -result;
}
+ ///
+ /// Sets the scale (number of digits after the decimal point) of the current value.
+ ///
+ /// If the scale of the current value is less than the specified scale, then the scale will be padded with zeroes.
+ /// If the scale of the current value is greater that the specified scale, then the scale will be truncated,
+ /// provided that there is no loss of precision; otherwise, will be thrown.
+ ///
+ ///
+ /// The decimal value to adjust.
+ /// The desired, non-negative scale.
+ /// A new with the exact specified scale.
+ /// if reducing the scale would result in a loss of precision.
+ /// 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));
+
+ if (value.Scale == scale)
+ return value;
+
+ if (value.Scale < scale)
+ {
+ decimal factor = GenericMath.Pow10(scale - value.Scale);
+ return value * factor / factor;
+ }
+
+ decimal pow10 = GenericMath.Pow10(scale);
+ decimal truncated = Math.Truncate(value * pow10) / pow10;
+
+ if (value == truncated)
+ return truncated;
+
+ throw new InvalidOperationException($"Cannot reduce scale without losing precision: {value}");
+ }
+
+ ///
+ /// Sets the scale (number of digits after the decimal point) of the current value.
+ ///
+ /// If the scale of the current value is less than the specified scale, then the scale will be padded with zeroes.
+ /// If the scale of the current value is greater that the specified scale, then the scale will be truncated,
+ /// provided that there is no loss of precision; otherwise, the value is rounded using the specified mode.
+ ///
+ ///
+ /// The decimal value to adjust.
+ /// The desired scale (number of decimal digits). Must be non-negative.
+ /// The rounding strategy to apply if the scale must be reduced with precision loss.
+ /// A new with the exact specified 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));
+
+ if (value.Scale == scale)
+ return value;
+
+ if (value.Scale < scale)
+ {
+ decimal factor = GenericMath.Pow10(scale - value.Scale);
+ return value * factor / factor;
+ }
+
+ decimal pow10 = GenericMath.Pow10(scale);
+ decimal truncated = Math.Truncate(value * pow10) / pow10;
+
+ return value == truncated ? truncated : Math.Round(value, scale, mode);
+ }
+
///
/// Gets the current value as an unscaled integer.
///
diff --git a/OnixLabs.Playground/Program.cs b/OnixLabs.Playground/Program.cs
index d629839..c03417f 100644
--- a/OnixLabs.Playground/Program.cs
+++ b/OnixLabs.Playground/Program.cs
@@ -12,11 +12,64 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+using System;
+
namespace OnixLabs.Playground;
internal static class Program
{
private static void Main()
{
+ decimal value = 100.13m;
+ decimal round = value.SetScale(2);
+
+ Console.WriteLine(round);
+ }
+
+ public static decimal SetScale(this decimal value, int scale)
+ {
+ Require(scale >= 0, "Scale must be greater than, or equal to zero.");
+
+ // Count actual decimal places
+ int actualScale = GetDecimalPlaces(value);
+
+ if (actualScale == scale)
+ {
+ return value;
+ }
+
+ if (actualScale < scale)
+ {
+ // Pad with zeroes by scaling and descaling
+ decimal factor = Pow10(scale - actualScale);
+ return value * factor / factor;
+ }
+
+ else // actualScale > scale
+ {
+ // Check if the digits beyond the desired scale are zero
+ decimal factor = Pow10(actualScale - scale);
+ decimal remainder = (value * factor) % 1;
+
+ if (remainder != 0)
+ throw new InvalidOperationException($"Cannot reduce scale without losing precision: {value}");
+
+ return Math.Truncate(value * Pow10(scale)) / Pow10(scale);
+ }
+ }
+
+ private static int GetDecimalPlaces(decimal value)
+ {
+ int[] bits = decimal.GetBits(value);
+ byte scale = (byte)((bits[3] >> 16) & 0x7F);
+ return scale;
+ }
+
+ private static decimal Pow10(int exp)
+ {
+ decimal result = 1m;
+ for (int i = 0; i < exp; i++)
+ result *= 10;
+ return result;
}
}