From 05576b72478d53a6e8102cb0f283e8231253c74d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Mon, 31 Aug 2026 10:38:19 +0200 Subject: [PATCH 1/4] Add `Decimal` arithmetic traits and corresponding tests **Details:** - Introduced `Add`, `Subtract`, `Multiply`, `Divide`, and `Round` methods as individual traits in `FireHub\Runtime\Math\Decimal` for arbitrary-precision decimal arithmetic. - Added implementation for core operations: addition, subtraction, multiplication, division (with scale support), and rounding. - Comprehensive use of PHPDoc annotations to ensure clarity, type safety, and IDE support. - Included unit tests in `DecimalEngineTest` to verify the correctness of all operations: - Extensive test cases for edge scenarios like negative numbers, fractional numbers, and invalid inputs. - Built-in exceptions tested for invalid scale values and division by zero. - Updated `CHANGELOG.md` with version `v0.1.5` to document fixes and new feature integration. This feature greatly extends mathematical functionality while ensuring reliability and clarity through rigorous testing and documentation. --- CHANGELOG.md | 7 +- README.md | 2 +- .../InvalidDecimalNumberException.php | 31 ++ src/Exception/InvalidPrecisionException.php | 31 ++ src/Exception/InvalidScaleNumberException.php | 31 ++ src/Math/Decimal/Arithmetic.php | 277 +++++++++ src/Math/Decimal/Divide.php | 207 +++++++ src/Math/Decimal/Multiply.php | 145 +++++ src/Math/Decimal/Round.php | 209 +++++++ src/Math/DecimalEngine.php | 184 ++++++ tests/Unit/Math/DecimalEngineTest.php | 526 ++++++++++++++++++ 11 files changed, 1648 insertions(+), 2 deletions(-) create mode 100644 src/Exception/InvalidDecimalNumberException.php create mode 100644 src/Exception/InvalidPrecisionException.php create mode 100644 src/Exception/InvalidScaleNumberException.php create mode 100644 src/Math/Decimal/Arithmetic.php create mode 100644 src/Math/Decimal/Divide.php create mode 100644 src/Math/Decimal/Multiply.php create mode 100644 src/Math/Decimal/Round.php create mode 100644 src/Math/DecimalEngine.php create mode 100644 tests/Unit/Math/DecimalEngineTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 7001eb9..9a53eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [[v0.1.4](https://github.com/The-FireHub-Project/Runtime/compare/v0.1.2...v0.1.3)] – 2026-08-03 +## [[v0.1.5](https://github.com/The-FireHub-Project/Runtime/compare/v0.1.4...v0.1.5)] – 2026-08-20 + +### Fixed +* Fix method name typo in isNaN and refine the return type PHPDoc in abs by @dieselxxx + +## [[v0.1.4](https://github.com/The-FireHub-Project/Runtime/compare/v0.1.3...v0.1.4)] – 2026-08-03 ### Added * Add `RegexDelimiter` enum by @dieselxxx diff --git a/README.md b/README.md index cbcf439..67855ce 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ /> version diff --git a/src/Exception/InvalidDecimalNumberException.php b/src/Exception/InvalidDecimalNumberException.php new file mode 100644 index 0000000..c0a978a --- /dev/null +++ b/src/Exception/InvalidDecimalNumberException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Runtime + */ + +namespace FireHub\Runtime\Exception; + +use FireHub\Core\Exception\Runtime\SystemException; + +/** + * ### Represents a failure caused by an invalid decimal number + * @since 1.0.0 + */ +final class InvalidDecimalNumberException extends SystemException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'The provided decimal number is invalid.'; + +} \ No newline at end of file diff --git a/src/Exception/InvalidPrecisionException.php b/src/Exception/InvalidPrecisionException.php new file mode 100644 index 0000000..e8631ca --- /dev/null +++ b/src/Exception/InvalidPrecisionException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Runtime + */ + +namespace FireHub\Runtime\Exception; + +use FireHub\Core\Exception\Runtime\SystemException; + +/** + * ### Represents a failure caused by an invalid precision + * @since 1.0.0 + */ +final class InvalidPrecisionException extends SystemException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'The provided precision is invalid.'; + +} \ No newline at end of file diff --git a/src/Exception/InvalidScaleNumberException.php b/src/Exception/InvalidScaleNumberException.php new file mode 100644 index 0000000..f0b7970 --- /dev/null +++ b/src/Exception/InvalidScaleNumberException.php @@ -0,0 +1,31 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.3 + * @package Runtime + */ + +namespace FireHub\Runtime\Exception; + +use FireHub\Core\Exception\Runtime\SystemException; + +/** + * ### Represents a failure caused by an invalid scale number + * @since 1.0.0 + */ +final class InvalidScaleNumberException extends SystemException { + + /** + * @inheritDoc + * + * @since 1.0.0 + */ + protected const string DEFAULT_MESSAGE = 'The provided scale number is invalid.'; + +} \ No newline at end of file diff --git a/src/Math/Decimal/Arithmetic.php b/src/Math/Decimal/Arithmetic.php new file mode 100644 index 0000000..13003b4 --- /dev/null +++ b/src/Math/Decimal/Arithmetic.php @@ -0,0 +1,277 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Runtime + */ + +namespace FireHub\Runtime\Math\Decimal; + +use FireHub\Runtime; + +/** + * ### Decimal Number Arithmetic + * @since 1.0.0 + */ +trait Arithmetic { + + /** + * ### Adds two decimal values together + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math\DecimalEngine::normalize() To normalize the decimal values. + * @uses \FireHub\Runtime\Math\DecimalEngine::addPositive() To add the positive decimal values. + * @uses \FireHub\Runtime\Math\DecimalEngine::subtractPositive() To subtract the positive decimal values. + * @uses \FireHub\Runtime\Math\DecimalEngine::comparePositive() To compare the decimal values. + * + * @param non-empty-string $left

+ * The first decimal value to add. + *

+ * + * @param non-empty-string $right

+ * The second decimal value to add. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If either decimal value is not a valid decimal number. + * + * @return string The sum of the two decimal values. + */ + public static function add (string $left, string $right):string { + + [$left_sign, $left_integer, $left_fraction] = self::normalize($left); + [$right_sign, $right_integer, $right_fraction] = self::normalize($right); + + if ($left_sign === $right_sign) { + + $result = self::addPositive( + $left_integer, + $left_fraction, + $right_integer, + $right_fraction + ); + + return $left_sign === '-' && $result !== '0' + ? '-'.$result + : $result; + + } + + $comparison = self::comparePositive( + $left_integer, + $left_fraction, + $right_integer, + $right_fraction + ); + + if ($comparison === 0) return '0'; + + if ($comparison > 0) { + + $result = self::subtractPositive( + $left_integer, + $left_fraction, + $right_integer, + $right_fraction + ); + + return $left_sign === '-' && $result !== '0' + ? '-'.$result + : $result; + + } + + $result = self::subtractPositive( + $right_integer, + $right_fraction, + $left_integer, + $left_fraction + ); + + return $right_sign === '-' && $result !== '0' + ? '-'.$result + : $result; + + } + + /** + * ### Subtracts one decimal value from another + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math\DecimalEngine::normalize() To normalize the decimal values. + * @uses \FireHub\Runtime\Math\DecimalEngine::add() To subtract the decimal value from another decimal value. + * + * @param non-empty-string $left

+ * The decimal value from which to subtract. + *

+ * + * @param non-empty-string $right

+ * The decimal value to subtract. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If either decimal value is not a valid decimal number. + * + * @return string The difference between the two decimal values. + */ + public static function subtract (string $left, string $right):string { + + [$right_sign, $right_integer, $right_fraction] = self::normalize($right); + + return self::add( + $left, + ($right_sign === '-' ? '' : '-') + .$right_integer + .($right_fraction !== '' ? '.'.$right_fraction : '') + ); + + } + + /** + * ### Adds two positive decimal values + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math::max() To get the maximum length of the two values. + * @uses \FireHub\Runtime\Str\SB\Transform::reverse() To reverse the string. + * @uses \FireHub\Runtime\Str\SB\Transform::pad() To pad the string with zeros. + * @uses \FireHub\Runtime\Str\SB\Inspection::length() To get the length of the string. + * @uses \FireHub\Runtime\Math::divideInt() To divide the sum by 10. + * @uses \FireHub\Runtime\Math\DecimalEngine::normalizeResult() To normalize the result. + * + * @param non-empty-string $left_integer

+ * The normalized integer part of the first value. + *

+ * @param string $left_fraction

+ * The normalized fractional part of the first value. + *

+ * @param non-empty-string $right_integer

+ * The normalized integer part of the second value. + *

+ * @param string $right_fraction

+ * The normalized fractional part of the second value. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If the value is not a valid decimal number. + * + * @return string The sum of the two positive decimal values. + */ + private static function addPositive (string $left_integer, string $left_fraction, string $right_integer, string $right_fraction):string { + + $scale = Runtime\Math::max( + Runtime\Str\SB\Inspection::length($left_fraction), + Runtime\Str\SB\Inspection::length($right_fraction) + ); + + $left = Runtime\Str\SB\Transform::reverse( + $left_integer.Runtime\Str\SB\Transform::pad($left_fraction, $scale, '0') + ); + $right = Runtime\Str\SB\Transform::reverse( + $right_integer.Runtime\Str\SB\Transform::pad($right_fraction, $scale, '0') + ); + + $length = Runtime\Math::max( + Runtime\Str\SB\Inspection::length($left), + Runtime\Str\SB\Inspection::length($right) + ); + + $carry = 0; $result = ''; + for ($i = 0; $i < $length; ++$i) { + + $sum = (int)($left[$i] ?? '0') + + (int)($right[$i] ?? '0') + + $carry; + + $result .= $sum % 10; + + $carry = Runtime\Math::divideInt($sum, 10); + + } + + if ($carry > 0) $result .= $carry; + + return self::normalizeResult( + Runtime\Str\SB\Transform::reverse($result), + $scale + ); + + } + + /** + * ### Subtracts a positive decimal value from another positive decimal value + * + * The left value must be greater than or equal to the right value. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math::max() To get the maximum length of the two values. + * @uses \FireHub\Runtime\Str\SB\Transform::reverse() To reverse the string. + * @uses \FireHub\Runtime\Str\SB\Transform::pad() To pad the string with zeros. + * @uses \FireHub\Runtime\Str\SB\Inspection::length() To get the length of the string. + * @uses \FireHub\Runtime\Math\DecimalEngine::normalizeResult() To normalize the result. + * + * @param non-empty-string $left_integer

+ * The normalized integer part of the first value. + *

+ * @param string $left_fraction

+ * The normalized fractional part of the first value. + *

+ * @param non-empty-string $right_integer

+ * The normalized integer part of the second value. + *

+ * @param string $right_fraction

+ * The normalized fractional part of the second value. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If the value is not a valid decimal number. + * + * @return string The difference between the two positive decimal values. + */ + private static function subtractPositive (string $left_integer, string $left_fraction, string $right_integer, string $right_fraction):string { + + $scale = Runtime\Math::max( + Runtime\Str\SB\Inspection::length($left_fraction), + Runtime\Str\SB\Inspection::length($right_fraction) + ); + + $left = Runtime\Str\SB\Transform::reverse( + $left_integer.Runtime\Str\SB\Transform::pad($left_fraction, $scale, '0') + ); + $right = Runtime\Str\SB\Transform::reverse( + $right_integer.Runtime\Str\SB\Transform::pad($right_fraction, $scale, '0') + ); + + $length = Runtime\Str\SB\Inspection::length($left); + + $borrow = 0; $result = ''; + for ($i = 0; $i < $length; ++$i) { + + $difference = (int)($left[$i] ?? '0') + - (int)($right[$i] ?? '0') + - $borrow; + + if ($difference < 0) { + + $difference += 10; + $borrow = 1; + + } else { + + $borrow = 0; + + } + + $result .= $difference; + + } + + return self::normalizeResult( + Runtime\Str\SB\Transform::reverse($result), + $scale + ); + + } + +} \ No newline at end of file diff --git a/src/Math/Decimal/Divide.php b/src/Math/Decimal/Divide.php new file mode 100644 index 0000000..ffdc7e1 --- /dev/null +++ b/src/Math/Decimal/Divide.php @@ -0,0 +1,207 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.4 + * @package Runtime + */ + +namespace FireHub\Runtime\Math\Decimal; + +use FireHub\Core\Meta\Enum\Side; +use FireHub\Runtime; +use FireHub\Runtime\Exception\InvalidScaleNumberException; +use DivisionByZeroError; + +/** + * ### Decimal Number Division + * @since 1.0.0 + */ +trait Divide { + + /** + * ### Divides one decimal value by another + * @param non-empty-string $left

+ * The dividend. + *

+ * @param non-empty-string $right

+ * The divisor. + *

+ * @param positive-int $scale [optional]

+ * The number of decimal places to calculate. + *

+ * + * @return string The quotient of the two decimal values. + *@throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If either decimal value is invalid. + * @throws \FireHub\Runtime\Exception\InvalidScaleNumberException If the scale is less than zero. + * @throws DivisionByZeroError If the divisor is zero. + * + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math\DecimalEngine::normalize() To normalize the decimal values. + * @uses \FireHub\Runtime\Math\DecimalEngine::dividePositive() To divide the positive decimal values. + * + */ + public static function divide (string $left, string $right, int $scale = 18):string { + + [$left_sign, $left_integer, $left_fraction] = self::normalize($left); + [$right_sign, $right_integer, $right_fraction] = self::normalize($right); + + if ($scale < 0) + throw new InvalidScaleNumberException('Scale must be greater than or equal to zero.'); + + if ($right_integer === '0' && $right_fraction === '') + throw new DivisionByZeroError; + + if ($left_integer === '0' && $left_fraction === '') + return '0'; + + $result = self::dividePositive( + $left_integer, + $left_fraction, + $right_integer, + $right_fraction, + $scale + ); + + if ($left_sign !== $right_sign && $result !== '0') return '-'.$result; + + return $result; + + } + + /** + * ### Divides two positive decimal values + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math\DecimalEngine::comparePositive() To compare the positive decimal values. + * @uses \FireHub\Runtime\Math\DecimalEngine::subtractPositive() To subtract the positive decimal values. + * @uses \FireHub\Runtime\Math::divideInt() To divide the sum by 10. + * @uses \FireHub\Runtime\Str\SB\Inspection::length() To get the length of the string. + * @uses \FireHub\Core\Meta\Enum\Side::LEFT To trim the left side of the string. + * @uses \FireHub\Runtime\Str\SB\Transform::trim() To trim leading zeros from the string. + * @uses \FireHub\Runtime\Str\SB\Transform::repeat() To create a string of zeros. + * + * @param non-empty-string $left_integer

+ * The normalized integer part of the dividend. + *

+ * @param string $left_fraction

+ * The normalized fractional part of the dividend. + *

+ * @param non-empty-string $right_integer

+ * The normalized integer part of the divisor. + *

+ * @param string $right_fraction

+ * The normalized fractional part of the divisor. + *

+ * @param int $scale

+ * The number of decimal places to calculate. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If the value is not a valid decimal number. + * + * @return string The quotient. + */ + private static function dividePositive (string $left_integer, string $left_fraction, string $right_integer, string $right_fraction, int $scale):string { + + $left_scale = Runtime\Str\SB\Inspection::length($left_fraction); + $right_scale = Runtime\Str\SB\Inspection::length($right_fraction); + + $dividend = $left_integer.$left_fraction; + $divisor = $right_integer.$right_fraction; + + $dividend = Runtime\Str\SB\Transform::trim($dividend, Side::LEFT, '0') ?: '0'; + $divisor = Runtime\Str\SB\Transform::trim($divisor, Side::LEFT, '0') ?: '0'; + + if ($right_scale > $left_scale) { + + $dividend .= Runtime\Str\SB\Transform::repeat('0', $right_scale - $left_scale); + + } else if ($left_scale > $right_scale) { + + $divisor .= Runtime\Str\SB\Transform::repeat('0', $left_scale - $right_scale); + + } + + $quotient = ''; $remainder = '0'; + $length = Runtime\Str\SB\Inspection::length($dividend); + + for ($i = 0; $i < $length; ++$i) { + + $remainder .= $dividend[$i]; + + $remainder = Runtime\Str\SB\Transform::trim($remainder, Side::LEFT, '0') ?: '0'; + + $digit = 0; + while ( + self::comparePositive( + $remainder, // @phpstan-ignore argument.type + '', + $divisor, + '' + ) >= 0 + ) { + + $remainder = self::subtractPositive( + $remainder, // @phpstan-ignore argument.type + '', + $divisor, + '' + ); + + ++$digit; + + } + + $quotient .= $digit; + + } + + $quotient = Runtime\Str\SB\Transform::trim($quotient, Side::LEFT, '0') ?: '0'; + + $fraction = ''; + for ($i = 0; $i < $scale; ++$i) { + + if ($remainder === '0') break; + + $remainder .= '0'; + + $remainder = Runtime\Str\SB\Transform::trim($remainder, Side::LEFT, '0') ?: '0'; + + $digit = 0; + while ( + self::comparePositive( + $remainder, // @phpstan-ignore argument.type + '', + $divisor, + '' + ) >= 0 + ) { + + $remainder = self::subtractPositive( + $remainder, // @phpstan-ignore argument.type + '', + $divisor, + '' + ); + + ++$digit; + + } + + $fraction .= $digit; + + } + + if ($fraction === '') return $quotient; + + return $quotient.'.'.$fraction; + + } + +} \ No newline at end of file diff --git a/src/Math/Decimal/Multiply.php b/src/Math/Decimal/Multiply.php new file mode 100644 index 0000000..a944eba --- /dev/null +++ b/src/Math/Decimal/Multiply.php @@ -0,0 +1,145 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Runtime + */ + +namespace FireHub\Runtime\Math\Decimal; + +use FireHub\Core\Meta\Enum\Side; +use FireHub\Runtime; + +/** + * ### Decimal Number Multiplication + * @since 1.0.0 + */ +trait Multiply { + + /** + * ### Multiplies two decimal values + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math\DecimalEngine::normalize() To normalize the decimal values. + * @uses \FireHub\Runtime\Math\DecimalEngine::multiplyPositive() To multiply the positive decimal values. + * + * @param non-empty-string $left

+ * The first decimal value to multiply. + *

+ * + * @param non-empty-string $right

+ * The second decimal value to multiply. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If either decimal value is not a valid decimal number. + * + * @return string The product of the two decimal values. + */ + public static function multiply (string $left, string $right):string { + + [$left_sign, $left_integer, $left_fraction] = self::normalize($left); + [$right_sign, $right_integer, $right_fraction] = self::normalize($right); + + if (($left_integer === '0' && $left_fraction === '') + || ($right_integer === '0' && $right_fraction === '') + ) return '0'; + + $result = self::multiplyPositive( + $left_integer, + $left_fraction, + $right_integer, + $right_fraction + ); + + if ($left_sign !== $right_sign && $result !== '0') return '-'.$result; + + return $result; + + } + + /** + * ### Multiplies two positive decimal values + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math\DecimalEngine::normalizeResult() To normalize the result. + * @uses \FireHub\Runtime\Math::divideInt() To divide the sum by 10. + * @uses \FireHub\Runtime\Str\SB\Inspection::length() To get the length of the string. + * @uses \FireHub\Core\Meta\Enum\Side::LEFT To trim the left side of the string. + * @uses \FireHub\Runtime\Str\SB\Transform::trim() To trim leading zeros from the string. + * @uses \FireHub\Runtime\Arr\Structure::fill() To create an array of zeros. + * + * @param non-empty-string $left_integer

+ * The normalized integer part of the first value. + *

+ * @param string $left_fraction

+ * The normalized fractional part of the first value. + *

+ * @param non-empty-string $right_integer

+ * The normalized integer part of the second value. + *

+ * @param string $right_fraction

+ * The normalized fractional part of the second value. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If the value is not a valid decimal number. + * + * @return string The product of the two positive decimal values. + */ + private static function multiplyPositive (string $left_integer, string $left_fraction, string $right_integer, string $right_fraction):string { + + $left = $left_integer.$left_fraction; + $right = $right_integer.$right_fraction; + + /** @var int<1, max> $left_length */ + $left_length = Runtime\Str\SB\Inspection::length($left); + /** @var int<1, max> $right_Length */ + $right_Length = Runtime\Str\SB\Inspection::length($right); + + $result = Runtime\Arr\Structure::fill(0, 0, $left_length + $right_Length); + + for ($i = $left_length - 1; $i >= 0; --$i) { + + $left_digit = (int)$left[$i]; + + for ($j = $right_Length - 1; $j >= 0; --$j) { + + $right_digit = (int)$right[$j]; + + $position = $i + $j + 1; + + $result[$position] += $left_digit * $right_digit; // @phpstan-ignore offsetAccess.notFound + + } + + } + + for ($i = $left_length + $right_Length - 1; $i > 0; --$i) { + + if ($result[$i] >= 10) { // @phpstan-ignore offsetAccess.notFound + + $result[$i - 1] += Runtime\Math::divideInt($result[$i], 10); // @phpstan-ignore offsetAccess.notFound + + $result[$i] %= 10; // @phpstan-ignore offsetAccess.notFound + + } + + } + + $value = ''; + foreach ($result as $digit) + $value .= $digit; + + return self::normalizeResult( + Runtime\Str\SB\Transform::trim($value, Side::LEFT, '0') ?: '0', + Runtime\Str\SB\Inspection::length($left_fraction) + Runtime\Str\SB\Inspection::length($right_fraction) + ); + + } + +} \ No newline at end of file diff --git a/src/Math/Decimal/Round.php b/src/Math/Decimal/Round.php new file mode 100644 index 0000000..3839864 --- /dev/null +++ b/src/Math/Decimal/Round.php @@ -0,0 +1,209 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=7.4 + * @package Runtime + */ + +namespace FireHub\Runtime\Math\Decimal; + +use FireHub\Core\Meta\Enum\Side; +use FireHub\Runtime; +use FireHub\Runtime\Math\RoundMode; +use FireHub\Runtime\Exception\InvalidPrecisionException; + +/** + * ### Decimal Number Rounding + * @since 1.0.0 + */ +trait Round { + + /** + * ### Rounds a decimal value + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math\DecimalEngine::normalize() To normalize the decimal value. + * @uses \FireHub\Runtime\Str\SB\Access::part() To get the integer and fractional parts. + * @uses \FireHub\Runtime\Str\SB\Inspection::length() To get the length of the string. + * @uses \FireHub\Runtime\Str\SB\Transform::pad() To pad the string with zeros. + * @uses \FireHub\Runtime\Math\Decimal\Round::shouldRound() To determine whether the value should be rounded. + * @uses \FireHub\Runtime\Math\DecimalEngine::add() To increment the value. + * + * @param non-empty-string $value

+ * The decimal value to round. + *

+ * @param int<0, max> $precision

+ * The number of fractional digits to preserve. + *

+ * @param \FireHub\Runtime\Math\RoundMode $mode [optional]

+ * The rounding mode. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If the value is not a valid decimal number. + * @throws \FireHub\Runtime\Exception\InvalidPrecisionException If the precision is less than zero. + * + * @return string The rounded decimal value. + */ + public static function round (string $value, int $precision = 0, RoundMode $mode = RoundMode::HALF_AWAY_FROM_ZERO):string { + + [$sign, $integer, $fraction] = self::normalize($value); + + if ($precision < 0) throw new InvalidPrecisionException('Precision must be greater than or equal to zero.'); + + if (Runtime\Str\SB\Inspection::length($fraction) <= $precision) + return $sign === '-' && ($integer !== '0' || $fraction !== '') + ? '-'.$integer.($fraction !== '' ? '.'.$fraction : '') + : $integer.($fraction !== '' ? '.'.$fraction : ''); + + $kept = Runtime\Str\SB\Access::part($fraction, 0, $precision); + + /** @var non-empty-string $discarded */ + $discarded = Runtime\Str\SB\Access::part($fraction, $precision); + + if (self::shouldRound($sign, $integer, $kept, $discarded, $mode)) { + + $number = Runtime\Math\DecimalEngine::add($integer.$kept, '1'); + + if ($precision > 0) { + + $number = Runtime\Str\SB\Transform::pad($number, $precision + 1, '0', Side::LEFT); + + $integer_length = Runtime\Str\SB\Inspection::length($number) - $precision; + + $integer = Runtime\Str\SB\Access::part($number, 0, $integer_length); + + $kept = Runtime\Str\SB\Access::part($number, $integer_length); + + } else { + + $integer = $number; + $kept = ''; + + } + + } + + if ($precision > 0) { + + $kept = Runtime\Str\SB\Transform::pad($kept, $precision, '0'); + + $result = $integer.'.'.$kept; + + } else $result = $integer; + + return $sign === '-' && $result !== '0' + ? '-'.$result + : $result; + + } + + /** + * ### Determines whether a decimal value should be rounded + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Inspection::length() To get the length of the string. + * + * @param '+'|'-' $sign

+ * The sign of the decimal value. + *

+ * @param non-empty-string $integer

+ * The integer part of the decimal value. + *

+ * @param string $kept

+ * The fractional digits that are kept. + *

+ * @param non-empty-string $discarded

+ * The fractional digits that are discarded. + *

+ * @param \FireHub\Runtime\Math\RoundMode $mode

+ * The rounding mode. + *

+ * + * @return bool Returns true if the retained value should be incremented. + */ + private static function shouldRound (string $sign, string $integer, string $kept, string $discarded, RoundMode $mode):bool { + + $first = (int)$discarded[0]; + + /** Less than half */ + if ($first < 5) { + + return match ($mode) { + RoundMode::TOWARDS_ZERO, RoundMode::HALF_AWAY_FROM_ZERO, RoundMode::HALF_TOWARDS_ZERO, + RoundMode::HALF_EVEN, RoundMode::HALF_ODD + => false, + RoundMode::AWAY_FROM_ZERO + => true, + RoundMode::POSITIVE_INFINITY + => $sign === '+', + RoundMode::NEGATIVE_INFINITY + => $sign === '-' + }; + + } + + /** Greater than half */ + if ($first > 5) + + return match ($mode) { + RoundMode::TOWARDS_ZERO + => false, + RoundMode::NEGATIVE_INFINITY + => $sign === '-', + RoundMode::POSITIVE_INFINITY + => $sign === '+', + RoundMode::AWAY_FROM_ZERO, RoundMode::HALF_AWAY_FROM_ZERO, RoundMode::HALF_TOWARDS_ZERO, + RoundMode::HALF_EVEN, RoundMode::HALF_ODD + => true + + }; + + /** The first discarded digit is 5 */ + for ($i = 1, $length = Runtime\Str\SB\Inspection::length($discarded); $i < $length; ++$i) { + + if ($discarded[$i] !== '0') { + + /** Greater than half.*/ + return match ($mode) { + RoundMode::TOWARDS_ZERO + => false, + RoundMode::NEGATIVE_INFINITY + => $sign === '-', + RoundMode::POSITIVE_INFINITY + => $sign === '+', + RoundMode::AWAY_FROM_ZERO, RoundMode::HALF_AWAY_FROM_ZERO, RoundMode::HALF_TOWARDS_ZERO, + RoundMode::HALF_EVEN, RoundMode::HALF_ODD + => true + }; + + } + + } + + /** Exactly halfway */ + $last = (int)($kept !== '' ? $kept[-1] : $integer[-1]); + + return match ($mode) { + RoundMode::HALF_AWAY_FROM_ZERO, RoundMode::AWAY_FROM_ZERO + => true, + RoundMode::HALF_TOWARDS_ZERO, RoundMode::TOWARDS_ZERO + => false, + RoundMode::HALF_EVEN + => $last % 2 !== 0, + RoundMode::HALF_ODD + => $last % 2 === 0, + RoundMode::NEGATIVE_INFINITY + => $sign === '-', + RoundMode::POSITIVE_INFINITY + => $sign === '+' + }; + + } + +} \ No newline at end of file diff --git a/src/Math/DecimalEngine.php b/src/Math/DecimalEngine.php new file mode 100644 index 0000000..fb909ed --- /dev/null +++ b/src/Math/DecimalEngine.php @@ -0,0 +1,184 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=8.4 + * @package Runtime + */ + +namespace FireHub\Runtime\Math; + +use FireHub\Core\Boundary\Runtime\NativeRuntime; +use FireHub\Core\Meta\Enum\Side; +use FireHub\Runtime\Math\Decimal\ { + Arithmetic, Divide, Multiply, Round +}; +use FireHub\Runtime; +use FireHub\Runtime\Exception\InvalidDecimalNumberException; + +/** + * ### Arbitrary-precision decimal arithmetic + * + * The DecimalEngine class provides string-based arithmetic operations for decimal values without relying on + * floating-point arithmetic or external arbitrary-precision extensions. + * + * The engine is responsible solely for decimal arithmetic execution, while value validation, immutability, and the + * developer-facing API remain the responsibility of the Decimal Value Object. + * @since 1.0.0 + */ +final class DecimalEngine extends NativeRuntime { + + /** + * ### Implements operations + * @since 1.0.0 + */ + use Arithmetic, Divide, Multiply, Round; + + /** + * ### Compares two positive decimal values + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math::max() To get the maximum length of the two values. + * @uses \FireHub\Runtime\Str\SB\Transform::reverse() To reverse the string. + * @uses \FireHub\Runtime\Str\SB\Transform::pad() To pad the string with zeros. + * @uses \FireHub\Runtime\Str\SB\Inspection::length() To get the length of the string. + * + * @param non-empty-string $left_integer

+ * The normalized integer part of the first value. + *

+ * @param string $left_fraction

+ * The normalized fractional part of the first value. + *

+ * @param non-empty-string $right_integer

+ * The normalized integer part of the second value. + *

+ * @param string $right_fraction

+ * The normalized fractional part of the second value. + *

+ * + * @return int<-1, 1> Returns -1 if left is smaller, 0 if equal, or 1 if left is greater. + */ + private static function comparePositive (string $left_integer, string $left_fraction, string $right_integer, string $right_fraction):int { + + $left_length = Runtime\Str\SB\Inspection::length($left_integer); + $right_length = Runtime\Str\SB\Inspection::length($right_integer); + + if ($left_length !== $right_length) return $left_length > $right_length ? 1 : -1; + + if ($left_integer !== $right_integer) return $left_integer > $right_integer ? 1 : -1; + + $scale = Runtime\Math::max( + Runtime\Str\SB\Inspection::length($left_fraction), + Runtime\Str\SB\Inspection::length($right_fraction) + ); + + $leftFraction = Runtime\Str\SB\Transform::pad($left_fraction, $scale, '0'); + + $rightFraction = Runtime\Str\SB\Transform::pad($right_fraction, $scale, '0'); + + if ($leftFraction === $rightFraction) return 0; + + return $leftFraction > $rightFraction ? 1 : -1; + + } + + /** + * ### Normalizes a decimal value + * + * Validates the decimal representation and returns its sign, integer part, and fractional part. + * + * The returned representation contains no unnecessary leading or trailing zeros. + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Str\SB\Regex::match() To check if the value is a valid decimal number. + * @uses \FireHub\Runtime\Str\SB\Delimiter::explode() To split the value into integer and fractional parts. + * @uses \FireHub\Runtime\Str\SB\Transform::trim() To trim leading and trailing zeros from the integer and + * fractional parts. + * @uses \FireHub\Core\Meta\Enum\Side::LEFT To trim the left side of the integer part. + * @uses \FireHub\Core\Meta\Enum\Side::RIGHT To trim the right side of the fractional part. + * + * @param string $value

+ * The decimal value to normalize. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If the value is not a valid decimal number. + * + * @return array{'+'|'-', non-empty-string, string} Returns the sign, normalized integer part, and normalized + * fractional part. + */ + private static function normalize (string $value):array { + + if (Runtime\Str\SB\Regex::match('/^[+-]?\d+(?:\.\d+)?$/', $value) === false) + throw new InvalidDecimalNumberException; + + $sign = '+'; + if ($value[0] === '-') { + + $sign = '-'; + $value = Runtime\Str\SB\Access::part($value, 1); + + } else if ($value[0] === '+') { + + $value = Runtime\Str\SB\Access::part($value, 1); + + } + + $parts = Runtime\Str\SB\Delimiter::explode($value, '.', 2); + + $integer = Runtime\Str\SB\Transform::trim($parts[0] ?? '', Side::LEFT, '0') ?: '0'; + + $fraction = Runtime\Str\SB\Transform::trim($parts[1] ?? '', Side::RIGHT, '0'); + + if ($integer === '0' && $fraction === '') return ['+', '0', '']; + + return [$sign, $integer, $fraction]; + + } + + /** + * ### Normalizes an integer representation and inserts the decimal point + * @since 1.0.0 + * + * @uses \FireHub\Runtime\Math\DecimalEngine::normalize() To normalize the integer representation. + * @uses \FireHub\Runtime\Str\SB\Transform::pad() To pad the integer representation with zeros. + * @uses \FireHub\Runtime\Str\SB\Access::part() To get the integer and fractional parts. + * @uses \FireHub\Runtime\Str\SB\Inspection::length() To get the length of the integer representation. + * @uses \FireHub\Core\Meta\Enum\Side::LEFT To pad the left side of the integer representation. + * + * @param string $value

+ * The integer representation. + *

+ * @param int $scale

+ * The decimal scale. + *

+ * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException If the value is not a valid decimal number. + * + * @return string The normalized decimal result. + */ + private static function normalizeResult (string $value, int $scale):string { + + if ($scale === 0) return self::normalize($value)[1]; + + $value = Runtime\Str\SB\Transform::pad($value, $scale + 1, '0', Side::LEFT); + + $position = Runtime\Str\SB\Inspection::length($value) - $scale; + + $value = Runtime\Str\SB\Access::part($value, 0, $position) + .'.' + .Runtime\Str\SB\Access::part($value, $position); + + return self::normalize($value)[1] + .(self::normalize($value)[2] !== '' + ? '.'.self::normalize($value)[2] + : '' + ); + + } + +} \ No newline at end of file diff --git a/tests/Unit/Math/DecimalEngineTest.php b/tests/Unit/Math/DecimalEngineTest.php new file mode 100644 index 0000000..176e9b4 --- /dev/null +++ b/tests/Unit/Math/DecimalEngineTest.php @@ -0,0 +1,526 @@ + + * @copyright 2026-present The FireHub Project - All rights reserved + * @license https://opensource.org/license/Apache-2-0 Apache License, Version 2.0 + * + * @php-version >=74 + * @package Runtime\Tests + */ + +namespace FireHub\Tests\Runtime\Unit\Math; + +use FireHub\Testing\FireHubTestCase; +use FireHub\Runtime\Math\DecimalEngine; +use FireHub\Runtime\Math\RoundMode; +use FireHub\Runtime\Exception\ { + InvalidDecimalNumberException, InvalidScaleNumberException +}; +use PHPUnit\Framework\Attributes\ { + CoversClass, Group, Small, TestWith +}; +use DivisionByZeroError; + +/** + * ### Test Arbitrary-precision decimal arithmetic + * @since 1.0.0 + */ +#[Small] +#[Group('src')] +#[CoversClass(DecimalEngine::class)] +final class DecimalEngineTest extends FireHubTestCase { + + /** + * @since 1.0.0 + * + * @param string $expected + * @param non-empty-string $left + * @param non-empty-string $right + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['5', '2', '3'])] + #[TestWith(['123', '0', '123'])] + #[TestWith(['18', '12.34', '5.66'])] + #[TestWith(['17.978', '12.3', '5.678'])] + #[TestWith(['1.1', '0.9', '0.2'])] + #[TestWith(['1000', '999', '1'])] + #[TestWith(['1000000000000000000', '999999999999999999', '1'])] + #[TestWith(['7', '10', '-3'])] + #[TestWith(['-7', '-10', '3'])] + #[TestWith(['-13', '-10', '-3'])] + #[TestWith(['0', '123.456', '-123.456'])] + #[TestWith(['0', '-0', '0'])] + #[TestWith(['20', '000012.30', '0007.70'])] + #[TestWith(['4', '1.2300', '2.7700'])] + #[TestWith(['0.000000000000003', '0.000000000000001', '0.000000000000002'])] + public function testAdd (string $expected, string $left, string $right):void { + + self::assertSame($expected, DecimalEngine::add($left, $right)); + + } + + /** + * @since 1.0.0 + * + * @param non-empty-string $left + * + * @return void + */ + #[TestWith(['12abc'])] + #[TestWith(['12.34.56'])] + #[TestWith(['.5'])] + #[TestWith(['12.'])] + #[TestWith(['1e10'])] + #[TestWith([' 12'])] + #[TestWith([''])] + #[TestWith(['+-12'])] + public function testInvalidDecimal (string $left):void { + + $this->expectException(InvalidDecimalNumberException::class); + + DecimalEngine::add($left, '1'); + + } + + /** + * @since 1.0.0 + * + * @param string $expected + * @param non-empty-string $left + * @param non-empty-string $right + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['2', '5', '3'])] + #[TestWith(['123', '123', '0'])] + #[TestWith(['7', '12.34', '5.34'])] + #[TestWith(['10.045', '12.345', '2.3'])] + #[TestWith(['0.9', '1', '0.1'])] + #[TestWith(['999', '1000', '1'])] + #[TestWith(['999999999999999999', '1000000000000000000', '1'])] + #[TestWith(['-7', '3', '10'])] + #[TestWith(['-13', '-10', '3'])] + #[TestWith(['13', '10', '-3'])] + #[TestWith(['-7', '-10', '-3'])] + #[TestWith(['0', '123.456', '123.456'])] + #[TestWith(['100', '000100.50', '000000.50'])] + #[TestWith(['10', '10.5000', '0.5000'])] + #[TestWith(['0.000000000000002', '0.000000000000003', '0.000000000000001'])] + public function testSubtract (string $expected, string $left, string $right):void { + + self::assertSame($expected, DecimalEngine::subtract($left, $right)); + + } + + /** + * @since 1.0.0 + * + * @param string $expected + * @param non-empty-string $left + * @param non-empty-string $right + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['6', '2', '3'])] + #[TestWith(['0', '0', '123456789'])] + #[TestWith(['24.68', '12.34', '2'])] + #[TestWith(['30.85', '12.34', '2.50'])] + #[TestWith(['0.02', '0.1', '0.2'])] + #[TestWith(['9801', '99', '99'])] + #[TestWith(['99.8001', '9.99', '9.99'])] + #[TestWith(['999999999999999998000000000000000001', '999999999999999999', '999999999999999999'])] + #[TestWith(['-25', '12.5', '-2'])] + #[TestWith(['-25', '-12.5', '2'])] + #[TestWith(['25', '-12.5', '-2'])] + #[TestWith(['25', '000012.50', '0002'])] + #[TestWith(['3', '1.2000', '2.5000'])] + #[TestWith(['0.000000000002', '0.000001', '0.000002'])] + public function testMultiply (string $expected, string $left, string $right):void { + + self::assertSame($expected, DecimalEngine::multiply($left, $right)); + + } + + /** + * @param string $expected + * @param non-empty-string $left + * @param non-empty-string $right + * @param positive-int $scale + * + * @return void + *@throws \FireHub\Runtime\Exception\InvalidScaleNumberException + * @throws \DivisionByZeroError + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * @since 1.0.0 + * + */ + #[TestWith(['5', '10', '2', 18])] + #[TestWith(['2.5', '10', '4', 18])] + #[TestWith(['6.17', '12.34', '2', 18])] + #[TestWith(['0.3333333333', '1', '3', 10])] + #[TestWith(['0.6666666666', '2', '3', 10])] + #[TestWith(['0.142857142857', '1', '7', 12])] + #[TestWith(['0.25', '0.5', '2', 18])] + #[TestWith(['20', '10', '0.5', 18])] + #[TestWith(['2.5', '1.25', '0.5', 18])] + #[TestWith(['-2.5', '-10', '4', 18])] + #[TestWith(['-2.5', '10', '-4', 18])] + #[TestWith(['2.5', '-10', '-4', 18])] + #[TestWith(['0', '0', '123', 18])] + #[TestWith(['25', '00100', '0004', 18])] + #[TestWith(['500000000000000000', '1000000000000000000', '2', 18])] + #[TestWith(['3.33', '10', '3', 2])] + #[TestWith(['3', '10', '3', 0])] + #[TestWith(['0.0000000000000005', '0.000000000000001', '2', 18])] + public function testDivide (string $expected, string $left, string $right, int $scale):void { + + self::assertSame($expected, DecimalEngine::divide($left, $right, $scale)); + + } + + /** + * @return void + *@throws \FireHub\Runtime\Exception\InvalidScaleNumberException + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * @since 1.0.0 + * + */ + public function testDivideByZero ():void { + + $this->expectException(DivisionByZeroError::class); + + DecimalEngine::divide('10', '0'); + + } + + /** + * @since 1.0.0 + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * @throws \DivisionByZeroError + * + * @return void + */ + public function testNegativeScale ():void { + + $this->expectException(InvalidScaleNumberException::class); + + DecimalEngine::divide('10', '3', -1); + + } + + /** + * @since 1.0.0 + * + * @param non-empty-string $expected + * @param non-empty-string $value + * @param non-negative-int $precision + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['1', '1.4', 0])] + #[TestWith(['2', '1.5', 0])] + #[TestWith(['2', '1.6', 0])] + #[TestWith(['-1', '-1.4', 0])] + #[TestWith(['-2', '-1.5', 0])] + #[TestWith(['-2', '-1.6', 0])] + #[TestWith(['12.35', '12.345', 2])] + #[TestWith(['12.36', '12.355', 2])] + #[TestWith(['-12.35', '-12.345', 2])] + #[TestWith(['-12.36', '-12.355', 2])] + #[TestWith(['100', '99.5', 0])] + #[TestWith(['1000', '999.5', 0])] + public function testHalfAwayFromZero (string $expected, string $value, int $precision):void { + + self::assertSame( + $expected, + DecimalEngine::round( + $value, + $precision, + RoundMode::HALF_AWAY_FROM_ZERO + ) + ); + + } + + /** + * @since 1.0.0 + * + * @param non-empty-string $expected + * @param non-empty-string $value + * @param non-negative-int $precision + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['1', '1.4', 0])] + #[TestWith(['1', '1.5', 0])] + #[TestWith(['2', '1.6', 0])] + #[TestWith(['-1', '-1.4', 0])] + #[TestWith(['-1', '-1.5', 0])] + #[TestWith(['-2', '-1.6', 0])] + #[TestWith(['12.34', '12.345', 2])] + #[TestWith(['12.36', '12.356', 2])] + #[TestWith(['-12.34', '-12.345', 2])] + #[TestWith(['-12.36', '-12.356', 2])] + public function testHalfTowardsZero (string $expected, string $value, int $precision):void { + + self::assertSame( + $expected, + DecimalEngine::round( + $value, + $precision, + RoundMode::HALF_TOWARDS_ZERO + ) + ); + + } + + /** + * @since 1.0.0 + * + * @param non-empty-string $expected + * @param non-empty-string $value + * @param non-negative-int $precision + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['2', '1.5', 0])] + #[TestWith(['2', '2.5', 0])] + #[TestWith(['4', '3.5', 0])] + #[TestWith(['4', '4.5', 0])] + #[TestWith(['-2', '-1.5', 0])] + #[TestWith(['-2', '-2.5', 0])] + #[TestWith(['-4', '-3.5', 0])] + #[TestWith(['-4', '-4.5', 0])] + #[TestWith(['12.34', '12.345', 2])] + #[TestWith(['12.36', '12.355', 2])] + #[TestWith(['12.36', '12.365', 2])] + #[TestWith(['12.38', '12.375', 2])] + #[TestWith(['-12.34', '-12.345', 2])] + #[TestWith(['-12.36', '-12.355', 2])] + public function testHalfEven (string $expected, string $value, int $precision):void { + + self::assertSame( + $expected, + DecimalEngine::round( + $value, + $precision, + RoundMode::HALF_EVEN + ) + ); + + } + + /** + * @since 1.0.0 + * + * @param non-empty-string $expected + * @param non-empty-string $value + * @param non-negative-int $precision + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['1', '1.5', 0])] + #[TestWith(['3', '2.5', 0])] + #[TestWith(['3', '3.5', 0])] + #[TestWith(['5', '4.5', 0])] + #[TestWith(['-1', '-1.5', 0])] + #[TestWith(['-3', '-2.5', 0])] + #[TestWith(['-3', '-3.5', 0])] + #[TestWith(['-5', '-4.5', 0])] + #[TestWith(['12.35', '12.345', 2])] + #[TestWith(['12.35', '12.355', 2])] + #[TestWith(['12.37', '12.365', 2])] + #[TestWith(['12.37', '12.375', 2])] + #[TestWith(['-12.35', '-12.345', 2])] + #[TestWith(['-12.35', '-12.355', 2])] + public function testHalfOdd (string $expected, string $value, int $precision):void { + + self::assertSame( + $expected, + DecimalEngine::round( + $value, + $precision, + RoundMode::HALF_ODD + ) + ); + + } + + /** + * @since 1.0.0 + * + * @param non-empty-string $expected + * @param non-empty-string $value + * @param non-negative-int $precision + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['1', '1.1', 0])] + #[TestWith(['1', '1.5', 0])] + #[TestWith(['1', '1.9', 0])] + #[TestWith(['-1', '-1.1', 0])] + #[TestWith(['-1', '-1.5', 0])] + #[TestWith(['-1', '-1.9', 0])] + #[TestWith(['12.34', '12.349', 2])] + #[TestWith(['12.34', '12.345', 2])] + #[TestWith(['12.34', '12.341', 2])] + #[TestWith(['-12.34', '-12.349', 2])] + #[TestWith(['-12.34', '-12.345', 2])] + #[TestWith(['-12.34', '-12.341', 2])] + #[TestWith(['1', '1.501', 0])] + #[TestWith(['-1', '-1.501', 0])] + public function testTowardsZero (string $expected, string $value, int $precision):void { + + self::assertSame( + $expected, + DecimalEngine::round( + $value, + $precision, + RoundMode::TOWARDS_ZERO + ) + ); + + } + + /** + * @since 1.0.0 + * + * @param non-empty-string $expected + * @param non-empty-string $value + * @param non-negative-int $precision + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['2', '1.1', 0])] + #[TestWith(['2', '1.5', 0])] + #[TestWith(['2', '1.9', 0])] + #[TestWith(['-2', '-1.1', 0])] + #[TestWith(['-2', '-1.5', 0])] + #[TestWith(['-2', '-1.9', 0])] + #[TestWith(['12.35', '12.341', 2])] + #[TestWith(['12.35', '12.345', 2])] + #[TestWith(['12.35', '12.349', 2])] + #[TestWith(['-12.35', '-12.341', 2])] + #[TestWith(['-12.35', '-12.345', 2])] + #[TestWith(['-12.35', '-12.349', 2])] + #[TestWith(['2', '1.501', 0])] + #[TestWith(['2', '1.5001', 0])] + #[TestWith(['1.51', '1.505', 2])] + #[TestWith(['-2', '-1.501', 0])] + #[TestWith(['-2', '-1.5001', 0])] + #[TestWith(['-1.51', '-1.505', 2])] + public function testAwayFromZero (string $expected, string $value, int $precision):void { + + self::assertSame( + $expected, + DecimalEngine::round( + $value, + $precision, + RoundMode::AWAY_FROM_ZERO + ) + ); + + } + + /** + * @since 1.0.0 + * + * @param non-empty-string $expected + * @param non-empty-string $value + * @param non-negative-int $precision + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['1', '1.1', 0])] + #[TestWith(['1', '1.5', 0])] + #[TestWith(['1', '1.9', 0])] + #[TestWith(['-2', '-1.1', 0])] + #[TestWith(['-2', '-1.5', 0])] + #[TestWith(['-2', '-1.9', 0])] + #[TestWith(['12.34', '12.349', 2])] + #[TestWith(['12.34', '12.345', 2])] + #[TestWith(['12.34', '12.341', 2])] + #[TestWith(['-12.35', '-12.349', 2])] + #[TestWith(['-12.35', '-12.345', 2])] + #[TestWith(['-12.35', '-12.341', 2])] + #[TestWith(['1', '1.501', 0])] + #[TestWith(['-2', '-1.501', 0])] + public function testNegativeInfinity (string $expected, string $value, int $precision):void { + + self::assertSame( + $expected, + DecimalEngine::round( + $value, + $precision, + RoundMode::NEGATIVE_INFINITY + ) + ); + + } + + /** + * @since 1.0.0 + * + * @param non-empty-string $expected + * @param non-empty-string $value + * @param non-negative-int $precision + * + * @throws \FireHub\Runtime\Exception\InvalidDecimalNumberException + * + * @return void + */ + #[TestWith(['2', '1.1', 0])] + #[TestWith(['2', '1.5', 0])] + #[TestWith(['2', '1.9', 0])] + #[TestWith(['-1', '-1.1', 0])] + #[TestWith(['-1', '-1.5', 0])] + #[TestWith(['-1', '-1.9', 0])] + #[TestWith(['12.35', '12.349', 2])] + #[TestWith(['12.35', '12.345', 2])] + #[TestWith(['12.35', '12.341', 2])] + #[TestWith(['-12.34', '-12.349', 2])] + #[TestWith(['-12.34', '-12.345', 2])] + #[TestWith(['-12.34', '-12.341', 2])] + #[TestWith(['2', '1.501', 0])] + #[TestWith(['-1', '-1.501', 0])] + public function testPositiveInfinity (string $expected, string $value, int $precision):void { + + self::assertSame( + $expected, + DecimalEngine::round( + $value, + $precision, + RoundMode::POSITIVE_INFINITY + ) + ); + + } + +} \ No newline at end of file From bd37ce82111d0173849cf0bbcafc7505e16a128a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Mon, 31 Aug 2026 10:40:12 +0200 Subject: [PATCH 2/4] Fix formatting issue in `CHANGELOG.md` header for `v0.1.0` **Details:** - Corrected the square bracket mismatch in the header for version `v0.1.0` to ensure proper formatting. - No changes to content; improves readability and consistency in the changelog structure. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a53eb6..69e6a4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Refine `Iterator::toArray` PHPDoc by @dieselxxx -## [[v0.1.0] – 2026-07-28 +## [v0.1.0] – 2026-07-28 ### Added * Implement Initial wrapper utility classes by @dieselxxx in https://github.com/The-FireHub-Project/Runtime/pull/2 \ No newline at end of file From 35e024a1430994cd6ea83f8c2ca741c9e2b17163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= Date: Mon, 31 Aug 2026 10:42:12 +0200 Subject: [PATCH 3/4] Add `DecimalEngine` enum to changelog for v0.2.0 **Details:** - Updated `CHANGELOG.md` with a new section for version `v0.2.0`, released on `2026-08-31`. - Documented the addition of the `DecimalEngine` enum. - Ensures changelog reflects latest changes for better version tracking and transparency. --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e6a4a..6173595 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [[v0.2.0](https://github.com/The-FireHub-Project/Runtime/compare/v0.1.5...v0.2.0)] – 2026-08-31 + +### Added +* Add `DecimalEngine` enum by @dieselxxx + ## [[v0.1.5](https://github.com/The-FireHub-Project/Runtime/compare/v0.1.4...v0.1.5)] – 2026-08-20 ### Fixed From bdd281c78febba6ca9b4fd0650edd23a5b833cbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Danijel=20Gali=C4=87?= <56673130+dieselxxx@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:43:21 +0200 Subject: [PATCH 4/4] Revise README for clarity and badge updates Updated README.md to reflect changes in repository purpose, branch information, and added various badges for better visibility. --- README.md | 111 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 70 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 67855ce..811d51f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ PHP Runtime Foundation
release @@ -22,25 +22,46 @@ /> -[![PHPStan](https://github.com/The-FireHub-Project/Runtime/actions/workflows/PHPStan.yml/badge.svg?branch=develop)](https://github.com/The-FireHub-Project/Runtime/actions/workflows/PHPStan.yml) -[![PHPUnit](https://github.com/The-FireHub-Project/Runtime/actions/workflows/PHPUnit.yml/badge.svg?branch=develop)](https://github.com/The-FireHub-Project/Runtime/actions/workflows/PHPUnit.yml) -[![Codecov](https://codecov.io/gh/The-FireHub-Project/Runtime/branch/develop/graph/badge.svg?token=XW2YEONF51)](https://app.codecov.io/gh/The-FireHub-Project/Runtime/tree/develop) -

- + + GitHub created at + GitHub last commit (branch) - + GitHub activity (branch) - + + GitHub contributors + GitHub commit difference between two branches + + GitHub top language + + GitHub repo size + + GitHub code size

@@ -52,47 +73,55 @@ It contains no domain logic, no business rules, and no framework coupling. Its r --- -## FireHub Icon Development Branch +## FireHub Icon Repository Purpose -⚠️ **This is the `develop` branch** +This repository provides a lightweight PHP runtime abstraction layer that wraps native PHP functionality into clean, consistent, and deterministic APIs. -- Unstable -- APIs may change without notice -- Not intended for production use +Its purpose is to reduce boilerplate and standardize common execution patterns across the FireHub ecosystem by offering ergonomic helper classes for arrays, strings, files, environment access, and other low-level runtime operations. -## FireHub Icon Branch Purpose +It is strictly focused on execution-level concerns and does not contain domain logic, business rules, or framework-level orchestration. It exists to improve developer productivity while preserving native PHP behavior and ensuring zero framework coupling. -The `develop` branch is the **primary integration branch** for all ongoing development. +## FireHub Icon Security -It serves as the staging area where: -- Feature branches are merged -- Bug fixes are integrated -- Experimental work is stabilized -- Code is prepared for upcoming releases +For security reporting and guidelines, please refer to our [SECURITY.md](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file. -All **release branches** are created **from `develop`**. +## FireHub Icon Community -## Stability Guarantee +![GitHub watchers](https://img.shields.io/github/watchers/The-FireHub-Project/Runtime?style=social) +![GitHub Repo stars](https://img.shields.io/github/stars/The-FireHub-Project/Runtime?style=social) +![GitHub forks](https://img.shields.io/github/forks/The-FireHub-Project/Runtime?style=social) -❌ No backward compatibility guarantee -❌ APIs may change without notice -❌ Behavior may be incomplete or inconsistent -❌ Breaking changes are expected +- Be sure to review the [Code of Conduct](https://github.com/The-FireHub-Project/.github/blob/master/.github/CODE_OF_CONDUCT.md). +- Read a [Contributing](https://github.com/The-FireHub-Project/.github/blob/master/.github/CONTRIBUTING.md) file to see how you can contribute to this project. +- Read our [Security policy](https://github.com/The-FireHub-Project/.github/blob/master/.github/SECURITY.md) file to see how you can report a vulnerability. +- Review the project [Governance model](https://github.com/The-FireHub-Project/.github/blob/master/.github/GOVERNANCE.md), including roles, decision-making, RFC process, and releases. -This branch is intended **only for contributors and advanced testers**. +## FireHub Icon Support -## FireHub Icon Composer Usage (Not Recommended) +

+ + GitHub in-progress issues + + GitHub total issues + + GitHub opened pull requests +

-For internal testing only: +For help with FireHub products, please refer to the following resources: -```json -{ - "require": { - "the-firehub-project/runtime": "dev-develop" - } -} -``` -⚠️ Never use dev-develop in production. +- **General questions & discussions**: [GitHub Discussions](https://github.com/orgs/The-FireHub-Project/discussions) +- **Bug reports & issues**: [Support](https://github.com/The-FireHub-Project/Runtime/issues) +- **Documentation & guides**: [FireHub Docs](https://the-firehub-project.github.io) +- **Commercial support** (Pro/Enterprise): contact your account manager ## FireHub Icon Authors and Contributors