Skip to content

feature/decimal-set-scale #96

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<Project>
<PropertyGroup>
<Version>12.1.0</Version>
<PackageVersion>12.1.0</PackageVersion>
<AssemblyVersion>12.1.0</AssemblyVersion>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<LangVersion>13</LangVersion>
<Nullable>enable</Nullable>
Expand All @@ -8,8 +11,5 @@
<NeutralLanguage>en</NeutralLanguage>
<Copyright>Copyright © ONIXLabs 2020</Copyright>
<RepositoryUrl>https://github.com/onix-labs/onixlabs-dotnet</RepositoryUrl>
<Version>12.0.0</Version>
<PackageVersion>12.0.0</PackageVersion>
<AssemblyVersion>12.0.0</AssemblyVersion>
</PropertyGroup>
</Project>
94 changes: 94 additions & 0 deletions OnixLabs.Numerics.UnitTests/GenericMathPow10Tests.cs
Original file line number Diff line number Diff line change
@@ -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<int>(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<long>(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<double>(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<decimal>(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<ArgumentException>(() => GenericMath.Pow10<int>(-1));

// Then
Assert.Contains("Exponent must be greater", exception.Message);
}
}
101 changes: 101 additions & 0 deletions OnixLabs.Numerics.UnitTests/NumericsExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 123.45m.SetScale(-1, MidpointRounding.AwayFromZero));
Assert.Contains("Scale must be greater than, or equal to zero.", exception.Message);
}

[Theory(DisplayName = "INumber<T>.IsBetween should produce the expected result")]
[InlineData(0, 0, 0, true)]
[InlineData(0, 0, 1, true)]
Expand Down
32 changes: 31 additions & 1 deletion OnixLabs.Numerics/GenericMath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -41,7 +42,7 @@ public static BigInteger Factorial<T>(T value) where T : IBinaryInteger<T>
{
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;

Expand Down Expand Up @@ -71,4 +72,33 @@ public static int IntegerLength<T>(T value) where T : INumberBase<T>
/// <typeparam name="T">The underlying <see cref="INumber{TSelf}"/> type.</typeparam>
/// <returns>Returns the minimum and maximum values from the specified left-hand and right-hand values.</returns>
public static (T Min, T Max) MinMax<T>(T left, T right) where T : INumber<T> => (T.Min(left, right), T.Max(left, right));

/// <summary>
/// Computes 10 raised to the power of a non-negative integer exponent using exponentiation by squaring, for any numeric type implementing <see cref="INumber{T}"/>.
/// </summary>
/// <typeparam name="T">The numeric type. Must implement <see cref="INumber{T}"/>.</typeparam>
/// <param name="exponent">The exponent to raise 10 to. Must be greater than or equal to zero.</param>
/// <returns>A value of type <typeparamref name="T"/> equal to 10 raised to the power of <paramref name="exponent"/>.</returns>
/// <exception cref="ArgumentException"> if <paramref name="exponent"/> is less than zero.</exception>
public static T Pow10<T>(int exponent) where T : INumber<T>
{
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;
}
}
67 changes: 67 additions & 0 deletions OnixLabs.Numerics/NumericsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,73 @@ public static BigInteger GetUnscaledValue(this decimal value)
return decimal.IsPositive(value) ? result : -result;
}

/// <summary>
/// Sets the scale (number of digits after the decimal point) of the current <see cref="decimal"/> value.
/// <remarks>
/// If the scale of the current <see cref="decimal"/> value is less than the specified scale, then the scale will be padded with zeroes.
/// If the scale of the current <see cref="decimal"/> value is greater that the specified scale, then the scale will be truncated,
/// provided that there is no loss of precision; otherwise, <see cref="InvalidOperationException"/> will be thrown.
/// </remarks>
/// </summary>
/// <param name="value">The decimal value to adjust.</param>
/// <param name="scale">The desired, non-negative scale.</param>
/// <returns>A new <see cref="decimal"/> with the exact specified scale.</returns>
/// <exception cref="InvalidOperationException"> if reducing the scale would result in a loss of precision.</exception>
/// <exception cref="ArgumentException"> if <paramref name="scale"/> is negative.</exception>
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<decimal>(scale - value.Scale);
return value * factor / factor;
}

decimal pow10 = GenericMath.Pow10<decimal>(scale);
decimal truncated = Math.Truncate(value * pow10) / pow10;

if (value == truncated)
return truncated;

throw new InvalidOperationException($"Cannot reduce scale without losing precision: {value}");
}

/// <summary>
/// Sets the scale (number of digits after the decimal point) of the current <see cref="decimal"/> value.
/// <remarks>
/// If the scale of the current <see cref="decimal"/> value is less than the specified scale, then the scale will be padded with zeroes.
/// If the scale of the current <see cref="decimal"/> 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 <see cref="MidpointRounding"/> mode.
/// </remarks>
/// </summary>
/// <param name="value">The decimal value to adjust.</param>
/// <param name="scale">The desired scale (number of decimal digits). Must be non-negative.</param>
/// <param name="mode">The rounding strategy to apply if the scale must be reduced with precision loss.</param>
/// <returns>A new <see cref="decimal"/> with the exact specified scale.</returns>
/// <exception cref="ArgumentException"> if <paramref name="scale"/> is negative.</exception>
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<decimal>(scale - value.Scale);
return value * factor / factor;
}

decimal pow10 = GenericMath.Pow10<decimal>(scale);
decimal truncated = Math.Truncate(value * pow10) / pow10;

return value == truncated ? truncated : Math.Round(value, scale, mode);
}

/// <summary>
/// Gets the current value as an unscaled integer.
/// </summary>
Expand Down
Loading