diff --git a/.docker/Dockerfile b/.docker/Dockerfile index dd46470..d7bc22c 100644 --- a/.docker/Dockerfile +++ b/.docker/Dockerfile @@ -1,14 +1,7 @@ -ARG PHP_VERSION=8.2 -ARG PHPUNIT_VERSION=10.4.0 -ARG PHPSTAN_VERSION=1.10.38 +ARG PHP_VERSION=8.5 FROM php:${PHP_VERSION}-cli-alpine COPY --from=composer /usr/bin/composer /usr/bin/composer -ARG PHPUNIT_VERSION -ARG PHPSTAN_VERSION - RUN docker-php-ext-install bcmath && docker-php-ext-enable bcmath -RUN wget -O phpunit https://phar.phpunit.de/phpunit-${PHPUNIT_VERSION}.phar && chmod +x phpunit -RUN wget -O phpstan https://github.com/phpstan/phpstan/releases/download/${PHPSTAN_VERSION}/phpstan.phar && chmod +x phpstan diff --git a/.env b/.env index af97ce2..93ca64f 100644 --- a/.env +++ b/.env @@ -1,3 +1 @@ -PHP_VERSION=8.2 -PHPUNIT_VERSION=10.4.0 -PHPSTAN_VERSION=1.10.38 +PHP_VERSION=8.5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 191fdc4..e40c7f8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,14 +9,14 @@ on: jobs: testsuite: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - php-version: ['8.1', '8.3', '8.5'] + php-version: ['8.2', '8.3', '8.4', '8.5'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -32,29 +32,29 @@ jobs: - name: Run PHPUnit run: | - if [[ ${{ matrix.php-version }} == '8.1' ]]; then + if [[ ${{ matrix.php-version }} == '8.2' ]]; then vendor/bin/phpunit --coverage-clover=coverage.xml else vendor/bin/phpunit fi - name: Code Coverage Report - if: success() && matrix.php-version == '8.1' - uses: codecov/codecov-action@v4 + if: success() && matrix.php-version == '8.2' + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} validation: name: Coding Standard & Static Analysis - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.1' + php-version: '8.2' extensions: mbstring, intl, bcmath coverage: none diff --git a/README.md b/README.md index 518f735..9193433 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/php-collective/decimal-object/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/php-collective/decimal-object/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/php-collective/decimal-object/branch/master/graph/badge.svg?token=L1thFB9nOG)](https://codecov.io/gh/php-collective/decimal-object) [![Latest Stable Version](https://poser.pugx.org/php-collective/decimal-object/v/stable.svg)](https://packagist.org/packages/php-collective/decimal-object) -[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF.svg)](https://php.net/) +[![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.2-8892BF.svg)](https://php.net/) [![PHPStan](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg?style=flat)](https://phpstan.org/) [![License](https://poser.pugx.org/php-collective/decimal-object/license)](https://packagist.org/packages/php-collective/decimal-object) @@ -26,7 +26,7 @@ Monetary values themselves could be handled also with [brick/money](https://gith For details see the [wiki](https://github.com/php-collective/decimal-object/wiki). -Note: If you are looking for pre PHP 8.1 versions, check out the [legacy library](https://github.com/spryker/decimal-object). +Note: PHP 8.1 is covered by the 1.3 releases. For anything older, check out the [legacy library](https://github.com/spryker/decimal-object). ## Features @@ -40,6 +40,7 @@ Note: If you are looking for pre PHP 8.1 versions, check out the [legacy library ### Requirements +- PHP 8.2+ - `bcmath` PHP extension enabled ### Composer (preferred) diff --git a/compose.yaml b/compose.yaml index 84edd30..7d9c008 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,9 +6,7 @@ services: context: . dockerfile: .docker/Dockerfile args: - PHP_VERSION: "${PHP_VERSION:-8.2}" - PHPUNIT_VERSION: "${PHPUNIT_VERSION:-10.4.0}" - PHPSTAN_VERSION: "${PHPSTAN_VERSION:-1.10.38}" + PHP_VERSION: "${PHP_VERSION:-8.5}" volumes: - ./:/code working_dir: /code diff --git a/composer.json b/composer.json index ae80adf..c37719e 100644 --- a/composer.json +++ b/composer.json @@ -3,6 +3,14 @@ "description": "PHP decimal handling as value object", "license": "MIT", "type": "library", + "keywords": [ + "decimal", + "value-object", + "bcmath", + "precision", + "arbitrary-precision", + "math" + ], "authors": [ { "name": "The PHP Collective", @@ -13,15 +21,19 @@ "homepage": "https://spryker.com" } ], + "support": { + "issues": "https://github.com/php-collective/decimal-object/issues", + "source": "https://github.com/php-collective/decimal-object" + }, "require": { - "php": ">=8.1", + "php": ">=8.2", "ext-bcmath": "*", "ext-json": "*" }, "require-dev": { - "php-collective/code-sniffer": "^0.2.1", + "php-collective/code-sniffer": "^0.6.8", "phpstan/phpstan": "^2.0.0", - "phpunit/phpunit": "^10.5.45 || ^11.0.0 || ^12.0.0 || ^13.0.0" + "phpunit/phpunit": "^11.5.0 || ^12.0.0 || ^13.0.0" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ef3d67a..eca8d3f 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,7 @@ diff --git a/src/Decimal.php b/src/Decimal.php index 8f005ad..f92677b 100644 --- a/src/Decimal.php +++ b/src/Decimal.php @@ -74,6 +74,7 @@ public function __construct(object|string|float|int $value, ?int $scale = null, $this->setValue($value, $scale); $this->setScale($scale, $strict); + $this->normalizeZero(); } /** @@ -105,11 +106,16 @@ protected function parseValue(object|string|float|int $value): string throw new InvalidArgumentException('Invalid non numeric value'); } - if (!is_string($value)) { - $value = (string)$value; + if (is_float($value)) { + if (!is_finite($value)) { + throw new InvalidArgumentException('Invalid non finite value'); + } + + // PHP renders large and small floats as `1.0E+25`, that zero is not a significant digit. + return str_replace('.0E', 'E', (string)$value); } - return $value; + return (string)$value; } /** @@ -120,22 +126,13 @@ protected function parseValue(object|string|float|int $value): string protected function normalizeValue(string $value): string { $value = trim($value); - /** @var string $value */ - $value = preg_replace( - [ - '/^^(-?)(\.)(.*)$/', // omitted leading zero - '/^0+(.)(\..*)?$/', // multiple leading zeros - '/^(\+(.*)|(-)(0))$/', // leading positive sign, tolerate minus zero too - ], - [ - '${1}0.${3}', - '${1}${2}', - '${4}${2}', - ], - $value, - ); + $sign = str_starts_with($value, '-') ? '-' : ''; + $value = ltrim(ltrim($value, '+-'), '0'); + if ($value === '' || !ctype_digit($value[0])) { + $value = '0' . $value; + } - return $value; + return $sign . $value; } /** @@ -165,7 +162,7 @@ public static function create(object|string|float|int $value, ?int $scale = null * * This method is equivalent to the `==` operator. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool TRUE if this decimal is considered equal to the given value. * Equal decimal values tie-break on precision. @@ -176,7 +173,7 @@ public function equals(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -186,7 +183,7 @@ public function greaterThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -196,7 +193,7 @@ public function lessThan(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -208,7 +205,7 @@ public function greaterThanOrEquals(self|string|float|int $value): bool /** * @deprecated Use {@link greaterThanOrEquals()} instead. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -218,7 +215,7 @@ public function greatherThanOrEquals(self|string|float|int $value): bool } /** - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return bool */ @@ -235,7 +232,7 @@ public function lessThanOrEquals(self|string|float|int $value): bool * - `0` if the instance is equal to $value, or * - `1` if the instance is greater than $value. * - * @param static|string|float|int $value + * @param self|string|float|int $value * * @return int */ @@ -250,7 +247,7 @@ public function compareTo(self|string|float|int $value): int /** * Add $value to this Decimal and return the sum as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -284,7 +281,7 @@ protected function resultScale(self $a, self $b, ?int $scale = null): int * Subtract $value from this Decimal and return the difference as a new * Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -392,12 +389,12 @@ public function isPositive(): bool /** * Multiply this Decimal by $value and return the product as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static */ - public function multiply(self|string|int|float $value, ?int $scale = null): static + public function multiply(self|string|float|int $value, ?int $scale = null): static { $decimal = static::create($value); if ($scale === null) { @@ -410,14 +407,14 @@ public function multiply(self|string|int|float $value, ?int $scale = null): stat /** * Divide this Decimal by $value and return the quotient as a new Decimal. * - * @param static|string|float|int $value + * @param self|string|float|int $value * @param int $scale Decimal places in the result * * @throws \DivisionByZeroError if $value is zero. * * @return static */ - public function divide(self|string|int|float $value, int $scale): static + public function divide(self|string|float|int $value, int $scale): static { $decimal = static::create($value); if ($decimal->isZero()) { @@ -430,7 +427,7 @@ public function divide(self|string|int|float $value, int $scale): static /** * This method is equivalent to the ** operator. * - * @param static|string|int $exponent + * @param self|string|int $exponent * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -441,7 +438,7 @@ public function pow(self|string|int $exponent, ?int $scale = null): static $scale = $this->scale(); } - return new static(bcpow((string)$this, (string)$exponent, $scale)); + return new static(bcpow((string)$this, (string)static::create($exponent), $scale)); } /** @@ -463,7 +460,7 @@ public function sqrt(?int $scale = null): static /** * This method is equivalent to the % operator. * - * @param static|string|int $value + * @param self|string|int $value * @param int|null $scale Decimal places in the result. Omit to enable auto-detection. * * @return static @@ -474,57 +471,48 @@ public function mod(self|string|int $value, ?int $scale = null): static $scale = $this->scale(); } - return new static(bcmod((string)$this, (string)$value, $scale)); + return new static(bcmod((string)$this, (string)static::create($value), $scale)); } /** * @param int $scale Decimal places in the result (only used with mode "half up") - * @param int $roundMode + * @param int $roundMode ROUND_HALF_UP, ROUND_CEIL or ROUND_FLOOR. Anything else rounds half up. + * + * @throws \InvalidArgumentException * * @return static */ public function round(int $scale = 0, int $roundMode = self::ROUND_HALF_UP): static { - $exponent = $scale + 1; + if ($scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } - switch ($roundMode) { - case static::ROUND_FLOOR: - $stringValue = (string)$this; + // Floor and ceil always round to a whole number in 1.x, the scale only labels the result. + $directed = in_array($roundMode, [static::ROUND_FLOOR, static::ROUND_CEIL], true); + $places = $directed ? 0 : $scale; - // If already an integer, return as is - if (!str_contains($stringValue, '.')) { - return new static($stringValue, $scale); - } + $value = (string)$this; + $truncated = bcadd($value, '0', $places); + if (bccomp($value, $truncated, $this->scale()) === 0) { + return new static($truncated, $scale); + } - // For positive values, truncate the decimal part (round down) - if (!$this->isNegative()) { - return new static(bcsub($stringValue, bcmod($stringValue, '1'), 0), $scale); - } + switch ($roundMode) { + case static::ROUND_FLOOR: + $result = $this->isNegative() ? bcsub($truncated, '1', 0) : $truncated; - // For negative values, round down away from zero - return new static(bcsub($stringValue, '1', 0), $scale); + break; case static::ROUND_CEIL: - $stringValue = (string)$this; - - // If already an integer, return as is - if (!str_contains($stringValue, '.')) { - return new static($stringValue, $scale); - } + $result = $this->isNegative() ? $truncated : bcadd($truncated, '1', 0); - // If negative, truncate (remove decimals without adding 1) - if ($this->isNegative()) { - return new static(bcsub($stringValue, '0', 0), $scale); - } - - // Otherwise, round up for positive numbers - return new static(bcadd($stringValue, '1', 0), $scale); - case static::ROUND_HALF_UP: + break; default: - $e = bcpow('10', (string)$exponent); - $v = bcdiv(bcadd(bcmul((string)$this, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); + $e = bcpow('10', (string)($scale + 1)); + $result = bcdiv(bcadd(bcmul($value, $e, 0), $this->isNegative() ? '-5' : '5'), $e, $scale); } - return new static($v, $scale); + return new static($result, $scale); } /** @@ -610,7 +598,9 @@ public function toInt(): int */ public function isBigInteger(): bool { - return bccomp($this->integralPart, (string)PHP_INT_MAX) === 1 || bccomp($this->integralPart, (string)PHP_INT_MIN) === -1; + $value = ($this->negative ? '-' : '') . $this->integralPart; + + return bccomp($value, (string)PHP_INT_MAX) === 1 || bccomp($value, (string)PHP_INT_MIN) === -1; } /** @@ -618,8 +608,7 @@ public function isBigInteger(): bool */ public function isBigDecimal(): bool { - return $this->isBigInteger() || - bccomp($this->fractionalPart, (string)PHP_INT_MAX) === 1 || bccomp($this->fractionalPart, (string)PHP_INT_MIN) === -1; + return $this->isBigInteger() || bccomp($this->fractionalPart ?: '0', (string)PHP_INT_MAX) === 1; } /** @@ -635,38 +624,29 @@ public function isBigDecimal(): bool */ public function toScientific(): string { - if ($this->integralPart) { - $exponent = 0; - $integralPart = $this->integralPart; - while ($integralPart >= 10) { - $integralPart /= 10; - $exponent++; - } + if ($this->isZero()) { + return $this->toString() . static::EXP_MARK . '0'; + } - $value = (string)$integralPart; - if (!str_contains($value, '.')) { - $value .= '.'; + if ($this->integralPart !== '0') { + $exponent = strlen($this->integralPart) - 1; + $digits = $this->integralPart . $this->fractionalPart; + if ($this->fractionalPart === '') { + $digits = rtrim($digits, '0'); } - $value .= $this->fractionalPart; } else { - $exponent = -1; - // 00002 - // 20000 - $fractionalPart = $this->fractionalPart; - while (str_starts_with($fractionalPart, '0')) { - $fractionalPart = substr($fractionalPart, 1); - $exponent--; - } - - $pos = abs($exponent) - 1; - $value = substr($this->fractionalPart, $pos, 1) . '.' . substr($this->fractionalPart, $pos + 1); + $digits = ltrim($this->fractionalPart, '0'); + $exponent = strlen($digits) - strlen($this->fractionalPart) - 1; } + // The radix mark stays even without decimals (`5.e0`) in 1.x. + $value = $digits[0] . static::RADIX_MARK . substr($digits, 1); + if ($this->negative) { $value = '-' . $value; } - return $value . 'e' . $exponent; + return $value . static::EXP_MARK . $exponent; } /** @@ -744,6 +724,7 @@ protected function copy(?string $integerPart = null, ?string $decimalPart = null if ($negative !== null) { $clone->negative = $negative; } + $clone->normalizeZero(); return $clone; } @@ -824,39 +805,33 @@ protected function fromFloat(string $value): void */ protected function fromScientific(string $value, ?int $scale): void { - $pattern = '/^(-?)(\d+(?:' . static::RADIX_MARK . '\d*)?|' . - '[' . static::RADIX_MARK . ']' . '\d+)' . static::EXP_MARK . '(-?\d*)?$/i'; - preg_match($pattern, $value, $matches); - if (!$matches) { + $pattern = '/^(-?)(\d+)(?:' . preg_quote(static::RADIX_MARK, '/') . '(\d*))?' . static::EXP_MARK . '([+-]?\d+)$/i'; + if (preg_match($pattern, $value, $matches) !== 1) { throw new InvalidArgumentException('Invalid scientific value/notation: ' . $value); } $this->negative = $matches[1] === '-'; - /** @var string $value */ - $value = preg_replace('/\b\.0$/', '', $matches[2]); - $exp = (int)$matches[3]; + $integralPart = $matches[2]; + // A mantissa of `1.0` is read as `1` in 1.x. + $fractionalPart = $matches[3] === '0' ? '' : $matches[3]; + $exp = (int)$matches[4]; if ($exp < 0) { - $this->integralPart = '0'; - /** @var string $value */ - $value = preg_replace('/^(\d+)(\.)?(\d+)$/', '${1}${3}', $value, 1); - $this->fractionalPart = str_repeat('0', -$exp - 1) . $value; - - if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale, '0'); - } + $integralPart = str_pad($integralPart, -$exp + 1, '0', STR_PAD_LEFT); + $fractionalPart = substr($integralPart, $exp) . $fractionalPart; + $integralPart = substr($integralPart, 0, $exp); } else { - $this->integralPart = bcmul($matches[2], bcpow('10', (string)$exp)); + $integralPart .= substr(str_pad($fractionalPart, $exp, '0'), 0, $exp); + $fractionalPart = substr($fractionalPart, $exp); + } - $pos = strlen($this->integralPart); - if (str_contains($value, '.')) { - $pos++; - } - $this->fractionalPart = rtrim(substr($value, $pos), '.'); + $this->integralPart = ltrim($integralPart, '0') ?: '0'; + $this->fractionalPart = $fractionalPart; - if ($scale !== null) { - $this->fractionalPart = str_pad($this->fractionalPart, $scale - strlen($this->integralPart), '0'); - } + if ($scale !== null) { + // 1.x pads positive exponents to the scale minus the integral digits. + $length = $exp < 0 ? $scale : $scale - strlen($this->integralPart); + $this->fractionalPart = str_pad($this->fractionalPart, $length, '0'); } } @@ -870,6 +845,11 @@ protected function fromScientific(string $value, ?int $scale): void */ protected function setScale(?int $scale, bool $strict): void { + if ($scale !== null && $scale < 0) { + throw new InvalidArgumentException('Scale must be >= 0.'); + } + + // A scale of 0 is treated like no scale in 1.x and does not cut off decimals. $calculatedScale = strlen($this->fractionalPart); if ($scale && $calculatedScale > $scale) { if ($strict) { @@ -881,4 +861,16 @@ protected function setScale(?int $scale, bool $strict): void $this->scale = $scale ?? $calculatedScale; } + + /** + * Zero has no sign, `-0.00` would otherwise report as negative. + * + * @return void + */ + protected function normalizeZero(): void + { + if ($this->negative && $this->isZero()) { + $this->negative = false; + } + } } diff --git a/tests/DecimalTest.php b/tests/DecimalTest.php index 7ba553f..aa5ad49 100644 --- a/tests/DecimalTest.php +++ b/tests/DecimalTest.php @@ -12,6 +12,7 @@ use DivisionByZeroError; use InvalidArgumentException; use PhpCollective\DecimalObject\Decimal; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Stringable; use TypeError; @@ -24,7 +25,7 @@ class DecimalTest extends TestCase * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] + #[DataProvider('baseProvider')] public function testNewObject(mixed $value, string $expected): void { $decimal = new Decimal($value); @@ -50,7 +51,7 @@ public function testNewObjectScientific(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('baseProvider')] + #[DataProvider('baseProvider')] public function testCreate(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -142,7 +143,7 @@ public function __toString(): string * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('invalidValuesProvider')] + #[DataProvider('invalidValuesProvider')] public function testNewObjectWithInvalidValueThrowsException(mixed $value): void { $this->expectException(InvalidArgumentException::class); @@ -169,7 +170,7 @@ public static function invalidValuesProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('truncateProvider')] + #[DataProvider('truncateProvider')] public function testTruncate(mixed $input, int $scale, string $expected): void { $decimal = Decimal::create($input); @@ -197,7 +198,7 @@ public static function truncateProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('integerProvider')] + #[DataProvider('integerProvider')] public function testIsInteger(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -229,7 +230,7 @@ public static function integerProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('zeroProvider')] + #[DataProvider('zeroProvider')] public function testIsZero(mixed $value, bool $expected): void { $decimal = Decimal::create($value); @@ -260,7 +261,7 @@ public static function zeroProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] + #[DataProvider('compareZeroProvider')] public function testIsPositive(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -273,7 +274,7 @@ public function testIsPositive(mixed $input, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareZeroProvider')] + #[DataProvider('compareZeroProvider')] public function testIsNegative(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -307,7 +308,7 @@ public static function compareZeroProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scaleProvider')] + #[DataProvider('scaleProvider')] public function testScale(mixed $input, int $expected): void { $decimal = Decimal::create($input); @@ -338,7 +339,7 @@ public static function scaleProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('scientificProvider')] + #[DataProvider('scientificProvider')] public function testToScientific(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -411,7 +412,7 @@ public function testToFloat(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('bigFloatDataProvider')] + #[DataProvider('bigFloatDataProvider')] public function testToFloatForBigDecimalThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -450,7 +451,7 @@ public function testToInt(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('bigIntDataProvider')] + #[DataProvider('bigIntDataProvider')] public function testToIntForBigIntThrowsAnException(string $value): void { $decimal = Decimal::create($value); @@ -605,7 +606,7 @@ public function testEquals(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('roundProvider')] + #[DataProvider('roundProvider')] public function testRound(mixed $value, int $scale, string $expected): void { $decimal = Decimal::create($value); @@ -664,7 +665,7 @@ public static function roundProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('floorProvider')] + #[DataProvider('floorProvider')] public function testFloor(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -717,7 +718,7 @@ public static function floorProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('ceilProvider')] + #[DataProvider('ceilProvider')] public function testCeil(mixed $value, string $expected): void { $decimal = Decimal::create($value); @@ -770,7 +771,7 @@ public static function ceilProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testGreaterThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -784,7 +785,7 @@ public function testGreaterThan(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testLessThan(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -798,7 +799,7 @@ public function testLessThan(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testGreaterEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -812,7 +813,7 @@ public function testGreaterEquals(mixed $a, mixed $b, int $expected): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('compareProvider')] + #[DataProvider('compareProvider')] public function testLessEquals(mixed $a, mixed $b, int $expected): void { $decimal = Decimal::create($a); @@ -876,7 +877,7 @@ public function testSubtract(): void * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationProvider')] + #[DataProvider('multiplicationProvider')] public function testMultiply(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -915,7 +916,7 @@ public static function multiplicationProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('multiplicationLegacyProvider')] + #[DataProvider('multiplicationLegacyProvider')] public function testMultiplyLegacy(mixed $a, mixed $b, ?int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -940,7 +941,7 @@ public static function multiplicationLegacyProvider(): array * * @return void */ - #[\PHPUnit\Framework\Attributes\DataProvider('divisionProvider')] + #[DataProvider('divisionProvider')] public function testDivide(mixed $a, mixed $b, int $scale, string $expected): void { $decimal = Decimal::create($a); @@ -1049,4 +1050,226 @@ public function testPrecisionLossFail(): void Decimal::create('0.123', 2, true); } + + /** + * @param mixed $value + * @param string $expected + * + * @return void + */ + #[DataProvider('normalizationProvider')] + public function testNewObjectNormalization(mixed $value, string $expected): void + { + $this->assertSame($expected, (string)new Decimal($value)); + } + + /** + * @return array + */ + public static function normalizationProvider(): array + { + return [ + 'leading zeros' => ['0010', '10'], + 'negative leading zeros' => ['-007', '-7'], + 'positive sign leading zeros' => ['+007', '7'], + 'positive sign omitted zero' => ['+.5', '0.5'], + 'negative zero with decimals' => ['-0.0', '0.0'], + 'negative double zero' => ['-00', '0'], + 'trailing radix' => ['5.', '5'], + 'explicit positive exponent' => ['1e+5', '100000'], + 'uppercase exponent' => ['1E5', '100000'], + 'multi digit mantissa' => ['12.5e-1', '1.25'], + 'integer mantissa' => ['100e-2', '1.00'], + 'integer mantissa trailing zero' => ['120e-1', '12.0'], + 'long mantissa' => ['123.456e-2', '1.23456'], + 'negative mantissa' => ['-1.5e-2', '-0.015'], + 'omitted zero mantissa' => ['.5e1', '5'], + 'small mantissa' => ['0.00012e3', '0.12'], + 'zero exponent' => ['1.5e0', '1.5'], + 'negative zero exponent' => ['-0e5', '0'], + 'zero mantissa' => ['0.0e-3', '0.000'], + 'large float' => [1e25, '10000000000000000000000000'], + 'small float' => [1.5e-7, '0.00000015'], + 'negative zero float' => [-0.0, '0'], + ]; + } + + /** + * @param float $value + * + * @return void + */ + #[DataProvider('nonFiniteProvider')] + public function testNewObjectNonFiniteFloat(float $value): void + { + $this->expectException(InvalidArgumentException::class); + + new Decimal($value); + } + + /** + * @return array + */ + public static function nonFiniteProvider(): array + { + return [ + 'inf' => [INF], + 'negative inf' => [-INF], + 'nan' => [NAN], + ]; + } + + /** + * @return void + */ + public function testNewObjectNegativeScale(): void + { + $this->expectException(InvalidArgumentException::class); + + new Decimal('1.56', -1); + } + + /** + * @return void + */ + public function testNewObjectScientificWithScale(): void + { + $this->assertSame('0.001200', (string)new Decimal('1.2e-3', 6)); + $this->assertSame('0.0012', (string)new Decimal('1.23456e-3', 4)); + } + + /** + * @return void + */ + public function testNoNegativeZero(): void + { + $decimal = new Decimal('-0.0'); + $this->assertFalse($decimal->isNegative()); + $this->assertSame(0, $decimal->sign()); + + $this->assertSame('0', (string)(new Decimal('0'))->negate()); + $this->assertSame('0.00', (string)(new Decimal('0.00'))->negate()); + $this->assertSame('0', (string)(new Decimal('-0.5'))->truncate()); + $this->assertSame('0.0', (string)(new Decimal('-0.05'))->truncate(1)); + $this->assertSame('0.00', (string)new Decimal('-0.001', 2)); + $this->assertFalse((new Decimal('-0.5'))->truncate()->isNegative()); + } + + /** + * @param string $value + * @param int $scale + * @param int $mode + * @param string $expected + * + * @return void + */ + #[DataProvider('roundModeProvider')] + public function testRoundMode(string $value, int $scale, int $mode, string $expected): void + { + $this->assertSame($expected, (string)(new Decimal($value))->round($scale, $mode)); + } + + /** + * @return array + */ + public static function roundModeProvider(): array + { + return [ + 'floor negative whole' => ['-1.0', 0, Decimal::ROUND_FLOOR, '-1'], + 'floor positive whole' => ['1.00', 0, Decimal::ROUND_FLOOR, '1'], + 'floor negative' => ['-1.5', 0, Decimal::ROUND_FLOOR, '-2'], + 'ceil positive whole' => ['1.0', 0, Decimal::ROUND_CEIL, '1'], + 'ceil negative whole' => ['-1.00', 0, Decimal::ROUND_CEIL, '-1'], + 'ceil positive' => ['1.5', 0, Decimal::ROUND_CEIL, '2'], + 'floor small negative' => ['-0.001', 0, Decimal::ROUND_FLOOR, '-1'], + 'floor zero with decimals' => ['0.00', 0, Decimal::ROUND_FLOOR, '0'], + 'ceil small positive' => ['0.001', 0, Decimal::ROUND_CEIL, '1'], + 'ceil zero with decimals' => ['0.00', 0, Decimal::ROUND_CEIL, '0'], + 'half up negative' => ['-1.005', 2, Decimal::ROUND_HALF_UP, '-1.01'], + 'half up to zero' => ['-0.004', 2, Decimal::ROUND_HALF_UP, '0.00'], + ]; + } + + /** + * @return void + */ + public function testRoundNegativeScale(): void + { + $this->expectException(InvalidArgumentException::class); + + (new Decimal('15'))->round(-1); + } + + /** + * @param string $value + * @param string $expected + * + * @return void + */ + #[DataProvider('scientificEdgeProvider')] + public function testToScientificEdgeCases(string $value, string $expected): void + { + $decimal = new Decimal($value); + $this->assertSame($expected, $decimal->toScientific()); + $this->assertSame($value, (string)new Decimal($decimal->toScientific())); + } + + /** + * @return array + */ + public static function scientificEdgeProvider(): array + { + return [ + 'zero' => ['0', '0e0'], + 'zero with scale' => ['0.00', '0.00e0'], + 'single digit' => ['5', '5.e0'], + 'power of ten' => ['10', '1.e1'], + 'large power of ten' => ['100000', '1.e5'], + 'trailing zero integer' => ['120', '1.2e2'], + 'trailing zero integer with fraction' => ['120.50', '1.2050e2'], + 'beyond float precision' => ['1234567890123456789012.5', '1.2345678901234567890125e21'], + 'beyond int precision' => ['12345678901234567', '1.2345678901234567e16'], + 'single fraction digit' => ['0.5', '5.e-1'], + 'small fraction' => ['0.00012', '1.2e-4'], + 'negative with scale' => ['-12.50', '-1.250e1'], + ]; + } + + /** + * @return void + */ + public function testToIntBoundaries(): void + { + $this->assertSame(PHP_INT_MIN, (new Decimal((string)PHP_INT_MIN))->toInt()); + $this->assertSame(PHP_INT_MAX, (new Decimal((string)PHP_INT_MAX))->toInt()); + $this->assertFalse((new Decimal((string)PHP_INT_MIN))->isBigInteger()); + $this->assertTrue((new Decimal('-9223372036854775809'))->isBigInteger()); + $this->assertTrue((new Decimal('9223372036854775808'))->isBigInteger()); + } + + /** + * @return void + */ + public function testModOperands(): void + { + $decimal = new Decimal('10'); + + $this->assertSame('0', (string)$decimal->mod('1e1')); + $this->assertSame('1', (string)$decimal->mod(new Decimal('3'))); + $this->assertSame('0.1', (string)$decimal->mod('0.3', 1)); + $this->assertSame('1.5', (string)(new Decimal('10.5'))->mod('3')); + } + + /** + * @return void + */ + public function testPowOperands(): void + { + $decimal = new Decimal('2'); + + $this->assertSame('100', (string)(new Decimal('10'))->pow('2e0')); + $this->assertSame('4', (string)$decimal->pow(new Decimal('2'))); + $this->assertSame('0.50', (string)$decimal->pow(-1, 2)); + $this->assertSame('2.25', (string)(new Decimal('1.50'))->pow(2)); + } }